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,
36 val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl)
37 val isNew: Boolean get() = id == null
40private fun isValidUrl(url: String): Boolean = try {
41 val u = java.net.URI(url.trim())
42 u.scheme != null && u.host != null && (u.scheme == "http" || u.scheme == "https")
43} catch (_: Exception) {
47class ServerEditViewModel(
48 private val container: AppContainer,
49 private val profileId: String?,
52 private val _state = MutableStateFlow(ServerEditState(id = profileId))
53 val state: StateFlow<ServerEditState> = _state.asStateFlow()
56 viewModelScope.launch {
57 if (profileId != null) {
58 // Wait for a non-empty profile list before searching, so DataStore
59 // emitting an empty list during loading doesn't cause a false timeout.
60 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
61 container.profileStore.profiles
62 .first { it.isNotEmpty() || it.none { p -> p.id == profileId } }
63 .firstOrNull { it.id == profileId }
65 if (existing != null) {
66 _state.value = ServerEditState(
68 label = existing.label,
69 baseUrl = existing.baseUrl,
70 username = existing.username.orEmpty(),
71 password = existing.password.orEmpty(),
73 // An existing profile that already has credentials saved should
74 // show the auth fields immediately — the user may want to edit them.
75 authFieldsVisible = existing.hasAuth,
79 // Profile existed but timed out loading — show an error instead of
80 // silently presenting an empty form that looks like "new server".
81 _state.value = ServerEditState(
84 error = container.string(R.string.error_load_timeout),
88 _state.update { it.copy(loaded = true) }
92 fun update(transform: (ServerEditState) -> ServerEditState) {
93 _state.update(transform)
97 * Probe the server at [baseUrl] to check reachability and detect whether
98 * authentication is required. On success the auth fields are shown only if the
99 * server returned an auth error (401/403); on failure the error is surfaced.
100 * Editing the base URL after a probe resets the auth-visibility so the next
101 * save re-probes against the new URL.
105 if (!s.canSave || s.probing) return
106 _state.update { it.copy(probing = true, error = null) }
107 viewModelScope.launch {
108 val result = runCatchingCancellable { container.probeServer(s.baseUrl) }
109 result.onSuccess { pr ->
112 is ProbeResult.Reachable -> it.copy(
114 authFieldsVisible = false,
117 is ProbeResult.NeedsAuth -> it.copy(
119 authFieldsVisible = true,
122 is ProbeResult.Unreachable -> it.copy(
124 authFieldsVisible = false,
130 _state.update { it.copy(probing = false, error = container.friendlyError(e)) }
135 fun save(onDone: () -> Unit) {
137 if (!s.canSave || s.saving) return
138 _state.update { it.copy(saving = true) }
139 viewModelScope.launch {
140 val result = runCatchingCancellable {
141 // Preserve the existing lastUsed timestamp so saving an edit doesn't
142 // reset the profile's sort position (profiles are sorted descending
143 // by lastUsed). connect() is responsible for updating lastUsed on
144 // actual server use. If loading the existing profile times out,
145 // lastUsed=0 is passed — ProfileStore.save() preserves the existing
146 // nonzero lastUsed in that case so the sort position isn't lost.
147 val existingLastUsed = if (s.id != null) {
148 withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
149 container.profileStore.profiles.first()
150 .firstOrNull { it.id == s.id }?.lastUsed
153 container.profileStore.save(
155 id = s.id ?: UUID.randomUUID().toString(),
156 label = s.label.trim(),
157 baseUrl = s.baseUrl.trim(),
158 username = s.username.trim().takeIf { it.isNotBlank() },
159 password = s.password.trim().takeIf { it.isNotEmpty() },
160 lastUsed = existingLastUsed,
164 result.onSuccess { onDone() }
165 .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