1package soy.iko.opencode.ui.server
3import androidx.activity.compose.BackHandler
4import androidx.compose.animation.AnimatedVisibility
5import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.Column
8import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.imePadding
12import androidx.compose.foundation.layout.padding
13import androidx.compose.foundation.layout.size
14import androidx.compose.foundation.layout.widthIn
15import androidx.compose.foundation.layout.wrapContentWidth
16import androidx.compose.foundation.rememberScrollState
17import androidx.compose.foundation.text.KeyboardActions
18import androidx.compose.foundation.text.KeyboardOptions
19import androidx.compose.foundation.verticalScroll
20import androidx.compose.material.icons.Icons
21import androidx.compose.material.icons.automirrored.filled.ArrowBack
22import androidx.compose.material.icons.filled.Delete
23import androidx.compose.material.icons.filled.ExpandLess
24import androidx.compose.material.icons.filled.ExpandMore
25import androidx.compose.material.icons.filled.Visibility
26import androidx.compose.material.icons.filled.VisibilityOff
27import androidx.compose.material3.AlertDialog
28import androidx.compose.material3.Button
29import androidx.compose.material3.CircularProgressIndicator
30import androidx.compose.material3.ExperimentalMaterial3Api
31import androidx.compose.material3.Icon
32import androidx.compose.material3.IconButton
33import androidx.compose.material3.MaterialTheme
34import androidx.compose.material3.OutlinedButton
35import androidx.compose.material3.OutlinedTextField
36import androidx.compose.material3.Scaffold
37import androidx.compose.material3.Switch
38import androidx.compose.material3.Text
39import androidx.compose.material3.TextButton
40import androidx.compose.material3.TopAppBar
41import androidx.compose.material3.TopAppBarDefaults
42import androidx.compose.runtime.Composable
43import androidx.compose.runtime.LaunchedEffect
44import androidx.compose.runtime.getValue
45import androidx.compose.runtime.mutableStateOf
46import androidx.compose.runtime.remember
47import androidx.compose.runtime.saveable.rememberSaveable
48import androidx.compose.runtime.setValue
49import androidx.compose.ui.Alignment
50import androidx.compose.ui.Modifier
51import androidx.compose.ui.focus.FocusRequester
52import androidx.compose.ui.focus.focusRequester
53import androidx.compose.ui.input.nestedscroll.nestedScroll
54import androidx.compose.ui.platform.LocalSoftwareKeyboardController
55import androidx.compose.ui.platform.testTag
56import androidx.compose.ui.semantics.Role
57import androidx.compose.ui.semantics.contentDescription
58import androidx.compose.ui.semantics.role
59import androidx.compose.ui.semantics.semantics
60import androidx.compose.ui.semantics.stateDescription
61import androidx.compose.ui.text.input.ImeAction
62import androidx.compose.ui.text.input.KeyboardType
63import androidx.compose.ui.text.input.PasswordVisualTransformation
64import androidx.compose.ui.text.input.VisualTransformation
65import androidx.compose.ui.res.stringResource
66import androidx.compose.ui.unit.dp
67import androidx.lifecycle.compose.collectAsStateWithLifecycle
68import androidx.lifecycle.viewmodel.compose.viewModel
69import soy.iko.opencode.di.AppContainer
70import soy.iko.opencode.R
71import soy.iko.opencode.ui.components.autofillModifier
72import soy.iko.opencode.ui.components.AutofillHint
73import soy.iko.opencode.ui.vmFactory
75@OptIn(ExperimentalMaterial3Api::class)
78 container: AppContainer,
81 sourceId: String? = null,
83 val vm: ServerEditViewModel =
84 viewModel(factory = vmFactory { ServerEditViewModel(container, profileId, sourceId) })
85 val state by vm.state.collectAsStateWithLifecycle()
86 var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
87 var passwordVisible by rememberSaveable { mutableStateOf(false) }
91 // A save is in flight: ignore the back gesture (matching the BackHandler guard
92 // below). Popping here would cancel the save's viewModelScope coroutine and
93 // silently abandon the write; the VM calls onDone itself once the save lands.
95 state.isDirty -> showDiscardConfirm = true
100 // Intercept the system back gesture when the form has unsaved changes so the user
101 // gets a chance to keep editing instead of losing their work silently. Stays enabled
102 // (as a no-op) while a save is in flight so the gesture can't propagate to the NavHost
103 // and cancel the save's viewModelScope coroutine mid-write — matching safeExit()'s guard.
104 BackHandler(enabled = (state.isDirty || state.saving) && !showDiscardConfirm) {
105 if (!state.saving) showDiscardConfirm = true
108 // Collapsing/lifting top bar so the long form scrolls under it, matching Settings and
109 // Diagnostics (the form can be tall: Base URL + Auth + Test + Connect + Save +
110 // Label + Security).
111 val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
115 title = { Text(if (state.isNew) stringResource(R.string.add_server) else stringResource(R.string.edit_server)) },
117 IconButton(onClick = ::safeExit) {
118 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
122 // Delete action in the overflow, only for an existing (saved) profile.
123 // The active profile is guarded in the ViewModel (delete disconnects it
124 // if it's the active one). A confirmation dialog gates the destructive action.
126 var showDeleteConfirm by remember { mutableStateOf(false) }
127 IconButton(onClick = { showDeleteConfirm = true }) {
128 Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete_server))
130 if (showDeleteConfirm) {
131 // Name the server (matching the server list's delete dialog, which
132 // includes displayLabel) so the user can verify which profile they're
133 // deleting before confirming. displayLabel falls back to baseUrl when
134 // no label is set, so there's always an identifiable name.
135 val displayName = state.label.takeIf { it.isNotBlank() } ?: state.baseUrl
137 onDismissRequest = { showDeleteConfirm = false },
138 title = { Text(stringResource(R.string.delete_server)) },
139 text = { Text(stringResource(R.string.remove_server_text, displayName)) },
141 TextButton(onClick = {
142 showDeleteConfirm = false
144 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
147 TextButton(onClick = { showDeleteConfirm = false }) { Text(stringResource(R.string.cancel)) }
153 scrollBehavior = scrollBehavior,
158 val loadingLabel = stringResource(R.string.loading)
159 Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
160 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
165 passwordVisible = passwordVisible,
166 onTogglePassword = { passwordVisible = !passwordVisible },
168 scrollBehavior = scrollBehavior,
175 if (showDiscardConfirm) {
177 onDismissRequest = { showDiscardConfirm = false },
178 title = { Text(stringResource(R.string.discard_title)) },
179 text = { Text(stringResource(R.string.discard_text)) },
181 TextButton(onClick = {
182 showDiscardConfirm = false
184 }) { Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) }
187 TextButton(onClick = { showDiscardConfirm = false }) { Text(stringResource(R.string.keep_editing)) }
193@OptIn(ExperimentalMaterial3Api::class)
195private fun ServerEditForm(
196 state: ServerEditState,
197 passwordVisible: Boolean,
198 onTogglePassword: () -> Unit,
199 padding: androidx.compose.foundation.layout.PaddingValues,
200 scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
201 vm: ServerEditViewModel,
204 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
205 // canSave parses the base URL with OkHttp's parser (which allocates on the common
206 // half-typed URL). Memoize on baseUrl so it runs once per edit instead of being
207 // re-parsed at each of the enabled= call sites on every keystroke's recomposition.
208 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
209 // isValidUrl parses with OkHttp too; memoize on baseUrl so it runs once per edit instead
210 // of re-parsing at each isError/supportingText call site on every keystroke's recomposition.
211 val urlValid = remember(state.baseUrl) { isValidUrl(state.baseUrl) }
212 // Focus the Base URL field (and raise the keyboard) on a fresh Add-server form so the
213 // user can start typing the required field immediately.
214 val baseUrlFocus = remember { FocusRequester() }
215 val keyboard = LocalSoftwareKeyboardController.current
216 LaunchedEffect(Unit) {
217 if (state.isNew && state.baseUrl.isBlank()) {
218 baseUrlFocus.requestFocus()
227 .wrapContentWidth(Alignment.CenterHorizontally)
228 .widthIn(max = 600.dp)
230 .nestedScroll(scrollBehavior.nestedScrollConnection)
231 .verticalScroll(rememberScrollState()),
232 verticalArrangement = Arrangement.spacedBy(12.dp),
234 // Base URL is the only required field, so it leads the form and takes focus on a
235 // fresh add form (via baseUrlFocus above).
237 value = state.baseUrl,
238 onValueChange = { v ->
239 // A paste of a full QR/JSON config into the URL field populates the whole form
240 // (so a user can paste a shared server config instead of scanning an image).
241 // Falls through to a normal URL edit when it isn't a recognized payload.
242 if (!vm.applyQrPayload(v)) vm.update { it.copy(baseUrl = v) }
244 label = { Text(stringResource(R.string.base_url)) },
245 placeholder = { Text(stringResource(R.string.base_url_hint)) },
247 isError = state.baseUrl.isNotBlank() && !urlValid,
249 if (state.baseUrl.isNotBlank() && !urlValid) {
250 // Offer a one-tap scheme fix for bare host:port input instead of a
251 // generic error, so the user doesn't have to know the URL needs a scheme.
252 // suggestUrlScheme() picks https:// for TLS-shaped hosts (port 443, public
253 // domains) so the quick-fix doesn't nudge users onto cleartext.
254 val suggestion = suggestUrlScheme(state.baseUrl)
255 if (suggestion != null) {
256 val labelRes = if (suggestion.startsWith("https://")) R.string.suggest_scheme_https else R.string.suggest_scheme
257 // Mark the supporting-text fix as a Button so TalkBack announces it as an
258 // actionable control. Without an explicit role, a TextButton inside
259 // supportingText can be missed by screen-reader users who don't scan
260 // the error slot visually.
262 onClick = { vm.update { it.copy(baseUrl = suggestion) } },
263 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
264 modifier = Modifier.semantics {
266 contentDescription = state.baseUrl
269 Text(stringResource(labelRes))
272 Text(stringResource(R.string.invalid_url))
274 } else if (state.baseUrl.isBlank()) {
275 // Surface the QR-paste shortcut so a user with a shared server config knows
276 // they can paste it here instead of scanning an image.
277 Text(stringResource(R.string.qr_paste_hint))
280 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
281 keyboardActions = KeyboardActions(onDone = { keyboard?.hide() }),
284 .focusRequester(baseUrlFocus)
285 .testTag("server_url"),
287 // Auth fields appear only once a connect attempt learns the server requires them
288 // (or an authed profile is loaded) — the common no-auth server never sees them.
289 AnimatedVisibility(visible = state.authFieldsVisible) {
292 passwordVisible = passwordVisible,
293 onTogglePassword = onTogglePassword,
294 onUpdate = vm::update,
295 onTestCredentials = vm::testCredentials,
296 onImeDone = { if (canSave && !state.saving) vm.connect(onDone) },
299 // Pre-save reachability probe: lets the user confirm the URL is reachable before
300 // committing. Extracted to [TestConnectionControl] to keep this function's complexity
301 // under the detekt threshold.
302 TestConnectionControl(
304 testing = state.testingConnection,
305 saving = state.saving,
306 testingCredentials = state.testingCredentials,
307 result = state.connectionResult,
308 onTest = vm::testConnection,
310 // Single primary action: probe-then-save-and-connect. Replaces the former
311 // Check-connectivity / Save / Save-&-connect trio.
314 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
317 enabled = canSave && !state.saving,
318 modifier = Modifier.fillMaxWidth().testTag("server_connect"),
321 CircularProgressIndicator(
322 modifier = Modifier.size(20.dp),
323 color = MaterialTheme.colorScheme.onPrimary,
326 Text(stringResource(R.string.connecting), modifier = Modifier.padding(start = 8.dp))
328 Text(stringResource(R.string.connect))
331 // Keep the error directly under the primary action so it's in view where the user is
332 // looking when a Connect/Save fails — not buried further down the form.
333 state.error?.let { message ->
336 color = MaterialTheme.colorScheme.error,
337 style = MaterialTheme.typography.bodySmall,
338 modifier = Modifier.fillMaxWidth(),
341 // Secondary, low-emphasis: persist without connecting (offline setup).
344 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
347 enabled = canSave && !state.saving,
348 modifier = Modifier.fillMaxWidth().testTag("server_save"),
350 Text(stringResource(R.string.save_without_connecting))
352 // De-emphasized optional label at the bottom: displayLabel already falls back to the
353 // URL, so most servers don't need one.
356 onValueChange = { v -> vm.update { it.copy(label = v) } },
357 label = { Text(stringResource(R.string.label_optional)) },
359 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
360 modifier = Modifier.fillMaxWidth().testTag("server_label"),
362 AdvancedSection(state = state, onUpdate = vm::update)
367private fun AuthFields(
368 state: ServerEditState,
369 passwordVisible: Boolean,
370 onTogglePassword: () -> Unit,
371 onUpdate: ((ServerEditState) -> ServerEditState) -> Unit,
372 onTestCredentials: () -> Unit,
373 onImeDone: () -> Unit,
375 val showPasswordLabel = stringResource(R.string.show_password)
376 val hidePasswordLabel = stringResource(R.string.hide_password)
377 // Memoize the URI parse on baseUrl so the test-credentials button doesn't re-parse it on
378 // every keystroke's recomposition (see ServerEditForm).
379 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
380 val usernameFocus = remember { FocusRequester() }
381 val passwordFocus = remember { FocusRequester() }
382 // AuthFields only enters composition once the server is known to require credentials
383 // (AnimatedVisibility in ServerEditForm), so a Unit-keyed effect fires once on reveal
384 // and focuses the username — otherwise the user has to tap into it after the probe flips
385 // the auth fields open.
386 LaunchedEffect(Unit) {
387 runCatching { usernameFocus.requestFocus() }
389 Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
391 stringResource(R.string.auth_required),
392 style = MaterialTheme.typography.bodySmall,
393 color = MaterialTheme.colorScheme.error,
396 value = state.username,
397 onValueChange = { v -> onUpdate { it.copy(username = v) } },
398 label = { Text(stringResource(R.string.username_optional)) },
400 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
401 keyboardActions = KeyboardActions(onNext = { passwordFocus.requestFocus() }),
404 .focusRequester(usernameFocus)
405 .then(autofillModifier(AutofillHint.Username) { v -> onUpdate { it.copy(username = v) } })
406 .testTag("server_username"),
409 value = state.password,
410 onValueChange = { v -> onUpdate { it.copy(password = v) } },
411 label = { Text(stringResource(R.string.password_optional)) },
413 visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
415 IconButton(onClick = onTogglePassword) {
417 if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
418 contentDescription = if (passwordVisible) hidePasswordLabel else showPasswordLabel,
422 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
423 keyboardActions = KeyboardActions(onDone = { onImeDone() }),
426 .focusRequester(passwordFocus)
427 .then(autofillModifier(AutofillHint.Password) { v -> onUpdate { it.copy(password = v) } })
428 .testTag("server_password"),
431 onClick = onTestCredentials,
432 // Require at least one credential field: testCredentials() no-ops when both are
433 // blank, so without this the button is tappable but silently does nothing.
434 enabled = canSave && !state.testingCredentials && !state.saving &&
435 (state.username.isNotBlank() || state.password.isNotBlank()),
436 modifier = Modifier.fillMaxWidth().testTag("server_test_creds"),
438 if (state.testingCredentials) {
439 CircularProgressIndicator(
440 modifier = Modifier.size(20.dp),
443 Text(stringResource(R.string.testing_credentials), modifier = Modifier.padding(start = 8.dp))
445 Text(stringResource(R.string.test_credentials))
448 state.credentialsResult?.let { ok ->
450 stringResource(if (ok) R.string.credentials_ok else R.string.credentials_rejected),
451 style = MaterialTheme.typography.bodySmall,
452 color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
458/** The "Test connection" button plus its success/failure feedback line. Extracted from
459 * [ServerEditForm] to keep that function's cyclomatic complexity under the detekt threshold. */
461private fun TestConnectionControl(
465 testingCredentials: Boolean,
466 result: ConnectionProbeResult?,
471 enabled = canSave && !testing && !saving && !testingCredentials,
472 modifier = Modifier.fillMaxWidth().testTag("server_test_connection"),
475 CircularProgressIndicator(
476 modifier = Modifier.size(20.dp),
479 Text(stringResource(R.string.testing_connection), modifier = Modifier.padding(start = 8.dp))
481 Text(stringResource(R.string.test_connection))
484 ConnectionProbeFeedback(result)
487/** Renders the success/failed feedback line for a "Test connection" probe. Extracted from
488 * [ServerEditForm] to keep that function's cyclomatic complexity under the detekt threshold. */
490private fun ConnectionProbeFeedback(result: ConnectionProbeResult?) {
491 if (result == null) return
492 val (msg, color) = when (result) {
493 is ConnectionProbeResult.Success ->
494 stringResource(R.string.test_connection_success, result.latencyMs) to MaterialTheme.colorScheme.primary
495 is ConnectionProbeResult.Failed ->
496 stringResource(R.string.test_connection_failed, result.message) to MaterialTheme.colorScheme.error
500 style = MaterialTheme.typography.bodySmall,
506 * Optional TLS hardening, tucked behind a collapsed "Security" expander so the common
507 * localhost/HTTP setup never sees it. Auto-expands when a loaded profile already uses one of
508 * these settings (so an invalid pin can't silently disable Connect from inside a closed
509 * section). Contains: force HTTPS (upgrade cleartext) and pin the server certificate — both
513private fun AdvancedSection(
514 state: ServerEditState,
515 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
517 var expanded by rememberSaveable { mutableStateOf(state.requireHttps || state.certPin.isNotBlank()) }
518 val stateLabel = stringResource(if (expanded) R.string.state_expanded else R.string.state_collapsed)
519 Column(modifier = Modifier.fillMaxWidth()) {
521 onClick = { expanded = !expanded },
522 modifier = Modifier.semantics { stateDescription = stateLabel },
523 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
526 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
527 contentDescription = null,
528 modifier = Modifier.size(18.dp),
530 Text(stringResource(R.string.server_security), modifier = Modifier.padding(start = 8.dp))
532 AnimatedVisibility(visible = expanded) {
533 SecurityFields(state = state, onUpdate = onUpdate)
538/** The actual security controls, rendered inside [AdvancedSection]'s expander. */
540private fun SecurityFields(
541 state: ServerEditState,
542 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
544 Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
545 Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
546 Column(modifier = Modifier.weight(1f)) {
547 Text(stringResource(R.string.require_https), style = MaterialTheme.typography.bodyLarge)
549 stringResource(R.string.require_https_desc),
550 style = MaterialTheme.typography.bodySmall,
551 color = MaterialTheme.colorScheme.onSurfaceVariant,
555 checked = state.requireHttps,
556 onCheckedChange = { v -> onUpdate { it.copy(requireHttps = v) } },
560 value = state.certPin,
561 onValueChange = { v -> onUpdate { it.copy(certPin = v) } },
562 label = { Text(stringResource(R.string.cert_pin)) },
563 placeholder = { Text(stringResource(R.string.cert_pin_hint)) },
565 isError = !state.certPinValid,
568 stringResource(if (state.certPinValid) R.string.cert_pin_desc else R.string.cert_pin_invalid),
569 style = MaterialTheme.typography.bodySmall,
570 color = if (state.certPinValid) MaterialTheme.colorScheme.onSurfaceVariant
571 else MaterialTheme.colorScheme.error,
574 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
575 modifier = Modifier.fillMaxWidth().testTag("server_cert_pin"),
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