git.iko.soy/opencode-android
on d643c13 help sign in
opencode-android/app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt
148 lines 5.9 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.flow.MutableStateFlow
16import kotlinx.coroutines.flow.StateFlow
17import kotlinx.coroutines.flow.asStateFlow
18import kotlinx.coroutines.flow.collectLatest
19import kotlinx.coroutines.launch
21@Immutable
22data class FileBrowserState(
23 val path: String = "",
24 val entries: List<FileNode> = emptyList(),
25 val statusMap: Map<String, FileStatusEntry> = emptyMap(),
26 val query: String = "",
27 val results: List<String> = emptyList(),
28 val searching: Boolean = false,
29 val loading: Boolean = true,
30 val error: String? = null,
31) {
32 val isSearching: Boolean get() = query.isNotBlank()
35@OptIn(ExperimentalCoroutinesApi::class)
36class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
38 private val api get() = container.activeConnection.value?.api
40 private val _state = MutableStateFlow(FileBrowserState())
41 val state: StateFlow<FileBrowserState> = _state.asStateFlow()
43 /** Transient errors (failed VCS status fetch, etc.) surfaced without hiding the file list. */
44 private val _transientError = MutableStateFlow<String?>(null)
45 val transientError: StateFlow<String?> = _transientError.asStateFlow()
46 fun clearTransientError() { _transientError.value = null }
48 private var searchJob: Job? = null
49 private var openJob: Job? = null
51 init {
52 open("")
53 // Observe the active connection so VCS status loads (or reloads) when a connection
54 // becomes available — including when the view opens during a reconnect window where
55 // activeConnection.value was momentarily null. Without this, loadStatus() would be
56 // called once from init with a null api and never retried.
57 viewModelScope.launch {
58 container.activeConnection.collectLatest { conn ->
59 if (conn == null) return@collectLatest
60 loadStatus()
61 // Re-open the current directory when a connection becomes available
62 // so the initial open("") (which may have run before auto-reconnect
63 // finished and found no connection) doesn't leave a stale error.
64 open(_state.value.path)
65 }
66 }
67 }
69 /** Tracks the in-flight VCS status load so a server switch can cancel it. */
70 private var statusJob: Job? = null
72 /** Fetch the repo-wide VCS status once so file rows can show git badges. */
73 private fun loadStatus() {
74 val client = api ?: return
75 statusJob?.cancel()
76 statusJob = viewModelScope.launch {
77 runCatchingCancellable { client.fileStatus() }
78 .onSuccess { entries ->
79 _state.value = _state.value.copy(statusMap = entries.associateBy { it.path })
80 }
81 .onFailure {
82 // VCS status is best-effort: don't block the file browser, but surface
83 // a transient error so the user knows why badges are missing.
84 _transientError.value = container.friendlyError(it)
85 }
86 }
87 }
89 fun open(path: String) {
90 val client = api ?: run {
91 _state.value = _state.value.copy(
92 path = path,
93 loading = false,
94 error = container.string(R.string.not_connected),
95 )
96 return
97 }
98 _state.value = _state.value.copy(
99 path = path,
100 query = "",
101 results = emptyList(),
102 searching = false,
103 loading = true,
104 error = null,
105 )
106 openJob?.cancel()
107 searchJob?.cancel()
108 openJob = viewModelScope.launch {
109 runCatchingCancellable { client.listDirectory(path) }
110 .onSuccess { entries ->
111 _state.value = _state.value.copy(
112 entries = entries.sortedWith(compareByDescending<FileNode> { it.isDirectory }.thenBy { it.name.lowercase() }),
113 loading = false,
114 )
115 }
116 .onFailure { _state.value = _state.value.copy(loading = false, error = container.friendlyError(it)) }
117 }
118 }
120 /** Navigate up one path segment. */
121 fun up() {
122 val current = _state.value.path
123 if (current.isBlank()) return
124 val parent = current.trimEnd('/').substringBeforeLast('/', missingDelimiterValue = "")
125 open(parent)
126 }
128 fun setQuery(query: String) {
129 _state.value = _state.value.copy(query = query, error = null)
130 searchJob?.cancel()
131 openJob?.cancel()
132 if (query.isBlank()) {
133 _state.value = _state.value.copy(results = emptyList(), searching = false)
134 return
135 }
136 val client = api ?: run {
137 _state.value = _state.value.copy(searching = false, error = container.string(R.string.not_connected))
138 return
139 }
140 searchJob = viewModelScope.launch {
141 delay(NetworkConfig.fileSearchDebounceMs) // debounce
142 _state.value = _state.value.copy(searching = true)
143 runCatchingCancellable { client.findFiles(query) }
144 .onSuccess { _state.value = _state.value.copy(results = it, searching = false) }
145 .onFailure { _state.value = _state.value.copy(searching = false, error = container.friendlyError(it)) }
146 }
147 }