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 soy.iko.opencode.R
18import soy.iko.opencode.util.runCatchingCancellable
22data class ServerEditState(
23 val id: String? = null,
24 val label: String = "",
25 val baseUrl: String = "",
26 val username: String = "",
27 val password: String = "",
28 val loaded: Boolean = false,
29 val saving: Boolean = false,
30 val error: String? = null,
31 /** Whether the auth (username/password) fields are shown. */
32 val authFieldsVisible: Boolean = false,
33 /** Whether a connectivity/auth probe is in progress. */
34 val probing: Boolean = false,
35 /** Snapshot of the profile as loaded (for dirty detection). Null until loaded. */
36 val initial: InitialProfile? = null,
37 /** Whether a credential test is in progress. */
38 val testingCredentials: Boolean = false,
39 /** Non-null when the last credential test succeeded (true) or failed (false). */
40 val credentialsResult: Boolean? = null,
42 val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl)
43 val isNew: Boolean get() = id == null
44 /** True if any field differs from its loaded value. */
47 val init = initial ?: return false
48 return label.trim() != init.label ||
49 baseUrl.trim() != init.baseUrl ||
50 username.trim() != init.username ||
51 password.trim() != init.password
55/** Snapshot of the profile loaded into the editor, normalized the same way save() stores it. */
56data class InitialProfile(
63fun isValidUrl(url: String): Boolean = try {
64 val u = java.net.URI(url.trim())
65 u.scheme != null && u.host != null && (u.scheme == "http" || u.scheme == "https")
66} catch (_: Exception) {
70class ServerEditViewModel(
71 private val container: AppContainer,
72 private val profileId: String?,
75 private val _state = MutableStateFlow(ServerEditState(id = profileId))
76 val state: StateFlow<ServerEditState> = _state.asStateFlow()
79 viewModelScope.launch {
80 if (profileId != null) {
81 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
82 container.profileStore.profiles
83 .first { it.isNotEmpty() || it.none { p -> p.id == profileId } }
84 .firstOrNull { it.id == profileId }
86 if (existing != null) {
87 val init = InitialProfile(
88 label = existing.label,
89 baseUrl = existing.baseUrl,
90 username = existing.username.orEmpty(),
91 password = existing.password.orEmpty(),
93 _state.value = ServerEditState(
95 label = existing.label,
96 baseUrl = existing.baseUrl,
97 username = existing.username.orEmpty(),
98 password = existing.password.orEmpty(),
100 authFieldsVisible = existing.hasAuth,
105 _state.value = ServerEditState(
108 error = container.string(R.string.error_load_timeout),
112 // New-profile form: seed an initial snapshot of empty values so isDirty
113 // becomes true the moment the user types anything.
117 initial = InitialProfile("", "", "", ""),
123 fun update(transform: (ServerEditState) -> ServerEditState) {
124 _state.update { transform(it).copy(credentialsResult = null) }
129 if (!s.canSave || s.probing) return
130 _state.update { it.copy(probing = true, error = null, credentialsResult = null) }
131 viewModelScope.launch {
132 val result = runCatchingCancellable { container.probeServer(s.baseUrl) }
133 result.onSuccess { pr ->
136 is ProbeResult.Reachable -> it.copy(
138 authFieldsVisible = false,
141 is ProbeResult.NeedsAuth -> it.copy(
143 authFieldsVisible = true,
146 is ProbeResult.Unreachable -> it.copy(
148 authFieldsVisible = false,
154 _state.update { it.copy(probing = false, error = container.friendlyError(e)) }
160 * Probe the server *with* the entered credentials to validate them before saving.
161 * Only meaningful once auth fields are visible (the server requires auth); calling
162 * it when no auth is needed is a no-op. Surfaces [ServerEditState.credentialsResult]
163 * so the UI can show success/failure without dismissing the form.
165 fun testCredentials() {
167 if (!s.canSave || s.testingCredentials) return
168 if (s.username.isBlank() && s.password.isBlank()) return
169 _state.update { it.copy(testingCredentials = true, credentialsResult = null, error = null) }
170 viewModelScope.launch {
171 val result = runCatchingCancellable { container.probeWithCredentials(s.baseUrl, s.username, s.password) }
172 result.onSuccess { ok ->
175 testingCredentials = false,
176 credentialsResult = ok,
177 error = if (ok) null else container.string(R.string.credentials_rejected),
183 testingCredentials = false,
184 credentialsResult = false,
185 error = container.friendlyError(e),
192 fun save(onDone: () -> Unit) {
193 saveInternal(connectAfter = false, onDone = onDone)
196 /** Save and then connect to the saved profile in one step. */
197 fun saveAndConnect(onDone: () -> Unit) {
198 saveInternal(connectAfter = true, onDone = onDone)
201 private fun saveInternal(connectAfter: Boolean, onDone: () -> Unit) {
203 if (!s.canSave || s.saving) return
204 _state.update { it.copy(saving = true) }
205 viewModelScope.launch {
206 val result = runCatchingCancellable {
207 val existingLastUsed = if (s.id != null) {
208 withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
209 container.profileStore.profiles.first()
210 .firstOrNull { it.id == s.id }?.lastUsed
213 val saved = ServerProfile(
214 id = s.id ?: UUID.randomUUID().toString(),
215 label = s.label.trim(),
216 baseUrl = s.baseUrl.trim(),
217 username = s.username.trim().takeIf { it.isNotBlank() },
218 password = s.password.trim().takeIf { it.isNotEmpty() },
219 lastUsed = existingLastUsed,
221 container.profileStore.save(saved)
223 container.connect(saved)
224 container.activeConnection.value?.api?.ping()
227 result.onSuccess { onDone() }
228 .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