git.iko.soy/opencode-android
on 8bc2cce help sign in
opencode-android/app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt
191 lines 8.3 kB history raw
1package soy.iko.opencode.ui.file
3import androidx.compose.runtime.Immutable
4import androidx.lifecycle.ViewModel
5import androidx.lifecycle.viewModelScope
6import soy.iko.opencode.data.model.FileNode
7import soy.iko.opencode.data.model.FileStatusEntry
8import soy.iko.opencode.data.network.NetworkConfig
9import soy.iko.opencode.di.AppContainer
10import soy.iko.opencode.R
11import soy.iko.opencode.util.runCatchingCancellable
12import kotlinx.coroutines.ExperimentalCoroutinesApi
13import kotlinx.coroutines.Job
14import kotlinx.coroutines.delay
15import kotlinx.coroutines.channels.BufferOverflow
16import kotlinx.coroutines.flow.MutableSharedFlow
17import kotlinx.coroutines.flow.MutableStateFlow
18import kotlinx.coroutines.flow.SharedFlow
19import kotlinx.coroutines.flow.StateFlow
20import kotlinx.coroutines.flow.asSharedFlow
21import kotlinx.coroutines.flow.asStateFlow
22import kotlinx.coroutines.flow.collectLatest
23import kotlinx.coroutines.flow.update
24import kotlinx.coroutines.launch
26@Immutable
27data class FileBrowserState(
28 val path: String = "",
29 val entries: List<FileNode> = emptyList(),
30 val statusMap: Map<String, FileStatusEntry> = emptyMap(),
31 val query: String = "",
32 val results: List<String> = emptyList(),
33 val searching: Boolean = false,
34 val loading: Boolean = true,
35 val error: String? = null,
36) {
37 val isSearching: Boolean get() = query.isNotBlank()
40@OptIn(ExperimentalCoroutinesApi::class)
41class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
43 private val api get() = container.activeConnection.value?.api
45 private val _state = MutableStateFlow(FileBrowserState())
46 val state: StateFlow<FileBrowserState> = _state.asStateFlow()
48 /** Tracks an in-flight manual refresh so pull-to-refresh can show its indicator. */
49 private val _refreshing = MutableStateFlow(false)
50 val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
52 /** One-shot transient errors (failed VCS status fetch, etc.) surfaced as snackbars without
53 * hiding the file list. A SharedFlow (not StateFlow) so each emission is delivered independently. */
54 private val _transientErrors = MutableSharedFlow<String>(
55 extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
56 onBufferOverflow = BufferOverflow.DROP_OLDEST,
57 )
58 val transientErrors: SharedFlow<String> = _transientErrors.asSharedFlow()
60 private var searchJob: Job? = null
61 private var openJob: Job? = null
63 init {
64 open("")
65 // Observe the active connection so VCS status loads (or reloads) when a connection
66 // becomes available — including when the view opens during a reconnect window where
67 // activeConnection.value was momentarily null. Without this, loadStatus() would be
68 // called once from init with a null api and never retried.
69 viewModelScope.launch {
70 container.activeConnection.collectLatest { conn ->
71 if (conn == null) return@collectLatest
72 loadStatus()
73 // Re-open the current directory when a connection becomes available
74 // so the initial open("") (which may have run before auto-reconnect
75 // finished and found no connection) doesn't leave a stale error. But if
76 // the user is mid-search, re-run the search instead — open() resets
77 // query/results and would silently discard their in-progress search.
78 if (_state.value.isSearching) setQuery(_state.value.query)
79 else open(_state.value.path)
80 }
81 }
82 }
84 /** Tracks the in-flight VCS status load so a server switch can cancel it. */
85 private var statusJob: Job? = null
87 /** Fetch the repo-wide VCS status once so file rows can show git badges. */
88 private fun loadStatus() {
89 val client = api ?: return
90 statusJob?.cancel()
91 statusJob = viewModelScope.launch {
92 runCatchingCancellable { client.fileStatus() }
93 .onSuccess { entries ->
94 _state.update { it.copy(statusMap = entries.associateBy { it.path }) }
95 }
96 .onFailure {
97 // VCS status is best-effort: don't block the file browser, but surface
98 // a transient error so the user knows why badges are missing.
99 _transientErrors.tryEmit(container.friendlyError(it))
100 }
101 }
102 }
104 /** Re-run the current view (manual refresh). Used by pull-to-refresh. */
105 fun refresh() {
106 _refreshing.value = true
107 val searching = _state.value.isSearching
108 if (searching) {
109 // Re-run the active search rather than open(), which would reset query/results
110 // and drop the user back into the directory listing — discarding their search.
111 setQuery(_state.value.query)
112 } else {
113 open(_state.value.path)
114 }
115 viewModelScope.launch {
116 // Clear the spinner once the in-flight load settles by joining whichever job
117 // the refresh kicked off (search or directory open).
118 (if (searching) searchJob else openJob)?.join()
119 _refreshing.value = false
120 }
121 }
123 fun open(path: String) {
124 val client = api ?: run {
125 _state.update { it.copy(
126 path = path,
127 loading = false,
128 error = container.string(R.string.not_connected),
129 ) }
130 return
131 }
132 _state.update { it.copy(
133 path = path,
134 query = "",
135 results = emptyList(),
136 searching = false,
137 loading = true,
138 error = null,
139 ) }
140 openJob?.cancel()
141 searchJob?.cancel()
142 openJob = viewModelScope.launch {
143 runCatchingCancellable { client.listDirectory(path) }
144 .onSuccess { entries ->
145 _state.update { it.copy(
146 entries = entries.sortedWith(compareByDescending<FileNode> { it.isDirectory }.thenBy { it.name.lowercase() }),
147 loading = false,
148 ) }
149 }
150 .onFailure { e -> _state.update { it.copy(loading = false, error = container.friendlyError(e)) } }
151 }
152 }
154 /** Navigate up one path segment. */
155 fun up() {
156 val current = _state.value.path
157 if (current.isBlank()) return
158 val parent = current.trimEnd('/').substringBeforeLast('/', missingDelimiterValue = "")
159 open(parent)
160 }
162 fun setQuery(query: String) {
163 _state.update { it.copy(query = query, error = null) }
164 searchJob?.cancel()
165 openJob?.cancel()
166 if (query.isBlank()) {
167 _state.update { it.copy(results = emptyList(), searching = false) }
168 // Cancelling openJob above may have interrupted an in-flight directory load,
169 // leaving loading=true stuck. Re-open the current directory so the listing
170 // re-populates instead of showing a perpetual spinner after clearing search.
171 open(_state.value.path)
172 return
173 }
174 // The search view replaces the directory listing; clear loading so the spinner
175 // from a cancelled open() doesn't persist into the search results view.
176 // Set searching=true immediately (not after the debounce) so the spinner shows
177 // during the debounce delay — otherwise the UI briefly renders SearchResults
178 // with an empty list, flashing "no matches" before the search even starts.
179 _state.update { it.copy(loading = false, searching = true) }
180 val client = api ?: run {
181 _state.update { it.copy(searching = false, error = container.string(R.string.not_connected)) }
182 return
183 }
184 searchJob = viewModelScope.launch {
185 delay(NetworkConfig.fileSearchDebounceMs) // debounce
186 runCatchingCancellable { client.findFiles(query) }
187 .onSuccess { results -> _state.update { it.copy(results = results, searching = false) } }
188 .onFailure { e -> _state.update { it.copy(searching = false, error = container.friendlyError(e)) } }
189 }
190 }