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.
91 BackHandler(enabled = state.isDirty && !state.saving && !showDiscardConfirm) {
92 showDiscardConfirm = true
98 title = { Text(if (state.isNew) stringResource(R.string.add_server) else stringResource(R.string.edit_server)) },
100 IconButton(onClick = ::safeExit) {
101 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
108 val loadingLabel = stringResource(R.string.loading)
109 Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
110 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
115 passwordVisible = passwordVisible,
116 onTogglePassword = { passwordVisible = !passwordVisible },
124 if (showDiscardConfirm) {
126 onDismissRequest = { showDiscardConfirm = false },
127 title = { Text(stringResource(R.string.discard_title)) },
128 text = { Text(stringResource(R.string.discard_text)) },
130 TextButton(onClick = {
131 showDiscardConfirm = false
133 }) { Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) }
136 TextButton(onClick = { showDiscardConfirm = false }) { Text(stringResource(R.string.keep_editing)) }
143private fun ServerEditForm(
144 state: ServerEditState,
145 passwordVisible: Boolean,
146 onTogglePassword: () -> Unit,
147 padding: androidx.compose.foundation.layout.PaddingValues,
148 vm: ServerEditViewModel,
151 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
152 // canSave parses the base URL with java.net.URI (which throws — and fills a stack trace —
153 // on the common half-typed URL). Memoize on baseUrl so it runs once per edit instead of
154 // being re-parsed at each of the enabled= call sites on every keystroke's recomposition.
155 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
160 .widthIn(max = 600.dp)
162 .verticalScroll(rememberScrollState()),
163 verticalArrangement = Arrangement.spacedBy(12.dp),
167 onValueChange = { v -> vm.update { it.copy(label = v) } },
168 label = { Text(stringResource(R.string.label_optional)) },
170 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
171 modifier = Modifier.fillMaxWidth().testTag("server_label"),
174 value = state.baseUrl,
175 onValueChange = { v -> vm.update { it.copy(baseUrl = v, error = null, probeReachable = null) } },
176 label = { Text(stringResource(R.string.base_url)) },
177 placeholder = { Text(stringResource(R.string.base_url_hint)) },
179 isError = state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl),
181 if (state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl)) {
182 // Offer a one-tap "http://" fix for bare host:port input instead of a
183 // generic error, so the user doesn't have to know the URL needs a scheme.
184 val suggestion = suggestUrlScheme(state.baseUrl)
185 if (suggestion != null) {
187 onClick = { vm.update { it.copy(baseUrl = suggestion, probeReachable = null) } },
188 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
190 Text(stringResource(R.string.suggest_scheme))
193 Text(stringResource(R.string.invalid_url))
197 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
198 modifier = Modifier.fillMaxWidth().testTag("server_url"),
201 onClick = { vm.probe() },
202 enabled = canSave && !state.probing && !state.saving,
203 modifier = Modifier.fillMaxWidth().testTag("server_probe"),
206 CircularProgressIndicator(
207 modifier = Modifier.size(20.dp),
210 Text(stringResource(R.string.checking), modifier = Modifier.padding(start = 8.dp))
212 Text(stringResource(R.string.check_connectivity))
215 // Positive feedback when the probe found the server reachable without auth, so
216 // the user knows the connection works before saving (closes the loop that the
217 // prior "silently does nothing" Reachable path left open).
218 if (state.probeReachable == true && !state.authFieldsVisible) {
220 stringResource(R.string.server_reachable),
221 style = MaterialTheme.typography.bodySmall,
222 color = MaterialTheme.colorScheme.primary,
223 modifier = Modifier.fillMaxWidth(),
227 onPick = { url -> vm.update { it.copy(baseUrl = url, error = null, probeReachable = null) } },
229 if (!state.authFieldsVisible) {
231 stringResource(R.string.auth_help),
232 style = MaterialTheme.typography.bodySmall,
233 color = MaterialTheme.colorScheme.onSurfaceVariant,
234 modifier = Modifier.fillMaxWidth(),
237 AnimatedVisibility(visible = state.authFieldsVisible) {
240 passwordVisible = passwordVisible,
241 onTogglePassword = onTogglePassword,
242 onUpdate = vm::update,
243 onTestCredentials = vm::testCredentials,
244 onImeDone = { if (canSave && !state.saving) vm.saveAndConnect(onDone) },
247 SecurityFields(state = state, onUpdate = vm::update)
249 modifier = Modifier.fillMaxWidth(),
250 horizontalArrangement = Arrangement.spacedBy(12.dp),
254 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
257 enabled = canSave && !state.saving,
258 modifier = Modifier.weight(1f),
261 CircularProgressIndicator(
262 modifier = Modifier.size(20.dp),
263 color = MaterialTheme.colorScheme.primary,
266 Text(stringResource(R.string.saving), modifier = Modifier.padding(start = 8.dp))
268 Text(stringResource(R.string.save))
273 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
274 vm.saveAndConnect(onDone)
276 enabled = canSave && !state.saving,
277 modifier = Modifier.weight(1f),
279 Text(stringResource(R.string.save_and_connect))
282 state.error?.let { message ->
285 color = MaterialTheme.colorScheme.error,
286 style = MaterialTheme.typography.bodySmall,
287 modifier = Modifier.fillMaxWidth(),
294private fun AuthFields(
295 state: ServerEditState,
296 passwordVisible: Boolean,
297 onTogglePassword: () -> Unit,
298 onUpdate: ((ServerEditState) -> ServerEditState) -> Unit,
299 onTestCredentials: () -> Unit,
300 onImeDone: () -> Unit,
302 val showPasswordLabel = stringResource(R.string.show_password)
303 val hidePasswordLabel = stringResource(R.string.hide_password)
304 // Memoize the URI parse on baseUrl so the test-credentials button doesn't re-parse it on
305 // every keystroke's recomposition (see ServerEditForm).
306 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
307 Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
309 stringResource(R.string.auth_required),
310 style = MaterialTheme.typography.bodySmall,
311 color = MaterialTheme.colorScheme.error,
314 value = state.username,
315 onValueChange = { v -> onUpdate { it.copy(username = v) } },
316 label = { Text(stringResource(R.string.username_optional)) },
318 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
319 modifier = Modifier.fillMaxWidth().testTag("server_username"),
322 value = state.password,
323 onValueChange = { v -> onUpdate { it.copy(password = v) } },
324 label = { Text(stringResource(R.string.password_optional)) },
326 visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
328 IconButton(onClick = onTogglePassword) {
330 if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
331 contentDescription = if (passwordVisible) hidePasswordLabel else showPasswordLabel,
335 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
336 keyboardActions = KeyboardActions(onDone = { onImeDone() }),
337 modifier = Modifier.fillMaxWidth().testTag("server_password"),
340 onClick = onTestCredentials,
341 // Require at least one credential field: testCredentials() no-ops when both are
342 // blank, so without this the button is tappable but silently does nothing.
343 enabled = canSave && !state.testingCredentials && !state.saving &&
344 (state.username.isNotBlank() || state.password.isNotBlank()),
345 modifier = Modifier.fillMaxWidth().testTag("server_test_creds"),
347 if (state.testingCredentials) {
348 CircularProgressIndicator(
349 modifier = Modifier.size(20.dp),
352 Text(stringResource(R.string.testing_credentials), modifier = Modifier.padding(start = 8.dp))
354 Text(stringResource(R.string.test_credentials))
357 state.credentialsResult?.let { ok ->
359 stringResource(if (ok) R.string.credentials_ok else R.string.credentials_rejected),
360 style = MaterialTheme.typography.bodySmall,
361 color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
367/** Optional TLS hardening for this server: force HTTPS (upgrade cleartext) and pin the
368 * server certificate. Both are off by default so the common localhost/HTTP setup is
371private fun SecurityFields(
372 state: ServerEditState,
373 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
375 Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
377 stringResource(R.string.server_security),
378 style = MaterialTheme.typography.titleSmall,
379 color = MaterialTheme.colorScheme.primary,
381 Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
382 Column(modifier = Modifier.weight(1f)) {
383 Text(stringResource(R.string.require_https), style = MaterialTheme.typography.bodyLarge)
385 stringResource(R.string.require_https_desc),
386 style = MaterialTheme.typography.bodySmall,
387 color = MaterialTheme.colorScheme.onSurfaceVariant,
391 checked = state.requireHttps,
392 onCheckedChange = { v -> onUpdate { it.copy(requireHttps = v) } },
396 value = state.certPin,
397 onValueChange = { v -> onUpdate { it.copy(certPin = v) } },
398 label = { Text(stringResource(R.string.cert_pin)) },
399 placeholder = { Text(stringResource(R.string.cert_pin_hint)) },
401 isError = !state.certPinValid,
404 stringResource(if (state.certPinValid) R.string.cert_pin_desc else R.string.cert_pin_invalid),
405 style = MaterialTheme.typography.bodySmall,
406 color = if (state.certPinValid) MaterialTheme.colorScheme.onSurfaceVariant
407 else MaterialTheme.colorScheme.error,
410 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
411 modifier = Modifier.fillMaxWidth().testTag("server_cert_pin"),
417 * Optional LAN discovery: browses for opencode servers advertised via mDNS (`--mdns`) and
418 * offers each as a one-tap fill for the base URL. Collapsed by default so the discovery (and
419 * its multicast traffic) only runs while the section is expanded — the collection stops when
420 * the section is collapsed or the screen leaves composition.
423private fun DiscoverySection(onPick: (String) -> Unit) {
424 val context = LocalContext.current
425 var expanded by rememberSaveable { mutableStateOf(false) }
426 Column(modifier = Modifier.fillMaxWidth()) {
428 onClick = { expanded = !expanded },
429 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
431 Icon(Icons.Filled.Wifi, contentDescription = null, modifier = Modifier.size(18.dp))
432 Text(stringResource(R.string.discover_on_network), modifier = Modifier.padding(start = 8.dp))
435 val discovery = remember { NsdDiscovery(context) }
436 val servers by remember(discovery) { discovery.discover() }
437 .collectAsStateWithLifecycle(initialValue = emptyList())
438 if (servers.isEmpty()) {
440 verticalAlignment = Alignment.CenterVertically,
441 modifier = Modifier.padding(vertical = 8.dp),
443 CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
445 stringResource(R.string.searching_network),
446 style = MaterialTheme.typography.bodySmall,
447 color = MaterialTheme.colorScheme.onSurfaceVariant,
448 modifier = Modifier.padding(start = 8.dp),
452 servers.forEach { server ->
456 .clickable { onPick(server.baseUrl) }
457 .padding(vertical = 8.dp),
459 Text(server.name, style = MaterialTheme.typography.bodyMedium)
462 style = MaterialTheme.typography.bodySmall,
463 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