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>" (or legacy "sha1/<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 // Require a well-formed sha256/ or sha1/ <base64>= pin (at least one base64 char before the
83 // padding). This rejects obviously malformed entries like "sha256/=" or "sha256/===" and
84 // unsupported algorithms like "md5/…". sha1 is accepted because OkHttp's CertificatePinner
85 // accepts it too — the validator must match what the runtime accepts, or a well-formed
86 // sha1 pin would be un-saveable. A real pin is 43 base64 chars + '=', but we accept any
87 // length for demo/test pins.
88 val pinRegex = Regex("(sha256|sha1)/[A-Za-z0-9+/]+=")
89 return entries.all { it.matches(pinRegex) }
92fun isValidUrl(url: String): Boolean {
93 val trimmed = url.trim()
94 if (trimmed.isBlank()) return false
95 // Parse with OkHttp's own parser (like HttpClientFactory) — java.net.URI rejects hosts
96 // OkHttp accepts (e.g. underscores in "my_server.local") and demands an exact-lowercase
97 // scheme ("HTTP://" fails). toHttpUrlOrNull only accepts http/https, so a successful parse
98 // already implies a valid scheme; a bare host without a scheme returns null, keeping the
99 // "suggest http:// prefix" flow in suggestUrlScheme() intact.
100 return trimmed.toHttpUrlOrNull() != null
104 * If [input] looks like a bare host (optionally with a port/path) and lacks a scheme,
105 * return the suggested `http://`-prefixed form (only when it parses to a valid URL).
106 * Lets the UI offer a one-tap fix instead of a generic "invalid URL" error.
108fun suggestUrlScheme(input: String): String? {
109 val trimmed = input.trim()
110 if (trimmed.isEmpty() || "://" in trimmed) return null
111 val candidate = "http://$trimmed"
112 return if (isValidUrl(candidate)) candidate else null
115class ServerEditViewModel(
116 private val container: AppContainer,
117 private val profileId: String?,
118 private val sourceId: String? = null,
121 private val _state = MutableStateFlow(ServerEditState(id = profileId))
122 val state: StateFlow<ServerEditState> = _state.asStateFlow()
125 viewModelScope.launch {
126 if (profileId != null) {
127 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
128 container.profileStore.profiles
129 .first { list -> list.any { p -> p.id == profileId } }
130 .firstOrNull { it.id == profileId }
132 if (existing != null) {
133 // FIX 21(a): normalize (trim) the dirty-snapshot fields the same way
134 // save() stores them, so a stored value with surrounding whitespace
135 // doesn't make the freshly-opened editor spuriously dirty (isDirty
136 // compares field.trim() against these).
137 val init = InitialProfile(
138 label = existing.label.trim(),
139 baseUrl = existing.baseUrl.trim(),
140 username = existing.username.orEmpty().trim(),
141 password = existing.password.orEmpty().trim(),
142 requireHttps = existing.requireHttps,
143 certPin = existing.certPin.orEmpty().trim(),
145 _state.value = ServerEditState(
147 label = existing.label,
148 baseUrl = existing.baseUrl,
149 username = existing.username.orEmpty(),
150 password = existing.password.orEmpty(),
151 requireHttps = existing.requireHttps,
152 certPin = existing.certPin.orEmpty(),
154 authFieldsVisible = existing.hasAuth,
159 _state.value = ServerEditState(
162 error = container.string(R.string.error_load_timeout),
166 // Duplicate: seed the form from an existing profile but as a NEW profile
167 // (id stays null so save() generates a fresh one). Appends " (copy)" to the
168 // label so the user can tell the duplicate from the original at a glance.
169 if (sourceId != null) {
170 val source = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
171 container.profileStore.profiles
172 .first { list -> list.any { p -> p.id == sourceId } }
173 .firstOrNull { it.id == sourceId }
175 if (source != null) {
176 val dupLabel = if (source.label.isBlank()) source.baseUrl + " (copy)" else "${source.label} (copy)"
177 _state.value = ServerEditState(
180 baseUrl = source.baseUrl,
181 username = source.username.orEmpty(),
182 password = source.password.orEmpty(),
183 requireHttps = source.requireHttps,
184 certPin = source.certPin.orEmpty(),
186 authFieldsVisible = source.hasAuth,
187 // Snapshot the seeded (copy) values so an untouched duplicate isn't
188 // immediately dirty — an empty initial would make backing out of a
189 // freshly opened duplicate always trigger the discard-changes dialog.
190 initial = InitialProfile(
191 label = dupLabel.trim(),
192 baseUrl = source.baseUrl.trim(),
193 username = source.username.orEmpty().trim(),
194 password = source.password.orEmpty().trim(),
195 requireHttps = source.requireHttps,
196 certPin = source.certPin.orEmpty().trim(),
201 // Source not found — fall through to a blank new-profile form.
203 // New-profile form: seed an initial snapshot of empty values so isDirty
204 // becomes true the moment the user types anything.
208 initial = InitialProfile("", "", "", ""),
214 fun update(transform: (ServerEditState) -> ServerEditState) {
215 // Clear both the inline credential note and the bottom-of-form error when any field
216 // changes: once the user starts editing (e.g. correcting a rejected password), a stale
217 // "Credentials rejected" / test-failure message no longer describes the current input.
218 _state.update { transform(it).copy(credentialsResult = null, error = null) }
223 if (!s.canSave || s.probing) return
224 _state.update { it.copy(probing = true, error = null, credentialsResult = null, probeReachable = null) }
225 // FIX 21(b): probe the trimmed URL that save() would actually store.
226 // FIX 11: remember which URL we probed so a stale result is dropped if the
227 // user edited the base URL away while the probe was in flight.
228 val probedUrl = s.baseUrl.trim()
229 val probedRequireHttps = s.requireHttps
230 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
231 viewModelScope.launch {
232 val result = runCatchingCancellable { container.probeServer(probedUrl, probedRequireHttps, probedCertPin) }
233 result.onSuccess { pr ->
235 // Stale result (URL edited away mid-probe): drop the reachability verdict but
236 // still clear the spinner, else `probing` latches true and blocks re-probing.
237 if (it.baseUrl.trim() != probedUrl) return@update it.copy(probing = false)
239 is ProbeResult.Reachable -> it.copy(
241 authFieldsVisible = false,
243 probeReachable = true,
245 is ProbeResult.NeedsAuth -> it.copy(
247 authFieldsVisible = true,
249 probeReachable = false,
251 is ProbeResult.Unreachable -> it.copy(
253 authFieldsVisible = false,
255 probeReachable = false,
261 if (it.baseUrl.trim() != probedUrl) return@update it.copy(probing = false)
262 it.copy(probing = false, error = container.friendlyError(e), probeReachable = false)
269 * Probe the server *with* the entered credentials to validate them before saving.
270 * Only meaningful once auth fields are visible (the server requires auth); calling
271 * it when no auth is needed is a no-op. Surfaces [ServerEditState.credentialsResult]
272 * so the UI can show success/failure without dismissing the form.
274 fun testCredentials() {
276 if (!s.canSave || s.testingCredentials) return
277 if (s.username.isBlank() && s.password.isBlank()) return
278 _state.update { it.copy(testingCredentials = true, credentialsResult = null, error = null) }
279 // FIX 21(b): test the trimmed URL/credentials that save() would actually store.
280 // FIX 11: remember the probed URL so a stale result is dropped if the user
281 // edited the base URL away while the test was in flight.
282 val probedUrl = s.baseUrl.trim()
283 val probedUser = s.username.trim()
284 val probedPass = s.password.trim()
285 val probedRequireHttps = s.requireHttps
286 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
287 viewModelScope.launch {
288 val result = runCatchingCancellable {
289 container.probeWithCredentials(probedUrl, probedUser, probedPass, probedRequireHttps, probedCertPin)
291 result.onSuccess { ok ->
293 // Stale result: clear the spinner but drop the verdict (see probe()).
294 if (it.baseUrl.trim() != probedUrl) return@update it.copy(testingCredentials = false)
296 testingCredentials = false,
297 credentialsResult = ok,
298 error = if (ok) null else container.string(R.string.credentials_rejected),
303 if (it.baseUrl.trim() != probedUrl) return@update it.copy(testingCredentials = false)
305 testingCredentials = false,
306 credentialsResult = false,
307 error = container.friendlyError(e),
314 fun save(onDone: () -> Unit) {
315 saveInternal(connectAfter = false, onDone = onDone)
318 /** Save and then connect to the saved profile in one step. */
319 fun saveAndConnect(onDone: () -> Unit) {
320 saveInternal(connectAfter = true, onDone = onDone)
323 private fun saveInternal(connectAfter: Boolean, onDone: () -> Unit) {
325 if (!s.canSave || s.saving) return
326 _state.update { it.copy(saving = true) }
327 viewModelScope.launch {
328 val result = runCatchingCancellable {
329 val existingLastUsed = if (s.id != null) {
330 withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
331 container.profileStore.profiles.first()
332 .firstOrNull { it.id == s.id }?.lastUsed
335 val saved = ServerProfile(
336 id = s.id ?: UUID.randomUUID().toString(),
337 label = s.label.trim(),
338 baseUrl = s.baseUrl.trim(),
339 username = s.username.trim().takeIf { it.isNotBlank() },
340 password = s.password.trim().takeIf { it.isNotEmpty() },
341 lastUsed = existingLastUsed,
342 requireHttps = s.requireHttps,
343 certPin = s.certPin.trim().takeIf { it.isNotBlank() },
345 container.profileStore.save(saved)
346 // FIX 3: persist the (possibly freshly-generated) id and the normalized
347 // values back into state right after the save succeeds — before the
348 // connect/ping that may throw. Otherwise a retry after a connect failure
349 // would see id == null again, mint a new UUID and create a DUPLICATE
350 // profile instead of upserting the same row. Also refresh the dirty
351 // snapshot so the just-saved values no longer read as unsaved changes.
355 initial = InitialProfile(
357 baseUrl = saved.baseUrl,
358 username = saved.username.orEmpty(),
359 password = saved.password.orEmpty(),
360 requireHttps = saved.requireHttps,
361 certPin = saved.certPin.orEmpty(),
366 container.connect(saved)
367 container.activeConnection.value?.api?.ping()
370 result.onSuccess { onDone() }
371 .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