git.iko.soy/opencode-android
on 3e723a1 help sign in
opencode-android/app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt
128 lines 5.3 kB history raw
1package soy.iko.opencode.ui.file
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
5import soy.iko.opencode.data.model.FileNode
6import soy.iko.opencode.data.model.FileStatusEntry
7import soy.iko.opencode.data.network.NetworkConfig
8import soy.iko.opencode.di.AppContainer
9import soy.iko.opencode.R
10import kotlinx.coroutines.ExperimentalCoroutinesApi
11import kotlinx.coroutines.Job
12import kotlinx.coroutines.delay
13import kotlinx.coroutines.flow.MutableStateFlow
14import kotlinx.coroutines.flow.StateFlow
15import kotlinx.coroutines.flow.asStateFlow
16import kotlinx.coroutines.flow.collectLatest
17import kotlinx.coroutines.launch
19data class FileBrowserState(
20 val path: String = "",
21 val entries: List<FileNode> = emptyList(),
22 val statusMap: Map<String, FileStatusEntry> = emptyMap(),
23 val query: String = "",
24 val results: List<String> = emptyList(),
25 val searching: Boolean = false,
26 val loading: Boolean = true,
27 val error: String? = null,
28) {
29 val isSearching: Boolean get() = query.isNotBlank()
32@OptIn(ExperimentalCoroutinesApi::class)
33class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
35 private val api get() = container.activeConnection.value?.api
37 private val _state = MutableStateFlow(FileBrowserState())
38 val state: StateFlow<FileBrowserState> = _state.asStateFlow()
40 /** Transient errors (failed VCS status fetch, etc.) surfaced without hiding the file list. */
41 private val _transientError = MutableStateFlow<String?>(null)
42 val transientError: StateFlow<String?> = _transientError.asStateFlow()
43 fun clearTransientError() { _transientError.value = null }
45 private var searchJob: Job? = null
46 private var openJob: Job? = null
48 init {
49 open("")
50 // Observe the active connection so VCS status loads (or reloads) when a connection
51 // becomes available — including when the view opens during a reconnect window where
52 // activeConnection.value was momentarily null. Without this, loadStatus() would be
53 // called once from init with a null api and never retried.
54 viewModelScope.launch {
55 container.activeConnection.collectLatest { conn ->
56 if (conn == null) return@collectLatest
57 loadStatus()
58 // Re-open the current directory when a connection becomes available
59 // so the initial open("") (which may have run before auto-reconnect
60 // finished and found no connection) doesn't leave a stale error.
61 open(_state.value.path)
62 }
63 }
64 }
66 /** Fetch the repo-wide VCS status once so file rows can show git badges. */
67 private fun loadStatus() {
68 val client = api ?: return
69 viewModelScope.launch {
70 runCatching { client.fileStatus() }
71 .onSuccess { entries ->
72 _state.value = _state.value.copy(statusMap = entries.associateBy { it.path })
73 }
74 .onFailure {
75 // VCS status is best-effort: don't block the file browser, but surface
76 // a transient error so the user knows why badges are missing.
77 _transientError.value = container.friendlyError(it)
78 }
79 }
80 }
82 fun open(path: String) {
83 val client = api ?: run {
84 _state.value = _state.value.copy(loading = false, error = container.string(R.string.not_connected))
85 return
86 }
87 _state.value = _state.value.copy(path = path, loading = true, error = null)
88 openJob?.cancel()
89 openJob = viewModelScope.launch {
90 runCatching { client.listDirectory(path) }
91 .onSuccess { entries ->
92 _state.value = _state.value.copy(
93 entries = entries.sortedWith(compareByDescending<FileNode> { it.isDirectory }.thenBy { it.name.lowercase() }),
94 loading = false,
95 )
96 }
97 .onFailure { _state.value = _state.value.copy(loading = false, error = container.friendlyError(it)) }
98 }
99 }
101 /** Navigate up one path segment. */
102 fun up() {
103 val current = _state.value.path
104 if (current.isBlank()) return
105 val parent = current.trimEnd('/').substringBeforeLast('/', missingDelimiterValue = "")
106 open(parent)
107 }
109 fun setQuery(query: String) {
110 _state.value = _state.value.copy(query = query, error = null)
111 searchJob?.cancel()
112 if (query.isBlank()) {
113 _state.value = _state.value.copy(results = emptyList(), searching = false)
114 return
115 }
116 val client = api ?: run {
117 _state.value = _state.value.copy(searching = false, error = container.string(R.string.not_connected))
118 return
119 }
120 searchJob = viewModelScope.launch {
121 delay(NetworkConfig.fileSearchDebounceMs) // debounce
122 _state.value = _state.value.copy(searching = true)
123 runCatching { client.findFiles(query) }
124 .onSuccess { _state.value = _state.value.copy(results = it, searching = false) }
125 .onFailure { _state.value = _state.value.copy(searching = false, error = container.friendlyError(it)) }
126 }
127 }