1package soy.iko.opencode.ui.file
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.horizontalScroll
5import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.fillMaxSize
9import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.padding
11import androidx.compose.foundation.layout.size
12import androidx.compose.foundation.lazy.LazyColumn
13import androidx.compose.foundation.lazy.items
14import androidx.compose.foundation.rememberScrollState
15import androidx.compose.foundation.text.KeyboardOptions
16import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.automirrored.filled.ArrowBack
18import androidx.compose.material.icons.filled.ChevronRight
19import androidx.compose.material.icons.filled.Description
20import androidx.compose.material.icons.filled.Folder
21import androidx.compose.material.icons.filled.Home
22import androidx.compose.material.icons.filled.Search
23import androidx.compose.material3.CircularProgressIndicator
24import androidx.compose.material3.ExperimentalMaterial3Api
25import androidx.compose.material3.HorizontalDivider
26import androidx.compose.material3.Icon
27import androidx.compose.material3.IconButton
28import androidx.compose.material3.MaterialTheme
29import androidx.compose.material3.OutlinedTextField
30import androidx.compose.material3.Scaffold
31import androidx.compose.material3.SnackbarHost
32import androidx.compose.material3.SnackbarHostState
33import androidx.compose.material3.Text
34import androidx.compose.material3.TopAppBar
35import androidx.compose.runtime.Composable
36import androidx.compose.runtime.LaunchedEffect
37import androidx.compose.runtime.getValue
38import androidx.compose.runtime.remember
39import androidx.compose.ui.Alignment
40import androidx.compose.ui.Modifier
41import androidx.compose.ui.graphics.Color
42import androidx.compose.ui.platform.testTag
43import androidx.compose.ui.semantics.Role
44import androidx.compose.ui.semantics.contentDescription
45import androidx.compose.ui.semantics.role
46import androidx.compose.ui.semantics.semantics
47import androidx.compose.ui.text.font.FontFamily
48import androidx.compose.ui.text.input.ImeAction
49import androidx.compose.ui.text.style.TextOverflow
50import androidx.compose.ui.unit.dp
51import androidx.compose.ui.res.stringResource
52import androidx.lifecycle.compose.collectAsStateWithLifecycle
53import androidx.lifecycle.viewmodel.compose.viewModel
54import soy.iko.opencode.data.model.FileStatusEntry
55import soy.iko.opencode.di.AppContainer
56import soy.iko.opencode.R
57import soy.iko.opencode.ui.vmFactory
59@OptIn(ExperimentalMaterial3Api::class)
62 container: AppContainer,
63 onOpenFile: (String) -> Unit,
66 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
67 val state by vm.state.collectAsStateWithLifecycle()
68 val transientError by vm.transientError.collectAsStateWithLifecycle()
69 val snackbar = remember { SnackbarHostState() }
71 LaunchedEffect(transientError) {
72 val msg = transientError ?: return@LaunchedEffect
73 snackbar.showSnackbar(msg)
74 vm.clearTransientError()
80 title = { Text(stringResource(R.string.files), maxLines = 1, overflow = TextOverflow.Ellipsis) },
82 IconButton(onClick = onBack) {
83 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
88 snackbarHost = { SnackbarHost(snackbar) },
90 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
91 // Tappable breadcrumb trail so deep paths are navigable.
94 onNavigate = vm::open,
95 modifier = Modifier.fillMaxWidth(),
99 onValueChange = vm::setQuery,
100 modifier = Modifier.fillMaxWidth().padding(12.dp).testTag("file_search"),
101 placeholder = { Text(stringResource(R.string.search_files)) },
102 label = { Text(stringResource(R.string.search_files)) },
103 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
105 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
108 Box(modifier = Modifier.fillMaxSize()) {
110 state.loading || state.searching -> {
111 val loadingLabel = stringResource(R.string.loading)
112 CircularProgressIndicator(
113 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
116 state.error != null && !state.isSearching -> Text(
118 modifier = Modifier.align(Alignment.Center).padding(24.dp),
119 color = MaterialTheme.colorScheme.error,
121 state.isSearching -> SearchResults(state.results, onOpenFile)
122 else -> DirectoryListing(
124 onOpenDir = vm::open,
126 onOpenFile = onOpenFile,
135private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
136 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
139 .horizontalScroll(rememberScrollState())
140 .padding(horizontal = 16.dp, vertical = 8.dp),
141 verticalAlignment = Alignment.CenterVertically,
143 IconButton(onClick = { onNavigate("") }) {
144 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
147 segments.forEachIndexed { index, segment ->
148 acc = if (acc.isEmpty()) segment else "$acc/$segment"
150 Icons.Filled.ChevronRight,
151 contentDescription = null,
152 modifier = Modifier.size(16.dp),
153 tint = MaterialTheme.colorScheme.onSurfaceVariant,
158 style = MaterialTheme.typography.bodyMedium,
159 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
161 .clickable(role = Role.Button) { onNavigate(target) }
162 .padding(horizontal = 6.dp, vertical = 14.dp),
169private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
170 if (results.isEmpty()) {
171 Text(stringResource(R.string.no_matches), modifier = Modifier.padding(24.dp))
174 LazyColumn(modifier = Modifier.fillMaxSize()) {
175 items(results, key = { it }) { path ->
176 FileRow(icon = false, label = path, onClick = { onOpenFile(path) })
183private fun DirectoryListing(
184 state: FileBrowserState,
185 onOpenDir: (String) -> Unit,
187 onOpenFile: (String) -> Unit,
189 if (state.entries.isEmpty() && state.path.isBlank()) {
191 stringResource(R.string.empty_folder),
192 modifier = Modifier.fillMaxWidth().padding(24.dp),
193 color = MaterialTheme.colorScheme.onSurfaceVariant,
197 LazyColumn(modifier = Modifier.fillMaxSize()) {
198 if (state.path.isNotBlank()) {
200 FileRow(icon = true, label = "..", onClick = onUp)
204 items(state.entries, key = { it.path + "_" + it.name }) { node ->
206 icon = node.isDirectory,
208 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
209 status = state.statusMap[node.path],
221 status: FileStatusEntry? = null,
223 val desc = if (label == "..") stringResource(R.string.parent_dir)
224 else if (icon) stringResource(R.string.folder, label)
225 else stringResource(R.string.file_label, label)
229 .clickable(role = Role.Button, onClick = onClick)
230 .semantics(mergeDescendants = true) { contentDescription = desc }
231 .padding(horizontal = 16.dp, vertical = 14.dp),
232 verticalAlignment = Alignment.CenterVertically,
235 if (icon) Icons.Filled.Folder else Icons.Filled.Description,
236 contentDescription = null,
237 modifier = Modifier.size(20.dp),
238 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
242 style = MaterialTheme.typography.bodyLarge,
244 overflow = TextOverflow.Ellipsis,
245 modifier = Modifier.weight(1f).padding(start = 8.dp),
247 if (status != null) StatusBadge(status)
252private fun StatusBadge(status: FileStatusEntry) {
253 val (letter, color, desc) = when (status.status) {
254 "added" -> Triple("A", MaterialTheme.colorScheme.primary, stringResource(R.string.git_added))
255 "modified" -> Triple("M", MaterialTheme.colorScheme.tertiary, stringResource(R.string.git_modified))
256 "deleted" -> Triple("D", MaterialTheme.colorScheme.error, stringResource(R.string.git_deleted))
257 else -> Triple("·", MaterialTheme.colorScheme.onSurfaceVariant, "")
260 verticalAlignment = Alignment.CenterVertically,
261 modifier = Modifier.semantics(mergeDescendants = true) {
262 if (desc.isNotEmpty()) contentDescription = desc
267 style = MaterialTheme.typography.labelMedium,
268 fontFamily = FontFamily.Monospace,
271 if (status.added > 0 || status.removed > 0) {
273 "+${status.added} −${status.removed}",
274 style = MaterialTheme.typography.labelSmall,
275 fontFamily = FontFamily.Monospace,
276 color = MaterialTheme.colorScheme.onSurfaceVariant,
277 modifier = Modifier.padding(start = 4.dp),
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