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.defaultMinSize
9import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.size
13import androidx.compose.foundation.lazy.LazyColumn
14import androidx.compose.foundation.lazy.items
15import androidx.compose.foundation.rememberScrollState
16import androidx.compose.foundation.text.KeyboardOptions
17import androidx.compose.material.icons.Icons
18import androidx.compose.material.icons.automirrored.filled.ArrowBack
19import androidx.compose.material.icons.filled.ChevronRight
20import androidx.compose.material.icons.filled.Description
21import androidx.compose.material.icons.filled.Folder
22import androidx.compose.material.icons.filled.Home
23import androidx.compose.material.icons.filled.Search
24import androidx.compose.material3.CircularProgressIndicator
25import androidx.compose.material3.ExperimentalMaterial3Api
26import androidx.compose.material3.HorizontalDivider
27import androidx.compose.material3.Icon
28import androidx.compose.material3.IconButton
29import androidx.compose.material3.MaterialTheme
30import androidx.compose.material3.OutlinedTextField
31import androidx.compose.material3.Scaffold
32import androidx.compose.material3.SnackbarHost
33import androidx.compose.material3.SnackbarHostState
34import androidx.compose.material3.Text
35import androidx.compose.material3.TopAppBar
36import androidx.compose.runtime.Composable
37import androidx.compose.runtime.LaunchedEffect
38import androidx.compose.runtime.getValue
39import androidx.compose.runtime.remember
40import androidx.compose.ui.Alignment
41import androidx.compose.ui.Modifier
42import androidx.compose.ui.graphics.Color
43import androidx.compose.ui.platform.testTag
44import androidx.compose.ui.semantics.Role
45import androidx.compose.ui.semantics.contentDescription
46import androidx.compose.ui.semantics.role
47import androidx.compose.ui.semantics.semantics
48import androidx.compose.ui.text.font.FontFamily
49import androidx.compose.ui.text.input.ImeAction
50import androidx.compose.ui.text.style.TextOverflow
51import androidx.compose.ui.unit.dp
52import androidx.compose.ui.res.stringResource
53import androidx.lifecycle.compose.collectAsStateWithLifecycle
54import androidx.lifecycle.viewmodel.compose.viewModel
55import soy.iko.opencode.data.model.FileStatusEntry
56import soy.iko.opencode.di.AppContainer
57import soy.iko.opencode.R
58import soy.iko.opencode.ui.vmFactory
60@OptIn(ExperimentalMaterial3Api::class)
63 container: AppContainer,
64 onOpenFile: (String) -> Unit,
67 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
68 val state by vm.state.collectAsStateWithLifecycle()
69 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
70 val snackbar = remember { SnackbarHostState() }
72 LaunchedEffect(Unit) {
73 vm.transientErrors.collect { msg ->
74 snackbar.showSnackbar(msg)
81 title = { Text(stringResource(R.string.files), maxLines = 1, overflow = TextOverflow.Ellipsis) },
83 IconButton(onClick = onBack) {
84 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
89 snackbarHost = { SnackbarHost(snackbar) },
91 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
92 // Tappable breadcrumb trail so deep paths are navigable.
95 onNavigate = vm::open,
96 modifier = Modifier.fillMaxWidth(),
100 onValueChange = vm::setQuery,
101 modifier = Modifier.fillMaxWidth().padding(12.dp).testTag("file_search"),
102 placeholder = { Text(stringResource(R.string.search_files)) },
103 label = { Text(stringResource(R.string.search_files)) },
104 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
106 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
109 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
110 isRefreshing = refreshing,
111 onRefresh = { vm.refresh() },
112 modifier = Modifier.fillMaxSize(),
114 Box(modifier = Modifier.fillMaxSize()) {
116 state.loading || state.searching -> {
117 val loadingLabel = stringResource(R.string.loading)
118 CircularProgressIndicator(
119 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
122 state.error != null -> Column(
123 modifier = Modifier.align(Alignment.Center).padding(24.dp),
124 horizontalAlignment = Alignment.CenterHorizontally,
128 color = MaterialTheme.colorScheme.error,
130 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
131 androidx.compose.material3.TextButton(onClick = { vm.open(state.path) }) {
132 Text(stringResource(R.string.retry))
135 state.isSearching -> SearchResults(state.results, onOpenFile)
136 else -> DirectoryListing(
138 onOpenDir = vm::open,
140 onOpenFile = onOpenFile,
150private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
151 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
154 .horizontalScroll(rememberScrollState())
155 .padding(horizontal = 16.dp, vertical = 8.dp),
156 verticalAlignment = Alignment.CenterVertically,
158 IconButton(onClick = { onNavigate("") }) {
159 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
162 segments.forEachIndexed { index, segment ->
163 acc = if (acc.isEmpty()) segment else "$acc/$segment"
165 Icons.Filled.ChevronRight,
166 contentDescription = null,
167 modifier = Modifier.size(16.dp),
168 tint = MaterialTheme.colorScheme.onSurfaceVariant,
173 style = MaterialTheme.typography.bodyMedium,
174 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
176 .clickable(role = Role.Button) { onNavigate(target) }
177 .padding(horizontal = 6.dp, vertical = 14.dp),
184private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
185 if (results.isEmpty()) {
186 Text(stringResource(R.string.no_matches), modifier = Modifier.padding(24.dp))
189 LazyColumn(modifier = Modifier.fillMaxSize()) {
190 items(results, key = { it }) { path ->
191 // Split the path into directory (muted) + filename (emphasized) so results
192 // are scannable instead of a wall of identical-looking full paths.
193 val dir = path.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
194 val name = path.substringAfterLast('/')
198 sublabel = dir.takeIf { it.isNotEmpty() },
199 onClick = { onOpenFile(path) },
207private fun DirectoryListing(
208 state: FileBrowserState,
209 onOpenDir: (String) -> Unit,
211 onOpenFile: (String) -> Unit,
213 if (state.entries.isEmpty()) {
215 stringResource(R.string.empty_folder),
216 modifier = Modifier.fillMaxWidth().padding(24.dp),
217 color = MaterialTheme.colorScheme.onSurfaceVariant,
221 LazyColumn(modifier = Modifier.fillMaxSize()) {
222 if (state.path.isNotBlank()) {
224 FileRow(icon = true, label = "..", onClick = onUp)
228 items(state.entries, key = { it.path + "_" + it.name }) { node ->
230 icon = node.isDirectory,
232 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
233 status = state.statusMap[node.path],
245 status: FileStatusEntry? = null,
246 sublabel: String? = null,
248 val desc = if (label == "..") stringResource(R.string.parent_dir)
249 else if (icon) stringResource(R.string.folder, label)
250 else stringResource(R.string.file_label, label)
254 .defaultMinSize(minHeight = 48.dp)
255 .clickable(role = Role.Button, onClick = onClick)
256 .semantics(mergeDescendants = true) { contentDescription = desc }
257 .padding(horizontal = 16.dp, vertical = 14.dp),
258 verticalAlignment = Alignment.CenterVertically,
261 if (icon) Icons.Filled.Folder else Icons.Filled.Description,
262 contentDescription = null,
263 modifier = Modifier.size(20.dp),
264 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
266 Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
269 style = MaterialTheme.typography.bodyLarge,
271 overflow = TextOverflow.Ellipsis,
273 if (sublabel != null) {
276 style = MaterialTheme.typography.labelSmall,
277 color = MaterialTheme.colorScheme.onSurfaceVariant,
279 overflow = TextOverflow.Ellipsis,
283 if (status != null) StatusBadge(status)
288private fun StatusBadge(status: FileStatusEntry) {
289 val (letter, color, desc) = when (status.status) {
290 "added" -> Triple("A", MaterialTheme.colorScheme.primary, stringResource(R.string.git_added))
291 "modified" -> Triple("M", MaterialTheme.colorScheme.tertiary, stringResource(R.string.git_modified))
292 "deleted" -> Triple("D", MaterialTheme.colorScheme.error, stringResource(R.string.git_deleted))
293 else -> Triple("·", MaterialTheme.colorScheme.onSurfaceVariant, "")
296 verticalAlignment = Alignment.CenterVertically,
297 modifier = Modifier.semantics(mergeDescendants = true) {
298 if (desc.isNotEmpty()) contentDescription = desc
303 style = MaterialTheme.typography.labelMedium,
304 fontFamily = FontFamily.Monospace,
307 if (status.added > 0 || status.removed > 0) {
309 "+${status.added} −${status.removed}",
310 style = MaterialTheme.typography.labelSmall,
311 fontFamily = FontFamily.Monospace,
312 color = MaterialTheme.colorScheme.onSurfaceVariant,
313 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