git.iko.soy/opencode-android
on c0621ff help sign in
opencode-android/app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt
266 lines 12 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.model.FindMatch
9import soy.iko.opencode.data.model.SymbolResult
10import soy.iko.opencode.data.network.NetworkConfig
11import soy.iko.opencode.di.AppContainer
12import soy.iko.opencode.R
13import soy.iko.opencode.util.runCatchingCancellable
14import kotlinx.coroutines.ExperimentalCoroutinesApi
15import kotlinx.coroutines.Job
16import kotlinx.coroutines.delay
17import kotlinx.coroutines.channels.BufferOverflow
18import kotlinx.coroutines.flow.MutableSharedFlow
19import kotlinx.coroutines.flow.MutableStateFlow
20import kotlinx.coroutines.flow.SharedFlow
21import kotlinx.coroutines.flow.StateFlow
22import kotlinx.coroutines.flow.asSharedFlow
23import kotlinx.coroutines.flow.asStateFlow
24import kotlinx.coroutines.flow.collectLatest
25import kotlinx.coroutines.flow.update
26import kotlinx.coroutines.launch
28/** What a search query targets: file names, file contents (ripgrep), or workspace symbols. */
29enum class SearchMode { FILES, TEXT, SYMBOL }
31@Immutable
32data class FileBrowserState(
33 val path: String = "",
34 val entries: List<FileNode> = emptyList(),
35 val statusMap: Map<String, FileStatusEntry> = emptyMap(),
36 val mode: SearchMode = SearchMode.FILES,
37 val query: String = "",
38 val results: List<String> = emptyList(),
39 val textResults: List<FindMatch> = emptyList(),
40 val symbolResults: List<SymbolResult> = emptyList(),
41 val searching: Boolean = false,
42 val loading: Boolean = true,
43 val error: String? = null,
44) {
45 /** True when the results view (rather than the directory listing) should be shown:
46 * any content/symbol mode, or file-name mode with a non-blank query. */
47 val isSearching: Boolean get() = mode != SearchMode.FILES || query.isNotBlank()
50@OptIn(ExperimentalCoroutinesApi::class)
51class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
53 private val api get() = container.activeConnection.value?.api
55 private val _state = MutableStateFlow(FileBrowserState())
56 val state: StateFlow<FileBrowserState> = _state.asStateFlow()
58 /** Tracks an in-flight manual refresh so pull-to-refresh can show its indicator. */
59 private val _refreshing = MutableStateFlow(false)
60 val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
62 /** One-shot transient errors (failed VCS status fetch, etc.) surfaced as snackbars without
63 * hiding the file list. A SharedFlow (not StateFlow) so each emission is delivered independently. */
64 private val _transientErrors = MutableSharedFlow<String>(
65 extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
66 onBufferOverflow = BufferOverflow.DROP_OLDEST,
67 )
68 val transientErrors: SharedFlow<String> = _transientErrors.asSharedFlow()
70 private var searchJob: Job? = null
71 private var openJob: Job? = null
73 init {
74 open("")
75 // Observe the active connection so VCS status loads (or reloads) when a connection
76 // becomes available — including when the view opens during a reconnect window where
77 // activeConnection.value was momentarily null. Without this, loadStatus() would be
78 // called once from init with a null api and never retried.
79 viewModelScope.launch {
80 container.activeConnection.collectLatest { conn ->
81 if (conn == null) return@collectLatest
82 loadStatus()
83 // Re-open the current directory when a connection becomes available
84 // so the initial open("") (which may have run before auto-reconnect
85 // finished and found no connection) doesn't leave a stale error. But if
86 // the user is mid-search, re-run the search instead — open() resets
87 // query/results and would silently discard their in-progress search.
88 if (_state.value.isSearching) setQuery(_state.value.query)
89 else open(_state.value.path)
90 }
91 }
92 }
94 /** Tracks the in-flight VCS status load so a server switch can cancel it. */
95 private var statusJob: Job? = null
97 /** Fetch the repo-wide VCS status once so file rows can show git badges. */
98 private fun loadStatus() {
99 val client = api ?: return
100 statusJob?.cancel()
101 statusJob = viewModelScope.launch {
102 runCatchingCancellable { client.fileStatus() }
103 .onSuccess { entries ->
104 _state.update { it.copy(statusMap = entries.associateBy { it.path }) }
105 }
106 .onFailure {
107 // VCS status is best-effort: don't block the file browser, but surface
108 // a transient error so the user knows why badges are missing.
109 _transientErrors.tryEmit(container.friendlyError(it))
110 }
111 }
112 }
114 /** Re-run the current view (manual refresh). Used by pull-to-refresh. */
115 fun refresh() {
116 _refreshing.value = true
117 val searching = _state.value.isSearching
118 if (searching) {
119 // Re-run the active search rather than open(), which would reset query/results
120 // and drop the user back into the directory listing — discarding their search.
121 setQuery(_state.value.query)
122 } else {
123 open(_state.value.path)
124 }
125 viewModelScope.launch {
126 // Clear the spinner once the in-flight load settles by joining whichever job
127 // the refresh kicked off (search or directory open).
128 (if (searching) searchJob else openJob)?.join()
129 _refreshing.value = false
130 }
131 }
133 fun open(path: String) {
134 val client = api ?: run {
135 _state.update { it.copy(
136 path = path,
137 loading = false,
138 error = container.string(R.string.not_connected),
139 ) }
140 return
141 }
142 _state.update { it.copy(
143 path = path,
144 mode = SearchMode.FILES,
145 query = "",
146 results = emptyList(),
147 textResults = emptyList(),
148 symbolResults = emptyList(),
149 searching = false,
150 loading = true,
151 error = null,
152 ) }
153 openJob?.cancel()
154 searchJob?.cancel()
155 openJob = viewModelScope.launch {
156 runCatchingCancellable { client.listDirectory(path) }
157 .onSuccess { entries ->
158 _state.update { it.copy(
159 entries = entries.sortedWith(compareByDescending<FileNode> { it.isDirectory }.thenBy { it.name.lowercase() }),
160 loading = false,
161 ) }
162 }
163 .onFailure { e -> _state.update { it.copy(loading = false, error = container.friendlyError(e)) } }
164 }
165 }
167 /** Navigate up one path segment. */
168 fun up() {
169 val current = _state.value.path
170 if (current.isBlank()) return
171 val parent = current.trimEnd('/').substringBeforeLast('/', missingDelimiterValue = "")
172 open(parent)
173 }
175 /** Switch what the query targets (file names / contents / symbols) and re-run it. */
176 fun setMode(mode: SearchMode) {
177 if (_state.value.mode == mode) return
178 searchJob?.cancel()
179 _state.update {
180 it.copy(
181 mode = mode,
182 results = emptyList(),
183 textResults = emptyList(),
184 symbolResults = emptyList(),
185 searching = false,
186 error = null,
187 )
188 }
189 val query = _state.value.query
190 if (mode == SearchMode.FILES && query.isBlank()) open(_state.value.path) else setQuery(query)
191 }
193 fun setQuery(query: String) {
194 _state.update { it.copy(query = query, error = null) }
195 searchJob?.cancel()
196 openJob?.cancel()
197 when (_state.value.mode) {
198 SearchMode.FILES -> searchFiles(query)
199 SearchMode.TEXT -> searchContent(query) { it.findText(query) }
200 SearchMode.SYMBOL -> searchSymbols(query)
201 }
202 }
204 private fun searchFiles(query: String) {
205 if (query.isBlank()) {
206 _state.update { it.copy(results = emptyList(), searching = false) }
207 // Cancelling openJob above may have interrupted an in-flight directory load,
208 // leaving loading=true stuck. Re-open the current directory so the listing
209 // re-populates instead of showing a perpetual spinner after clearing search.
210 open(_state.value.path)
211 return
212 }
213 // The search view replaces the directory listing; clear loading so the spinner
214 // from a cancelled open() doesn't persist into the search results view.
215 // Set searching=true immediately (not after the debounce) so the spinner shows
216 // during the debounce delay — otherwise the UI briefly renders SearchResults
217 // with an empty list, flashing "no matches" before the search even starts.
218 _state.update { it.copy(loading = false, searching = true) }
219 val client = api ?: run {
220 _state.update { it.copy(searching = false, error = container.string(R.string.not_connected)) }
221 return
222 }
223 searchJob = viewModelScope.launch {
224 delay(NetworkConfig.fileSearchDebounceMs) // debounce
225 runCatchingCancellable { client.findFiles(query) }
226 .onSuccess { results -> _state.update { it.copy(results = results, searching = false) } }
227 .onFailure { e -> _state.update { it.copy(searching = false, error = container.friendlyError(e)) } }
228 }
229 }
231 private fun searchContent(query: String, fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> List<FindMatch>) {
232 if (query.isBlank()) {
233 _state.update { it.copy(textResults = emptyList(), searching = false, loading = false) }
234 return
235 }
236 _state.update { it.copy(loading = false, searching = true) }
237 val client = api ?: run {
238 _state.update { it.copy(searching = false, error = container.string(R.string.not_connected)) }
239 return
240 }
241 searchJob = viewModelScope.launch {
242 delay(NetworkConfig.fileSearchDebounceMs)
243 runCatchingCancellable { fetch(client) }
244 .onSuccess { results -> _state.update { it.copy(textResults = results, searching = false) } }
245 .onFailure { e -> _state.update { it.copy(searching = false, error = container.friendlyError(e)) } }
246 }
247 }
249 private fun searchSymbols(query: String) {
250 if (query.isBlank()) {
251 _state.update { it.copy(symbolResults = emptyList(), searching = false, loading = false) }
252 return
253 }
254 _state.update { it.copy(loading = false, searching = true) }
255 val client = api ?: run {
256 _state.update { it.copy(searching = false, error = container.string(R.string.not_connected)) }
257 return
258 }
259 searchJob = viewModelScope.launch {
260 delay(NetworkConfig.fileSearchDebounceMs)
261 runCatchingCancellable { client.findSymbol(query) }
262 .onSuccess { results -> _state.update { it.copy(symbolResults = results, searching = false) } }
263 .onFailure { e -> _state.update { it.copy(searching = false, error = container.friendlyError(e)) } }
264 }
265 }