@@ -27,6 +27,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
2727import androidx.compose.foundation.rememberScrollState
2828import androidx.compose.foundation.text.KeyboardActions
2929import androidx.compose.foundation.text.KeyboardOptions
30+import androidx.compose.foundation.text.selection.SelectionContainer
3031import androidx.compose.foundation.verticalScroll
3132import androidx.compose.material.icons.Icons
3233import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -40,6 +41,7 @@ import androidx.compose.material.icons.filled.MoreVert
4041import androidx.compose.material.icons.filled.Refresh
4142import androidx.compose.material.icons.filled.Search
4243import androidx.compose.material.icons.filled.Share
44+import androidx.compose.material3.AlertDialog
4345import androidx.compose.material3.CircularProgressIndicator
4446import androidx.compose.material3.DropdownMenu
4547import androidx.compose.material3.DropdownMenuItem
@@ -76,6 +78,7 @@ import androidx.compose.ui.semantics.contentDescription
7678import androidx.compose.ui.semantics.semantics
7779import androidx.compose.ui.text.font.FontFamily
7880import androidx.compose.ui.text.input.ImeAction
81+import androidx.compose.ui.text.input.KeyboardType
7982import androidx.compose.ui.text.style.TextOverflow
8083import androidx.compose.ui.unit.dp
8184import androidx.compose.ui.res.stringResource
@@ -141,6 +144,13 @@ fun FileViewScreen(
141144 var wrap by rememberSaveable(path) { mutableStateOf(codeWrap) }
142145 val listState = rememberLazyListState()
143146 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 }
153+ }
144154 // Split the raw text exactly once here (the line viewer and find-in-file both consume this
145155 // list) and cap it so a huge file doesn't get split twice or overwhelm the LazyColumn.
146156 val allLines = remember(rawText) { rawText.split("\n") }
@@ -158,6 +168,9 @@ fun FileViewScreen(
158168 lineCount = lines.size,
159169 listState = listState,
160170 )
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) }
161174 // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
162175 // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
163176 var debouncedFind by remember { mutableStateOf("") }
@@ -165,11 +178,13 @@ fun FileViewScreen(
165178 val q = findQuery.trim()
166179 if (q.isEmpty()) debouncedFind = "" else { delay(150); debouncedFind = q }
167180 }
168− val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind) {
181+ val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind, caseSensitive) {
169182 val q = debouncedFind
170183 value = if (q.isEmpty()) emptyList()
171184 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) }
187+ }
173188 }
174189 }
175190 // Keep the current match index in range when the match set shrinks (e.g. the file
@@ -181,10 +196,19 @@ fun FileViewScreen(
181196 LaunchedEffect(matchPos, matchIndices) {
182197 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
183198 }
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) } }
206+ }
184207 Scaffold(
185208 topBar = {
186209 FileViewTopBar(
187210 filename = filename,
211+ subtitle = pathSubtitle,
188212 loading = state.loading,
189213 content = rawText,
190214 wrap = wrap,
@@ -203,6 +227,7 @@ fun FileViewScreen(
203227 }
204228 },
205229 onToggleWrap = { wrap = !wrap },
230+ onGoToLine = { showGoToLine = true },
206231 onCopy = { copyToClipboard(context, filename, rawText) },
207232 onCopyPath = { copyToClipboard(context, context.getString(R.string.clip_label_path), path) },
208233 onShare = {
@@ -270,11 +295,81 @@ fun FileViewScreen(
270295 highlightLineIndex = highlightLine.value,
271296 listState = listState,
272297 onRetry = { vm.reload() },
298+ caseSensitive = caseSensitive,
299+ onToggleCaseSensitive = { caseSensitive = !caseSensitive },
300+ onShareFull = {
301+ val send = Intent(Intent.ACTION_SEND).apply {
302+ type = "text/plain"
303+ putExtra(Intent.EXTRA_SUBJECT, filename)
304+ putExtra(Intent.EXTRA_TEXT, rawText)
305+ }
306+ runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
307+ .onFailure { scope.launch { snackbar.showSnackbar(context.getString(R.string.no_share_app)) } }
308+ },
273309 )
274310 }
275311 }
276312 }
277313 }
314+
315+ GoToLineLauncher(
316+ visible = showGoToLine,
317+ maxLine = lines.size,
318+ onConfirm = { n -> showGoToLine = false; goToLine(n) },
319+ onDismiss = { showGoToLine = false },
320+ )
321+}
322+
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).
325+@Composable
326+private fun GoToLineLauncher(
327+ visible: Boolean,
328+ maxLine: Int,
329+ onConfirm: (Int) -> Unit,
330+ onDismiss: () -> Unit,
331+) {
332+ if (visible) {
333+ GoToLineDialog(maxLine = maxLine, onConfirm = onConfirm, onDismiss = onDismiss)
334+ }
335+}
336+
337+@Composable
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
342+ AlertDialog(
343+ onDismissRequest = onDismiss,
344+ title = { Text(stringResource(R.string.go_to_line)) },
345+ text = {
346+ OutlinedTextField(
347+ value = text,
348+ onValueChange = { v -> text = v.filter { it.isDigit() } },
349+ singleLine = true,
350+ isError = text.isNotEmpty() && !valid,
351+ supportingText = {
352+ Text(
353+ stringResource(R.string.go_to_line_hint, maxLine),
354+ style = MaterialTheme.typography.bodySmall,
355+ )
356+ },
357+ keyboardOptions = KeyboardOptions(
358+ keyboardType = KeyboardType.Number,
359+ imeAction = ImeAction.Done,
360+ ),
361+ keyboardActions = KeyboardActions(onDone = { parsed?.let { onConfirm(it) } }),
362+ )
363+ },
364+ confirmButton = {
365+ TextButton(onClick = { parsed?.let { onConfirm(it) } }, enabled = valid) {
366+ Text(stringResource(R.string.go_to_line))
367+ }
368+ },
369+ dismissButton = {
370+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
371+ },
372+ )
278373}
279374
280375// 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(
312407@Composable
313408private fun FileViewTopBar(
314409 filename: String,
410+ subtitle: String,
315411 loading: Boolean,
316412 content: String,
317413 wrap: Boolean,
@@ -320,12 +416,29 @@ private fun FileViewTopBar(
320416 onReload: () -> Unit,
321417 onToggleFind: () -> Unit,
322418 onToggleWrap: () -> Unit,
419+ onGoToLine: () -> Unit,
323420 onCopy: () -> Unit,
324421 onCopyPath: () -> Unit,
325422 onShare: () -> Unit,
326423) {
327424 TopAppBar(
328− title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
425+ title = {
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.
429+ Column {
430+ Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis)
431+ if (subtitle.isNotBlank()) {
432+ Text(
433+ subtitle,
434+ maxLines = 1,
435+ overflow = TextOverflow.Ellipsis,
436+ style = MaterialTheme.typography.bodySmall,
437+ color = MaterialTheme.colorScheme.onSurfaceVariant,
438+ )
439+ }
440+ }
441+ },
329442 navigationIcon = {
330443 IconButton(onClick = onBack) {
331444 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
@@ -365,6 +478,11 @@ private fun FileViewTopBar(
365478 // Wrap long lines instead of horizontal-scrolling; useful for prose. Disabled
366479 // in diff mode because DiffView ignores wrap, so an active-looking toggle there
367480 // would be a silent no-op.
481+ DropdownMenuItem(
482+ text = { Text(stringResource(R.string.go_to_line)) },
483+ onClick = { onGoToLine(); menuExpanded = false },
484+ leadingIcon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
485+ )
368486 DropdownMenuItem(
369487 text = { Text(stringResource(R.string.wrap_lines)) },
370488 onClick = { onToggleWrap(); menuExpanded = false },
@@ -419,6 +537,9 @@ private fun BoxScope.FileViewStateContent(
419537 highlightLineIndex: Int?,
420538 listState: LazyListState,
421539 onRetry: () -> Unit,
540+ onShareFull: () -> Unit,
541+ caseSensitive: Boolean,
542+ onToggleCaseSensitive: () -> Unit,
422543) {
423544 when {
424545 // Full-screen spinner only on the initial load (no content yet). A reload keeps the
@@ -461,6 +582,9 @@ private fun BoxScope.FileViewStateContent(
461582 truncated = truncated,
462583 highlightLineIndex = highlightLineIndex,
463584 listState = listState,
585+ onShareFull = onShareFull,
586+ caseSensitive = caseSensitive,
587+ onToggleCaseSensitive = onToggleCaseSensitive,
464588 )
465589 }
466590}
@@ -485,6 +609,9 @@ private fun BoxScope.FileViewContentBody(
485609 truncated: Boolean,
486610 highlightLineIndex: Int?,
487611 listState: LazyListState,
612+ onShareFull: () -> Unit,
613+ caseSensitive: Boolean,
614+ onToggleCaseSensitive: () -> Unit,
488615) {
489616 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
490617 // reachable while scrolling. Each contributes a top inset so the content below
@@ -543,6 +670,8 @@ private fun BoxScope.FileViewContentBody(
543670 onPrev = onPrev,
544671 onNext = onNext,
545672 onClose = onCloseFind,
673+ caseSensitive = caseSensitive,
674+ onToggleCaseSensitive = onToggleCaseSensitive,
546675 )
547676 }
548677 }
@@ -575,6 +704,7 @@ private fun BoxScope.FileViewContentBody(
575704 matchIndices = matchIndices,
576705 highlightLineIndex = highlightLineIndex,
577706 listState = listState,
707+ onShareFull = onShareFull,
578708 )
579709 }
580710 }
@@ -589,8 +719,11 @@ private fun FindBar(
589719 onPrev: () -> Unit,
590720 onNext: () -> Unit,
591721 onClose: () -> Unit,
722+ caseSensitive: Boolean,
723+ onToggleCaseSensitive: () -> Unit,
592724) {
593725 val keyboardController = LocalSoftwareKeyboardController.current
726+ val matchCaseLabel = stringResource(R.string.match_case)
594727 Row(
595728 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
596729 verticalAlignment = Alignment.CenterVertically,
@@ -604,22 +737,33 @@ private fun FindBar(
604737 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
605738 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
606739 trailingIcon = {
607− val countText = if (query.isBlank()) ""
608− else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
609− else stringResource(R.string.match_count, matchPos + 1, matchCount)
610− if (countText.isNotEmpty()) {
611− // Use error color for "no matches" so the zero-result state is
612− // distinguishable from a positive match count at a glance.
613− val color = if (query.isNotBlank() && matchCount == 0) MaterialTheme.colorScheme.error
614− else MaterialTheme.colorScheme.onSurfaceVariant
615− Text(
616− countText,
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,
621− color = color,
740+ Row(verticalAlignment = Alignment.CenterVertically) {
741+ // Case-sensitivity toggle. A small "Aa" chip keeps the bar compact while
742+ // surfacing a previously substring-only, always-case-insensitive search.
743+ FilterChip(
744+ selected = caseSensitive,
745+ onClick = onToggleCaseSensitive,
746+ label = { Text("Aa", style = MaterialTheme.typography.labelSmall) },
747+ modifier = Modifier.semantics { contentDescription = matchCaseLabel },
622748 )
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
758+ Text(
759+ countText,
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,
764+ color = color,
765+ )
766+ }
623767 }
624768 },
625769 )
@@ -646,6 +790,7 @@ private fun FileTextContent(
646790 matchIndices: List<Int>,
647791 highlightLineIndex: Int?,
648792 listState: LazyListState,
793+ onShareFull: () -> Unit,
649794) {
650795 // Honor the user's chat text-size preference (the same one the chat markdown honors) so
651796 // a user who bumped the scale for readability gets the same scale in the code viewer.
@@ -674,49 +819,67 @@ private fun FileTextContent(
674819 MaterialTheme(typography = scaled) {
675820 Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
676821 if (truncated) {
677− Text(
678− stringResource(R.string.file_truncated, MAX_RENDERED_LINES),
679− style = MaterialTheme.typography.labelSmall,
680− color = MaterialTheme.colorScheme.onSurfaceVariant,
822+ androidx.compose.foundation.layout.Row(
681823 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
682− )
824+ verticalAlignment = Alignment.CenterVertically,
825+ horizontalArrangement = Arrangement.SpaceBetween,
826+ ) {
827+ Text(
828+ stringResource(R.string.file_truncated, MAX_RENDERED_LINES),
829+ style = MaterialTheme.typography.labelSmall,
830+ color = MaterialTheme.colorScheme.onSurfaceVariant,
831+ modifier = Modifier.weight(1f),
832+ )
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))
838+ }
839+ }
683840 }
684− LazyColumn(
685− state = listState,
686− modifier = Modifier
687− .weight(1f)
688− .fillMaxWidth()
689− .padding(8.dp),
690− ) {
691− itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
692− val isMatch = q.isNotEmpty() && index in matchSet
693− // 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.
695− val isTarget = index == highlightLineIndex
696− Row(
697− modifier = Modifier
698− .fillMaxWidth()
699− .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
700− .then(if (isMatch || isTarget) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
701− ) {
702− Text(
703− "${index + 1}",
704− modifier = Modifier.width(gutterWidth),
705− style = MaterialTheme.typography.bodySmall,
706− fontFamily = FontFamily.Monospace,
707− color = MaterialTheme.colorScheme.onSurfaceVariant,
708− )
709− // Heuristic syntax highlighting per line. Falls back to plain text for
710− // unknown extensions, so non-code files render unchanged. Memoized so the
711− // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
712− // 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).
714− val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
715− Text(
716− highlighted,
717− modifier = Modifier.padding(start = 8.dp),
718− style = MaterialTheme.typography.bodySmall,
719− )
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 {
846+ LazyColumn(
847+ state = listState,
848+ modifier = Modifier
849+ .weight(1f)
850+ .fillMaxWidth()
851+ .padding(8.dp),
852+ ) {
853+ itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
854+ val isMatch = q.isNotEmpty() && index in matchSet
855+ // The jump-to-line target keeps the same emphasis as a find match so the line
856+ // the viewer landed on is obvious until the user scrolls or starts a find.
857+ val isTarget = index == highlightLineIndex
858+ Row(
859+ modifier = Modifier
860+ .fillMaxWidth()
861+ .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
862+ .then(if (isMatch || isTarget) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
863+ ) {
864+ Text(
865+ "${index + 1}",
866+ modifier = Modifier.width(gutterWidth),
867+ style = MaterialTheme.typography.bodySmall,
868+ fontFamily = FontFamily.Monospace,
869+ color = MaterialTheme.colorScheme.onSurfaceVariant,
870+ )
871+ // Heuristic syntax highlighting per line. Falls back to plain text for
872+ // unknown extensions, so non-code files render unchanged. Memoized so the
873+ // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
874+ // file, or palette actually changes — not on every recomposition (e.g. every
875+ // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
876+ val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
877+ Text(
878+ highlighted,
879+ modifier = Modifier.padding(start = 8.dp),
880+ style = MaterialTheme.typography.bodySmall,
881+ )
882+ }
720883 }
721884 }
722885 }