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.LinearProgressIndicator
36import androidx.compose.material3.MaterialTheme
37import androidx.compose.material3.OutlinedTextField
38import androidx.compose.material3.Scaffold
39import androidx.compose.material3.SnackbarHost
40import androidx.compose.material3.SnackbarHostState
41import androidx.compose.material3.Text
42import androidx.compose.material3.TopAppBar
43import androidx.compose.runtime.Composable
44import androidx.compose.runtime.LaunchedEffect
45import androidx.compose.runtime.getValue
46import androidx.compose.runtime.remember
47import androidx.compose.ui.Alignment
48import androidx.compose.ui.Modifier
49import androidx.compose.ui.focus.FocusRequester
50import androidx.compose.ui.focus.focusRequester
51import androidx.compose.ui.graphics.Color
52import androidx.compose.ui.platform.LocalSoftwareKeyboardController
53import androidx.compose.ui.platform.testTag
54import androidx.compose.ui.semantics.Role
55import androidx.compose.ui.semantics.contentDescription
56import androidx.compose.ui.semantics.role
57import androidx.compose.ui.semantics.semantics
58import androidx.compose.ui.text.SpanStyle
59import androidx.compose.ui.text.buildAnnotatedString
60import androidx.compose.ui.text.font.FontFamily
61import androidx.compose.ui.text.font.FontWeight
62import androidx.compose.ui.text.input.ImeAction
63import androidx.compose.ui.text.style.TextOverflow
64import androidx.compose.ui.text.withStyle
65import androidx.compose.ui.unit.dp
66import androidx.compose.ui.res.pluralStringResource
67import androidx.compose.ui.res.stringResource
68import androidx.lifecycle.compose.collectAsStateWithLifecycle
69import androidx.lifecycle.viewmodel.compose.viewModel
70import soy.iko.opencode.data.model.FileStatusEntry
71import soy.iko.opencode.data.model.FindMatch
72import soy.iko.opencode.data.model.SymbolResult
73import soy.iko.opencode.data.model.symbolKindLabel
74import soy.iko.opencode.di.AppContainer
75import soy.iko.opencode.R
76import soy.iko.opencode.ui.vmFactory
77import soy.iko.opencode.util.runCatchingCancellable
79@OptIn(ExperimentalMaterial3Api::class)
82 container: AppContainer,
83 onOpenFile: (path: String, line: Int?) -> Unit,
86 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
87 val state by vm.state.collectAsStateWithLifecycle()
88 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
89 val snackbar = remember { SnackbarHostState() }
91 LaunchedEffect(Unit) {
92 vm.transientErrors.collect { msg ->
93 snackbar.showSnackbar(msg)
100 title = { Text(stringResource(R.string.files), maxLines = 1, overflow = TextOverflow.Ellipsis) },
102 IconButton(onClick = onBack) {
103 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
108 snackbarHost = { SnackbarHost(snackbar) },
110 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
111 // Tappable breadcrumb trail so deep paths are navigable.
114 onNavigate = vm::open,
115 modifier = Modifier.fillMaxWidth(),
117 val keyboardController = LocalSoftwareKeyboardController.current
118 // Search is the primary reason to open this screen, so focus the field on first
119 // composition. Focus only (no forced keyboard) so it doesn't fight the breadcrumb/listing.
120 val searchFocus = remember { FocusRequester() }
121 LaunchedEffect(Unit) { runCatchingCancellable { searchFocus.requestFocus() } }
124 onValueChange = vm::setQuery,
127 .padding(start = 12.dp, end = 12.dp, top = 12.dp)
128 .focusRequester(searchFocus)
129 .testTag("file_search"),
134 SearchMode.FILES -> R.string.search_files
135 SearchMode.TEXT -> R.string.search_in_files
136 SearchMode.SYMBOL -> R.string.search_symbols_label
141 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
142 trailingIcon = if (state.query.isNotEmpty()) {
144 IconButton(onClick = { vm.setQuery("") }) {
145 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
150 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
151 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
153 // Mode selector: file names / contents / symbols.
155 modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp),
156 horizontalArrangement = Arrangement.spacedBy(8.dp),
158 SearchModeChip(stringResource(R.string.search_mode_files), state.mode == SearchMode.FILES) { vm.setMode(SearchMode.FILES) }
159 SearchModeChip(stringResource(R.string.search_mode_text), state.mode == SearchMode.TEXT) { vm.setMode(SearchMode.TEXT) }
160 SearchModeChip(stringResource(R.string.search_mode_symbols), state.mode == SearchMode.SYMBOL) { vm.setMode(SearchMode.SYMBOL) }
162 Spacer(Modifier.size(8.dp))
164 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
165 isRefreshing = refreshing,
166 onRefresh = { vm.refresh() },
167 modifier = Modifier.fillMaxSize(),
169 Box(modifier = Modifier.fillMaxSize()) {
170 val loadingLabel = stringResource(R.string.loading)
172 // Full-screen spinner only for the initial directory load. An in-flight
173 // search keeps the previous results visible (a slim top bar below shows the
174 // progress) instead of blanking the list behind a spinner on every keystroke.
175 state.loading -> CircularProgressIndicator(
176 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
178 state.error != null -> Column(
179 modifier = Modifier.align(Alignment.Center).padding(24.dp),
180 horizontalAlignment = Alignment.CenterHorizontally,
184 color = MaterialTheme.colorScheme.error,
186 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
187 // Retry the operation that actually failed: re-run the search
188 // when one was active (open() would reset the query and drop the
189 // user back into the directory listing), otherwise reload the dir.
190 androidx.compose.material3.TextButton(
191 onClick = { if (state.isSearching) vm.setQuery(state.query) else vm.open(state.path) },
193 Text(stringResource(R.string.retry))
196 state.mode == SearchMode.TEXT ->
199 searchEmptyMessage(state.searching, state.query.isBlank(), R.string.search_contents_hint),
200 ) { path, line -> onOpenFile(path, line) }
201 state.mode == SearchMode.SYMBOL ->
204 searchEmptyMessage(state.searching, state.query.isBlank(), R.string.search_symbols_hint),
205 ) { path, line -> onOpenFile(path, line) }
206 state.isSearching -> SearchResults(state.results) { onOpenFile(it, null) }
207 else -> DirectoryListing(
209 onOpenDir = vm::open,
211 onOpenFile = { onOpenFile(it, null) },
214 // Slim progress bar for an in-flight search; the results list stays visible
215 // underneath so incremental typing doesn't flash an empty screen each keystroke.
216 if (state.searching) {
217 LinearProgressIndicator(
219 .align(Alignment.TopCenter)
221 .semantics { contentDescription = loadingLabel },
231private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
232 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
233 val scrollState = rememberScrollState()
234 // Scroll to the deepest segment when the path changes so the current directory stays in
235 // view instead of hiding off the right edge behind the earlier segments on a deep path.
236 LaunchedEffect(path) { scrollState.animateScrollTo(scrollState.maxValue) }
239 .horizontalScroll(scrollState)
240 .padding(horizontal = 16.dp, vertical = 8.dp),
241 verticalAlignment = Alignment.CenterVertically,
243 IconButton(onClick = { onNavigate("") }) {
244 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
247 segments.forEachIndexed { index, segment ->
248 acc = if (acc.isEmpty()) segment else "$acc/$segment"
250 Icons.Filled.ChevronRight,
251 contentDescription = null,
252 modifier = Modifier.size(16.dp),
253 tint = MaterialTheme.colorScheme.onSurfaceVariant,
258 style = MaterialTheme.typography.bodyMedium,
259 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
261 .clickable(role = Role.Button) { onNavigate(target) }
262 .defaultMinSize(minHeight = 48.dp)
263 .padding(horizontal = 6.dp, vertical = 14.dp),
270private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
271 if (results.isEmpty()) {
273 icon = Icons.Filled.Search,
274 message = stringResource(R.string.no_matches),
275 modifier = Modifier.fillMaxWidth().padding(24.dp),
279 LazyColumn(modifier = Modifier.fillMaxSize()) {
280 // Key on path + index (not the bare path): a duplicate path from findFiles would
281 // otherwise crash LazyColumn with "Key already used". Mirrors TextResults/SymbolResults.
282 itemsIndexed(results, key = { i, p -> "$p:$i" }) { _, path ->
283 // Split the path into directory (muted) + filename (emphasized) so results
284 // are scannable instead of a wall of identical-looking full paths.
285 val dir = path.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
286 val name = path.substringAfterLast('/')
290 sublabel = dir.takeIf { it.isNotEmpty() },
291 onClick = { onOpenFile(path) },
292 modifier = Modifier.animateItem(),
299@OptIn(ExperimentalMaterial3Api::class)
301private fun SearchModeChip(label: String, selected: Boolean, onClick: () -> Unit) {
302 FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
305/** Empty-state message for a content/symbol search: a "type to search" hint before anything
306 * is typed, nothing while a search is in flight (the top progress bar covers that, so the
307 * first keystroke doesn't flash a false "No matches"), and "No matches" only once a real
308 * query genuinely returned nothing. */
310private fun searchEmptyMessage(searching: Boolean, queryBlank: Boolean, hint: Int): String? = when {
312 queryBlank -> stringResource(hint)
313 else -> stringResource(R.string.no_matches)
316/** Content (ripgrep) search results: file + line number, with the matched line highlighted. */
318private fun TextResults(results: List<FindMatch>, emptyMessage: String?, onOpen: (String, Int?) -> Unit) {
319 if (results.isEmpty()) {
320 if (emptyMessage != null) {
322 icon = Icons.Filled.Search,
323 message = emptyMessage,
324 modifier = Modifier.fillMaxWidth().padding(24.dp),
329 LazyColumn(modifier = Modifier.fillMaxSize()) {
330 item(key = "__count") {
332 pluralStringResource(R.plurals.file_search_results, results.size, results.size),
333 style = MaterialTheme.typography.labelSmall,
334 color = MaterialTheme.colorScheme.onSurfaceVariant,
335 modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
338 itemsIndexed(results, key = { i, m -> "${m.filePath}:${m.lineNumber}:$i" }) { _, match ->
339 val name = match.filePath.substringAfterLast('/')
340 val dir = match.filePath.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
341 val highlighted = remember(match) { highlightMatchLine(match) }
345 .clickable(role = Role.Button) { onOpen(match.filePath, match.lineNumber) }
346 .padding(horizontal = 16.dp, vertical = 10.dp),
348 Row(verticalAlignment = Alignment.CenterVertically) {
351 style = MaterialTheme.typography.bodyMedium,
353 overflow = TextOverflow.Ellipsis,
354 modifier = Modifier.weight(1f, fill = false),
357 ":${match.lineNumber}",
358 style = MaterialTheme.typography.labelSmall,
359 color = MaterialTheme.colorScheme.onSurfaceVariant,
360 fontFamily = FontFamily.Monospace,
361 modifier = Modifier.padding(start = 4.dp),
364 if (dir.isNotEmpty()) {
367 style = MaterialTheme.typography.labelSmall,
368 color = MaterialTheme.colorScheme.onSurfaceVariant,
370 overflow = TextOverflow.Ellipsis,
375 style = MaterialTheme.typography.bodySmall,
376 fontFamily = FontFamily.Monospace,
378 overflow = TextOverflow.Ellipsis,
379 modifier = Modifier.padding(top = 2.dp),
387/** Workspace symbol results: symbol name + kind, and the file:line it's defined at. */
389private fun SymbolResults(results: List<SymbolResult>, emptyMessage: String?, onOpen: (String, Int?) -> Unit) {
390 if (results.isEmpty()) {
391 if (emptyMessage != null) {
393 icon = Icons.Filled.Search,
394 message = emptyMessage,
395 modifier = Modifier.fillMaxWidth().padding(24.dp),
400 LazyColumn(modifier = Modifier.fillMaxSize()) {
401 item(key = "__count") {
403 pluralStringResource(R.plurals.file_search_results, results.size, results.size),
404 style = MaterialTheme.typography.labelSmall,
405 color = MaterialTheme.colorScheme.onSurfaceVariant,
406 modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
409 itemsIndexed(results, key = { i, s -> "${s.filePath}:${s.name}:$i" }) { _, symbol ->
410 val fileName = symbol.filePath.substringAfterLast('/')
414 .clickable(role = Role.Button) { onOpen(symbol.filePath, symbol.displayLine) }
415 .padding(horizontal = 16.dp, vertical = 10.dp),
417 Row(verticalAlignment = Alignment.CenterVertically) {
420 style = MaterialTheme.typography.bodyMedium,
422 overflow = TextOverflow.Ellipsis,
423 modifier = Modifier.weight(1f, fill = false),
426 symbolKindLabel(symbol.kind),
427 style = MaterialTheme.typography.labelSmall,
428 color = MaterialTheme.colorScheme.primary,
429 modifier = Modifier.padding(start = 8.dp),
433 "$fileName:${symbol.displayLine}",
434 style = MaterialTheme.typography.labelSmall,
435 color = MaterialTheme.colorScheme.onSurfaceVariant,
436 fontFamily = FontFamily.Monospace,
438 overflow = TextOverflow.Ellipsis,
446/** Build the matched line with each ripgrep submatch bolded. Offsets are byte columns
447 * within the line; clamped to the (trimmed) line length so a multibyte line can't crash. */
448private fun highlightMatchLine(match: FindMatch): androidx.compose.ui.text.AnnotatedString {
449 val line = match.lineText
450 return buildAnnotatedString {
452 for (sub in match.submatches.sortedBy { it.start }) {
453 val start = sub.start.coerceIn(0, line.length)
454 val end = sub.end.coerceIn(start, line.length)
455 if (start > cursor) append(line.substring(cursor, start))
456 withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(line.substring(start, end)) }
459 if (cursor < line.length) append(line.substring(cursor))
464private fun DirectoryListing(
465 state: FileBrowserState,
466 onOpenDir: (String) -> Unit,
468 onOpenFile: (String) -> Unit,
470 if (state.entries.isEmpty()) {
472 icon = Icons.Filled.Folder,
473 message = stringResource(R.string.empty_folder),
474 modifier = Modifier.fillMaxWidth().padding(24.dp),
478 LazyColumn(modifier = Modifier.fillMaxSize()) {
479 if (state.path.isNotBlank()) {
481 FileRow(icon = true, label = "..", onClick = onUp)
485 items(state.entries, key = { it.path + "_" + it.name }) { node ->
487 icon = node.isDirectory,
489 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
490 status = state.statusMap[node.path],
491 modifier = Modifier.animateItem(),
498/** Shared empty-state for the file browser: icon + message, matching the icon+text
499 * pattern used by the session and server lists (the prior versions were bare Text
500 * lines that read as status messages rather than intentional empty states). */
502private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector, message: String, modifier: Modifier = Modifier) {
505 horizontalAlignment = Alignment.CenterHorizontally,
509 contentDescription = null,
510 modifier = Modifier.size(48.dp),
511 tint = MaterialTheme.colorScheme.onSurfaceVariant,
513 Spacer(Modifier.size(12.dp))
516 style = MaterialTheme.typography.bodyMedium,
517 color = MaterialTheme.colorScheme.onSurfaceVariant,
527 status: FileStatusEntry? = null,
528 sublabel: String? = null,
529 modifier: Modifier = Modifier,
531 val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
532 else if (icon) stringResource(R.string.folder, label)
533 else stringResource(R.string.file_label, label)
534 // Build a combined description so TalkBack announces both the file name and its git
535 // status. The parent sets an explicit contentDescription (mergeDescendants), which
536 // would otherwise suppress the StatusBadge's own description.
537 val statusDesc = status?.let {
539 "added" -> stringResource(R.string.git_added)
540 "modified" -> stringResource(R.string.git_modified)
541 "deleted" -> stringResource(R.string.git_deleted)
545 val fullDesc = if (statusDesc.isNotEmpty()) "$fileDesc, $statusDesc" else fileDesc
549 .defaultMinSize(minHeight = 48.dp)
550 .clickable(role = Role.Button, onClick = onClick)
551 .semantics(mergeDescendants = true) { contentDescription = fullDesc }
552 .padding(horizontal = 16.dp, vertical = 14.dp),
553 verticalAlignment = Alignment.CenterVertically,
556 if (icon) Icons.Filled.Folder else Icons.Filled.Description,
557 contentDescription = null,
558 modifier = Modifier.size(20.dp),
559 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
561 Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
564 style = MaterialTheme.typography.bodyLarge,
566 overflow = TextOverflow.Ellipsis,
568 if (sublabel != null) {
571 style = MaterialTheme.typography.labelSmall,
572 color = MaterialTheme.colorScheme.onSurfaceVariant,
574 overflow = TextOverflow.Ellipsis,
578 if (status != null) StatusBadge(status)
583private fun StatusBadge(status: FileStatusEntry) {
584 val (letter, color) = when (status.status) {
585 "added" -> "A" to MaterialTheme.colorScheme.primary
586 "modified" -> "M" to MaterialTheme.colorScheme.tertiary
587 "deleted" -> "D" to MaterialTheme.colorScheme.error
588 else -> "·" to MaterialTheme.colorScheme.onSurfaceVariant
591 verticalAlignment = Alignment.CenterVertically,
595 style = MaterialTheme.typography.labelMedium,
596 fontFamily = FontFamily.Monospace,
599 if (status.added > 0 || status.removed > 0) {
601 "+${status.added} −${status.removed}",
602 style = MaterialTheme.typography.labelSmall,
603 fontFamily = FontFamily.Monospace,
604 color = MaterialTheme.colorScheme.onSurfaceVariant,
605 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