1package soy.iko.opencode.ui.file
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.horizontalScroll
5import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.Column
8import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.Spacer
10import androidx.compose.foundation.layout.defaultMinSize
11import androidx.compose.foundation.layout.fillMaxSize
12import androidx.compose.foundation.layout.fillMaxWidth
13import androidx.compose.foundation.layout.padding
14import androidx.compose.foundation.layout.size
15import androidx.compose.foundation.lazy.LazyColumn
16import androidx.compose.foundation.lazy.items
17import androidx.compose.foundation.lazy.itemsIndexed
18import androidx.compose.foundation.rememberScrollState
19import androidx.compose.foundation.text.KeyboardActions
20import androidx.compose.foundation.text.KeyboardOptions
21import androidx.compose.material.icons.Icons
22import androidx.compose.material.icons.automirrored.filled.ArrowBack
23import androidx.compose.material.icons.filled.ChevronRight
24import androidx.compose.material.icons.filled.Close
25import androidx.compose.material.icons.filled.Description
26import androidx.compose.material.icons.filled.Folder
27import androidx.compose.material.icons.filled.Home
28import androidx.compose.material.icons.filled.Search
29import androidx.compose.material3.CircularProgressIndicator
30import androidx.compose.material3.ExperimentalMaterial3Api
31import androidx.compose.material3.FilterChip
32import androidx.compose.material3.HorizontalDivider
33import androidx.compose.material3.Icon
34import androidx.compose.material3.IconButton
35import androidx.compose.material3.MaterialTheme
36import androidx.compose.material3.OutlinedTextField
37import androidx.compose.material3.Scaffold
38import androidx.compose.material3.SnackbarHost
39import androidx.compose.material3.SnackbarHostState
40import androidx.compose.material3.Text
41import androidx.compose.material3.TopAppBar
42import androidx.compose.runtime.Composable
43import androidx.compose.runtime.LaunchedEffect
44import androidx.compose.runtime.getValue
45import androidx.compose.runtime.remember
46import androidx.compose.ui.Alignment
47import androidx.compose.ui.Modifier
48import androidx.compose.ui.graphics.Color
49import androidx.compose.ui.platform.LocalSoftwareKeyboardController
50import androidx.compose.ui.platform.testTag
51import androidx.compose.ui.semantics.Role
52import androidx.compose.ui.semantics.contentDescription
53import androidx.compose.ui.semantics.role
54import androidx.compose.ui.semantics.semantics
55import androidx.compose.ui.text.SpanStyle
56import androidx.compose.ui.text.buildAnnotatedString
57import androidx.compose.ui.text.font.FontFamily
58import androidx.compose.ui.text.font.FontWeight
59import androidx.compose.ui.text.input.ImeAction
60import androidx.compose.ui.text.style.TextOverflow
61import androidx.compose.ui.text.withStyle
62import androidx.compose.ui.unit.dp
63import androidx.compose.ui.res.stringResource
64import androidx.lifecycle.compose.collectAsStateWithLifecycle
65import androidx.lifecycle.viewmodel.compose.viewModel
66import soy.iko.opencode.data.model.FileStatusEntry
67import soy.iko.opencode.data.model.FindMatch
68import soy.iko.opencode.data.model.SymbolResult
69import soy.iko.opencode.data.model.symbolKindLabel
70import soy.iko.opencode.di.AppContainer
71import soy.iko.opencode.R
72import soy.iko.opencode.ui.vmFactory
74@OptIn(ExperimentalMaterial3Api::class)
77 container: AppContainer,
78 onOpenFile: (path: String, line: Int?) -> Unit,
81 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
82 val state by vm.state.collectAsStateWithLifecycle()
83 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
84 val snackbar = remember { SnackbarHostState() }
86 LaunchedEffect(Unit) {
87 vm.transientErrors.collect { msg ->
88 snackbar.showSnackbar(msg)
95 title = { Text(stringResource(R.string.files), maxLines = 1, overflow = TextOverflow.Ellipsis) },
97 IconButton(onClick = onBack) {
98 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
103 snackbarHost = { SnackbarHost(snackbar) },
105 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
106 // Tappable breadcrumb trail so deep paths are navigable.
109 onNavigate = vm::open,
110 modifier = Modifier.fillMaxWidth(),
112 val keyboardController = LocalSoftwareKeyboardController.current
115 onValueChange = vm::setQuery,
116 modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, top = 12.dp).testTag("file_search"),
121 SearchMode.FILES -> R.string.search_files
122 SearchMode.TEXT -> R.string.search_in_files
123 SearchMode.SYMBOL -> R.string.search_symbols_label
128 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
129 trailingIcon = if (state.query.isNotEmpty()) {
131 IconButton(onClick = { vm.setQuery("") }) {
132 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
137 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
138 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
140 // Mode selector: file names / contents / symbols.
142 modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp),
143 horizontalArrangement = Arrangement.spacedBy(8.dp),
145 SearchModeChip(stringResource(R.string.search_mode_files), state.mode == SearchMode.FILES) { vm.setMode(SearchMode.FILES) }
146 SearchModeChip(stringResource(R.string.search_mode_text), state.mode == SearchMode.TEXT) { vm.setMode(SearchMode.TEXT) }
147 SearchModeChip(stringResource(R.string.search_mode_symbols), state.mode == SearchMode.SYMBOL) { vm.setMode(SearchMode.SYMBOL) }
149 Spacer(Modifier.size(8.dp))
151 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
152 isRefreshing = refreshing,
153 onRefresh = { vm.refresh() },
154 modifier = Modifier.fillMaxSize(),
156 Box(modifier = Modifier.fillMaxSize()) {
158 state.loading || state.searching -> {
159 val loadingLabel = stringResource(R.string.loading)
160 CircularProgressIndicator(
161 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
164 state.error != null -> Column(
165 modifier = Modifier.align(Alignment.Center).padding(24.dp),
166 horizontalAlignment = Alignment.CenterHorizontally,
170 color = MaterialTheme.colorScheme.error,
172 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
173 // Retry the operation that actually failed: re-run the search
174 // when one was active (open() would reset the query and drop the
175 // user back into the directory listing), otherwise reload the dir.
176 androidx.compose.material3.TextButton(
177 onClick = { if (state.isSearching) vm.setQuery(state.query) else vm.open(state.path) },
179 Text(stringResource(R.string.retry))
182 state.mode == SearchMode.TEXT ->
183 TextResults(state.textResults) { path, line -> onOpenFile(path, line) }
184 state.mode == SearchMode.SYMBOL ->
185 SymbolResults(state.symbolResults) { path, line -> onOpenFile(path, line) }
186 state.isSearching -> SearchResults(state.results) { onOpenFile(it, null) }
187 else -> DirectoryListing(
189 onOpenDir = vm::open,
191 onOpenFile = { onOpenFile(it, null) },
201private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
202 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
205 .horizontalScroll(rememberScrollState())
206 .padding(horizontal = 16.dp, vertical = 8.dp),
207 verticalAlignment = Alignment.CenterVertically,
209 IconButton(onClick = { onNavigate("") }) {
210 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
213 segments.forEachIndexed { index, segment ->
214 acc = if (acc.isEmpty()) segment else "$acc/$segment"
216 Icons.Filled.ChevronRight,
217 contentDescription = null,
218 modifier = Modifier.size(16.dp),
219 tint = MaterialTheme.colorScheme.onSurfaceVariant,
224 style = MaterialTheme.typography.bodyMedium,
225 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
227 .clickable(role = Role.Button) { onNavigate(target) }
228 .defaultMinSize(minHeight = 48.dp)
229 .padding(horizontal = 6.dp, vertical = 14.dp),
236private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
237 if (results.isEmpty()) {
239 icon = Icons.Filled.Search,
240 message = stringResource(R.string.no_matches),
241 modifier = Modifier.fillMaxWidth().padding(24.dp),
245 LazyColumn(modifier = Modifier.fillMaxSize()) {
246 items(results, key = { it }) { path ->
247 // Split the path into directory (muted) + filename (emphasized) so results
248 // are scannable instead of a wall of identical-looking full paths.
249 val dir = path.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
250 val name = path.substringAfterLast('/')
254 sublabel = dir.takeIf { it.isNotEmpty() },
255 onClick = { onOpenFile(path) },
256 modifier = Modifier.animateItem(),
263@OptIn(ExperimentalMaterial3Api::class)
265private fun SearchModeChip(label: String, selected: Boolean, onClick: () -> Unit) {
266 FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
269/** Content (ripgrep) search results: file + line number, with the matched line highlighted. */
271private fun TextResults(results: List<FindMatch>, onOpen: (String, Int?) -> Unit) {
272 if (results.isEmpty()) {
274 icon = Icons.Filled.Search,
275 message = stringResource(R.string.no_matches),
276 modifier = Modifier.fillMaxWidth().padding(24.dp),
280 LazyColumn(modifier = Modifier.fillMaxSize()) {
281 itemsIndexed(results, key = { i, m -> "${m.filePath}:${m.lineNumber}:$i" }) { _, match ->
282 val name = match.filePath.substringAfterLast('/')
283 val dir = match.filePath.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
284 val highlighted = remember(match) { highlightMatchLine(match) }
288 .clickable(role = Role.Button) { onOpen(match.filePath, match.lineNumber) }
289 .padding(horizontal = 16.dp, vertical = 10.dp),
291 Row(verticalAlignment = Alignment.CenterVertically) {
294 style = MaterialTheme.typography.bodyMedium,
296 overflow = TextOverflow.Ellipsis,
297 modifier = Modifier.weight(1f, fill = false),
300 ":${match.lineNumber}",
301 style = MaterialTheme.typography.labelSmall,
302 color = MaterialTheme.colorScheme.onSurfaceVariant,
303 fontFamily = FontFamily.Monospace,
304 modifier = Modifier.padding(start = 4.dp),
307 if (dir.isNotEmpty()) {
310 style = MaterialTheme.typography.labelSmall,
311 color = MaterialTheme.colorScheme.onSurfaceVariant,
313 overflow = TextOverflow.Ellipsis,
318 style = MaterialTheme.typography.bodySmall,
319 fontFamily = FontFamily.Monospace,
321 overflow = TextOverflow.Ellipsis,
322 modifier = Modifier.padding(top = 2.dp),
330/** Workspace symbol results: symbol name + kind, and the file:line it's defined at. */
332private fun SymbolResults(results: List<SymbolResult>, onOpen: (String, Int?) -> Unit) {
333 if (results.isEmpty()) {
335 icon = Icons.Filled.Search,
336 message = stringResource(R.string.no_matches),
337 modifier = Modifier.fillMaxWidth().padding(24.dp),
341 LazyColumn(modifier = Modifier.fillMaxSize()) {
342 itemsIndexed(results, key = { i, s -> "${s.filePath}:${s.name}:$i" }) { _, symbol ->
343 val fileName = symbol.filePath.substringAfterLast('/')
347 .clickable(role = Role.Button) { onOpen(symbol.filePath, symbol.displayLine) }
348 .padding(horizontal = 16.dp, vertical = 10.dp),
350 Row(verticalAlignment = Alignment.CenterVertically) {
353 style = MaterialTheme.typography.bodyMedium,
355 overflow = TextOverflow.Ellipsis,
356 modifier = Modifier.weight(1f, fill = false),
359 symbolKindLabel(symbol.kind),
360 style = MaterialTheme.typography.labelSmall,
361 color = MaterialTheme.colorScheme.primary,
362 modifier = Modifier.padding(start = 8.dp),
366 "$fileName:${symbol.displayLine}",
367 style = MaterialTheme.typography.labelSmall,
368 color = MaterialTheme.colorScheme.onSurfaceVariant,
369 fontFamily = FontFamily.Monospace,
371 overflow = TextOverflow.Ellipsis,
379/** Build the matched line with each ripgrep submatch bolded. Offsets are byte columns
380 * within the line; clamped to the (trimmed) line length so a multibyte line can't crash. */
381private fun highlightMatchLine(match: FindMatch): androidx.compose.ui.text.AnnotatedString {
382 val line = match.lineText
383 return buildAnnotatedString {
385 for (sub in match.submatches.sortedBy { it.start }) {
386 val start = sub.start.coerceIn(0, line.length)
387 val end = sub.end.coerceIn(start, line.length)
388 if (start > cursor) append(line.substring(cursor, start))
389 withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(line.substring(start, end)) }
392 if (cursor < line.length) append(line.substring(cursor))
397private fun DirectoryListing(
398 state: FileBrowserState,
399 onOpenDir: (String) -> Unit,
401 onOpenFile: (String) -> Unit,
403 if (state.entries.isEmpty()) {
405 icon = Icons.Filled.Folder,
406 message = stringResource(R.string.empty_folder),
407 modifier = Modifier.fillMaxWidth().padding(24.dp),
411 LazyColumn(modifier = Modifier.fillMaxSize()) {
412 if (state.path.isNotBlank()) {
414 FileRow(icon = true, label = "..", onClick = onUp)
418 items(state.entries, key = { it.path + "_" + it.name }) { node ->
420 icon = node.isDirectory,
422 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
423 status = state.statusMap[node.path],
424 modifier = Modifier.animateItem(),
431/** Shared empty-state for the file browser: icon + message, matching the icon+text
432 * pattern used by the session and server lists (the prior versions were bare Text
433 * lines that read as status messages rather than intentional empty states). */
435private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector, message: String, modifier: Modifier = Modifier) {
438 horizontalAlignment = Alignment.CenterHorizontally,
442 contentDescription = null,
443 modifier = Modifier.size(48.dp),
444 tint = MaterialTheme.colorScheme.onSurfaceVariant,
446 Spacer(Modifier.size(12.dp))
449 style = MaterialTheme.typography.bodyMedium,
450 color = MaterialTheme.colorScheme.onSurfaceVariant,
460 status: FileStatusEntry? = null,
461 sublabel: String? = null,
462 modifier: Modifier = Modifier,
464 val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
465 else if (icon) stringResource(R.string.folder, label)
466 else stringResource(R.string.file_label, label)
467 // Build a combined description so TalkBack announces both the file name and its git
468 // status. The parent sets an explicit contentDescription (mergeDescendants), which
469 // would otherwise suppress the StatusBadge's own description.
470 val statusDesc = status?.let {
472 "added" -> stringResource(R.string.git_added)
473 "modified" -> stringResource(R.string.git_modified)
474 "deleted" -> stringResource(R.string.git_deleted)
478 val fullDesc = if (statusDesc.isNotEmpty()) "$fileDesc, $statusDesc" else fileDesc
482 .defaultMinSize(minHeight = 48.dp)
483 .clickable(role = Role.Button, onClick = onClick)
484 .semantics(mergeDescendants = true) { contentDescription = fullDesc }
485 .padding(horizontal = 16.dp, vertical = 14.dp),
486 verticalAlignment = Alignment.CenterVertically,
489 if (icon) Icons.Filled.Folder else Icons.Filled.Description,
490 contentDescription = null,
491 modifier = Modifier.size(20.dp),
492 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
494 Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
497 style = MaterialTheme.typography.bodyLarge,
499 overflow = TextOverflow.Ellipsis,
501 if (sublabel != null) {
504 style = MaterialTheme.typography.labelSmall,
505 color = MaterialTheme.colorScheme.onSurfaceVariant,
507 overflow = TextOverflow.Ellipsis,
511 if (status != null) StatusBadge(status)
516private fun StatusBadge(status: FileStatusEntry) {
517 val (letter, color) = when (status.status) {
518 "added" -> "A" to MaterialTheme.colorScheme.primary
519 "modified" -> "M" to MaterialTheme.colorScheme.tertiary
520 "deleted" -> "D" to MaterialTheme.colorScheme.error
521 else -> "·" to MaterialTheme.colorScheme.onSurfaceVariant
524 verticalAlignment = Alignment.CenterVertically,
528 style = MaterialTheme.typography.labelMedium,
529 fontFamily = FontFamily.Monospace,
532 if (status.added > 0 || status.removed > 0) {
534 "+${status.added} −${status.removed}",
535 style = MaterialTheme.typography.labelSmall,
536 fontFamily = FontFamily.Monospace,
537 color = MaterialTheme.colorScheme.onSurfaceVariant,
538 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