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 }
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,
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,
68 val transientErrors: SharedFlow<String> = _transientErrors.asSharedFlow()
70 private var searchJob: Job? = null
71 private var openJob: Job? = null
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 ->
82 // Cancel the in-flight VCS status load so it can't write stale badges
83 // from the old server after the connection drops.
88 // Re-open the current directory when a connection becomes available
89 // so the initial open("") (which may have run before auto-reconnect
90 // finished and found no connection) doesn't leave a stale error. But if
91 // the user is mid-search, re-run the search instead — open() resets
92 // query/results and would silently discard their in-progress search.
93 if (_state.value.isSearching) setQuery(_state.value.query)
94 else open(_state.value.path)
99 /** Tracks the in-flight VCS status load so a server switch can cancel it. */
100 private var statusJob: Job? = null
102 /** Fetch the repo-wide VCS status once so file rows can show git badges. */
103 private fun loadStatus() {
104 val client = api ?: return
106 statusJob = viewModelScope.launch {
107 runCatchingCancellable { client.fileStatus() }
108 .onSuccess { entries ->
109 _state.update { it.copy(statusMap = entries.associateBy { it.path }) }
112 // VCS status is best-effort: don't block the file browser, but surface
113 // a transient error so the user knows why badges are missing.
114 _transientErrors.tryEmit(container.friendlyError(it))
119 /** Re-run the current view (manual refresh). Used by pull-to-refresh. */
121 _refreshing.value = true
122 val searching = _state.value.isSearching
124 // Re-run the active search rather than open(), which would reset query/results
125 // and drop the user back into the directory listing — discarding their search.
126 setQuery(_state.value.query)
128 open(_state.value.path)
130 // Capture the job this refresh kicked off. open()/setQuery() reassign the field to a
131 // new job on a second refresh() (cancelling the first), so a naive join() on the first
132 // would complete immediately (cancelled) and clear the spinner while the second load is
133 // still in flight. Only clear when the active job is still the one we joined — a newer
134 // refresh owns the spinner now. Mirrors SessionListViewModel.refresh().
135 val job = if (searching) searchJob else openJob
136 viewModelScope.launch {
138 if ((if (searching) searchJob else openJob) === job) _refreshing.value = false
142 fun open(path: String) {
143 val client = api ?: run {
144 _state.update { it.copy(
147 error = container.string(R.string.not_connected),
151 _state.update { it.copy(
153 mode = SearchMode.FILES,
155 results = emptyList(),
156 textResults = emptyList(),
157 symbolResults = emptyList(),
159 // Keep the current listing visible during a same-directory refresh (pull-to-refresh)
160 // instead of blanking it behind a full-screen spinner; only show the spinner when
161 // there's nothing to display yet or we're navigating to a DIFFERENT path (where the
162 // existing entries belong to the old directory and shouldn't linger).
163 loading = it.entries.isEmpty() || it.path != path,
168 openJob = viewModelScope.launch {
169 runCatchingCancellable { client.listDirectory(path) }
170 .onSuccess { entries ->
171 _state.update { it.copy(
172 entries = entries.sortedWith(compareByDescending<FileNode> { it.isDirectory }.thenBy { it.name.lowercase() }),
176 .onFailure { e -> _state.update { it.copy(loading = false, error = container.friendlyError(e)) } }
180 /** Navigate up one path segment. */
182 val current = _state.value.path
183 if (current.isBlank()) return
184 val parent = current.trimEnd('/').substringBeforeLast('/', missingDelimiterValue = "")
188 /** Switch what the query targets (file names / contents / symbols) and re-run it. */
189 fun setMode(mode: SearchMode) {
190 if (_state.value.mode == mode) return
195 results = emptyList(),
196 textResults = emptyList(),
197 symbolResults = emptyList(),
202 val query = _state.value.query
203 if (mode == SearchMode.FILES && query.isBlank()) open(_state.value.path) else setQuery(query)
206 fun setQuery(query: String) {
207 _state.update { it.copy(query = query, error = null) }
210 when (_state.value.mode) {
211 SearchMode.FILES -> searchFiles(query)
212 SearchMode.TEXT -> searchContent(query) { it.findText(query) }
213 SearchMode.SYMBOL -> searchSymbols(query)
217 private fun searchFiles(query: String) {
218 if (query.isBlank()) {
219 _state.update { it.copy(results = emptyList(), searching = false) }
220 // Cancelling openJob above may have interrupted an in-flight directory load,
221 // leaving loading=true stuck. Re-open the current directory so the listing
222 // re-populates instead of showing a perpetual spinner after clearing search.
223 open(_state.value.path)
226 // The search view replaces the directory listing; clear loading so the spinner
227 // from a cancelled open() doesn't persist into the search results view.
228 // Set searching=true immediately (not after the debounce) so the spinner shows
229 // during the debounce delay — otherwise the UI briefly renders SearchResults
230 // with an empty list, flashing "no matches" before the search even starts.
231 _state.update { it.copy(loading = false, searching = true) }
232 val client = api ?: run {
233 _state.update { it.copy(searching = false, error = container.string(R.string.not_connected)) }
236 searchJob = viewModelScope.launch {
237 delay(NetworkConfig.fileSearchDebounceMs) // debounce
238 runCatchingCancellable { client.findFiles(query) }
239 .onSuccess { results -> _state.update { it.copy(results = results, searching = false) } }
240 .onFailure { e -> _state.update { it.copy(searching = false, error = container.friendlyError(e)) } }
244 private fun searchContent(query: String, fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> List<FindMatch>) {
245 if (query.isBlank()) {
246 _state.update { it.copy(textResults = emptyList(), searching = false, loading = false) }
249 _state.update { it.copy(loading = false, searching = true) }
250 val client = api ?: run {
251 _state.update { it.copy(searching = false, error = container.string(R.string.not_connected)) }
254 searchJob = viewModelScope.launch {
255 delay(NetworkConfig.fileSearchDebounceMs)
256 runCatchingCancellable { fetch(client) }
257 .onSuccess { results -> _state.update { it.copy(textResults = results, searching = false) } }
258 .onFailure { e -> _state.update { it.copy(searching = false, error = container.friendlyError(e)) } }
262 private fun searchSymbols(query: String) {
263 if (query.isBlank()) {
264 _state.update { it.copy(symbolResults = emptyList(), searching = false, loading = false) }
267 _state.update { it.copy(loading = false, searching = true) }
268 val client = api ?: run {
269 _state.update { it.copy(searching = false, error = container.string(R.string.not_connected)) }
272 searchJob = viewModelScope.launch {
273 delay(NetworkConfig.fileSearchDebounceMs)
274 runCatchingCancellable { client.findSymbol(query) }
275 .onSuccess { results -> _state.update { it.copy(symbolResults = results, searching = false) } }
276 .onFailure { e -> _state.update { it.copy(searching = false, error = container.friendlyError(e)) } }
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