1package soy.iko.opencode.ui.server
3import androidx.compose.runtime.Immutable
4import androidx.lifecycle.ViewModel
5import androidx.lifecycle.viewModelScope
6import soy.iko.opencode.data.model.ServerProfile
7import soy.iko.opencode.data.network.NetworkConfig
8import soy.iko.opencode.di.AppContainer
9import soy.iko.opencode.di.ProbeResult
10import kotlinx.coroutines.flow.MutableStateFlow
11import kotlinx.coroutines.flow.StateFlow
12import kotlinx.coroutines.flow.asStateFlow
13import kotlinx.coroutines.flow.first
14import kotlinx.coroutines.flow.update
15import kotlinx.coroutines.launch
16import kotlinx.coroutines.withTimeoutOrNull
17import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
18import soy.iko.opencode.R
19import soy.iko.opencode.util.runCatchingCancellable
23data class ServerEditState(
24 val id: String? = null,
25 val label: String = "",
26 val baseUrl: String = "",
27 val username: String = "",
28 val password: String = "",
29 /** Upgrade cleartext http:// to https:// for this server. */
30 val requireHttps: Boolean = false,
31 /** Optional OkHttp certificate pin(s), "sha256/<base64>" (whitespace/comma separated). */
32 val certPin: String = "",
33 val loaded: Boolean = false,
34 val saving: Boolean = false,
35 val error: String? = null,
36 /** Whether the auth (username/password) fields are shown. */
37 val authFieldsVisible: Boolean = false,
38 /** Whether a connectivity/auth probe is in progress. */
39 val probing: Boolean = false,
40 /** Snapshot of the profile as loaded (for dirty detection). Null until loaded. */
41 val initial: InitialProfile? = null,
42 /** Whether a credential test is in progress. */
43 val testingCredentials: Boolean = false,
44 /** Non-null when the last credential test succeeded (true) or failed (false). */
45 val credentialsResult: Boolean? = null,
46 /** True when the last connectivity probe found the server reachable without auth.
47 * Null until a probe runs, and cleared when the base URL is edited. */
48 val probeReachable: Boolean? = null,
50 val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl) && certPinValid
51 val isNew: Boolean get() = id == null
52 /** A blank pin is fine (feature off); otherwise every entry must be a valid OkHttp pin. */
53 val certPinValid: Boolean get() = isValidCertPin(certPin)
54 /** True if any field differs from its loaded value. */
57 val init = initial ?: return false
58 return label.trim() != init.label ||
59 baseUrl.trim() != init.baseUrl ||
60 username.trim() != init.username ||
61 password.trim() != init.password ||
62 requireHttps != init.requireHttps ||
63 certPin.trim() != init.certPin
67/** Snapshot of the profile loaded into the editor, normalized the same way save() stores it. */
68data class InitialProfile(
73 val requireHttps: Boolean = false,
74 val certPin: String = "",
77/** True when the certificate-pin field is empty (feature off) or every whitespace/comma
78 * separated entry is a well-formed OkHttp "sha256/<base64>" pin. */
79fun isValidCertPin(raw: String): Boolean {
80 val entries = raw.split(Regex("[\\s,]+")).map { it.trim() }.filter { it.isNotEmpty() }
81 if (entries.isEmpty()) return true
82 // FIX 21(c): require a well-formed sha256/<base64>= pin (at least one base64 char
83 // before the padding). This rejects obviously malformed entries like "sha256/=" or "sha256/===".
84 // A real SHA-256 pin is 43 base64 chars + '=', but we accept any length for demo/test pins.
85 val pinRegex = Regex("sha256/[A-Za-z0-9+/]+=")
86 return entries.all { it.matches(pinRegex) }
89fun isValidUrl(url: String): Boolean {
90 val trimmed = url.trim()
91 if (trimmed.isBlank()) return false
92 // Parse with OkHttp's own parser (like HttpClientFactory) — java.net.URI rejects hosts
93 // OkHttp accepts (e.g. underscores in "my_server.local") and demands an exact-lowercase
94 // scheme ("HTTP://" fails). toHttpUrlOrNull only accepts http/https, so a successful parse
95 // already implies a valid scheme; a bare host without a scheme returns null, keeping the
96 // "suggest http:// prefix" flow in suggestUrlScheme() intact.
97 return trimmed.toHttpUrlOrNull() != null
101 * If [input] looks like a bare host (optionally with a port/path) and lacks a scheme,
102 * return the suggested `http://`-prefixed form (only when it parses to a valid URL).
103 * Lets the UI offer a one-tap fix instead of a generic "invalid URL" error.
105fun suggestUrlScheme(input: String): String? {
106 val trimmed = input.trim()
107 if (trimmed.isEmpty() || "://" in trimmed) return null
108 val candidate = "http://$trimmed"
109 return if (isValidUrl(candidate)) candidate else null
112class ServerEditViewModel(
113 private val container: AppContainer,
114 private val profileId: String?,
115 private val sourceId: String? = null,
118 private val _state = MutableStateFlow(ServerEditState(id = profileId))
119 val state: StateFlow<ServerEditState> = _state.asStateFlow()
122 viewModelScope.launch {
123 if (profileId != null) {
124 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
125 container.profileStore.profiles
126 .first { list -> list.any { p -> p.id == profileId } }
127 .firstOrNull { it.id == profileId }
129 if (existing != null) {
130 // FIX 21(a): normalize (trim) the dirty-snapshot fields the same way
131 // save() stores them, so a stored value with surrounding whitespace
132 // doesn't make the freshly-opened editor spuriously dirty (isDirty
133 // compares field.trim() against these).
134 val init = InitialProfile(
135 label = existing.label.trim(),
136 baseUrl = existing.baseUrl.trim(),
137 username = existing.username.orEmpty().trim(),
138 password = existing.password.orEmpty().trim(),
139 requireHttps = existing.requireHttps,
140 certPin = existing.certPin.orEmpty().trim(),
142 _state.value = ServerEditState(
144 label = existing.label,
145 baseUrl = existing.baseUrl,
146 username = existing.username.orEmpty(),
147 password = existing.password.orEmpty(),
148 requireHttps = existing.requireHttps,
149 certPin = existing.certPin.orEmpty(),
151 authFieldsVisible = existing.hasAuth,
156 _state.value = ServerEditState(
159 error = container.string(R.string.error_load_timeout),
163 // Duplicate: seed the form from an existing profile but as a NEW profile
164 // (id stays null so save() generates a fresh one). Appends " (copy)" to the
165 // label so the user can tell the duplicate from the original at a glance.
166 if (sourceId != null) {
167 val source = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
168 container.profileStore.profiles
169 .first { list -> list.any { p -> p.id == sourceId } }
170 .firstOrNull { it.id == sourceId }
172 if (source != null) {
173 val dupLabel = if (source.label.isBlank()) source.baseUrl + " (copy)" else "${source.label} (copy)"
174 _state.value = ServerEditState(
177 baseUrl = source.baseUrl,
178 username = source.username.orEmpty(),
179 password = source.password.orEmpty(),
180 requireHttps = source.requireHttps,
181 certPin = source.certPin.orEmpty(),
183 authFieldsVisible = source.hasAuth,
184 // Snapshot the seeded (copy) values so an untouched duplicate isn't
185 // immediately dirty — an empty initial would make backing out of a
186 // freshly opened duplicate always trigger the discard-changes dialog.
187 initial = InitialProfile(
188 label = dupLabel.trim(),
189 baseUrl = source.baseUrl.trim(),
190 username = source.username.orEmpty().trim(),
191 password = source.password.orEmpty().trim(),
192 requireHttps = source.requireHttps,
193 certPin = source.certPin.orEmpty().trim(),
198 // Source not found — fall through to a blank new-profile form.
200 // New-profile form: seed an initial snapshot of empty values so isDirty
201 // becomes true the moment the user types anything.
205 initial = InitialProfile("", "", "", ""),
211 fun update(transform: (ServerEditState) -> ServerEditState) {
212 // Clear both the inline credential note and the bottom-of-form error when any field
213 // changes: once the user starts editing (e.g. correcting a rejected password), a stale
214 // "Credentials rejected" / test-failure message no longer describes the current input.
215 _state.update { transform(it).copy(credentialsResult = null, error = null) }
220 if (!s.canSave || s.probing) return
221 _state.update { it.copy(probing = true, error = null, credentialsResult = null, probeReachable = null) }
222 // FIX 21(b): probe the trimmed URL that save() would actually store.
223 // FIX 11: remember which URL we probed so a stale result is dropped if the
224 // user edited the base URL away while the probe was in flight.
225 val probedUrl = s.baseUrl.trim()
226 viewModelScope.launch {
227 val result = runCatchingCancellable { container.probeServer(probedUrl) }
228 result.onSuccess { pr ->
230 // Stale result (URL edited away mid-probe): drop the reachability verdict but
231 // still clear the spinner, else `probing` latches true and blocks re-probing.
232 if (it.baseUrl.trim() != probedUrl) return@update it.copy(probing = false)
234 is ProbeResult.Reachable -> it.copy(
236 authFieldsVisible = false,
238 probeReachable = true,
240 is ProbeResult.NeedsAuth -> it.copy(
242 authFieldsVisible = true,
244 probeReachable = false,
246 is ProbeResult.Unreachable -> it.copy(
248 authFieldsVisible = false,
250 probeReachable = false,
256 if (it.baseUrl.trim() != probedUrl) return@update it.copy(probing = false)
257 it.copy(probing = false, error = container.friendlyError(e), probeReachable = false)
264 * Probe the server *with* the entered credentials to validate them before saving.
265 * Only meaningful once auth fields are visible (the server requires auth); calling
266 * it when no auth is needed is a no-op. Surfaces [ServerEditState.credentialsResult]
267 * so the UI can show success/failure without dismissing the form.
269 fun testCredentials() {
271 if (!s.canSave || s.testingCredentials) return
272 if (s.username.isBlank() && s.password.isBlank()) return
273 _state.update { it.copy(testingCredentials = true, credentialsResult = null, error = null) }
274 // FIX 21(b): test the trimmed URL/credentials that save() would actually store.
275 // FIX 11: remember the probed URL so a stale result is dropped if the user
276 // edited the base URL away while the test was in flight.
277 val probedUrl = s.baseUrl.trim()
278 val probedUser = s.username.trim()
279 val probedPass = s.password.trim()
280 viewModelScope.launch {
281 val result = runCatchingCancellable { container.probeWithCredentials(probedUrl, probedUser, probedPass) }
282 result.onSuccess { ok ->
284 // Stale result: clear the spinner but drop the verdict (see probe()).
285 if (it.baseUrl.trim() != probedUrl) return@update it.copy(testingCredentials = false)
287 testingCredentials = false,
288 credentialsResult = ok,
289 error = if (ok) null else container.string(R.string.credentials_rejected),
294 if (it.baseUrl.trim() != probedUrl) return@update it.copy(testingCredentials = false)
296 testingCredentials = false,
297 credentialsResult = false,
298 error = container.friendlyError(e),
305 fun save(onDone: () -> Unit) {
306 saveInternal(connectAfter = false, onDone = onDone)
309 /** Save and then connect to the saved profile in one step. */
310 fun saveAndConnect(onDone: () -> Unit) {
311 saveInternal(connectAfter = true, onDone = onDone)
314 private fun saveInternal(connectAfter: Boolean, onDone: () -> Unit) {
316 if (!s.canSave || s.saving) return
317 _state.update { it.copy(saving = true) }
318 viewModelScope.launch {
319 val result = runCatchingCancellable {
320 val existingLastUsed = if (s.id != null) {
321 withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
322 container.profileStore.profiles.first()
323 .firstOrNull { it.id == s.id }?.lastUsed
326 val saved = ServerProfile(
327 id = s.id ?: UUID.randomUUID().toString(),
328 label = s.label.trim(),
329 baseUrl = s.baseUrl.trim(),
330 username = s.username.trim().takeIf { it.isNotBlank() },
331 password = s.password.trim().takeIf { it.isNotEmpty() },
332 lastUsed = existingLastUsed,
333 requireHttps = s.requireHttps,
334 certPin = s.certPin.trim().takeIf { it.isNotBlank() },
336 container.profileStore.save(saved)
337 // FIX 3: persist the (possibly freshly-generated) id and the normalized
338 // values back into state right after the save succeeds — before the
339 // connect/ping that may throw. Otherwise a retry after a connect failure
340 // would see id == null again, mint a new UUID and create a DUPLICATE
341 // profile instead of upserting the same row. Also refresh the dirty
342 // snapshot so the just-saved values no longer read as unsaved changes.
346 initial = InitialProfile(
348 baseUrl = saved.baseUrl,
349 username = saved.username.orEmpty(),
350 password = saved.password.orEmpty(),
351 requireHttps = saved.requireHttps,
352 certPin = saved.certPin.orEmpty(),
357 container.connect(saved)
358 container.activeConnection.value?.api?.ping()
361 result.onSuccess { onDone() }
362 .onFailure { e -> _state.update { it.copy(error = container.friendlyError(e), saving = false) } }
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