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(loading = false, error = container.string(R.string.not_connected))
94 _state.value = _state.value.copy(path = path, loading = true, error = null)
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() }),
106 .onFailure { _state.value = _state.value.copy(loading = false, error = container.friendlyError(it)) }
110 /** Navigate up one path segment. */
112 val current = _state.value.path
113 if (current.isBlank()) return
114 val parent = current.trimEnd('/').substringBeforeLast('/', missingDelimiterValue = "")
118 fun setQuery(query: String) {
119 _state.value = _state.value.copy(query = query, error = null)
121 if (query.isBlank()) {
122 _state.value = _state.value.copy(results = emptyList(), searching = false)
125 val client = api ?: run {
126 _state.value = _state.value.copy(searching = false, error = container.string(R.string.not_connected))
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)) }
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