git.iko.soy/opencode-android
on master help sign in
Split add-server into a dedicated add-only flow and move label + advanced security to a new per-server Server Settings screen, auto-deriving the default label from the hostname
commit3750b1b9bcfe17d7864d3e3aaebac9da0d9c37ce authoriko <mail@iko.soy> dateSun 5 Jul 2026, 18:58 (3 months ago) parentc4151ca
12 files changed+1029−409
app/detekt-baseline.xml+1−1 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+20−17 app/src/main/java/soy/iko/opencode/ui/Routes.kt+2−5 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+5−143 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+23−110 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+7−16 app/src/main/java/soy/iko/opencode/ui/server/ServerSettingsScreen.kt+415 app/src/main/java/soy/iko/opencode/ui/server/ServerSettingsViewModel.kt+221 app/src/main/res/values-es/strings.xml+2−2 app/src/main/res/values/strings.xml+2−2 app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.kt+44−113 app/src/test/java/soy/iko/opencode/ui/server/ServerSettingsViewModelTest.kt+287
app/detekt-baseline.xml+1−1
@@ -16,7 +16,7 @@
1616 <ID>CyclomaticComplexMethod:GlobalSearchScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun GlobalSearchScreen( container: AppContainer, onOpenSession: (String, String?) -&gt; Unit, onBack: () -&gt; Unit, )</ID>
1717 <ID>CyclomaticComplexMethod:MainActivity.kt$MainActivity$private fun handleIntent(intent: Intent?)</ID>
1818 <ID>CyclomaticComplexMethod:OpencodeApp.kt$@Composable fun OpencodeApp(container: AppContainer)</ID>
19− <ID>CyclomaticComplexMethod:ServerListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ServerListScreen( container: AppContainer, onConnected: () -&gt; Unit, onAddProfile: () -&gt; Unit, onEditProfile: (String) -&gt; Unit, onDuplicateProfile: (String) -&gt; Unit, )</ID>
19+ <ID>CyclomaticComplexMethod:ServerListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ServerListScreen( container: AppContainer, onConnected: () -&gt; Unit, onAddProfile: () -&gt; Unit, onServerSettings: (String) -&gt; Unit, )</ID>
2020 <ID>CyclomaticComplexMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable fun SessionListScreen( container: AppContainer, onOpenSession: (String) -&gt; Unit, onDisconnect: () -&gt; Unit, onOpenFiles: () -&gt; Unit, onOpenSettings: () -&gt; Unit, onAddServer: () -&gt; Unit, onOpenSearch: () -&gt; Unit = {}, onEditProfile: ((String) -&gt; Unit)? = null, // Incremented by a host (the two-pane empty-detail pane) to open the new-session // directory picker, so that pane shares this screen's dialog instead of bypassing it. externalNewSessionTrigger: Int = 0, selectedSessionId: String? = null, )</ID>
2121 <ID>CyclomaticComplexMethod:SettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class) @Composable fun SettingsScreen( container: AppContainer, onBack: () -&gt; Unit, onManageServers: () -&gt; Unit, onOpenDiagnostics: () -&gt; Unit, onOpenUsage: () -&gt; Unit = {}, onOpenMcp: () -&gt; Unit = {}, onEditProfile: ((String) -&gt; Unit)? = null, )</ID>
2222 <ID>LargeClass:AppContainer.kt$AppContainer</ID>
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+20−17
@@ -51,6 +51,7 @@ import soy.iko.opencode.ui.file.FileViewScreen
5151import soy.iko.opencode.ui.file.TwoPaneFileBrowser
5252import soy.iko.opencode.ui.server.ServerEditScreen
5353import soy.iko.opencode.ui.server.ServerListScreen
54+import soy.iko.opencode.ui.server.ServerSettingsScreen
5455import soy.iko.opencode.ui.search.GlobalSearchScreen
5556import androidx.compose.ui.platform.LocalHapticFeedback
5657import soy.iko.opencode.ui.components.LargeScreenNavRail
@@ -390,23 +391,25 @@ fun OpencodeApp(container: AppContainer) {
390391 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
391392 }
392393 },
393− onAddProfile = { navController.navigate(Routes.serverEdit()) },
394− onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
395− onDuplicateProfile = { id -> navController.navigate(Routes.serverEditDuplicate(id)) },
394+ onAddProfile = { navController.navigate(Routes.SERVER_EDIT) },
395+ onServerSettings = { id -> navController.navigate(Routes.serverSettings(id)) },
396+ )
397+ }
398+
399+ composable(Routes.SERVER_EDIT) {
400+ ServerEditScreen(
401+ container = container,
402+ onDone = { navController.popBackStack() },
396403 )
397404 }
398405
399406 composable(
400− route = "${Routes.SERVER_EDIT}?id={id}&dup={dup}",
401− arguments = listOf(
402− navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null },
403− navArgument("dup") { type = NavType.StringType; nullable = true; defaultValue = null },
404− ),
407+ route = "${Routes.SERVER_SETTINGS}/{id}",
408+ arguments = listOf(navArgument("id") { type = NavType.StringType }),
405409 ) { entry ->
406− ServerEditScreen(
410+ ServerSettingsScreen(
407411 container = container,
408− profileId = entry.arguments?.getString("id"),
409− sourceId = entry.arguments?.getString("dup"),
412+ profileId = entry.arguments?.getString("id").orEmpty(),
410413 onDone = { navController.popBackStack() },
411414 )
412415 }
@@ -423,8 +426,8 @@ fun OpencodeApp(container: AppContainer) {
423426 scope.launch { runCatchingCancellable { container.disconnect() } }
424427 navController.popBackStack(Routes.SERVERS, inclusive = false)
425428 },
426− onAddServer = { navController.navigate(Routes.serverEdit()) },
427− onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
429+ onAddServer = { navController.navigate(Routes.SERVER_EDIT) },
430+ onEditProfile = { id -> navController.navigate(Routes.serverSettings(id)) },
428431 )
429432 } else {
430433 SessionListScreen(
@@ -446,8 +449,8 @@ fun OpencodeApp(container: AppContainer) {
446449 onOpenFiles = { navController.navigate(Routes.FILES) },
447450 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
448451 onOpenSearch = { navController.navigate(Routes.SEARCH) },
449− onAddServer = { navController.navigate(Routes.serverEdit()) },
450− onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
452+ onAddServer = { navController.navigate(Routes.SERVER_EDIT) },
453+ onEditProfile = { id -> navController.navigate(Routes.serverSettings(id)) },
451454 )
452455 }
453456 }
@@ -473,7 +476,7 @@ fun OpencodeApp(container: AppContainer) {
473476 launchSingleTop = true
474477 }
475478 },
476− onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
479+ onEditProfile = { id -> navController.navigate(Routes.serverSettings(id)) },
477480 )
478481 }
479482
@@ -528,7 +531,7 @@ fun OpencodeApp(container: AppContainer) {
528531 onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
529532 onOpenUsage = { navController.navigate(Routes.USAGE) },
530533 onOpenMcp = { navController.navigate(Routes.MCP) },
531− onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
534+ onEditProfile = { id -> navController.navigate(Routes.serverSettings(id)) },
532535 )
533536 }
534537
app/src/main/java/soy/iko/opencode/ui/Routes.kt+2−5
@@ -12,11 +12,8 @@ object Routes {
1212 const val SEARCH = "search"
1313
1414 const val SERVER_EDIT = "server_edit"
15− fun serverEdit(id: String? = null) =
16− if (id.isNullOrEmpty()) SERVER_EDIT else "$SERVER_EDIT?id=${android.net.Uri.encode(id)}"
17−
18− /** Open the editor seeded with [sourceId]'s fields but creating a new profile. */
19− fun serverEditDuplicate(sourceId: String) = "$SERVER_EDIT?dup=${android.net.Uri.encode(sourceId)}"
15+ const val SERVER_SETTINGS = "server_settings"
16+ fun serverSettings(id: String) = "$SERVER_SETTINGS/${android.net.Uri.encode(id)}"
2017
2118 const val CHAT = "chat"
2219 fun chat(sessionId: String, focusMessageId: String? = null): String {
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+5−143
@@ -5,7 +5,6 @@ import androidx.compose.animation.AnimatedVisibility
55import androidx.compose.foundation.layout.Arrangement
66import androidx.compose.foundation.layout.Box
77import androidx.compose.foundation.layout.Column
8−import androidx.compose.foundation.layout.Row
98import androidx.compose.foundation.layout.fillMaxSize
109import androidx.compose.foundation.layout.fillMaxWidth
1110import androidx.compose.foundation.layout.imePadding
@@ -19,9 +18,6 @@ import androidx.compose.foundation.text.KeyboardOptions
1918import androidx.compose.foundation.verticalScroll
2019import androidx.compose.material.icons.Icons
2120import androidx.compose.material.icons.automirrored.filled.ArrowBack
22−import androidx.compose.material.icons.filled.Delete
23−import androidx.compose.material.icons.filled.ExpandLess
24−import androidx.compose.material.icons.filled.ExpandMore
2521import androidx.compose.material.icons.filled.Visibility
2622import androidx.compose.material.icons.filled.VisibilityOff
2723import androidx.compose.material3.AlertDialog
@@ -34,7 +30,6 @@ import androidx.compose.material3.MaterialTheme
3430import androidx.compose.material3.OutlinedButton
3531import androidx.compose.material3.OutlinedTextField
3632import androidx.compose.material3.Scaffold
37−import androidx.compose.material3.Switch
3833import androidx.compose.material3.Text
3934import androidx.compose.material3.TextButton
4035import androidx.compose.material3.TopAppBar
@@ -55,7 +50,6 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
5550import androidx.compose.ui.platform.testTag
5651import androidx.compose.ui.semantics.contentDescription
5752import androidx.compose.ui.semantics.semantics
58−import androidx.compose.ui.semantics.stateDescription
5953import androidx.compose.ui.text.input.ImeAction
6054import androidx.compose.ui.text.input.KeyboardType
6155import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -74,12 +68,10 @@ import soy.iko.opencode.ui.vmFactory
7468@Composable
7569fun ServerEditScreen(
7670 container: AppContainer,
77− profileId: String?,
7871 onDone: () -> Unit,
79− sourceId: String? = null,
8072) {
8173 val vm: ServerEditViewModel =
82− viewModel(factory = vmFactory { ServerEditViewModel(container, profileId, sourceId) })
74+ viewModel(factory = vmFactory { ServerEditViewModel(container) })
8375 val state by vm.state.collectAsStateWithLifecycle()
8476 var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
8577 var passwordVisible by rememberSaveable { mutableStateOf(false) }
@@ -95,59 +87,20 @@ fun ServerEditScreen(
9587 }
9688 }
9789
98− // Intercept the system back gesture when the form has unsaved changes so the user
99− // gets a chance to keep editing instead of losing their work silently. Stays enabled
100− // (as a no-op) while a save is in flight so the gesture can't propagate to the NavHost
101− // and cancel the save's viewModelScope coroutine mid-write — matching safeExit()'s guard.
10290 BackHandler(enabled = (state.isDirty || state.saving) && !showDiscardConfirm) {
10391 if (!state.saving) showDiscardConfirm = true
10492 }
10593
106− // Collapsing/lifting top bar so the long form scrolls under it, matching Settings and
107− // Diagnostics (the form can be tall: Base URL + Auth + Test + Connect + Save +
108− // Label + Security).
10994 val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
11095 Scaffold(
11196 topBar = {
11297 TopAppBar(
113− title = { Text(if (state.isNew) stringResource(R.string.add_server) else stringResource(R.string.edit_server)) },
98+ title = { Text(stringResource(R.string.add_server)) },
11499 navigationIcon = {
115100 IconButton(onClick = ::safeExit) {
116101 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
117102 }
118103 },
119− actions = {
120− // Delete action in the overflow, only for an existing (saved) profile.
121− // The active profile is guarded in the ViewModel (delete disconnects it
122− // if it's the active one). A confirmation dialog gates the destructive action.
123− if (!state.isNew) {
124− var showDeleteConfirm by remember { mutableStateOf(false) }
125− IconButton(onClick = { showDeleteConfirm = true }) {
126− Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete_server))
127− }
128− if (showDeleteConfirm) {
129− // Name the server (matching the server list's delete dialog, which
130− // includes displayLabel) so the user can verify which profile they're
131− // deleting before confirming. displayLabel falls back to baseUrl when
132− // no label is set, so there's always an identifiable name.
133− val displayName = state.label.takeIf { it.isNotBlank() } ?: state.baseUrl
134− AlertDialog(
135− onDismissRequest = { showDeleteConfirm = false },
136− title = { Text(stringResource(R.string.delete_server)) },
137− text = { Text(stringResource(R.string.remove_server_text, displayName)) },
138− confirmButton = {
139− TextButton(onClick = {
140− showDeleteConfirm = false
141− vm.delete(onDone)
142− }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
143− },
144− dismissButton = {
145− TextButton(onClick = { showDeleteConfirm = false }) { Text(stringResource(R.string.cancel)) }
146− },
147− )
148− }
149− }
150− },
151104 scrollBehavior = scrollBehavior,
152105 )
153106 },
@@ -200,19 +153,14 @@ private fun ServerEditForm(
200153 onDone: () -> Unit,
201154) {
202155 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
203− // canSave parses the base URL with OkHttp's parser (which allocates on the common
204− // half-typed URL). Memoize on baseUrl so it runs once per edit instead of being
205− // re-parsed at each of the enabled= call sites on every keystroke's recomposition.
206156 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
207− // isValidUrl parses with OkHttp too; memoize on baseUrl so it runs once per edit instead
208− // of re-parsing at each isError/supportingText call site on every keystroke's recomposition.
209157 val urlValid = remember(state.baseUrl) { isValidUrl(state.baseUrl) }
210− // Focus the Base URL field (and raise the keyboard) on a fresh Add-server form so the
211− // user can start typing the required field immediately.
212158 val baseUrlFocus = remember { FocusRequester() }
213159 val keyboard = LocalSoftwareKeyboardController.current
160+ // Focus the Base URL field (and raise the keyboard) on the fresh Add-server form so the
161+ // user can start typing the required field immediately.
214162 LaunchedEffect(Unit) {
215− if (state.isNew && state.baseUrl.isBlank()) {
163+ if (state.baseUrl.isBlank()) {
216164 baseUrlFocus.requestFocus()
217165 keyboard?.show()
218166 }
@@ -293,17 +241,6 @@ private fun ServerEditForm(
293241 modifier = Modifier.fillMaxWidth(),
294242 )
295243 }
296− // De-emphasized optional label at the bottom: displayLabel already falls back to the
297− // URL, so most servers don't need one.
298− OutlinedTextField(
299− value = state.label,
300− onValueChange = { v -> vm.update { it.copy(label = v) } },
301− label = { Text(stringResource(R.string.label_optional)) },
302− singleLine = true,
303− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
304− modifier = Modifier.fillMaxWidth().testTag("server_label"),
305− )
306− AdvancedSection(state = state, onUpdate = vm::update)
307244 }
308245}
309246
@@ -398,78 +335,3 @@ private fun AuthFields(
398335 }
399336 }
400337}
401−
402−/**
403− * Optional TLS hardening, tucked behind a collapsed "Security" expander so the common
404− * localhost/HTTP setup never sees it. Auto-expands when a loaded profile already uses one of
405− * these settings (so an invalid pin can't silently disable Connect from inside a closed
406− * section). Contains: force HTTPS (upgrade cleartext) and pin the server certificate — both
407− * off by default.
408− */
409−@Composable
410−private fun AdvancedSection(
411− state: ServerEditState,
412− onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
413−) {
414− var expanded by rememberSaveable { mutableStateOf(state.requireHttps || state.certPin.isNotBlank()) }
415− val stateLabel = stringResource(if (expanded) R.string.state_expanded else R.string.state_collapsed)
416− Column(modifier = Modifier.fillMaxWidth()) {
417− TextButton(
418− onClick = { expanded = !expanded },
419− modifier = Modifier.semantics { stateDescription = stateLabel },
420− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
421− ) {
422− Icon(
423− if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
424− contentDescription = null,
425− modifier = Modifier.size(18.dp),
426− )
427− Text(stringResource(R.string.server_security), modifier = Modifier.padding(start = 8.dp))
428− }
429− AnimatedVisibility(visible = expanded) {
430− SecurityFields(state = state, onUpdate = onUpdate)
431− }
432− }
433−}
434−
435−/** The actual security controls, rendered inside [AdvancedSection]'s expander. */
436−@Composable
437−private fun SecurityFields(
438− state: ServerEditState,
439− onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
440−) {
441− Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
442− Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
443− Column(modifier = Modifier.weight(1f)) {
444− Text(stringResource(R.string.require_https), style = MaterialTheme.typography.bodyLarge)
445− Text(
446− stringResource(R.string.require_https_desc),
447− style = MaterialTheme.typography.bodySmall,
448− color = MaterialTheme.colorScheme.onSurfaceVariant,
449− )
450− }
451− Switch(
452− checked = state.requireHttps,
453− onCheckedChange = { v -> onUpdate { it.copy(requireHttps = v) } },
454− )
455− }
456− OutlinedTextField(
457− value = state.certPin,
458− onValueChange = { v -> onUpdate { it.copy(certPin = v) } },
459− label = { Text(stringResource(R.string.cert_pin)) },
460− placeholder = { Text(stringResource(R.string.cert_pin_hint)) },
461− singleLine = true,
462− isError = !state.certPinValid,
463− supportingText = {
464− Text(
465− stringResource(if (state.certPinValid) R.string.cert_pin_desc else R.string.cert_pin_invalid),
466− style = MaterialTheme.typography.bodySmall,
467− color = if (state.certPinValid) MaterialTheme.colorScheme.onSurfaceVariant
468− else MaterialTheme.colorScheme.error,
469− )
470− },
471− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
472− modifier = Modifier.fillMaxWidth().testTag("server_cert_pin"),
473− )
474− }
475−}
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+23−110
@@ -115,96 +115,30 @@ fun isValidUrl(url: String): Boolean {
115115 return "https://$trimmed".toHttpUrlOrNull() != null
116116}
117117
118+/** Derive a default display label from a base URL: the host plus port when non-default.
119+ * Used by the add-server flow which has no label field — the label is auto-derived from
120+ * the hostname so the server list can show a readable name without extra user input.
121+ * Default ports (80 for http, 443 for https) are omitted; an explicit non-default port
122+ * is included so two servers on the same host but different ports are distinguishable. */
123+fun hostFromBaseUrl(url: String): String {
124+ val normalized = normalizeForSave(url)
125+ val httpUrl = normalized.toHttpUrlOrNull() ?: return url.trim()
126+ val isDefaultPort = httpUrl.port == 80 || httpUrl.port == 443 || httpUrl.port == -1
127+ return if (!isDefaultPort) "${httpUrl.host}:${httpUrl.port}" else httpUrl.host
128+}
129+
118130class ServerEditViewModel(
119131 private val container: AppContainer,
120− private val profileId: String?,
121− private val sourceId: String? = null,
122132) : ViewModel() {
123133
124− private val _state = MutableStateFlow(ServerEditState(id = profileId))
134+ private val _state = MutableStateFlow(ServerEditState())
125135 val state: StateFlow<ServerEditState> = _state.asStateFlow()
126136
127137 init {
138+ // Add-only form: seed an initial snapshot of empty values so isDirty becomes true the
139+ // moment the user types anything. No existing-profile load, no duplicate seeding, no
140+ // delete — editing an existing server lives on the Server Settings screen.
128141 viewModelScope.launch {
129− if (profileId != null) {
130− val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
131− container.profileStore.profiles
132− .first { list -> list.any { p -> p.id == profileId } }
133− .firstOrNull { it.id == profileId }
134− }
135− if (existing != null) {
136− // FIX 21(a): normalize (trim) the dirty-snapshot fields the same way
137− // save() stores them, so a stored value with surrounding whitespace
138− // doesn't make the freshly-opened editor spuriously dirty (isDirty
139− // compares field.trim() against these).
140− val init = InitialProfile(
141− label = existing.label.trim(),
142− baseUrl = existing.baseUrl.trim(),
143− username = existing.username.orEmpty().trim(),
144− password = existing.password.orEmpty().trim(),
145− requireHttps = existing.requireHttps,
146− certPin = existing.certPin.orEmpty().trim(),
147− )
148− _state.value = ServerEditState(
149− id = existing.id,
150− label = existing.label,
151− baseUrl = existing.baseUrl,
152− username = existing.username.orEmpty(),
153− password = existing.password.orEmpty(),
154− requireHttps = existing.requireHttps,
155− certPin = existing.certPin.orEmpty(),
156− loaded = true,
157− authFieldsVisible = existing.hasAuth,
158− initial = init,
159− )
160− return@launch
161− }
162− _state.value = ServerEditState(
163− id = profileId,
164− loaded = true,
165− error = container.string(R.string.error_load_timeout),
166− )
167− return@launch
168− }
169− // Duplicate: seed the form from an existing profile but as a NEW profile
170− // (id stays null so save() generates a fresh one). Appends " (copy)" to the
171− // label so the user can tell the duplicate from the original at a glance.
172− if (sourceId != null) {
173− val source = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
174− container.profileStore.profiles
175− .first { list -> list.any { p -> p.id == sourceId } }
176− .firstOrNull { it.id == sourceId }
177− }
178− if (source != null) {
179− val dupLabel = if (source.label.isBlank()) source.baseUrl + " (copy)" else "${source.label} (copy)"
180− _state.value = ServerEditState(
181− id = null,
182− label = dupLabel,
183− baseUrl = source.baseUrl,
184− username = source.username.orEmpty(),
185− password = source.password.orEmpty(),
186− requireHttps = source.requireHttps,
187− certPin = source.certPin.orEmpty(),
188− loaded = true,
189− authFieldsVisible = source.hasAuth,
190− // Snapshot the seeded (copy) values so an untouched duplicate isn't
191− // immediately dirty — an empty initial would make backing out of a
192− // freshly opened duplicate always trigger the discard-changes dialog.
193− initial = InitialProfile(
194− label = dupLabel.trim(),
195− baseUrl = source.baseUrl.trim(),
196− username = source.username.orEmpty().trim(),
197− password = source.password.orEmpty().trim(),
198− requireHttps = source.requireHttps,
199− certPin = source.certPin.orEmpty().trim(),
200− ),
201− )
202− return@launch
203− }
204− // Source not found — fall through to a blank new-profile form.
205− }
206− // New-profile form: seed an initial snapshot of empty values so isDirty
207− // becomes true the moment the user types anything.
208142 _state.update {
209143 it.copy(
210144 loaded = true,
@@ -326,32 +260,6 @@ class ServerEditViewModel(
326260 }
327261 }
328262
329− /**
330− * Delete the profile being edited (only valid for an existing profile, not a new one).
331− * Fires [onDone] on success so the screen pops back to the list. The active profile can't
332− * be deleted here (the list enforces the same guard); surface an error instead.
333− */
334− fun delete(onDone: () -> Unit) {
335− val s = _state.value
336− val id = s.id ?: return
337− if (s.saving || s.testingCredentials) return
338− _state.update { it.copy(saving = true, error = null) }
339− viewModelScope.launch {
340− val result = runCatchingCancellable { container.profileStore.delete(id) }
341− result.onSuccess {
342− // If this was the active profile, disconnect so the app doesn't hold a stale
343− // connection to a deleted profile. The list screen also guards against deleting
344− // the active profile, but the edit screen reaches here via its own overflow.
345− if (container.activeConnection.value?.profile?.id == id) {
346− runCatchingCancellable { container.disconnect() }
347− }
348− onDone()
349− }.onFailure { e ->
350− _state.update { it.copy(saving = false, error = container.friendlyError(e)) }
351− }
352− }
353− }
354−
355263 private fun saveInternal(connectAfter: Boolean, onDone: () -> Unit) {
356264 val s = _state.value
357265 if (!s.canSave || s.saving) return
@@ -369,7 +277,11 @@ class ServerEditViewModel(
369277 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
370278 container.profileStore.profiles.first()
371279 } ?: emptyList()
372− val duplicate = existing.any { it.id != s.id && it.baseUrl == savedBaseUrl && it.label == s.label.trim() }
280+ // The add-server form has no label field, so derive a default label from the
281+ // hostname (e.g. "192.168.1.10:4096") rather than saving an empty label. The
282+ // user can rename it later from Server Settings.
283+ val savedLabel = s.label.trim().ifBlank { hostFromBaseUrl(savedBaseUrl) }
284+ val duplicate = existing.any { it.id != s.id && it.baseUrl == savedBaseUrl && it.label == savedLabel }
373285 if (duplicate) {
374286 throw java.io.IOException(container.string(R.string.duplicate_server_warning))
375287 }
@@ -385,7 +297,7 @@ class ServerEditViewModel(
385297 } else 0L
386298 val saved = ServerProfile(
387299 id = s.id ?: UUID.randomUUID().toString(),
388− label = s.label.trim(),
300+ label = savedLabel,
389301 baseUrl = savedBaseUrl,
390302 username = s.username.trim().takeIf { it.isNotBlank() },
391303 password = s.password.trim().takeIf { it.isNotEmpty() },
@@ -405,6 +317,7 @@ class ServerEditViewModel(
405317 _state.update {
406318 it.copy(
407319 id = saved.id,
320+ label = saved.label,
408321 baseUrl = saved.baseUrl,
409322 initial = InitialProfile(
410323 label = saved.label,
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+7−16
@@ -95,8 +95,7 @@ fun ServerListScreen(
9595 container: AppContainer,
9696 onConnected: () -> Unit,
9797 onAddProfile: () -> Unit,
98− onEditProfile: (String) -> Unit,
99− onDuplicateProfile: (String) -> Unit,
98+ onServerSettings: (String) -> Unit,
10099) {
101100 val vm: ServerListViewModel = viewModel(factory = vmFactory { ServerListViewModel(container) })
102101 val profiles by vm.profiles.collectAsStateWithLifecycle()
@@ -389,8 +388,7 @@ fun ServerListScreen(
389388 isActive = true,
390389 isConnecting = false,
391390 activeState = activeSseState,
392− onEdit = { onEditProfile(profile.id) },
393− onDuplicate = { onDuplicateProfile(profile.id) },
391+ onSettings = { onServerSettings(profile.id) },
394392 onPendingDelete = { pendingDeleteId = profile.id },
395393 onTestConnection = { vm.testConnection(profile) },
396394 isProbing = probingId == profile.id,
@@ -442,8 +440,7 @@ fun ServerListScreen(
442440 profile = profile,
443441 isActive = false,
444442 isConnecting = connectingId == profile.id,
445− onEdit = { onEditProfile(profile.id) },
446− onDuplicate = { onDuplicateProfile(profile.id) },
443+ onSettings = { onServerSettings(profile.id) },
447444 onPendingDelete = { pendingDeleteId = profile.id },
448445 onTestConnection = { vm.testConnection(profile) },
449446 isProbing = probingId == profile.id,
@@ -495,8 +492,7 @@ private fun ServerCardContent(
495492 profile: ServerProfile,
496493 isActive: Boolean,
497494 isConnecting: Boolean,
498− onEdit: () -> Unit,
499− onDuplicate: () -> Unit,
495+ onSettings: () -> Unit,
500496 onPendingDelete: () -> Unit,
501497 onTestConnection: () -> Unit = {},
502498 isProbing: Boolean = false,
@@ -597,8 +593,7 @@ private fun ServerCardContent(
597593 // Close this row's overflow on back press instead of navigating away, matching
598594 // the sort menu BackHandler above.
599595 BackHandler(enabled = showRowMenu) { showRowMenu = false }
600− val editLabel = stringResource(R.string.edit)
601− val duplicateLabel = stringResource(R.string.duplicate_server)
596+ val settingsLabel = stringResource(R.string.settings)
602597 val removeLabel = stringResource(R.string.remove)
603598 val testConnLabel = stringResource(R.string.test_connection)
604599 val testConnDesc = stringResource(R.string.test_connection_desc, profile.displayLabel)
@@ -621,12 +616,8 @@ private fun ServerCardContent(
621616 onDismissRequest = { showRowMenu = false },
622617 ) {
623618 DropdownMenuItem(
624− text = { Text(editLabel) },
625− onClick = { showRowMenu = false; onEdit() },
626− )
627− DropdownMenuItem(
628− text = { Text(duplicateLabel) },
629− onClick = { showRowMenu = false; onDuplicate() },
619+ text = { Text(settingsLabel) },
620+ onClick = { showRowMenu = false; onSettings() },
630621 )
631622 DropdownMenuItem(
632623 text = { Text(testConnLabel) },
app/src/main/java/soy/iko/opencode/ui/server/ServerSettingsScreen.ktnew file+415
@@ -0,0 +1,415 @@
1+package soy.iko.opencode.ui.server
2+
3+import androidx.activity.compose.BackHandler
4+import androidx.compose.animation.AnimatedVisibility
5+import androidx.compose.foundation.layout.Arrangement
6+import androidx.compose.foundation.layout.Box
7+import androidx.compose.foundation.layout.Column
8+import androidx.compose.foundation.layout.Row
9+import androidx.compose.foundation.layout.fillMaxSize
10+import androidx.compose.foundation.layout.fillMaxWidth
11+import androidx.compose.foundation.layout.imePadding
12+import androidx.compose.foundation.layout.padding
13+import androidx.compose.foundation.layout.size
14+import androidx.compose.foundation.layout.widthIn
15+import androidx.compose.foundation.layout.wrapContentWidth
16+import androidx.compose.foundation.rememberScrollState
17+import androidx.compose.foundation.text.KeyboardActions
18+import androidx.compose.foundation.text.KeyboardOptions
19+import androidx.compose.foundation.verticalScroll
20+import androidx.compose.material.icons.Icons
21+import androidx.compose.material.icons.automirrored.filled.ArrowBack
22+import androidx.compose.material.icons.filled.Delete
23+import androidx.compose.material.icons.filled.ExpandLess
24+import androidx.compose.material.icons.filled.ExpandMore
25+import androidx.compose.material.icons.filled.Visibility
26+import androidx.compose.material.icons.filled.VisibilityOff
27+import androidx.compose.material3.AlertDialog
28+import androidx.compose.material3.Button
29+import androidx.compose.material3.CircularProgressIndicator
30+import androidx.compose.material3.ExperimentalMaterial3Api
31+import androidx.compose.material3.Icon
32+import androidx.compose.material3.IconButton
33+import androidx.compose.material3.MaterialTheme
34+import androidx.compose.material3.OutlinedButton
35+import androidx.compose.material3.OutlinedTextField
36+import androidx.compose.material3.Scaffold
37+import androidx.compose.material3.Switch
38+import androidx.compose.material3.Text
39+import androidx.compose.material3.TextButton
40+import androidx.compose.material3.TopAppBar
41+import androidx.compose.material3.TopAppBarDefaults
42+import androidx.compose.runtime.Composable
43+import androidx.compose.runtime.LaunchedEffect
44+import androidx.compose.runtime.getValue
45+import androidx.compose.runtime.mutableStateOf
46+import androidx.compose.runtime.remember
47+import androidx.compose.runtime.saveable.rememberSaveable
48+import androidx.compose.runtime.setValue
49+import androidx.compose.ui.Alignment
50+import androidx.compose.ui.Modifier
51+import androidx.compose.ui.focus.FocusRequester
52+import androidx.compose.ui.focus.focusRequester
53+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
54+import androidx.compose.ui.input.nestedscroll.nestedScroll
55+import androidx.compose.ui.platform.LocalHapticFeedback
56+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
57+import androidx.compose.ui.platform.testTag
58+import androidx.compose.ui.res.stringResource
59+import androidx.compose.ui.semantics.contentDescription
60+import androidx.compose.ui.semantics.semantics
61+import androidx.compose.ui.semantics.stateDescription
62+import androidx.compose.ui.text.input.ImeAction
63+import androidx.compose.ui.text.input.KeyboardType
64+import androidx.compose.ui.text.input.PasswordVisualTransformation
65+import androidx.compose.ui.text.input.VisualTransformation
66+import androidx.compose.ui.unit.dp
67+import androidx.lifecycle.compose.collectAsStateWithLifecycle
68+import androidx.lifecycle.viewmodel.compose.viewModel
69+import soy.iko.opencode.R
70+import soy.iko.opencode.di.AppContainer
71+import soy.iko.opencode.ui.components.AutofillHint
72+import soy.iko.opencode.ui.components.autofillModifier
73+import soy.iko.opencode.ui.vmFactory
74+
75+@OptIn(ExperimentalMaterial3Api::class)
76+@Composable
77+fun ServerSettingsScreen(
78+ container: AppContainer,
79+ profileId: String,
80+ onDone: () -> Unit,
81+) {
82+ val vm: ServerSettingsViewModel =
83+ viewModel(factory = vmFactory { ServerSettingsViewModel(container, profileId) })
84+ val state by vm.state.collectAsStateWithLifecycle()
85+ var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
86+ var passwordVisible by rememberSaveable { mutableStateOf(false) }
87+
88+ fun safeExit() {
89+ when {
90+ // A save is in flight: ignore the back gesture (matching the BackHandler guard
91+ // below). Popping here would cancel the save's viewModelScope coroutine and
92+ // silently abandon the write; the VM calls onDone itself once the save lands.
93+ state.saving -> Unit
94+ state.isDirty -> showDiscardConfirm = true
95+ else -> onDone()
96+ }
97+ }
98+
99+ BackHandler(enabled = (state.isDirty || state.saving) && !showDiscardConfirm) {
100+ if (!state.saving) showDiscardConfirm = true
101+ }
102+
103+ val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
104+ Scaffold(
105+ topBar = {
106+ TopAppBar(
107+ title = { Text(stringResource(R.string.server_settings)) },
108+ navigationIcon = {
109+ IconButton(onClick = ::safeExit) {
110+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
111+ }
112+ },
113+ actions = {
114+ var showDeleteConfirm by remember { mutableStateOf(false) }
115+ IconButton(onClick = { showDeleteConfirm = true }) {
116+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete_server))
117+ }
118+ if (showDeleteConfirm) {
119+ val displayName = state.label.takeIf { it.isNotBlank() } ?: state.baseUrl
120+ AlertDialog(
121+ onDismissRequest = { showDeleteConfirm = false },
122+ title = { Text(stringResource(R.string.delete_server)) },
123+ text = { Text(stringResource(R.string.remove_server_text, displayName)) },
124+ confirmButton = {
125+ TextButton(onClick = {
126+ showDeleteConfirm = false
127+ vm.delete(onDone)
128+ }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
129+ },
130+ dismissButton = {
131+ TextButton(onClick = { showDeleteConfirm = false }) { Text(stringResource(R.string.cancel)) }
132+ },
133+ )
134+ }
135+ },
136+ scrollBehavior = scrollBehavior,
137+ )
138+ },
139+ ) { padding ->
140+ if (!state.loaded) {
141+ val loadingLabel = stringResource(R.string.loading)
142+ Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
143+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
144+ }
145+ } else {
146+ ServerSettingsForm(
147+ state = state,
148+ passwordVisible = passwordVisible,
149+ onTogglePassword = { passwordVisible = !passwordVisible },
150+ padding = padding,
151+ scrollBehavior = scrollBehavior,
152+ vm = vm,
153+ onDone = onDone,
154+ )
155+ }
156+ }
157+
158+ if (showDiscardConfirm) {
159+ AlertDialog(
160+ onDismissRequest = { showDiscardConfirm = false },
161+ title = { Text(stringResource(R.string.discard_title)) },
162+ text = { Text(stringResource(R.string.discard_text)) },
163+ confirmButton = {
164+ TextButton(onClick = {
165+ showDiscardConfirm = false
166+ onDone()
167+ }) { Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) }
168+ },
169+ dismissButton = {
170+ TextButton(onClick = { showDiscardConfirm = false }) { Text(stringResource(R.string.keep_editing)) }
171+ },
172+ )
173+ }
174+}
175+
176+@OptIn(ExperimentalMaterial3Api::class)
177+@Composable
178+private fun ServerSettingsForm(
179+ state: ServerEditState,
180+ passwordVisible: Boolean,
181+ onTogglePassword: () -> Unit,
182+ padding: androidx.compose.foundation.layout.PaddingValues,
183+ scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
184+ vm: ServerSettingsViewModel,
185+ onDone: () -> Unit,
186+) {
187+ val haptics = LocalHapticFeedback.current
188+ val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
189+ val urlValid = remember(state.baseUrl) { isValidUrl(state.baseUrl) }
190+ Column(
191+ modifier = Modifier
192+ .imePadding()
193+ .padding(padding)
194+ .fillMaxWidth()
195+ .wrapContentWidth(Alignment.CenterHorizontally)
196+ .widthIn(max = 600.dp)
197+ .padding(16.dp)
198+ .nestedScroll(scrollBehavior.nestedScrollConnection)
199+ .verticalScroll(rememberScrollState()),
200+ verticalArrangement = Arrangement.spacedBy(12.dp),
201+ ) {
202+ OutlinedTextField(
203+ value = state.baseUrl,
204+ onValueChange = { v -> vm.update { it.copy(baseUrl = v) } },
205+ label = { Text(stringResource(R.string.base_url)) },
206+ placeholder = { Text(stringResource(R.string.base_url_hint)) },
207+ singleLine = true,
208+ isError = state.baseUrl.isNotBlank() && !urlValid,
209+ supportingText = {
210+ if (state.baseUrl.isNotBlank() && !urlValid) {
211+ Text(stringResource(R.string.invalid_url))
212+ }
213+ },
214+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
215+ modifier = Modifier.fillMaxWidth().testTag("server_url"),
216+ )
217+ AnimatedVisibility(visible = state.authFieldsVisible) {
218+ AuthFields(
219+ state = state,
220+ passwordVisible = passwordVisible,
221+ onTogglePassword = onTogglePassword,
222+ onUpdate = vm::update,
223+ onTestCredentials = vm::testCredentials,
224+ onImeDone = { if (canSave && !state.saving) vm.save(onDone) },
225+ )
226+ }
227+ Button(
228+ onClick = {
229+ haptics.performHapticFeedback(HapticFeedbackType.LongPress)
230+ vm.save(onDone)
231+ },
232+ enabled = canSave && !state.saving,
233+ modifier = Modifier.fillMaxWidth().testTag("server_save"),
234+ ) {
235+ if (state.saving) {
236+ CircularProgressIndicator(
237+ modifier = Modifier.size(20.dp),
238+ color = MaterialTheme.colorScheme.onPrimary,
239+ strokeWidth = 2.dp,
240+ )
241+ Text(stringResource(R.string.saving), modifier = Modifier.padding(start = 8.dp))
242+ } else {
243+ Text(stringResource(R.string.save))
244+ }
245+ }
246+ state.error?.let { message ->
247+ Text(
248+ message,
249+ color = MaterialTheme.colorScheme.error,
250+ style = MaterialTheme.typography.bodySmall,
251+ modifier = Modifier.fillMaxWidth(),
252+ )
253+ }
254+ OutlinedTextField(
255+ value = state.label,
256+ onValueChange = { v -> vm.update { it.copy(label = v) } },
257+ label = { Text(stringResource(R.string.label_optional)) },
258+ singleLine = true,
259+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
260+ modifier = Modifier.fillMaxWidth().testTag("server_label"),
261+ )
262+ AdvancedSection(state = state, onUpdate = vm::update)
263+ }
264+}
265+
266+@Composable
267+private fun AuthFields(
268+ state: ServerEditState,
269+ passwordVisible: Boolean,
270+ onTogglePassword: () -> Unit,
271+ onUpdate: ((ServerEditState) -> ServerEditState) -> Unit,
272+ onTestCredentials: () -> Unit,
273+ onImeDone: () -> Unit,
274+) {
275+ val showPasswordLabel = stringResource(R.string.show_password)
276+ val hidePasswordLabel = stringResource(R.string.hide_password)
277+ val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
278+ val usernameFocus = remember { FocusRequester() }
279+ val passwordFocus = remember { FocusRequester() }
280+ LaunchedEffect(Unit) {
281+ runCatching { usernameFocus.requestFocus() }
282+ }
283+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
284+ Text(
285+ stringResource(R.string.auth_required),
286+ style = MaterialTheme.typography.bodySmall,
287+ color = MaterialTheme.colorScheme.error,
288+ )
289+ OutlinedTextField(
290+ value = state.username,
291+ onValueChange = { v -> onUpdate { it.copy(username = v) } },
292+ label = { Text(stringResource(R.string.username_optional)) },
293+ singleLine = true,
294+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
295+ keyboardActions = KeyboardActions(onNext = { passwordFocus.requestFocus() }),
296+ modifier = Modifier
297+ .fillMaxWidth()
298+ .focusRequester(usernameFocus)
299+ .then(autofillModifier(AutofillHint.Username) { v -> onUpdate { it.copy(username = v) } })
300+ .testTag("server_username"),
301+ )
302+ OutlinedTextField(
303+ value = state.password,
304+ onValueChange = { v -> onUpdate { it.copy(password = v) } },
305+ label = { Text(stringResource(R.string.password_optional)) },
306+ singleLine = true,
307+ visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
308+ trailingIcon = {
309+ IconButton(onClick = onTogglePassword) {
310+ Icon(
311+ if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
312+ contentDescription = if (passwordVisible) hidePasswordLabel else showPasswordLabel,
313+ )
314+ }
315+ },
316+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
317+ keyboardActions = KeyboardActions(onDone = { onImeDone() }),
318+ modifier = Modifier
319+ .fillMaxWidth()
320+ .focusRequester(passwordFocus)
321+ .then(autofillModifier(AutofillHint.Password) { v -> onUpdate { it.copy(password = v) } })
322+ .testTag("server_password"),
323+ )
324+ OutlinedButton(
325+ onClick = onTestCredentials,
326+ enabled = canSave && !state.testingCredentials && !state.saving &&
327+ (state.username.isNotBlank() || state.password.isNotBlank()),
328+ modifier = Modifier.fillMaxWidth().testTag("server_test_creds"),
329+ ) {
330+ if (state.testingCredentials) {
331+ CircularProgressIndicator(
332+ modifier = Modifier.size(20.dp),
333+ strokeWidth = 2.dp,
334+ )
335+ Text(stringResource(R.string.testing_credentials), modifier = Modifier.padding(start = 8.dp))
336+ } else {
337+ Text(stringResource(R.string.test_credentials))
338+ }
339+ }
340+ state.credentialsResult?.let { ok ->
341+ Text(
342+ stringResource(if (ok) R.string.credentials_ok else R.string.credentials_rejected),
343+ style = MaterialTheme.typography.bodySmall,
344+ color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
345+ )
346+ }
347+ }
348+}
349+
350+@Composable
351+private fun AdvancedSection(
352+ state: ServerEditState,
353+ onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
354+) {
355+ var expanded by rememberSaveable { mutableStateOf(state.requireHttps || state.certPin.isNotBlank()) }
356+ val stateLabel = stringResource(if (expanded) R.string.state_expanded else R.string.state_collapsed)
357+ Column(modifier = Modifier.fillMaxWidth()) {
358+ TextButton(
359+ onClick = { expanded = !expanded },
360+ modifier = Modifier.semantics { stateDescription = stateLabel },
361+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
362+ ) {
363+ Icon(
364+ if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
365+ contentDescription = null,
366+ modifier = Modifier.size(18.dp),
367+ )
368+ Text(stringResource(R.string.server_security), modifier = Modifier.padding(start = 8.dp))
369+ }
370+ AnimatedVisibility(visible = expanded) {
371+ SecurityFields(state = state, onUpdate = onUpdate)
372+ }
373+ }
374+}
375+
376+@Composable
377+private fun SecurityFields(
378+ state: ServerEditState,
379+ onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
380+) {
381+ Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
382+ Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
383+ Column(modifier = Modifier.weight(1f)) {
384+ Text(stringResource(R.string.require_https), style = MaterialTheme.typography.bodyLarge)
385+ Text(
386+ stringResource(R.string.require_https_desc),
387+ style = MaterialTheme.typography.bodySmall,
388+ color = MaterialTheme.colorScheme.onSurfaceVariant,
389+ )
390+ }
391+ Switch(
392+ checked = state.requireHttps,
393+ onCheckedChange = { v -> onUpdate { it.copy(requireHttps = v) } },
394+ )
395+ }
396+ OutlinedTextField(
397+ value = state.certPin,
398+ onValueChange = { v -> onUpdate { it.copy(certPin = v) } },
399+ label = { Text(stringResource(R.string.cert_pin)) },
400+ placeholder = { Text(stringResource(R.string.cert_pin_hint)) },
401+ singleLine = true,
402+ isError = !state.certPinValid,
403+ supportingText = {
404+ Text(
405+ stringResource(if (state.certPinValid) R.string.cert_pin_desc else R.string.cert_pin_invalid),
406+ style = MaterialTheme.typography.bodySmall,
407+ color = if (state.certPinValid) MaterialTheme.colorScheme.onSurfaceVariant
408+ else MaterialTheme.colorScheme.error,
409+ )
410+ },
411+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
412+ modifier = Modifier.fillMaxWidth().testTag("server_cert_pin"),
413+ )
414+ }
415+}
app/src/main/java/soy/iko/opencode/ui/server/ServerSettingsViewModel.ktnew file+221
@@ -0,0 +1,221 @@
1+package soy.iko.opencode.ui.server
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewModelScope
5+import soy.iko.opencode.data.model.ServerProfile
6+import soy.iko.opencode.data.network.NetworkConfig
7+import soy.iko.opencode.di.AppContainer
8+import kotlinx.coroutines.flow.MutableStateFlow
9+import kotlinx.coroutines.flow.StateFlow
10+import kotlinx.coroutines.flow.asStateFlow
11+import kotlinx.coroutines.flow.first
12+import kotlinx.coroutines.flow.update
13+import kotlinx.coroutines.launch
14+import kotlinx.coroutines.withTimeoutOrNull
15+import soy.iko.opencode.R
16+import soy.iko.opencode.util.runCatchingCancellable
17+import java.util.UUID
18+
19+/**
20+ * ViewModel for the Server Settings screen, which edits an *existing* saved server profile.
21+ * Holds the fields that don't appear on the add-server form: the display label, the advanced
22+ * security options (require-HTTPS + certificate pin), plus the URL and credentials. Saving
23+ * persists the profile and, when it's the active connection, reconnects to it so the edit
24+ * takes effect immediately. Deleting disconnects first when the profile is active.
25+ */
26+class ServerSettingsViewModel(
27+ private val container: AppContainer,
28+ private val profileId: String,
29+) : ViewModel() {
30+
31+ private val _state = MutableStateFlow(ServerEditState(id = profileId))
32+ val state: StateFlow<ServerEditState> = _state.asStateFlow()
33+
34+ init {
35+ viewModelScope.launch {
36+ val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
37+ container.profileStore.profiles
38+ .first { list -> list.any { p -> p.id == profileId } }
39+ .firstOrNull { it.id == profileId }
40+ }
41+ if (existing != null) {
42+ // Normalize (trim) the dirty-snapshot fields the same way save() stores them,
43+ // so a stored value with surrounding whitespace doesn't make the freshly-opened
44+ // settings screen spuriously dirty (isDirty compares field.trim() against these).
45+ val init = InitialProfile(
46+ label = existing.label.trim(),
47+ baseUrl = existing.baseUrl.trim(),
48+ username = existing.username.orEmpty().trim(),
49+ password = existing.password.orEmpty().trim(),
50+ requireHttps = existing.requireHttps,
51+ certPin = existing.certPin.orEmpty().trim(),
52+ )
53+ _state.value = ServerEditState(
54+ id = existing.id,
55+ label = existing.label,
56+ baseUrl = existing.baseUrl,
57+ username = existing.username.orEmpty(),
58+ password = existing.password.orEmpty(),
59+ requireHttps = existing.requireHttps,
60+ certPin = existing.certPin.orEmpty(),
61+ loaded = true,
62+ authFieldsVisible = existing.hasAuth,
63+ initial = init,
64+ )
65+ return@launch
66+ }
67+ _state.value = ServerEditState(
68+ id = profileId,
69+ loaded = true,
70+ error = container.string(R.string.error_load_timeout),
71+ )
72+ }
73+ }
74+
75+ fun update(transform: (ServerEditState) -> ServerEditState) {
76+ // Clear both the inline credential note and the bottom-of-form error when any field
77+ // changes: once the user starts editing (e.g. correcting a rejected password), a stale
78+ // "Credentials rejected" message no longer describes the current input.
79+ _state.update { transform(it).copy(credentialsResult = null, error = null) }
80+ }
81+
82+ /**
83+ * Probe the server *with* the entered credentials to validate them before saving. Only
84+ * meaningful once auth fields are visible (the server requires auth). Surfaces
85+ * [ServerEditState.credentialsResult] so the UI can show success/failure without
86+ * dismissing the form.
87+ */
88+ fun testCredentials() {
89+ val s = _state.value
90+ if (!s.canSave || s.testingCredentials) return
91+ if (s.username.isBlank() && s.password.isBlank()) return
92+ _state.update { it.copy(testingCredentials = true, credentialsResult = null, error = null) }
93+ // Test the trimmed URL/credentials that save() would actually store, and remember the
94+ // probed URL so a stale result is dropped if the user edited the base URL away while
95+ // the test was in flight.
96+ val probedUrl = normalizeForSave(s.baseUrl)
97+ val probedUser = s.username.trim()
98+ val probedPass = s.password.trim()
99+ val probedRequireHttps = s.requireHttps
100+ val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
101+ viewModelScope.launch {
102+ val result = runCatchingCancellable {
103+ withTimeoutOrNull(NetworkConfig.testCredentialsTimeoutMs) {
104+ container.probeWithCredentials(probedUrl, probedUser, probedPass, probedRequireHttps, probedCertPin)
105+ }
106+ }
107+ result.onSuccess { ok ->
108+ _state.update {
109+ if (normalizeForSave(it.baseUrl) != probedUrl) return@update it.copy(testingCredentials = false)
110+ it.copy(
111+ testingCredentials = false,
112+ credentialsResult = ok,
113+ error = if (ok == true) null else container.string(R.string.credentials_rejected),
114+ )
115+ }
116+ }.onFailure { e ->
117+ _state.update {
118+ if (normalizeForSave(it.baseUrl) != probedUrl) return@update it.copy(testingCredentials = false)
119+ it.copy(
120+ testingCredentials = false,
121+ credentialsResult = false,
122+ error = container.friendlyError(e),
123+ )
124+ }
125+ }
126+ }
127+ }
128+
129+ /**
130+ * Persist the edited profile. When it's the active connection, reconnect so the change
131+ * (URL, credentials, security) takes effect immediately. Fires [onDone] on success so the
132+ * screen pops back.
133+ */
134+ fun save(onDone: () -> Unit) {
135+ val s = _state.value
136+ if (!s.canSave || s.saving || s.testingCredentials) return
137+ _state.update { it.copy(saving = true, error = null) }
138+ val savedBaseUrl = normalizeForSave(s.baseUrl)
139+ viewModelScope.launch {
140+ val result = runCatchingCancellable {
141+ val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
142+ container.profileStore.profiles.first()
143+ } ?: emptyList()
144+ // Duplicate detection: warn when a *different* profile with the same URL and
145+ // label already exists. Excludes the profile being edited (by id) so re-saving
146+ // the same profile doesn't trigger.
147+ val savedLabel = s.label.trim().ifBlank { hostFromBaseUrl(savedBaseUrl) }
148+ val duplicate = existing.any { it.id != s.id && it.baseUrl == savedBaseUrl && it.label == savedLabel }
149+ if (duplicate) {
150+ throw java.io.IOException(container.string(R.string.duplicate_server_warning))
151+ }
152+ // Preserve lastUsed so re-saving doesn't reset the profile's sort order. When
153+ // the load timed out (existing is empty) fall back to now rather than 0, which
154+ // would silently drop the user's most-recently-used server to the list bottom.
155+ val existingLastUsed = existing.firstOrNull { it.id == s.id }?.lastUsed ?: System.currentTimeMillis()
156+ val saved = ServerProfile(
157+ id = s.id ?: UUID.randomUUID().toString(),
158+ label = savedLabel,
159+ baseUrl = savedBaseUrl,
160+ username = s.username.trim().takeIf { it.isNotBlank() },
161+ password = s.password.trim().takeIf { it.isNotEmpty() },
162+ lastUsed = existingLastUsed,
163+ requireHttps = s.requireHttps,
164+ certPin = s.certPin.trim().takeIf { it.isNotBlank() },
165+ )
166+ container.profileStore.save(saved)
167+ // Persist the normalized values back into state and refresh the dirty snapshot
168+ // so the just-saved values no longer read as unsaved changes.
169+ _state.update {
170+ it.copy(
171+ id = saved.id,
172+ label = saved.label,
173+ baseUrl = saved.baseUrl,
174+ initial = InitialProfile(
175+ label = saved.label,
176+ baseUrl = saved.baseUrl,
177+ username = saved.username.orEmpty(),
178+ password = saved.password.orEmpty(),
179+ requireHttps = saved.requireHttps,
180+ certPin = saved.certPin.orEmpty(),
181+ ),
182+ )
183+ }
184+ // If this is the active profile, reconnect so the edit (URL/creds/security)
185+ // takes effect immediately. A non-active profile just persists.
186+ if (container.activeConnection.value?.profile?.id == saved.id) {
187+ container.connect(saved)
188+ val pingOk = runCatchingCancellable { container.activeConnection.value?.api?.ping() }.isSuccess
189+ if (!pingOk) {
190+ throw java.io.IOException(container.string(R.string.error_ping_failed))
191+ }
192+ }
193+ }
194+ result.onSuccess { onDone() }
195+ .onFailure { e -> _state.update { it.copy(error = container.friendlyError(e), saving = false) } }
196+ }
197+ }
198+
199+ /**
200+ * Delete the profile being edited. Fires [onDone] on success so the screen pops back. The
201+ * active profile is disconnected first so the app doesn't hold a stale connection to a
202+ * deleted profile.
203+ */
204+ fun delete(onDone: () -> Unit) {
205+ val s = _state.value
206+ val id = s.id ?: return
207+ if (s.saving || s.testingCredentials) return
208+ _state.update { it.copy(saving = true, error = null) }
209+ viewModelScope.launch {
210+ val result = runCatchingCancellable { container.profileStore.delete(id) }
211+ result.onSuccess {
212+ if (container.activeConnection.value?.profile?.id == id) {
213+ runCatchingCancellable { container.disconnect() }
214+ }
215+ onDone()
216+ }.onFailure { e ->
217+ _state.update { it.copy(saving = false, error = container.friendlyError(e)) }
218+ }
219+ }
220+ }
221+}
app/src/main/res/values-es/strings.xml+2−2
@@ -69,7 +69,8 @@
6969 <string name="servers_title">Servidores de opencode</string>
7070 <string name="sessions_title">Sesiones</string>
7171 <string name="add_server">Añadir servidor</string>
72− <string name="edit_server">Editar servidor</string>
72+ <string name="server_settings">Ajustes del servidor</string>
73+ <string name="saving">Guardando…</string>
7374 <string name="connected">Conectado</string>
7475 <string name="no_servers_yet">Aún no hay servidores</string>
7576 <string name="no_servers_hint">Añade un endpoint de opencode serve para conectar.</string>
@@ -334,7 +335,6 @@
334335 <string name="sort_title">Título</string>
335336 <string name="last_used_never">Sin usar aún</string>
336337 <string name="last_used">Usado por última vez %1$s</string>
337− <string name="duplicate_server">Duplicar</string>
338338 <string name="find_in_file">Buscar en el archivo</string>
339339 <string name="find_previous">Buscar anterior</string>
340340 <string name="find_next">Buscar siguiente</string>
app/src/main/res/values/strings.xml+2−2
@@ -70,7 +70,8 @@
7070 <string name="servers_title">opencode servers</string>
7171 <string name="sessions_title">Sessions</string>
7272 <string name="add_server">Add server</string>
73− <string name="edit_server">Edit server</string>
73+ <string name="server_settings">Server settings</string>
74+ <string name="saving">Saving…</string>
7475 <string name="connected">Connected</string>
7576 <string name="no_servers_yet">No servers yet</string>
7677 <string name="no_servers_hint">Add an opencode serve endpoint to connect.</string>
@@ -336,7 +337,6 @@
336337 <string name="sort_name">Name</string>
337338 <string name="last_used_never">Not used yet</string>
338339 <string name="last_used">Last used %1$s</string>
339− <string name="duplicate_server">Duplicate</string>
340340 <string name="find_in_file">Find in file</string>
341341 <string name="find_previous">Find previous</string>
342342 <string name="find_next">Find next</string>
app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.kt+44−113
@@ -35,8 +35,8 @@ class ServerEditViewModelTest {
3535 Dispatchers.resetMain()
3636 }
3737
38− private fun makeVm(container: FakeAppContainer, profileId: String? = null): ServerEditViewModel {
39− val vm = ServerEditViewModel(container, profileId)
38+ private fun makeVm(container: FakeAppContainer): ServerEditViewModel {
39+ val vm = ServerEditViewModel(container)
4040 testScope.testScheduler.advanceUntilIdle()
4141 return vm
4242 }
@@ -46,7 +46,7 @@ class ServerEditViewModelTest {
4646 @Test
4747 fun init_newProfile_loadsEmptyForm() = testScope.runTest {
4848 val container = FakeAppContainer()
49− val vm = makeVm(container, profileId = null)
49+ val vm = makeVm(container)
5050 assertTrue(vm.state.value.loaded)
5151 assertTrue(vm.state.value.isNew)
5252 assertEquals("", vm.state.value.baseUrl)
@@ -55,38 +55,6 @@ class ServerEditViewModelTest {
5555 assertNull(vm.state.value.error)
5656 }
5757
58− // --- existing profile ---
59−
60− @Test
61− fun init_existingProfile_loadsFormFields() = testScope.runTest {
62− val container = FakeAppContainer()
63− val profile = ServerProfile(
64− id = "p1",
65− label = "My Server",
66− baseUrl = "http://localhost:3000",
67− username = "admin",
68− password = "secret",
69− )
70− container.fakeProfileStore.setProfiles(listOf(profile))
71− val vm = makeVm(container, profileId = "p1")
72− assertTrue(vm.state.value.loaded)
73− assertFalse(vm.state.value.isNew)
74− assertEquals("p1", vm.state.value.id)
75− assertEquals("My Server", vm.state.value.label)
76− assertEquals("http://localhost:3000", vm.state.value.baseUrl)
77− assertEquals("admin", vm.state.value.username)
78− assertEquals("secret", vm.state.value.password)
79− }
80−
81− @Test
82− fun init_nonExistentProfile_showsError() = testScope.runTest {
83− val container = FakeAppContainer()
84− container.fakeProfileStore.setProfiles(emptyList())
85− val vm = makeVm(container, profileId = "nonexistent")
86− assertTrue(vm.state.value.loaded)
87− assertNotNull(vm.state.value.error)
88− }
89−
9058 // --- update() ---
9159
9260 @Test
@@ -161,50 +129,73 @@ class ServerEditViewModelTest {
161129 assertTrue(vm.state.value.canSave)
162130 }
163131
132+ // --- hostFromBaseUrl (default label derivation) ---
133+
134+ @Test
135+ fun hostFromBaseUrl_extractsHostAndPort() {
136+ assertEquals("localhost:3000", hostFromBaseUrl("http://localhost:3000"))
137+ assertEquals("192.168.1.10:4096", hostFromBaseUrl("192.168.1.10:4096"))
138+ assertEquals("example.com", hostFromBaseUrl("https://example.com"))
139+ assertEquals("example.com", hostFromBaseUrl("example.com"))
140+ // Default port (443 for https) is omitted.
141+ assertEquals("example.com", hostFromBaseUrl("https://example.com:443"))
142+ }
143+
164144 // --- connect() save path (probe Reachable → save + connect + ping) ---
165− // The former save()-specific tests now exercise the same persist path via connect(),
166− // since the standalone "Save without connecting" action was removed: connect() is the
167− // single primary action and its probe IS the connection test.
145+ // The add-server form has no label field, so the saved label is derived from the hostname
146+ // via hostFromBaseUrl. Connect is the single primary action and its probe IS the
147+ // connection test.
168148
169149 @Test
170150 fun connect_persistsProfileAndCallsOnDone() = testScope.runTest {
171151 val container = FakeAppContainer()
172152 container.probeResult = ProbeResult.Reachable
173153 val vm = makeVm(container)
174− vm.update { it.copy(baseUrl = "http://localhost:3000", label = "Test") }
154+ vm.update { it.copy(baseUrl = "http://localhost:3000") }
175155 var doneCalled = false
176156 vm.connect { doneCalled = true }
177157 testScheduler.advanceUntilIdle()
178158 assertTrue(doneCalled)
179159 assertNotNull(container.fakeProfileStore.savedProfile)
180160 assertEquals("http://localhost:3000", container.fakeProfileStore.savedProfile?.baseUrl)
181− assertEquals("Test", container.fakeProfileStore.savedProfile?.label)
182161 }
183162
184163 @Test
185− fun connect_generatesIdForNewProfile() = testScope.runTest {
164+ fun connect_derivesLabelFromHostnameWhenLabelBlank() = testScope.runTest {
186165 val container = FakeAppContainer()
187166 container.probeResult = ProbeResult.Reachable
188167 val vm = makeVm(container)
189− vm.update { it.copy(baseUrl = "http://localhost:3000") }
168+ vm.update { it.copy(baseUrl = "192.168.1.10:4096") }
190169 vm.connect { }
191170 testScheduler.advanceUntilIdle()
192− val saved = container.fakeProfileStore.savedProfile
193− assertNotNull(saved?.id)
194− assertTrue(saved!!.id.isNotEmpty())
171+ val saved = container.fakeProfileStore.savedProfile!!
172+ assertEquals("192.168.1.10:4096", saved.label)
195173 }
196174
197175 @Test
198− fun connect_preservesIdForExistingProfile() = testScope.runTest {
176+ fun connect_preservesExplicitLabelWhenProvided() = testScope.runTest {
199177 val container = FakeAppContainer()
200178 container.probeResult = ProbeResult.Reachable
201− val profile = ServerProfile(id = "existing-id", label = "Old", baseUrl = "http://old")
202− container.fakeProfileStore.setProfiles(listOf(profile))
203− val vm = makeVm(container, profileId = "existing-id")
204− vm.update { it.copy(baseUrl = "http://new") }
179+ val vm = makeVm(container)
180+ // The add form has no label field, but the VM still honors a label set via update()
181+ // (e.g. by a future caller) — only a blank label is derived.
182+ vm.update { it.copy(baseUrl = "http://localhost:3000", label = "My Server") }
205183 vm.connect { }
206184 testScheduler.advanceUntilIdle()
207− assertEquals("existing-id", container.fakeProfileStore.savedProfile?.id)
185+ assertEquals("My Server", container.fakeProfileStore.savedProfile?.label)
186+ }
187+
188+ @Test
189+ fun connect_generatesIdForNewProfile() = testScope.runTest {
190+ val container = FakeAppContainer()
191+ container.probeResult = ProbeResult.Reachable
192+ val vm = makeVm(container)
193+ vm.update { it.copy(baseUrl = "http://localhost:3000") }
194+ vm.connect { }
195+ testScheduler.advanceUntilIdle()
196+ val saved = container.fakeProfileStore.savedProfile
197+ assertNotNull(saved?.id)
198+ assertTrue(saved!!.id.isNotEmpty())
208199 }
209200
210201 @Test
@@ -212,12 +203,11 @@ class ServerEditViewModelTest {
212203 val container = FakeAppContainer()
213204 container.probeResult = ProbeResult.Reachable
214205 val vm = makeVm(container)
215− vm.update { it.copy(baseUrl = " http://localhost:3000 ", label = " Test ", username = " admin ") }
206+ vm.update { it.copy(baseUrl = " http://localhost:3000 ", username = " admin ") }
216207 vm.connect { }
217208 testScheduler.advanceUntilIdle()
218209 val saved = container.fakeProfileStore.savedProfile!!
219210 assertEquals("http://localhost:3000", saved.baseUrl)
220− assertEquals("Test", saved.label)
221211 assertEquals("admin", saved.username)
222212 }
223213
@@ -226,14 +216,13 @@ class ServerEditViewModelTest {
226216 val container = FakeAppContainer()
227217 container.probeResult = ProbeResult.Reachable
228218 val vm = makeVm(container)
229− vm.update { it.copy(baseUrl = "localhost:3000", label = "LAN") }
219+ vm.update { it.copy(baseUrl = "localhost:3000") }
230220 vm.connect { }
231221 testScheduler.advanceUntilIdle()
232222 val saved = container.fakeProfileStore.savedProfile!!
233223 // A bare host is always schemed to https:// (the safe default). The user can still
234224 // opt into cleartext by typing http:// explicitly.
235225 assertEquals("https://localhost:3000", saved.baseUrl)
236− assertEquals("LAN", saved.label)
237226 // The editor's own field is refreshed to the schemed form so a just-saved bare-host
238227 // entry doesn't read as dirty against its own stored value on re-open.
239228 assertEquals("https://localhost:3000", vm.state.value.baseUrl)
@@ -433,62 +422,4 @@ class ServerEditViewModelTest {
433422 testScheduler.advanceUntilIdle()
434423 assertFalse(vm.state.value.saving)
435424 }
436−
437− @Test
438− fun init_existingProfileWithAuth_showsAuthFields() = testScope.runTest {
439− val container = FakeAppContainer()
440− val profile = ServerProfile(
441− id = "p1",
442− label = "My Server",
443− baseUrl = "http://localhost:3000",
444− username = "admin",
445− password = "secret",
446− )
447− container.fakeProfileStore.setProfiles(listOf(profile))
448− val vm = makeVm(container, profileId = "p1")
449− assertTrue(vm.state.value.authFieldsVisible)
450− }
451−
452− @Test
453− fun init_existingProfileWithoutAuth_hidesAuthFields() = testScope.runTest {
454− val container = FakeAppContainer()
455− val profile = ServerProfile(
456− id = "p1",
457− label = "My Server",
458− baseUrl = "http://localhost:3000",
459− )
460− container.fakeProfileStore.setProfiles(listOf(profile))
461− val vm = makeVm(container, profileId = "p1")
462− assertFalse(vm.state.value.authFieldsVisible)
463− }
464−
465− @Test
466− fun init_newProfile_hidesAuthFields() = testScope.runTest {
467− val container = FakeAppContainer()
468− val vm = makeVm(container)
469− assertFalse(vm.state.value.authFieldsVisible)
470− }
471−
472− @Test
473− fun duplicate_seedsFromSourceProfileAsNew() = testScope.runTest {
474− val container = FakeAppContainer()
475− val source = ServerProfile(
476− id = "src1",
477− label = "My Server",
478− baseUrl = "http://localhost:3000",
479− username = "admin",
480− password = "secret",
481− )
482− container.fakeProfileStore.setProfiles(listOf(source))
483− val vm = ServerEditViewModel(container, profileId = null, sourceId = "src1")
484− testScope.testScheduler.advanceUntilIdle()
485− assertTrue(vm.state.value.loaded)
486− // A duplicate is a NEW profile (no id), so save() generates a fresh one.
487− assertNull(vm.state.value.id)
488− assertEquals("My Server (copy)", vm.state.value.label)
489− assertEquals("http://localhost:3000", vm.state.value.baseUrl)
490− assertEquals("admin", vm.state.value.username)
491− assertEquals("secret", vm.state.value.password)
492− assertTrue(vm.state.value.authFieldsVisible)
493− }
494425}
app/src/test/java/soy/iko/opencode/ui/server/ServerSettingsViewModelTest.ktnew file+287
@@ -0,0 +1,287 @@
1+@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
2+
3+package soy.iko.opencode.ui.server
4+
5+import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.ExperimentalCoroutinesApi
7+import kotlinx.coroutines.test.StandardTestDispatcher
8+import kotlinx.coroutines.test.TestScope
9+import kotlinx.coroutines.test.resetMain
10+import kotlinx.coroutines.test.runTest
11+import kotlinx.coroutines.test.setMain
12+import org.junit.After
13+import org.junit.Assert.assertEquals
14+import org.junit.Assert.assertFalse
15+import org.junit.Assert.assertNotNull
16+import org.junit.Assert.assertNull
17+import org.junit.Assert.assertTrue
18+import org.junit.Before
19+import org.junit.Test
20+import soy.iko.opencode.data.model.ServerProfile
21+import soy.iko.opencode.ui.testing.FakeAppContainer
22+import soy.iko.opencode.ui.testing.FakeOpencodeApiClient
23+import soy.iko.opencode.ui.testing.FakeOpencodeConnection
24+import soy.iko.opencode.ui.testing.FakeEventStreamClient
25+import soy.iko.opencode.ui.testing.FakeSessionRepository
26+
27+class ServerSettingsViewModelTest {
28+ private val testDispatcher = StandardTestDispatcher()
29+ private val testScope = TestScope(testDispatcher)
30+
31+ @Before
32+ fun setUp() {
33+ Dispatchers.setMain(testDispatcher)
34+ }
35+
36+ @After
37+ fun tearDown() {
38+ Dispatchers.resetMain()
39+ }
40+
41+ private fun makeVm(container: FakeAppContainer, profileId: String): ServerSettingsViewModel {
42+ val vm = ServerSettingsViewModel(container, profileId)
43+ testScope.testScheduler.advanceUntilIdle()
44+ return vm
45+ }
46+
47+ private fun profile(
48+ id: String = "p1",
49+ label: String = "My Server",
50+ baseUrl: String = "http://localhost:3000",
51+ username: String? = null,
52+ password: String? = null,
53+ requireHttps: Boolean = false,
54+ certPin: String? = null,
55+ ): ServerProfile = ServerProfile(id, label, baseUrl, username, password, requireHttps = requireHttps, certPin = certPin)
56+
57+ // --- init: load existing profile ---
58+
59+ @Test
60+ fun init_loadsExistingProfileFields() = testScope.runTest {
61+ val container = FakeAppContainer()
62+ container.fakeProfileStore.setProfiles(listOf(profile(label = "My Server", baseUrl = "http://localhost:3000")))
63+ val vm = makeVm(container, "p1")
64+ assertTrue(vm.state.value.loaded)
65+ assertEquals("p1", vm.state.value.id)
66+ assertEquals("My Server", vm.state.value.label)
67+ assertEquals("http://localhost:3000", vm.state.value.baseUrl)
68+ assertFalse(vm.state.value.isDirty)
69+ }
70+
71+ @Test
72+ fun init_authedProfile_showsAuthFields() = testScope.runTest {
73+ val container = FakeAppContainer()
74+ container.fakeProfileStore.setProfiles(listOf(profile(username = "admin", password = "secret")))
75+ val vm = makeVm(container, "p1")
76+ assertTrue(vm.state.value.authFieldsVisible)
77+ assertEquals("admin", vm.state.value.username)
78+ assertEquals("secret", vm.state.value.password)
79+ }
80+
81+ @Test
82+ fun init_nonExistentProfile_showsError() = testScope.runTest {
83+ val container = FakeAppContainer()
84+ container.fakeProfileStore.setProfiles(emptyList())
85+ val vm = makeVm(container, "nonexistent")
86+ assertTrue(vm.state.value.loaded)
87+ assertNotNull(vm.state.value.error)
88+ }
89+
90+ @Test
91+ fun init_securitySettingsLoaded() = testScope.runTest {
92+ val container = FakeAppContainer()
93+ container.fakeProfileStore.setProfiles(
94+ listOf(profile(requireHttps = true, certPin = "sha256/abcdefg=")),
95+ )
96+ val vm = makeVm(container, "p1")
97+ assertTrue(vm.state.value.requireHttps)
98+ assertEquals("sha256/abcdefg=", vm.state.value.certPin)
99+ }
100+
101+ // --- update / dirty detection ---
102+
103+ @Test
104+ fun update_marksDirty() = testScope.runTest {
105+ val container = FakeAppContainer()
106+ container.fakeProfileStore.setProfiles(listOf(profile(label = "Old")))
107+ val vm = makeVm(container, "p1")
108+ assertFalse(vm.state.value.isDirty)
109+ vm.update { it.copy(label = "New") }
110+ assertTrue(vm.state.value.isDirty)
111+ }
112+
113+ @Test
114+ fun update_clearsError() = testScope.runTest {
115+ val container = FakeAppContainer()
116+ container.fakeProfileStore.setProfiles(emptyList())
117+ val vm = makeVm(container, "nonexistent")
118+ assertNotNull(vm.state.value.error)
119+ vm.update { it.copy(label = "x") }
120+ assertNull(vm.state.value.error)
121+ }
122+
123+ // --- save() ---
124+
125+ @Test
126+ fun save_persistsAndCallsOnDone() = testScope.runTest {
127+ val container = FakeAppContainer()
128+ container.fakeProfileStore.setProfiles(listOf(profile(label = "Old")))
129+ val vm = makeVm(container, "p1")
130+ vm.update { it.copy(label = "New") }
131+ var doneCalled = false
132+ vm.save { doneCalled = true }
133+ testScheduler.advanceUntilIdle()
134+ assertTrue(doneCalled)
135+ assertEquals("New", container.fakeProfileStore.savedProfile?.label)
136+ }
137+
138+ @Test
139+ fun save_derivesLabelFromHostnameWhenBlank() = testScope.runTest {
140+ val container = FakeAppContainer()
141+ container.fakeProfileStore.setProfiles(listOf(profile(label = "Old", baseUrl = "http://localhost:3000")))
142+ val vm = makeVm(container, "p1")
143+ vm.update { it.copy(label = "") }
144+ vm.save { }
145+ testScheduler.advanceUntilIdle()
146+ assertEquals("localhost:3000", container.fakeProfileStore.savedProfile?.label)
147+ }
148+
149+ @Test
150+ fun save_normalizesBaseUrl() = testScope.runTest {
151+ val container = FakeAppContainer()
152+ container.fakeProfileStore.setProfiles(listOf(profile(baseUrl = "http://old")))
153+ val vm = makeVm(container, "p1")
154+ vm.update { it.copy(baseUrl = "localhost:4096") }
155+ vm.save { }
156+ testScheduler.advanceUntilIdle()
157+ assertEquals("https://localhost:4096", container.fakeProfileStore.savedProfile?.baseUrl)
158+ }
159+
160+ @Test
161+ fun save_preservesIdAndLastUsed() = testScope.runTest {
162+ val container = FakeAppContainer()
163+ val original = profile(id = "p1", baseUrl = "http://old").copy(lastUsed = 12345L)
164+ container.fakeProfileStore.setProfiles(listOf(original))
165+ val vm = makeVm(container, "p1")
166+ vm.update { it.copy(baseUrl = "http://new") }
167+ vm.save { }
168+ testScheduler.advanceUntilIdle()
169+ assertEquals("p1", container.fakeProfileStore.savedProfile?.id)
170+ assertEquals(12345L, container.fakeProfileStore.savedProfile?.lastUsed)
171+ }
172+
173+ @Test
174+ fun save_reconnectsWhenActiveProfile() = testScope.runTest {
175+ val container = FakeAppContainer()
176+ container.fakeProfileStore.setProfiles(listOf(profile(id = "p1")))
177+ val vm = makeVm(container, "p1")
178+ // Make p1 the active connection.
179+ val api = FakeOpencodeApiClient()
180+ val events = FakeEventStreamClient()
181+ val conn = FakeOpencodeConnection(
182+ api, events, FakeSessionRepository(api, events), profile(id = "p1"),
183+ )
184+ container.setActiveConnection(conn)
185+ vm.update { it.copy(label = "Renamed") }
186+ vm.save { }
187+ testScheduler.advanceUntilIdle()
188+ // Saving an active profile reconnects (connect is called again with the saved profile).
189+ assertEquals(1, container.connectCalls.size)
190+ assertEquals("p1", container.connectCalls[0].id)
191+ }
192+
193+ @Test
194+ fun save_doesNotReconnectWhenNotActive() = testScope.runTest {
195+ val container = FakeAppContainer()
196+ container.fakeProfileStore.setProfiles(listOf(profile(id = "p1")))
197+ val vm = makeVm(container, "p1")
198+ // No active connection set.
199+ vm.update { it.copy(label = "Renamed") }
200+ vm.save { }
201+ testScheduler.advanceUntilIdle()
202+ assertEquals(0, container.connectCalls.size)
203+ assertNotNull(container.fakeProfileStore.savedProfile)
204+ }
205+
206+ @Test
207+ fun save_failureSetsErrorAndClearsSaving() = testScope.runTest {
208+ val container = FakeAppContainer()
209+ container.fakeProfileStore.setProfiles(listOf(profile()))
210+ container.fakeProfileStore.saveException = RuntimeException("disk full")
211+ val vm = makeVm(container, "p1")
212+ vm.update { it.copy(label = "New") }
213+ var doneCalled = false
214+ vm.save { doneCalled = true }
215+ testScheduler.advanceUntilIdle()
216+ assertFalse(doneCalled)
217+ assertFalse(vm.state.value.saving)
218+ assertNotNull(vm.state.value.error)
219+ assertTrue(vm.state.value.error!!.contains("disk full"))
220+ }
221+
222+ @Test
223+ fun save_whileSavingDoesNotSaveAgain() = testScope.runTest {
224+ val container = FakeAppContainer()
225+ container.fakeProfileStore.setProfiles(listOf(profile()))
226+ val vm = makeVm(container, "p1")
227+ vm.update { it.copy(label = "New") }
228+ var doneCount = 0
229+ vm.save { doneCount++ }
230+ vm.save { doneCount++ }
231+ testScheduler.advanceUntilIdle()
232+ assertEquals(1, doneCount)
233+ }
234+
235+ @Test
236+ fun save_invalidUrlDoesNotSave() = testScope.runTest {
237+ val container = FakeAppContainer()
238+ container.fakeProfileStore.setProfiles(listOf(profile(baseUrl = "http://localhost:3000")))
239+ val vm = makeVm(container, "p1")
240+ vm.update { it.copy(baseUrl = "not a url") }
241+ var doneCalled = false
242+ vm.save { doneCalled = true }
243+ testScheduler.advanceUntilIdle()
244+ assertFalse(doneCalled)
245+ // savedProfile stays null because save() never reached profileStore.save().
246+ assertNull(container.fakeProfileStore.savedProfile)
247+ }
248+
249+ // --- delete() ---
250+
251+ @Test
252+ fun delete_removesProfileAndCallsOnDone() = testScope.runTest {
253+ val container = FakeAppContainer()
254+ container.fakeProfileStore.setProfiles(listOf(profile(id = "p1")))
255+ val vm = makeVm(container, "p1")
256+ var doneCalled = false
257+ vm.delete { doneCalled = true }
258+ testScheduler.advanceUntilIdle()
259+ assertTrue(doneCalled)
260+ }
261+
262+ @Test
263+ fun delete_activeProfile_disconnects() = testScope.runTest {
264+ val container = FakeAppContainer()
265+ container.fakeProfileStore.setProfiles(listOf(profile(id = "p1")))
266+ val vm = makeVm(container, "p1")
267+ val api = FakeOpencodeApiClient()
268+ val events = FakeEventStreamClient()
269+ val conn = FakeOpencodeConnection(
270+ api, events, FakeSessionRepository(api, events), profile(id = "p1"),
271+ )
272+ container.setActiveConnection(conn)
273+ vm.delete { }
274+ testScheduler.advanceUntilIdle()
275+ assertEquals(1, container.disconnectCalls)
276+ }
277+
278+ @Test
279+ fun delete_nonActiveProfile_doesNotDisconnect() = testScope.runTest {
280+ val container = FakeAppContainer()
281+ container.fakeProfileStore.setProfiles(listOf(profile(id = "p1")))
282+ val vm = makeVm(container, "p1")
283+ vm.delete { }
284+ testScheduler.advanceUntilIdle()
285+ assertEquals(0, container.disconnectCalls)
286+ }
287+}