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.ChatBubbleOutline
28import androidx.compose.material.icons.filled.ChevronRight
29import androidx.compose.material.icons.filled.Close
30import androidx.compose.material.icons.filled.Description
31import androidx.compose.material.icons.filled.ErrorOutline
32import androidx.compose.material.icons.filled.Folder
33import androidx.compose.material.icons.filled.Home
34import androidx.compose.material.icons.filled.Image
35import androidx.compose.material.icons.filled.Code
36import androidx.compose.material.icons.filled.Article
37import androidx.compose.material.icons.filled.KeyboardArrowDown
38import androidx.compose.material.icons.filled.KeyboardArrowUp
39import androidx.compose.material.icons.filled.Link
40import androidx.compose.material.icons.filled.MoreVert
41import androidx.compose.material.icons.filled.Search
42import androidx.compose.material.icons.filled.SortByAlpha
43import androidx.compose.material3.CircularProgressIndicator
44import androidx.compose.material3.DropdownMenu
45import androidx.compose.material3.DropdownMenuItem
46import androidx.compose.material3.ExperimentalMaterial3Api
47import androidx.compose.material3.FilterChip
48import androidx.compose.material3.HorizontalDivider
49import androidx.compose.material3.Icon
50import androidx.compose.material3.IconButton
51import androidx.compose.material3.LinearProgressIndicator
52import androidx.compose.material3.MaterialTheme
53import androidx.compose.material3.OutlinedTextField
54import androidx.compose.material3.Scaffold
55import androidx.compose.material3.SnackbarHost
56import androidx.compose.material3.SnackbarHostState
57import androidx.compose.material3.Text
58import androidx.compose.runtime.Composable
59import androidx.compose.runtime.LaunchedEffect
60import androidx.compose.runtime.getValue
61import androidx.compose.runtime.mutableStateOf
62import androidx.compose.runtime.remember
63import androidx.compose.runtime.saveable.rememberSaveable
64import androidx.compose.runtime.setValue
65import androidx.compose.ui.Alignment
66import androidx.compose.ui.Modifier
67import androidx.compose.ui.focus.FocusRequester
68import androidx.compose.ui.focus.focusRequester
69import androidx.compose.ui.graphics.Color
70import androidx.compose.ui.graphics.graphicsLayer
71import androidx.compose.ui.platform.LocalContext
72import androidx.compose.ui.platform.LocalSoftwareKeyboardController
73import androidx.compose.ui.platform.testTag
74import androidx.compose.ui.semantics.Role
75import androidx.compose.ui.semantics.contentDescription
76import androidx.compose.ui.semantics.heading
77import androidx.compose.ui.semantics.role
78import androidx.compose.ui.semantics.semantics
79import androidx.compose.ui.text.SpanStyle
80import androidx.compose.ui.text.buildAnnotatedString
81import androidx.compose.ui.text.font.FontFamily
82import androidx.compose.ui.text.font.FontWeight
83import androidx.compose.ui.text.input.ImeAction
84import androidx.compose.ui.text.style.TextOverflow
85import androidx.compose.ui.text.withStyle
86import androidx.compose.ui.unit.dp
87import androidx.compose.ui.res.pluralStringResource
88import androidx.compose.ui.res.stringResource
89import androidx.lifecycle.compose.collectAsStateWithLifecycle
90import androidx.lifecycle.viewmodel.compose.viewModel
91import soy.iko.opencode.data.model.FileNode
92import soy.iko.opencode.data.model.FileStatusEntry
93import soy.iko.opencode.data.model.FindMatch
94import soy.iko.opencode.data.model.SymbolResult
95import soy.iko.opencode.data.model.symbolKindLabel
96import soy.iko.opencode.data.network.NetworkConfig
97import soy.iko.opencode.di.AppContainer
98import soy.iko.opencode.R
99import soy.iko.opencode.ui.components.AppTopBar
100import soy.iko.opencode.ui.components.ConnectionBannerFor
101import soy.iko.opencode.ui.components.EmptyState
102import soy.iko.opencode.ui.components.reducedMotionAnimateItem
103import soy.iko.opencode.ui.components.copyToClipboard
104import soy.iko.opencode.ui.components.relativeTime
105import soy.iko.opencode.ui.vmFactory
106import soy.iko.opencode.util.runCatchingCancellable
108@OptIn(ExperimentalMaterial3Api::class)
110fun FileBrowserScreen(
111 container: AppContainer,
112 onOpenFile: (path: String, line: Int?) -> Unit,
115 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
116 val state by vm.state.collectAsStateWithLifecycle()
117 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
118 val snackbar = remember { SnackbarHostState() }
120 // System-back navigates up the directory tree while inside a subfolder (matching every
121 // other file manager's muscle memory), only exiting the screen at the root. Uses
122 // PredictiveBackHandler so the Android 14 predictive-back gesture animates as the user
123 // swipes — a plain BackHandler consumes the gesture without visual feedback, which reads
124 // as "nothing happened" mid-swipe. The handler navigates up on completion; the progress
125 // is used to drive a subtle scale on the content so the gesture feels connected.
126 val backProgress = remember { androidx.compose.runtime.mutableFloatStateOf(0f) }
127 androidx.activity.compose.PredictiveBackHandler(
128 enabled = state.path.isNotBlank(),
131 flow.collect { event ->
132 backProgress.floatValue = event.progress
134 // Gesture completed: navigate up.
137 backProgress.floatValue = 0f
141 LaunchedEffect(Unit) {
142 vm.transientErrors.collect { msg ->
143 snackbar.showSnackbar(msg)
149 AppTopBar(title = stringResource(R.string.files), onBack = onBack)
151 snackbarHost = { SnackbarHost(snackbar) },
153 // Apply a subtle scale driven by the predictive-back progress so the up-navigation
154 // gesture feels connected to the content (the content shrinks slightly as the user
155 // swipes, then snaps back if cancelled or navigates up if completed).
156 val scale = 1f - (backProgress.floatValue * 0.05f)
162 .graphicsLayer { scaleX = scale; scaleY = scale },
164 // Surface SSE connection state so a dropped stream is visible while browsing
165 // files, not just on the chat/session screens.
166 Box(modifier = Modifier.fillMaxWidth()) {
167 ConnectionBannerFor(container)
169 // Tappable breadcrumb trail so deep paths are navigable.
172 onNavigate = vm::open,
173 modifier = Modifier.fillMaxWidth(),
175 val keyboardController = LocalSoftwareKeyboardController.current
176 // Search is the primary reason to open this screen, so focus the field on first
177 // composition. Focus only (no forced keyboard) so it doesn't fight the breadcrumb/listing.
178 val searchFocus = remember { FocusRequester() }
179 LaunchedEffect(Unit) { runCatchingCancellable { searchFocus.requestFocus() } }
182 onValueChange = vm::setQuery,
185 .padding(start = 12.dp, end = 12.dp, top = 12.dp)
186 .focusRequester(searchFocus)
187 .testTag("file_search"),
192 SearchMode.FILES -> R.string.search_files
193 SearchMode.TEXT -> R.string.search_in_files
194 SearchMode.SYMBOL -> R.string.search_symbols_label
199 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
200 trailingIcon = if (state.query.isNotEmpty()) {
202 IconButton(onClick = { vm.setQuery("") }) {
203 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
208 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
209 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
211 // Mode selector: file names / contents / symbols.
213 modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp),
214 horizontalArrangement = Arrangement.spacedBy(8.dp),
216 SearchModeChip(stringResource(R.string.search_mode_files), state.mode == SearchMode.FILES) { vm.setMode(SearchMode.FILES) }
217 SearchModeChip(stringResource(R.string.search_mode_text), state.mode == SearchMode.TEXT) { vm.setMode(SearchMode.TEXT) }
218 SearchModeChip(stringResource(R.string.search_mode_symbols), state.mode == SearchMode.SYMBOL) { vm.setMode(SearchMode.SYMBOL) }
220 Spacer(Modifier.size(8.dp))
222 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
223 isRefreshing = refreshing,
224 onRefresh = { vm.refresh() },
225 modifier = Modifier.fillMaxSize(),
227 Box(modifier = Modifier.fillMaxSize()) {
228 val loadingLabel = stringResource(R.string.loading)
230 // Full-screen spinner only for the initial directory load. An in-flight
231 // search keeps the previous results visible (a slim top bar below shows the
232 // progress) instead of blanking the list behind a spinner on every keystroke.
233 state.loading -> Column(
234 modifier = Modifier.align(Alignment.Center).padding(24.dp),
235 horizontalAlignment = Alignment.CenterHorizontally,
237 CircularProgressIndicator(
238 Modifier.semantics { contentDescription = loadingLabel },
241 stringResource(R.string.loading),
242 style = MaterialTheme.typography.bodyMedium,
243 color = MaterialTheme.colorScheme.onSurfaceVariant,
244 modifier = Modifier.padding(top = 8.dp),
247 state.error != null -> Column(
248 modifier = Modifier.align(Alignment.Center).padding(24.dp),
249 horizontalAlignment = Alignment.CenterHorizontally,
252 Icons.Filled.ErrorOutline,
253 contentDescription = null,
254 tint = MaterialTheme.colorScheme.error,
255 modifier = Modifier.size(56.dp),
257 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
260 color = MaterialTheme.colorScheme.error,
262 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
263 // Retry the operation that actually failed: re-run the search
264 // when one was active (open() would reset the query and drop the
265 // user back into the directory listing), otherwise reload the dir.
266 androidx.compose.material3.TextButton(
267 onClick = { if (state.isSearching) vm.setQuery(state.query) else vm.open(state.path) },
269 Text(stringResource(R.string.retry))
272 state.mode == SearchMode.TEXT ->
275 searchEmptyMessage(state.searching, state.query.isBlank(), R.string.search_contents_hint),
276 ) { path, line -> onOpenFile(path, line) }
277 state.mode == SearchMode.SYMBOL ->
280 searchEmptyMessage(state.searching, state.query.isBlank(), R.string.search_symbols_hint),
281 ) { path, line -> onOpenFile(path, line) }
282 state.isSearching -> SearchResults(state.results) { onOpenFile(it, null) }
283 else -> DirectoryListing(
285 onOpenDir = vm::open,
287 onOpenFile = { onOpenFile(it, null) },
288 onOpenInChat = { path ->
289 container.setPendingShare("@$path")
290 container.requestNewSession()
294 // Slim progress bar for an in-flight search; the results list stays visible
295 // underneath so incremental typing doesn't flash an empty screen each keystroke.
296 if (state.searching) {
297 LinearProgressIndicator(
299 .align(Alignment.TopCenter)
301 .semantics { contentDescription = loadingLabel },
311private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
312 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
313 val scrollState = rememberScrollState()
314 // Scroll to the deepest segment when the path changes so the current directory stays in
315 // view instead of hiding off the right edge behind the earlier segments on a deep path.
316 LaunchedEffect(path) { scrollState.animateScrollTo(scrollState.maxValue) }
317 // Haptic on breadcrumb navigation for consistency with the session cards / message actions,
318 // which all fire TextHandleMove on tap.
319 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
320 val onSegmentClick: (String) -> Unit = { target ->
321 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
326 .horizontalScroll(scrollState)
327 .padding(horizontal = 16.dp, vertical = 8.dp),
328 verticalAlignment = Alignment.CenterVertically,
330 IconButton(onClick = { onSegmentClick("") }) {
331 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
334 segments.forEachIndexed { index, segment ->
335 acc = if (acc.isEmpty()) segment else "$acc/$segment"
337 Icons.Filled.ChevronRight,
338 contentDescription = null,
339 modifier = Modifier.size(16.dp),
340 tint = MaterialTheme.colorScheme.onSurfaceVariant,
343 // Cap each segment's width so a single long folder name (e.g. a lengthy monorepo
344 // directory) doesn't push its siblings and the current folder off the screeen.
345 // The deepest segment gets a larger cap since it's the one the user navigated to
346 // and the auto-scroll keeps it in view.
347 val segMaxWidth = if (index == segments.lastIndex) 220.dp else 120.dp
350 style = MaterialTheme.typography.bodyMedium,
351 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
353 overflow = TextOverflow.Ellipsis,
355 .widthIn(max = segMaxWidth)
356 .clickable(role = Role.Button) { onSegmentClick(target) }
357 .defaultMinSize(minHeight = 48.dp)
358 .padding(horizontal = 6.dp, vertical = 14.dp),
365private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
366 val context = LocalContext.current
367 if (results.isEmpty()) {
369 icon = Icons.Filled.Search,
370 message = stringResource(R.string.no_matches),
371 modifier = Modifier.fillMaxWidth().padding(24.dp),
375 LazyColumn(modifier = Modifier.fillMaxSize()) {
376 // Key on path + index (not the bare path): a duplicate path from findFiles would
377 // otherwise crash LazyColumn with "Key already used". Mirrors TextResults/SymbolResults.
378 itemsIndexed(results, key = { i, p -> "$p:$i" }) { _, path ->
379 // Split the path into directory (muted) + filename (emphasized) so results
380 // are scannable instead of a wall of identical-looking full paths.
381 val dir = path.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
382 val name = path.substringAfterLast('/')
386 sublabel = dir.takeIf { it.isNotEmpty() },
387 onClick = { onOpenFile(path) },
389 copyToClipboard(context, context.getString(R.string.clip_label_path), path)
391 modifier = reducedMotionAnimateItem(),
398@OptIn(ExperimentalMaterial3Api::class)
400private fun SearchModeChip(label: String, selected: Boolean, onClick: () -> Unit) {
401 FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
404/** Empty-state message for a content/symbol search: a "type to search" hint before anything
405 * is typed, nothing while a search is in flight (the top progress bar covers that, so the
406 * first keystroke doesn't flash a false "No matches"), and "No matches" only once a real
407 * query genuinely returned nothing. */
409private fun searchEmptyMessage(searching: Boolean, queryBlank: Boolean, hint: Int): String? = when {
411 queryBlank -> stringResource(hint)
412 else -> stringResource(R.string.no_matches)
415/** Content (ripgrep) search results: file + line number, with the matched line highlighted. */
417private fun TextResults(results: List<FindMatch>, emptyMessage: String?, onOpen: (String, Int?) -> Unit) {
418 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
419 if (results.isEmpty()) {
420 if (emptyMessage != null) {
422 icon = Icons.Filled.Search,
423 message = emptyMessage,
424 modifier = Modifier.fillMaxWidth().padding(24.dp),
429 LazyColumn(modifier = Modifier.fillMaxSize()) {
430 item(key = "__count") {
432 pluralStringResource(R.plurals.file_search_results, results.size, results.size),
433 style = MaterialTheme.typography.labelSmall,
434 color = MaterialTheme.colorScheme.onSurfaceVariant,
435 modifier = Modifier.fillMaxWidth()
436 .padding(horizontal = 16.dp, vertical = 6.dp)
437 .semantics { heading() },
440 itemsIndexed(results, key = { i, m -> "${m.filePath}:${m.lineNumber}:$i" }) { _, match ->
441 val name = match.filePath.substringAfterLast('/')
442 val dir = match.filePath.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
443 val highlighted = remember(match) { highlightMatchLine(match) }
447 .defaultMinSize(minHeight = 48.dp)
448 .clickable(role = Role.Button) {
449 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
450 onOpen(match.filePath, match.lineNumber)
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 ":${match.lineNumber}",
464 style = MaterialTheme.typography.labelSmall,
465 color = MaterialTheme.colorScheme.onSurfaceVariant,
466 fontFamily = FontFamily.Monospace,
467 modifier = Modifier.padding(start = 4.dp),
470 if (dir.isNotEmpty()) {
473 style = MaterialTheme.typography.labelSmall,
474 color = MaterialTheme.colorScheme.onSurfaceVariant,
476 overflow = TextOverflow.Ellipsis,
481 style = MaterialTheme.typography.bodySmall,
482 fontFamily = FontFamily.Monospace,
484 overflow = TextOverflow.Ellipsis,
485 modifier = Modifier.padding(top = 2.dp),
493/** Workspace symbol results: symbol name + kind, and the file:line it's defined at. */
495private fun SymbolResults(results: List<SymbolResult>, emptyMessage: String?, onOpen: (String, Int?) -> Unit) {
496 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
497 if (results.isEmpty()) {
498 if (emptyMessage != null) {
500 icon = Icons.Filled.Search,
501 message = emptyMessage,
502 modifier = Modifier.fillMaxWidth().padding(24.dp),
507 LazyColumn(modifier = Modifier.fillMaxSize()) {
508 item(key = "__count") {
510 pluralStringResource(R.plurals.file_search_results, results.size, results.size),
511 style = MaterialTheme.typography.labelSmall,
512 color = MaterialTheme.colorScheme.onSurfaceVariant,
513 modifier = Modifier.fillMaxWidth()
514 .padding(horizontal = 16.dp, vertical = 6.dp)
515 .semantics { heading() },
518 itemsIndexed(results, key = { i, s -> "${s.filePath}:${s.name}:$i" }) { _, symbol ->
519 val fileName = symbol.filePath.substringAfterLast('/')
523 .defaultMinSize(minHeight = 48.dp)
524 .clickable(role = Role.Button) {
525 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
526 onOpen(symbol.filePath, symbol.displayLine)
528 .padding(horizontal = 16.dp, vertical = 10.dp),
530 Row(verticalAlignment = Alignment.CenterVertically) {
533 style = MaterialTheme.typography.bodyMedium,
535 overflow = TextOverflow.Ellipsis,
536 modifier = Modifier.weight(1f, fill = false),
539 symbolKindLabel(symbol.kind),
540 style = MaterialTheme.typography.labelSmall,
541 color = MaterialTheme.colorScheme.primary,
542 modifier = Modifier.padding(start = 8.dp),
546 "$fileName:${symbol.displayLine}",
547 style = MaterialTheme.typography.labelSmall,
548 color = MaterialTheme.colorScheme.onSurfaceVariant,
549 fontFamily = FontFamily.Monospace,
551 overflow = TextOverflow.Ellipsis,
559/** Build the matched line with each ripgrep submatch bolded. Offsets are byte columns
560 * within the line; clamped to the (trimmed) line length so a multibyte line can't crash. */
561private fun highlightMatchLine(match: FindMatch): androidx.compose.ui.text.AnnotatedString {
562 val line = match.lineText
563 return buildAnnotatedString {
565 for (sub in match.submatches.sortedBy { it.start }) {
566 val start = sub.start.coerceIn(0, line.length)
567 val end = sub.end.coerceIn(start, line.length)
568 if (start > cursor) append(line.substring(cursor, start))
569 withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(line.substring(start, end)) }
572 if (cursor < line.length) append(line.substring(cursor))
576@Suppress("CyclomaticComplexMethod")
578private fun DirectoryListing(
579 state: FileBrowserState,
580 onOpenDir: (String) -> Unit,
582 onOpenFile: (String) -> Unit,
583 onOpenInChat: ((String) -> Unit)? = null,
585 val context = LocalContext.current
586 if (state.entries.isEmpty()) {
588 icon = Icons.Filled.Folder,
589 message = stringResource(R.string.empty_folder),
590 modifier = Modifier.fillMaxWidth().padding(24.dp),
594 // Sort preference for the directory listing. Folders-first + name sort is the most useful
595 // default; the toggle flips between folders-first name sort and the server's raw order.
596 var foldersFirst by rememberSaveable { mutableStateOf(true) }
597 // Hidden-files preference. The server may already include dotfiles in its listing; this
598 // hides them by default (the common case for browsing a repo) and lets the user reveal
599 // .env / .git / .opencode on demand. Default off so the listing isn't cluttered.
600 var showHidden by rememberSaveable { mutableStateOf(false) }
601 // Sort key for files within the listing. Name is the default (and the only axis the server
602 // reliably supports); Size/Modified appear only when the server emits size/mtime (otherwise
603 // those sorts are no-ops on null fields), gated behind a menu so they don't clutter the bar.
604 var sortKey by rememberSaveable { mutableStateOf(FileSortKey.NAME) }
605 var sortDesc by rememberSaveable { mutableStateOf(false) }
606 val visible = remember(state.entries, showHidden) {
607 if (showHidden) state.entries else state.entries.filterNot { it.name.startsWith(".") }
609 val sorted = remember(visible, foldersFirst, sortKey, sortDesc) {
610 // Null size/mtime coerce to values that sink the unknown entries to the bottom in the
611 // default (ascending) view, so a partially-populated listing keeps sized/dated files on top.
612 val comparator = when (sortKey) {
613 FileSortKey.NAME -> compareBy<FileNode> { it.name.lowercase() }
614 FileSortKey.SIZE -> compareBy<FileNode> { it.size ?: Long.MAX_VALUE }
615 FileSortKey.MODIFIED -> compareBy<FileNode> { it.mtime ?: 0L }
616 }.let { if (sortDesc) it.reversed() else it }
618 val (dirs, files) = visible.partition { it.isDirectory }
619 // Directories always sort by name (size/date on a folder is rarely meaningful);
620 // the chosen key applies to the files within.
621 dirs.sortedBy { it.name.lowercase() } + files.sortedWith(comparator)
623 visible.sortedWith(comparator)
626 // Windowed render: compose only the first `renderCap` rows, growing as the user
627 // scrolls near the bottom, so a directory with thousands of entries doesn't
628 // compose every row on the first frame (the sort still runs over the full list,
629 // so the order is correct — only the composed slice is capped). Mirrors the
630 // session list's windowed render pattern. Hoisted above the LazyColumn because
631 // state reads must be in a @Composable scope (LazyListScope is not one).
632 var renderCap by remember { mutableStateOf(NetworkConfig.fileListInitialPage) }
633 val visibleSorted = remember(sorted, renderCap) {
634 if (sorted.size <= renderCap) sorted else sorted.take(renderCap)
636 LazyColumn(modifier = Modifier.fillMaxSize()) {
637 if (state.path.isNotBlank()) {
639 FileRow(icon = true, label = "..", onClick = onUp)
643 item(key = "__sort") {
645 modifier = Modifier.fillMaxWidth()
646 .horizontalScroll(rememberScrollState())
647 .padding(end = 8.dp, top = 2.dp, bottom = 2.dp),
648 horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
649 verticalAlignment = Alignment.CenterVertically,
651 // rememberSaveable so an open sort dropdown survives a rotation instead of
652 // closing and leaving the user to re-open it.
653 var showSortMenu by rememberSaveable { mutableStateOf(false) }
654 // Close the sort dropdown on back press instead of navigating away, matching
655 // the session/server list dropdown back-handling.
656 BackHandler(enabled = showSortMenu) { showSortMenu = false }
657 androidx.compose.material3.FilterChip(
658 selected = sortKey != FileSortKey.NAME || sortDesc,
659 onClick = { showSortMenu = true },
661 Icon(Icons.Filled.SortByAlpha, contentDescription = null, modifier = Modifier.size(16.dp))
663 label = { Text(stringResource(R.string.sort_by), style = MaterialTheme.typography.labelSmall) },
665 DropdownMenu(expanded = showSortMenu, onDismissRequest = { showSortMenu = false }) {
666 val labels = remember {
668 FileSortKey.NAME to R.string.sort_name,
669 FileSortKey.SIZE to R.string.sort_size,
670 FileSortKey.MODIFIED to R.string.sort_modified,
673 FileSortKey.entries.forEach { key ->
676 Row(verticalAlignment = Alignment.CenterVertically) {
677 Text(stringResource(labels[key]!!), modifier = Modifier.weight(1f))
678 if (sortKey == key) {
680 if (sortDesc) Icons.Filled.KeyboardArrowDown else Icons.Filled.KeyboardArrowUp,
681 contentDescription = null,
682 modifier = Modifier.size(18.dp),
688 // Tapping the active key flips the direction; tapping a different
689 // key switches to it (ascending by default).
690 if (sortKey == key) sortDesc = !sortDesc else { sortKey = key; sortDesc = false }
696 androidx.compose.material3.FilterChip(
697 selected = showHidden,
698 onClick = { showHidden = !showHidden },
699 label = { Text(stringResource(R.string.show_hidden), style = MaterialTheme.typography.labelSmall) },
701 androidx.compose.material3.FilterChip(
702 selected = foldersFirst,
703 onClick = { foldersFirst = !foldersFirst },
706 Icon(Icons.Filled.Folder, contentDescription = null, modifier = Modifier.size(16.dp))
709 label = { Text(stringResource(R.string.folders_first), style = MaterialTheme.typography.labelSmall) },
713 // Windowed render: compose only the first `renderCap` rows, growing as the user
714 // scrolls near the bottom, so a directory with thousands of entries doesn't
715 // compose every row on the first frame (the sort still runs over the full list,
716 // so the order is correct — only the composed slice is capped). Mirrors the
717 // session list's windowed render pattern.
718 items(visibleSorted, key = { it.path + "_" + it.name }) { node ->
720 icon = node.isDirectory,
722 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
724 copyToClipboard(context, context.getString(R.string.clip_label_path), node.path)
726 onOpenInChat = onOpenInChat?.let { cb -> { cb(node.path) } },
729 status = state.statusMap[node.path],
730 modifier = reducedMotionAnimateItem(),
734 // Grow the render window as the user nears the last visible row.
735 if (sorted.size > visibleSorted.size) {
736 item(key = "__load_more") {
737 LaunchedEffect(sorted.size, visibleSorted.size) {
738 renderCap = (renderCap + NetworkConfig.fileListPageStep).coerceAtMost(sorted.size)
745/** Shared empty-state for the file browser. Delegates to the canonical [EmptyState] so every
746 * screen's "nothing here" reads and behaves identically (icon size, spacing, styling). */
748private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector, message: String, modifier: Modifier = Modifier) {
749 EmptyState(icon = icon, title = message, modifier = modifier)
752@OptIn(ExperimentalFoundationApi::class)
754@Suppress("CyclomaticComplexMethod")
759 status: FileStatusEntry? = null,
760 sublabel: String? = null,
763 onCopyPath: (() -> Unit)? = null,
764 onOpenInChat: (() -> Unit)? = null,
765 modifier: Modifier = Modifier,
767 val context = LocalContext.current
768 val copyPathLabel = stringResource(R.string.copy_path)
769 // Haptic on row open for consistency with the breadcrumb taps and other screens.
770 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
771 val onRowClick: () -> Unit = {
772 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
775 // Long-press → "Copy path" dropdown. Rendered only when [onCopyPath] is supplied, so the
776 // parent ".." row (which passes none) stays a plain tap target. Anchored within the row.
777 // rememberSaveable so an open row menu survives a rotation instead of closing.
778 var menu by rememberSaveable { mutableStateOf(false) }
779 // Close this row's overflow on back press instead of navigating away, matching the
780 // sort menu BackHandler above.
781 BackHandler(enabled = menu) { menu = false }
782 val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
783 else if (icon) stringResource(R.string.folder, label)
784 else stringResource(R.string.file_label, label)
785 // Build a combined description so TalkBack announces both the file name and its git
786 // status. The parent sets an explicit contentDescription (mergeDescendants), which
787 // would otherwise suppress the StatusBadge's own description.
788 val statusDesc = status?.let {
790 "added" -> stringResource(R.string.git_added)
791 "modified" -> stringResource(R.string.git_modified)
792 "deleted" -> stringResource(R.string.git_deleted)
796 val fullDesc = if (statusDesc.isNotEmpty()) "$fileDesc, $statusDesc" else fileDesc
800 .defaultMinSize(minHeight = 48.dp)
803 onClick = onRowClick,
804 onLongClick = onCopyPath?.let { { menu = true } },
805 onLongClickLabel = onCopyPath?.let { copyPathLabel },
807 .semantics(mergeDescendants = true) { contentDescription = fullDesc }
808 .padding(horizontal = 16.dp, vertical = 14.dp),
809 verticalAlignment = Alignment.CenterVertically,
812 if (icon) Icons.Filled.Folder else iconForFile(label),
813 contentDescription = null,
814 modifier = Modifier.size(20.dp),
815 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
817 Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
820 style = MaterialTheme.typography.bodyLarge,
822 overflow = TextOverflow.Ellipsis,
824 // Secondary line: a caller-supplied sublabel (directory in search results) and/or
825 // the file's size + last-modified time when the server provides them. Combines the
826 // fragments so a single muted line carries all metadata instead of stacking rows.
827 val detailParts = buildList {
828 if (sublabel != null) add(sublabel)
830 size?.let { add(formatFileSize(it)) }
831 mtime?.let { add(relativeTime(context, it)) }
834 if (detailParts.isNotEmpty()) {
836 detailParts.joinToString(" · "),
837 style = MaterialTheme.typography.labelSmall,
838 color = MaterialTheme.colorScheme.onSurfaceVariant,
840 overflow = TextOverflow.Ellipsis,
844 if (status != null) StatusBadge(status)
845 // Explicit overflow button so "Copy path" is reachable without a long-press (which
846 // isn't available to TalkBack/keyboard users or anyone who can't hold the gesture).
847 // Rendered only when the row supplies an onCopyPath (the parent ".." row does not).
848 if (onCopyPath != null) {
849 IconButton(onClick = { menu = true }) {
850 Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options))
853 DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
855 leadingIcon = { Icon(Icons.Filled.Link, contentDescription = null) },
856 text = { Text(copyPathLabel) },
857 onClick = { menu = false; onCopyPath?.invoke() },
859 // "Open in chat": attach this file's path to a new chat prompt so the user
860 // can ask the agent about a file they were just browsing. Only for files
861 // (not directories) and when the callback is wired.
862 if (onOpenInChat != null && !icon) {
864 leadingIcon = { Icon(Icons.Filled.ChatBubbleOutline, contentDescription = null) },
865 text = { Text(stringResource(R.string.open_in_chat)) },
866 onClick = { menu = false; onOpenInChat.invoke() },
874private fun StatusBadge(status: FileStatusEntry) {
875 val (letter, color) = when (status.status) {
876 "added" -> "A" to MaterialTheme.colorScheme.primary
877 "modified" -> "M" to MaterialTheme.colorScheme.tertiary
878 "deleted" -> "D" to MaterialTheme.colorScheme.error
879 else -> "·" to MaterialTheme.colorScheme.onSurfaceVariant
882 verticalAlignment = Alignment.CenterVertically,
886 style = MaterialTheme.typography.labelMedium,
887 fontFamily = FontFamily.Monospace,
890 if (status.added > 0 || status.removed > 0) {
892 "+${status.added} −${status.removed}",
893 style = MaterialTheme.typography.labelSmall,
894 fontFamily = FontFamily.Monospace,
895 color = MaterialTheme.colorScheme.onSurfaceVariant,
896 modifier = Modifier.padding(start = 4.dp),
902/** Sort axis for the directory listing. Name is always available; Size/Modified depend on the
903 * server emitting size/mtime (otherwise they sort nulls to the bottom). */
904private enum class FileSortKey { NAME, SIZE, MODIFIED }
906/** Format a byte count as a compact human-readable size (e.g. 1.2 KB, 3.4 MB). Uses 1024-based
907 * units with one decimal place, matching how file managers render sizes. */
908private fun formatFileSize(bytes: Long): String {
909 if (bytes < 1024) return "$bytes B"
910 val units = arrayOf("KB", "MB", "GB", "TB")
911 var value = bytes.toDouble() / 1024.0
913 while (value >= 1024 && unit < units.lastIndex) {
917 val formatted = if (value >= 100) "%.0f".format(value) else "%.1f".format(value)
918 return "$formatted ${units[unit]}"
921/** Pick an icon for a file by its extension so the directory listing is scannable instead of a
922 * wall of identical `Description` glyphs: images, source code, and Markdown/article files each
923 * get a distinct glyph; unknown extensions fall back to the generic document icon. */
924private fun iconForFile(name: String): androidx.compose.ui.graphics.vector.ImageVector {
925 val ext = name.substringAfterLast('.', missingDelimiterValue = "").lowercase()
927 "png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico", "heic" -> Icons.Filled.Image
928 "kt", "kts", "java", "js", "ts", "jsx", "tsx", "py", "rb", "go", "rs", "c", "cpp", "h",
929 "hpp", "cs", "swift", "scala", "gradle", "groovy", "sh", "bash", "zsh", "fish", "ps1",
930 "lua", "r", "dart", "php", "pl", "sql", "vue", "svelte" -> Icons.Filled.Code
931 "md", "markdown", "txt", "rst", "adoc", "rtf", "doc", "docx", "pdf", "epub" -> Icons.Filled.Article
932 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