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.imePadding
13import androidx.compose.foundation.layout.padding
14import androidx.compose.foundation.layout.size
15import androidx.compose.foundation.layout.widthIn
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.Visibility
23import androidx.compose.material.icons.filled.VisibilityOff
24import androidx.compose.material.icons.filled.Wifi
25import androidx.compose.material3.AlertDialog
26import androidx.compose.material3.Button
27import androidx.compose.material3.CircularProgressIndicator
28import androidx.compose.material3.ExperimentalMaterial3Api
29import androidx.compose.material3.Icon
30import androidx.compose.material3.IconButton
31import androidx.compose.material3.MaterialTheme
32import androidx.compose.material3.OutlinedButton
33import androidx.compose.material3.OutlinedTextField
34import androidx.compose.material3.Scaffold
35import androidx.compose.material3.Switch
36import androidx.compose.material3.Text
37import androidx.compose.material3.TextButton
38import androidx.compose.material3.TopAppBar
39import androidx.compose.runtime.Composable
40import androidx.compose.runtime.getValue
41import androidx.compose.runtime.mutableStateOf
42import androidx.compose.runtime.remember
43import androidx.compose.runtime.saveable.rememberSaveable
44import androidx.compose.runtime.setValue
45import androidx.compose.ui.Alignment
46import androidx.compose.ui.Modifier
47import androidx.compose.ui.platform.LocalContext
48import androidx.compose.ui.platform.testTag
49import androidx.compose.ui.semantics.contentDescription
50import androidx.compose.ui.semantics.semantics
51import androidx.compose.ui.text.input.ImeAction
52import androidx.compose.ui.text.input.KeyboardType
53import androidx.compose.ui.text.input.PasswordVisualTransformation
54import androidx.compose.ui.text.input.VisualTransformation
55import androidx.compose.ui.res.stringResource
56import androidx.compose.ui.unit.dp
57import androidx.lifecycle.compose.collectAsStateWithLifecycle
58import androidx.lifecycle.viewmodel.compose.viewModel
59import soy.iko.opencode.data.network.NsdDiscovery
60import soy.iko.opencode.di.AppContainer
61import soy.iko.opencode.R
62import soy.iko.opencode.ui.vmFactory
64@OptIn(ExperimentalMaterial3Api::class)
67 container: AppContainer,
70 sourceId: String? = null,
72 val vm: ServerEditViewModel =
73 viewModel(factory = vmFactory { ServerEditViewModel(container, profileId, sourceId) })
74 val state by vm.state.collectAsStateWithLifecycle()
75 var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
76 var passwordVisible by rememberSaveable { mutableStateOf(false) }
80 // A save is in flight: ignore the back gesture (matching the BackHandler guard
81 // below). Popping here would cancel the save's viewModelScope coroutine and
82 // silently abandon the write; the VM calls onDone itself once the save lands.
84 state.isDirty -> showDiscardConfirm = true
89 // Intercept the system back gesture when the form has unsaved changes so the user
90 // gets a chance to keep editing instead of losing their work silently. Stays enabled
91 // (as a no-op) while a save is in flight so the gesture can't propagate to the NavHost
92 // and cancel the save's viewModelScope coroutine mid-write — matching safeExit()'s guard.
93 BackHandler(enabled = (state.isDirty || state.saving) && !showDiscardConfirm) {
94 if (!state.saving) showDiscardConfirm = true
100 title = { Text(if (state.isNew) stringResource(R.string.add_server) else stringResource(R.string.edit_server)) },
102 IconButton(onClick = ::safeExit) {
103 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
110 val loadingLabel = stringResource(R.string.loading)
111 Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
112 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
117 passwordVisible = passwordVisible,
118 onTogglePassword = { passwordVisible = !passwordVisible },
126 if (showDiscardConfirm) {
128 onDismissRequest = { showDiscardConfirm = false },
129 title = { Text(stringResource(R.string.discard_title)) },
130 text = { Text(stringResource(R.string.discard_text)) },
132 TextButton(onClick = {
133 showDiscardConfirm = false
135 }) { Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) }
138 TextButton(onClick = { showDiscardConfirm = false }) { Text(stringResource(R.string.keep_editing)) }
145private fun ServerEditForm(
146 state: ServerEditState,
147 passwordVisible: Boolean,
148 onTogglePassword: () -> Unit,
149 padding: androidx.compose.foundation.layout.PaddingValues,
150 vm: ServerEditViewModel,
153 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
154 // canSave parses the base URL with java.net.URI (which throws — and fills a stack trace —
155 // on the common half-typed URL). Memoize on baseUrl so it runs once per edit instead of
156 // being re-parsed at each of the enabled= call sites on every keystroke's recomposition.
157 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
162 .widthIn(max = 600.dp)
164 .verticalScroll(rememberScrollState()),
165 verticalArrangement = Arrangement.spacedBy(12.dp),
169 onValueChange = { v -> vm.update { it.copy(label = v) } },
170 label = { Text(stringResource(R.string.label_optional)) },
172 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
173 modifier = Modifier.fillMaxWidth().testTag("server_label"),
176 value = state.baseUrl,
177 onValueChange = { v -> vm.update { it.copy(baseUrl = v, error = null, probeReachable = null) } },
178 label = { Text(stringResource(R.string.base_url)) },
179 placeholder = { Text(stringResource(R.string.base_url_hint)) },
181 isError = state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl),
183 if (state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl)) {
184 // Offer a one-tap "http://" fix for bare host:port input instead of a
185 // generic error, so the user doesn't have to know the URL needs a scheme.
186 val suggestion = suggestUrlScheme(state.baseUrl)
187 if (suggestion != null) {
189 onClick = { vm.update { it.copy(baseUrl = suggestion, probeReachable = null) } },
190 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
192 Text(stringResource(R.string.suggest_scheme))
195 Text(stringResource(R.string.invalid_url))
199 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
200 modifier = Modifier.fillMaxWidth().testTag("server_url"),
203 onClick = { vm.probe() },
204 enabled = canSave && !state.probing && !state.saving,
205 modifier = Modifier.fillMaxWidth().testTag("server_probe"),
208 CircularProgressIndicator(
209 modifier = Modifier.size(20.dp),
212 Text(stringResource(R.string.checking), modifier = Modifier.padding(start = 8.dp))
214 Text(stringResource(R.string.check_connectivity))
217 // Positive feedback when the probe found the server reachable without auth, so
218 // the user knows the connection works before saving (closes the loop that the
219 // prior "silently does nothing" Reachable path left open).
220 if (state.probeReachable == true && !state.authFieldsVisible) {
222 stringResource(R.string.server_reachable),
223 style = MaterialTheme.typography.bodySmall,
224 color = MaterialTheme.colorScheme.primary,
225 modifier = Modifier.fillMaxWidth(),
229 onPick = { url -> vm.update { it.copy(baseUrl = url, error = null, probeReachable = null) } },
231 if (!state.authFieldsVisible) {
233 stringResource(R.string.auth_help),
234 style = MaterialTheme.typography.bodySmall,
235 color = MaterialTheme.colorScheme.onSurfaceVariant,
236 modifier = Modifier.fillMaxWidth(),
239 AnimatedVisibility(visible = state.authFieldsVisible) {
242 passwordVisible = passwordVisible,
243 onTogglePassword = onTogglePassword,
244 onUpdate = vm::update,
245 onTestCredentials = vm::testCredentials,
246 onImeDone = { if (canSave && !state.saving) vm.saveAndConnect(onDone) },
249 SecurityFields(state = state, onUpdate = vm::update)
251 modifier = Modifier.fillMaxWidth(),
252 horizontalArrangement = Arrangement.spacedBy(12.dp),
256 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
259 enabled = canSave && !state.saving,
260 modifier = Modifier.weight(1f),
263 CircularProgressIndicator(
264 modifier = Modifier.size(20.dp),
265 color = MaterialTheme.colorScheme.primary,
268 Text(stringResource(R.string.saving), modifier = Modifier.padding(start = 8.dp))
270 Text(stringResource(R.string.save))
275 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
276 vm.saveAndConnect(onDone)
278 enabled = canSave && !state.saving,
279 modifier = Modifier.weight(1f),
281 Text(stringResource(R.string.save_and_connect))
284 state.error?.let { message ->
287 color = MaterialTheme.colorScheme.error,
288 style = MaterialTheme.typography.bodySmall,
289 modifier = Modifier.fillMaxWidth(),
296private fun AuthFields(
297 state: ServerEditState,
298 passwordVisible: Boolean,
299 onTogglePassword: () -> Unit,
300 onUpdate: ((ServerEditState) -> ServerEditState) -> Unit,
301 onTestCredentials: () -> Unit,
302 onImeDone: () -> Unit,
304 val showPasswordLabel = stringResource(R.string.show_password)
305 val hidePasswordLabel = stringResource(R.string.hide_password)
306 // Memoize the URI parse on baseUrl so the test-credentials button doesn't re-parse it on
307 // every keystroke's recomposition (see ServerEditForm).
308 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
309 Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
311 stringResource(R.string.auth_required),
312 style = MaterialTheme.typography.bodySmall,
313 color = MaterialTheme.colorScheme.error,
316 value = state.username,
317 onValueChange = { v -> onUpdate { it.copy(username = v) } },
318 label = { Text(stringResource(R.string.username_optional)) },
320 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
321 modifier = Modifier.fillMaxWidth().testTag("server_username"),
324 value = state.password,
325 onValueChange = { v -> onUpdate { it.copy(password = v) } },
326 label = { Text(stringResource(R.string.password_optional)) },
328 visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
330 IconButton(onClick = onTogglePassword) {
332 if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
333 contentDescription = if (passwordVisible) hidePasswordLabel else showPasswordLabel,
337 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
338 keyboardActions = KeyboardActions(onDone = { onImeDone() }),
339 modifier = Modifier.fillMaxWidth().testTag("server_password"),
342 onClick = onTestCredentials,
343 // Require at least one credential field: testCredentials() no-ops when both are
344 // blank, so without this the button is tappable but silently does nothing.
345 enabled = canSave && !state.testingCredentials && !state.saving &&
346 (state.username.isNotBlank() || state.password.isNotBlank()),
347 modifier = Modifier.fillMaxWidth().testTag("server_test_creds"),
349 if (state.testingCredentials) {
350 CircularProgressIndicator(
351 modifier = Modifier.size(20.dp),
354 Text(stringResource(R.string.testing_credentials), modifier = Modifier.padding(start = 8.dp))
356 Text(stringResource(R.string.test_credentials))
359 state.credentialsResult?.let { ok ->
361 stringResource(if (ok) R.string.credentials_ok else R.string.credentials_rejected),
362 style = MaterialTheme.typography.bodySmall,
363 color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
369/** Optional TLS hardening for this server: force HTTPS (upgrade cleartext) and pin the
370 * server certificate. Both are off by default so the common localhost/HTTP setup is
373private fun SecurityFields(
374 state: ServerEditState,
375 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
377 Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
379 stringResource(R.string.server_security),
380 style = MaterialTheme.typography.titleSmall,
381 color = MaterialTheme.colorScheme.primary,
383 Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
384 Column(modifier = Modifier.weight(1f)) {
385 Text(stringResource(R.string.require_https), style = MaterialTheme.typography.bodyLarge)
387 stringResource(R.string.require_https_desc),
388 style = MaterialTheme.typography.bodySmall,
389 color = MaterialTheme.colorScheme.onSurfaceVariant,
393 checked = state.requireHttps,
394 onCheckedChange = { v -> onUpdate { it.copy(requireHttps = v) } },
398 value = state.certPin,
399 onValueChange = { v -> onUpdate { it.copy(certPin = v) } },
400 label = { Text(stringResource(R.string.cert_pin)) },
401 placeholder = { Text(stringResource(R.string.cert_pin_hint)) },
403 isError = !state.certPinValid,
406 stringResource(if (state.certPinValid) R.string.cert_pin_desc else R.string.cert_pin_invalid),
407 style = MaterialTheme.typography.bodySmall,
408 color = if (state.certPinValid) MaterialTheme.colorScheme.onSurfaceVariant
409 else MaterialTheme.colorScheme.error,
412 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
413 modifier = Modifier.fillMaxWidth().testTag("server_cert_pin"),
419 * Optional LAN discovery: browses for opencode servers advertised via mDNS (`--mdns`) and
420 * offers each as a one-tap fill for the base URL. Collapsed by default so the discovery (and
421 * its multicast traffic) only runs while the section is expanded — the collection stops when
422 * the section is collapsed or the screen leaves composition.
425private fun DiscoverySection(onPick: (String) -> Unit) {
426 val context = LocalContext.current
427 var expanded by rememberSaveable { mutableStateOf(false) }
428 Column(modifier = Modifier.fillMaxWidth()) {
430 onClick = { expanded = !expanded },
431 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
433 Icon(Icons.Filled.Wifi, contentDescription = null, modifier = Modifier.size(18.dp))
434 Text(stringResource(R.string.discover_on_network), modifier = Modifier.padding(start = 8.dp))
437 val discovery = remember { NsdDiscovery(context) }
438 val servers by remember(discovery) { discovery.discover() }
439 .collectAsStateWithLifecycle(initialValue = emptyList())
440 if (servers.isEmpty()) {
442 verticalAlignment = Alignment.CenterVertically,
443 modifier = Modifier.padding(vertical = 8.dp),
445 CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
447 stringResource(R.string.searching_network),
448 style = MaterialTheme.typography.bodySmall,
449 color = MaterialTheme.colorScheme.onSurfaceVariant,
450 modifier = Modifier.padding(start = 8.dp),
454 servers.forEach { server ->
458 .clickable { onPick(server.baseUrl) }
459 .padding(vertical = 8.dp),
461 Text(server.name, style = MaterialTheme.typography.bodyMedium)
464 style = MaterialTheme.typography.bodySmall,
465 color = MaterialTheme.colorScheme.onSurfaceVariant,
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