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.fillMaxWidth
16import androidx.compose.foundation.layout.padding
17import androidx.compose.foundation.layout.size
18import androidx.compose.foundation.layout.widthIn
19import androidx.compose.foundation.lazy.LazyColumn
20import androidx.compose.foundation.lazy.items
21import androidx.compose.foundation.lazy.itemsIndexed
22import androidx.compose.foundation.rememberScrollState
23import androidx.compose.foundation.text.KeyboardActions
24import androidx.compose.foundation.text.KeyboardOptions
25import androidx.compose.material.icons.Icons
26import androidx.compose.material.icons.filled.ChevronRight
27import androidx.compose.material.icons.filled.Close
28import androidx.compose.material.icons.filled.Description
29import androidx.compose.material.icons.filled.Folder
30import androidx.compose.material.icons.filled.Home
31import androidx.compose.material.icons.filled.Image
32import androidx.compose.material.icons.filled.Code
33import androidx.compose.material.icons.filled.Article
34import androidx.compose.material.icons.filled.KeyboardArrowDown
35import androidx.compose.material.icons.filled.KeyboardArrowUp
36import androidx.compose.material.icons.filled.Link
37import androidx.compose.material.icons.filled.MoreVert
38import androidx.compose.material.icons.filled.Search
39import androidx.compose.material.icons.filled.SortByAlpha
40import androidx.compose.material3.CircularProgressIndicator
41import androidx.compose.material3.DropdownMenu
42import androidx.compose.material3.DropdownMenuItem
43import androidx.compose.material3.ExperimentalMaterial3Api
44import androidx.compose.material3.FilterChip
45import androidx.compose.material3.HorizontalDivider
46import androidx.compose.material3.Icon
47import androidx.compose.material3.IconButton
48import androidx.compose.material3.LinearProgressIndicator
49import androidx.compose.material3.MaterialTheme
50import androidx.compose.material3.OutlinedTextField
51import androidx.compose.material3.Scaffold
52import androidx.compose.material3.SnackbarHost
53import androidx.compose.material3.SnackbarHostState
54import androidx.compose.material3.Text
55import androidx.compose.runtime.Composable
56import androidx.compose.runtime.LaunchedEffect
57import androidx.compose.runtime.getValue
58import androidx.compose.runtime.mutableStateOf
59import androidx.compose.runtime.remember
60import androidx.compose.runtime.saveable.rememberSaveable
61import androidx.compose.runtime.setValue
62import androidx.compose.ui.Alignment
63import androidx.compose.ui.Modifier
64import androidx.compose.ui.focus.FocusRequester
65import androidx.compose.ui.focus.focusRequester
66import androidx.compose.ui.graphics.Color
67import androidx.compose.ui.platform.LocalContext
68import androidx.compose.ui.platform.LocalSoftwareKeyboardController
69import androidx.compose.ui.platform.testTag
70import androidx.compose.ui.semantics.Role
71import androidx.compose.ui.semantics.contentDescription
72import androidx.compose.ui.semantics.heading
73import androidx.compose.ui.semantics.role
74import androidx.compose.ui.semantics.semantics
75import androidx.compose.ui.text.SpanStyle
76import androidx.compose.ui.text.buildAnnotatedString
77import androidx.compose.ui.text.font.FontFamily
78import androidx.compose.ui.text.font.FontWeight
79import androidx.compose.ui.text.input.ImeAction
80import androidx.compose.ui.text.style.TextOverflow
81import androidx.compose.ui.text.withStyle
82import androidx.compose.ui.unit.dp
83import androidx.compose.ui.res.pluralStringResource
84import androidx.compose.ui.res.stringResource
85import androidx.lifecycle.compose.collectAsStateWithLifecycle
86import androidx.lifecycle.viewmodel.compose.viewModel
87import soy.iko.opencode.data.model.FileNode
88import soy.iko.opencode.data.model.FileStatusEntry
89import soy.iko.opencode.data.model.FindMatch
90import soy.iko.opencode.data.model.SymbolResult
91import soy.iko.opencode.data.model.symbolKindLabel
92import soy.iko.opencode.di.AppContainer
93import soy.iko.opencode.R
94import soy.iko.opencode.ui.components.AppTopBar
95import soy.iko.opencode.ui.components.ConnectionBannerFor
96import soy.iko.opencode.ui.components.EmptyState
97import soy.iko.opencode.ui.components.copyToClipboard
98import soy.iko.opencode.ui.components.relativeTime
99import soy.iko.opencode.ui.vmFactory
100import soy.iko.opencode.util.runCatchingCancellable
102@OptIn(ExperimentalMaterial3Api::class)
104fun FileBrowserScreen(
105 container: AppContainer,
106 onOpenFile: (path: String, line: Int?) -> Unit,
109 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
110 val state by vm.state.collectAsStateWithLifecycle()
111 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
112 val snackbar = remember { SnackbarHostState() }
114 LaunchedEffect(Unit) {
115 vm.transientErrors.collect { msg ->
116 snackbar.showSnackbar(msg)
122 AppTopBar(title = stringResource(R.string.files), onBack = onBack)
124 snackbarHost = { SnackbarHost(snackbar) },
126 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
127 // Surface SSE connection state so a dropped stream is visible while browsing
128 // files, not just on the chat/session screens.
129 Box(modifier = Modifier.fillMaxWidth()) {
130 ConnectionBannerFor(container)
132 // Tappable breadcrumb trail so deep paths are navigable.
135 onNavigate = vm::open,
136 modifier = Modifier.fillMaxWidth(),
138 val keyboardController = LocalSoftwareKeyboardController.current
139 // Search is the primary reason to open this screen, so focus the field on first
140 // composition. Focus only (no forced keyboard) so it doesn't fight the breadcrumb/listing.
141 val searchFocus = remember { FocusRequester() }
142 LaunchedEffect(Unit) { runCatchingCancellable { searchFocus.requestFocus() } }
145 onValueChange = vm::setQuery,
148 .padding(start = 12.dp, end = 12.dp, top = 12.dp)
149 .focusRequester(searchFocus)
150 .testTag("file_search"),
155 SearchMode.FILES -> R.string.search_files
156 SearchMode.TEXT -> R.string.search_in_files
157 SearchMode.SYMBOL -> R.string.search_symbols_label
162 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
163 trailingIcon = if (state.query.isNotEmpty()) {
165 IconButton(onClick = { vm.setQuery("") }) {
166 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
171 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
172 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
174 // Mode selector: file names / contents / symbols.
176 modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp),
177 horizontalArrangement = Arrangement.spacedBy(8.dp),
179 SearchModeChip(stringResource(R.string.search_mode_files), state.mode == SearchMode.FILES) { vm.setMode(SearchMode.FILES) }
180 SearchModeChip(stringResource(R.string.search_mode_text), state.mode == SearchMode.TEXT) { vm.setMode(SearchMode.TEXT) }
181 SearchModeChip(stringResource(R.string.search_mode_symbols), state.mode == SearchMode.SYMBOL) { vm.setMode(SearchMode.SYMBOL) }
183 Spacer(Modifier.size(8.dp))
185 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
186 isRefreshing = refreshing,
187 onRefresh = { vm.refresh() },
188 modifier = Modifier.fillMaxSize(),
190 Box(modifier = Modifier.fillMaxSize()) {
191 val loadingLabel = stringResource(R.string.loading)
193 // Full-screen spinner only for the initial directory load. An in-flight
194 // search keeps the previous results visible (a slim top bar below shows the
195 // progress) instead of blanking the list behind a spinner on every keystroke.
196 state.loading -> CircularProgressIndicator(
197 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
199 state.error != null -> Column(
200 modifier = Modifier.align(Alignment.Center).padding(24.dp),
201 horizontalAlignment = Alignment.CenterHorizontally,
205 color = MaterialTheme.colorScheme.error,
207 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
208 // Retry the operation that actually failed: re-run the search
209 // when one was active (open() would reset the query and drop the
210 // user back into the directory listing), otherwise reload the dir.
211 androidx.compose.material3.TextButton(
212 onClick = { if (state.isSearching) vm.setQuery(state.query) else vm.open(state.path) },
214 Text(stringResource(R.string.retry))
217 state.mode == SearchMode.TEXT ->
220 searchEmptyMessage(state.searching, state.query.isBlank(), R.string.search_contents_hint),
221 ) { path, line -> onOpenFile(path, line) }
222 state.mode == SearchMode.SYMBOL ->
225 searchEmptyMessage(state.searching, state.query.isBlank(), R.string.search_symbols_hint),
226 ) { path, line -> onOpenFile(path, line) }
227 state.isSearching -> SearchResults(state.results) { onOpenFile(it, null) }
228 else -> DirectoryListing(
230 onOpenDir = vm::open,
232 onOpenFile = { onOpenFile(it, null) },
235 // Slim progress bar for an in-flight search; the results list stays visible
236 // underneath so incremental typing doesn't flash an empty screen each keystroke.
237 if (state.searching) {
238 LinearProgressIndicator(
240 .align(Alignment.TopCenter)
242 .semantics { contentDescription = loadingLabel },
252private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
253 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
254 val scrollState = rememberScrollState()
255 // Scroll to the deepest segment when the path changes so the current directory stays in
256 // view instead of hiding off the right edge behind the earlier segments on a deep path.
257 LaunchedEffect(path) { scrollState.animateScrollTo(scrollState.maxValue) }
260 .horizontalScroll(scrollState)
261 .padding(horizontal = 16.dp, vertical = 8.dp),
262 verticalAlignment = Alignment.CenterVertically,
264 IconButton(onClick = { onNavigate("") }) {
265 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
268 segments.forEachIndexed { index, segment ->
269 acc = if (acc.isEmpty()) segment else "$acc/$segment"
271 Icons.Filled.ChevronRight,
272 contentDescription = null,
273 modifier = Modifier.size(16.dp),
274 tint = MaterialTheme.colorScheme.onSurfaceVariant,
277 // Cap each segment's width so a single long folder name (e.g. a lengthy monorepo
278 // directory) doesn't push its siblings and the current folder off the screeen.
279 // The deepest segment gets a larger cap since it's the one the user navigated to
280 // and the auto-scroll keeps it in view.
281 val segMaxWidth = if (index == segments.lastIndex) 220.dp else 120.dp
284 style = MaterialTheme.typography.bodyMedium,
285 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
287 overflow = TextOverflow.Ellipsis,
289 .widthIn(max = segMaxWidth)
290 .clickable(role = Role.Button) { onNavigate(target) }
291 .defaultMinSize(minHeight = 48.dp)
292 .padding(horizontal = 6.dp, vertical = 14.dp),
299private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
300 val context = LocalContext.current
301 if (results.isEmpty()) {
303 icon = Icons.Filled.Search,
304 message = stringResource(R.string.no_matches),
305 modifier = Modifier.fillMaxWidth().padding(24.dp),
309 LazyColumn(modifier = Modifier.fillMaxSize()) {
310 // Key on path + index (not the bare path): a duplicate path from findFiles would
311 // otherwise crash LazyColumn with "Key already used". Mirrors TextResults/SymbolResults.
312 itemsIndexed(results, key = { i, p -> "$p:$i" }) { _, path ->
313 // Split the path into directory (muted) + filename (emphasized) so results
314 // are scannable instead of a wall of identical-looking full paths.
315 val dir = path.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
316 val name = path.substringAfterLast('/')
320 sublabel = dir.takeIf { it.isNotEmpty() },
321 onClick = { onOpenFile(path) },
323 copyToClipboard(context, context.getString(R.string.clip_label_path), path)
325 modifier = Modifier.animateItem(),
332@OptIn(ExperimentalMaterial3Api::class)
334private fun SearchModeChip(label: String, selected: Boolean, onClick: () -> Unit) {
335 FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
338/** Empty-state message for a content/symbol search: a "type to search" hint before anything
339 * is typed, nothing while a search is in flight (the top progress bar covers that, so the
340 * first keystroke doesn't flash a false "No matches"), and "No matches" only once a real
341 * query genuinely returned nothing. */
343private fun searchEmptyMessage(searching: Boolean, queryBlank: Boolean, hint: Int): String? = when {
345 queryBlank -> stringResource(hint)
346 else -> stringResource(R.string.no_matches)
349/** Content (ripgrep) search results: file + line number, with the matched line highlighted. */
351private fun TextResults(results: List<FindMatch>, emptyMessage: String?, onOpen: (String, Int?) -> Unit) {
352 if (results.isEmpty()) {
353 if (emptyMessage != null) {
355 icon = Icons.Filled.Search,
356 message = emptyMessage,
357 modifier = Modifier.fillMaxWidth().padding(24.dp),
362 LazyColumn(modifier = Modifier.fillMaxSize()) {
363 item(key = "__count") {
365 pluralStringResource(R.plurals.file_search_results, results.size, results.size),
366 style = MaterialTheme.typography.labelSmall,
367 color = MaterialTheme.colorScheme.onSurfaceVariant,
368 modifier = Modifier.fillMaxWidth()
369 .padding(horizontal = 16.dp, vertical = 6.dp)
370 .semantics { heading() },
373 itemsIndexed(results, key = { i, m -> "${m.filePath}:${m.lineNumber}:$i" }) { _, match ->
374 val name = match.filePath.substringAfterLast('/')
375 val dir = match.filePath.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
376 val highlighted = remember(match) { highlightMatchLine(match) }
380 .clickable(role = Role.Button) { onOpen(match.filePath, match.lineNumber) }
381 .padding(horizontal = 16.dp, vertical = 10.dp),
383 Row(verticalAlignment = Alignment.CenterVertically) {
386 style = MaterialTheme.typography.bodyMedium,
388 overflow = TextOverflow.Ellipsis,
389 modifier = Modifier.weight(1f, fill = false),
392 ":${match.lineNumber}",
393 style = MaterialTheme.typography.labelSmall,
394 color = MaterialTheme.colorScheme.onSurfaceVariant,
395 fontFamily = FontFamily.Monospace,
396 modifier = Modifier.padding(start = 4.dp),
399 if (dir.isNotEmpty()) {
402 style = MaterialTheme.typography.labelSmall,
403 color = MaterialTheme.colorScheme.onSurfaceVariant,
405 overflow = TextOverflow.Ellipsis,
410 style = MaterialTheme.typography.bodySmall,
411 fontFamily = FontFamily.Monospace,
413 overflow = TextOverflow.Ellipsis,
414 modifier = Modifier.padding(top = 2.dp),
422/** Workspace symbol results: symbol name + kind, and the file:line it's defined at. */
424private fun SymbolResults(results: List<SymbolResult>, emptyMessage: String?, onOpen: (String, Int?) -> Unit) {
425 if (results.isEmpty()) {
426 if (emptyMessage != null) {
428 icon = Icons.Filled.Search,
429 message = emptyMessage,
430 modifier = Modifier.fillMaxWidth().padding(24.dp),
435 LazyColumn(modifier = Modifier.fillMaxSize()) {
436 item(key = "__count") {
438 pluralStringResource(R.plurals.file_search_results, results.size, results.size),
439 style = MaterialTheme.typography.labelSmall,
440 color = MaterialTheme.colorScheme.onSurfaceVariant,
441 modifier = Modifier.fillMaxWidth()
442 .padding(horizontal = 16.dp, vertical = 6.dp)
443 .semantics { heading() },
446 itemsIndexed(results, key = { i, s -> "${s.filePath}:${s.name}:$i" }) { _, symbol ->
447 val fileName = symbol.filePath.substringAfterLast('/')
451 .clickable(role = Role.Button) { onOpen(symbol.filePath, symbol.displayLine) }
452 .padding(horizontal = 16.dp, vertical = 10.dp),
454 Row(verticalAlignment = Alignment.CenterVertically) {
457 style = MaterialTheme.typography.bodyMedium,
459 overflow = TextOverflow.Ellipsis,
460 modifier = Modifier.weight(1f, fill = false),
463 symbolKindLabel(symbol.kind),
464 style = MaterialTheme.typography.labelSmall,
465 color = MaterialTheme.colorScheme.primary,
466 modifier = Modifier.padding(start = 8.dp),
470 "$fileName:${symbol.displayLine}",
471 style = MaterialTheme.typography.labelSmall,
472 color = MaterialTheme.colorScheme.onSurfaceVariant,
473 fontFamily = FontFamily.Monospace,
475 overflow = TextOverflow.Ellipsis,
483/** Build the matched line with each ripgrep submatch bolded. Offsets are byte columns
484 * within the line; clamped to the (trimmed) line length so a multibyte line can't crash. */
485private fun highlightMatchLine(match: FindMatch): androidx.compose.ui.text.AnnotatedString {
486 val line = match.lineText
487 return buildAnnotatedString {
489 for (sub in match.submatches.sortedBy { it.start }) {
490 val start = sub.start.coerceIn(0, line.length)
491 val end = sub.end.coerceIn(start, line.length)
492 if (start > cursor) append(line.substring(cursor, start))
493 withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(line.substring(start, end)) }
496 if (cursor < line.length) append(line.substring(cursor))
501private fun DirectoryListing(
502 state: FileBrowserState,
503 onOpenDir: (String) -> Unit,
505 onOpenFile: (String) -> Unit,
507 val context = LocalContext.current
508 if (state.entries.isEmpty()) {
510 icon = Icons.Filled.Folder,
511 message = stringResource(R.string.empty_folder),
512 modifier = Modifier.fillMaxWidth().padding(24.dp),
516 // Sort preference for the directory listing. Folders-first + name sort is the most useful
517 // default; the toggle flips between folders-first name sort and the server's raw order.
518 var foldersFirst by rememberSaveable { mutableStateOf(true) }
519 // Hidden-files preference. The server may already include dotfiles in its listing; this
520 // hides them by default (the common case for browsing a repo) and lets the user reveal
521 // .env / .git / .opencode on demand. Default off so the listing isn't cluttered.
522 var showHidden by rememberSaveable { mutableStateOf(false) }
523 // Sort key for files within the listing. Name is the default (and the only axis the server
524 // reliably supports); Size/Modified appear only when the server emits size/mtime (otherwise
525 // those sorts are no-ops on null fields), gated behind a menu so they don't clutter the bar.
526 var sortKey by rememberSaveable { mutableStateOf(FileSortKey.NAME) }
527 var sortDesc by rememberSaveable { mutableStateOf(false) }
528 val visible = remember(state.entries, showHidden) {
529 if (showHidden) state.entries else state.entries.filterNot { it.name.startsWith(".") }
531 val sorted = remember(visible, foldersFirst, sortKey, sortDesc) {
532 // Null size/mtime coerce to values that sink the unknown entries to the bottom in the
533 // default (ascending) view, so a partially-populated listing keeps sized/dated files on top.
534 val comparator = when (sortKey) {
535 FileSortKey.NAME -> compareBy<FileNode> { it.name.lowercase() }
536 FileSortKey.SIZE -> compareBy<FileNode> { it.size ?: Long.MAX_VALUE }
537 FileSortKey.MODIFIED -> compareBy<FileNode> { it.mtime ?: 0L }
538 }.let { if (sortDesc) it.reversed() else it }
540 val (dirs, files) = visible.partition { it.isDirectory }
541 // Directories always sort by name (size/date on a folder is rarely meaningful);
542 // the chosen key applies to the files within.
543 dirs.sortedBy { it.name.lowercase() } + files.sortedWith(comparator)
545 visible.sortedWith(comparator)
548 LazyColumn(modifier = Modifier.fillMaxSize()) {
549 if (state.path.isNotBlank()) {
551 FileRow(icon = true, label = "..", onClick = onUp)
555 item(key = "__sort") {
557 modifier = Modifier.fillMaxWidth()
558 .horizontalScroll(rememberScrollState())
559 .padding(end = 8.dp, top = 2.dp, bottom = 2.dp),
560 horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
561 verticalAlignment = Alignment.CenterVertically,
563 // rememberSaveable so an open sort dropdown survives a rotation instead of
564 // closing and leaving the user to re-open it.
565 var showSortMenu by rememberSaveable { mutableStateOf(false) }
566 // Close the sort dropdown on back press instead of navigating away, matching
567 // the session/server list dropdown back-handling.
568 BackHandler(enabled = showSortMenu) { showSortMenu = false }
569 androidx.compose.material3.FilterChip(
570 selected = sortKey != FileSortKey.NAME || sortDesc,
571 onClick = { showSortMenu = true },
573 Icon(Icons.Filled.SortByAlpha, contentDescription = null, modifier = Modifier.size(16.dp))
575 label = { Text(stringResource(R.string.sort_by), style = MaterialTheme.typography.labelSmall) },
577 DropdownMenu(expanded = showSortMenu, onDismissRequest = { showSortMenu = false }) {
578 val labels = remember {
580 FileSortKey.NAME to R.string.sort_name,
581 FileSortKey.SIZE to R.string.sort_size,
582 FileSortKey.MODIFIED to R.string.sort_modified,
585 FileSortKey.entries.forEach { key ->
588 Row(verticalAlignment = Alignment.CenterVertically) {
589 Text(stringResource(labels[key]!!), modifier = Modifier.weight(1f))
590 if (sortKey == key) {
592 if (sortDesc) Icons.Filled.KeyboardArrowDown else Icons.Filled.KeyboardArrowUp,
593 contentDescription = null,
594 modifier = Modifier.size(18.dp),
600 // Tapping the active key flips the direction; tapping a different
601 // key switches to it (ascending by default).
602 if (sortKey == key) sortDesc = !sortDesc else { sortKey = key; sortDesc = false }
608 androidx.compose.material3.FilterChip(
609 selected = showHidden,
610 onClick = { showHidden = !showHidden },
611 label = { Text(stringResource(R.string.show_hidden), style = MaterialTheme.typography.labelSmall) },
613 androidx.compose.material3.FilterChip(
614 selected = foldersFirst,
615 onClick = { foldersFirst = !foldersFirst },
618 Icon(Icons.Filled.Folder, contentDescription = null, modifier = Modifier.size(16.dp))
621 label = { Text(stringResource(R.string.folders_first), style = MaterialTheme.typography.labelSmall) },
625 items(sorted, key = { it.path + "_" + it.name }) { node ->
627 icon = node.isDirectory,
629 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
631 copyToClipboard(context, context.getString(R.string.clip_label_path), node.path)
635 status = state.statusMap[node.path],
636 modifier = Modifier.animateItem(),
643/** Shared empty-state for the file browser. Delegates to the canonical [EmptyState] so every
644 * screen's "nothing here" reads and behaves identically (icon size, spacing, styling). */
646private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector, message: String, modifier: Modifier = Modifier) {
647 EmptyState(icon = icon, title = message, modifier = modifier)
650@OptIn(ExperimentalFoundationApi::class)
656 status: FileStatusEntry? = null,
657 sublabel: String? = null,
660 onCopyPath: (() -> Unit)? = null,
661 modifier: Modifier = Modifier,
663 val copyPathLabel = stringResource(R.string.copy_path)
664 // Long-press → "Copy path" dropdown. Rendered only when [onCopyPath] is supplied, so the
665 // parent ".." row (which passes none) stays a plain tap target. Anchored within the row.
666 // rememberSaveable so an open row menu survives a rotation instead of closing.
667 var menu by rememberSaveable { mutableStateOf(false) }
668 // Close this row's overflow on back press instead of navigating away, matching the
669 // sort menu BackHandler above.
670 BackHandler(enabled = menu) { menu = false }
671 val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
672 else if (icon) stringResource(R.string.folder, label)
673 else stringResource(R.string.file_label, label)
674 // Build a combined description so TalkBack announces both the file name and its git
675 // status. The parent sets an explicit contentDescription (mergeDescendants), which
676 // would otherwise suppress the StatusBadge's own description.
677 val statusDesc = status?.let {
679 "added" -> stringResource(R.string.git_added)
680 "modified" -> stringResource(R.string.git_modified)
681 "deleted" -> stringResource(R.string.git_deleted)
685 val fullDesc = if (statusDesc.isNotEmpty()) "$fileDesc, $statusDesc" else fileDesc
689 .defaultMinSize(minHeight = 48.dp)
693 onLongClick = onCopyPath?.let { { menu = true } },
694 onLongClickLabel = onCopyPath?.let { copyPathLabel },
696 .semantics(mergeDescendants = true) { contentDescription = fullDesc }
697 .padding(horizontal = 16.dp, vertical = 14.dp),
698 verticalAlignment = Alignment.CenterVertically,
701 if (icon) Icons.Filled.Folder else iconForFile(label),
702 contentDescription = null,
703 modifier = Modifier.size(20.dp),
704 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
706 Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
709 style = MaterialTheme.typography.bodyLarge,
711 overflow = TextOverflow.Ellipsis,
713 // Secondary line: a caller-supplied sublabel (directory in search results) and/or
714 // the file's size + last-modified time when the server provides them. Combines the
715 // fragments so a single muted line carries all metadata instead of stacking rows.
716 val detailParts = buildList {
717 if (sublabel != null) add(sublabel)
719 size?.let { add(formatFileSize(it)) }
720 mtime?.let { add(relativeTime(it)) }
723 if (detailParts.isNotEmpty()) {
725 detailParts.joinToString(" · "),
726 style = MaterialTheme.typography.labelSmall,
727 color = MaterialTheme.colorScheme.onSurfaceVariant,
729 overflow = TextOverflow.Ellipsis,
733 if (status != null) StatusBadge(status)
734 // Explicit overflow button so "Copy path" is reachable without a long-press (which
735 // isn't available to TalkBack/keyboard users or anyone who can't hold the gesture).
736 // Rendered only when the row supplies an onCopyPath (the parent ".." row does not).
737 if (onCopyPath != null) {
738 IconButton(onClick = { menu = true }) {
739 Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options))
742 DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
744 leadingIcon = { Icon(Icons.Filled.Link, contentDescription = null) },
745 text = { Text(copyPathLabel) },
746 onClick = { menu = false; onCopyPath?.invoke() },
753private fun StatusBadge(status: FileStatusEntry) {
754 val (letter, color) = when (status.status) {
755 "added" -> "A" to MaterialTheme.colorScheme.primary
756 "modified" -> "M" to MaterialTheme.colorScheme.tertiary
757 "deleted" -> "D" to MaterialTheme.colorScheme.error
758 else -> "·" to MaterialTheme.colorScheme.onSurfaceVariant
761 verticalAlignment = Alignment.CenterVertically,
765 style = MaterialTheme.typography.labelMedium,
766 fontFamily = FontFamily.Monospace,
769 if (status.added > 0 || status.removed > 0) {
771 "+${status.added} −${status.removed}",
772 style = MaterialTheme.typography.labelSmall,
773 fontFamily = FontFamily.Monospace,
774 color = MaterialTheme.colorScheme.onSurfaceVariant,
775 modifier = Modifier.padding(start = 4.dp),
781/** Sort axis for the directory listing. Name is always available; Size/Modified depend on the
782 * server emitting size/mtime (otherwise they sort nulls to the bottom). */
783private enum class FileSortKey { NAME, SIZE, MODIFIED }
785/** Format a byte count as a compact human-readable size (e.g. 1.2 KB, 3.4 MB). Uses 1024-based
786 * units with one decimal place, matching how file managers render sizes. */
787private fun formatFileSize(bytes: Long): String {
788 if (bytes < 1024) return "$bytes B"
789 val units = arrayOf("KB", "MB", "GB", "TB")
790 var value = bytes.toDouble() / 1024.0
792 while (value >= 1024 && unit < units.lastIndex) {
796 val formatted = if (value >= 100) "%.0f".format(value) else "%.1f".format(value)
797 return "$formatted ${units[unit]}"
800/** Pick an icon for a file by its extension so the directory listing is scannable instead of a
801 * wall of identical `Description` glyphs: images, source code, and Markdown/article files each
802 * get a distinct glyph; unknown extensions fall back to the generic document icon. */
803private fun iconForFile(name: String): androidx.compose.ui.graphics.vector.ImageVector {
804 val ext = name.substringAfterLast('.', missingDelimiterValue = "").lowercase()
806 "png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico", "heic" -> Icons.Filled.Image
807 "kt", "kts", "java", "js", "ts", "jsx", "tsx", "py", "rb", "go", "rs", "c", "cpp", "h",
808 "hpp", "cs", "swift", "scala", "gradle", "groovy", "sh", "bash", "zsh", "fish", "ps1",
809 "lua", "r", "dart", "php", "pl", "sql", "vue", "svelte" -> Icons.Filled.Code
810 "md", "markdown", "txt", "rst", "adoc", "rtf", "doc", "docx", "pdf", "epub" -> Icons.Filled.Article
811 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