git.iko.soy/opencode-android
on d29e57b help sign in
opencode-android/app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt
182 lines 7.8 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.
76 open(_state.value.path)
77 }
78 }
79 }
81 /** Tracks the in-flight VCS status load so a server switch can cancel it. */
82 private var statusJob: Job? = null
84 /** Fetch the repo-wide VCS status once so file rows can show git badges. */
85 private fun loadStatus() {
86 val client = api ?: return
87 statusJob?.cancel()
88 statusJob = viewModelScope.launch {
89 runCatchingCancellable { client.fileStatus() }
90 .onSuccess { entries ->
91 _state.update { it.copy(statusMap = entries.associateBy { it.path }) }
92 }
93 .onFailure {
94 // VCS status is best-effort: don't block the file browser, but surface
95 // a transient error so the user knows why badges are missing.
96 _transientErrors.tryEmit(container.friendlyError(it))
97 }
98 }
99 }
101 /** Re-open the current directory (manual refresh). Used by pull-to-refresh. */
102 fun refresh() {
103 _refreshing.value = true
104 open(_state.value.path)
105 viewModelScope.launch {
106 // Clear the spinner once the open() launch settles. open() sets loading=true
107 // synchronously and flips it false in its own coroutine; wait one frame cycle.
108 // Simpler: clear after the openJob completes by joining it.
109 openJob?.join()
110 _refreshing.value = false
111 }
112 }
114 fun open(path: String) {
115 val client = api ?: run {
116 _state.update { it.copy(
117 path = path,
118 loading = false,
119 error = container.string(R.string.not_connected),
120 ) }
121 return
122 }
123 _state.update { it.copy(
124 path = path,
125 query = "",
126 results = emptyList(),
127 searching = false,
128 loading = true,
129 error = null,
130 ) }
131 openJob?.cancel()
132 searchJob?.cancel()
133 openJob = viewModelScope.launch {
134 runCatchingCancellable { client.listDirectory(path) }
135 .onSuccess { entries ->
136 _state.update { it.copy(
137 entries = entries.sortedWith(compareByDescending<FileNode> { it.isDirectory }.thenBy { it.name.lowercase() }),
138 loading = false,
139 ) }
140 }
141 .onFailure { e -> _state.update { it.copy(loading = false, error = container.friendlyError(e)) } }
142 }
143 }
145 /** Navigate up one path segment. */
146 fun up() {
147 val current = _state.value.path
148 if (current.isBlank()) return
149 val parent = current.trimEnd('/').substringBeforeLast('/', missingDelimiterValue = "")
150 open(parent)
151 }
153 fun setQuery(query: String) {
154 _state.update { it.copy(query = query, error = null) }
155 searchJob?.cancel()
156 openJob?.cancel()
157 if (query.isBlank()) {
158 _state.update { it.copy(results = emptyList(), searching = false) }
159 // Cancelling openJob above may have interrupted an in-flight directory load,
160 // leaving loading=true stuck. Re-open the current directory so the listing
161 // re-populates instead of showing a perpetual spinner after clearing search.
162 open(_state.value.path)
163 return
164 }
165 // The search view replaces the directory listing; clear loading so the spinner
166 // from a cancelled open() doesn't persist into the search results view.
167 // Set searching=true immediately (not after the debounce) so the spinner shows
168 // during the debounce delay — otherwise the UI briefly renders SearchResults
169 // with an empty list, flashing "no matches" before the search even starts.
170 _state.update { it.copy(loading = false, searching = true) }
171 val client = api ?: run {
172 _state.update { it.copy(searching = false, error = container.string(R.string.not_connected)) }
173 return
174 }
175 searchJob = viewModelScope.launch {
176 delay(NetworkConfig.fileSearchDebounceMs) // debounce
177 runCatchingCancellable { client.findFiles(query) }
178 .onSuccess { results -> _state.update { it.copy(results = results, searching = false) } }
179 .onFailure { e -> _state.update { it.copy(searching = false, error = container.friendlyError(e)) } }
180 }
181 }