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
3750b1biko
app/detekt-baseline.xml+1−1
@@ -16,7 +16,7 @@
16 <ID>CyclomaticComplexMethod:GlobalSearchScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun GlobalSearchScreen( container: AppContainer, onOpenSession: (String, String?) -&gt; Unit, onBack: () -&gt; Unit, )</ID>
16 <ID>CyclomaticComplexMethod:GlobalSearchScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun GlobalSearchScreen( container: AppContainer, onOpenSession: (String, String?) -&gt; Unit, onBack: () -&gt; Unit, )</ID>
17 <ID>CyclomaticComplexMethod:MainActivity.kt$MainActivity$private fun handleIntent(intent: Intent?)</ID>
17 <ID>CyclomaticComplexMethod:MainActivity.kt$MainActivity$private fun handleIntent(intent: Intent?)</ID>
18 <ID>CyclomaticComplexMethod:OpencodeApp.kt$@Composable fun OpencodeApp(container: AppContainer)</ID>
18 <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>
20 <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>
20 <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>
21 <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>
21 <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>
22 <ID>LargeClass:AppContainer.kt$AppContainer</ID>
22 <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
51import soy.iko.opencode.ui.file.TwoPaneFileBrowser
51import soy.iko.opencode.ui.file.TwoPaneFileBrowser
52import soy.iko.opencode.ui.server.ServerEditScreen
52import soy.iko.opencode.ui.server.ServerEditScreen
53import soy.iko.opencode.ui.server.ServerListScreen
53import soy.iko.opencode.ui.server.ServerListScreen
54+import soy.iko.opencode.ui.server.ServerSettingsScreen
54import soy.iko.opencode.ui.search.GlobalSearchScreen
55import soy.iko.opencode.ui.search.GlobalSearchScreen
55import androidx.compose.ui.platform.LocalHapticFeedback
56import androidx.compose.ui.platform.LocalHapticFeedback
56import soy.iko.opencode.ui.components.LargeScreenNavRail
57import soy.iko.opencode.ui.components.LargeScreenNavRail
@@ -390,23 +391,25 @@ fun OpencodeApp(container: AppContainer) {
390 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
391 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
391 }
392 }
392 },
393 },
393− onAddProfile = { navController.navigate(Routes.serverEdit()) },
394+ onAddProfile = { navController.navigate(Routes.SERVER_EDIT) },
394− onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
395+ onServerSettings = { id -> navController.navigate(Routes.serverSettings(id)) },
395− onDuplicateProfile = { id -> navController.navigate(Routes.serverEditDuplicate(id)) },
396+ )
397+ }
398+
399+ composable(Routes.SERVER_EDIT) {
400+ ServerEditScreen(
401+ container = container,
402+ onDone = { navController.popBackStack() },
396 )
403 )
397 }
404 }
398
405
399 composable(
406 composable(
400− route = "${Routes.SERVER_EDIT}?id={id}&dup={dup}",
407+ route = "${Routes.SERVER_SETTINGS}/{id}",
401− arguments = listOf(
402− navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null },
408+ arguments = listOf(navArgument("id") { type = NavType.StringType }),
403− navArgument("dup") { type = NavType.StringType; nullable = true; defaultValue = null },
404− ),
405 ) { entry ->
409 ) { entry ->
406− ServerEditScreen(
410+ ServerSettingsScreen(
407 container = container,
411 container = container,
408− profileId = entry.arguments?.getString("id"),
412+ profileId = entry.arguments?.getString("id").orEmpty(),
409− sourceId = entry.arguments?.getString("dup"),
410 onDone = { navController.popBackStack() },
413 onDone = { navController.popBackStack() },
411 )
414 )
412 }
415 }
@@ -423,8 +426,8 @@ fun OpencodeApp(container: AppContainer) {
423 scope.launch { runCatchingCancellable { container.disconnect() } }
426 scope.launch { runCatchingCancellable { container.disconnect() } }
424 navController.popBackStack(Routes.SERVERS, inclusive = false)
427 navController.popBackStack(Routes.SERVERS, inclusive = false)
425 },
428 },
426− onAddServer = { navController.navigate(Routes.serverEdit()) },
429+ onAddServer = { navController.navigate(Routes.SERVER_EDIT) },
427− onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
430+ onEditProfile = { id -> navController.navigate(Routes.serverSettings(id)) },
428 )
431 )
429 } else {
432 } else {
430 SessionListScreen(
433 SessionListScreen(
@@ -446,8 +449,8 @@ fun OpencodeApp(container: AppContainer) {
446 onOpenFiles = { navController.navigate(Routes.FILES) },
449 onOpenFiles = { navController.navigate(Routes.FILES) },
447 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
450 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
448 onOpenSearch = { navController.navigate(Routes.SEARCH) },
451 onOpenSearch = { navController.navigate(Routes.SEARCH) },
449− onAddServer = { navController.navigate(Routes.serverEdit()) },
452+ onAddServer = { navController.navigate(Routes.SERVER_EDIT) },
450− onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
453+ onEditProfile = { id -> navController.navigate(Routes.serverSettings(id)) },
451 )
454 )
452 }
455 }
453 }
456 }
@@ -473,7 +476,7 @@ fun OpencodeApp(container: AppContainer) {
473 launchSingleTop = true
476 launchSingleTop = true
474 }
477 }
475 },
478 },
476− onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
479+ onEditProfile = { id -> navController.navigate(Routes.serverSettings(id)) },
477 )
480 )
478 }
481 }
479
482
@@ -528,7 +531,7 @@ fun OpencodeApp(container: AppContainer) {
528 onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
531 onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
529 onOpenUsage = { navController.navigate(Routes.USAGE) },
532 onOpenUsage = { navController.navigate(Routes.USAGE) },
530 onOpenMcp = { navController.navigate(Routes.MCP) },
533 onOpenMcp = { navController.navigate(Routes.MCP) },
531− onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
534+ onEditProfile = { id -> navController.navigate(Routes.serverSettings(id)) },
532 )
535 )
533 }
536 }
534
537
app/src/main/java/soy/iko/opencode/ui/Routes.kt+2−5
@@ -12,11 +12,8 @@ object Routes {
12 const val SEARCH = "search"
12 const val SEARCH = "search"
13
13
14 const val SERVER_EDIT = "server_edit"
14 const val SERVER_EDIT = "server_edit"
15− fun serverEdit(id: String? = null) =
15+ const val SERVER_SETTINGS = "server_settings"
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)}"
16+ fun serverSettings(id: String) = "$SERVER_SETTINGS/${android.net.Uri.encode(id)}"
20
17
21 const val CHAT = "chat"
18 const val CHAT = "chat"
22 fun chat(sessionId: String, focusMessageId: String? = null): String {
19 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
5import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Column
8−import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.fillMaxSize
8import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxWidth
9import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.imePadding
10import androidx.compose.foundation.layout.imePadding
@@ -19,9 +18,6 @@ import androidx.compose.foundation.text.KeyboardOptions
19import androidx.compose.foundation.verticalScroll
18import androidx.compose.foundation.verticalScroll
20import androidx.compose.material.icons.Icons
19import androidx.compose.material.icons.Icons
21import androidx.compose.material.icons.automirrored.filled.ArrowBack
20import 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
25import androidx.compose.material.icons.filled.Visibility
21import androidx.compose.material.icons.filled.Visibility
26import androidx.compose.material.icons.filled.VisibilityOff
22import androidx.compose.material.icons.filled.VisibilityOff
27import androidx.compose.material3.AlertDialog
23import androidx.compose.material3.AlertDialog
@@ -34,7 +30,6 @@ import androidx.compose.material3.MaterialTheme
34import androidx.compose.material3.OutlinedButton
30import androidx.compose.material3.OutlinedButton
35import androidx.compose.material3.OutlinedTextField
31import androidx.compose.material3.OutlinedTextField
36import androidx.compose.material3.Scaffold
32import androidx.compose.material3.Scaffold
37−import androidx.compose.material3.Switch
38import androidx.compose.material3.Text
33import androidx.compose.material3.Text
39import androidx.compose.material3.TextButton
34import androidx.compose.material3.TextButton
40import androidx.compose.material3.TopAppBar
35import androidx.compose.material3.TopAppBar
@@ -55,7 +50,6 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
55import androidx.compose.ui.platform.testTag
50import androidx.compose.ui.platform.testTag
56import androidx.compose.ui.semantics.contentDescription
51import androidx.compose.ui.semantics.contentDescription
57import androidx.compose.ui.semantics.semantics
52import androidx.compose.ui.semantics.semantics
58−import androidx.compose.ui.semantics.stateDescription
59import androidx.compose.ui.text.input.ImeAction
53import androidx.compose.ui.text.input.ImeAction
60import androidx.compose.ui.text.input.KeyboardType
54import androidx.compose.ui.text.input.KeyboardType
61import androidx.compose.ui.text.input.PasswordVisualTransformation
55import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -74,12 +68,10 @@ import soy.iko.opencode.ui.vmFactory
74@Composable
68@Composable
75fun ServerEditScreen(
69fun ServerEditScreen(
76 container: AppContainer,
70 container: AppContainer,
77− profileId: String?,
78 onDone: () -> Unit,
71 onDone: () -> Unit,
79− sourceId: String? = null,
80) {
72) {
81 val vm: ServerEditViewModel =
73 val vm: ServerEditViewModel =
82− viewModel(factory = vmFactory { ServerEditViewModel(container, profileId, sourceId) })
74+ viewModel(factory = vmFactory { ServerEditViewModel(container) })
83 val state by vm.state.collectAsStateWithLifecycle()
75 val state by vm.state.collectAsStateWithLifecycle()
84 var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
76 var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
85 var passwordVisible by rememberSaveable { mutableStateOf(false) }
77 var passwordVisible by rememberSaveable { mutableStateOf(false) }
@@ -95,59 +87,20 @@ fun ServerEditScreen(
95 }
87 }
96 }
88 }
97
89
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.
102 BackHandler(enabled = (state.isDirty || state.saving) && !showDiscardConfirm) {
90 BackHandler(enabled = (state.isDirty || state.saving) && !showDiscardConfirm) {
103 if (!state.saving) showDiscardConfirm = true
91 if (!state.saving) showDiscardConfirm = true
104 }
92 }
105
93
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).
109 val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
94 val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
110 Scaffold(
95 Scaffold(
111 topBar = {
96 topBar = {
112 TopAppBar(
97 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)) },
114 navigationIcon = {
99 navigationIcon = {
115 IconButton(onClick = ::safeExit) {
100 IconButton(onClick = ::safeExit) {
116 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
101 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
117 }
102 }
118 },
103 },
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− },
151 scrollBehavior = scrollBehavior,
104 scrollBehavior = scrollBehavior,
152 )
105 )
153 },
106 },
@@ -200,19 +153,14 @@ private fun ServerEditForm(
200 onDone: () -> Unit,
153 onDone: () -> Unit,
201) {
154) {
202 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
155 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.
206 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
156 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.
209 val urlValid = remember(state.baseUrl) { isValidUrl(state.baseUrl) }
157 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.
212 val baseUrlFocus = remember { FocusRequester() }
158 val baseUrlFocus = remember { FocusRequester() }
213 val keyboard = LocalSoftwareKeyboardController.current
159 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.
214 LaunchedEffect(Unit) {
162 LaunchedEffect(Unit) {
215− if (state.isNew && state.baseUrl.isBlank()) {
163+ if (state.baseUrl.isBlank()) {
216 baseUrlFocus.requestFocus()
164 baseUrlFocus.requestFocus()
217 keyboard?.show()
165 keyboard?.show()
218 }
166 }
@@ -293,17 +241,6 @@ private fun ServerEditForm(
293 modifier = Modifier.fillMaxWidth(),
241 modifier = Modifier.fillMaxWidth(),
294 )
242 )
295 }
243 }
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)
307 }
244 }
308}
245}
309
246
@@ -398,78 +335,3 @@ private fun AuthFields(
398 }
335 }
399 }
336 }
400}
337}
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 {
115 return "https://$trimmed".toHttpUrlOrNull() != null
115 return "https://$trimmed".toHttpUrlOrNull() != null
116}
116}
117
117
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+
118class ServerEditViewModel(
130class ServerEditViewModel(
119 private val container: AppContainer,
131 private val container: AppContainer,
120− private val profileId: String?,
121− private val sourceId: String? = null,
122) : ViewModel() {
132) : ViewModel() {
123
133
124− private val _state = MutableStateFlow(ServerEditState(id = profileId))
134+ private val _state = MutableStateFlow(ServerEditState())
125 val state: StateFlow<ServerEditState> = _state.asStateFlow()
135 val state: StateFlow<ServerEditState> = _state.asStateFlow()
126
136
127 init {
137 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.
128 viewModelScope.launch {
141 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.
208 _state.update {
142 _state.update {
209 it.copy(
143 it.copy(
210 loaded = true,
144 loaded = true,
@@ -326,32 +260,6 @@ class ServerEditViewModel(
326 }
260 }
327 }
261 }
328
262
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−
355 private fun saveInternal(connectAfter: Boolean, onDone: () -> Unit) {
263 private fun saveInternal(connectAfter: Boolean, onDone: () -> Unit) {
356 val s = _state.value
264 val s = _state.value
357 if (!s.canSave || s.saving) return
265 if (!s.canSave || s.saving) return
@@ -369,7 +277,11 @@ class ServerEditViewModel(
369 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
277 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
370 container.profileStore.profiles.first()
278 container.profileStore.profiles.first()
371 } ?: emptyList()
279 } ?: emptyList()
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) }
372− val duplicate = existing.any { it.id != s.id && it.baseUrl == savedBaseUrl && it.label == s.label.trim() }
284+ val duplicate = existing.any { it.id != s.id && it.baseUrl == savedBaseUrl && it.label == savedLabel }
373 if (duplicate) {
285 if (duplicate) {
374 throw java.io.IOException(container.string(R.string.duplicate_server_warning))
286 throw java.io.IOException(container.string(R.string.duplicate_server_warning))
375 }
287 }
@@ -385,7 +297,7 @@ class ServerEditViewModel(
385 } else 0L
297 } else 0L
386 val saved = ServerProfile(
298 val saved = ServerProfile(
387 id = s.id ?: UUID.randomUUID().toString(),
299 id = s.id ?: UUID.randomUUID().toString(),
388− label = s.label.trim(),
300+ label = savedLabel,
389 baseUrl = savedBaseUrl,
301 baseUrl = savedBaseUrl,
390 username = s.username.trim().takeIf { it.isNotBlank() },
302 username = s.username.trim().takeIf { it.isNotBlank() },
391 password = s.password.trim().takeIf { it.isNotEmpty() },
303 password = s.password.trim().takeIf { it.isNotEmpty() },
@@ -405,6 +317,7 @@ class ServerEditViewModel(
405 _state.update {
317 _state.update {
406 it.copy(
318 it.copy(
407 id = saved.id,
319 id = saved.id,
320+ label = saved.label,
408 baseUrl = saved.baseUrl,
321 baseUrl = saved.baseUrl,
409 initial = InitialProfile(
322 initial = InitialProfile(
410 label = saved.label,
323 label = saved.label,
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+7−16
@@ -95,8 +95,7 @@ fun ServerListScreen(
95 container: AppContainer,
95 container: AppContainer,
96 onConnected: () -> Unit,
96 onConnected: () -> Unit,
97 onAddProfile: () -> Unit,
97 onAddProfile: () -> Unit,
98− onEditProfile: (String) -> Unit,
98+ onServerSettings: (String) -> Unit,
99− onDuplicateProfile: (String) -> Unit,
100) {
99) {
101 val vm: ServerListViewModel = viewModel(factory = vmFactory { ServerListViewModel(container) })
100 val vm: ServerListViewModel = viewModel(factory = vmFactory { ServerListViewModel(container) })
102 val profiles by vm.profiles.collectAsStateWithLifecycle()
101 val profiles by vm.profiles.collectAsStateWithLifecycle()
@@ -389,8 +388,7 @@ fun ServerListScreen(
389 isActive = true,
388 isActive = true,
390 isConnecting = false,
389 isConnecting = false,
391 activeState = activeSseState,
390 activeState = activeSseState,
392− onEdit = { onEditProfile(profile.id) },
391+ onSettings = { onServerSettings(profile.id) },
393− onDuplicate = { onDuplicateProfile(profile.id) },
394 onPendingDelete = { pendingDeleteId = profile.id },
392 onPendingDelete = { pendingDeleteId = profile.id },
395 onTestConnection = { vm.testConnection(profile) },
393 onTestConnection = { vm.testConnection(profile) },
396 isProbing = probingId == profile.id,
394 isProbing = probingId == profile.id,
@@ -442,8 +440,7 @@ fun ServerListScreen(
442 profile = profile,
440 profile = profile,
443 isActive = false,
441 isActive = false,
444 isConnecting = connectingId == profile.id,
442 isConnecting = connectingId == profile.id,
445− onEdit = { onEditProfile(profile.id) },
443+ onSettings = { onServerSettings(profile.id) },
446− onDuplicate = { onDuplicateProfile(profile.id) },
447 onPendingDelete = { pendingDeleteId = profile.id },
444 onPendingDelete = { pendingDeleteId = profile.id },
448 onTestConnection = { vm.testConnection(profile) },
445 onTestConnection = { vm.testConnection(profile) },
449 isProbing = probingId == profile.id,
446 isProbing = probingId == profile.id,
@@ -495,8 +492,7 @@ private fun ServerCardContent(
495 profile: ServerProfile,
492 profile: ServerProfile,
496 isActive: Boolean,
493 isActive: Boolean,
497 isConnecting: Boolean,
494 isConnecting: Boolean,
498− onEdit: () -> Unit,
495+ onSettings: () -> Unit,
499− onDuplicate: () -> Unit,
500 onPendingDelete: () -> Unit,
496 onPendingDelete: () -> Unit,
501 onTestConnection: () -> Unit = {},
497 onTestConnection: () -> Unit = {},
502 isProbing: Boolean = false,
498 isProbing: Boolean = false,
@@ -597,8 +593,7 @@ private fun ServerCardContent(
597 // Close this row's overflow on back press instead of navigating away, matching
593 // Close this row's overflow on back press instead of navigating away, matching
598 // the sort menu BackHandler above.
594 // the sort menu BackHandler above.
599 BackHandler(enabled = showRowMenu) { showRowMenu = false }
595 BackHandler(enabled = showRowMenu) { showRowMenu = false }
600− val editLabel = stringResource(R.string.edit)
596+ val settingsLabel = stringResource(R.string.settings)
601− val duplicateLabel = stringResource(R.string.duplicate_server)
602 val removeLabel = stringResource(R.string.remove)
597 val removeLabel = stringResource(R.string.remove)
603 val testConnLabel = stringResource(R.string.test_connection)
598 val testConnLabel = stringResource(R.string.test_connection)
604 val testConnDesc = stringResource(R.string.test_connection_desc, profile.displayLabel)
599 val testConnDesc = stringResource(R.string.test_connection_desc, profile.displayLabel)
@@ -621,12 +616,8 @@ private fun ServerCardContent(
621 onDismissRequest = { showRowMenu = false },
616 onDismissRequest = { showRowMenu = false },
622 ) {
617 ) {
623 DropdownMenuItem(
618 DropdownMenuItem(
624− text = { Text(editLabel) },
619+ text = { Text(settingsLabel) },
625− onClick = { showRowMenu = false; onEdit() },
626− )
627− DropdownMenuItem(
628− text = { Text(duplicateLabel) },
629− onClick = { showRowMenu = false; onDuplicate() },
620+ onClick = { showRowMenu = false; onSettings() },
630 )
621 )
631 DropdownMenuItem(
622 DropdownMenuItem(
632 text = { Text(testConnLabel) },
623 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 @@
69 <string name="servers_title">Servidores de opencode</string>
69 <string name="servers_title">Servidores de opencode</string>
70 <string name="sessions_title">Sesiones</string>
70 <string name="sessions_title">Sesiones</string>
71 <string name="add_server">Añadir servidor</string>
71 <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>
73 <string name="connected">Conectado</string>
74 <string name="connected">Conectado</string>
74 <string name="no_servers_yet">Aún no hay servidores</string>
75 <string name="no_servers_yet">Aún no hay servidores</string>
75 <string name="no_servers_hint">Añade un endpoint de opencode serve para conectar.</string>
76 <string name="no_servers_hint">Añade un endpoint de opencode serve para conectar.</string>
@@ -334,7 +335,6 @@
334 <string name="sort_title">Título</string>
335 <string name="sort_title">Título</string>
335 <string name="last_used_never">Sin usar aún</string>
336 <string name="last_used_never">Sin usar aún</string>
336 <string name="last_used">Usado por última vez %1$s</string>
337 <string name="last_used">Usado por última vez %1$s</string>
337− <string name="duplicate_server">Duplicar</string>
338 <string name="find_in_file">Buscar en el archivo</string>
338 <string name="find_in_file">Buscar en el archivo</string>
339 <string name="find_previous">Buscar anterior</string>
339 <string name="find_previous">Buscar anterior</string>
340 <string name="find_next">Buscar siguiente</string>
340 <string name="find_next">Buscar siguiente</string>
app/src/main/res/values/strings.xml+2−2
@@ -70,7 +70,8 @@
70 <string name="servers_title">opencode servers</string>
70 <string name="servers_title">opencode servers</string>
71 <string name="sessions_title">Sessions</string>
71 <string name="sessions_title">Sessions</string>
72 <string name="add_server">Add server</string>
72 <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>
74 <string name="connected">Connected</string>
75 <string name="connected">Connected</string>
75 <string name="no_servers_yet">No servers yet</string>
76 <string name="no_servers_yet">No servers yet</string>
76 <string name="no_servers_hint">Add an opencode serve endpoint to connect.</string>
77 <string name="no_servers_hint">Add an opencode serve endpoint to connect.</string>
@@ -336,7 +337,6 @@
336 <string name="sort_name">Name</string>
337 <string name="sort_name">Name</string>
337 <string name="last_used_never">Not used yet</string>
338 <string name="last_used_never">Not used yet</string>
338 <string name="last_used">Last used %1$s</string>
339 <string name="last_used">Last used %1$s</string>
339− <string name="duplicate_server">Duplicate</string>
340 <string name="find_in_file">Find in file</string>
340 <string name="find_in_file">Find in file</string>
341 <string name="find_previous">Find previous</string>
341 <string name="find_previous">Find previous</string>
342 <string name="find_next">Find next</string>
342 <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 {
35 Dispatchers.resetMain()
35 Dispatchers.resetMain()
36 }
36 }
37
37
38− private fun makeVm(container: FakeAppContainer, profileId: String? = null): ServerEditViewModel {
38+ private fun makeVm(container: FakeAppContainer): ServerEditViewModel {
39− val vm = ServerEditViewModel(container, profileId)
39+ val vm = ServerEditViewModel(container)
40 testScope.testScheduler.advanceUntilIdle()
40 testScope.testScheduler.advanceUntilIdle()
41 return vm
41 return vm
42 }
42 }
@@ -46,7 +46,7 @@ class ServerEditViewModelTest {
46 @Test
46 @Test
47 fun init_newProfile_loadsEmptyForm() = testScope.runTest {
47 fun init_newProfile_loadsEmptyForm() = testScope.runTest {
48 val container = FakeAppContainer()
48 val container = FakeAppContainer()
49− val vm = makeVm(container, profileId = null)
49+ val vm = makeVm(container)
50 assertTrue(vm.state.value.loaded)
50 assertTrue(vm.state.value.loaded)
51 assertTrue(vm.state.value.isNew)
51 assertTrue(vm.state.value.isNew)
52 assertEquals("", vm.state.value.baseUrl)
52 assertEquals("", vm.state.value.baseUrl)
@@ -55,38 +55,6 @@ class ServerEditViewModelTest {
55 assertNull(vm.state.value.error)
55 assertNull(vm.state.value.error)
56 }
56 }
57
57
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−
90 // --- update() ---
58 // --- update() ---
91
59
92 @Test
60 @Test
@@ -161,50 +129,73 @@ class ServerEditViewModelTest {
161 assertTrue(vm.state.value.canSave)
129 assertTrue(vm.state.value.canSave)
162 }
130 }
163
131
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+
164 // --- connect() save path (probe Reachable → save + connect + ping) ---
144 // --- connect() save path (probe Reachable → save + connect + ping) ---
165− // The former save()-specific tests now exercise the same persist path via connect(),
145+ // The add-server form has no label field, so the saved label is derived from the hostname
166− // since the standalone "Save without connecting" action was removed: connect() is the
167− // single primary action and its probe IS the connection test.
146+ // via hostFromBaseUrl. Connect is the single primary action and its probe IS the
147+ // connection test.
168
148
169 @Test
149 @Test
170 fun connect_persistsProfileAndCallsOnDone() = testScope.runTest {
150 fun connect_persistsProfileAndCallsOnDone() = testScope.runTest {
171 val container = FakeAppContainer()
151 val container = FakeAppContainer()
172 container.probeResult = ProbeResult.Reachable
152 container.probeResult = ProbeResult.Reachable
173 val vm = makeVm(container)
153 val vm = makeVm(container)
174− vm.update { it.copy(baseUrl = "http://localhost:3000", label = "Test") }
154+ vm.update { it.copy(baseUrl = "http://localhost:3000") }
175 var doneCalled = false
155 var doneCalled = false
176 vm.connect { doneCalled = true }
156 vm.connect { doneCalled = true }
177 testScheduler.advanceUntilIdle()
157 testScheduler.advanceUntilIdle()
178 assertTrue(doneCalled)
158 assertTrue(doneCalled)
179 assertNotNull(container.fakeProfileStore.savedProfile)
159 assertNotNull(container.fakeProfileStore.savedProfile)
180 assertEquals("http://localhost:3000", container.fakeProfileStore.savedProfile?.baseUrl)
160 assertEquals("http://localhost:3000", container.fakeProfileStore.savedProfile?.baseUrl)
181− assertEquals("Test", container.fakeProfileStore.savedProfile?.label)
182 }
161 }
183
162
184 @Test
163 @Test
185− fun connect_generatesIdForNewProfile() = testScope.runTest {
164+ fun connect_derivesLabelFromHostnameWhenLabelBlank() = testScope.runTest {
186 val container = FakeAppContainer()
165 val container = FakeAppContainer()
187 container.probeResult = ProbeResult.Reachable
166 container.probeResult = ProbeResult.Reachable
188 val vm = makeVm(container)
167 val vm = makeVm(container)
189− vm.update { it.copy(baseUrl = "http://localhost:3000") }
168+ vm.update { it.copy(baseUrl = "192.168.1.10:4096") }
190 vm.connect { }
169 vm.connect { }
191 testScheduler.advanceUntilIdle()
170 testScheduler.advanceUntilIdle()
192− val saved = container.fakeProfileStore.savedProfile
171+ val saved = container.fakeProfileStore.savedProfile!!
193− assertNotNull(saved?.id)
172+ assertEquals("192.168.1.10:4096", saved.label)
194− assertTrue(saved!!.id.isNotEmpty())
195 }
173 }
196
174
197 @Test
175 @Test
198− fun connect_preservesIdForExistingProfile() = testScope.runTest {
176+ fun connect_preservesExplicitLabelWhenProvided() = testScope.runTest {
199 val container = FakeAppContainer()
177 val container = FakeAppContainer()
200 container.probeResult = ProbeResult.Reachable
178 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")
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.
204− vm.update { it.copy(baseUrl = "http://new") }
182+ vm.update { it.copy(baseUrl = "http://localhost:3000", label = "My Server") }
205 vm.connect { }
183 vm.connect { }
206 testScheduler.advanceUntilIdle()
184 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())
208 }
199 }
209
200
210 @Test
201 @Test
@@ -212,12 +203,11 @@ class ServerEditViewModelTest {
212 val container = FakeAppContainer()
203 val container = FakeAppContainer()
213 container.probeResult = ProbeResult.Reachable
204 container.probeResult = ProbeResult.Reachable
214 val vm = makeVm(container)
205 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 ") }
216 vm.connect { }
207 vm.connect { }
217 testScheduler.advanceUntilIdle()
208 testScheduler.advanceUntilIdle()
218 val saved = container.fakeProfileStore.savedProfile!!
209 val saved = container.fakeProfileStore.savedProfile!!
219 assertEquals("http://localhost:3000", saved.baseUrl)
210 assertEquals("http://localhost:3000", saved.baseUrl)
220− assertEquals("Test", saved.label)
221 assertEquals("admin", saved.username)
211 assertEquals("admin", saved.username)
222 }
212 }
223
213
@@ -226,14 +216,13 @@ class ServerEditViewModelTest {
226 val container = FakeAppContainer()
216 val container = FakeAppContainer()
227 container.probeResult = ProbeResult.Reachable
217 container.probeResult = ProbeResult.Reachable
228 val vm = makeVm(container)
218 val vm = makeVm(container)
229− vm.update { it.copy(baseUrl = "localhost:3000", label = "LAN") }
219+ vm.update { it.copy(baseUrl = "localhost:3000") }
230 vm.connect { }
220 vm.connect { }
231 testScheduler.advanceUntilIdle()
221 testScheduler.advanceUntilIdle()
232 val saved = container.fakeProfileStore.savedProfile!!
222 val saved = container.fakeProfileStore.savedProfile!!
233 // A bare host is always schemed to https:// (the safe default). The user can still
223 // A bare host is always schemed to https:// (the safe default). The user can still
234 // opt into cleartext by typing http:// explicitly.
224 // opt into cleartext by typing http:// explicitly.
235 assertEquals("https://localhost:3000", saved.baseUrl)
225 assertEquals("https://localhost:3000", saved.baseUrl)
236− assertEquals("LAN", saved.label)
237 // The editor's own field is refreshed to the schemed form so a just-saved bare-host
226 // The editor's own field is refreshed to the schemed form so a just-saved bare-host
238 // entry doesn't read as dirty against its own stored value on re-open.
227 // entry doesn't read as dirty against its own stored value on re-open.
239 assertEquals("https://localhost:3000", vm.state.value.baseUrl)
228 assertEquals("https://localhost:3000", vm.state.value.baseUrl)
@@ -433,62 +422,4 @@ class ServerEditViewModelTest {
433 testScheduler.advanceUntilIdle()
422 testScheduler.advanceUntilIdle()
434 assertFalse(vm.state.value.saving)
423 assertFalse(vm.state.value.saving)
435 }
424 }
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− }
494}
425}
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+}