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 // Key on path + index (not the bare path): a duplicate path from findFiles would
247 // otherwise crash LazyColumn with "Key already used". Mirrors TextResults/SymbolResults.
248 itemsIndexed(results, key = { i, p -> "$p:$i" }) { _, path ->
249 // Split the path into directory (muted) + filename (emphasized) so results
250 // are scannable instead of a wall of identical-looking full paths.
251 val dir = path.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
252 val name = path.substringAfterLast('/')
256 sublabel = dir.takeIf { it.isNotEmpty() },
257 onClick = { onOpenFile(path) },
258 modifier = Modifier.animateItem(),
265@OptIn(ExperimentalMaterial3Api::class)
267private fun SearchModeChip(label: String, selected: Boolean, onClick: () -> Unit) {
268 FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
271/** Content (ripgrep) search results: file + line number, with the matched line highlighted. */
273private fun TextResults(results: List<FindMatch>, onOpen: (String, Int?) -> Unit) {
274 if (results.isEmpty()) {
276 icon = Icons.Filled.Search,
277 message = stringResource(R.string.no_matches),
278 modifier = Modifier.fillMaxWidth().padding(24.dp),
282 LazyColumn(modifier = Modifier.fillMaxSize()) {
283 itemsIndexed(results, key = { i, m -> "${m.filePath}:${m.lineNumber}:$i" }) { _, match ->
284 val name = match.filePath.substringAfterLast('/')
285 val dir = match.filePath.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
286 val highlighted = remember(match) { highlightMatchLine(match) }
290 .clickable(role = Role.Button) { onOpen(match.filePath, match.lineNumber) }
291 .padding(horizontal = 16.dp, vertical = 10.dp),
293 Row(verticalAlignment = Alignment.CenterVertically) {
296 style = MaterialTheme.typography.bodyMedium,
298 overflow = TextOverflow.Ellipsis,
299 modifier = Modifier.weight(1f, fill = false),
302 ":${match.lineNumber}",
303 style = MaterialTheme.typography.labelSmall,
304 color = MaterialTheme.colorScheme.onSurfaceVariant,
305 fontFamily = FontFamily.Monospace,
306 modifier = Modifier.padding(start = 4.dp),
309 if (dir.isNotEmpty()) {
312 style = MaterialTheme.typography.labelSmall,
313 color = MaterialTheme.colorScheme.onSurfaceVariant,
315 overflow = TextOverflow.Ellipsis,
320 style = MaterialTheme.typography.bodySmall,
321 fontFamily = FontFamily.Monospace,
323 overflow = TextOverflow.Ellipsis,
324 modifier = Modifier.padding(top = 2.dp),
332/** Workspace symbol results: symbol name + kind, and the file:line it's defined at. */
334private fun SymbolResults(results: List<SymbolResult>, onOpen: (String, Int?) -> Unit) {
335 if (results.isEmpty()) {
337 icon = Icons.Filled.Search,
338 message = stringResource(R.string.no_matches),
339 modifier = Modifier.fillMaxWidth().padding(24.dp),
343 LazyColumn(modifier = Modifier.fillMaxSize()) {
344 itemsIndexed(results, key = { i, s -> "${s.filePath}:${s.name}:$i" }) { _, symbol ->
345 val fileName = symbol.filePath.substringAfterLast('/')
349 .clickable(role = Role.Button) { onOpen(symbol.filePath, symbol.displayLine) }
350 .padding(horizontal = 16.dp, vertical = 10.dp),
352 Row(verticalAlignment = Alignment.CenterVertically) {
355 style = MaterialTheme.typography.bodyMedium,
357 overflow = TextOverflow.Ellipsis,
358 modifier = Modifier.weight(1f, fill = false),
361 symbolKindLabel(symbol.kind),
362 style = MaterialTheme.typography.labelSmall,
363 color = MaterialTheme.colorScheme.primary,
364 modifier = Modifier.padding(start = 8.dp),
368 "$fileName:${symbol.displayLine}",
369 style = MaterialTheme.typography.labelSmall,
370 color = MaterialTheme.colorScheme.onSurfaceVariant,
371 fontFamily = FontFamily.Monospace,
373 overflow = TextOverflow.Ellipsis,
381/** Build the matched line with each ripgrep submatch bolded. Offsets are byte columns
382 * within the line; clamped to the (trimmed) line length so a multibyte line can't crash. */
383private fun highlightMatchLine(match: FindMatch): androidx.compose.ui.text.AnnotatedString {
384 val line = match.lineText
385 return buildAnnotatedString {
387 for (sub in match.submatches.sortedBy { it.start }) {
388 val start = sub.start.coerceIn(0, line.length)
389 val end = sub.end.coerceIn(start, line.length)
390 if (start > cursor) append(line.substring(cursor, start))
391 withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(line.substring(start, end)) }
394 if (cursor < line.length) append(line.substring(cursor))
399private fun DirectoryListing(
400 state: FileBrowserState,
401 onOpenDir: (String) -> Unit,
403 onOpenFile: (String) -> Unit,
405 if (state.entries.isEmpty()) {
407 icon = Icons.Filled.Folder,
408 message = stringResource(R.string.empty_folder),
409 modifier = Modifier.fillMaxWidth().padding(24.dp),
413 LazyColumn(modifier = Modifier.fillMaxSize()) {
414 if (state.path.isNotBlank()) {
416 FileRow(icon = true, label = "..", onClick = onUp)
420 items(state.entries, key = { it.path + "_" + it.name }) { node ->
422 icon = node.isDirectory,
424 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
425 status = state.statusMap[node.path],
426 modifier = Modifier.animateItem(),
433/** Shared empty-state for the file browser: icon + message, matching the icon+text
434 * pattern used by the session and server lists (the prior versions were bare Text
435 * lines that read as status messages rather than intentional empty states). */
437private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector, message: String, modifier: Modifier = Modifier) {
440 horizontalAlignment = Alignment.CenterHorizontally,
444 contentDescription = null,
445 modifier = Modifier.size(48.dp),
446 tint = MaterialTheme.colorScheme.onSurfaceVariant,
448 Spacer(Modifier.size(12.dp))
451 style = MaterialTheme.typography.bodyMedium,
452 color = MaterialTheme.colorScheme.onSurfaceVariant,
462 status: FileStatusEntry? = null,
463 sublabel: String? = null,
464 modifier: Modifier = Modifier,
466 val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
467 else if (icon) stringResource(R.string.folder, label)
468 else stringResource(R.string.file_label, label)
469 // Build a combined description so TalkBack announces both the file name and its git
470 // status. The parent sets an explicit contentDescription (mergeDescendants), which
471 // would otherwise suppress the StatusBadge's own description.
472 val statusDesc = status?.let {
474 "added" -> stringResource(R.string.git_added)
475 "modified" -> stringResource(R.string.git_modified)
476 "deleted" -> stringResource(R.string.git_deleted)
480 val fullDesc = if (statusDesc.isNotEmpty()) "$fileDesc, $statusDesc" else fileDesc
484 .defaultMinSize(minHeight = 48.dp)
485 .clickable(role = Role.Button, onClick = onClick)
486 .semantics(mergeDescendants = true) { contentDescription = fullDesc }
487 .padding(horizontal = 16.dp, vertical = 14.dp),
488 verticalAlignment = Alignment.CenterVertically,
491 if (icon) Icons.Filled.Folder else Icons.Filled.Description,
492 contentDescription = null,
493 modifier = Modifier.size(20.dp),
494 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
496 Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
499 style = MaterialTheme.typography.bodyLarge,
501 overflow = TextOverflow.Ellipsis,
503 if (sublabel != null) {
506 style = MaterialTheme.typography.labelSmall,
507 color = MaterialTheme.colorScheme.onSurfaceVariant,
509 overflow = TextOverflow.Ellipsis,
513 if (status != null) StatusBadge(status)
518private fun StatusBadge(status: FileStatusEntry) {
519 val (letter, color) = when (status.status) {
520 "added" -> "A" to MaterialTheme.colorScheme.primary
521 "modified" -> "M" to MaterialTheme.colorScheme.tertiary
522 "deleted" -> "D" to MaterialTheme.colorScheme.error
523 else -> "·" to MaterialTheme.colorScheme.onSurfaceVariant
526 verticalAlignment = Alignment.CenterVertically,
530 style = MaterialTheme.typography.labelMedium,
531 fontFamily = FontFamily.Monospace,
534 if (status.added > 0 || status.removed > 0) {
536 "+${status.added} −${status.removed}",
537 style = MaterialTheme.typography.labelSmall,
538 fontFamily = FontFamily.Monospace,
539 color = MaterialTheme.colorScheme.onSurfaceVariant,
540 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