git.iko.soy/opencode-android
on 66d239c help sign in
opencode-android/app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt
137 lines 5.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.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(loading = false, error = container.string(R.string.not_connected))
92 return
93 }
94 _state.value = _state.value.copy(path = path, loading = true, error = null)
95 openJob?.cancel()
96 searchJob?.cancel()
97 if (_state.value.isSearching) _state.value = _state.value.copy(results = emptyList(), searching = false)
98 openJob = viewModelScope.launch {
99 runCatchingCancellable { client.listDirectory(path) }
100 .onSuccess { entries ->
101 _state.value = _state.value.copy(
102 entries = entries.sortedWith(compareByDescending<FileNode> { it.isDirectory }.thenBy { it.name.lowercase() }),
103 loading = false,
104 )
105 }
106 .onFailure { _state.value = _state.value.copy(loading = false, error = container.friendlyError(it)) }
107 }
108 }
110 /** Navigate up one path segment. */
111 fun up() {
112 val current = _state.value.path
113 if (current.isBlank()) return
114 val parent = current.trimEnd('/').substringBeforeLast('/', missingDelimiterValue = "")
115 open(parent)
116 }
118 fun setQuery(query: String) {
119 _state.value = _state.value.copy(query = query, error = null)
120 searchJob?.cancel()
121 if (query.isBlank()) {
122 _state.value = _state.value.copy(results = emptyList(), searching = false)
123 return
124 }
125 val client = api ?: run {
126 _state.value = _state.value.copy(searching = false, error = container.string(R.string.not_connected))
127 return
128 }
129 searchJob = viewModelScope.launch {
130 delay(NetworkConfig.fileSearchDebounceMs) // debounce
131 _state.value = _state.value.copy(searching = true)
132 runCatchingCancellable { client.findFiles(query) }
133 .onSuccess { _state.value = _state.value.copy(results = it, searching = false) }
134 .onFailure { _state.value = _state.value.copy(searching = false, error = container.friendlyError(it)) }
135 }
136 }