@@ -9,6 +9,7 @@ import androidx.compose.animation.fadeOut
99import androidx.compose.animation.togetherWith
1010import androidx.compose.foundation.background
1111import androidx.compose.foundation.horizontalScroll
12+import androidx.compose.foundation.interaction.DragInteraction
1213import androidx.compose.foundation.layout.Box
1314import androidx.compose.foundation.layout.BoxScope
1415import androidx.compose.foundation.layout.Column
@@ -35,10 +36,13 @@ import androidx.compose.material.icons.filled.Close
3536import androidx.compose.material.icons.filled.ContentCopy
3637import androidx.compose.material.icons.filled.KeyboardArrowDown
3738import androidx.compose.material.icons.filled.KeyboardArrowUp
39+import androidx.compose.material.icons.filled.MoreVert
3840import androidx.compose.material.icons.filled.Refresh
3941import androidx.compose.material.icons.filled.Search
4042import androidx.compose.material.icons.filled.Share
4143import androidx.compose.material3.CircularProgressIndicator
44+import androidx.compose.material3.DropdownMenu
45+import androidx.compose.material3.DropdownMenuItem
4246import androidx.compose.material3.ExperimentalMaterial3Api
4347import androidx.compose.material3.FilterChip
4448import androidx.compose.material3.Icon
@@ -51,6 +55,7 @@ import androidx.compose.material3.TextButton
5155import androidx.compose.material3.TopAppBar
5256import androidx.compose.runtime.Composable
5357import androidx.compose.runtime.LaunchedEffect
58+import androidx.compose.runtime.MutableState
5459import androidx.compose.runtime.getValue
5560import androidx.compose.runtime.mutableIntStateOf
5661import androidx.compose.runtime.mutableStateOf
@@ -85,8 +90,14 @@ import soy.iko.opencode.ui.vmFactory
8590import soy.iko.opencode.util.runCatchingCancellable
8691import kotlinx.coroutines.Dispatchers
8792import kotlinx.coroutines.delay
93+import kotlinx.coroutines.flow.collect
8894import kotlinx.coroutines.withContext
8995
96+/** Cap on how many lines the raw viewer renders and searches. A multi-megabyte file is
97+ * split once (not twice) into this many lines at most, so it can't hold two full line
98+ * lists in memory or stall the LazyColumn; beyond the cap a truncation banner is shown. */
99+private const val MAX_RENDERED_LINES = 5000
100+
90101@OptIn(ExperimentalMaterial3Api::class)
91102@Composable
92103fun FileViewScreen(
@@ -121,7 +132,23 @@ fun FileViewScreen(
121132 var wrap by rememberSaveable(path) { mutableStateOf(codeWrap) }
122133 val listState = rememberLazyListState()
123134 val rawText = state.content?.content.orEmpty()
124− val lines = remember(rawText) { rawText.split("\n") }
135+ // Split the raw text exactly once here (the line viewer and find-in-file both consume this
136+ // list) and cap it so a huge file doesn't get split twice or overwhelm the LazyColumn.
137+ val allLines = remember(rawText) { rawText.split("\n") }
138+ val truncated = allLines.size > MAX_RENDERED_LINES
139+ val lines = remember(allLines) {
140+ if (allLines.size > MAX_RENDERED_LINES) allLines.subList(0, MAX_RENDERED_LINES) else allLines
141+ }
142+ // The line the viewer was opened at (from a search hit), highlighted until the user scrolls
143+ // or starts an in-file find so the landed line stands out. Null once cleared.
144+ val highlightLine = rememberJumpToLineHighlight(
145+ path = path,
146+ initialLine = initialLine,
147+ content = state.content,
148+ isBinary = state.content?.isBinary == true,
149+ lineCount = lines.size,
150+ listState = listState,
151+ )
125152 // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
126153 // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
127154 var debouncedFind by remember { mutableStateOf("") }
@@ -145,19 +172,6 @@ fun FileViewScreen(
145172 LaunchedEffect(matchPos, matchIndices) {
146173 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
147174 }
148− // Opened at a specific line (from a search hit): scroll there once the raw content loads.
149− // One-shot so a later find-in-file navigation isn't overridden.
150− var didInitialLineScroll by rememberSaveable(path) { mutableStateOf(false) }
151− LaunchedEffect(state.content, initialLine) {
152− val line = initialLine ?: return@LaunchedEffect
153− if (didInitialLineScroll) return@LaunchedEffect
154− val content = state.content ?: return@LaunchedEffect
155− if (content.isBinary) return@LaunchedEffect
156− val target = (line - 1).coerceIn(0, (lines.size - 1).coerceAtLeast(0))
157− runCatchingCancellable { listState.scrollToItem(target) }
158− didInitialLineScroll = true
159− }
160−
161175 Scaffold(
162176 topBar = {
163177 FileViewTopBar(
@@ -165,6 +179,7 @@ fun FileViewScreen(
165179 loading = state.loading,
166180 content = rawText,
167181 wrap = wrap,
182+ diffShown = showToggle && showDiff,
168183 onBack = onBack,
169184 onReload = { vm.reload() },
170185 // Find searches the raw line list, which isn't on screen under a diff. Rather
@@ -222,13 +237,16 @@ fun FileViewScreen(
222237 },
223238 findActive = findActive,
224239 findQuery = findQuery,
225− onQueryChange = { findQuery = it; matchPos = 0 },
240+ onQueryChange = { findQuery = it; matchPos = 0; if (it.isNotEmpty()) highlightLine.value = null },
226241 matchIndices = matchIndices,
227242 matchPos = matchPos,
228243 onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
229244 onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
230245 onCloseFind = { findActive = false; findQuery = "" },
231246 wrap = wrap,
247+ lines = lines,
248+ truncated = truncated,
249+ highlightLineIndex = highlightLine.value,
232250 listState = listState,
233251 onRetry = { vm.reload() },
234252 )
@@ -237,6 +255,37 @@ fun FileViewScreen(
237255 }
238256}
239257
258+// Owns the "jump to a line from a search hit" behaviour: scroll to the target once the raw
259+// content loads (one-shot), and clear the highlight the moment the user drags the list by hand.
260+// Extracted so FileViewScreen stays under the cyclomatic-complexity threshold.
261+@Composable
262+private fun rememberJumpToLineHighlight(
263+ path: String,
264+ initialLine: Int?,
265+ content: Any?,
266+ isBinary: Boolean,
267+ lineCount: Int,
268+ listState: LazyListState,
269+): MutableState<Int?> {
270+ val highlight = rememberSaveable(path) { mutableStateOf<Int?>(null) }
271+ var didInitialScroll by rememberSaveable(path) { mutableStateOf(false) }
272+ LaunchedEffect(content, initialLine) {
273+ val line = initialLine ?: return@LaunchedEffect
274+ if (didInitialScroll || content == null || isBinary) return@LaunchedEffect
275+ val target = (line - 1).coerceIn(0, (lineCount - 1).coerceAtLeast(0))
276+ runCatchingCancellable { listState.scrollToItem(target) }
277+ highlight.value = target
278+ didInitialScroll = true
279+ }
280+ LaunchedEffect(listState) {
281+ if (initialLine == null) return@LaunchedEffect
282+ listState.interactionSource.interactions.collect { interaction ->
283+ if (interaction is DragInteraction.Start) highlight.value = null
284+ }
285+ }
286+ return highlight
287+}
288+
240289@OptIn(ExperimentalMaterial3Api::class)
241290@Composable
242291private fun FileViewTopBar(
@@ -244,6 +293,7 @@ private fun FileViewTopBar(
244293 loading: Boolean,
245294 content: String,
246295 wrap: Boolean,
296+ diffShown: Boolean,
247297 onBack: () -> Unit,
248298 onReload: () -> Unit,
249299 onToggleFind: () -> Unit,
@@ -263,9 +313,13 @@ private fun FileViewTopBar(
263313 // be editing it), so reload without requiring a back-out. Shows a spinner while
264314 // loading since the content now stays on screen during a reload (the spinner is
265315 // the only progress signal).
316+ val loadingLabel = stringResource(R.string.loading)
266317 IconButton(onClick = onReload, enabled = !loading) {
267318 if (loading) {
268− CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
319+ CircularProgressIndicator(
320+ Modifier.size(20.dp).semantics { contentDescription = loadingLabel },
321+ strokeWidth = 2.dp,
322+ )
269323 } else {
270324 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
271325 }
@@ -278,19 +332,38 @@ private fun FileViewTopBar(
278332 IconButton(onClick = onToggleFind) {
279333 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
280334 }
281− // Wrap long lines instead of horizontal-scrolling, useful for prose.
282− IconButton(onClick = onToggleWrap) {
283− Icon(
284− Icons.AutoMirrored.Filled.WrapText,
285− contentDescription = stringResource(R.string.wrap_lines),
286− tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
287− )
288− }
289− IconButton(onClick = onCopy) {
290− Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy))
335+ // Overflow the less-frequent actions (wrap / copy / share) so a long filename
336+ // title isn't crowded off screen by five inline icons on a phone.
337+ var menuExpanded by remember { mutableStateOf(false) }
338+ IconButton(onClick = { menuExpanded = true }) {
339+ Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options))
291340 }
292− IconButton(onClick = onShare) {
293− Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
341+ DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
342+ // Wrap long lines instead of horizontal-scrolling; useful for prose. Disabled
343+ // in diff mode because DiffView ignores wrap, so an active-looking toggle there
344+ // would be a silent no-op.
345+ DropdownMenuItem(
346+ text = { Text(stringResource(R.string.wrap_lines)) },
347+ onClick = { onToggleWrap(); menuExpanded = false },
348+ enabled = !diffShown,
349+ leadingIcon = {
350+ Icon(
351+ Icons.AutoMirrored.Filled.WrapText,
352+ contentDescription = null,
353+ tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
354+ )
355+ },
356+ )
357+ DropdownMenuItem(
358+ text = { Text(stringResource(R.string.copy)) },
359+ onClick = { onCopy(); menuExpanded = false },
360+ leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) },
361+ )
362+ DropdownMenuItem(
363+ text = { Text(stringResource(R.string.share)) },
364+ onClick = { onShare(); menuExpanded = false },
365+ leadingIcon = { Icon(Icons.Filled.Share, contentDescription = null) },
366+ )
294367 }
295368 }
296369 },
@@ -313,6 +386,9 @@ private fun BoxScope.FileViewStateContent(
313386 onNext: () -> Unit,
314387 onCloseFind: () -> Unit,
315388 wrap: Boolean,
389+ lines: List<String>,
390+ truncated: Boolean,
391+ highlightLineIndex: Int?,
316392 listState: LazyListState,
317393 onRetry: () -> Unit,
318394) {
@@ -353,6 +429,9 @@ private fun BoxScope.FileViewStateContent(
353429 onNext = onNext,
354430 onCloseFind = onCloseFind,
355431 wrap = wrap,
432+ lines = lines,
433+ truncated = truncated,
434+ highlightLineIndex = highlightLineIndex,
356435 listState = listState,
357436 )
358437 }
@@ -374,6 +453,9 @@ private fun BoxScope.FileViewContentBody(
374453 onNext: () -> Unit,
375454 onCloseFind: () -> Unit,
376455 wrap: Boolean,
456+ lines: List<String>,
457+ truncated: Boolean,
458+ highlightLineIndex: Int?,
377459 listState: LazyListState,
378460) {
379461 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
@@ -456,12 +538,14 @@ private fun BoxScope.FileViewContentBody(
456538 )
457539 } else {
458540 FileTextContent(
459− text = text,
541+ lines = lines,
460542 filename = filename,
461543 topInset = topInset,
544+ truncated = truncated,
462545 wrap = wrap,
463546 findQuery = findQuery,
464547 matchIndices = matchIndices,
548+ highlightLineIndex = highlightLineIndex,
465549 listState = listState,
466550 )
467551 }
@@ -522,15 +606,16 @@ private fun FindBar(
522606
523607@Composable
524608private fun FileTextContent(
525− text: String,
609+ lines: List<String>,
526610 filename: String,
527611 topInset: androidx.compose.ui.unit.Dp,
612+ truncated: Boolean,
528613 wrap: Boolean,
529614 findQuery: String,
530615 matchIndices: List<Int>,
616+ highlightLineIndex: Int?,
531617 listState: LazyListState,
532618) {
533− val lines = remember(text) { text.split("\n") }
534619 // Gutter width scales with the digit count and the font scale so accessibility
535620 // text scaling (e.g. 1.3x) doesn't make line numbers overflow the gutter and
536621 // collide with the code text. 10dp/digit covers bodySmall at 1.0x; scale up
@@ -545,39 +630,52 @@ private fun FileTextContent(
545630 val syntax = remember(filename) { syntaxFor(filename) }
546631 val q = findQuery.trim()
547632 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
548− LazyColumn(
549− state = listState,
550− modifier = Modifier
551− .fillMaxSize()
552− .padding(top = topInset)
553− .padding(8.dp),
554− ) {
555− itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
556− val isMatch = q.isNotEmpty() && index in matchSet
557− Row(
558− modifier = Modifier
559− .fillMaxWidth()
560− .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
561− .then(if (isMatch) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
562− ) {
563− Text(
564− "${index + 1}",
565− modifier = Modifier.width(gutterWidth),
566− style = MaterialTheme.typography.bodySmall,
567− fontFamily = FontFamily.Monospace,
568− color = MaterialTheme.colorScheme.onSurfaceVariant,
569− )
570− // Heuristic syntax highlighting per line. Falls back to plain text for
571− // unknown extensions, so non-code files render unchanged. Memoized so the
572− // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
573− // file, or palette actually changes — not on every recomposition (e.g. every
574− // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
575− val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
576− Text(
577− highlighted,
578− modifier = Modifier.padding(start = 8.dp),
579− style = MaterialTheme.typography.bodySmall,
580− )
633+ Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
634+ if (truncated) {
635+ Text(
636+ stringResource(R.string.file_truncated, MAX_RENDERED_LINES),
637+ style = MaterialTheme.typography.labelSmall,
638+ color = MaterialTheme.colorScheme.onSurfaceVariant,
639+ modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
640+ )
641+ }
642+ LazyColumn(
643+ state = listState,
644+ modifier = Modifier
645+ .weight(1f)
646+ .fillMaxWidth()
647+ .padding(8.dp),
648+ ) {
649+ itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
650+ val isMatch = q.isNotEmpty() && index in matchSet
651+ // The jump-to-line target keeps the same emphasis as a find match so the line
652+ // the viewer landed on is obvious until the user scrolls or starts a find.
653+ val isTarget = index == highlightLineIndex
654+ Row(
655+ modifier = Modifier
656+ .fillMaxWidth()
657+ .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
658+ .then(if (isMatch || isTarget) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
659+ ) {
660+ Text(
661+ "${index + 1}",
662+ modifier = Modifier.width(gutterWidth),
663+ style = MaterialTheme.typography.bodySmall,
664+ fontFamily = FontFamily.Monospace,
665+ color = MaterialTheme.colorScheme.onSurfaceVariant,
666+ )
667+ // Heuristic syntax highlighting per line. Falls back to plain text for
668+ // unknown extensions, so non-code files render unchanged. Memoized so the
669+ // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
670+ // file, or palette actually changes — not on every recomposition (e.g. every
671+ // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
672+ val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
673+ Text(
674+ highlighted,
675+ modifier = Modifier.padding(start = 8.dp),
676+ style = MaterialTheme.typography.bodySmall,
677+ )
678+ }
581679 }
582680 }
583681 }