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