1package soy.iko.opencode.ui.server
3import androidx.activity.compose.BackHandler
4import androidx.compose.animation.AnimatedVisibility
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.Box
8import androidx.compose.foundation.layout.Column
9import androidx.compose.foundation.layout.Row
10import androidx.compose.foundation.layout.fillMaxSize
11import androidx.compose.foundation.layout.fillMaxWidth
12import androidx.compose.foundation.layout.heightIn
13import androidx.compose.foundation.layout.imePadding
14import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.size
16import androidx.compose.foundation.layout.widthIn
17import androidx.compose.foundation.layout.wrapContentWidth
18import androidx.compose.foundation.rememberScrollState
19import androidx.compose.foundation.text.KeyboardActions
20import androidx.compose.foundation.text.KeyboardOptions
21import androidx.compose.foundation.verticalScroll
22import androidx.compose.material.icons.Icons
23import androidx.compose.material.icons.automirrored.filled.ArrowBack
24import androidx.compose.material.icons.filled.ExpandLess
25import androidx.compose.material.icons.filled.ExpandMore
26import androidx.compose.material.icons.filled.Visibility
27import androidx.compose.material.icons.filled.VisibilityOff
28import androidx.compose.material.icons.filled.Wifi
29import androidx.compose.material3.AlertDialog
30import androidx.compose.material3.Button
31import androidx.compose.material3.CircularProgressIndicator
32import androidx.compose.material3.ExperimentalMaterial3Api
33import androidx.compose.material3.Icon
34import androidx.compose.material3.IconButton
35import androidx.compose.material3.MaterialTheme
36import androidx.compose.material3.OutlinedButton
37import androidx.compose.material3.OutlinedTextField
38import androidx.compose.material3.Scaffold
39import androidx.compose.material3.Switch
40import androidx.compose.material3.Text
41import androidx.compose.material3.TextButton
42import androidx.compose.material3.TopAppBar
43import androidx.compose.runtime.Composable
44import androidx.compose.runtime.LaunchedEffect
45import androidx.compose.runtime.getValue
46import androidx.compose.runtime.mutableStateOf
47import androidx.compose.runtime.remember
48import androidx.compose.runtime.saveable.rememberSaveable
49import androidx.compose.runtime.setValue
50import androidx.compose.ui.Alignment
51import androidx.compose.ui.Modifier
52import androidx.compose.ui.focus.FocusRequester
53import androidx.compose.ui.focus.focusRequester
54import androidx.compose.ui.platform.LocalContext
55import androidx.compose.ui.platform.LocalSoftwareKeyboardController
56import androidx.compose.ui.platform.testTag
57import androidx.compose.ui.semantics.Role
58import androidx.compose.ui.semantics.contentDescription
59import androidx.compose.ui.semantics.role
60import androidx.compose.ui.semantics.semantics
61import androidx.compose.ui.semantics.stateDescription
62import androidx.compose.ui.text.input.ImeAction
63import androidx.compose.ui.text.input.KeyboardType
64import androidx.compose.ui.text.input.PasswordVisualTransformation
65import androidx.compose.ui.text.input.VisualTransformation
66import androidx.compose.ui.res.stringResource
67import androidx.compose.ui.unit.dp
68import androidx.lifecycle.compose.collectAsStateWithLifecycle
69import androidx.lifecycle.viewmodel.compose.viewModel
70import soy.iko.opencode.data.network.NsdDiscovery
71import soy.iko.opencode.data.network.NetworkConfig
72import soy.iko.opencode.di.AppContainer
73import soy.iko.opencode.R
74import soy.iko.opencode.ui.components.autofillModifier
75import soy.iko.opencode.ui.components.AutofillHint
76import soy.iko.opencode.ui.vmFactory
78@OptIn(ExperimentalMaterial3Api::class)
81 container: AppContainer,
84 sourceId: String? = null,
86 val vm: ServerEditViewModel =
87 viewModel(factory = vmFactory { ServerEditViewModel(container, profileId, sourceId) })
88 val state by vm.state.collectAsStateWithLifecycle()
89 var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
90 var passwordVisible by rememberSaveable { mutableStateOf(false) }
94 // A save is in flight: ignore the back gesture (matching the BackHandler guard
95 // below). Popping here would cancel the save's viewModelScope coroutine and
96 // silently abandon the write; the VM calls onDone itself once the save lands.
98 state.isDirty -> showDiscardConfirm = true
103 // Intercept the system back gesture when the form has unsaved changes so the user
104 // gets a chance to keep editing instead of losing their work silently. Stays enabled
105 // (as a no-op) while a save is in flight so the gesture can't propagate to the NavHost
106 // and cancel the save's viewModelScope coroutine mid-write — matching safeExit()'s guard.
107 BackHandler(enabled = (state.isDirty || state.saving) && !showDiscardConfirm) {
108 if (!state.saving) showDiscardConfirm = true
114 title = { Text(if (state.isNew) stringResource(R.string.add_server) else stringResource(R.string.edit_server)) },
116 IconButton(onClick = ::safeExit) {
117 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
124 val loadingLabel = stringResource(R.string.loading)
125 Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
126 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
131 passwordVisible = passwordVisible,
132 onTogglePassword = { passwordVisible = !passwordVisible },
140 if (showDiscardConfirm) {
142 onDismissRequest = { showDiscardConfirm = false },
143 title = { Text(stringResource(R.string.discard_title)) },
144 text = { Text(stringResource(R.string.discard_text)) },
146 TextButton(onClick = {
147 showDiscardConfirm = false
149 }) { Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) }
152 TextButton(onClick = { showDiscardConfirm = false }) { Text(stringResource(R.string.keep_editing)) }
159private fun ServerEditForm(
160 state: ServerEditState,
161 passwordVisible: Boolean,
162 onTogglePassword: () -> Unit,
163 padding: androidx.compose.foundation.layout.PaddingValues,
164 vm: ServerEditViewModel,
167 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
168 // canSave parses the base URL with OkHttp's parser (which allocates on the common
169 // half-typed URL). Memoize on baseUrl so it runs once per edit instead of being
170 // re-parsed at each of the enabled= call sites on every keystroke's recomposition.
171 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
172 // isValidUrl parses with OkHttp too; memoize on baseUrl so it runs once per edit instead
173 // of re-parsing at each isError/supportingText call site on every keystroke's recomposition.
174 val urlValid = remember(state.baseUrl) { isValidUrl(state.baseUrl) }
175 // Focus the Base URL field (and raise the keyboard) on a fresh Add-server form so the
176 // user can start typing the required field immediately.
177 val baseUrlFocus = remember { FocusRequester() }
178 val keyboard = LocalSoftwareKeyboardController.current
179 LaunchedEffect(Unit) {
180 if (state.isNew && state.baseUrl.isBlank()) {
181 baseUrlFocus.requestFocus()
190 .wrapContentWidth(Alignment.CenterHorizontally)
191 .widthIn(max = 600.dp)
193 .verticalScroll(rememberScrollState()),
194 verticalArrangement = Arrangement.spacedBy(12.dp),
196 // Base URL is the only required field, so it leads the form and takes focus on a
197 // fresh add form (via baseUrlFocus above).
199 value = state.baseUrl,
200 onValueChange = { v ->
201 // A paste of a full QR/JSON config into the URL field populates the whole form
202 // (so a user can paste a shared server config instead of scanning an image).
203 // Falls through to a normal URL edit when it isn't a recognized payload.
204 if (!vm.applyQrPayload(v)) vm.update { it.copy(baseUrl = v) }
206 label = { Text(stringResource(R.string.base_url)) },
207 placeholder = { Text(stringResource(R.string.base_url_hint)) },
209 isError = state.baseUrl.isNotBlank() && !urlValid,
211 if (state.baseUrl.isNotBlank() && !urlValid) {
212 // Offer a one-tap scheme fix for bare host:port input instead of a
213 // generic error, so the user doesn't have to know the URL needs a scheme.
214 // suggestUrlScheme() picks https:// for TLS-shaped hosts (port 443, public
215 // domains) so the quick-fix doesn't nudge users onto cleartext.
216 val suggestion = suggestUrlScheme(state.baseUrl)
217 if (suggestion != null) {
218 val labelRes = if (suggestion.startsWith("https://")) R.string.suggest_scheme_https else R.string.suggest_scheme
219 // Mark the supporting-text fix as a Button so TalkBack announces it as an
220 // actionable control. Without an explicit role, a TextButton inside
221 // supportingText can be missed by screen-reader users who don't scan
222 // the error slot visually.
224 onClick = { vm.update { it.copy(baseUrl = suggestion) } },
225 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
226 modifier = Modifier.semantics {
228 contentDescription = state.baseUrl
231 Text(stringResource(labelRes))
234 Text(stringResource(R.string.invalid_url))
236 } else if (state.baseUrl.isBlank()) {
237 // Surface the QR-paste shortcut so a user with a shared server config knows
238 // they can paste it here instead of scanning an image.
239 Text(stringResource(R.string.qr_paste_hint))
242 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
243 keyboardActions = KeyboardActions(onDone = { keyboard?.hide() }),
246 .focusRequester(baseUrlFocus)
247 .testTag("server_url"),
249 // Auto-expand LAN discovery only on a fresh form (no URL yet) so tapping a found
250 // server is the primary path; on edit the populated URL keeps it (and its multicast)
253 initiallyExpanded = state.baseUrl.isBlank(),
254 onPick = { url -> vm.update { it.copy(baseUrl = url) } },
256 // Auth fields appear only once a connect attempt learns the server requires them
257 // (or an authed profile is loaded) — the common no-auth server never sees them.
258 AnimatedVisibility(visible = state.authFieldsVisible) {
261 passwordVisible = passwordVisible,
262 onTogglePassword = onTogglePassword,
263 onUpdate = vm::update,
264 onTestCredentials = vm::testCredentials,
265 onImeDone = { if (canSave && !state.saving) vm.connect(onDone) },
268 // Single primary action: probe-then-save-and-connect. Replaces the former
269 // Check-connectivity / Save / Save-&-connect trio.
272 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
275 enabled = canSave && !state.saving,
276 modifier = Modifier.fillMaxWidth().testTag("server_connect"),
279 CircularProgressIndicator(
280 modifier = Modifier.size(20.dp),
281 color = MaterialTheme.colorScheme.onPrimary,
284 Text(stringResource(R.string.connecting), modifier = Modifier.padding(start = 8.dp))
286 Text(stringResource(R.string.connect))
289 // Keep the error directly under the primary action so it's in view where the user is
290 // looking when a Connect/Save fails — not buried further down the form.
291 state.error?.let { message ->
294 color = MaterialTheme.colorScheme.error,
295 style = MaterialTheme.typography.bodySmall,
296 modifier = Modifier.fillMaxWidth(),
299 // Secondary, low-emphasis: persist without connecting (offline setup).
302 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
305 enabled = canSave && !state.saving,
306 modifier = Modifier.fillMaxWidth().testTag("server_save"),
308 Text(stringResource(R.string.save_without_connecting))
310 // De-emphasized optional label at the bottom: displayLabel already falls back to the
311 // URL, so most servers don't need one.
314 onValueChange = { v -> vm.update { it.copy(label = v) } },
315 label = { Text(stringResource(R.string.label_optional)) },
317 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
318 modifier = Modifier.fillMaxWidth().testTag("server_label"),
320 AdvancedSection(state = state, onUpdate = vm::update)
325private fun AuthFields(
326 state: ServerEditState,
327 passwordVisible: Boolean,
328 onTogglePassword: () -> Unit,
329 onUpdate: ((ServerEditState) -> ServerEditState) -> Unit,
330 onTestCredentials: () -> Unit,
331 onImeDone: () -> Unit,
333 val showPasswordLabel = stringResource(R.string.show_password)
334 val hidePasswordLabel = stringResource(R.string.hide_password)
335 // Memoize the URI parse on baseUrl so the test-credentials button doesn't re-parse it on
336 // every keystroke's recomposition (see ServerEditForm).
337 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
338 val usernameFocus = remember { FocusRequester() }
339 val passwordFocus = remember { FocusRequester() }
340 // AuthFields only enters composition once the server is known to require credentials
341 // (AnimatedVisibility in ServerEditForm), so a Unit-keyed effect fires once on reveal
342 // and focuses the username — otherwise the user has to tap into it after the probe flips
343 // the auth fields open.
344 LaunchedEffect(Unit) {
345 runCatching { usernameFocus.requestFocus() }
347 Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
349 stringResource(R.string.auth_required),
350 style = MaterialTheme.typography.bodySmall,
351 color = MaterialTheme.colorScheme.error,
354 value = state.username,
355 onValueChange = { v -> onUpdate { it.copy(username = v) } },
356 label = { Text(stringResource(R.string.username_optional)) },
358 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
359 keyboardActions = KeyboardActions(onNext = { passwordFocus.requestFocus() }),
362 .focusRequester(usernameFocus)
363 .then(autofillModifier(AutofillHint.Username) { v -> onUpdate { it.copy(username = v) } })
364 .testTag("server_username"),
367 value = state.password,
368 onValueChange = { v -> onUpdate { it.copy(password = v) } },
369 label = { Text(stringResource(R.string.password_optional)) },
371 visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
373 IconButton(onClick = onTogglePassword) {
375 if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
376 contentDescription = if (passwordVisible) hidePasswordLabel else showPasswordLabel,
380 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
381 keyboardActions = KeyboardActions(onDone = { onImeDone() }),
384 .focusRequester(passwordFocus)
385 .then(autofillModifier(AutofillHint.Password) { v -> onUpdate { it.copy(password = v) } })
386 .testTag("server_password"),
389 onClick = onTestCredentials,
390 // Require at least one credential field: testCredentials() no-ops when both are
391 // blank, so without this the button is tappable but silently does nothing.
392 enabled = canSave && !state.testingCredentials && !state.saving &&
393 (state.username.isNotBlank() || state.password.isNotBlank()),
394 modifier = Modifier.fillMaxWidth().testTag("server_test_creds"),
396 if (state.testingCredentials) {
397 CircularProgressIndicator(
398 modifier = Modifier.size(20.dp),
401 Text(stringResource(R.string.testing_credentials), modifier = Modifier.padding(start = 8.dp))
403 Text(stringResource(R.string.test_credentials))
406 state.credentialsResult?.let { ok ->
408 stringResource(if (ok) R.string.credentials_ok else R.string.credentials_rejected),
409 style = MaterialTheme.typography.bodySmall,
410 color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
417 * Optional TLS hardening, tucked behind a collapsed "Security" expander so the common
418 * localhost/HTTP setup never sees it. Auto-expands when a loaded profile already uses one of
419 * these settings (so an invalid pin can't silently disable Connect from inside a closed
420 * section). Contains: force HTTPS (upgrade cleartext) and pin the server certificate — both
424private fun AdvancedSection(
425 state: ServerEditState,
426 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
428 var expanded by rememberSaveable { mutableStateOf(state.requireHttps || state.certPin.isNotBlank()) }
429 val stateLabel = stringResource(if (expanded) R.string.state_expanded else R.string.state_collapsed)
430 Column(modifier = Modifier.fillMaxWidth()) {
432 onClick = { expanded = !expanded },
433 modifier = Modifier.semantics { stateDescription = stateLabel },
434 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
437 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
438 contentDescription = null,
439 modifier = Modifier.size(18.dp),
441 Text(stringResource(R.string.server_security), modifier = Modifier.padding(start = 8.dp))
443 AnimatedVisibility(visible = expanded) {
444 SecurityFields(state = state, onUpdate = onUpdate)
449/** The actual security controls, rendered inside [AdvancedSection]'s expander. */
451private fun SecurityFields(
452 state: ServerEditState,
453 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
455 Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
456 Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
457 Column(modifier = Modifier.weight(1f)) {
458 Text(stringResource(R.string.require_https), style = MaterialTheme.typography.bodyLarge)
460 stringResource(R.string.require_https_desc),
461 style = MaterialTheme.typography.bodySmall,
462 color = MaterialTheme.colorScheme.onSurfaceVariant,
466 checked = state.requireHttps,
467 onCheckedChange = { v -> onUpdate { it.copy(requireHttps = v) } },
471 value = state.certPin,
472 onValueChange = { v -> onUpdate { it.copy(certPin = v) } },
473 label = { Text(stringResource(R.string.cert_pin)) },
474 placeholder = { Text(stringResource(R.string.cert_pin_hint)) },
476 isError = !state.certPinValid,
479 stringResource(if (state.certPinValid) R.string.cert_pin_desc else R.string.cert_pin_invalid),
480 style = MaterialTheme.typography.bodySmall,
481 color = if (state.certPinValid) MaterialTheme.colorScheme.onSurfaceVariant
482 else MaterialTheme.colorScheme.error,
485 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
486 modifier = Modifier.fillMaxWidth().testTag("server_cert_pin"),
492 * Optional LAN discovery: browses for opencode servers advertised via mDNS (`--mdns`) and
493 * offers each as a one-tap fill for the base URL. Discovery (and its multicast traffic) only
494 * runs while the section is expanded — the collection stops when the section is collapsed or
495 * the screen leaves composition. [initiallyExpanded] starts it open on a fresh add form so the
496 * discovered list is the primary path; an edit form (populated URL) leaves it collapsed.
499private fun DiscoverySection(onPick: (String) -> Unit, initiallyExpanded: Boolean = false) {
500 val context = LocalContext.current
501 var expanded by rememberSaveable { mutableStateOf(initiallyExpanded) }
502 val stateLabel = stringResource(if (expanded) R.string.state_expanded else R.string.state_collapsed)
503 Column(modifier = Modifier.fillMaxWidth()) {
505 onClick = { expanded = !expanded },
506 modifier = Modifier.semantics { stateDescription = stateLabel },
507 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
509 Icon(Icons.Filled.Wifi, contentDescription = null, modifier = Modifier.size(18.dp))
510 Text(stringResource(R.string.discover_on_network), modifier = Modifier.padding(start = 8.dp))
513 val discovery = remember { NsdDiscovery(context) }
514 val servers by remember(discovery) { discovery.discover() }
515 .collectAsStateWithLifecycle(initialValue = emptyList())
516 // mDNS discovery is a continuous flow that never emits a terminal "no results"
517 // state — on a network with no opencode servers the spinner would spin forever.
518 // After the timeout, swap the spinner for a "no servers found" message. Discovery
519 // keeps running, so a late server can still replace the message when it arrives.
520 var searchTimedOut by remember { mutableStateOf(false) }
521 LaunchedEffect(Unit) {
522 searchTimedOut = false
523 kotlinx.coroutines.delay(NetworkConfig.nsdDiscoveryNoResultTimeoutMs)
524 searchTimedOut = true
526 if (servers.isNotEmpty()) {
527 servers.forEach { server ->
531 .heightIn(min = 48.dp)
532 .clickable(role = Role.Button) { onPick(server.baseUrl) }
533 .padding(vertical = 8.dp),
534 verticalArrangement = Arrangement.Center,
536 Text(server.name, style = MaterialTheme.typography.bodyMedium)
539 style = MaterialTheme.typography.bodySmall,
540 color = MaterialTheme.colorScheme.onSurfaceVariant,
544 } else if (searchTimedOut) {
546 stringResource(R.string.no_servers_found),
547 style = MaterialTheme.typography.bodySmall,
548 color = MaterialTheme.colorScheme.onSurfaceVariant,
549 modifier = Modifier.padding(vertical = 8.dp),
553 verticalAlignment = Alignment.CenterVertically,
554 modifier = Modifier.padding(vertical = 8.dp),
556 CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
558 stringResource(R.string.searching_network),
559 style = MaterialTheme.typography.bodySmall,
560 color = MaterialTheme.colorScheme.onSurfaceVariant,
561 modifier = Modifier.padding(start = 8.dp),
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list