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