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 kotlinx.coroutines.flow.MutableStateFlow
10import kotlinx.coroutines.flow.StateFlow
11import kotlinx.coroutines.flow.asStateFlow
12import kotlinx.coroutines.flow.first
13import kotlinx.coroutines.flow.update
14import kotlinx.coroutines.launch
15import kotlinx.coroutines.withTimeoutOrNull
16import soy.iko.opencode.R
17import soy.iko.opencode.util.runCatchingCancellable
21data class ServerEditState(
22 val id: String? = null,
23 val label: String = "",
24 val baseUrl: String = "",
25 val username: String = "",
26 val password: String = "",
27 val loaded: Boolean = false,
28 val saving: Boolean = false,
29 val error: String? = null,
31 val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl)
32 val isNew: Boolean get() = id == null
35private fun isValidUrl(url: String): Boolean = try {
36 val u = java.net.URI(url.trim())
37 u.scheme != null && u.host != null && (u.scheme == "http" || u.scheme == "https")
38} catch (_: Exception) {
42class ServerEditViewModel(
43 private val container: AppContainer,
44 private val profileId: String?,
47 private val _state = MutableStateFlow(ServerEditState(id = profileId))
48 val state: StateFlow<ServerEditState> = _state.asStateFlow()
51 viewModelScope.launch {
52 if (profileId != null) {
53 // Wait for a non-empty profile list before searching, so DataStore
54 // emitting an empty list during loading doesn't cause a false timeout.
55 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
56 container.profileStore.profiles
57 .first { it.isNotEmpty() || it.none { p -> p.id == profileId } }
58 .firstOrNull { it.id == profileId }
60 if (existing != null) {
61 _state.value = ServerEditState(
63 label = existing.label,
64 baseUrl = existing.baseUrl,
65 username = existing.username.orEmpty(),
66 password = existing.password.orEmpty(),
71 // Profile existed but timed out loading — show an error instead of
72 // silently presenting an empty form that looks like "new server".
73 _state.value = ServerEditState(
76 error = container.string(R.string.error_load_timeout),
80 _state.update { it.copy(loaded = true) }
84 fun update(transform: (ServerEditState) -> ServerEditState) {
85 _state.update(transform)
88 fun save(onDone: () -> Unit) {
90 if (!s.canSave || s.saving) return
91 _state.update { it.copy(saving = true) }
92 viewModelScope.launch {
93 val result = runCatchingCancellable {
94 // Preserve the existing lastUsed timestamp so saving an edit doesn't
95 // reset the profile's sort position (profiles are sorted descending
96 // by lastUsed). connect() is responsible for updating lastUsed on
97 // actual server use. If loading the existing profile times out,
98 // lastUsed=0 is passed — ProfileStore.save() preserves the existing
99 // nonzero lastUsed in that case so the sort position isn't lost.
100 val existingLastUsed = if (s.id != null) {
101 withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
102 container.profileStore.profiles.first()
103 .firstOrNull { it.id == s.id }?.lastUsed
106 container.profileStore.save(
108 id = s.id ?: UUID.randomUUID().toString(),
109 label = s.label.trim(),
110 baseUrl = s.baseUrl.trim(),
111 username = s.username.trim().takeIf { it.isNotBlank() },
112 password = s.password.trim().takeIf { it.isNotEmpty() },
113 lastUsed = existingLastUsed,
117 result.onSuccess { onDone() }
118 .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