git.iko.soy/opencode-android
on 2794fda help sign in
91 lines 4.4 kB history raw
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.FileContent
7import soy.iko.opencode.di.AppContainer
8import soy.iko.opencode.R
9import kotlinx.coroutines.ExperimentalCoroutinesApi
10import kotlinx.coroutines.flow.MutableStateFlow
11import kotlinx.coroutines.flow.StateFlow
12import kotlinx.coroutines.flow.asStateFlow
13import kotlinx.coroutines.flow.collectLatest
14import kotlinx.coroutines.flow.merge
15import kotlinx.coroutines.flow.update
16import kotlinx.coroutines.launch
17import soy.iko.opencode.util.runCatchingCancellable
19@Immutable
20data class FileViewState(
21 val loading: Boolean = true,
22 val content: FileContent? = null,
23 val error: String? = null,
24 val size: Long? = null,
25 val mtime: Long? = null,
28@OptIn(ExperimentalCoroutinesApi::class)
29class FileViewModel(
30 private val container: AppContainer,
31 private val path: String,
32) : ViewModel() {
34 private val _state = MutableStateFlow(FileViewState())
35 val state: StateFlow<FileViewState> = _state.asStateFlow()
37 /** Incremented by [reload] to trigger a fresh fetch without waiting for a connection change. */
38 private val _reload = MutableStateFlow(0)
40 fun reload() { _reload.value++ }
42 init {
43 // Observe the active connection so the file loads (or reloads) when a connection
44 // becomes available — including when the view opens during a reconnect window
45 // where activeConnection.value was momentarily null. collectLatest cancels the
46 // in-flight load if the connection is replaced mid-read. The reload trigger is
47 // merged in so a manual retry (e.g. after a transient error) re-fetches.
48 viewModelScope.launch {
49 merge(container.activeConnection, _reload).collectLatest {
50 val conn = container.activeConnection.value
51 if (conn == null) {
52 _state.value = FileViewState(loading = false, error = container.string(R.string.not_connected))
53 return@collectLatest
54 }
55 // Keep any already-loaded content visible during a reload (only the initial
56 // load has null content) so a manual refresh doesn't blank the file — matching
57 // the file browser, which keeps the prior listing while refreshing.
58 _state.update { it.copy(loading = true, error = null) }
59 runCatchingCancellable { conn.api.readFile(path) }
60 .onSuccess { result ->
61 // Fetch the file's size/mtime. The server doesn't expose these on the
62 // content endpoint, so look the file up in its parent directory listing
63 // (which carries size/mtime when the server emits them). A missing/failed
64 // metadata fetch is non-fatal — the header just stays hidden — so a
65 // metadata failure can't clobber a successful read.
66 val meta = runCatchingCancellable { fetchMetadata(conn.api, path) }.getOrNull()
67 _state.value = FileViewState(
68 loading = false,
69 content = result,
70 size = meta?.size,
71 mtime = meta?.mtime,
72 )
73 }
74 .onFailure { _state.value = FileViewState(loading = false, error = container.friendlyError(it)) }
75 }
76 }
77 }
79 /** Look up [path] in its parent directory listing to read size/mtime. Returns null when
80 * the server doesn't emit size/mtime or the listing fails — the caller treats metadata
81 * as optional. Matches by the full [path] (FileNode.path), falling back to the name so a
82 * server that returns only relative names still resolves. */
83 private suspend fun fetchMetadata(api: soy.iko.opencode.data.network.OpencodeApiClient, path: String): soy.iko.opencode.data.model.FileNode? {
84 val trimmed = path.trimEnd('/')
85 val parent = trimmed.substringBeforeLast('/', "")
86 val name = trimmed.substringAfterLast('/')
87 val listing = api.listDirectory(parent.ifBlank { "/" })
88 return listing.firstOrNull { it.path == path }
89 ?: listing.firstOrNull { it.name == name }
90 }