1package soy.iko.opencode.ui.file
3import androidx.activity.compose.BackHandler
4import androidx.compose.foundation.ExperimentalFoundationApi
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.combinedClickable
7import androidx.compose.foundation.horizontalScroll
8import androidx.compose.foundation.layout.Arrangement
9import androidx.compose.foundation.layout.Box
10import androidx.compose.foundation.layout.Column
11import androidx.compose.foundation.layout.Row
12import androidx.compose.foundation.layout.Spacer
13import androidx.compose.foundation.layout.defaultMinSize
14import androidx.compose.foundation.layout.fillMaxSize
15import androidx.compose.foundation.layout.imePadding
16import androidx.compose.foundation.layout.fillMaxWidth
17import androidx.compose.foundation.layout.padding
18import androidx.compose.foundation.layout.size
19import androidx.compose.foundation.layout.widthIn
20import androidx.compose.foundation.lazy.LazyColumn
21import androidx.compose.foundation.lazy.items
22import androidx.compose.foundation.lazy.itemsIndexed
23import androidx.compose.foundation.rememberScrollState
24import androidx.compose.foundation.text.KeyboardActions
25import androidx.compose.foundation.text.KeyboardOptions
26import androidx.compose.material.icons.Icons
27import androidx.compose.material.icons.filled.ChevronRight
28import androidx.compose.material.icons.filled.Close
29import androidx.compose.material.icons.filled.Description
30import androidx.compose.material.icons.filled.ErrorOutline
31import androidx.compose.material.icons.filled.Folder
32import androidx.compose.material.icons.filled.Home
33import androidx.compose.material.icons.filled.Image
34import androidx.compose.material.icons.filled.Code
35import androidx.compose.material.icons.filled.Article
36import androidx.compose.material.icons.filled.KeyboardArrowDown
37import androidx.compose.material.icons.filled.KeyboardArrowUp
38import androidx.compose.material.icons.filled.Link
39import androidx.compose.material.icons.filled.MoreVert
40import androidx.compose.material.icons.filled.Search
41import androidx.compose.material.icons.filled.SortByAlpha
42import androidx.compose.material3.CircularProgressIndicator
43import androidx.compose.material3.DropdownMenu
44import androidx.compose.material3.DropdownMenuItem
45import androidx.compose.material3.ExperimentalMaterial3Api
46import androidx.compose.material3.FilterChip
47import androidx.compose.material3.HorizontalDivider
48import androidx.compose.material3.Icon
49import androidx.compose.material3.IconButton
50import androidx.compose.material3.LinearProgressIndicator
51import androidx.compose.material3.MaterialTheme
52import androidx.compose.material3.OutlinedTextField
53import androidx.compose.material3.Scaffold
54import androidx.compose.material3.SnackbarHost
55import androidx.compose.material3.SnackbarHostState
56import androidx.compose.material3.Text
57import androidx.compose.runtime.Composable
58import androidx.compose.runtime.LaunchedEffect
59import androidx.compose.runtime.getValue
60import androidx.compose.runtime.mutableStateOf
61import androidx.compose.runtime.remember
62import androidx.compose.runtime.saveable.rememberSaveable
63import androidx.compose.runtime.setValue
64import androidx.compose.ui.Alignment
65import androidx.compose.ui.Modifier
66import androidx.compose.ui.focus.FocusRequester
67import androidx.compose.ui.focus.focusRequester
68import androidx.compose.ui.graphics.Color
69import androidx.compose.ui.platform.LocalContext
70import androidx.compose.ui.platform.LocalSoftwareKeyboardController
71import androidx.compose.ui.platform.testTag
72import androidx.compose.ui.semantics.Role
73import androidx.compose.ui.semantics.contentDescription
74import androidx.compose.ui.semantics.heading
75import androidx.compose.ui.semantics.role
76import androidx.compose.ui.semantics.semantics
77import androidx.compose.ui.text.SpanStyle
78import androidx.compose.ui.text.buildAnnotatedString
79import androidx.compose.ui.text.font.FontFamily
80import androidx.compose.ui.text.font.FontWeight
81import androidx.compose.ui.text.input.ImeAction
82import androidx.compose.ui.text.style.TextOverflow
83import androidx.compose.ui.text.withStyle
84import androidx.compose.ui.unit.dp
85import androidx.compose.ui.res.pluralStringResource
86import androidx.compose.ui.res.stringResource
87import androidx.lifecycle.compose.collectAsStateWithLifecycle
88import androidx.lifecycle.viewmodel.compose.viewModel
89import soy.iko.opencode.data.model.FileNode
90import soy.iko.opencode.data.model.FileStatusEntry
91import soy.iko.opencode.data.model.FindMatch
92import soy.iko.opencode.data.model.SymbolResult
93import soy.iko.opencode.data.model.symbolKindLabel
94import soy.iko.opencode.di.AppContainer
95import soy.iko.opencode.R
96import soy.iko.opencode.ui.components.AppTopBar
97import soy.iko.opencode.ui.components.ConnectionBannerFor
98import soy.iko.opencode.ui.components.EmptyState
99import soy.iko.opencode.ui.components.reducedMotionAnimateItem
100import soy.iko.opencode.ui.components.copyToClipboard
101import soy.iko.opencode.ui.components.relativeTime
102import soy.iko.opencode.ui.vmFactory
103import soy.iko.opencode.util.runCatchingCancellable
105@OptIn(ExperimentalMaterial3Api::class)
107fun FileBrowserScreen(
108 container: AppContainer,
109 onOpenFile: (path: String, line: Int?) -> Unit,
112 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
113 val state by vm.state.collectAsStateWithLifecycle()
114 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
115 val snackbar = remember { SnackbarHostState() }
117 // System-back navigates up the directory tree while inside a subfolder (matching every
118 // other file manager's muscle memory), only exiting the screen at the root.
119 androidx.activity.compose.BackHandler(enabled = state.path.isNotBlank()) { vm.up() }
121 LaunchedEffect(Unit) {
122 vm.transientErrors.collect { msg ->
123 snackbar.showSnackbar(msg)
129 AppTopBar(title = stringResource(R.string.files), onBack = onBack)
131 snackbarHost = { SnackbarHost(snackbar) },
133 Column(modifier = Modifier.fillMaxSize().imePadding().padding(padding)) {
134 // Surface SSE connection state so a dropped stream is visible while browsing
135 // files, not just on the chat/session screens.
136 Box(modifier = Modifier.fillMaxWidth()) {
137 ConnectionBannerFor(container)
139 // Tappable breadcrumb trail so deep paths are navigable.
142 onNavigate = vm::open,
143 modifier = Modifier.fillMaxWidth(),
145 val keyboardController = LocalSoftwareKeyboardController.current
146 // Search is the primary reason to open this screen, so focus the field on first
147 // composition. Focus only (no forced keyboard) so it doesn't fight the breadcrumb/listing.
148 val searchFocus = remember { FocusRequester() }
149 LaunchedEffect(Unit) { runCatchingCancellable { searchFocus.requestFocus() } }
152 onValueChange = vm::setQuery,
155 .padding(start = 12.dp, end = 12.dp, top = 12.dp)
156 .focusRequester(searchFocus)
157 .testTag("file_search"),
162 SearchMode.FILES -> R.string.search_files
163 SearchMode.TEXT -> R.string.search_in_files
164 SearchMode.SYMBOL -> R.string.search_symbols_label
169 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
170 trailingIcon = if (state.query.isNotEmpty()) {
172 IconButton(onClick = { vm.setQuery("") }) {
173 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
178 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
179 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
181 // Mode selector: file names / contents / symbols.
183 modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp),
184 horizontalArrangement = Arrangement.spacedBy(8.dp),
186 SearchModeChip(stringResource(R.string.search_mode_files), state.mode == SearchMode.FILES) { vm.setMode(SearchMode.FILES) }
187 SearchModeChip(stringResource(R.string.search_mode_text), state.mode == SearchMode.TEXT) { vm.setMode(SearchMode.TEXT) }
188 SearchModeChip(stringResource(R.string.search_mode_symbols), state.mode == SearchMode.SYMBOL) { vm.setMode(SearchMode.SYMBOL) }
190 Spacer(Modifier.size(8.dp))
192 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
193 isRefreshing = refreshing,
194 onRefresh = { vm.refresh() },
195 modifier = Modifier.fillMaxSize(),
197 Box(modifier = Modifier.fillMaxSize()) {
198 val loadingLabel = stringResource(R.string.loading)
200 // Full-screen spinner only for the initial directory load. An in-flight
201 // search keeps the previous results visible (a slim top bar below shows the
202 // progress) instead of blanking the list behind a spinner on every keystroke.
203 state.loading -> Column(
204 modifier = Modifier.align(Alignment.Center).padding(24.dp),
205 horizontalAlignment = Alignment.CenterHorizontally,
207 CircularProgressIndicator(
208 Modifier.semantics { contentDescription = loadingLabel },
211 stringResource(R.string.loading),
212 style = MaterialTheme.typography.bodyMedium,
213 color = MaterialTheme.colorScheme.onSurfaceVariant,
214 modifier = Modifier.padding(top = 8.dp),
217 state.error != null -> Column(
218 modifier = Modifier.align(Alignment.Center).padding(24.dp),
219 horizontalAlignment = Alignment.CenterHorizontally,
222 Icons.Filled.ErrorOutline,
223 contentDescription = null,
224 tint = MaterialTheme.colorScheme.error,
225 modifier = Modifier.size(56.dp),
227 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
230 color = MaterialTheme.colorScheme.error,
232 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
233 // Retry the operation that actually failed: re-run the search
234 // when one was active (open() would reset the query and drop the
235 // user back into the directory listing), otherwise reload the dir.
236 androidx.compose.material3.TextButton(
237 onClick = { if (state.isSearching) vm.setQuery(state.query) else vm.open(state.path) },
239 Text(stringResource(R.string.retry))
242 state.mode == SearchMode.TEXT ->
245 searchEmptyMessage(state.searching, state.query.isBlank(), R.string.search_contents_hint),
246 ) { path, line -> onOpenFile(path, line) }
247 state.mode == SearchMode.SYMBOL ->
250 searchEmptyMessage(state.searching, state.query.isBlank(), R.string.search_symbols_hint),
251 ) { path, line -> onOpenFile(path, line) }
252 state.isSearching -> SearchResults(state.results) { onOpenFile(it, null) }
253 else -> DirectoryListing(
255 onOpenDir = vm::open,
257 onOpenFile = { onOpenFile(it, null) },
260 // Slim progress bar for an in-flight search; the results list stays visible
261 // underneath so incremental typing doesn't flash an empty screen each keystroke.
262 if (state.searching) {
263 LinearProgressIndicator(
265 .align(Alignment.TopCenter)
267 .semantics { contentDescription = loadingLabel },
277private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
278 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
279 val scrollState = rememberScrollState()
280 // Scroll to the deepest segment when the path changes so the current directory stays in
281 // view instead of hiding off the right edge behind the earlier segments on a deep path.
282 LaunchedEffect(path) { scrollState.animateScrollTo(scrollState.maxValue) }
283 // Haptic on breadcrumb navigation for consistency with the session cards / message actions,
284 // which all fire TextHandleMove on tap.
285 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
286 val onSegmentClick: (String) -> Unit = { target ->
287 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
292 .horizontalScroll(scrollState)
293 .padding(horizontal = 16.dp, vertical = 8.dp),
294 verticalAlignment = Alignment.CenterVertically,
296 IconButton(onClick = { onSegmentClick("") }) {
297 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
300 segments.forEachIndexed { index, segment ->
301 acc = if (acc.isEmpty()) segment else "$acc/$segment"
303 Icons.Filled.ChevronRight,
304 contentDescription = null,
305 modifier = Modifier.size(16.dp),
306 tint = MaterialTheme.colorScheme.onSurfaceVariant,
309 // Cap each segment's width so a single long folder name (e.g. a lengthy monorepo
310 // directory) doesn't push its siblings and the current folder off the screeen.
311 // The deepest segment gets a larger cap since it's the one the user navigated to
312 // and the auto-scroll keeps it in view.
313 val segMaxWidth = if (index == segments.lastIndex) 220.dp else 120.dp
316 style = MaterialTheme.typography.bodyMedium,
317 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
319 overflow = TextOverflow.Ellipsis,
321 .widthIn(max = segMaxWidth)
322 .clickable(role = Role.Button) { onSegmentClick(target) }
323 .defaultMinSize(minHeight = 48.dp)
324 .padding(horizontal = 6.dp, vertical = 14.dp),
331private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
332 val context = LocalContext.current
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 // Key on path + index (not the bare path): a duplicate path from findFiles would
343 // otherwise crash LazyColumn with "Key already used". Mirrors TextResults/SymbolResults.
344 itemsIndexed(results, key = { i, p -> "$p:$i" }) { _, path ->
345 // Split the path into directory (muted) + filename (emphasized) so results
346 // are scannable instead of a wall of identical-looking full paths.
347 val dir = path.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
348 val name = path.substringAfterLast('/')
352 sublabel = dir.takeIf { it.isNotEmpty() },
353 onClick = { onOpenFile(path) },
355 copyToClipboard(context, context.getString(R.string.clip_label_path), path)
357 modifier = reducedMotionAnimateItem(),
364@OptIn(ExperimentalMaterial3Api::class)
366private fun SearchModeChip(label: String, selected: Boolean, onClick: () -> Unit) {
367 FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
370/** Empty-state message for a content/symbol search: a "type to search" hint before anything
371 * is typed, nothing while a search is in flight (the top progress bar covers that, so the
372 * first keystroke doesn't flash a false "No matches"), and "No matches" only once a real
373 * query genuinely returned nothing. */
375private fun searchEmptyMessage(searching: Boolean, queryBlank: Boolean, hint: Int): String? = when {
377 queryBlank -> stringResource(hint)
378 else -> stringResource(R.string.no_matches)
381/** Content (ripgrep) search results: file + line number, with the matched line highlighted. */
383private fun TextResults(results: List<FindMatch>, emptyMessage: String?, onOpen: (String, Int?) -> Unit) {
384 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
385 if (results.isEmpty()) {
386 if (emptyMessage != null) {
388 icon = Icons.Filled.Search,
389 message = emptyMessage,
390 modifier = Modifier.fillMaxWidth().padding(24.dp),
395 LazyColumn(modifier = Modifier.fillMaxSize()) {
396 item(key = "__count") {
398 pluralStringResource(R.plurals.file_search_results, results.size, results.size),
399 style = MaterialTheme.typography.labelSmall,
400 color = MaterialTheme.colorScheme.onSurfaceVariant,
401 modifier = Modifier.fillMaxWidth()
402 .padding(horizontal = 16.dp, vertical = 6.dp)
403 .semantics { heading() },
406 itemsIndexed(results, key = { i, m -> "${m.filePath}:${m.lineNumber}:$i" }) { _, match ->
407 val name = match.filePath.substringAfterLast('/')
408 val dir = match.filePath.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
409 val highlighted = remember(match) { highlightMatchLine(match) }
413 .defaultMinSize(minHeight = 48.dp)
414 .clickable(role = Role.Button) {
415 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
416 onOpen(match.filePath, match.lineNumber)
418 .padding(horizontal = 16.dp, vertical = 10.dp),
420 Row(verticalAlignment = Alignment.CenterVertically) {
423 style = MaterialTheme.typography.bodyMedium,
425 overflow = TextOverflow.Ellipsis,
426 modifier = Modifier.weight(1f, fill = false),
429 ":${match.lineNumber}",
430 style = MaterialTheme.typography.labelSmall,
431 color = MaterialTheme.colorScheme.onSurfaceVariant,
432 fontFamily = FontFamily.Monospace,
433 modifier = Modifier.padding(start = 4.dp),
436 if (dir.isNotEmpty()) {
439 style = MaterialTheme.typography.labelSmall,
440 color = MaterialTheme.colorScheme.onSurfaceVariant,
442 overflow = TextOverflow.Ellipsis,
447 style = MaterialTheme.typography.bodySmall,
448 fontFamily = FontFamily.Monospace,
450 overflow = TextOverflow.Ellipsis,
451 modifier = Modifier.padding(top = 2.dp),
459/** Workspace symbol results: symbol name + kind, and the file:line it's defined at. */
461private fun SymbolResults(results: List<SymbolResult>, emptyMessage: String?, onOpen: (String, Int?) -> Unit) {
462 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
463 if (results.isEmpty()) {
464 if (emptyMessage != null) {
466 icon = Icons.Filled.Search,
467 message = emptyMessage,
468 modifier = Modifier.fillMaxWidth().padding(24.dp),
473 LazyColumn(modifier = Modifier.fillMaxSize()) {
474 item(key = "__count") {
476 pluralStringResource(R.plurals.file_search_results, results.size, results.size),
477 style = MaterialTheme.typography.labelSmall,
478 color = MaterialTheme.colorScheme.onSurfaceVariant,
479 modifier = Modifier.fillMaxWidth()
480 .padding(horizontal = 16.dp, vertical = 6.dp)
481 .semantics { heading() },
484 itemsIndexed(results, key = { i, s -> "${s.filePath}:${s.name}:$i" }) { _, symbol ->
485 val fileName = symbol.filePath.substringAfterLast('/')
489 .defaultMinSize(minHeight = 48.dp)
490 .clickable(role = Role.Button) {
491 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
492 onOpen(symbol.filePath, symbol.displayLine)
494 .padding(horizontal = 16.dp, vertical = 10.dp),
496 Row(verticalAlignment = Alignment.CenterVertically) {
499 style = MaterialTheme.typography.bodyMedium,
501 overflow = TextOverflow.Ellipsis,
502 modifier = Modifier.weight(1f, fill = false),
505 symbolKindLabel(symbol.kind),
506 style = MaterialTheme.typography.labelSmall,
507 color = MaterialTheme.colorScheme.primary,
508 modifier = Modifier.padding(start = 8.dp),
512 "$fileName:${symbol.displayLine}",
513 style = MaterialTheme.typography.labelSmall,
514 color = MaterialTheme.colorScheme.onSurfaceVariant,
515 fontFamily = FontFamily.Monospace,
517 overflow = TextOverflow.Ellipsis,
525/** Build the matched line with each ripgrep submatch bolded. Offsets are byte columns
526 * within the line; clamped to the (trimmed) line length so a multibyte line can't crash. */
527private fun highlightMatchLine(match: FindMatch): androidx.compose.ui.text.AnnotatedString {
528 val line = match.lineText
529 return buildAnnotatedString {
531 for (sub in match.submatches.sortedBy { it.start }) {
532 val start = sub.start.coerceIn(0, line.length)
533 val end = sub.end.coerceIn(start, line.length)
534 if (start > cursor) append(line.substring(cursor, start))
535 withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(line.substring(start, end)) }
538 if (cursor < line.length) append(line.substring(cursor))
543private fun DirectoryListing(
544 state: FileBrowserState,
545 onOpenDir: (String) -> Unit,
547 onOpenFile: (String) -> Unit,
549 val context = LocalContext.current
550 if (state.entries.isEmpty()) {
552 icon = Icons.Filled.Folder,
553 message = stringResource(R.string.empty_folder),
554 modifier = Modifier.fillMaxWidth().padding(24.dp),
558 // Sort preference for the directory listing. Folders-first + name sort is the most useful
559 // default; the toggle flips between folders-first name sort and the server's raw order.
560 var foldersFirst by rememberSaveable { mutableStateOf(true) }
561 // Hidden-files preference. The server may already include dotfiles in its listing; this
562 // hides them by default (the common case for browsing a repo) and lets the user reveal
563 // .env / .git / .opencode on demand. Default off so the listing isn't cluttered.
564 var showHidden by rememberSaveable { mutableStateOf(false) }
565 // Sort key for files within the listing. Name is the default (and the only axis the server
566 // reliably supports); Size/Modified appear only when the server emits size/mtime (otherwise
567 // those sorts are no-ops on null fields), gated behind a menu so they don't clutter the bar.
568 var sortKey by rememberSaveable { mutableStateOf(FileSortKey.NAME) }
569 var sortDesc by rememberSaveable { mutableStateOf(false) }
570 val visible = remember(state.entries, showHidden) {
571 if (showHidden) state.entries else state.entries.filterNot { it.name.startsWith(".") }
573 val sorted = remember(visible, foldersFirst, sortKey, sortDesc) {
574 // Null size/mtime coerce to values that sink the unknown entries to the bottom in the
575 // default (ascending) view, so a partially-populated listing keeps sized/dated files on top.
576 val comparator = when (sortKey) {
577 FileSortKey.NAME -> compareBy<FileNode> { it.name.lowercase() }
578 FileSortKey.SIZE -> compareBy<FileNode> { it.size ?: Long.MAX_VALUE }
579 FileSortKey.MODIFIED -> compareBy<FileNode> { it.mtime ?: 0L }
580 }.let { if (sortDesc) it.reversed() else it }
582 val (dirs, files) = visible.partition { it.isDirectory }
583 // Directories always sort by name (size/date on a folder is rarely meaningful);
584 // the chosen key applies to the files within.
585 dirs.sortedBy { it.name.lowercase() } + files.sortedWith(comparator)
587 visible.sortedWith(comparator)
590 LazyColumn(modifier = Modifier.fillMaxSize()) {
591 if (state.path.isNotBlank()) {
593 FileRow(icon = true, label = "..", onClick = onUp)
597 item(key = "__sort") {
599 modifier = Modifier.fillMaxWidth()
600 .horizontalScroll(rememberScrollState())
601 .padding(end = 8.dp, top = 2.dp, bottom = 2.dp),
602 horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
603 verticalAlignment = Alignment.CenterVertically,
605 // rememberSaveable so an open sort dropdown survives a rotation instead of
606 // closing and leaving the user to re-open it.
607 var showSortMenu by rememberSaveable { mutableStateOf(false) }
608 // Close the sort dropdown on back press instead of navigating away, matching
609 // the session/server list dropdown back-handling.
610 BackHandler(enabled = showSortMenu) { showSortMenu = false }
611 androidx.compose.material3.FilterChip(
612 selected = sortKey != FileSortKey.NAME || sortDesc,
613 onClick = { showSortMenu = true },
615 Icon(Icons.Filled.SortByAlpha, contentDescription = null, modifier = Modifier.size(16.dp))
617 label = { Text(stringResource(R.string.sort_by), style = MaterialTheme.typography.labelSmall) },
619 DropdownMenu(expanded = showSortMenu, onDismissRequest = { showSortMenu = false }) {
620 val labels = remember {
622 FileSortKey.NAME to R.string.sort_name,
623 FileSortKey.SIZE to R.string.sort_size,
624 FileSortKey.MODIFIED to R.string.sort_modified,
627 FileSortKey.entries.forEach { key ->
630 Row(verticalAlignment = Alignment.CenterVertically) {
631 Text(stringResource(labels[key]!!), modifier = Modifier.weight(1f))
632 if (sortKey == key) {
634 if (sortDesc) Icons.Filled.KeyboardArrowDown else Icons.Filled.KeyboardArrowUp,
635 contentDescription = null,
636 modifier = Modifier.size(18.dp),
642 // Tapping the active key flips the direction; tapping a different
643 // key switches to it (ascending by default).
644 if (sortKey == key) sortDesc = !sortDesc else { sortKey = key; sortDesc = false }
650 androidx.compose.material3.FilterChip(
651 selected = showHidden,
652 onClick = { showHidden = !showHidden },
653 label = { Text(stringResource(R.string.show_hidden), style = MaterialTheme.typography.labelSmall) },
655 androidx.compose.material3.FilterChip(
656 selected = foldersFirst,
657 onClick = { foldersFirst = !foldersFirst },
660 Icon(Icons.Filled.Folder, contentDescription = null, modifier = Modifier.size(16.dp))
663 label = { Text(stringResource(R.string.folders_first), style = MaterialTheme.typography.labelSmall) },
667 items(sorted, key = { it.path + "_" + it.name }) { node ->
669 icon = node.isDirectory,
671 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
673 copyToClipboard(context, context.getString(R.string.clip_label_path), node.path)
677 status = state.statusMap[node.path],
678 modifier = reducedMotionAnimateItem(),
685/** Shared empty-state for the file browser. Delegates to the canonical [EmptyState] so every
686 * screen's "nothing here" reads and behaves identically (icon size, spacing, styling). */
688private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector, message: String, modifier: Modifier = Modifier) {
689 EmptyState(icon = icon, title = message, modifier = modifier)
692@OptIn(ExperimentalFoundationApi::class)
698 status: FileStatusEntry? = null,
699 sublabel: String? = null,
702 onCopyPath: (() -> Unit)? = null,
703 modifier: Modifier = Modifier,
705 val context = LocalContext.current
706 val copyPathLabel = stringResource(R.string.copy_path)
707 // Haptic on row open for consistency with the breadcrumb taps and other screens.
708 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
709 val onRowClick: () -> Unit = {
710 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
713 // Long-press → "Copy path" dropdown. Rendered only when [onCopyPath] is supplied, so the
714 // parent ".." row (which passes none) stays a plain tap target. Anchored within the row.
715 // rememberSaveable so an open row menu survives a rotation instead of closing.
716 var menu by rememberSaveable { mutableStateOf(false) }
717 // Close this row's overflow on back press instead of navigating away, matching the
718 // sort menu BackHandler above.
719 BackHandler(enabled = menu) { menu = false }
720 val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
721 else if (icon) stringResource(R.string.folder, label)
722 else stringResource(R.string.file_label, label)
723 // Build a combined description so TalkBack announces both the file name and its git
724 // status. The parent sets an explicit contentDescription (mergeDescendants), which
725 // would otherwise suppress the StatusBadge's own description.
726 val statusDesc = status?.let {
728 "added" -> stringResource(R.string.git_added)
729 "modified" -> stringResource(R.string.git_modified)
730 "deleted" -> stringResource(R.string.git_deleted)
734 val fullDesc = if (statusDesc.isNotEmpty()) "$fileDesc, $statusDesc" else fileDesc
738 .defaultMinSize(minHeight = 48.dp)
741 onClick = onRowClick,
742 onLongClick = onCopyPath?.let { { menu = true } },
743 onLongClickLabel = onCopyPath?.let { copyPathLabel },
745 .semantics(mergeDescendants = true) { contentDescription = fullDesc }
746 .padding(horizontal = 16.dp, vertical = 14.dp),
747 verticalAlignment = Alignment.CenterVertically,
750 if (icon) Icons.Filled.Folder else iconForFile(label),
751 contentDescription = null,
752 modifier = Modifier.size(20.dp),
753 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
755 Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
758 style = MaterialTheme.typography.bodyLarge,
760 overflow = TextOverflow.Ellipsis,
762 // Secondary line: a caller-supplied sublabel (directory in search results) and/or
763 // the file's size + last-modified time when the server provides them. Combines the
764 // fragments so a single muted line carries all metadata instead of stacking rows.
765 val detailParts = buildList {
766 if (sublabel != null) add(sublabel)
768 size?.let { add(formatFileSize(it)) }
769 mtime?.let { add(relativeTime(context, it)) }
772 if (detailParts.isNotEmpty()) {
774 detailParts.joinToString(" · "),
775 style = MaterialTheme.typography.labelSmall,
776 color = MaterialTheme.colorScheme.onSurfaceVariant,
778 overflow = TextOverflow.Ellipsis,
782 if (status != null) StatusBadge(status)
783 // Explicit overflow button so "Copy path" is reachable without a long-press (which
784 // isn't available to TalkBack/keyboard users or anyone who can't hold the gesture).
785 // Rendered only when the row supplies an onCopyPath (the parent ".." row does not).
786 if (onCopyPath != null) {
787 IconButton(onClick = { menu = true }) {
788 Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options))
791 DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
793 leadingIcon = { Icon(Icons.Filled.Link, contentDescription = null) },
794 text = { Text(copyPathLabel) },
795 onClick = { menu = false; onCopyPath?.invoke() },
802private fun StatusBadge(status: FileStatusEntry) {
803 val (letter, color) = when (status.status) {
804 "added" -> "A" to MaterialTheme.colorScheme.primary
805 "modified" -> "M" to MaterialTheme.colorScheme.tertiary
806 "deleted" -> "D" to MaterialTheme.colorScheme.error
807 else -> "·" to MaterialTheme.colorScheme.onSurfaceVariant
810 verticalAlignment = Alignment.CenterVertically,
814 style = MaterialTheme.typography.labelMedium,
815 fontFamily = FontFamily.Monospace,
818 if (status.added > 0 || status.removed > 0) {
820 "+${status.added} −${status.removed}",
821 style = MaterialTheme.typography.labelSmall,
822 fontFamily = FontFamily.Monospace,
823 color = MaterialTheme.colorScheme.onSurfaceVariant,
824 modifier = Modifier.padding(start = 4.dp),
830/** Sort axis for the directory listing. Name is always available; Size/Modified depend on the
831 * server emitting size/mtime (otherwise they sort nulls to the bottom). */
832private enum class FileSortKey { NAME, SIZE, MODIFIED }
834/** Format a byte count as a compact human-readable size (e.g. 1.2 KB, 3.4 MB). Uses 1024-based
835 * units with one decimal place, matching how file managers render sizes. */
836private fun formatFileSize(bytes: Long): String {
837 if (bytes < 1024) return "$bytes B"
838 val units = arrayOf("KB", "MB", "GB", "TB")
839 var value = bytes.toDouble() / 1024.0
841 while (value >= 1024 && unit < units.lastIndex) {
845 val formatted = if (value >= 100) "%.0f".format(value) else "%.1f".format(value)
846 return "$formatted ${units[unit]}"
849/** Pick an icon for a file by its extension so the directory listing is scannable instead of a
850 * wall of identical `Description` glyphs: images, source code, and Markdown/article files each
851 * get a distinct glyph; unknown extensions fall back to the generic document icon. */
852private fun iconForFile(name: String): androidx.compose.ui.graphics.vector.ImageVector {
853 val ext = name.substringAfterLast('.', missingDelimiterValue = "").lowercase()
855 "png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico", "heic" -> Icons.Filled.Image
856 "kt", "kts", "java", "js", "ts", "jsx", "tsx", "py", "rb", "go", "rs", "c", "cpp", "h",
857 "hpp", "cs", "swift", "scala", "gradle", "groovy", "sh", "bash", "zsh", "fish", "ps1",
858 "lua", "r", "dart", "php", "pl", "sql", "vue", "svelte" -> Icons.Filled.Code
859 "md", "markdown", "txt", "rst", "adoc", "rtf", "doc", "docx", "pdf", "epub" -> Icons.Filled.Article
860 else -> Icons.Filled.Description
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