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 LaunchedEffect(Unit) {
118 vm.transientErrors.collect { msg ->
119 snackbar.showSnackbar(msg)
125 AppTopBar(title = stringResource(R.string.files), onBack = onBack)
127 snackbarHost = { SnackbarHost(snackbar) },
129 Column(modifier = Modifier.fillMaxSize().imePadding().padding(padding)) {
130 // Surface SSE connection state so a dropped stream is visible while browsing
131 // files, not just on the chat/session screens.
132 Box(modifier = Modifier.fillMaxWidth()) {
133 ConnectionBannerFor(container)
135 // Tappable breadcrumb trail so deep paths are navigable.
138 onNavigate = vm::open,
139 modifier = Modifier.fillMaxWidth(),
141 val keyboardController = LocalSoftwareKeyboardController.current
142 // Search is the primary reason to open this screen, so focus the field on first
143 // composition. Focus only (no forced keyboard) so it doesn't fight the breadcrumb/listing.
144 val searchFocus = remember { FocusRequester() }
145 LaunchedEffect(Unit) { runCatchingCancellable { searchFocus.requestFocus() } }
148 onValueChange = vm::setQuery,
151 .padding(start = 12.dp, end = 12.dp, top = 12.dp)
152 .focusRequester(searchFocus)
153 .testTag("file_search"),
158 SearchMode.FILES -> R.string.search_files
159 SearchMode.TEXT -> R.string.search_in_files
160 SearchMode.SYMBOL -> R.string.search_symbols_label
165 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
166 trailingIcon = if (state.query.isNotEmpty()) {
168 IconButton(onClick = { vm.setQuery("") }) {
169 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
174 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
175 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
177 // Mode selector: file names / contents / symbols.
179 modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp),
180 horizontalArrangement = Arrangement.spacedBy(8.dp),
182 SearchModeChip(stringResource(R.string.search_mode_files), state.mode == SearchMode.FILES) { vm.setMode(SearchMode.FILES) }
183 SearchModeChip(stringResource(R.string.search_mode_text), state.mode == SearchMode.TEXT) { vm.setMode(SearchMode.TEXT) }
184 SearchModeChip(stringResource(R.string.search_mode_symbols), state.mode == SearchMode.SYMBOL) { vm.setMode(SearchMode.SYMBOL) }
186 Spacer(Modifier.size(8.dp))
188 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
189 isRefreshing = refreshing,
190 onRefresh = { vm.refresh() },
191 modifier = Modifier.fillMaxSize(),
193 Box(modifier = Modifier.fillMaxSize()) {
194 val loadingLabel = stringResource(R.string.loading)
196 // Full-screen spinner only for the initial directory load. An in-flight
197 // search keeps the previous results visible (a slim top bar below shows the
198 // progress) instead of blanking the list behind a spinner on every keystroke.
199 state.loading -> Column(
200 modifier = Modifier.align(Alignment.Center).padding(24.dp),
201 horizontalAlignment = Alignment.CenterHorizontally,
203 CircularProgressIndicator(
204 Modifier.semantics { contentDescription = loadingLabel },
207 stringResource(R.string.loading),
208 style = MaterialTheme.typography.bodyMedium,
209 color = MaterialTheme.colorScheme.onSurfaceVariant,
210 modifier = Modifier.padding(top = 8.dp),
213 state.error != null -> Column(
214 modifier = Modifier.align(Alignment.Center).padding(24.dp),
215 horizontalAlignment = Alignment.CenterHorizontally,
218 Icons.Filled.ErrorOutline,
219 contentDescription = null,
220 tint = MaterialTheme.colorScheme.error,
221 modifier = Modifier.size(56.dp),
223 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
226 color = MaterialTheme.colorScheme.error,
228 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
229 // Retry the operation that actually failed: re-run the search
230 // when one was active (open() would reset the query and drop the
231 // user back into the directory listing), otherwise reload the dir.
232 androidx.compose.material3.TextButton(
233 onClick = { if (state.isSearching) vm.setQuery(state.query) else vm.open(state.path) },
235 Text(stringResource(R.string.retry))
238 state.mode == SearchMode.TEXT ->
241 searchEmptyMessage(state.searching, state.query.isBlank(), R.string.search_contents_hint),
242 ) { path, line -> onOpenFile(path, line) }
243 state.mode == SearchMode.SYMBOL ->
246 searchEmptyMessage(state.searching, state.query.isBlank(), R.string.search_symbols_hint),
247 ) { path, line -> onOpenFile(path, line) }
248 state.isSearching -> SearchResults(state.results) { onOpenFile(it, null) }
249 else -> DirectoryListing(
251 onOpenDir = vm::open,
253 onOpenFile = { onOpenFile(it, null) },
256 // Slim progress bar for an in-flight search; the results list stays visible
257 // underneath so incremental typing doesn't flash an empty screen each keystroke.
258 if (state.searching) {
259 LinearProgressIndicator(
261 .align(Alignment.TopCenter)
263 .semantics { contentDescription = loadingLabel },
273private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
274 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
275 val scrollState = rememberScrollState()
276 // Scroll to the deepest segment when the path changes so the current directory stays in
277 // view instead of hiding off the right edge behind the earlier segments on a deep path.
278 LaunchedEffect(path) { scrollState.animateScrollTo(scrollState.maxValue) }
281 .horizontalScroll(scrollState)
282 .padding(horizontal = 16.dp, vertical = 8.dp),
283 verticalAlignment = Alignment.CenterVertically,
285 IconButton(onClick = { onNavigate("") }) {
286 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
289 segments.forEachIndexed { index, segment ->
290 acc = if (acc.isEmpty()) segment else "$acc/$segment"
292 Icons.Filled.ChevronRight,
293 contentDescription = null,
294 modifier = Modifier.size(16.dp),
295 tint = MaterialTheme.colorScheme.onSurfaceVariant,
298 // Cap each segment's width so a single long folder name (e.g. a lengthy monorepo
299 // directory) doesn't push its siblings and the current folder off the screeen.
300 // The deepest segment gets a larger cap since it's the one the user navigated to
301 // and the auto-scroll keeps it in view.
302 val segMaxWidth = if (index == segments.lastIndex) 220.dp else 120.dp
305 style = MaterialTheme.typography.bodyMedium,
306 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
308 overflow = TextOverflow.Ellipsis,
310 .widthIn(max = segMaxWidth)
311 .clickable(role = Role.Button) { onNavigate(target) }
312 .defaultMinSize(minHeight = 48.dp)
313 .padding(horizontal = 6.dp, vertical = 14.dp),
320private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
321 val context = LocalContext.current
322 if (results.isEmpty()) {
324 icon = Icons.Filled.Search,
325 message = stringResource(R.string.no_matches),
326 modifier = Modifier.fillMaxWidth().padding(24.dp),
330 LazyColumn(modifier = Modifier.fillMaxSize()) {
331 // Key on path + index (not the bare path): a duplicate path from findFiles would
332 // otherwise crash LazyColumn with "Key already used". Mirrors TextResults/SymbolResults.
333 itemsIndexed(results, key = { i, p -> "$p:$i" }) { _, path ->
334 // Split the path into directory (muted) + filename (emphasized) so results
335 // are scannable instead of a wall of identical-looking full paths.
336 val dir = path.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
337 val name = path.substringAfterLast('/')
341 sublabel = dir.takeIf { it.isNotEmpty() },
342 onClick = { onOpenFile(path) },
344 copyToClipboard(context, context.getString(R.string.clip_label_path), path)
346 modifier = reducedMotionAnimateItem(),
353@OptIn(ExperimentalMaterial3Api::class)
355private fun SearchModeChip(label: String, selected: Boolean, onClick: () -> Unit) {
356 FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
359/** Empty-state message for a content/symbol search: a "type to search" hint before anything
360 * is typed, nothing while a search is in flight (the top progress bar covers that, so the
361 * first keystroke doesn't flash a false "No matches"), and "No matches" only once a real
362 * query genuinely returned nothing. */
364private fun searchEmptyMessage(searching: Boolean, queryBlank: Boolean, hint: Int): String? = when {
366 queryBlank -> stringResource(hint)
367 else -> stringResource(R.string.no_matches)
370/** Content (ripgrep) search results: file + line number, with the matched line highlighted. */
372private fun TextResults(results: List<FindMatch>, emptyMessage: String?, onOpen: (String, Int?) -> Unit) {
373 if (results.isEmpty()) {
374 if (emptyMessage != null) {
376 icon = Icons.Filled.Search,
377 message = emptyMessage,
378 modifier = Modifier.fillMaxWidth().padding(24.dp),
383 LazyColumn(modifier = Modifier.fillMaxSize()) {
384 item(key = "__count") {
386 pluralStringResource(R.plurals.file_search_results, results.size, results.size),
387 style = MaterialTheme.typography.labelSmall,
388 color = MaterialTheme.colorScheme.onSurfaceVariant,
389 modifier = Modifier.fillMaxWidth()
390 .padding(horizontal = 16.dp, vertical = 6.dp)
391 .semantics { heading() },
394 itemsIndexed(results, key = { i, m -> "${m.filePath}:${m.lineNumber}:$i" }) { _, match ->
395 val name = match.filePath.substringAfterLast('/')
396 val dir = match.filePath.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
397 val highlighted = remember(match) { highlightMatchLine(match) }
401 .defaultMinSize(minHeight = 48.dp)
402 .clickable(role = Role.Button) { onOpen(match.filePath, match.lineNumber) }
403 .padding(horizontal = 16.dp, vertical = 10.dp),
405 Row(verticalAlignment = Alignment.CenterVertically) {
408 style = MaterialTheme.typography.bodyMedium,
410 overflow = TextOverflow.Ellipsis,
411 modifier = Modifier.weight(1f, fill = false),
414 ":${match.lineNumber}",
415 style = MaterialTheme.typography.labelSmall,
416 color = MaterialTheme.colorScheme.onSurfaceVariant,
417 fontFamily = FontFamily.Monospace,
418 modifier = Modifier.padding(start = 4.dp),
421 if (dir.isNotEmpty()) {
424 style = MaterialTheme.typography.labelSmall,
425 color = MaterialTheme.colorScheme.onSurfaceVariant,
427 overflow = TextOverflow.Ellipsis,
432 style = MaterialTheme.typography.bodySmall,
433 fontFamily = FontFamily.Monospace,
435 overflow = TextOverflow.Ellipsis,
436 modifier = Modifier.padding(top = 2.dp),
444/** Workspace symbol results: symbol name + kind, and the file:line it's defined at. */
446private fun SymbolResults(results: List<SymbolResult>, emptyMessage: String?, onOpen: (String, Int?) -> Unit) {
447 if (results.isEmpty()) {
448 if (emptyMessage != null) {
450 icon = Icons.Filled.Search,
451 message = emptyMessage,
452 modifier = Modifier.fillMaxWidth().padding(24.dp),
457 LazyColumn(modifier = Modifier.fillMaxSize()) {
458 item(key = "__count") {
460 pluralStringResource(R.plurals.file_search_results, results.size, results.size),
461 style = MaterialTheme.typography.labelSmall,
462 color = MaterialTheme.colorScheme.onSurfaceVariant,
463 modifier = Modifier.fillMaxWidth()
464 .padding(horizontal = 16.dp, vertical = 6.dp)
465 .semantics { heading() },
468 itemsIndexed(results, key = { i, s -> "${s.filePath}:${s.name}:$i" }) { _, symbol ->
469 val fileName = symbol.filePath.substringAfterLast('/')
473 .defaultMinSize(minHeight = 48.dp)
474 .clickable(role = Role.Button) { onOpen(symbol.filePath, symbol.displayLine) }
475 .padding(horizontal = 16.dp, vertical = 10.dp),
477 Row(verticalAlignment = Alignment.CenterVertically) {
480 style = MaterialTheme.typography.bodyMedium,
482 overflow = TextOverflow.Ellipsis,
483 modifier = Modifier.weight(1f, fill = false),
486 symbolKindLabel(symbol.kind),
487 style = MaterialTheme.typography.labelSmall,
488 color = MaterialTheme.colorScheme.primary,
489 modifier = Modifier.padding(start = 8.dp),
493 "$fileName:${symbol.displayLine}",
494 style = MaterialTheme.typography.labelSmall,
495 color = MaterialTheme.colorScheme.onSurfaceVariant,
496 fontFamily = FontFamily.Monospace,
498 overflow = TextOverflow.Ellipsis,
506/** Build the matched line with each ripgrep submatch bolded. Offsets are byte columns
507 * within the line; clamped to the (trimmed) line length so a multibyte line can't crash. */
508private fun highlightMatchLine(match: FindMatch): androidx.compose.ui.text.AnnotatedString {
509 val line = match.lineText
510 return buildAnnotatedString {
512 for (sub in match.submatches.sortedBy { it.start }) {
513 val start = sub.start.coerceIn(0, line.length)
514 val end = sub.end.coerceIn(start, line.length)
515 if (start > cursor) append(line.substring(cursor, start))
516 withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(line.substring(start, end)) }
519 if (cursor < line.length) append(line.substring(cursor))
524private fun DirectoryListing(
525 state: FileBrowserState,
526 onOpenDir: (String) -> Unit,
528 onOpenFile: (String) -> Unit,
530 val context = LocalContext.current
531 if (state.entries.isEmpty()) {
533 icon = Icons.Filled.Folder,
534 message = stringResource(R.string.empty_folder),
535 modifier = Modifier.fillMaxWidth().padding(24.dp),
539 // Sort preference for the directory listing. Folders-first + name sort is the most useful
540 // default; the toggle flips between folders-first name sort and the server's raw order.
541 var foldersFirst by rememberSaveable { mutableStateOf(true) }
542 // Hidden-files preference. The server may already include dotfiles in its listing; this
543 // hides them by default (the common case for browsing a repo) and lets the user reveal
544 // .env / .git / .opencode on demand. Default off so the listing isn't cluttered.
545 var showHidden by rememberSaveable { mutableStateOf(false) }
546 // Sort key for files within the listing. Name is the default (and the only axis the server
547 // reliably supports); Size/Modified appear only when the server emits size/mtime (otherwise
548 // those sorts are no-ops on null fields), gated behind a menu so they don't clutter the bar.
549 var sortKey by rememberSaveable { mutableStateOf(FileSortKey.NAME) }
550 var sortDesc by rememberSaveable { mutableStateOf(false) }
551 val visible = remember(state.entries, showHidden) {
552 if (showHidden) state.entries else state.entries.filterNot { it.name.startsWith(".") }
554 val sorted = remember(visible, foldersFirst, sortKey, sortDesc) {
555 // Null size/mtime coerce to values that sink the unknown entries to the bottom in the
556 // default (ascending) view, so a partially-populated listing keeps sized/dated files on top.
557 val comparator = when (sortKey) {
558 FileSortKey.NAME -> compareBy<FileNode> { it.name.lowercase() }
559 FileSortKey.SIZE -> compareBy<FileNode> { it.size ?: Long.MAX_VALUE }
560 FileSortKey.MODIFIED -> compareBy<FileNode> { it.mtime ?: 0L }
561 }.let { if (sortDesc) it.reversed() else it }
563 val (dirs, files) = visible.partition { it.isDirectory }
564 // Directories always sort by name (size/date on a folder is rarely meaningful);
565 // the chosen key applies to the files within.
566 dirs.sortedBy { it.name.lowercase() } + files.sortedWith(comparator)
568 visible.sortedWith(comparator)
571 LazyColumn(modifier = Modifier.fillMaxSize()) {
572 if (state.path.isNotBlank()) {
574 FileRow(icon = true, label = "..", onClick = onUp)
578 item(key = "__sort") {
580 modifier = Modifier.fillMaxWidth()
581 .horizontalScroll(rememberScrollState())
582 .padding(end = 8.dp, top = 2.dp, bottom = 2.dp),
583 horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
584 verticalAlignment = Alignment.CenterVertically,
586 // rememberSaveable so an open sort dropdown survives a rotation instead of
587 // closing and leaving the user to re-open it.
588 var showSortMenu by rememberSaveable { mutableStateOf(false) }
589 // Close the sort dropdown on back press instead of navigating away, matching
590 // the session/server list dropdown back-handling.
591 BackHandler(enabled = showSortMenu) { showSortMenu = false }
592 androidx.compose.material3.FilterChip(
593 selected = sortKey != FileSortKey.NAME || sortDesc,
594 onClick = { showSortMenu = true },
596 Icon(Icons.Filled.SortByAlpha, contentDescription = null, modifier = Modifier.size(16.dp))
598 label = { Text(stringResource(R.string.sort_by), style = MaterialTheme.typography.labelSmall) },
600 DropdownMenu(expanded = showSortMenu, onDismissRequest = { showSortMenu = false }) {
601 val labels = remember {
603 FileSortKey.NAME to R.string.sort_name,
604 FileSortKey.SIZE to R.string.sort_size,
605 FileSortKey.MODIFIED to R.string.sort_modified,
608 FileSortKey.entries.forEach { key ->
611 Row(verticalAlignment = Alignment.CenterVertically) {
612 Text(stringResource(labels[key]!!), modifier = Modifier.weight(1f))
613 if (sortKey == key) {
615 if (sortDesc) Icons.Filled.KeyboardArrowDown else Icons.Filled.KeyboardArrowUp,
616 contentDescription = null,
617 modifier = Modifier.size(18.dp),
623 // Tapping the active key flips the direction; tapping a different
624 // key switches to it (ascending by default).
625 if (sortKey == key) sortDesc = !sortDesc else { sortKey = key; sortDesc = false }
631 androidx.compose.material3.FilterChip(
632 selected = showHidden,
633 onClick = { showHidden = !showHidden },
634 label = { Text(stringResource(R.string.show_hidden), style = MaterialTheme.typography.labelSmall) },
636 androidx.compose.material3.FilterChip(
637 selected = foldersFirst,
638 onClick = { foldersFirst = !foldersFirst },
641 Icon(Icons.Filled.Folder, contentDescription = null, modifier = Modifier.size(16.dp))
644 label = { Text(stringResource(R.string.folders_first), style = MaterialTheme.typography.labelSmall) },
648 items(sorted, key = { it.path + "_" + it.name }) { node ->
650 icon = node.isDirectory,
652 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
654 copyToClipboard(context, context.getString(R.string.clip_label_path), node.path)
658 status = state.statusMap[node.path],
659 modifier = reducedMotionAnimateItem(),
666/** Shared empty-state for the file browser. Delegates to the canonical [EmptyState] so every
667 * screen's "nothing here" reads and behaves identically (icon size, spacing, styling). */
669private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector, message: String, modifier: Modifier = Modifier) {
670 EmptyState(icon = icon, title = message, modifier = modifier)
673@OptIn(ExperimentalFoundationApi::class)
679 status: FileStatusEntry? = null,
680 sublabel: String? = null,
683 onCopyPath: (() -> Unit)? = null,
684 modifier: Modifier = Modifier,
686 val copyPathLabel = stringResource(R.string.copy_path)
687 // Long-press → "Copy path" dropdown. Rendered only when [onCopyPath] is supplied, so the
688 // parent ".." row (which passes none) stays a plain tap target. Anchored within the row.
689 // rememberSaveable so an open row menu survives a rotation instead of closing.
690 var menu by rememberSaveable { mutableStateOf(false) }
691 // Close this row's overflow on back press instead of navigating away, matching the
692 // sort menu BackHandler above.
693 BackHandler(enabled = menu) { menu = false }
694 val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
695 else if (icon) stringResource(R.string.folder, label)
696 else stringResource(R.string.file_label, label)
697 // Build a combined description so TalkBack announces both the file name and its git
698 // status. The parent sets an explicit contentDescription (mergeDescendants), which
699 // would otherwise suppress the StatusBadge's own description.
700 val statusDesc = status?.let {
702 "added" -> stringResource(R.string.git_added)
703 "modified" -> stringResource(R.string.git_modified)
704 "deleted" -> stringResource(R.string.git_deleted)
708 val fullDesc = if (statusDesc.isNotEmpty()) "$fileDesc, $statusDesc" else fileDesc
712 .defaultMinSize(minHeight = 48.dp)
716 onLongClick = onCopyPath?.let { { menu = true } },
717 onLongClickLabel = onCopyPath?.let { copyPathLabel },
719 .semantics(mergeDescendants = true) { contentDescription = fullDesc }
720 .padding(horizontal = 16.dp, vertical = 14.dp),
721 verticalAlignment = Alignment.CenterVertically,
724 if (icon) Icons.Filled.Folder else iconForFile(label),
725 contentDescription = null,
726 modifier = Modifier.size(20.dp),
727 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
729 Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
732 style = MaterialTheme.typography.bodyLarge,
734 overflow = TextOverflow.Ellipsis,
736 // Secondary line: a caller-supplied sublabel (directory in search results) and/or
737 // the file's size + last-modified time when the server provides them. Combines the
738 // fragments so a single muted line carries all metadata instead of stacking rows.
739 val detailParts = buildList {
740 if (sublabel != null) add(sublabel)
742 size?.let { add(formatFileSize(it)) }
743 mtime?.let { add(relativeTime(it)) }
746 if (detailParts.isNotEmpty()) {
748 detailParts.joinToString(" · "),
749 style = MaterialTheme.typography.labelSmall,
750 color = MaterialTheme.colorScheme.onSurfaceVariant,
752 overflow = TextOverflow.Ellipsis,
756 if (status != null) StatusBadge(status)
757 // Explicit overflow button so "Copy path" is reachable without a long-press (which
758 // isn't available to TalkBack/keyboard users or anyone who can't hold the gesture).
759 // Rendered only when the row supplies an onCopyPath (the parent ".." row does not).
760 if (onCopyPath != null) {
761 IconButton(onClick = { menu = true }) {
762 Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options))
765 DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
767 leadingIcon = { Icon(Icons.Filled.Link, contentDescription = null) },
768 text = { Text(copyPathLabel) },
769 onClick = { menu = false; onCopyPath?.invoke() },
776private fun StatusBadge(status: FileStatusEntry) {
777 val (letter, color) = when (status.status) {
778 "added" -> "A" to MaterialTheme.colorScheme.primary
779 "modified" -> "M" to MaterialTheme.colorScheme.tertiary
780 "deleted" -> "D" to MaterialTheme.colorScheme.error
781 else -> "·" to MaterialTheme.colorScheme.onSurfaceVariant
784 verticalAlignment = Alignment.CenterVertically,
788 style = MaterialTheme.typography.labelMedium,
789 fontFamily = FontFamily.Monospace,
792 if (status.added > 0 || status.removed > 0) {
794 "+${status.added} −${status.removed}",
795 style = MaterialTheme.typography.labelSmall,
796 fontFamily = FontFamily.Monospace,
797 color = MaterialTheme.colorScheme.onSurfaceVariant,
798 modifier = Modifier.padding(start = 4.dp),
804/** Sort axis for the directory listing. Name is always available; Size/Modified depend on the
805 * server emitting size/mtime (otherwise they sort nulls to the bottom). */
806private enum class FileSortKey { NAME, SIZE, MODIFIED }
808/** Format a byte count as a compact human-readable size (e.g. 1.2 KB, 3.4 MB). Uses 1024-based
809 * units with one decimal place, matching how file managers render sizes. */
810private fun formatFileSize(bytes: Long): String {
811 if (bytes < 1024) return "$bytes B"
812 val units = arrayOf("KB", "MB", "GB", "TB")
813 var value = bytes.toDouble() / 1024.0
815 while (value >= 1024 && unit < units.lastIndex) {
819 val formatted = if (value >= 100) "%.0f".format(value) else "%.1f".format(value)
820 return "$formatted ${units[unit]}"
823/** Pick an icon for a file by its extension so the directory listing is scannable instead of a
824 * wall of identical `Description` glyphs: images, source code, and Markdown/article files each
825 * get a distinct glyph; unknown extensions fall back to the generic document icon. */
826private fun iconForFile(name: String): androidx.compose.ui.graphics.vector.ImageVector {
827 val ext = name.substringAfterLast('.', missingDelimiterValue = "").lowercase()
829 "png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico", "heic" -> Icons.Filled.Image
830 "kt", "kts", "java", "js", "ts", "jsx", "tsx", "py", "rb", "go", "rs", "c", "cpp", "h",
831 "hpp", "cs", "swift", "scala", "gradle", "groovy", "sh", "bash", "zsh", "fish", "ps1",
832 "lua", "r", "dart", "php", "pl", "sql", "vue", "svelte" -> Icons.Filled.Code
833 "md", "markdown", "txt", "rst", "adoc", "rtf", "doc", "docx", "pdf", "epub" -> Icons.Filled.Article
834 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