git.iko.soy/opencode-android
on 366f92e help sign in
836 lines 41 kB history raw
1package soy.iko.opencode.ui.file
3import androidx.activity.compose.BackHandler
4import androidx.compose.foundation.ExperimentalFoundationApi
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.combinedClickable
7import androidx.compose.foundation.horizontalScroll
8import androidx.compose.foundation.layout.Arrangement
9import androidx.compose.foundation.layout.Box
10import androidx.compose.foundation.layout.Column
11import androidx.compose.foundation.layout.Row
12import androidx.compose.foundation.layout.Spacer
13import androidx.compose.foundation.layout.defaultMinSize
14import androidx.compose.foundation.layout.fillMaxSize
15import androidx.compose.foundation.layout.imePadding
16import androidx.compose.foundation.layout.fillMaxWidth
17import androidx.compose.foundation.layout.padding
18import androidx.compose.foundation.layout.size
19import androidx.compose.foundation.layout.widthIn
20import androidx.compose.foundation.lazy.LazyColumn
21import androidx.compose.foundation.lazy.items
22import androidx.compose.foundation.lazy.itemsIndexed
23import androidx.compose.foundation.rememberScrollState
24import androidx.compose.foundation.text.KeyboardActions
25import androidx.compose.foundation.text.KeyboardOptions
26import androidx.compose.material.icons.Icons
27import androidx.compose.material.icons.filled.ChevronRight
28import androidx.compose.material.icons.filled.Close
29import androidx.compose.material.icons.filled.Description
30import androidx.compose.material.icons.filled.ErrorOutline
31import androidx.compose.material.icons.filled.Folder
32import androidx.compose.material.icons.filled.Home
33import androidx.compose.material.icons.filled.Image
34import androidx.compose.material.icons.filled.Code
35import androidx.compose.material.icons.filled.Article
36import androidx.compose.material.icons.filled.KeyboardArrowDown
37import androidx.compose.material.icons.filled.KeyboardArrowUp
38import androidx.compose.material.icons.filled.Link
39import androidx.compose.material.icons.filled.MoreVert
40import androidx.compose.material.icons.filled.Search
41import androidx.compose.material.icons.filled.SortByAlpha
42import androidx.compose.material3.CircularProgressIndicator
43import androidx.compose.material3.DropdownMenu
44import androidx.compose.material3.DropdownMenuItem
45import androidx.compose.material3.ExperimentalMaterial3Api
46import androidx.compose.material3.FilterChip
47import androidx.compose.material3.HorizontalDivider
48import androidx.compose.material3.Icon
49import androidx.compose.material3.IconButton
50import androidx.compose.material3.LinearProgressIndicator
51import androidx.compose.material3.MaterialTheme
52import androidx.compose.material3.OutlinedTextField
53import androidx.compose.material3.Scaffold
54import androidx.compose.material3.SnackbarHost
55import androidx.compose.material3.SnackbarHostState
56import androidx.compose.material3.Text
57import androidx.compose.runtime.Composable
58import androidx.compose.runtime.LaunchedEffect
59import androidx.compose.runtime.getValue
60import androidx.compose.runtime.mutableStateOf
61import androidx.compose.runtime.remember
62import androidx.compose.runtime.saveable.rememberSaveable
63import androidx.compose.runtime.setValue
64import androidx.compose.ui.Alignment
65import androidx.compose.ui.Modifier
66import androidx.compose.ui.focus.FocusRequester
67import androidx.compose.ui.focus.focusRequester
68import androidx.compose.ui.graphics.Color
69import androidx.compose.ui.platform.LocalContext
70import androidx.compose.ui.platform.LocalSoftwareKeyboardController
71import androidx.compose.ui.platform.testTag
72import androidx.compose.ui.semantics.Role
73import androidx.compose.ui.semantics.contentDescription
74import androidx.compose.ui.semantics.heading
75import androidx.compose.ui.semantics.role
76import androidx.compose.ui.semantics.semantics
77import androidx.compose.ui.text.SpanStyle
78import androidx.compose.ui.text.buildAnnotatedString
79import androidx.compose.ui.text.font.FontFamily
80import androidx.compose.ui.text.font.FontWeight
81import androidx.compose.ui.text.input.ImeAction
82import androidx.compose.ui.text.style.TextOverflow
83import androidx.compose.ui.text.withStyle
84import androidx.compose.ui.unit.dp
85import androidx.compose.ui.res.pluralStringResource
86import androidx.compose.ui.res.stringResource
87import androidx.lifecycle.compose.collectAsStateWithLifecycle
88import androidx.lifecycle.viewmodel.compose.viewModel
89import soy.iko.opencode.data.model.FileNode
90import soy.iko.opencode.data.model.FileStatusEntry
91import soy.iko.opencode.data.model.FindMatch
92import soy.iko.opencode.data.model.SymbolResult
93import soy.iko.opencode.data.model.symbolKindLabel
94import soy.iko.opencode.di.AppContainer
95import soy.iko.opencode.R
96import soy.iko.opencode.ui.components.AppTopBar
97import soy.iko.opencode.ui.components.ConnectionBannerFor
98import soy.iko.opencode.ui.components.EmptyState
99import soy.iko.opencode.ui.components.reducedMotionAnimateItem
100import soy.iko.opencode.ui.components.copyToClipboard
101import soy.iko.opencode.ui.components.relativeTime
102import soy.iko.opencode.ui.vmFactory
103import soy.iko.opencode.util.runCatchingCancellable
105@OptIn(ExperimentalMaterial3Api::class)
106@Composable
107fun FileBrowserScreen(
108 container: AppContainer,
109 onOpenFile: (path: String, line: Int?) -> Unit,
110 onBack: () -> Unit,
111) {
112 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
113 val state by vm.state.collectAsStateWithLifecycle()
114 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
115 val snackbar = remember { SnackbarHostState() }
117 LaunchedEffect(Unit) {
118 vm.transientErrors.collect { msg ->
119 snackbar.showSnackbar(msg)
120 }
121 }
123 Scaffold(
124 topBar = {
125 AppTopBar(title = stringResource(R.string.files), onBack = onBack)
126 },
127 snackbarHost = { SnackbarHost(snackbar) },
128 ) { padding ->
129 Column(modifier = Modifier.fillMaxSize().imePadding().padding(padding)) {
130 // Surface SSE connection state so a dropped stream is visible while browsing
131 // files, not just on the chat/session screens.
132 Box(modifier = Modifier.fillMaxWidth()) {
133 ConnectionBannerFor(container)
134 }
135 // Tappable breadcrumb trail so deep paths are navigable.
136 Breadcrumbs(
137 path = state.path,
138 onNavigate = vm::open,
139 modifier = Modifier.fillMaxWidth(),
140 )
141 val keyboardController = LocalSoftwareKeyboardController.current
142 // Search is the primary reason to open this screen, so focus the field on first
143 // composition. Focus only (no forced keyboard) so it doesn't fight the breadcrumb/listing.
144 val searchFocus = remember { FocusRequester() }
145 LaunchedEffect(Unit) { runCatchingCancellable { searchFocus.requestFocus() } }
146 OutlinedTextField(
147 value = state.query,
148 onValueChange = vm::setQuery,
149 modifier = Modifier
150 .fillMaxWidth()
151 .padding(start = 12.dp, end = 12.dp, top = 12.dp)
152 .focusRequester(searchFocus)
153 .testTag("file_search"),
154 label = {
155 Text(
156 stringResource(
157 when (state.mode) {
158 SearchMode.FILES -> R.string.search_files
159 SearchMode.TEXT -> R.string.search_in_files
160 SearchMode.SYMBOL -> R.string.search_symbols_label
161 },
162 ),
163 )
164 },
165 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
166 trailingIcon = if (state.query.isNotEmpty()) {
167 {
168 IconButton(onClick = { vm.setQuery("") }) {
169 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
170 }
171 }
172 } else null,
173 singleLine = true,
174 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
175 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
176 )
177 // Mode selector: file names / contents / symbols.
178 Row(
179 modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp),
180 horizontalArrangement = Arrangement.spacedBy(8.dp),
181 ) {
182 SearchModeChip(stringResource(R.string.search_mode_files), state.mode == SearchMode.FILES) { vm.setMode(SearchMode.FILES) }
183 SearchModeChip(stringResource(R.string.search_mode_text), state.mode == SearchMode.TEXT) { vm.setMode(SearchMode.TEXT) }
184 SearchModeChip(stringResource(R.string.search_mode_symbols), state.mode == SearchMode.SYMBOL) { vm.setMode(SearchMode.SYMBOL) }
185 }
186 Spacer(Modifier.size(8.dp))
188 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
189 isRefreshing = refreshing,
190 onRefresh = { vm.refresh() },
191 modifier = Modifier.fillMaxSize(),
192 ) {
193 Box(modifier = Modifier.fillMaxSize()) {
194 val loadingLabel = stringResource(R.string.loading)
195 when {
196 // Full-screen spinner only for the initial directory load. An in-flight
197 // search keeps the previous results visible (a slim top bar below shows the
198 // progress) instead of blanking the list behind a spinner on every keystroke.
199 state.loading -> Column(
200 modifier = Modifier.align(Alignment.Center).padding(24.dp),
201 horizontalAlignment = Alignment.CenterHorizontally,
202 ) {
203 CircularProgressIndicator(
204 Modifier.semantics { contentDescription = loadingLabel },
205 )
206 Text(
207 stringResource(R.string.loading),
208 style = MaterialTheme.typography.bodyMedium,
209 color = MaterialTheme.colorScheme.onSurfaceVariant,
210 modifier = Modifier.padding(top = 8.dp),
211 )
212 }
213 state.error != null -> Column(
214 modifier = Modifier.align(Alignment.Center).padding(24.dp),
215 horizontalAlignment = Alignment.CenterHorizontally,
216 ) {
217 Icon(
218 Icons.Filled.ErrorOutline,
219 contentDescription = null,
220 tint = MaterialTheme.colorScheme.error,
221 modifier = Modifier.size(56.dp),
222 )
223 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
224 Text(
225 state.error ?: "",
226 color = MaterialTheme.colorScheme.error,
227 )
228 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
229 // Retry the operation that actually failed: re-run the search
230 // when one was active (open() would reset the query and drop the
231 // user back into the directory listing), otherwise reload the dir.
232 androidx.compose.material3.TextButton(
233 onClick = { if (state.isSearching) vm.setQuery(state.query) else vm.open(state.path) },
234 ) {
235 Text(stringResource(R.string.retry))
236 }
237 }
238 state.mode == SearchMode.TEXT ->
239 TextResults(
240 state.textResults,
241 searchEmptyMessage(state.searching, state.query.isBlank(), R.string.search_contents_hint),
242 ) { path, line -> onOpenFile(path, line) }
243 state.mode == SearchMode.SYMBOL ->
244 SymbolResults(
245 state.symbolResults,
246 searchEmptyMessage(state.searching, state.query.isBlank(), R.string.search_symbols_hint),
247 ) { path, line -> onOpenFile(path, line) }
248 state.isSearching -> SearchResults(state.results) { onOpenFile(it, null) }
249 else -> DirectoryListing(
250 state = state,
251 onOpenDir = vm::open,
252 onUp = vm::up,
253 onOpenFile = { onOpenFile(it, null) },
254 )
255 }
256 // Slim progress bar for an in-flight search; the results list stays visible
257 // underneath so incremental typing doesn't flash an empty screen each keystroke.
258 if (state.searching) {
259 LinearProgressIndicator(
260 modifier = Modifier
261 .align(Alignment.TopCenter)
262 .fillMaxWidth()
263 .semantics { contentDescription = loadingLabel },
264 )
265 }
266 }
267 }
268 }
269 }
272@Composable
273private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
274 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
275 val scrollState = rememberScrollState()
276 // Scroll to the deepest segment when the path changes so the current directory stays in
277 // view instead of hiding off the right edge behind the earlier segments on a deep path.
278 LaunchedEffect(path) { scrollState.animateScrollTo(scrollState.maxValue) }
279 Row(
280 modifier = modifier
281 .horizontalScroll(scrollState)
282 .padding(horizontal = 16.dp, vertical = 8.dp),
283 verticalAlignment = Alignment.CenterVertically,
284 ) {
285 IconButton(onClick = { onNavigate("") }) {
286 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
287 }
288 var acc = ""
289 segments.forEachIndexed { index, segment ->
290 acc = if (acc.isEmpty()) segment else "$acc/$segment"
291 Icon(
292 Icons.Filled.ChevronRight,
293 contentDescription = null,
294 modifier = Modifier.size(16.dp),
295 tint = MaterialTheme.colorScheme.onSurfaceVariant,
296 )
297 val target = acc
298 // Cap each segment's width so a single long folder name (e.g. a lengthy monorepo
299 // directory) doesn't push its siblings and the current folder off the screeen.
300 // The deepest segment gets a larger cap since it's the one the user navigated to
301 // and the auto-scroll keeps it in view.
302 val segMaxWidth = if (index == segments.lastIndex) 220.dp else 120.dp
303 Text(
304 segment,
305 style = MaterialTheme.typography.bodyMedium,
306 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
307 maxLines = 1,
308 overflow = TextOverflow.Ellipsis,
309 modifier = Modifier
310 .widthIn(max = segMaxWidth)
311 .clickable(role = Role.Button) { onNavigate(target) }
312 .defaultMinSize(minHeight = 48.dp)
313 .padding(horizontal = 6.dp, vertical = 14.dp),
314 )
315 }
316 }
319@Composable
320private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
321 val context = LocalContext.current
322 if (results.isEmpty()) {
323 EmptyFileState(
324 icon = Icons.Filled.Search,
325 message = stringResource(R.string.no_matches),
326 modifier = Modifier.fillMaxWidth().padding(24.dp),
327 )
328 return
329 }
330 LazyColumn(modifier = Modifier.fillMaxSize()) {
331 // Key on path + index (not the bare path): a duplicate path from findFiles would
332 // otherwise crash LazyColumn with "Key already used". Mirrors TextResults/SymbolResults.
333 itemsIndexed(results, key = { i, p -> "$p:$i" }) { _, path ->
334 // Split the path into directory (muted) + filename (emphasized) so results
335 // are scannable instead of a wall of identical-looking full paths.
336 val dir = path.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
337 val name = path.substringAfterLast('/')
338 FileRow(
339 icon = false,
340 label = name,
341 sublabel = dir.takeIf { it.isNotEmpty() },
342 onClick = { onOpenFile(path) },
343 onCopyPath = {
344 copyToClipboard(context, context.getString(R.string.clip_label_path), path)
345 },
346 modifier = reducedMotionAnimateItem(),
347 )
348 HorizontalDivider()
349 }
350 }
353@OptIn(ExperimentalMaterial3Api::class)
354@Composable
355private fun SearchModeChip(label: String, selected: Boolean, onClick: () -> Unit) {
356 FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
359/** Empty-state message for a content/symbol search: a "type to search" hint before anything
360 * is typed, nothing while a search is in flight (the top progress bar covers that, so the
361 * first keystroke doesn't flash a false "No matches"), and "No matches" only once a real
362 * query genuinely returned nothing. */
363@Composable
364private fun searchEmptyMessage(searching: Boolean, queryBlank: Boolean, hint: Int): String? = when {
365 searching -> null
366 queryBlank -> stringResource(hint)
367 else -> stringResource(R.string.no_matches)
370/** Content (ripgrep) search results: file + line number, with the matched line highlighted. */
371@Composable
372private fun TextResults(results: List<FindMatch>, emptyMessage: String?, onOpen: (String, Int?) -> Unit) {
373 if (results.isEmpty()) {
374 if (emptyMessage != null) {
375 EmptyFileState(
376 icon = Icons.Filled.Search,
377 message = emptyMessage,
378 modifier = Modifier.fillMaxWidth().padding(24.dp),
379 )
380 }
381 return
382 }
383 LazyColumn(modifier = Modifier.fillMaxSize()) {
384 item(key = "__count") {
385 Text(
386 pluralStringResource(R.plurals.file_search_results, results.size, results.size),
387 style = MaterialTheme.typography.labelSmall,
388 color = MaterialTheme.colorScheme.onSurfaceVariant,
389 modifier = Modifier.fillMaxWidth()
390 .padding(horizontal = 16.dp, vertical = 6.dp)
391 .semantics { heading() },
392 )
393 }
394 itemsIndexed(results, key = { i, m -> "${m.filePath}:${m.lineNumber}:$i" }) { _, match ->
395 val name = match.filePath.substringAfterLast('/')
396 val dir = match.filePath.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
397 val highlighted = remember(match) { highlightMatchLine(match) }
398 Column(
399 modifier = Modifier
400 .fillMaxWidth()
401 .defaultMinSize(minHeight = 48.dp)
402 .clickable(role = Role.Button) { onOpen(match.filePath, match.lineNumber) }
403 .padding(horizontal = 16.dp, vertical = 10.dp),
404 ) {
405 Row(verticalAlignment = Alignment.CenterVertically) {
406 Text(
407 name,
408 style = MaterialTheme.typography.bodyMedium,
409 maxLines = 1,
410 overflow = TextOverflow.Ellipsis,
411 modifier = Modifier.weight(1f, fill = false),
412 )
413 Text(
414 ":${match.lineNumber}",
415 style = MaterialTheme.typography.labelSmall,
416 color = MaterialTheme.colorScheme.onSurfaceVariant,
417 fontFamily = FontFamily.Monospace,
418 modifier = Modifier.padding(start = 4.dp),
419 )
420 }
421 if (dir.isNotEmpty()) {
422 Text(
423 dir,
424 style = MaterialTheme.typography.labelSmall,
425 color = MaterialTheme.colorScheme.onSurfaceVariant,
426 maxLines = 1,
427 overflow = TextOverflow.Ellipsis,
428 )
429 }
430 Text(
431 highlighted,
432 style = MaterialTheme.typography.bodySmall,
433 fontFamily = FontFamily.Monospace,
434 maxLines = 2,
435 overflow = TextOverflow.Ellipsis,
436 modifier = Modifier.padding(top = 2.dp),
437 )
438 }
439 HorizontalDivider()
440 }
441 }
444/** Workspace symbol results: symbol name + kind, and the file:line it's defined at. */
445@Composable
446private fun SymbolResults(results: List<SymbolResult>, emptyMessage: String?, onOpen: (String, Int?) -> Unit) {
447 if (results.isEmpty()) {
448 if (emptyMessage != null) {
449 EmptyFileState(
450 icon = Icons.Filled.Search,
451 message = emptyMessage,
452 modifier = Modifier.fillMaxWidth().padding(24.dp),
453 )
454 }
455 return
456 }
457 LazyColumn(modifier = Modifier.fillMaxSize()) {
458 item(key = "__count") {
459 Text(
460 pluralStringResource(R.plurals.file_search_results, results.size, results.size),
461 style = MaterialTheme.typography.labelSmall,
462 color = MaterialTheme.colorScheme.onSurfaceVariant,
463 modifier = Modifier.fillMaxWidth()
464 .padding(horizontal = 16.dp, vertical = 6.dp)
465 .semantics { heading() },
466 )
467 }
468 itemsIndexed(results, key = { i, s -> "${s.filePath}:${s.name}:$i" }) { _, symbol ->
469 val fileName = symbol.filePath.substringAfterLast('/')
470 Column(
471 modifier = Modifier
472 .fillMaxWidth()
473 .defaultMinSize(minHeight = 48.dp)
474 .clickable(role = Role.Button) { onOpen(symbol.filePath, symbol.displayLine) }
475 .padding(horizontal = 16.dp, vertical = 10.dp),
476 ) {
477 Row(verticalAlignment = Alignment.CenterVertically) {
478 Text(
479 symbol.name,
480 style = MaterialTheme.typography.bodyMedium,
481 maxLines = 1,
482 overflow = TextOverflow.Ellipsis,
483 modifier = Modifier.weight(1f, fill = false),
484 )
485 Text(
486 symbolKindLabel(symbol.kind),
487 style = MaterialTheme.typography.labelSmall,
488 color = MaterialTheme.colorScheme.primary,
489 modifier = Modifier.padding(start = 8.dp),
490 )
491 }
492 Text(
493 "$fileName:${symbol.displayLine}",
494 style = MaterialTheme.typography.labelSmall,
495 color = MaterialTheme.colorScheme.onSurfaceVariant,
496 fontFamily = FontFamily.Monospace,
497 maxLines = 1,
498 overflow = TextOverflow.Ellipsis,
499 )
500 }
501 HorizontalDivider()
502 }
503 }
506/** Build the matched line with each ripgrep submatch bolded. Offsets are byte columns
507 * within the line; clamped to the (trimmed) line length so a multibyte line can't crash. */
508private fun highlightMatchLine(match: FindMatch): androidx.compose.ui.text.AnnotatedString {
509 val line = match.lineText
510 return buildAnnotatedString {
511 var cursor = 0
512 for (sub in match.submatches.sortedBy { it.start }) {
513 val start = sub.start.coerceIn(0, line.length)
514 val end = sub.end.coerceIn(start, line.length)
515 if (start > cursor) append(line.substring(cursor, start))
516 withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(line.substring(start, end)) }
517 cursor = end
518 }
519 if (cursor < line.length) append(line.substring(cursor))
520 }
523@Composable
524private fun DirectoryListing(
525 state: FileBrowserState,
526 onOpenDir: (String) -> Unit,
527 onUp: () -> Unit,
528 onOpenFile: (String) -> Unit,
529) {
530 val context = LocalContext.current
531 if (state.entries.isEmpty()) {
532 EmptyFileState(
533 icon = Icons.Filled.Folder,
534 message = stringResource(R.string.empty_folder),
535 modifier = Modifier.fillMaxWidth().padding(24.dp),
536 )
537 return
538 }
539 // Sort preference for the directory listing. Folders-first + name sort is the most useful
540 // default; the toggle flips between folders-first name sort and the server's raw order.
541 var foldersFirst by rememberSaveable { mutableStateOf(true) }
542 // Hidden-files preference. The server may already include dotfiles in its listing; this
543 // hides them by default (the common case for browsing a repo) and lets the user reveal
544 // .env / .git / .opencode on demand. Default off so the listing isn't cluttered.
545 var showHidden by rememberSaveable { mutableStateOf(false) }
546 // Sort key for files within the listing. Name is the default (and the only axis the server
547 // reliably supports); Size/Modified appear only when the server emits size/mtime (otherwise
548 // those sorts are no-ops on null fields), gated behind a menu so they don't clutter the bar.
549 var sortKey by rememberSaveable { mutableStateOf(FileSortKey.NAME) }
550 var sortDesc by rememberSaveable { mutableStateOf(false) }
551 val visible = remember(state.entries, showHidden) {
552 if (showHidden) state.entries else state.entries.filterNot { it.name.startsWith(".") }
553 }
554 val sorted = remember(visible, foldersFirst, sortKey, sortDesc) {
555 // Null size/mtime coerce to values that sink the unknown entries to the bottom in the
556 // default (ascending) view, so a partially-populated listing keeps sized/dated files on top.
557 val comparator = when (sortKey) {
558 FileSortKey.NAME -> compareBy<FileNode> { it.name.lowercase() }
559 FileSortKey.SIZE -> compareBy<FileNode> { it.size ?: Long.MAX_VALUE }
560 FileSortKey.MODIFIED -> compareBy<FileNode> { it.mtime ?: 0L }
561 }.let { if (sortDesc) it.reversed() else it }
562 if (foldersFirst) {
563 val (dirs, files) = visible.partition { it.isDirectory }
564 // Directories always sort by name (size/date on a folder is rarely meaningful);
565 // the chosen key applies to the files within.
566 dirs.sortedBy { it.name.lowercase() } + files.sortedWith(comparator)
567 } else {
568 visible.sortedWith(comparator)
569 }
570 }
571 LazyColumn(modifier = Modifier.fillMaxSize()) {
572 if (state.path.isNotBlank()) {
573 item(key = "__up") {
574 FileRow(icon = true, label = "..", onClick = onUp)
575 HorizontalDivider()
576 }
577 }
578 item(key = "__sort") {
579 Row(
580 modifier = Modifier.fillMaxWidth()
581 .horizontalScroll(rememberScrollState())
582 .padding(end = 8.dp, top = 2.dp, bottom = 2.dp),
583 horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
584 verticalAlignment = Alignment.CenterVertically,
585 ) {
586 // rememberSaveable so an open sort dropdown survives a rotation instead of
587 // closing and leaving the user to re-open it.
588 var showSortMenu by rememberSaveable { mutableStateOf(false) }
589 // Close the sort dropdown on back press instead of navigating away, matching
590 // the session/server list dropdown back-handling.
591 BackHandler(enabled = showSortMenu) { showSortMenu = false }
592 androidx.compose.material3.FilterChip(
593 selected = sortKey != FileSortKey.NAME || sortDesc,
594 onClick = { showSortMenu = true },
595 leadingIcon = {
596 Icon(Icons.Filled.SortByAlpha, contentDescription = null, modifier = Modifier.size(16.dp))
597 },
598 label = { Text(stringResource(R.string.sort_by), style = MaterialTheme.typography.labelSmall) },
599 )
600 DropdownMenu(expanded = showSortMenu, onDismissRequest = { showSortMenu = false }) {
601 val labels = remember {
602 mapOf(
603 FileSortKey.NAME to R.string.sort_name,
604 FileSortKey.SIZE to R.string.sort_size,
605 FileSortKey.MODIFIED to R.string.sort_modified,
606 )
607 }
608 FileSortKey.entries.forEach { key ->
609 DropdownMenuItem(
610 text = {
611 Row(verticalAlignment = Alignment.CenterVertically) {
612 Text(stringResource(labels[key]!!), modifier = Modifier.weight(1f))
613 if (sortKey == key) {
614 Icon(
615 if (sortDesc) Icons.Filled.KeyboardArrowDown else Icons.Filled.KeyboardArrowUp,
616 contentDescription = null,
617 modifier = Modifier.size(18.dp),
618 )
619 }
620 }
621 },
622 onClick = {
623 // Tapping the active key flips the direction; tapping a different
624 // key switches to it (ascending by default).
625 if (sortKey == key) sortDesc = !sortDesc else { sortKey = key; sortDesc = false }
626 showSortMenu = false
627 },
628 )
629 }
630 }
631 androidx.compose.material3.FilterChip(
632 selected = showHidden,
633 onClick = { showHidden = !showHidden },
634 label = { Text(stringResource(R.string.show_hidden), style = MaterialTheme.typography.labelSmall) },
635 )
636 androidx.compose.material3.FilterChip(
637 selected = foldersFirst,
638 onClick = { foldersFirst = !foldersFirst },
639 leadingIcon = {
640 if (foldersFirst) {
641 Icon(Icons.Filled.Folder, contentDescription = null, modifier = Modifier.size(16.dp))
642 }
643 },
644 label = { Text(stringResource(R.string.folders_first), style = MaterialTheme.typography.labelSmall) },
645 )
646 }
647 }
648 items(sorted, key = { it.path + "_" + it.name }) { node ->
649 FileRow(
650 icon = node.isDirectory,
651 label = node.name,
652 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
653 onCopyPath = {
654 copyToClipboard(context, context.getString(R.string.clip_label_path), node.path)
655 },
656 size = node.size,
657 mtime = node.mtime,
658 status = state.statusMap[node.path],
659 modifier = reducedMotionAnimateItem(),
660 )
661 HorizontalDivider()
662 }
663 }
666/** Shared empty-state for the file browser. Delegates to the canonical [EmptyState] so every
667 * screen's "nothing here" reads and behaves identically (icon size, spacing, styling). */
668@Composable
669private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector, message: String, modifier: Modifier = Modifier) {
670 EmptyState(icon = icon, title = message, modifier = modifier)
673@OptIn(ExperimentalFoundationApi::class)
674@Composable
675private fun FileRow(
676 icon: Boolean,
677 label: String,
678 onClick: () -> Unit,
679 status: FileStatusEntry? = null,
680 sublabel: String? = null,
681 size: Long? = null,
682 mtime: Long? = null,
683 onCopyPath: (() -> Unit)? = null,
684 modifier: Modifier = Modifier,
685) {
686 val copyPathLabel = stringResource(R.string.copy_path)
687 // Long-press → "Copy path" dropdown. Rendered only when [onCopyPath] is supplied, so the
688 // parent ".." row (which passes none) stays a plain tap target. Anchored within the row.
689 // rememberSaveable so an open row menu survives a rotation instead of closing.
690 var menu by rememberSaveable { mutableStateOf(false) }
691 // Close this row's overflow on back press instead of navigating away, matching the
692 // sort menu BackHandler above.
693 BackHandler(enabled = menu) { menu = false }
694 val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
695 else if (icon) stringResource(R.string.folder, label)
696 else stringResource(R.string.file_label, label)
697 // Build a combined description so TalkBack announces both the file name and its git
698 // status. The parent sets an explicit contentDescription (mergeDescendants), which
699 // would otherwise suppress the StatusBadge's own description.
700 val statusDesc = status?.let {
701 when (it.status) {
702 "added" -> stringResource(R.string.git_added)
703 "modified" -> stringResource(R.string.git_modified)
704 "deleted" -> stringResource(R.string.git_deleted)
705 else -> ""
706 }
707 }.orEmpty()
708 val fullDesc = if (statusDesc.isNotEmpty()) "$fileDesc, $statusDesc" else fileDesc
709 Row(
710 modifier = modifier
711 .fillMaxWidth()
712 .defaultMinSize(minHeight = 48.dp)
713 .combinedClickable(
714 role = Role.Button,
715 onClick = onClick,
716 onLongClick = onCopyPath?.let { { menu = true } },
717 onLongClickLabel = onCopyPath?.let { copyPathLabel },
718 )
719 .semantics(mergeDescendants = true) { contentDescription = fullDesc }
720 .padding(horizontal = 16.dp, vertical = 14.dp),
721 verticalAlignment = Alignment.CenterVertically,
722 ) {
723 Icon(
724 if (icon) Icons.Filled.Folder else iconForFile(label),
725 contentDescription = null,
726 modifier = Modifier.size(20.dp),
727 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
728 )
729 Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
730 Text(
731 label,
732 style = MaterialTheme.typography.bodyLarge,
733 maxLines = 1,
734 overflow = TextOverflow.Ellipsis,
735 )
736 // Secondary line: a caller-supplied sublabel (directory in search results) and/or
737 // the file's size + last-modified time when the server provides them. Combines the
738 // fragments so a single muted line carries all metadata instead of stacking rows.
739 val detailParts = buildList {
740 if (sublabel != null) add(sublabel)
741 if (!icon) {
742 size?.let { add(formatFileSize(it)) }
743 mtime?.let { add(relativeTime(it)) }
744 }
745 }
746 if (detailParts.isNotEmpty()) {
747 Text(
748 detailParts.joinToString(" · "),
749 style = MaterialTheme.typography.labelSmall,
750 color = MaterialTheme.colorScheme.onSurfaceVariant,
751 maxLines = 1,
752 overflow = TextOverflow.Ellipsis,
753 )
754 }
755 }
756 if (status != null) StatusBadge(status)
757 // Explicit overflow button so "Copy path" is reachable without a long-press (which
758 // isn't available to TalkBack/keyboard users or anyone who can't hold the gesture).
759 // Rendered only when the row supplies an onCopyPath (the parent ".." row does not).
760 if (onCopyPath != null) {
761 IconButton(onClick = { menu = true }) {
762 Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options))
763 }
764 }
765 DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
766 DropdownMenuItem(
767 leadingIcon = { Icon(Icons.Filled.Link, contentDescription = null) },
768 text = { Text(copyPathLabel) },
769 onClick = { menu = false; onCopyPath?.invoke() },
770 )
771 }
772 }
775@Composable
776private fun StatusBadge(status: FileStatusEntry) {
777 val (letter, color) = when (status.status) {
778 "added" -> "A" to MaterialTheme.colorScheme.primary
779 "modified" -> "M" to MaterialTheme.colorScheme.tertiary
780 "deleted" -> "D" to MaterialTheme.colorScheme.error
781 else -> "·" to MaterialTheme.colorScheme.onSurfaceVariant
782 }
783 Row(
784 verticalAlignment = Alignment.CenterVertically,
785 ) {
786 Text(
787 letter,
788 style = MaterialTheme.typography.labelMedium,
789 fontFamily = FontFamily.Monospace,
790 color = color,
791 )
792 if (status.added > 0 || status.removed > 0) {
793 Text(
794 "+${status.added} −${status.removed}",
795 style = MaterialTheme.typography.labelSmall,
796 fontFamily = FontFamily.Monospace,
797 color = MaterialTheme.colorScheme.onSurfaceVariant,
798 modifier = Modifier.padding(start = 4.dp),
799 )
800 }
801 }
804/** Sort axis for the directory listing. Name is always available; Size/Modified depend on the
805 * server emitting size/mtime (otherwise they sort nulls to the bottom). */
806private enum class FileSortKey { NAME, SIZE, MODIFIED }
808/** Format a byte count as a compact human-readable size (e.g. 1.2 KB, 3.4 MB). Uses 1024-based
809 * units with one decimal place, matching how file managers render sizes. */
810private fun formatFileSize(bytes: Long): String {
811 if (bytes < 1024) return "$bytes B"
812 val units = arrayOf("KB", "MB", "GB", "TB")
813 var value = bytes.toDouble() / 1024.0
814 var unit = 0
815 while (value >= 1024 && unit < units.lastIndex) {
816 value /= 1024.0
817 unit++
818 }
819 val formatted = if (value >= 100) "%.0f".format(value) else "%.1f".format(value)
820 return "$formatted ${units[unit]}"
823/** Pick an icon for a file by its extension so the directory listing is scannable instead of a
824 * wall of identical `Description` glyphs: images, source code, and Markdown/article files each
825 * get a distinct glyph; unknown extensions fall back to the generic document icon. */
826private fun iconForFile(name: String): androidx.compose.ui.graphics.vector.ImageVector {
827 val ext = name.substringAfterLast('.', missingDelimiterValue = "").lowercase()
828 return when (ext) {
829 "png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico", "heic" -> Icons.Filled.Image
830 "kt", "kts", "java", "js", "ts", "jsx", "tsx", "py", "rb", "go", "rs", "c", "cpp", "h",
831 "hpp", "cs", "swift", "scala", "gradle", "groovy", "sh", "bash", "zsh", "fish", "ps1",
832 "lua", "r", "dart", "php", "pl", "sql", "vue", "svelte" -> Icons.Filled.Code
833 "md", "markdown", "txt", "rst", "adoc", "rtf", "doc", "docx", "pdf", "epub" -> Icons.Filled.Article
834 else -> Icons.Filled.Description
835 }