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.flow.MutableStateFlow
16import kotlinx.coroutines.flow.StateFlow
17import kotlinx.coroutines.flow.asStateFlow
18import kotlinx.coroutines.flow.collectLatest
19import kotlinx.coroutines.flow.update
20import kotlinx.coroutines.launch
23data class FileBrowserState(
24 val path: String = "",
25 val entries: List<FileNode> = emptyList(),
26 val statusMap: Map<String, FileStatusEntry> = emptyMap(),
27 val query: String = "",
28 val results: List<String> = emptyList(),
29 val searching: Boolean = false,
30 val loading: Boolean = true,
31 val error: String? = null,
33 val isSearching: Boolean get() = query.isNotBlank()
36@OptIn(ExperimentalCoroutinesApi::class)
37class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
39 private val api get() = container.activeConnection.value?.api
41 private val _state = MutableStateFlow(FileBrowserState())
42 val state: StateFlow<FileBrowserState> = _state.asStateFlow()
44 /** Transient errors (failed VCS status fetch, etc.) surfaced without hiding the file list. */
45 private val _transientError = MutableStateFlow<String?>(null)
46 val transientError: StateFlow<String?> = _transientError.asStateFlow()
47 fun clearTransientError() { _transientError.value = null }
49 private var searchJob: Job? = null
50 private var openJob: Job? = null
54 // Observe the active connection so VCS status loads (or reloads) when a connection
55 // becomes available — including when the view opens during a reconnect window where
56 // activeConnection.value was momentarily null. Without this, loadStatus() would be
57 // called once from init with a null api and never retried.
58 viewModelScope.launch {
59 container.activeConnection.collectLatest { conn ->
60 if (conn == null) return@collectLatest
62 // Re-open the current directory when a connection becomes available
63 // so the initial open("") (which may have run before auto-reconnect
64 // finished and found no connection) doesn't leave a stale error.
65 open(_state.value.path)
70 /** Tracks the in-flight VCS status load so a server switch can cancel it. */
71 private var statusJob: Job? = null
73 /** Fetch the repo-wide VCS status once so file rows can show git badges. */
74 private fun loadStatus() {
75 val client = api ?: return
77 statusJob = viewModelScope.launch {
78 runCatchingCancellable { client.fileStatus() }
79 .onSuccess { entries ->
80 _state.update { it.copy(statusMap = entries.associateBy { it.path }) }
83 // VCS status is best-effort: don't block the file browser, but surface
84 // a transient error so the user knows why badges are missing.
85 _transientError.value = container.friendlyError(it)
90 fun open(path: String) {
91 val client = api ?: run {
92 _state.update { it.copy(
95 error = container.string(R.string.not_connected),
99 _state.update { it.copy(
102 results = emptyList(),
109 openJob = viewModelScope.launch {
110 runCatchingCancellable { client.listDirectory(path) }
111 .onSuccess { entries ->
112 _state.update { it.copy(
113 entries = entries.sortedWith(compareByDescending<FileNode> { it.isDirectory }.thenBy { it.name.lowercase() }),
117 .onFailure { e -> _state.update { it.copy(loading = false, error = container.friendlyError(e)) } }
121 /** Navigate up one path segment. */
123 val current = _state.value.path
124 if (current.isBlank()) return
125 val parent = current.trimEnd('/').substringBeforeLast('/', missingDelimiterValue = "")
129 fun setQuery(query: String) {
130 _state.update { it.copy(query = query, error = null) }
133 if (query.isBlank()) {
134 _state.update { it.copy(results = emptyList(), searching = false) }
135 // Cancelling openJob above may have interrupted an in-flight directory load,
136 // leaving loading=true stuck. Re-open the current directory so the listing
137 // re-populates instead of showing a perpetual spinner after clearing search.
138 open(_state.value.path)
141 // The search view replaces the directory listing; clear loading so the spinner
142 // from a cancelled open() doesn't persist into the search results view.
143 // Set searching=true immediately (not after the debounce) so the spinner shows
144 // during the debounce delay — otherwise the UI briefly renders SearchResults
145 // with an empty list, flashing "no matches" before the search even starts.
146 _state.update { it.copy(loading = false, searching = true) }
147 val client = api ?: run {
148 _state.update { it.copy(searching = false, error = container.string(R.string.not_connected)) }
151 searchJob = viewModelScope.launch {
152 delay(NetworkConfig.fileSearchDebounceMs) // debounce
153 runCatchingCancellable { client.findFiles(query) }
154 .onSuccess { results -> _state.update { it.copy(results = results, searching = false) } }
155 .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