git.iko.soy/opencode-android
on master help sign in
Simplify server-add UI: fold probe into Connect action, reorder form, collapse security
commit017cb9abc3d046936d9b5d018fa7c28c52eadd14 authoriko <mail@iko.soy> dateFri 3 Jul 2026, 10:23 (3 months ago) parent0a26f8d
5 files changed+195−211
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+94−94 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+37−41 app/src/main/res/values-es/strings.xml+2−6 app/src/main/res/values/strings.xml+2−6 app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.kt+60−64
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+94−94
@@ -19,6 +19,8 @@ import androidx.compose.foundation.text.KeyboardOptions
1919import androidx.compose.foundation.verticalScroll
2020import androidx.compose.material.icons.Icons
2121import androidx.compose.material.icons.automirrored.filled.ArrowBack
22+import androidx.compose.material.icons.filled.ExpandLess
23+import androidx.compose.material.icons.filled.ExpandMore
2224import androidx.compose.material.icons.filled.Visibility
2325import androidx.compose.material.icons.filled.VisibilityOff
2426import androidx.compose.material.icons.filled.Wifi
@@ -151,9 +153,9 @@ private fun ServerEditForm(
151153 onDone: () -> Unit,
152154) {
153155 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
154− // canSave parses the base URL with java.net.URI (which throws — and fills a stack trace —
155− // on the common half-typed URL). Memoize on baseUrl so it runs once per edit instead of
156− // being re-parsed at each of the enabled= call sites on every keystroke's recomposition.
156+ // canSave parses the base URL with OkHttp's parser (which allocates on the common
157+ // half-typed URL). Memoize on baseUrl so it runs once per edit instead of being
158+ // re-parsed at each of the enabled= call sites on every keystroke's recomposition.
157159 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
158160 Column(
159161 modifier = Modifier
@@ -164,17 +166,10 @@ private fun ServerEditForm(
164166 .verticalScroll(rememberScrollState()),
165167 verticalArrangement = Arrangement.spacedBy(12.dp),
166168 ) {
167− OutlinedTextField(
168− value = state.label,
169− onValueChange = { v -> vm.update { it.copy(label = v) } },
170− label = { Text(stringResource(R.string.label_optional)) },
171− singleLine = true,
172− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
173− modifier = Modifier.fillMaxWidth().testTag("server_label"),
174− )
169+ // Base URL is the only required field, so it leads the form and takes focus.
175170 OutlinedTextField(
176171 value = state.baseUrl,
177− onValueChange = { v -> vm.update { it.copy(baseUrl = v, error = null, probeReachable = null) } },
172+ onValueChange = { v -> vm.update { it.copy(baseUrl = v) } },
178173 label = { Text(stringResource(R.string.base_url)) },
179174 placeholder = { Text(stringResource(R.string.base_url_hint)) },
180175 singleLine = true,
@@ -186,7 +181,7 @@ private fun ServerEditForm(
186181 val suggestion = suggestUrlScheme(state.baseUrl)
187182 if (suggestion != null) {
188183 TextButton(
189− onClick = { vm.update { it.copy(baseUrl = suggestion, probeReachable = null) } },
184+ onClick = { vm.update { it.copy(baseUrl = suggestion) } },
190185 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
191186 ) {
192187 Text(stringResource(R.string.suggest_scheme))
@@ -199,43 +194,15 @@ private fun ServerEditForm(
199194 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
200195 modifier = Modifier.fillMaxWidth().testTag("server_url"),
201196 )
202− OutlinedButton(
203− onClick = { vm.probe() },
204− enabled = canSave && !state.probing && !state.saving,
205− modifier = Modifier.fillMaxWidth().testTag("server_probe"),
206− ) {
207− if (state.probing) {
208− CircularProgressIndicator(
209− modifier = Modifier.size(20.dp),
210− strokeWidth = 2.dp,
211− )
212− Text(stringResource(R.string.checking), modifier = Modifier.padding(start = 8.dp))
213− } else {
214− Text(stringResource(R.string.check_connectivity))
215− }
216− }
217− // Positive feedback when the probe found the server reachable without auth, so
218− // the user knows the connection works before saving (closes the loop that the
219− // prior "silently does nothing" Reachable path left open).
220− if (state.probeReachable == true && !state.authFieldsVisible) {
221− Text(
222− stringResource(R.string.server_reachable),
223− style = MaterialTheme.typography.bodySmall,
224− color = MaterialTheme.colorScheme.primary,
225− modifier = Modifier.fillMaxWidth(),
226− )
227− }
197+ // Auto-expand LAN discovery only on a fresh form (no URL yet) so tapping a found
198+ // server is the primary path; on edit the populated URL keeps it (and its multicast)
199+ // collapsed.
228200 DiscoverySection(
229− onPick = { url -> vm.update { it.copy(baseUrl = url, error = null, probeReachable = null) } },
201+ initiallyExpanded = state.baseUrl.isBlank(),
202+ onPick = { url -> vm.update { it.copy(baseUrl = url) } },
230203 )
231− if (!state.authFieldsVisible) {
232− Text(
233− stringResource(R.string.auth_help),
234− style = MaterialTheme.typography.bodySmall,
235− color = MaterialTheme.colorScheme.onSurfaceVariant,
236− modifier = Modifier.fillMaxWidth(),
237− )
238− }
204+ // Auth fields appear only once a connect attempt learns the server requires them
205+ // (or an authed profile is loaded) — the common no-auth server never sees them.
239206 AnimatedVisibility(visible = state.authFieldsVisible) {
240207 AuthFields(
241208 state = state,
@@ -243,44 +210,41 @@ private fun ServerEditForm(
243210 onTogglePassword = onTogglePassword,
244211 onUpdate = vm::update,
245212 onTestCredentials = vm::testCredentials,
246− onImeDone = { if (canSave && !state.saving) vm.saveAndConnect(onDone) },
213+ onImeDone = { if (canSave && !state.saving) vm.connect(onDone) },
247214 )
248215 }
249− SecurityFields(state = state, onUpdate = vm::update)
250− Row(
251− modifier = Modifier.fillMaxWidth(),
252− horizontalArrangement = Arrangement.spacedBy(12.dp),
216+ // Single primary action: probe-then-save-and-connect. Replaces the former
217+ // Check-connectivity / Save / Save-&-connect trio.
218+ Button(
219+ onClick = {
220+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
221+ vm.connect(onDone)
222+ },
223+ enabled = canSave && !state.saving,
224+ modifier = Modifier.fillMaxWidth().testTag("server_connect"),
253225 ) {
254− OutlinedButton(
255− onClick = {
256− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
257− vm.save(onDone)
258− },
259− enabled = canSave && !state.saving,
260− modifier = Modifier.weight(1f),
261− ) {
262− if (state.saving) {
263− CircularProgressIndicator(
264− modifier = Modifier.size(20.dp),
265− color = MaterialTheme.colorScheme.primary,
266− strokeWidth = 2.dp,
267− )
268− Text(stringResource(R.string.saving), modifier = Modifier.padding(start = 8.dp))
269− } else {
270− Text(stringResource(R.string.save))
271− }
272− }
273− Button(
274− onClick = {
275− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
276− vm.saveAndConnect(onDone)
277− },
278− enabled = canSave && !state.saving,
279− modifier = Modifier.weight(1f),
280− ) {
281− Text(stringResource(R.string.save_and_connect))
226+ if (state.saving) {
227+ CircularProgressIndicator(
228+ modifier = Modifier.size(20.dp),
229+ color = MaterialTheme.colorScheme.onPrimary,
230+ strokeWidth = 2.dp,
231+ )
232+ Text(stringResource(R.string.connecting), modifier = Modifier.padding(start = 8.dp))
233+ } else {
234+ Text(stringResource(R.string.connect))
282235 }
283236 }
237+ // Secondary, low-emphasis: persist without connecting (offline setup).
238+ TextButton(
239+ onClick = {
240+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
241+ vm.save(onDone)
242+ },
243+ enabled = canSave && !state.saving,
244+ modifier = Modifier.fillMaxWidth().testTag("server_save"),
245+ ) {
246+ Text(stringResource(R.string.save_without_connecting))
247+ }
284248 state.error?.let { message ->
285249 Text(
286250 message,
@@ -289,6 +253,17 @@ private fun ServerEditForm(
289253 modifier = Modifier.fillMaxWidth(),
290254 )
291255 }
256+ // De-emphasized optional label at the bottom: displayLabel already falls back to the
257+ // URL, so most servers don't need one.
258+ OutlinedTextField(
259+ value = state.label,
260+ onValueChange = { v -> vm.update { it.copy(label = v) } },
261+ label = { Text(stringResource(R.string.label_optional)) },
262+ singleLine = true,
263+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
264+ modifier = Modifier.fillMaxWidth().testTag("server_label"),
265+ )
266+ AdvancedSection(state = state, onUpdate = vm::update)
292267 }
293268}
294269
@@ -366,20 +341,44 @@ private fun AuthFields(
366341 }
367342}
368343
369−/** Optional TLS hardening for this server: force HTTPS (upgrade cleartext) and pin the
370− * server certificate. Both are off by default so the common localhost/HTTP setup is
371− * unaffected. */
344+/**
345+ * Optional TLS hardening, tucked behind a collapsed "Security" expander so the common
346+ * localhost/HTTP setup never sees it. Auto-expands when a loaded profile already uses one of
347+ * these settings (so an invalid pin can't silently disable Connect from inside a closed
348+ * section). Contains: force HTTPS (upgrade cleartext) and pin the server certificate — both
349+ * off by default.
350+ */
351+@Composable
352+private fun AdvancedSection(
353+ state: ServerEditState,
354+ onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
355+) {
356+ var expanded by rememberSaveable { mutableStateOf(state.requireHttps || state.certPin.isNotBlank()) }
357+ Column(modifier = Modifier.fillMaxWidth()) {
358+ TextButton(
359+ onClick = { expanded = !expanded },
360+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
361+ ) {
362+ Icon(
363+ if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
364+ contentDescription = null,
365+ modifier = Modifier.size(18.dp),
366+ )
367+ Text(stringResource(R.string.server_security), modifier = Modifier.padding(start = 8.dp))
368+ }
369+ AnimatedVisibility(visible = expanded) {
370+ SecurityFields(state = state, onUpdate = onUpdate)
371+ }
372+ }
373+}
374+
375+/** The actual security controls, rendered inside [AdvancedSection]'s expander. */
372376@Composable
373377private fun SecurityFields(
374378 state: ServerEditState,
375379 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
376380) {
377381 Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
378− Text(
379− stringResource(R.string.server_security),
380− style = MaterialTheme.typography.titleSmall,
381− color = MaterialTheme.colorScheme.primary,
382− )
383382 Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
384383 Column(modifier = Modifier.weight(1f)) {
385384 Text(stringResource(R.string.require_https), style = MaterialTheme.typography.bodyLarge)
@@ -417,14 +416,15 @@ private fun SecurityFields(
417416
418417/**
419418 * Optional LAN discovery: browses for opencode servers advertised via mDNS (`--mdns`) and
420− * offers each as a one-tap fill for the base URL. Collapsed by default so the discovery (and
421− * its multicast traffic) only runs while the section is expanded — the collection stops when
422− * the section is collapsed or the screen leaves composition.
419+ * offers each as a one-tap fill for the base URL. Discovery (and its multicast traffic) only
420+ * runs while the section is expanded — the collection stops when the section is collapsed or
421+ * the screen leaves composition. [initiallyExpanded] starts it open on a fresh add form so the
422+ * discovered list is the primary path; an edit form (populated URL) leaves it collapsed.
423423 */
424424@Composable
425−private fun DiscoverySection(onPick: (String) -> Unit) {
425+private fun DiscoverySection(onPick: (String) -> Unit, initiallyExpanded: Boolean = false) {
426426 val context = LocalContext.current
427− var expanded by rememberSaveable { mutableStateOf(false) }
427+ var expanded by rememberSaveable { mutableStateOf(initiallyExpanded) }
428428 Column(modifier = Modifier.fillMaxWidth()) {
429429 TextButton(
430430 onClick = { expanded = !expanded },
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+37−41
@@ -35,17 +35,12 @@ data class ServerEditState(
3535 val error: String? = null,
3636 /** Whether the auth (username/password) fields are shown. */
3737 val authFieldsVisible: Boolean = false,
38− /** Whether a connectivity/auth probe is in progress. */
39− val probing: Boolean = false,
4038 /** Snapshot of the profile as loaded (for dirty detection). Null until loaded. */
4139 val initial: InitialProfile? = null,
4240 /** Whether a credential test is in progress. */
4341 val testingCredentials: Boolean = false,
4442 /** Non-null when the last credential test succeeded (true) or failed (false). */
4543 val credentialsResult: Boolean? = null,
46− /** True when the last connectivity probe found the server reachable without auth.
47− * Null until a probe runs, and cleared when the base URL is edited. */
48− val probeReachable: Boolean? = null,
4944) {
5045 val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl) && certPinValid
5146 val isNew: Boolean get() = id == null
@@ -218,48 +213,53 @@ class ServerEditViewModel(
218213 _state.update { transform(it).copy(credentialsResult = null, error = null) }
219214 }
220215
221− fun probe() {
216+ /**
217+ * Primary action: save the profile and connect to it. When the server needs auth and the
218+ * user hasn't supplied credentials yet, a connectivity probe reveals the username/password
219+ * fields instead of connecting with none — folding the former separate "Check connectivity"
220+ * step into this single action so the common case is one tap.
221+ */
222+ fun connect(onDone: () -> Unit) {
222223 val s = _state.value
223− if (!s.canSave || s.probing) return
224− _state.update { it.copy(probing = true, error = null, credentialsResult = null, probeReachable = null) }
225− // FIX 21(b): probe the trimmed URL that save() would actually store.
226− // FIX 11: remember which URL we probed so a stale result is dropped if the
227− // user edited the base URL away while the probe was in flight.
224+ if (!s.canSave || s.saving) return
225+ // Credentials already known (an authed profile is loaded, or the user typed some): skip
226+ // the probe and go straight to save + connect. A wrong password then surfaces as a
227+ // connect error rather than silently re-opening the auth fields.
228+ if (s.authFieldsVisible || s.username.isNotBlank() || s.password.isNotBlank()) {
229+ saveInternal(connectAfter = true, onDone = onDone)
230+ return
231+ }
232+ _state.update { it.copy(saving = true, error = null, credentialsResult = null) }
233+ // FIX 21(b)/FIX 11: probe the trimmed URL save() would store, and remember it so a stale
234+ // result is dropped if the user edited the base URL away while the probe was in flight.
228235 val probedUrl = s.baseUrl.trim()
229236 val probedRequireHttps = s.requireHttps
230237 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
231238 viewModelScope.launch {
232239 val result = runCatchingCancellable { container.probeServer(probedUrl, probedRequireHttps, probedCertPin) }
233240 result.onSuccess { pr ->
234− _state.update {
235− // Stale result (URL edited away mid-probe): drop the reachability verdict but
236− // still clear the spinner, else `probing` latches true and blocks re-probing.
237− if (it.baseUrl.trim() != probedUrl) return@update it.copy(probing = false)
238− when (pr) {
239− is ProbeResult.Reachable -> it.copy(
240− probing = false,
241− authFieldsVisible = false,
242− error = null,
243− probeReachable = true,
244− )
245− is ProbeResult.NeedsAuth -> it.copy(
246− probing = false,
247− authFieldsVisible = true,
248− error = null,
249− probeReachable = false,
250− )
251− is ProbeResult.Unreachable -> it.copy(
252− probing = false,
253− authFieldsVisible = false,
254− error = pr.error,
255− probeReachable = false,
256− )
241+ // Stale result (URL edited away mid-probe): clear the spinner and stop, else
242+ // `saving` latches true and blocks re-connecting.
243+ if (_state.value.baseUrl.trim() != probedUrl) {
244+ _state.update { it.copy(saving = false) }
245+ return@onSuccess
246+ }
247+ when (pr) {
248+ is ProbeResult.NeedsAuth ->
249+ _state.update { it.copy(saving = false, authFieldsVisible = true, error = null) }
250+ is ProbeResult.Unreachable ->
251+ _state.update { it.copy(saving = false, error = pr.error) }
252+ is ProbeResult.Reachable -> {
253+ // Reachable without auth: clear the probe-owned saving flag and hand off
254+ // to the normal save+connect path (saveInternal re-sets saving itself).
255+ _state.update { it.copy(saving = false) }
256+ saveInternal(connectAfter = true, onDone = onDone)
257257 }
258258 }
259259 }.onFailure { e ->
260260 _state.update {
261− if (it.baseUrl.trim() != probedUrl) return@update it.copy(probing = false)
262− it.copy(probing = false, error = container.friendlyError(e), probeReachable = false)
261+ if (it.baseUrl.trim() != probedUrl) it.copy(saving = false)
262+ else it.copy(saving = false, error = container.friendlyError(e))
263263 }
264264 }
265265 }
@@ -311,15 +311,11 @@ class ServerEditViewModel(
311311 }
312312 }
313313
314+ /** Secondary action: persist the profile without connecting (e.g. setting up offline). */
314315 fun save(onDone: () -> Unit) {
315316 saveInternal(connectAfter = false, onDone = onDone)
316317 }
317318
318− /** Save and then connect to the saved profile in one step. */
319− fun saveAndConnect(onDone: () -> Unit) {
320− saveInternal(connectAfter = true, onDone = onDone)
321− }
322−
323319 private fun saveInternal(connectAfter: Boolean, onDone: () -> Unit) {
324320 val s = _state.value
325321 if (!s.canSave || s.saving) return
app/src/main/res/values-es/strings.xml+2−6
@@ -59,13 +59,11 @@
5959 <string name="base_url">URL base</string>
6060 <string name="base_url_hint">http://192.168.1.10:4096</string>
6161 <string name="invalid_url">Introduce una URL http:// o https:// válida.</string>
62− <string name="check_connectivity">Comprobar conectividad</string>
63− <string name="checking">Comprobando…</string>
6462 <string name="auth_required">Este servidor requiere autenticación.</string>
65− <string name="auth_help">Toca "Comprobar conectividad" para probar el servidor. Los campos de autenticación aparecen automáticamente solo si el servidor los requiere.</string>
6663 <string name="username_optional">Usuario (opcional)</string>
6764 <string name="password_optional">Contraseña (opcional)</string>
68− <string name="save_and_connect">Guardar y conectar</string>
65+ <string name="connect">Conectar</string>
66+ <string name="save_without_connecting">Guardar sin conectar</string>
6967 <string name="discard_title">¿Descartar cambios?</string>
7068 <string name="discard_text">Se perderán tus cambios.</string>
7169 <string name="discard">Descartar</string>
@@ -173,7 +171,6 @@
173171 <string name="no_commands">El servidor no informó de ningún comando.</string>
174172 <string name="no_commands_match">Ningún comando coincide con "%1$s".</string>
175173 <string name="load_failed">Error al cargar. Toca reintentar.</string>
176− <string name="saving">Guardando…</string>
177174 <string name="image_failed">Error al cargar la imagen</string>
178175 <string name="search_models">Buscar modelos…</string>
179176 <string name="search_agents">Buscar agentes…</string>
@@ -265,7 +262,6 @@
265262 <string name="unknown_message">Tipo de mensaje desconocido</string>
266263 <string name="unknown_part">Contenido no compatible</string>
267264 <string name="tool_preview">🔧 %1$s</string>
268− <string name="server_reachable">Servidor accesible ✓</string>
269265 <string name="suggest_scheme">Añadir prefijo http://</string>
270266 <string name="sort">Ordenar</string>
271267 <string name="sort_recent">Actividad reciente</string>
app/src/main/res/values/strings.xml+2−6
@@ -59,13 +59,11 @@
5959 <string name="base_url">Base URL</string>
6060 <string name="base_url_hint">http://192.168.1.10:4096</string>
6161 <string name="invalid_url">Enter a valid http:// or https:// URL.</string>
62− <string name="check_connectivity">Check connectivity</string>
63− <string name="checking">Checking…</string>
6462 <string name="auth_required">This server requires authentication.</string>
65− <string name="auth_help">Tap "Check connectivity" to test the server. Authentication fields appear automatically only if the server requires them.</string>
6663 <string name="username_optional">Username (optional)</string>
6764 <string name="password_optional">Password (optional)</string>
68− <string name="save_and_connect">Save &amp; connect</string>
65+ <string name="connect">Connect</string>
66+ <string name="save_without_connecting">Save without connecting</string>
6967 <string name="discard_title">Discard changes?</string>
7068 <string name="discard_text">Your edits will be lost.</string>
7169 <string name="discard">Discard</string>
@@ -173,7 +171,6 @@
173171 <string name="no_commands">No commands reported by the server.</string>
174172 <string name="no_commands_match">No commands match "%1$s".</string>
175173 <string name="load_failed">Failed to load. Tap retry.</string>
176− <string name="saving">Saving…</string>
177174 <string name="image_failed">Failed to load image</string>
178175 <string name="search_models">Search models…</string>
179176 <string name="search_agents">Search agents…</string>
@@ -265,7 +262,6 @@
265262 <string name="unknown_message">Unknown message type</string>
266263 <string name="unknown_part">Unsupported content</string>
267264 <string name="tool_preview">🔧 %1$s</string>
268− <string name="server_reachable">Server is reachable ✓</string>
269265 <string name="suggest_scheme">Add http:// prefix</string>
270266 <string name="sort">Sort</string>
271267 <string name="sort_recent">Recent activity</string>
app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.kt+60−64
@@ -259,97 +259,127 @@ class ServerEditViewModelTest {
259259 assertEquals("Edited", vm.state.value.label)
260260 }
261261
262− // --- probe() auth detection ---
262+ // --- connect(): probe folded into the primary action ---
263263
264264 @Test
265− fun probe_reachableServer_hidesAuthFields() = testScope.runTest {
265+ fun connect_reachableServer_savesAndConnects() = testScope.runTest {
266266 val container = FakeAppContainer()
267267 container.probeResult = ProbeResult.Reachable
268268 val vm = makeVm(container)
269269 vm.update { it.copy(baseUrl = "http://localhost:3000") }
270− vm.probe()
270+ var doneCalled = false
271+ vm.connect { doneCalled = true }
271272 testScheduler.advanceUntilIdle()
272− assertFalse(vm.state.value.probing)
273273 assertFalse(vm.state.value.authFieldsVisible)
274274 assertNull(vm.state.value.error)
275275 assertEquals(1, container.probeCalls.size)
276+ // Reachable → the profile is saved and a connection is opened.
277+ assertNotNull(container.fakeProfileStore.savedProfile)
278+ assertEquals(1, container.connectCalls.size)
279+ assertTrue(doneCalled)
276280 }
277281
278282 @Test
279− fun probe_needsAuth_showsAuthFields() = testScope.runTest {
283+ fun connect_needsAuth_showsAuthFieldsWithoutSaving() = testScope.runTest {
280284 val container = FakeAppContainer()
281285 container.probeResult = ProbeResult.NeedsAuth
282286 val vm = makeVm(container)
283287 vm.update { it.copy(baseUrl = "http://localhost:3000") }
284− vm.probe()
288+ var doneCalled = false
289+ vm.connect { doneCalled = true }
285290 testScheduler.advanceUntilIdle()
286− assertFalse(vm.state.value.probing)
291+ assertFalse(vm.state.value.saving)
287292 assertTrue(vm.state.value.authFieldsVisible)
288293 assertNull(vm.state.value.error)
294+ // Auth needed: reveal the fields; nothing is saved or connected yet.
295+ assertNull(container.fakeProfileStore.savedProfile)
296+ assertEquals(0, container.connectCalls.size)
297+ assertFalse(doneCalled)
289298 }
290299
291300 @Test
292− fun probe_unreachable_showsErrorAndHidesAuthFields() = testScope.runTest {
301+ fun connect_unreachable_showsErrorAndDoesNotSave() = testScope.runTest {
293302 val container = FakeAppContainer()
294303 container.probeResult = ProbeResult.Unreachable("Could not reach server")
295304 val vm = makeVm(container)
296305 vm.update { it.copy(baseUrl = "http://localhost:3000") }
297− vm.probe()
306+ vm.connect { }
298307 testScheduler.advanceUntilIdle()
299− assertFalse(vm.state.value.probing)
308+ assertFalse(vm.state.value.saving)
300309 assertFalse(vm.state.value.authFieldsVisible)
301310 assertNotNull(vm.state.value.error)
302311 assertTrue(vm.state.value.error!!.contains("Could not reach server"))
312+ assertNull(container.fakeProfileStore.savedProfile)
303313 }
304314
305315 @Test
306− fun probe_invalidUrlDoesNotProbe() = testScope.runTest {
316+ fun connect_withCredentials_skipsProbeAndSaves() = testScope.runTest {
307317 val container = FakeAppContainer()
308− container.probeResult = ProbeResult.Reachable
318+ container.probeResult = ProbeResult.NeedsAuth
309319 val vm = makeVm(container)
310− vm.update { it.copy(baseUrl = "invalid") }
311− vm.probe()
320+ vm.update { it.copy(baseUrl = "http://localhost:3000", username = "admin", password = "secret") }
321+ var doneCalled = false
322+ vm.connect { doneCalled = true }
323+ testScheduler.advanceUntilIdle()
324+ // Credentials present: no probe, straight to save + connect.
325+ assertEquals(0, container.probeCalls.size)
326+ assertNotNull(container.fakeProfileStore.savedProfile)
327+ assertEquals(1, container.connectCalls.size)
328+ assertTrue(doneCalled)
329+ }
330+
331+ @Test
332+ fun connect_authFieldsVisible_skipsProbe() = testScope.runTest {
333+ val container = FakeAppContainer()
334+ container.probeResult = ProbeResult.NeedsAuth
335+ val vm = makeVm(container)
336+ // Simulate a first Connect that revealed auth, then a second Connect with creds typed.
337+ vm.update { it.copy(baseUrl = "http://localhost:3000", authFieldsVisible = true) }
338+ vm.connect { }
312339 testScheduler.advanceUntilIdle()
313340 assertEquals(0, container.probeCalls.size)
341+ assertNotNull(container.fakeProfileStore.savedProfile)
314342 }
315343
316344 @Test
317− fun probe_whileProbingDoesNotProbeAgain() = testScope.runTest {
345+ fun connect_invalidUrlDoesNothing() = testScope.runTest {
318346 val container = FakeAppContainer()
319347 container.probeResult = ProbeResult.Reachable
320348 val vm = makeVm(container)
321− vm.update { it.copy(baseUrl = "http://localhost:3000") }
322− vm.probe()
323− vm.probe()
349+ vm.update { it.copy(baseUrl = "invalid") }
350+ var doneCalled = false
351+ vm.connect { doneCalled = true }
324352 testScheduler.advanceUntilIdle()
325− assertEquals(1, container.probeCalls.size)
353+ assertEquals(0, container.probeCalls.size)
354+ assertNull(container.fakeProfileStore.savedProfile)
355+ assertFalse(doneCalled)
326356 }
327357
328358 @Test
329− fun probe_setsProbingFlagDuringCall() = testScope.runTest {
359+ fun connect_whileSavingDoesNotConnectAgain() = testScope.runTest {
330360 val container = FakeAppContainer()
331361 container.probeResult = ProbeResult.Reachable
332362 val vm = makeVm(container)
333363 vm.update { it.copy(baseUrl = "http://localhost:3000") }
334− vm.probe()
335− // Before advancing, probing should be true
336− assertTrue(vm.state.value.probing)
364+ vm.connect { }
365+ vm.connect { }
337366 testScheduler.advanceUntilIdle()
338− assertFalse(vm.state.value.probing)
367+ assertEquals(1, container.probeCalls.size)
339368 }
340369
341370 @Test
342− fun editingBaseUrlAfterProbe_resetsAuthVisibility() = testScope.runTest {
371+ fun connect_setsSavingFlagDuringProbeThenClearsOnAuth() = testScope.runTest {
343372 val container = FakeAppContainer()
373+ // NeedsAuth ends the flow (reveals auth) rather than navigating away, so saving
374+ // returns to false and is observable — the reachable path stays saving until onDone.
344375 container.probeResult = ProbeResult.NeedsAuth
345376 val vm = makeVm(container)
346377 vm.update { it.copy(baseUrl = "http://localhost:3000") }
347− vm.probe()
378+ vm.connect { }
379+ // Before advancing, the probe is in flight → saving is true.
380+ assertTrue(vm.state.value.saving)
348381 testScheduler.advanceUntilIdle()
349− assertTrue(vm.state.value.authFieldsVisible)
350− // User edits the URL — auth fields should hide so a re-probe is required
351− vm.update { it.copy(baseUrl = "http://localhost:4000", authFieldsVisible = false, error = null) }
352− assertFalse(vm.state.value.authFieldsVisible)
382+ assertFalse(vm.state.value.saving)
353383 }
354384
355385 @Test
@@ -387,40 +417,6 @@ class ServerEditViewModelTest {
387417 assertFalse(vm.state.value.authFieldsVisible)
388418 }
389419
390− @Test
391− fun probe_failure_setsError() = testScope.runTest {
392− val container = FakeAppContainer()
393− container.probeResult = null // probeServer defaults to Reachable, so simulate exception
394− val vm = makeVm(container)
395− vm.update { it.copy(baseUrl = "http://localhost:3000") }
396− vm.probe()
397− testScheduler.advanceUntilIdle()
398− // With default fake, probe succeeds → no error
399− assertNull(vm.state.value.error)
400− }
401−
402− @Test
403− fun probe_reachable_setsProbeReachable() = testScope.runTest {
404− val container = FakeAppContainer()
405− container.probeResult = ProbeResult.Reachable
406− val vm = makeVm(container)
407− vm.update { it.copy(baseUrl = "http://localhost:3000") }
408− vm.probe()
409− testScheduler.advanceUntilIdle()
410− assertEquals(true, vm.state.value.probeReachable)
411− }
412−
413− @Test
414− fun probe_needsAuth_setsProbeReachableFalse() = testScope.runTest {
415− val container = FakeAppContainer()
416− container.probeResult = ProbeResult.NeedsAuth
417− val vm = makeVm(container)
418− vm.update { it.copy(baseUrl = "http://localhost:3000") }
419− vm.probe()
420− testScheduler.advanceUntilIdle()
421− assertEquals(false, vm.state.value.probeReachable)
422− }
423−
424420 @Test
425421 fun suggestUrlScheme_addsHttpForBareHost() {
426422 assertEquals("http://192.168.1.10:4096", suggestUrlScheme("192.168.1.10:4096"))