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
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,
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 /** One-shot transient errors (failed VCS status fetch, etc.) surfaced as snackbars without
49 * hiding the file list. A SharedFlow (not StateFlow) so each emission is delivered independently. */
50 private val _transientErrors = MutableSharedFlow<String>(
51 extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
52 onBufferOverflow = BufferOverflow.DROP_OLDEST,
54 val transientErrors: SharedFlow<String> = _transientErrors.asSharedFlow()
56 private var searchJob: Job? = null
57 private var openJob: Job? = null
61 // Observe the active connection so VCS status loads (or reloads) when a connection
62 // becomes available — including when the view opens during a reconnect window where
63 // activeConnection.value was momentarily null. Without this, loadStatus() would be
64 // called once from init with a null api and never retried.
65 viewModelScope.launch {
66 container.activeConnection.collectLatest { conn ->
67 if (conn == null) return@collectLatest
69 // Re-open the current directory when a connection becomes available
70 // so the initial open("") (which may have run before auto-reconnect
71 // finished and found no connection) doesn't leave a stale error.
72 open(_state.value.path)
77 /** Tracks the in-flight VCS status load so a server switch can cancel it. */
78 private var statusJob: Job? = null
80 /** Fetch the repo-wide VCS status once so file rows can show git badges. */
81 private fun loadStatus() {
82 val client = api ?: return
84 statusJob = viewModelScope.launch {
85 runCatchingCancellable { client.fileStatus() }
86 .onSuccess { entries ->
87 _state.update { it.copy(statusMap = entries.associateBy { it.path }) }
90 // VCS status is best-effort: don't block the file browser, but surface
91 // a transient error so the user knows why badges are missing.
92 _transientErrors.tryEmit(container.friendlyError(it))
97 fun open(path: String) {
98 val client = api ?: run {
99 _state.update { it.copy(
102 error = container.string(R.string.not_connected),
106 _state.update { it.copy(
109 results = emptyList(),
116 openJob = viewModelScope.launch {
117 runCatchingCancellable { client.listDirectory(path) }
118 .onSuccess { entries ->
119 _state.update { it.copy(
120 entries = entries.sortedWith(compareByDescending<FileNode> { it.isDirectory }.thenBy { it.name.lowercase() }),
124 .onFailure { e -> _state.update { it.copy(loading = false, error = container.friendlyError(e)) } }
128 /** Navigate up one path segment. */
130 val current = _state.value.path
131 if (current.isBlank()) return
132 val parent = current.trimEnd('/').substringBeforeLast('/', missingDelimiterValue = "")
136 fun setQuery(query: String) {
137 _state.update { it.copy(query = query, error = null) }
140 if (query.isBlank()) {
141 _state.update { it.copy(results = emptyList(), searching = false) }
142 // Cancelling openJob above may have interrupted an in-flight directory load,
143 // leaving loading=true stuck. Re-open the current directory so the listing
144 // re-populates instead of showing a perpetual spinner after clearing search.
145 open(_state.value.path)
148 // The search view replaces the directory listing; clear loading so the spinner
149 // from a cancelled open() doesn't persist into the search results view.
150 // Set searching=true immediately (not after the debounce) so the spinner shows
151 // during the debounce delay — otherwise the UI briefly renders SearchResults
152 // with an empty list, flashing "no matches" before the search even starts.
153 _state.update { it.copy(loading = false, searching = true) }
154 val client = api ?: run {
155 _state.update { it.copy(searching = false, error = container.string(R.string.not_connected)) }
158 searchJob = viewModelScope.launch {
159 delay(NetworkConfig.fileSearchDebounceMs) // debounce
160 runCatchingCancellable { client.findFiles(query) }
161 .onSuccess { results -> _state.update { it.copy(results = results, searching = false) } }
162 .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