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,
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
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
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)
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 })
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)
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))
87 _state.value = _state.value.copy(path = path, loading = true, error = null)
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() }),
97 .onFailure { _state.value = _state.value.copy(loading = false, error = container.friendlyError(it)) }
101 /** Navigate up one path segment. */
103 val current = _state.value.path
104 if (current.isBlank()) return
105 val parent = current.trimEnd('/').substringBeforeLast('/', missingDelimiterValue = "")
109 fun setQuery(query: String) {
110 _state.value = _state.value.copy(query = query, error = null)
112 if (query.isBlank()) {
113 _state.value = _state.value.copy(results = emptyList(), searching = false)
116 val client = api ?: run {
117 _state.value = _state.value.copy(searching = false, error = container.string(R.string.not_connected))
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)) }
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