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.fillMaxSize
9import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.imePadding
11import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.size
13import androidx.compose.foundation.layout.widthIn
14import androidx.compose.foundation.layout.wrapContentWidth
15import androidx.compose.foundation.rememberScrollState
16import androidx.compose.foundation.text.KeyboardActions
17import androidx.compose.foundation.text.KeyboardOptions
18import androidx.compose.foundation.verticalScroll
19import androidx.compose.material.icons.Icons
20import androidx.compose.material.icons.automirrored.filled.ArrowBack
21import androidx.compose.material.icons.filled.Visibility
22import androidx.compose.material.icons.filled.VisibilityOff
23import androidx.compose.material3.AlertDialog
24import androidx.compose.material3.Button
25import androidx.compose.material3.CircularProgressIndicator
26import androidx.compose.material3.ExperimentalMaterial3Api
27import androidx.compose.material3.Icon
28import androidx.compose.material3.IconButton
29import androidx.compose.material3.MaterialTheme
30import androidx.compose.material3.OutlinedButton
31import androidx.compose.material3.OutlinedTextField
32import androidx.compose.material3.Scaffold
33import androidx.compose.material3.Text
34import androidx.compose.material3.TextButton
35import androidx.compose.material3.TopAppBar
36import androidx.compose.material3.TopAppBarDefaults
37import androidx.compose.runtime.Composable
38import androidx.compose.runtime.LaunchedEffect
39import androidx.compose.runtime.getValue
40import androidx.compose.runtime.mutableStateOf
41import androidx.compose.runtime.remember
42import androidx.compose.runtime.saveable.rememberSaveable
43import androidx.compose.runtime.setValue
44import androidx.compose.ui.Alignment
45import androidx.compose.ui.Modifier
46import androidx.compose.ui.focus.FocusRequester
47import androidx.compose.ui.focus.focusRequester
48import androidx.compose.ui.input.nestedscroll.nestedScroll
49import androidx.compose.ui.platform.LocalSoftwareKeyboardController
50import androidx.compose.ui.platform.testTag
51import androidx.compose.ui.semantics.contentDescription
52import androidx.compose.ui.semantics.semantics
53import androidx.compose.ui.text.input.ImeAction
54import androidx.compose.ui.text.input.KeyboardType
55import androidx.compose.ui.text.input.PasswordVisualTransformation
56import androidx.compose.ui.text.input.VisualTransformation
57import androidx.compose.ui.res.stringResource
58import androidx.compose.ui.unit.dp
59import androidx.lifecycle.compose.collectAsStateWithLifecycle
60import androidx.lifecycle.viewmodel.compose.viewModel
61import soy.iko.opencode.di.AppContainer
62import soy.iko.opencode.R
63import soy.iko.opencode.ui.components.autofillModifier
64import soy.iko.opencode.ui.components.AutofillHint
65import soy.iko.opencode.ui.vmFactory
67@OptIn(ExperimentalMaterial3Api::class)
70 container: AppContainer,
73 val vm: ServerEditViewModel =
74 viewModel(factory = vmFactory { ServerEditViewModel(container) })
75 val state by vm.state.collectAsStateWithLifecycle()
76 var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
77 // Keyed on state.id so the password-reveal flag resets when a different profile is loaded
78 // into the same back-stack entry. Without the key, revealing server A's password and then
79 // editing server B (which reuses this entry via nav back-and-forth) would show B's password
80 // in cleartext without the user tapping the eye icon — a minor credential info-leak.
81 var passwordVisible by rememberSaveable(state.id) { mutableStateOf(false) }
85 // A save is in flight: ignore the back gesture (matching the BackHandler guard
86 // below). Popping here would cancel the save's viewModelScope coroutine and
87 // silently abandon the write; the VM calls onDone itself once the save lands.
89 state.isDirty -> showDiscardConfirm = true
94 BackHandler(enabled = (state.isDirty || state.saving) && !showDiscardConfirm) {
95 if (!state.saving) showDiscardConfirm = true
98 val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
102 title = { Text(stringResource(R.string.add_server)) },
104 IconButton(onClick = ::safeExit) {
105 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
108 scrollBehavior = scrollBehavior,
113 val loadingLabel = stringResource(R.string.loading)
114 Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
115 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
120 passwordVisible = passwordVisible,
121 onTogglePassword = { passwordVisible = !passwordVisible },
123 scrollBehavior = scrollBehavior,
130 if (showDiscardConfirm) {
132 onDismissRequest = { showDiscardConfirm = false },
133 title = { Text(stringResource(R.string.discard_title)) },
134 text = { Text(stringResource(R.string.discard_text)) },
136 TextButton(onClick = {
137 showDiscardConfirm = false
139 }) { Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) }
142 TextButton(onClick = { showDiscardConfirm = false }) { Text(stringResource(R.string.keep_editing)) }
148@OptIn(ExperimentalMaterial3Api::class)
150private fun ServerEditForm(
151 state: ServerEditState,
152 passwordVisible: Boolean,
153 onTogglePassword: () -> Unit,
154 padding: androidx.compose.foundation.layout.PaddingValues,
155 scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
156 vm: ServerEditViewModel,
159 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
160 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
161 val urlValid = remember(state.baseUrl) { isValidUrl(state.baseUrl) }
162 val baseUrlFocus = remember { FocusRequester() }
163 val keyboard = LocalSoftwareKeyboardController.current
164 // Focus the Base URL field (and raise the keyboard) on the fresh Add-server form so the
165 // user can start typing the required field immediately.
166 LaunchedEffect(Unit) {
167 if (state.baseUrl.isBlank()) {
168 baseUrlFocus.requestFocus()
177 .wrapContentWidth(Alignment.CenterHorizontally)
178 .widthIn(max = 600.dp)
180 .nestedScroll(scrollBehavior.nestedScrollConnection)
181 .verticalScroll(rememberScrollState()),
182 verticalArrangement = Arrangement.spacedBy(12.dp),
184 // Base URL is the only required field, so it leads the form and takes focus on a
185 // fresh add form (via baseUrlFocus above).
187 value = state.baseUrl,
188 onValueChange = { v -> vm.update { it.copy(baseUrl = v) } },
189 label = { Text(stringResource(R.string.base_url)) },
190 placeholder = { Text(stringResource(R.string.base_url_hint)) },
192 isError = state.baseUrl.isNotBlank() && !urlValid,
194 if (state.baseUrl.isNotBlank() && !urlValid) {
195 Text(stringResource(R.string.invalid_url))
198 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
199 keyboardActions = KeyboardActions(onDone = { keyboard?.hide() }),
202 .focusRequester(baseUrlFocus)
203 .testTag("server_url"),
205 // Auth fields appear only once a connect attempt learns the server requires them
206 // (or an authed profile is loaded) — the common no-auth server never sees them.
207 AnimatedVisibility(visible = state.authFieldsVisible) {
210 passwordVisible = passwordVisible,
211 onTogglePassword = onTogglePassword,
212 onUpdate = vm::update,
213 onTestCredentials = vm::testCredentials,
214 onImeDone = { if (canSave && !state.saving) vm.connect(onDone) },
217 // Warn when credentials will travel over cleartext HTTP: an http:// URL with a
218 // username sends Basic auth unencrypted on every request. Surface this prominently
219 // so the user switches to https:// (or enables Require HTTPS) rather than silently
220 // leaking the password on a hostile LAN.
221 if (state.baseUrl.lowercase().startsWith("http://") && state.username.isNotBlank()) {
223 stringResource(R.string.cleartext_credentials_warning),
224 color = MaterialTheme.colorScheme.error,
225 style = MaterialTheme.typography.bodySmall,
226 modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
229 // Single primary action: probe-then-save-and-connect. The probe IS the connection
230 // test — a failed probe keeps the user on this screen with the error shown below.
233 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
236 enabled = canSave && !state.saving,
237 modifier = Modifier.fillMaxWidth().testTag("server_connect"),
240 CircularProgressIndicator(
241 modifier = Modifier.size(20.dp),
242 color = MaterialTheme.colorScheme.onPrimary,
245 Text(stringResource(R.string.connecting), modifier = Modifier.padding(start = 8.dp))
247 Text(stringResource(R.string.connect))
250 // Keep the error directly under the primary action so it's in view where the user is
251 // looking when a Connect/Save fails — not buried further down the form.
252 state.error?.let { message ->
255 color = MaterialTheme.colorScheme.error,
256 style = MaterialTheme.typography.bodySmall,
257 modifier = Modifier.fillMaxWidth(),
264private fun AuthFields(
265 state: ServerEditState,
266 passwordVisible: Boolean,
267 onTogglePassword: () -> Unit,
268 onUpdate: ((ServerEditState) -> ServerEditState) -> Unit,
269 onTestCredentials: () -> Unit,
270 onImeDone: () -> Unit,
272 val showPasswordLabel = stringResource(R.string.show_password)
273 val hidePasswordLabel = stringResource(R.string.hide_password)
274 // Memoize the URI parse on baseUrl so the test-credentials button doesn't re-parse it on
275 // every keystroke's recomposition (see ServerEditForm).
276 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
277 val usernameFocus = remember { FocusRequester() }
278 val passwordFocus = remember { FocusRequester() }
279 // AuthFields only enters composition once the server is known to require credentials
280 // (AnimatedVisibility in ServerEditForm), so a Unit-keyed effect fires once on reveal
281 // and focuses the username — otherwise the user has to tap into it after the probe flips
282 // the auth fields open.
283 LaunchedEffect(Unit) {
284 runCatching { usernameFocus.requestFocus() }
286 Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
288 stringResource(R.string.auth_required),
289 style = MaterialTheme.typography.bodySmall,
290 color = MaterialTheme.colorScheme.error,
293 value = state.username,
294 onValueChange = { v -> onUpdate { it.copy(username = v) } },
295 label = { Text(stringResource(R.string.username_optional)) },
297 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
298 keyboardActions = KeyboardActions(onNext = { passwordFocus.requestFocus() }),
301 .focusRequester(usernameFocus)
302 .then(autofillModifier(AutofillHint.Username) { v -> onUpdate { it.copy(username = v) } })
303 .testTag("server_username"),
306 value = state.password,
307 onValueChange = { v -> onUpdate { it.copy(password = v) } },
308 label = { Text(stringResource(R.string.password_optional)) },
310 visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
312 IconButton(onClick = onTogglePassword) {
314 if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
315 contentDescription = if (passwordVisible) hidePasswordLabel else showPasswordLabel,
319 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
320 keyboardActions = KeyboardActions(onDone = { onImeDone() }),
323 .focusRequester(passwordFocus)
324 .then(autofillModifier(AutofillHint.Password) { v -> onUpdate { it.copy(password = v) } })
325 .testTag("server_password"),
328 onClick = onTestCredentials,
329 // Require at least one credential field: testCredentials() no-ops when both are
330 // blank, so without this the button is tappable but silently does nothing.
331 enabled = canSave && !state.testingCredentials && !state.saving &&
332 (state.username.isNotBlank() || state.password.isNotBlank()),
333 modifier = Modifier.fillMaxWidth().testTag("server_test_creds"),
335 if (state.testingCredentials) {
336 CircularProgressIndicator(
337 modifier = Modifier.size(20.dp),
340 Text(stringResource(R.string.testing_credentials), modifier = Modifier.padding(start = 8.dp))
342 Text(stringResource(R.string.test_credentials))
345 state.credentialsResult?.let { ok ->
347 stringResource(if (ok) R.string.credentials_ok else R.string.credentials_rejected),
348 style = MaterialTheme.typography.bodySmall,
349 color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
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