@@ -2,14 +2,12 @@ package soy.iko.opencode.ui.server
3import androidx.activity.compose.BackHandler
3import androidx.activity.compose.BackHandler
4import androidx.compose.animation.AnimatedVisibility
4import androidx.compose.animation.AnimatedVisibility
5−import androidx.compose.foundation.clickable
6import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Box
8import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Column
9import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.Row
10import androidx.compose.foundation.layout.fillMaxSize
9import androidx.compose.foundation.layout.fillMaxSize
11import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.fillMaxWidth
12−import androidx.compose.foundation.layout.heightIn
13import androidx.compose.foundation.layout.imePadding
11import androidx.compose.foundation.layout.imePadding
14import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.size
13import androidx.compose.foundation.layout.size
@@ -20,14 +18,12 @@ import androidx.compose.foundation.text.KeyboardActions
20import androidx.compose.foundation.text.KeyboardOptions
18import androidx.compose.foundation.text.KeyboardOptions
21import androidx.compose.foundation.verticalScroll
19import androidx.compose.foundation.verticalScroll
22import androidx.compose.material.icons.Icons
20import androidx.compose.material.icons.Icons
23−import androidx.compose.material.icons.filled.Refresh
24import androidx.compose.material.icons.automirrored.filled.ArrowBack
21import androidx.compose.material.icons.automirrored.filled.ArrowBack
25import androidx.compose.material.icons.filled.Delete
22import androidx.compose.material.icons.filled.Delete
26import androidx.compose.material.icons.filled.ExpandLess
23import androidx.compose.material.icons.filled.ExpandLess
27import androidx.compose.material.icons.filled.ExpandMore
24import androidx.compose.material.icons.filled.ExpandMore
28import androidx.compose.material.icons.filled.Visibility
25import androidx.compose.material.icons.filled.Visibility
29import androidx.compose.material.icons.filled.VisibilityOff
26import androidx.compose.material.icons.filled.VisibilityOff
30−import androidx.compose.material.icons.filled.Wifi
31import androidx.compose.material3.AlertDialog
27import androidx.compose.material3.AlertDialog
32import androidx.compose.material3.Button
28import androidx.compose.material3.Button
33import androidx.compose.material3.CircularProgressIndicator
29import androidx.compose.material3.CircularProgressIndicator
@@ -55,7 +51,6 @@ import androidx.compose.ui.Modifier
55import androidx.compose.ui.focus.FocusRequester
51import androidx.compose.ui.focus.FocusRequester
56import androidx.compose.ui.focus.focusRequester
52import androidx.compose.ui.focus.focusRequester
57import androidx.compose.ui.input.nestedscroll.nestedScroll
53import androidx.compose.ui.input.nestedscroll.nestedScroll
58−import androidx.compose.ui.platform.LocalContext
59import androidx.compose.ui.platform.LocalSoftwareKeyboardController
54import androidx.compose.ui.platform.LocalSoftwareKeyboardController
60import androidx.compose.ui.platform.testTag
55import androidx.compose.ui.platform.testTag
61import androidx.compose.ui.semantics.Role
56import androidx.compose.ui.semantics.Role
@@ -71,8 +66,6 @@ import androidx.compose.ui.res.stringResource
71import androidx.compose.ui.unit.dp
66import androidx.compose.ui.unit.dp
72import androidx.lifecycle.compose.collectAsStateWithLifecycle
67import androidx.lifecycle.compose.collectAsStateWithLifecycle
73import androidx.lifecycle.viewmodel.compose.viewModel
68import androidx.lifecycle.viewmodel.compose.viewModel
74−import soy.iko.opencode.data.network.NsdDiscovery
75−import soy.iko.opencode.data.network.NetworkConfig
76import soy.iko.opencode.di.AppContainer
69import soy.iko.opencode.di.AppContainer
77import soy.iko.opencode.R
70import soy.iko.opencode.R
78import soy.iko.opencode.ui.components.autofillModifier
71import soy.iko.opencode.ui.components.autofillModifier
@@ -113,7 +106,7 @@ fun ServerEditScreen(
115 // Collapsing/lifting top bar so the long form scrolls under it, matching Settings and
108 // Collapsing/lifting top bar so the long form scrolls under it, matching Settings and
116− // Diagnostics (the form can be tall: Base URL + Discovery + Auth + Test + Connect + Save +
109+ // Diagnostics (the form can be tall: Base URL + Auth + Test + Connect + Save +
117 // Label + Security).
110 // Label + Security).
118 val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
111 val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
119 Scaffold(
112 Scaffold(
@@ -291,13 +284,6 @@ private fun ServerEditForm(
291 .focusRequester(baseUrlFocus)
284 .focusRequester(baseUrlFocus)
292 .testTag("server_url"),
285 .testTag("server_url"),
294− // Auto-expand LAN discovery only on a fresh form (no URL yet) so tapping a found
295− // server is the primary path; on edit the populated URL keeps it (and its multicast)
298− initiallyExpanded = state.baseUrl.isBlank(),
299− onPick = { url -> vm.update { it.copy(baseUrl = url) } },
301 // Auth fields appear only once a connect attempt learns the server requires them
287 // Auth fields appear only once a connect attempt learns the server requires them
302 // (or an authed profile is loaded) — the common no-auth server never sees them.
288 // (or an authed profile is loaded) — the common no-auth server never sees them.
303 AnimatedVisibility(visible = state.authFieldsVisible) {
289 AnimatedVisibility(visible = state.authFieldsVisible) {
@@ -590,96 +576,3 @@ private fun SecurityFields(
595− * Optional LAN discovery: browses for opencode servers advertised via mDNS (`--mdns`) and
596− * offers each as a one-tap fill for the base URL. Discovery (and its multicast traffic) only
597− * runs while the section is expanded — the collection stops when the section is collapsed or
598− * the screen leaves composition. [initiallyExpanded] starts it open on a fresh add form so the
599− * discovered list is the primary path; an edit form (populated URL) leaves it collapsed.
602−private fun DiscoverySection(onPick: (String) -> Unit, initiallyExpanded: Boolean = false) {
603− val context = LocalContext.current
604− var expanded by rememberSaveable { mutableStateOf(initiallyExpanded) }
605− val stateLabel = stringResource(if (expanded) R.string.state_expanded else R.string.state_collapsed)
606− Column(modifier = Modifier.fillMaxWidth()) {
608− onClick = { expanded = !expanded },
609− modifier = Modifier.semantics { stateDescription = stateLabel },
610− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
612− Icon(Icons.Filled.Wifi, contentDescription = null, modifier = Modifier.size(18.dp))
613− Text(stringResource(R.string.discover_on_network), modifier = Modifier.padding(start = 8.dp))
616− val discovery = remember { NsdDiscovery(context) }
617− // scanNonce drives a manual re-scan: bumping it recreates the discovery flow
618− // (cancelling the prior collection / Nsd listener) and restarts the no-result
619− // timeout, so a user on a flaky multicast network can retry without collapsing
620− // and re-expanding the section.
621− var scanNonce by remember { mutableStateOf(0) }
622− val servers by remember(discovery, scanNonce) { discovery.discover() }
623− .collectAsStateWithLifecycle(initialValue = emptyList())
624− // mDNS discovery is a continuous flow that never emits a terminal "no results"
625− // state — on a network with no opencode servers the spinner would spin forever.
626− // After the timeout, swap the spinner for a "no servers found" message. Discovery
627− // keeps running, so a late server can still replace the message when it arrives.
628− var searchTimedOut by remember { mutableStateOf(false) }
629− LaunchedEffect(scanNonce) {
630− searchTimedOut = false
631− kotlinx.coroutines.delay(NetworkConfig.nsdDiscoveryNoResultTimeoutMs)
632− searchTimedOut = true
634− if (servers.isNotEmpty()) {
635− servers.forEach { server ->
639− .heightIn(min = 48.dp)
640− .clickable(role = Role.Button) { onPick(server.baseUrl) }
641− .padding(vertical = 8.dp),
642− verticalArrangement = Arrangement.Center,
644− Text(server.name, style = MaterialTheme.typography.bodyMedium)
647− style = MaterialTheme.typography.bodySmall,
648− color = MaterialTheme.colorScheme.onSurfaceVariant,
652− } else if (searchTimedOut) {
654− stringResource(R.string.no_servers_found),
655− style = MaterialTheme.typography.bodySmall,
656− color = MaterialTheme.colorScheme.onSurfaceVariant,
657− modifier = Modifier.padding(vertical = 8.dp),
661− verticalAlignment = Alignment.CenterVertically,
662− modifier = Modifier.padding(vertical = 8.dp),
664− CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
666− stringResource(R.string.searching_network),
667− style = MaterialTheme.typography.bodySmall,
668− color = MaterialTheme.colorScheme.onSurfaceVariant,
669− modifier = Modifier.padding(start = 8.dp),
673− // Manual re-scan affordance: shown after a timeout (or while results are present)
674− // so a user on flaky multicast can retry discovery without leaving the section.
676− onClick = { scanNonce++ },
677− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
678− modifier = Modifier.padding(top = 4.dp),
680− Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
681− Text(stringResource(R.string.scan_again), modifier = Modifier.padding(start = 8.dp))