1package soy.iko.opencode.ui.file
3import androidx.activity.compose.BackHandler
4import androidx.compose.animation.Crossfade
5import androidx.compose.animation.core.tween
6import androidx.compose.foundation.ExperimentalFoundationApi
7import androidx.compose.foundation.clickable
8import androidx.compose.foundation.combinedClickable
9import androidx.compose.foundation.horizontalScroll
10import androidx.compose.foundation.layout.Arrangement
11import androidx.compose.foundation.layout.Box
12import androidx.compose.foundation.layout.Column
13import androidx.compose.foundation.layout.Row
14import androidx.compose.foundation.layout.Spacer
15import androidx.compose.foundation.layout.defaultMinSize
16import androidx.compose.foundation.layout.fillMaxSize
17import androidx.compose.foundation.layout.imePadding
18import androidx.compose.foundation.layout.fillMaxWidth
19import androidx.compose.foundation.layout.padding
20import androidx.compose.foundation.layout.size
21import androidx.compose.foundation.layout.widthIn
22import androidx.compose.foundation.layout.wrapContentWidth
23import androidx.compose.foundation.lazy.LazyColumn
24import androidx.compose.foundation.lazy.items
25import androidx.compose.foundation.lazy.itemsIndexed
26import androidx.compose.foundation.rememberScrollState
27import androidx.compose.foundation.text.KeyboardActions
28import androidx.compose.foundation.text.KeyboardOptions
29import androidx.compose.material.icons.Icons
30import androidx.compose.material.icons.filled.ChatBubbleOutline
31import androidx.compose.material.icons.filled.ChevronRight
32import androidx.compose.material.icons.filled.Close
33import androidx.compose.material.icons.filled.Description
34import androidx.compose.material.icons.filled.ErrorOutline
35import androidx.compose.material.icons.filled.Folder
36import androidx.compose.material.icons.filled.Home
37import androidx.compose.material.icons.filled.Image
38import androidx.compose.material.icons.filled.Lock
39import androidx.compose.material.icons.filled.Code
40import androidx.compose.material.icons.filled.Article
41import androidx.compose.material.icons.filled.KeyboardArrowDown
42import androidx.compose.material.icons.filled.KeyboardArrowUp
43import androidx.compose.material.icons.filled.Link
44import androidx.compose.material.icons.filled.MoreVert
45import androidx.compose.material.icons.filled.Search
46import androidx.compose.material.icons.filled.SortByAlpha
47import androidx.compose.material3.DropdownMenu
48import androidx.compose.material3.DropdownMenuItem
49import androidx.compose.material3.ExperimentalMaterial3Api
50import androidx.compose.material3.FilterChip
51import androidx.compose.material3.HorizontalDivider
52import androidx.compose.material3.Icon
53import androidx.compose.material3.IconButton
54import androidx.compose.material3.LinearProgressIndicator
55import androidx.compose.material3.MaterialTheme
56import androidx.compose.material3.OutlinedTextField
57import androidx.compose.material3.Scaffold
58import androidx.compose.material3.SnackbarHost
59import androidx.compose.material3.SnackbarHostState
60import androidx.compose.material3.Text
61import androidx.compose.runtime.Composable
62import androidx.compose.runtime.LaunchedEffect
63import androidx.compose.runtime.getValue
64import androidx.compose.runtime.mutableStateOf
65import androidx.compose.runtime.remember
66import androidx.compose.runtime.rememberCoroutineScope
67import androidx.compose.runtime.saveable.rememberSaveable
68import androidx.compose.runtime.setValue
69import androidx.compose.ui.Alignment
70import androidx.compose.ui.Modifier
71import androidx.compose.ui.graphics.Color
72import androidx.compose.ui.graphics.graphicsLayer
73import androidx.compose.ui.platform.LocalContext
74import androidx.compose.ui.platform.LocalSoftwareKeyboardController
75import androidx.compose.ui.platform.testTag
76import androidx.compose.ui.semantics.Role
77import androidx.compose.ui.semantics.contentDescription
78import androidx.compose.ui.semantics.heading
79import androidx.compose.ui.semantics.role
80import androidx.compose.ui.semantics.semantics
81import androidx.compose.ui.text.SpanStyle
82import androidx.compose.ui.text.buildAnnotatedString
83import androidx.compose.ui.text.font.FontFamily
84import androidx.compose.ui.text.font.FontWeight
85import androidx.compose.ui.text.input.ImeAction
86import androidx.compose.ui.text.style.TextOverflow
87import androidx.compose.ui.text.withStyle
88import androidx.compose.ui.unit.dp
89import androidx.compose.ui.res.pluralStringResource
90import androidx.compose.ui.res.stringResource
91import androidx.lifecycle.compose.collectAsStateWithLifecycle
92import androidx.lifecycle.viewmodel.compose.viewModel
93import soy.iko.opencode.data.model.FileNode
94import soy.iko.opencode.data.model.FileStatusEntry
95import soy.iko.opencode.data.model.FindMatch
96import soy.iko.opencode.data.model.SymbolResult
97import soy.iko.opencode.data.model.symbolKindLabel
98import soy.iko.opencode.data.network.NetworkConfig
99import soy.iko.opencode.data.repo.FileSortKey
100import soy.iko.opencode.di.AppContainer
101import soy.iko.opencode.R
102import soy.iko.opencode.ui.components.AppTopBar
103import soy.iko.opencode.ui.components.ConnectionBannerFor
104import soy.iko.opencode.ui.components.EmptyState
105import soy.iko.opencode.ui.components.SkeletonRow
106import soy.iko.opencode.ui.components.reducedMotionAnimateItem
107import soy.iko.opencode.ui.components.copyToClipboard
108import soy.iko.opencode.ui.components.relativeTime
109import soy.iko.opencode.ui.vmFactory
110import soy.iko.opencode.util.runCatchingCancellable
111import kotlinx.coroutines.launch
113@OptIn(ExperimentalMaterial3Api::class)
115fun FileBrowserScreen(
116 container: AppContainer,
117 onOpenFile: (path: String, line: Int?) -> Unit,
120 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
121 val state by vm.state.collectAsStateWithLifecycle()
122 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
123 val snackbar = remember { SnackbarHostState() }
125 // System-back navigates up the directory tree while inside a subfolder (matching every
126 // other file manager's muscle memory), only exiting the screen at the root. Uses
127 // PredictiveBackHandler so the Android 14 predictive-back gesture animates as the user
128 // swipes — a plain BackHandler consumes the gesture without visual feedback, which reads
129 // as "nothing happened" mid-swipe. The handler navigates up on completion; the progress
130 // is used to drive a subtle scale on the content so the gesture feels connected.
131 val backProgress = remember { androidx.compose.runtime.mutableFloatStateOf(0f) }
132 androidx.activity.compose.PredictiveBackHandler(
133 enabled = state.path.isNotBlank(),
136 flow.collect { event ->
137 backProgress.floatValue = event.progress
139 // Gesture completed: navigate up.
142 backProgress.floatValue = 0f
146 LaunchedEffect(Unit) {
147 vm.transientErrors.collect { msg ->
148 snackbar.showSnackbar(msg)
154 AppTopBar(title = stringResource(R.string.files), onBack = onBack)
156 snackbarHost = { SnackbarHost(snackbar) },
158 // Apply a subtle scale driven by the predictive-back progress so the up-navigation
159 // gesture feels connected to the content (the content shrinks slightly as the user
160 // swipes, then snaps back if cancelled or navigates up if completed).
161 val scale = 1f - (backProgress.floatValue * 0.05f)
165 .wrapContentWidth(Alignment.CenterHorizontally)
166 .widthIn(max = soy.iko.opencode.data.network.NetworkConfig.listContentMaxWidthDp.dp)
169 .graphicsLayer { scaleX = scale; scaleY = scale },
171 // Surface SSE connection state so a dropped stream is visible while browsing
172 // files, not just on the chat/session screens. Wrapped in a Box because
173 // ConnectionBannerFor uses BoxScope.align(TopCenter) for the banner's placement.
174 Box(modifier = Modifier.fillMaxWidth()) {
175 ConnectionBannerFor(container)
177 // Tappable breadcrumb trail so deep paths are navigable.
180 onNavigate = vm::open,
181 modifier = Modifier.fillMaxWidth(),
183 val keyboardController = LocalSoftwareKeyboardController.current
184 // Don't auto-focus the search field on entry: doing so raises the IME keyboard
185 // over the directory listing (the other primary function of this screen) and
186 // forces a browse-first user to dismiss it. The session/server lists don't
187 // auto-focus either, so this is also a consistency fix. The field is still
188 // prominent at the top and a tap focuses it as expected.
191 onValueChange = vm::setQuery,
194 .padding(start = 12.dp, end = 12.dp, top = 12.dp)
195 .testTag("file_search"),
200 SearchMode.FILES -> R.string.search_files
201 SearchMode.TEXT -> R.string.search_in_files
202 SearchMode.SYMBOL -> R.string.search_symbols_label
207 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
208 trailingIcon = if (state.query.isNotEmpty()) {
210 IconButton(onClick = { vm.setQuery("") }) {
211 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
216 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
217 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
219 // Mode selector: file names / contents / symbols.
221 modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp),
222 horizontalArrangement = Arrangement.spacedBy(8.dp),
224 SearchModeChip(stringResource(R.string.search_mode_files), state.mode == SearchMode.FILES) { vm.setMode(SearchMode.FILES) }
225 SearchModeChip(stringResource(R.string.search_mode_text), state.mode == SearchMode.TEXT) { vm.setMode(SearchMode.TEXT) }
226 SearchModeChip(stringResource(R.string.search_mode_symbols), state.mode == SearchMode.SYMBOL) { vm.setMode(SearchMode.SYMBOL) }
228 Spacer(Modifier.size(8.dp))
230 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
231 isRefreshing = refreshing,
232 onRefresh = { vm.refresh() },
233 modifier = Modifier.fillMaxSize(),
235 Box(modifier = Modifier.fillMaxSize()) {
236 val loadingLabel = stringResource(R.string.loading)
237 // Crossfade between content states so transitions read as a smooth fade
238 // instead of an instant snap. Matches the session list's Crossfade pattern;
239 // reduced motion is honored by Crossfade's default spec.
240 val stateKey = fileBrowserStateKey(state)
241 @Suppress("UnusedCrossfadeTargetStateParameter")
243 targetState = stateKey,
244 animationSpec = tween(NetworkConfig.motionFadeDurationMs.toInt()),
245 label = "file_browser_state",
248 // Skeleton loader for the initial directory load. An in-flight search
249 // keeps the previous results visible (a slim top bar below shows the
250 // progress) instead of blanking the list on every keystroke.
251 state.loading -> Column(
252 modifier = Modifier.fillMaxSize().padding(16.dp),
253 verticalArrangement = Arrangement.spacedBy(12.dp),
255 repeat(6) { SkeletonRow() }
257 state.error != null -> {
258 // Detect a permission-denied error (the server returns a 403/EACCES
259 // when the workspace directory isn't readable) and show a specific
260 // message + icon instead of the generic error, so the user
261 // understands the cause is access, not a network failure.
262 val err = state.error
263 val isPermissionDenied = err != null && (
264 err.contains("permission", ignoreCase = true) ||
265 err.contains("denied", ignoreCase = true) ||
266 err.contains("403", ignoreCase = true)
268 val errorIcon = if (isPermissionDenied) Icons.Filled.Lock else Icons.Filled.ErrorOutline
269 val errorText = if (isPermissionDenied) {
270 stringResource(R.string.permission_denied_files)
275 modifier = Modifier.align(Alignment.Center).padding(24.dp),
276 horizontalAlignment = Alignment.CenterHorizontally,
280 contentDescription = null,
281 tint = MaterialTheme.colorScheme.error,
282 modifier = Modifier.size(56.dp),
284 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
287 color = MaterialTheme.colorScheme.error,
289 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
290 // Retry the operation that actually failed: re-run the search
291 // when one was active (open() would reset the query and drop the
292 // user back into the directory listing), otherwise reload the dir.
293 androidx.compose.material3.TextButton(
294 onClick = { if (state.isSearching) vm.setQuery(state.query) else vm.open(state.path) },
296 Text(stringResource(R.string.retry))
300 state.mode == SearchMode.TEXT ->
303 searchEmptyMessage(state.searching, state.query.isBlank(), R.string.search_contents_hint),
304 ) { path, line -> onOpenFile(path, line) }
305 state.mode == SearchMode.SYMBOL ->
308 searchEmptyMessage(state.searching, state.query.isBlank(), R.string.search_symbols_hint),
309 ) { path, line -> onOpenFile(path, line) }
310 state.isSearching -> SearchResults(state.results) { onOpenFile(it, null) }
311 else -> DirectoryListing(
312 container = container,
314 onOpenDir = vm::open,
316 onOpenFile = { onOpenFile(it, null) },
317 onOpenInChat = { path ->
318 container.setPendingShare("@$path")
319 container.requestNewSession()
324 // Slim progress bar for an in-flight search; the results list stays visible
325 // underneath so incremental typing doesn't flash an empty screen each keystroke.
326 if (state.searching) {
327 LinearProgressIndicator(
329 .align(Alignment.TopCenter)
331 .semantics { contentDescription = loadingLabel },
340/** Maps the file-browser view state to a stable string key for the Crossfade, so the screen's
341 * content-state transitions fade smoothly. Extracted from [FileBrowserScreen] to keep its
342 * cyclomatic complexity under the detekt threshold. */
343private fun fileBrowserStateKey(state: FileBrowserState): String = when {
344 state.loading -> "loading"
345 state.error != null -> "error"
346 state.mode == SearchMode.TEXT -> "text"
347 state.mode == SearchMode.SYMBOL -> "symbol"
348 state.isSearching -> "search"
353private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
354 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
355 val scrollState = rememberScrollState()
356 // Scroll to the deepest segment when the path changes so the current directory stays in
357 // view instead of hiding off the right edge behind the earlier segments on a deep path.
358 LaunchedEffect(path) { scrollState.animateScrollTo(scrollState.maxValue) }
359 // Haptic on breadcrumb navigation for consistency with the session cards / message actions,
360 // which all fire TextHandleMove on tap.
361 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
362 val onSegmentClick: (String) -> Unit = { target ->
363 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
368 .horizontalScroll(scrollState)
369 .padding(horizontal = 16.dp, vertical = 8.dp),
370 verticalAlignment = Alignment.CenterVertically,
372 IconButton(onClick = { onSegmentClick("") }) {
373 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
376 segments.forEachIndexed { index, segment ->
377 acc = if (acc.isEmpty()) segment else "$acc/$segment"
379 Icons.Filled.ChevronRight,
380 contentDescription = null,
381 modifier = Modifier.size(16.dp),
382 tint = MaterialTheme.colorScheme.onSurfaceVariant,
385 // Cap each segment's width so a single long folder name (e.g. a lengthy monorepo
386 // directory) doesn't push its siblings and the current folder off the screeen.
387 // The deepest segment gets a larger cap since it's the one the user navigated to
388 // and the auto-scroll keeps it in view.
389 val segMaxWidth = if (index == segments.lastIndex) 220.dp else 120.dp
392 style = MaterialTheme.typography.bodyMedium,
393 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
395 overflow = TextOverflow.Ellipsis,
397 .widthIn(max = segMaxWidth)
398 .clickable(role = Role.Button) { onSegmentClick(target) }
399 .defaultMinSize(minHeight = 48.dp)
400 .padding(horizontal = 6.dp, vertical = 14.dp),
407private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
408 val context = LocalContext.current
409 if (results.isEmpty()) {
411 icon = Icons.Filled.Search,
412 message = stringResource(R.string.no_matches),
413 modifier = Modifier.fillMaxWidth().padding(24.dp),
417 LazyColumn(modifier = Modifier.fillMaxSize()) {
418 // Key on path + index (not the bare path): a duplicate path from findFiles would
419 // otherwise crash LazyColumn with "Key already used". Mirrors TextResults/SymbolResults.
420 itemsIndexed(results, key = { i, p -> "$p:$i" }) { _, path ->
421 // Split the path into directory (muted) + filename (emphasized) so results
422 // are scannable instead of a wall of identical-looking full paths.
423 val dir = path.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
424 val name = path.substringAfterLast('/')
428 sublabel = dir.takeIf { it.isNotEmpty() },
429 onClick = { onOpenFile(path) },
431 copyToClipboard(context, context.getString(R.string.clip_label_path), path)
433 modifier = reducedMotionAnimateItem(),
440@OptIn(ExperimentalMaterial3Api::class)
442private fun SearchModeChip(label: String, selected: Boolean, onClick: () -> Unit) {
443 FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
446/** Empty-state message for a content/symbol search: a "type to search" hint before anything
447 * is typed, nothing while a search is in flight (the top progress bar covers that, so the
448 * first keystroke doesn't flash a false "No matches"), and "No matches" only once a real
449 * query genuinely returned nothing. */
451private fun searchEmptyMessage(searching: Boolean, queryBlank: Boolean, hint: Int): String? = when {
453 queryBlank -> stringResource(hint)
454 else -> stringResource(R.string.no_matches)
457/** Content (ripgrep) search results: file + line number, with the matched line highlighted. */
459private fun TextResults(results: List<FindMatch>, emptyMessage: String?, onOpen: (String, Int?) -> Unit) {
460 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
461 if (results.isEmpty()) {
462 if (emptyMessage != null) {
464 icon = Icons.Filled.Search,
465 message = emptyMessage,
466 modifier = Modifier.fillMaxWidth().padding(24.dp),
471 LazyColumn(modifier = Modifier.fillMaxSize()) {
472 item(key = "__count") {
474 pluralStringResource(R.plurals.file_search_results, results.size, results.size),
475 style = MaterialTheme.typography.labelSmall,
476 color = MaterialTheme.colorScheme.onSurfaceVariant,
477 modifier = Modifier.fillMaxWidth()
478 .padding(horizontal = 16.dp, vertical = 6.dp)
479 .semantics { heading() },
482 itemsIndexed(results, key = { i, m -> "${m.filePath}:${m.lineNumber}:$i" }) { _, match ->
483 val name = match.filePath.substringAfterLast('/')
484 val dir = match.filePath.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
485 val highlighted = remember(match) { highlightMatchLine(match) }
489 .defaultMinSize(minHeight = 48.dp)
490 .clickable(role = Role.Button) {
491 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
492 onOpen(match.filePath, match.lineNumber)
494 .padding(horizontal = 16.dp, vertical = 10.dp),
496 Row(verticalAlignment = Alignment.CenterVertically) {
499 style = MaterialTheme.typography.bodyMedium,
501 overflow = TextOverflow.Ellipsis,
502 modifier = Modifier.weight(1f, fill = false),
505 ":${match.lineNumber}",
506 style = MaterialTheme.typography.labelSmall,
507 color = MaterialTheme.colorScheme.onSurfaceVariant,
508 fontFamily = FontFamily.Monospace,
509 modifier = Modifier.padding(start = 4.dp),
512 if (dir.isNotEmpty()) {
515 style = MaterialTheme.typography.labelSmall,
516 color = MaterialTheme.colorScheme.onSurfaceVariant,
518 overflow = TextOverflow.Ellipsis,
523 style = MaterialTheme.typography.bodySmall,
524 fontFamily = FontFamily.Monospace,
526 overflow = TextOverflow.Ellipsis,
527 modifier = Modifier.padding(top = 2.dp),
535/** Workspace symbol results: symbol name + kind, and the file:line it's defined at. */
537private fun SymbolResults(results: List<SymbolResult>, emptyMessage: String?, onOpen: (String, Int?) -> Unit) {
538 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
539 if (results.isEmpty()) {
540 if (emptyMessage != null) {
542 icon = Icons.Filled.Search,
543 message = emptyMessage,
544 modifier = Modifier.fillMaxWidth().padding(24.dp),
549 LazyColumn(modifier = Modifier.fillMaxSize()) {
550 item(key = "__count") {
552 pluralStringResource(R.plurals.file_search_results, results.size, results.size),
553 style = MaterialTheme.typography.labelSmall,
554 color = MaterialTheme.colorScheme.onSurfaceVariant,
555 modifier = Modifier.fillMaxWidth()
556 .padding(horizontal = 16.dp, vertical = 6.dp)
557 .semantics { heading() },
560 itemsIndexed(results, key = { i, s -> "${s.filePath}:${s.name}:$i" }) { _, symbol ->
561 val fileName = symbol.filePath.substringAfterLast('/')
565 .defaultMinSize(minHeight = 48.dp)
566 .clickable(role = Role.Button) {
567 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
568 onOpen(symbol.filePath, symbol.displayLine)
570 .padding(horizontal = 16.dp, vertical = 10.dp),
572 Row(verticalAlignment = Alignment.CenterVertically) {
575 style = MaterialTheme.typography.bodyMedium,
577 overflow = TextOverflow.Ellipsis,
578 modifier = Modifier.weight(1f, fill = false),
581 symbolKindLabel(symbol.kind),
582 style = MaterialTheme.typography.labelSmall,
583 color = MaterialTheme.colorScheme.primary,
584 modifier = Modifier.padding(start = 8.dp),
588 "$fileName:${symbol.displayLine}",
589 style = MaterialTheme.typography.labelSmall,
590 color = MaterialTheme.colorScheme.onSurfaceVariant,
591 fontFamily = FontFamily.Monospace,
593 overflow = TextOverflow.Ellipsis,
601/** Build the matched line with each ripgrep submatch bolded. Offsets are byte columns
602 * within the line; clamped to the (trimmed) line length so a multibyte line can't crash. */
603private fun highlightMatchLine(match: FindMatch): androidx.compose.ui.text.AnnotatedString {
604 val line = match.lineText
605 return buildAnnotatedString {
607 for (sub in match.submatches.sortedBy { it.start }) {
608 val start = sub.start.coerceIn(0, line.length)
609 val end = sub.end.coerceIn(start, line.length)
610 if (start > cursor) append(line.substring(cursor, start))
611 withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(line.substring(start, end)) }
614 if (cursor < line.length) append(line.substring(cursor))
618@Suppress("CyclomaticComplexMethod")
620private fun DirectoryListing(
621 container: AppContainer,
622 state: FileBrowserState,
623 onOpenDir: (String) -> Unit,
625 onOpenFile: (String) -> Unit,
626 onOpenInChat: ((String) -> Unit)? = null,
628 val context = LocalContext.current
629 if (state.entries.isEmpty()) {
631 icon = Icons.Filled.Folder,
632 message = stringResource(R.string.empty_folder),
633 modifier = Modifier.fillMaxWidth().padding(24.dp),
637 // Sort/filter preferences are persisted via FileBrowserPrefs (DataStore) so a user who
638 // prefers size-sort or hidden-files-visible keeps it across process death, not just
639 // rotation. Defaults match the prior rememberSaveable defaults so the first-run view is
640 // unchanged: folders-first ON, hidden OFF, sort NAME ascending, changed-only OFF.
641 val prefs = container.fileBrowserPrefs
642 val scope = rememberCoroutineScope()
643 val foldersFirst by prefs.foldersFirst.collectAsStateWithLifecycle(initialValue = true)
644 val showHidden by prefs.showHidden.collectAsStateWithLifecycle(initialValue = false)
645 val sortKey by prefs.sortKey.collectAsStateWithLifecycle(initialValue = FileSortKey.NAME)
646 val sortDesc by prefs.sortDesc.collectAsStateWithLifecycle(initialValue = false)
647 val changedOnly by prefs.changedOnly.collectAsStateWithLifecycle(initialValue = false)
648 fun updateFoldersFirst(value: Boolean) {
649 scope.launch { runCatchingCancellable { prefs.setFoldersFirst(value) } }
651 fun updateShowHidden(value: Boolean) {
652 scope.launch { runCatchingCancellable { prefs.setShowHidden(value) } }
654 fun updateSortKey(key: FileSortKey) {
655 scope.launch { runCatchingCancellable { prefs.setSortKey(key) } }
657 fun updateSortDesc(value: Boolean) {
658 scope.launch { runCatchingCancellable { prefs.setSortDesc(value) } }
660 fun updateChangedOnly(value: Boolean) {
661 scope.launch { runCatchingCancellable { prefs.setChangedOnly(value) } }
663 val visible = remember(state.entries, showHidden, changedOnly, state.statusMap) {
664 val byHidden = if (showHidden) state.entries else state.entries.filterNot { it.name.startsWith(".") }
665 // "Changed only" narrows to entries with a git status badge (A/M/D). Directories are
666 // kept when the filter is OFF (so navigation isn't broken mid-review) but dropped when
667 // it's ON, since git status is per-file and a directory row would never match.
668 if (changedOnly) byHidden.filter { !it.isDirectory && state.statusMap[it.path] != null }
671 val sorted = remember(visible, foldersFirst, sortKey, sortDesc) {
672 // Null size/mtime coerce to values that sink the unknown entries to the bottom in the
673 // default (ascending) view, so a partially-populated listing keeps sized/dated files on top.
674 val comparator = when (sortKey) {
675 FileSortKey.NAME -> compareBy<FileNode> { it.name.lowercase() }
676 FileSortKey.SIZE -> compareBy<FileNode> { it.size ?: Long.MAX_VALUE }
677 FileSortKey.MODIFIED -> compareBy<FileNode> { it.mtime ?: 0L }
678 }.let { if (sortDesc) it.reversed() else it }
680 val (dirs, files) = visible.partition { it.isDirectory }
681 // Directories always sort by name (size/date on a folder is rarely meaningful);
682 // the chosen key applies to the files within.
683 dirs.sortedBy { it.name.lowercase() } + files.sortedWith(comparator)
685 visible.sortedWith(comparator)
688 // Windowed render: compose only the first `renderCap` rows, growing as the user
689 // scrolls near the bottom, so a directory with thousands of entries doesn't
690 // compose every row on the first frame (the sort still runs over the full list,
691 // so the order is correct — only the composed slice is capped). Mirrors the
692 // session list's windowed render pattern. Hoisted above the LazyColumn because
693 // state reads must be in a @Composable scope (LazyListScope is not one).
694 var renderCap by remember { mutableStateOf(NetworkConfig.fileListInitialPage) }
695 val visibleSorted = remember(sorted, renderCap) {
696 if (sorted.size <= renderCap) sorted else sorted.take(renderCap)
698 LazyColumn(modifier = Modifier.fillMaxSize()) {
699 // Item-count summary header so a large directory is self-describing ("137 items")
700 // mirroring the search-results count header. Uses the pre-hidden-filter count so the
701 // user sees the true directory size; hidden entries are a display filter, not a
702 // property of the folder.
703 item(key = "__count") {
704 val count = state.entries.size
706 pluralStringResource(R.plurals.file_count, count, count),
707 style = MaterialTheme.typography.labelSmall,
708 color = MaterialTheme.colorScheme.onSurfaceVariant,
709 modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp),
712 if (state.path.isNotBlank()) {
714 FileRow(icon = true, label = "..", onClick = onUp)
718 item(key = "__sort") {
720 modifier = Modifier.fillMaxWidth()
721 .horizontalScroll(rememberScrollState())
722 .padding(end = 8.dp, top = 2.dp, bottom = 2.dp),
723 horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
724 verticalAlignment = Alignment.CenterVertically,
726 // rememberSaveable so an open sort dropdown survives a rotation instead of
727 // closing and leaving the user to re-open it.
728 var showSortMenu by rememberSaveable { mutableStateOf(false) }
729 // Close the sort dropdown on back press instead of navigating away, matching
730 // the session/server list dropdown back-handling.
731 BackHandler(enabled = showSortMenu) { showSortMenu = false }
732 androidx.compose.material3.FilterChip(
733 selected = sortKey != FileSortKey.NAME || sortDesc,
734 onClick = { showSortMenu = true },
736 Icon(Icons.Filled.SortByAlpha, contentDescription = null, modifier = Modifier.size(16.dp))
738 label = { Text(stringResource(R.string.sort_by), style = MaterialTheme.typography.labelSmall) },
740 DropdownMenu(expanded = showSortMenu, onDismissRequest = { showSortMenu = false }) {
741 val labels = remember {
743 FileSortKey.NAME to R.string.sort_name,
744 FileSortKey.SIZE to R.string.sort_size,
745 FileSortKey.MODIFIED to R.string.sort_modified,
748 FileSortKey.entries.forEach { key ->
751 Row(verticalAlignment = Alignment.CenterVertically) {
752 Text(stringResource(labels[key]!!), modifier = Modifier.weight(1f))
753 if (sortKey == key) {
755 if (sortDesc) Icons.Filled.KeyboardArrowDown else Icons.Filled.KeyboardArrowUp,
756 contentDescription = null,
757 modifier = Modifier.size(18.dp),
763 // Tapping the active key flips the direction; tapping a different
764 // key switches to it (ascending by default).
765 if (sortKey == key) updateSortDesc(!sortDesc)
766 else { updateSortKey(key); updateSortDesc(false) }
772 androidx.compose.material3.FilterChip(
773 selected = showHidden,
774 onClick = { updateShowHidden(!showHidden) },
775 label = { Text(stringResource(R.string.show_hidden), style = MaterialTheme.typography.labelSmall) },
777 androidx.compose.material3.FilterChip(
778 selected = foldersFirst,
779 onClick = { updateFoldersFirst(!foldersFirst) },
782 Icon(Icons.Filled.Folder, contentDescription = null, modifier = Modifier.size(16.dp))
785 label = { Text(stringResource(R.string.folders_first), style = MaterialTheme.typography.labelSmall) },
787 androidx.compose.material3.FilterChip(
788 selected = changedOnly,
789 onClick = { updateChangedOnly(!changedOnly) },
792 Icon(Icons.Filled.Code, contentDescription = null, modifier = Modifier.size(16.dp))
795 label = { Text(stringResource(R.string.changed_only), style = MaterialTheme.typography.labelSmall) },
799 // Windowed render: compose only the first `renderCap` rows, growing as the user
800 // scrolls near the bottom, so a directory with thousands of entries doesn't
801 // compose every row on the first frame (the sort still runs over the full list,
802 // so the order is correct — only the composed slice is capped). Mirrors the
803 // session list's windowed render pattern.
804 items(visibleSorted, key = { it.path + "_" + it.name }) { node ->
806 icon = node.isDirectory,
808 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
810 copyToClipboard(context, context.getString(R.string.clip_label_path), node.path)
812 onOpenInChat = onOpenInChat?.let { cb -> { cb(node.path) } },
815 status = state.statusMap[node.path],
816 modifier = reducedMotionAnimateItem(),
820 // Grow the render window as the user nears the last visible row.
821 if (sorted.size > visibleSorted.size) {
822 item(key = "__load_more") {
823 LaunchedEffect(sorted.size, visibleSorted.size) {
824 renderCap = (renderCap + NetworkConfig.fileListPageStep).coerceAtMost(sorted.size)
831/** Shared empty-state for the file browser. Delegates to the canonical [EmptyState] so every
832 * screen's "nothing here" reads and behaves identically (icon size, spacing, styling). */
834private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector, message: String, modifier: Modifier = Modifier) {
835 EmptyState(icon = icon, title = message, modifier = modifier)
838@OptIn(ExperimentalFoundationApi::class)
840@Suppress("CyclomaticComplexMethod")
845 status: FileStatusEntry? = null,
846 sublabel: String? = null,
849 onCopyPath: (() -> Unit)? = null,
850 onOpenInChat: (() -> Unit)? = null,
851 modifier: Modifier = Modifier,
853 val context = LocalContext.current
854 val copyPathLabel = stringResource(R.string.copy_path)
855 // Haptic on row open for consistency with the breadcrumb taps and other screens.
856 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
857 val onRowClick: () -> Unit = {
858 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
861 // Long-press → "Copy path" dropdown. Rendered only when [onCopyPath] is supplied, so the
862 // parent ".." row (which passes none) stays a plain tap target. Anchored within the row.
863 // rememberSaveable so an open row menu survives a rotation instead of closing.
864 var menu by rememberSaveable { mutableStateOf(false) }
865 // Close this row's overflow on back press instead of navigating away, matching the
866 // sort menu BackHandler above.
867 BackHandler(enabled = menu) { menu = false }
868 val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
869 else if (icon) stringResource(R.string.folder, label)
870 else stringResource(R.string.file_label, label)
871 // Build a combined description so TalkBack announces both the file name and its git
872 // status. The parent sets an explicit contentDescription (mergeDescendants), which
873 // would otherwise suppress the StatusBadge's own description.
874 val statusDesc = status?.let {
876 "added" -> stringResource(R.string.git_added)
877 "modified" -> stringResource(R.string.git_modified)
878 "deleted" -> stringResource(R.string.git_deleted)
882 val fullDesc = if (statusDesc.isNotEmpty()) "$fileDesc, $statusDesc" else fileDesc
886 .defaultMinSize(minHeight = 48.dp)
889 onClick = onRowClick,
890 onLongClick = onCopyPath?.let { { menu = true } },
891 onLongClickLabel = onCopyPath?.let { copyPathLabel },
893 .semantics(mergeDescendants = true) { contentDescription = fullDesc }
894 .padding(horizontal = 16.dp, vertical = 14.dp),
895 verticalAlignment = Alignment.CenterVertically,
898 if (icon) Icons.Filled.Folder else iconForFile(label),
899 contentDescription = null,
900 modifier = Modifier.size(20.dp),
901 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
903 Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
906 style = MaterialTheme.typography.bodyLarge,
908 overflow = TextOverflow.Ellipsis,
910 // Secondary line: a caller-supplied sublabel (directory in search results) and/or
911 // the file's size + last-modified time when the server provides them. Combines the
912 // fragments so a single muted line carries all metadata instead of stacking rows.
913 val detailParts = buildList {
914 if (sublabel != null) add(sublabel)
916 size?.let { add(formatFileSize(it)) }
917 mtime?.let { add(relativeTime(context, it)) }
920 if (detailParts.isNotEmpty()) {
922 detailParts.joinToString(" · "),
923 style = MaterialTheme.typography.labelSmall,
924 color = MaterialTheme.colorScheme.onSurfaceVariant,
926 overflow = TextOverflow.Ellipsis,
930 if (status != null) StatusBadge(status)
931 // Explicit overflow button so "Copy path" is reachable without a long-press (which
932 // isn't available to TalkBack/keyboard users or anyone who can't hold the gesture).
933 // Rendered only when the row supplies an onCopyPath (the parent ".." row does not).
934 if (onCopyPath != null) {
935 IconButton(onClick = { menu = true }) {
936 Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options))
939 DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
941 leadingIcon = { Icon(Icons.Filled.Link, contentDescription = null) },
942 text = { Text(copyPathLabel) },
943 onClick = { menu = false; onCopyPath?.invoke() },
945 // "Open in chat": attach this file's path to a new chat prompt so the user
946 // can ask the agent about a file they were just browsing. Only for files
947 // (not directories) and when the callback is wired.
948 if (onOpenInChat != null && !icon) {
950 leadingIcon = { Icon(Icons.Filled.ChatBubbleOutline, contentDescription = null) },
951 text = { Text(stringResource(R.string.open_in_chat)) },
952 onClick = { menu = false; onOpenInChat.invoke() },
960private fun StatusBadge(status: FileStatusEntry) {
961 val (letter, color) = when (status.status) {
962 "added" -> "A" to MaterialTheme.colorScheme.primary
963 "modified" -> "M" to MaterialTheme.colorScheme.tertiary
964 "deleted" -> "D" to MaterialTheme.colorScheme.error
965 else -> "·" to MaterialTheme.colorScheme.onSurfaceVariant
968 verticalAlignment = Alignment.CenterVertically,
972 style = MaterialTheme.typography.labelMedium,
973 fontFamily = FontFamily.Monospace,
976 if (status.added > 0 || status.removed > 0) {
978 "+${status.added} −${status.removed}",
979 style = MaterialTheme.typography.labelSmall,
980 fontFamily = FontFamily.Monospace,
981 color = MaterialTheme.colorScheme.onSurfaceVariant,
982 modifier = Modifier.padding(start = 4.dp),
988/** Format a byte count as a compact human-readable size (e.g. 1.2 KB, 3.4 MB). Uses 1024-based
989 * units with one decimal place, matching how file managers render sizes. */
990internal fun formatFileSize(bytes: Long): String {
991 if (bytes < 1024) return "$bytes B"
992 val units = arrayOf("KB", "MB", "GB", "TB")
993 var value = bytes.toDouble() / 1024.0
995 while (value >= 1024 && unit < units.lastIndex) {
999 val formatted = if (value >= 100) "%.0f".format(value) else "%.1f".format(value)
1000 return "$formatted ${units[unit]}"
1003/** Pick an icon for a file by its extension so the directory listing is scannable instead of a
1004 * wall of identical `Description` glyphs: images, source code, and Markdown/article files each
1005 * get a distinct glyph; unknown extensions fall back to the generic document icon. */
1006private fun iconForFile(name: String): androidx.compose.ui.graphics.vector.ImageVector {
1007 val ext = name.substringAfterLast('.', missingDelimiterValue = "").lowercase()
1009 "png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico", "heic" -> Icons.Filled.Image
1010 "kt", "kts", "java", "js", "ts", "jsx", "tsx", "py", "rb", "go", "rs", "c", "cpp", "h",
1011 "hpp", "cs", "swift", "scala", "gradle", "groovy", "sh", "bash", "zsh", "fish", "ps1",
1012 "lua", "r", "dart", "php", "pl", "sql", "vue", "svelte" -> Icons.Filled.Code
1013 "md", "markdown", "txt", "rst", "adoc", "rtf", "doc", "docx", "pdf", "epub" -> Icons.Filled.Article
1014 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