@@ -27,6 +27,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
27import androidx.compose.foundation.rememberScrollState
27import androidx.compose.foundation.rememberScrollState
28import androidx.compose.foundation.text.KeyboardActions
28import androidx.compose.foundation.text.KeyboardActions
29import androidx.compose.foundation.text.KeyboardOptions
29import androidx.compose.foundation.text.KeyboardOptions
30+import androidx.compose.foundation.text.selection.SelectionContainer
30import androidx.compose.foundation.verticalScroll
31import androidx.compose.foundation.verticalScroll
31import androidx.compose.material.icons.Icons
32import androidx.compose.material.icons.Icons
32import androidx.compose.material.icons.automirrored.filled.ArrowBack
33import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -40,6 +41,7 @@ import androidx.compose.material.icons.filled.MoreVert
40import androidx.compose.material.icons.filled.Refresh
41import androidx.compose.material.icons.filled.Refresh
41import androidx.compose.material.icons.filled.Search
42import androidx.compose.material.icons.filled.Search
42import androidx.compose.material.icons.filled.Share
43import androidx.compose.material.icons.filled.Share
44+import androidx.compose.material3.AlertDialog
43import androidx.compose.material3.CircularProgressIndicator
45import androidx.compose.material3.CircularProgressIndicator
44import androidx.compose.material3.DropdownMenu
46import androidx.compose.material3.DropdownMenu
45import androidx.compose.material3.DropdownMenuItem
47import androidx.compose.material3.DropdownMenuItem
@@ -76,6 +78,7 @@ import androidx.compose.ui.semantics.contentDescription
76import androidx.compose.ui.semantics.semantics
78import androidx.compose.ui.semantics.semantics
77import androidx.compose.ui.text.font.FontFamily
79import androidx.compose.ui.text.font.FontFamily
78import androidx.compose.ui.text.input.ImeAction
80import androidx.compose.ui.text.input.ImeAction
81+import androidx.compose.ui.text.input.KeyboardType
79import androidx.compose.ui.text.style.TextOverflow
82import androidx.compose.ui.text.style.TextOverflow
80import androidx.compose.ui.unit.dp
83import androidx.compose.ui.unit.dp
81import androidx.compose.ui.res.stringResource
84import androidx.compose.ui.res.stringResource
@@ -141,6 +144,13 @@ fun FileViewScreen(
141 var wrap by rememberSaveable(path) { mutableStateOf(codeWrap) }
144 var wrap by rememberSaveable(path) { mutableStateOf(codeWrap) }
142 val listState = rememberLazyListState()
145 val listState = rememberLazyListState()
143 val rawText = state.content?.content.orEmpty()
146 val rawText = state.content?.content.orEmpty()
147+ // Directory portion of the path, shown under the filename in the top bar so a user who
148+ // has drilled into a deep workspace keeps context of where this file lives.
149+ val pathSubtitle = remember(path) {
150+ val trimmed = path.trimEnd('/')
151+ val parent = trimmed.substringBeforeLast('/', "")
152+ parent.ifBlank { path }
144 // Split the raw text exactly once here (the line viewer and find-in-file both consume this
154 // Split the raw text exactly once here (the line viewer and find-in-file both consume this
145 // list) and cap it so a huge file doesn't get split twice or overwhelm the LazyColumn.
155 // list) and cap it so a huge file doesn't get split twice or overwhelm the LazyColumn.
146 val allLines = remember(rawText) { rawText.split("\n") }
156 val allLines = remember(rawText) { rawText.split("\n") }
@@ -158,6 +168,9 @@ fun FileViewScreen(
158 lineCount = lines.size,
168 lineCount = lines.size,
159 listState = listState,
169 listState = listState,
171+ // Find-in-file match-case toggle (default case-insensitive). Persisted so a rotation keeps
172+ // the choice, and applied to the off-thread match scan below.
173+ var caseSensitive by rememberSaveable(path) { mutableStateOf(false) }
161 // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
174 // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
162 // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
175 // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
163 var debouncedFind by remember { mutableStateOf("") }
176 var debouncedFind by remember { mutableStateOf("") }
@@ -165,11 +178,13 @@ fun FileViewScreen(
165 val q = findQuery.trim()
178 val q = findQuery.trim()
166 if (q.isEmpty()) debouncedFind = "" else { delay(150); debouncedFind = q }
179 if (q.isEmpty()) debouncedFind = "" else { delay(150); debouncedFind = q }
168− val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind) {
181+ val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind, caseSensitive) {
169 val q = debouncedFind
182 val q = debouncedFind
170 value = if (q.isEmpty()) emptyList()
183 value = if (q.isEmpty()) emptyList()
171 else withContext(Dispatchers.Default) {
184 else withContext(Dispatchers.Default) {
172− lines.mapIndexedNotNull { index, line -> index.takeIf { line.contains(q, ignoreCase = true) } }
185+ lines.mapIndexedNotNull { index, line ->
186+ index.takeIf { line.contains(q, ignoreCase = !caseSensitive) }
175 // Keep the current match index in range when the match set shrinks (e.g. the file
190 // Keep the current match index in range when the match set shrinks (e.g. the file
@@ -181,10 +196,19 @@ fun FileViewScreen(
181 LaunchedEffect(matchPos, matchIndices) {
196 LaunchedEffect(matchPos, matchIndices) {
182 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
197 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
199+ // "Go to line" dialog state. Clears the jump highlight and scrolls the LazyColumn to the
200+ // requested line (clamped to the rendered range) when the user confirms.
201+ var showGoToLine by rememberSaveable(path) { mutableStateOf(false) }
202+ fun goToLine(n: Int) {
203+ val target = (n - 1).coerceIn(0, (lines.size - 1).coerceAtLeast(0))
204+ highlightLine.value = null
205+ scope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
184 Scaffold(
207 Scaffold(
185 topBar = {
208 topBar = {
186 FileViewTopBar(
209 FileViewTopBar(
187 filename = filename,
210 filename = filename,
211+ subtitle = pathSubtitle,
188 loading = state.loading,
212 loading = state.loading,
189 content = rawText,
213 content = rawText,
190 wrap = wrap,
214 wrap = wrap,
@@ -203,6 +227,7 @@ fun FileViewScreen(
205 onToggleWrap = { wrap = !wrap },
229 onToggleWrap = { wrap = !wrap },
230+ onGoToLine = { showGoToLine = true },
206 onCopy = { copyToClipboard(context, filename, rawText) },
231 onCopy = { copyToClipboard(context, filename, rawText) },
207 onCopyPath = { copyToClipboard(context, context.getString(R.string.clip_label_path), path) },
232 onCopyPath = { copyToClipboard(context, context.getString(R.string.clip_label_path), path) },
208 onShare = {
233 onShare = {
@@ -270,11 +295,81 @@ fun FileViewScreen(
270 highlightLineIndex = highlightLine.value,
295 highlightLineIndex = highlightLine.value,
271 listState = listState,
296 listState = listState,
272 onRetry = { vm.reload() },
297 onRetry = { vm.reload() },
298+ caseSensitive = caseSensitive,
299+ onToggleCaseSensitive = { caseSensitive = !caseSensitive },
301+ val send = Intent(Intent.ACTION_SEND).apply {
303+ putExtra(Intent.EXTRA_SUBJECT, filename)
304+ putExtra(Intent.EXTRA_TEXT, rawText)
306+ runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
307+ .onFailure { scope.launch { snackbar.showSnackbar(context.getString(R.string.no_share_app)) } }
316+ visible = showGoToLine,
317+ maxLine = lines.size,
318+ onConfirm = { n -> showGoToLine = false; goToLine(n) },
319+ onDismiss = { showGoToLine = false },
323+// Wraps the go-to-line dialog's visibility gate so the branch stays out of FileViewScreen
324+// (keeping that composable under the cyclomatic-complexity threshold).
326+private fun GoToLineLauncher(
329+ onConfirm: (Int) -> Unit,
330+ onDismiss: () -> Unit,
333+ GoToLineDialog(maxLine = maxLine, onConfirm = onConfirm, onDismiss = onDismiss)
338+private fun GoToLineDialog(maxLine: Int, onConfirm: (Int) -> Unit, onDismiss: () -> Unit) {
339+ var text by rememberSaveable { mutableStateOf("") }
340+ val parsed = text.trim().toIntOrNull()
341+ val valid = parsed != null && parsed >= 1
343+ onDismissRequest = onDismiss,
344+ title = { Text(stringResource(R.string.go_to_line)) },
348+ onValueChange = { v -> text = v.filter { it.isDigit() } },
350+ isError = text.isNotEmpty() && !valid,
353+ stringResource(R.string.go_to_line_hint, maxLine),
354+ style = MaterialTheme.typography.bodySmall,
357+ keyboardOptions = KeyboardOptions(
358+ keyboardType = KeyboardType.Number,
359+ imeAction = ImeAction.Done,
361+ keyboardActions = KeyboardActions(onDone = { parsed?.let { onConfirm(it) } }),
365+ TextButton(onClick = { parsed?.let { onConfirm(it) } }, enabled = valid) {
366+ Text(stringResource(R.string.go_to_line))
370+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
280// Owns the "jump to a line from a search hit" behaviour: scroll to the target once the raw
375// Owns the "jump to a line from a search hit" behaviour: scroll to the target once the raw
@@ -312,6 +407,7 @@ private fun rememberJumpToLineHighlight(
312@Composable
407@Composable
313private fun FileViewTopBar(
408private fun FileViewTopBar(
314 filename: String,
409 filename: String,
315 loading: Boolean,
411 loading: Boolean,
316 content: String,
412 content: String,
317 wrap: Boolean,
413 wrap: Boolean,
@@ -320,12 +416,29 @@ private fun FileViewTopBar(
320 onReload: () -> Unit,
416 onReload: () -> Unit,
321 onToggleFind: () -> Unit,
417 onToggleFind: () -> Unit,
322 onToggleWrap: () -> Unit,
418 onToggleWrap: () -> Unit,
419+ onGoToLine: () -> Unit,
323 onCopy: () -> Unit,
420 onCopy: () -> Unit,
324 onCopyPath: () -> Unit,
421 onCopyPath: () -> Unit,
325 onShare: () -> Unit,
422 onShare: () -> Unit,
327 TopAppBar(
424 TopAppBar(
426+ // Two-line title: filename (primary) + the directory portion of the path
427+ // (secondary), so a file opened from deep in a workspace keeps its location
428+ // visible without an extra breadcrumb row.
328− title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
430+ Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis)
431+ if (subtitle.isNotBlank()) {
435+ overflow = TextOverflow.Ellipsis,
436+ style = MaterialTheme.typography.bodySmall,
437+ color = MaterialTheme.colorScheme.onSurfaceVariant,
329 navigationIcon = {
442 navigationIcon = {
330 IconButton(onClick = onBack) {
443 IconButton(onClick = onBack) {
331 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
444 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
@@ -365,6 +478,11 @@ private fun FileViewTopBar(
365 // Wrap long lines instead of horizontal-scrolling; useful for prose. Disabled
478 // Wrap long lines instead of horizontal-scrolling; useful for prose. Disabled
366 // in diff mode because DiffView ignores wrap, so an active-looking toggle there
479 // in diff mode because DiffView ignores wrap, so an active-looking toggle there
367 // would be a silent no-op.
480 // would be a silent no-op.
482+ text = { Text(stringResource(R.string.go_to_line)) },
483+ onClick = { onGoToLine(); menuExpanded = false },
484+ leadingIcon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
368 DropdownMenuItem(
486 DropdownMenuItem(
369 text = { Text(stringResource(R.string.wrap_lines)) },
487 text = { Text(stringResource(R.string.wrap_lines)) },
370 onClick = { onToggleWrap(); menuExpanded = false },
488 onClick = { onToggleWrap(); menuExpanded = false },
@@ -419,6 +537,9 @@ private fun BoxScope.FileViewStateContent(
419 highlightLineIndex: Int?,
537 highlightLineIndex: Int?,
420 listState: LazyListState,
538 listState: LazyListState,
421 onRetry: () -> Unit,
539 onRetry: () -> Unit,
540+ onShareFull: () -> Unit,
541+ caseSensitive: Boolean,
542+ onToggleCaseSensitive: () -> Unit,
424 // Full-screen spinner only on the initial load (no content yet). A reload keeps the
545 // Full-screen spinner only on the initial load (no content yet). A reload keeps the
@@ -461,6 +582,9 @@ private fun BoxScope.FileViewStateContent(
461 truncated = truncated,
582 truncated = truncated,
462 highlightLineIndex = highlightLineIndex,
583 highlightLineIndex = highlightLineIndex,
463 listState = listState,
584 listState = listState,
585+ onShareFull = onShareFull,
586+ caseSensitive = caseSensitive,
587+ onToggleCaseSensitive = onToggleCaseSensitive,
@@ -485,6 +609,9 @@ private fun BoxScope.FileViewContentBody(
485 truncated: Boolean,
609 truncated: Boolean,
486 highlightLineIndex: Int?,
610 highlightLineIndex: Int?,
487 listState: LazyListState,
611 listState: LazyListState,
612+ onShareFull: () -> Unit,
613+ caseSensitive: Boolean,
614+ onToggleCaseSensitive: () -> Unit,
489 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
616 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
490 // reachable while scrolling. Each contributes a top inset so the content below
617 // reachable while scrolling. Each contributes a top inset so the content below
@@ -543,6 +670,8 @@ private fun BoxScope.FileViewContentBody(
543 onPrev = onPrev,
670 onPrev = onPrev,
544 onNext = onNext,
671 onNext = onNext,
545 onClose = onCloseFind,
672 onClose = onCloseFind,
673+ caseSensitive = caseSensitive,
674+ onToggleCaseSensitive = onToggleCaseSensitive,
@@ -575,6 +704,7 @@ private fun BoxScope.FileViewContentBody(
575 matchIndices = matchIndices,
704 matchIndices = matchIndices,
576 highlightLineIndex = highlightLineIndex,
705 highlightLineIndex = highlightLineIndex,
577 listState = listState,
706 listState = listState,
707+ onShareFull = onShareFull,
@@ -589,8 +719,11 @@ private fun FindBar(
589 onPrev: () -> Unit,
719 onPrev: () -> Unit,
590 onNext: () -> Unit,
720 onNext: () -> Unit,
591 onClose: () -> Unit,
721 onClose: () -> Unit,
722+ caseSensitive: Boolean,
723+ onToggleCaseSensitive: () -> Unit,
593 val keyboardController = LocalSoftwareKeyboardController.current
725 val keyboardController = LocalSoftwareKeyboardController.current
726+ val matchCaseLabel = stringResource(R.string.match_case)
595 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
728 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
596 verticalAlignment = Alignment.CenterVertically,
729 verticalAlignment = Alignment.CenterVertically,
@@ -604,22 +737,33 @@ private fun FindBar(
604 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
737 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
605 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
738 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
606 trailingIcon = {
739 trailingIcon = {
607− val countText = if (query.isBlank()) ""
740+ Row(verticalAlignment = Alignment.CenterVertically) {
608− else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
741+ // Case-sensitivity toggle. A small "Aa" chip keeps the bar compact while
609− else stringResource(R.string.match_count, matchPos + 1, matchCount)
742+ // surfacing a previously substring-only, always-case-insensitive search.
610− if (countText.isNotEmpty()) {
743+ FilterChip(
611− // Use error color for "no matches" so the zero-result state is
744+ selected = caseSensitive,
612− // distinguishable from a positive match count at a glance.
745+ onClick = onToggleCaseSensitive,
613− val color = if (query.isNotBlank() && matchCount == 0) MaterialTheme.colorScheme.error
614− else MaterialTheme.colorScheme.onSurfaceVariant
617− // labelMedium (not labelSmall) so the counter is legible for low-vision
618− // users at large font scales; the error color still distinguishes zero
619− // matches at a glance.
620− style = MaterialTheme.typography.labelMedium,
746+ label = { Text("Aa", style = MaterialTheme.typography.labelSmall) },
621− color = color,
747+ modifier = Modifier.semantics { contentDescription = matchCaseLabel },
749+ Spacer(Modifier.size(8.dp))
750+ val countText = if (query.isBlank()) ""
751+ else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
752+ else stringResource(R.string.match_count, matchPos + 1, matchCount)
753+ if (countText.isNotEmpty()) {
754+ // Use error color for "no matches" so the zero-result state is
755+ // distinguishable from a positive match count at a glance.
756+ val color = if (query.isNotBlank() && matchCount == 0) MaterialTheme.colorScheme.error
757+ else MaterialTheme.colorScheme.onSurfaceVariant
760+ // labelMedium (not labelSmall) so the counter is legible for low-vision
761+ // users at large font scales; the error color still distinguishes zero
762+ // matches at a glance.
763+ style = MaterialTheme.typography.labelMedium,
@@ -646,6 +790,7 @@ private fun FileTextContent(
646 matchIndices: List<Int>,
790 matchIndices: List<Int>,
647 highlightLineIndex: Int?,
791 highlightLineIndex: Int?,
648 listState: LazyListState,
792 listState: LazyListState,
793+ onShareFull: () -> Unit,
650 // Honor the user's chat text-size preference (the same one the chat markdown honors) so
795 // Honor the user's chat text-size preference (the same one the chat markdown honors) so
651 // a user who bumped the scale for readability gets the same scale in the code viewer.
796 // a user who bumped the scale for readability gets the same scale in the code viewer.
@@ -674,49 +819,67 @@ private fun FileTextContent(
674 MaterialTheme(typography = scaled) {
819 MaterialTheme(typography = scaled) {
675 Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
820 Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
676 if (truncated) {
821 if (truncated) {
677− Text(
822+ androidx.compose.foundation.layout.Row(
678− stringResource(R.string.file_truncated, MAX_RENDERED_LINES),
679− style = MaterialTheme.typography.labelSmall,
680− color = MaterialTheme.colorScheme.onSurfaceVariant,
681 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
823 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
824+ verticalAlignment = Alignment.CenterVertically,
825+ horizontalArrangement = Arrangement.SpaceBetween,
828+ stringResource(R.string.file_truncated, MAX_RENDERED_LINES),
829+ style = MaterialTheme.typography.labelSmall,
830+ color = MaterialTheme.colorScheme.onSurfaceVariant,
831+ modifier = Modifier.weight(1f),
833+ // Escape hatch: when only the first N lines render, offer the (already
834+ // full-content) Share action inline so the user isn't stranded without the
835+ // rest of the file. The overflow Share shares the same full content.
836+ TextButton(onClick = onShareFull) {
837+ Text(stringResource(R.string.share_full_file))
841+ // SelectionContainer lets the user select/copy a portion of the rendered code
842+ // (not just the whole-file Copy in the overflow). Selection across recycled
843+ // LazyColumn items can drop spans that have scrolled away, but partial selection
844+ // of the visible region — the common case — works.
845+ SelectionContainer {
684− LazyColumn(
846+ LazyColumn(
685− state = listState,
847+ state = listState,
686− modifier = Modifier
848+ modifier = Modifier
687− .weight(1f)
849+ .weight(1f)
688− .fillMaxWidth()
850+ .fillMaxWidth()
689− .padding(8.dp),
851+ .padding(8.dp),
691− itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
853+ itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
692− val isMatch = q.isNotEmpty() && index in matchSet
854+ val isMatch = q.isNotEmpty() && index in matchSet
693− // The jump-to-line target keeps the same emphasis as a find match so the line
855+ // The jump-to-line target keeps the same emphasis as a find match so the line
694− // the viewer landed on is obvious until the user scrolls or starts a find.
856+ // the viewer landed on is obvious until the user scrolls or starts a find.
695− val isTarget = index == highlightLineIndex
857+ val isTarget = index == highlightLineIndex
697− modifier = Modifier
859+ modifier = Modifier
698− .fillMaxWidth()
860+ .fillMaxWidth()
699− .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
861+ .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
700− .then(if (isMatch || isTarget) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
862+ .then(if (isMatch || isTarget) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
703− "${index + 1}",
865+ "${index + 1}",
704− modifier = Modifier.width(gutterWidth),
866+ modifier = Modifier.width(gutterWidth),
705− style = MaterialTheme.typography.bodySmall,
867+ style = MaterialTheme.typography.bodySmall,
706− fontFamily = FontFamily.Monospace,
868+ fontFamily = FontFamily.Monospace,
707− color = MaterialTheme.colorScheme.onSurfaceVariant,
869+ color = MaterialTheme.colorScheme.onSurfaceVariant,
709− // Heuristic syntax highlighting per line. Falls back to plain text for
871+ // Heuristic syntax highlighting per line. Falls back to plain text for
710− // unknown extensions, so non-code files render unchanged. Memoized so the
872+ // unknown extensions, so non-code files render unchanged. Memoized so the
711− // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
873+ // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
712− // file, or palette actually changes — not on every recomposition (e.g. every
874+ // file, or palette actually changes — not on every recomposition (e.g. every
713− // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
875+ // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
714− val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
876+ val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
716− highlighted,
878+ highlighted,
717− modifier = Modifier.padding(start = 8.dp),
879+ modifier = Modifier.padding(start = 8.dp),
718− style = MaterialTheme.typography.bodySmall,
880+ style = MaterialTheme.typography.bodySmall,