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,
41 /** True when the last connectivity probe found the server reachable without auth.
42 * Null until a probe runs, and cleared when the base URL is edited. */
43 val probeReachable: Boolean? = null,
45 val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl)
46 val isNew: Boolean get() = id == null
47 /** True if any field differs from its loaded value. */
50 val init = initial ?: return false
51 return label.trim() != init.label ||
52 baseUrl.trim() != init.baseUrl ||
53 username.trim() != init.username ||
54 password.trim() != init.password
58/** Snapshot of the profile loaded into the editor, normalized the same way save() stores it. */
59data class InitialProfile(
66fun isValidUrl(url: String): Boolean = try {
67 val u = java.net.URI(url.trim())
68 u.scheme != null && u.host != null && (u.scheme == "http" || u.scheme == "https")
69} catch (_: Exception) {
74 * If [input] looks like a bare host (optionally with a port/path) and lacks a scheme,
75 * return the suggested `http://`-prefixed form (only when it parses to a valid URL).
76 * Lets the UI offer a one-tap fix instead of a generic "invalid URL" error.
78fun suggestUrlScheme(input: String): String? {
79 val trimmed = input.trim()
80 if (trimmed.isEmpty() || "://" in trimmed) return null
81 val candidate = "http://$trimmed"
82 return if (isValidUrl(candidate)) candidate else null
85class ServerEditViewModel(
86 private val container: AppContainer,
87 private val profileId: String?,
88 private val sourceId: String? = null,
91 private val _state = MutableStateFlow(ServerEditState(id = profileId))
92 val state: StateFlow<ServerEditState> = _state.asStateFlow()
95 viewModelScope.launch {
96 if (profileId != null) {
97 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
98 container.profileStore.profiles
99 .first { list -> list.any { p -> p.id == profileId } }
100 .firstOrNull { it.id == profileId }
102 if (existing != null) {
103 val init = InitialProfile(
104 label = existing.label,
105 baseUrl = existing.baseUrl,
106 username = existing.username.orEmpty(),
107 password = existing.password.orEmpty(),
109 _state.value = ServerEditState(
111 label = existing.label,
112 baseUrl = existing.baseUrl,
113 username = existing.username.orEmpty(),
114 password = existing.password.orEmpty(),
116 authFieldsVisible = existing.hasAuth,
121 _state.value = ServerEditState(
124 error = container.string(R.string.error_load_timeout),
128 // Duplicate: seed the form from an existing profile but as a NEW profile
129 // (id stays null so save() generates a fresh one). Appends " (copy)" to the
130 // label so the user can tell the duplicate from the original at a glance.
131 if (sourceId != null) {
132 val source = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
133 container.profileStore.profiles
134 .first { list -> list.any { p -> p.id == sourceId } }
135 .firstOrNull { it.id == sourceId }
137 if (source != null) {
138 val dupLabel = if (source.label.isBlank()) source.baseUrl + " (copy)" else "${source.label} (copy)"
139 _state.value = ServerEditState(
142 baseUrl = source.baseUrl,
143 username = source.username.orEmpty(),
144 password = source.password.orEmpty(),
146 authFieldsVisible = source.hasAuth,
147 initial = InitialProfile("", "", "", ""),
151 // Source not found — fall through to a blank new-profile form.
153 // New-profile form: seed an initial snapshot of empty values so isDirty
154 // becomes true the moment the user types anything.
158 initial = InitialProfile("", "", "", ""),
164 fun update(transform: (ServerEditState) -> ServerEditState) {
165 _state.update { transform(it).copy(credentialsResult = null) }
170 if (!s.canSave || s.probing) return
171 _state.update { it.copy(probing = true, error = null, credentialsResult = null, probeReachable = null) }
172 viewModelScope.launch {
173 val result = runCatchingCancellable { container.probeServer(s.baseUrl) }
174 result.onSuccess { pr ->
177 is ProbeResult.Reachable -> it.copy(
179 authFieldsVisible = false,
181 probeReachable = true,
183 is ProbeResult.NeedsAuth -> it.copy(
185 authFieldsVisible = true,
187 probeReachable = false,
189 is ProbeResult.Unreachable -> it.copy(
191 authFieldsVisible = false,
193 probeReachable = false,
198 _state.update { it.copy(probing = false, error = container.friendlyError(e), probeReachable = false) }
204 * Probe the server *with* the entered credentials to validate them before saving.
205 * Only meaningful once auth fields are visible (the server requires auth); calling
206 * it when no auth is needed is a no-op. Surfaces [ServerEditState.credentialsResult]
207 * so the UI can show success/failure without dismissing the form.
209 fun testCredentials() {
211 if (!s.canSave || s.testingCredentials) return
212 if (s.username.isBlank() && s.password.isBlank()) return
213 _state.update { it.copy(testingCredentials = true, credentialsResult = null, error = null) }
214 viewModelScope.launch {
215 val result = runCatchingCancellable { container.probeWithCredentials(s.baseUrl, s.username, s.password) }
216 result.onSuccess { ok ->
219 testingCredentials = false,
220 credentialsResult = ok,
221 error = if (ok) null else container.string(R.string.credentials_rejected),
227 testingCredentials = false,
228 credentialsResult = false,
229 error = container.friendlyError(e),
236 fun save(onDone: () -> Unit) {
237 saveInternal(connectAfter = false, onDone = onDone)
240 /** Save and then connect to the saved profile in one step. */
241 fun saveAndConnect(onDone: () -> Unit) {
242 saveInternal(connectAfter = true, onDone = onDone)
245 private fun saveInternal(connectAfter: Boolean, onDone: () -> Unit) {
247 if (!s.canSave || s.saving) return
248 _state.update { it.copy(saving = true) }
249 viewModelScope.launch {
250 val result = runCatchingCancellable {
251 val existingLastUsed = if (s.id != null) {
252 withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
253 container.profileStore.profiles.first()
254 .firstOrNull { it.id == s.id }?.lastUsed
257 val saved = ServerProfile(
258 id = s.id ?: UUID.randomUUID().toString(),
259 label = s.label.trim(),
260 baseUrl = s.baseUrl.trim(),
261 username = s.username.trim().takeIf { it.isNotBlank() },
262 password = s.password.trim().takeIf { it.isNotEmpty() },
263 lastUsed = existingLastUsed,
265 container.profileStore.save(saved)
267 container.connect(saved)
268 container.activeConnection.value?.api?.ping()
271 result.onSuccess { onDone() }
272 .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