1package soy.iko.opencode.ui.server
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
5import soy.iko.opencode.data.model.ServerProfile
6import soy.iko.opencode.data.network.NetworkConfig
7import soy.iko.opencode.di.AppContainer
8import kotlinx.coroutines.flow.MutableStateFlow
9import kotlinx.coroutines.flow.StateFlow
10import kotlinx.coroutines.flow.asStateFlow
11import kotlinx.coroutines.flow.first
12import kotlinx.coroutines.flow.update
13import kotlinx.coroutines.launch
14import kotlinx.coroutines.withTimeoutOrNull
15import soy.iko.opencode.R
16import soy.iko.opencode.util.runCatchingCancellable
20 * ViewModel for the Server Settings screen, which edits an *existing* saved server profile.
21 * Holds the fields that don't appear on the add-server form: the display label, the advanced
22 * security options (require-HTTPS + certificate pin), plus the URL and credentials. Saving
23 * persists the profile and, when it's the active connection, reconnects to it so the edit
24 * takes effect immediately. Deleting disconnects first when the profile is active.
26class ServerSettingsViewModel(
27 private val container: AppContainer,
28 private val profileId: String,
31 private val _state = MutableStateFlow(ServerEditState(id = profileId))
32 val state: StateFlow<ServerEditState> = _state.asStateFlow()
35 viewModelScope.launch {
36 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
37 container.profileStore.profiles
38 .first { list -> list.any { p -> p.id == profileId } }
39 .firstOrNull { it.id == profileId }
41 if (existing != null) {
42 // Normalize (trim) the dirty-snapshot fields the same way save() stores them,
43 // so a stored value with surrounding whitespace doesn't make the freshly-opened
44 // settings screen spuriously dirty (isDirty compares field.trim() against these).
45 val init = InitialProfile(
46 label = existing.label.trim(),
47 baseUrl = existing.baseUrl.trim(),
48 username = existing.username.orEmpty().trim(),
49 password = existing.password.orEmpty().trim(),
50 requireHttps = existing.requireHttps,
51 certPin = existing.certPin.orEmpty().trim(),
53 _state.value = ServerEditState(
55 label = existing.label,
56 baseUrl = existing.baseUrl,
57 username = existing.username.orEmpty(),
58 password = existing.password.orEmpty(),
59 requireHttps = existing.requireHttps,
60 certPin = existing.certPin.orEmpty(),
62 authFieldsVisible = existing.hasAuth,
67 _state.value = ServerEditState(
70 error = container.string(R.string.error_load_timeout),
75 fun update(transform: (ServerEditState) -> ServerEditState) {
76 // Clear both the inline credential note and the bottom-of-form error when any field
77 // changes: once the user starts editing (e.g. correcting a rejected password), a stale
78 // "Credentials rejected" message no longer describes the current input.
79 _state.update { transform(it).copy(credentialsResult = null, error = null) }
83 * Probe the server *with* the entered credentials to validate them before saving. Only
84 * meaningful once auth fields are visible (the server requires auth). Surfaces
85 * [ServerEditState.credentialsResult] so the UI can show success/failure without
86 * dismissing the form.
88 fun testCredentials() {
90 if (!s.canSave || s.testingCredentials) return
91 if (s.username.isBlank() && s.password.isBlank()) return
92 _state.update { it.copy(testingCredentials = true, credentialsResult = null, error = null) }
93 // Test the trimmed URL/credentials that save() would actually store, and remember the
94 // probed URL so a stale result is dropped if the user edited the base URL away while
95 // the test was in flight.
96 val probedUrl = normalizeForSave(s.baseUrl)
97 val probedUser = s.username.trim()
98 val probedPass = s.password.trim()
99 val probedRequireHttps = s.requireHttps
100 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
101 viewModelScope.launch {
102 val result = runCatchingCancellable {
103 withTimeoutOrNull(NetworkConfig.testCredentialsTimeoutMs) {
104 container.probeWithCredentials(probedUrl, probedUser, probedPass, probedRequireHttps, probedCertPin)
107 result.onSuccess { ok ->
109 if (normalizeForSave(it.baseUrl) != probedUrl) return@update it.copy(testingCredentials = false)
111 testingCredentials = false,
112 credentialsResult = ok,
113 error = if (ok == true) null else container.string(R.string.credentials_rejected),
118 if (normalizeForSave(it.baseUrl) != probedUrl) return@update it.copy(testingCredentials = false)
120 testingCredentials = false,
121 credentialsResult = false,
122 error = container.friendlyError(e),
130 * Persist the edited profile. When it's the active connection, reconnect so the change
131 * (URL, credentials, security) takes effect immediately. Fires [onDone] on success so the
134 fun save(onDone: () -> Unit) {
136 if (!s.canSave || s.saving || s.testingCredentials) return
137 _state.update { it.copy(saving = true, error = null) }
138 val savedBaseUrl = normalizeForSave(s.baseUrl)
139 viewModelScope.launch {
140 val result = runCatchingCancellable {
141 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
142 container.profileStore.profiles.first()
144 // Duplicate detection: warn when a *different* profile with the same URL and
145 // label already exists. Excludes the profile being edited (by id) so re-saving
146 // the same profile doesn't trigger.
147 val savedLabel = s.label.trim().ifBlank { hostFromBaseUrl(savedBaseUrl) }
148 val duplicate = existing.any { it.id != s.id && it.baseUrl == savedBaseUrl && it.label == savedLabel }
150 throw java.io.IOException(container.string(R.string.duplicate_server_warning))
152 // Preserve lastUsed so re-saving doesn't reset the profile's sort order. When
153 // the load timed out (existing is empty) fall back to now rather than 0, which
154 // would silently drop the user's most-recently-used server to the list bottom.
155 val existingLastUsed = existing.firstOrNull { it.id == s.id }?.lastUsed ?: System.currentTimeMillis()
156 val saved = ServerProfile(
157 id = s.id ?: UUID.randomUUID().toString(),
159 baseUrl = savedBaseUrl,
160 username = s.username.trim().takeIf { it.isNotBlank() },
161 password = s.password.trim().takeIf { it.isNotEmpty() },
162 lastUsed = existingLastUsed,
163 requireHttps = s.requireHttps,
164 certPin = s.certPin.trim().takeIf { it.isNotBlank() },
166 container.profileStore.save(saved)
167 // Persist the normalized values back into state and refresh the dirty snapshot
168 // so the just-saved values no longer read as unsaved changes.
173 baseUrl = saved.baseUrl,
174 initial = InitialProfile(
176 baseUrl = saved.baseUrl,
177 username = saved.username.orEmpty(),
178 password = saved.password.orEmpty(),
179 requireHttps = saved.requireHttps,
180 certPin = saved.certPin.orEmpty(),
184 // If this is the active profile, reconnect so the edit (URL/creds/security)
185 // takes effect immediately. A non-active profile just persists.
186 if (container.activeConnection.value?.profile?.id == saved.id) {
187 container.connect(saved)
188 val pingOk = runCatchingCancellable { container.activeConnection.value?.api?.ping() }.isSuccess
190 throw java.io.IOException(container.string(R.string.error_ping_failed))
194 result.onSuccess { onDone() }
195 .onFailure { e -> _state.update { it.copy(error = container.friendlyError(e), saving = false) } }
200 * Delete the profile being edited. Fires [onDone] on success so the screen pops back. The
201 * active profile is disconnected first so the app doesn't hold a stale connection to a
204 fun delete(onDone: () -> Unit) {
206 val id = s.id ?: return
207 if (s.saving || s.testingCredentials) return
208 _state.update { it.copy(saving = true, error = null) }
209 viewModelScope.launch {
210 val result = runCatchingCancellable { container.profileStore.delete(id) }
212 if (container.activeConnection.value?.profile?.id == id) {
213 runCatchingCancellable { container.disconnect() }
217 _state.update { it.copy(saving = false, error = container.friendlyError(e)) }
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