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 // Snapshot the seeded (copy) values so an untouched duplicate isn't
148 // immediately dirty — an empty initial would make backing out of a
149 // freshly opened duplicate always trigger the discard-changes dialog.
150 initial = InitialProfile(
151 label = dupLabel.trim(),
152 baseUrl = source.baseUrl.trim(),
153 username = source.username.orEmpty().trim(),
154 password = source.password.orEmpty().trim(),
159 // Source not found — fall through to a blank new-profile form.
161 // New-profile form: seed an initial snapshot of empty values so isDirty
162 // becomes true the moment the user types anything.
166 initial = InitialProfile("", "", "", ""),
172 fun update(transform: (ServerEditState) -> ServerEditState) {
173 // Clear both the inline credential note and the bottom-of-form error when any field
174 // changes: once the user starts editing (e.g. correcting a rejected password), a stale
175 // "Credentials rejected" / test-failure message no longer describes the current input.
176 _state.update { transform(it).copy(credentialsResult = null, error = null) }
181 if (!s.canSave || s.probing) return
182 _state.update { it.copy(probing = true, error = null, credentialsResult = null, probeReachable = null) }
183 viewModelScope.launch {
184 val result = runCatchingCancellable { container.probeServer(s.baseUrl) }
185 result.onSuccess { pr ->
188 is ProbeResult.Reachable -> it.copy(
190 authFieldsVisible = false,
192 probeReachable = true,
194 is ProbeResult.NeedsAuth -> it.copy(
196 authFieldsVisible = true,
198 probeReachable = false,
200 is ProbeResult.Unreachable -> it.copy(
202 authFieldsVisible = false,
204 probeReachable = false,
209 _state.update { it.copy(probing = false, error = container.friendlyError(e), probeReachable = false) }
215 * Probe the server *with* the entered credentials to validate them before saving.
216 * Only meaningful once auth fields are visible (the server requires auth); calling
217 * it when no auth is needed is a no-op. Surfaces [ServerEditState.credentialsResult]
218 * so the UI can show success/failure without dismissing the form.
220 fun testCredentials() {
222 if (!s.canSave || s.testingCredentials) return
223 if (s.username.isBlank() && s.password.isBlank()) return
224 _state.update { it.copy(testingCredentials = true, credentialsResult = null, error = null) }
225 viewModelScope.launch {
226 val result = runCatchingCancellable { container.probeWithCredentials(s.baseUrl, s.username, s.password) }
227 result.onSuccess { ok ->
230 testingCredentials = false,
231 credentialsResult = ok,
232 error = if (ok) null else container.string(R.string.credentials_rejected),
238 testingCredentials = false,
239 credentialsResult = false,
240 error = container.friendlyError(e),
247 fun save(onDone: () -> Unit) {
248 saveInternal(connectAfter = false, onDone = onDone)
251 /** Save and then connect to the saved profile in one step. */
252 fun saveAndConnect(onDone: () -> Unit) {
253 saveInternal(connectAfter = true, onDone = onDone)
256 private fun saveInternal(connectAfter: Boolean, onDone: () -> Unit) {
258 if (!s.canSave || s.saving) return
259 _state.update { it.copy(saving = true) }
260 viewModelScope.launch {
261 val result = runCatchingCancellable {
262 val existingLastUsed = if (s.id != null) {
263 withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
264 container.profileStore.profiles.first()
265 .firstOrNull { it.id == s.id }?.lastUsed
268 val saved = ServerProfile(
269 id = s.id ?: UUID.randomUUID().toString(),
270 label = s.label.trim(),
271 baseUrl = s.baseUrl.trim(),
272 username = s.username.trim().takeIf { it.isNotBlank() },
273 password = s.password.trim().takeIf { it.isNotEmpty() },
274 lastUsed = existingLastUsed,
276 container.profileStore.save(saved)
278 container.connect(saved)
279 container.activeConnection.value?.api?.ping()
282 result.onSuccess { onDone() }
283 .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