git.iko.soy/opencode-android
on a40d3a2 help sign in
opencode-android/app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt
165 lines 7.0 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 /** 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,
53 )
54 val transientErrors: SharedFlow<String> = _transientErrors.asSharedFlow()
56 private var searchJob: Job? = null
57 private var openJob: Job? = null
59 init {
60 open("")
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
68 loadStatus()
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)
73 }
74 }
75 }
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
83 statusJob?.cancel()
84 statusJob = viewModelScope.launch {
85 runCatchingCancellable { client.fileStatus() }
86 .onSuccess { entries ->
87 _state.update { it.copy(statusMap = entries.associateBy { it.path }) }
88 }
89 .onFailure {
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))
93 }
94 }
95 }
97 fun open(path: String) {
98 val client = api ?: run {
99 _state.update { it.copy(
100 path = path,
101 loading = false,
102 error = container.string(R.string.not_connected),
103 ) }
104 return
105 }
106 _state.update { it.copy(
107 path = path,
108 query = "",
109 results = emptyList(),
110 searching = false,
111 loading = true,
112 error = null,
113 ) }
114 openJob?.cancel()
115 searchJob?.cancel()
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() }),
121 loading = false,
122 ) }
123 }
124 .onFailure { e -> _state.update { it.copy(loading = false, error = container.friendlyError(e)) } }
125 }
126 }
128 /** Navigate up one path segment. */
129 fun up() {
130 val current = _state.value.path
131 if (current.isBlank()) return
132 val parent = current.trimEnd('/').substringBeforeLast('/', missingDelimiterValue = "")
133 open(parent)
134 }
136 fun setQuery(query: String) {
137 _state.update { it.copy(query = query, error = null) }
138 searchJob?.cancel()
139 openJob?.cancel()
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)
146 return
147 }
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)) }
156 return
157 }
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)) } }
163 }
164 }