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.Job
11import kotlinx.coroutines.delay
12import kotlinx.coroutines.flow.MutableStateFlow
13import kotlinx.coroutines.flow.StateFlow
14import kotlinx.coroutines.flow.asStateFlow
15import kotlinx.coroutines.launch
17data class FileBrowserState(
18 val path: String = "",
19 val entries: List<FileNode> = emptyList(),
20 val statusMap: Map<String, FileStatusEntry> = emptyMap(),
21 val query: String = "",
22 val results: List<String> = emptyList(),
23 val searching: Boolean = false,
24 val loading: Boolean = true,
25 val error: String? = null,
27 val isSearching: Boolean get() = query.isNotBlank()
30class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
32 private val api get() = container.activeConnection.value?.api
34 private val _state = MutableStateFlow(FileBrowserState())
35 val state: StateFlow<FileBrowserState> = _state.asStateFlow()
37 /** Transient errors (failed VCS status fetch, etc.) surfaced without hiding the file list. */
38 private val _transientError = MutableStateFlow<String?>(null)
39 val transientError: StateFlow<String?> = _transientError.asStateFlow()
40 fun clearTransientError() { _transientError.value = null }
42 private var searchJob: Job? = null
43 private var openJob: Job? = null
45 init { open(""); loadStatus() }
47 /** Fetch the repo-wide VCS status once so file rows can show git badges. */
48 private fun loadStatus() {
49 val client = api ?: return
50 viewModelScope.launch {
51 runCatching { client.fileStatus() }
52 .onSuccess { entries ->
53 _state.value = _state.value.copy(statusMap = entries.associateBy { it.path })
56 // VCS status is best-effort: don't block the file browser, but surface
57 // a transient error so the user knows why badges are missing.
58 _transientError.value = container.friendlyError(it)
63 fun open(path: String) {
64 val client = api ?: run {
65 _state.value = _state.value.copy(loading = false, error = container.string(R.string.not_connected))
68 _state.value = _state.value.copy(path = path, loading = true, error = null)
70 openJob = viewModelScope.launch {
71 runCatching { client.listDirectory(path) }
72 .onSuccess { entries ->
73 _state.value = _state.value.copy(
74 entries = entries.sortedWith(compareByDescending<FileNode> { it.isDirectory }.thenBy { it.name.lowercase() }),
78 .onFailure { _state.value = _state.value.copy(loading = false, error = container.friendlyError(it)) }
82 /** Navigate up one path segment. */
84 val current = _state.value.path
85 if (current.isBlank()) return
86 val parent = current.trimEnd('/').substringBeforeLast('/', missingDelimiterValue = "")
90 fun setQuery(query: String) {
91 _state.value = _state.value.copy(query = query)
93 if (query.isBlank()) {
94 _state.value = _state.value.copy(results = emptyList(), searching = false)
97 val client = api ?: return
98 searchJob = viewModelScope.launch {
99 delay(NetworkConfig.fileSearchDebounceMs) // debounce
100 _state.value = _state.value.copy(searching = true)
101 runCatching { client.findFiles(query) }
102 .onSuccess { _state.value = _state.value.copy(results = it, searching = false) }
103 .onFailure { _state.value = _state.value.copy(searching = false, error = container.friendlyError(it)) }
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list