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
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,
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
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
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)
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
76 statusJob = viewModelScope.launch {
77 runCatchingCancellable { client.fileStatus() }
78 .onSuccess { entries ->
79 _state.value = _state.value.copy(statusMap = entries.associateBy { it.path })
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)
89 fun open(path: String) {
90 val client = api ?: run {
91 _state.value = _state.value.copy(
94 error = container.string(R.string.not_connected),
98 _state.value = _state.value.copy(
101 results = emptyList(),
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() }),
116 .onFailure { _state.value = _state.value.copy(loading = false, error = container.friendlyError(it)) }
120 /** Navigate up one path segment. */
122 val current = _state.value.path
123 if (current.isBlank()) return
124 val parent = current.trimEnd('/').substringBeforeLast('/', missingDelimiterValue = "")
128 fun setQuery(query: String) {
129 _state.value = _state.value.copy(query = query, error = null)
132 if (query.isBlank()) {
133 _state.value = _state.value.copy(results = emptyList(), searching = false)
136 val client = api ?: run {
137 _state.value = _state.value.copy(searching = false, error = container.string(R.string.not_connected))
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)) }
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