@@ -9,6 +9,7 @@ import androidx.compose.animation.fadeOut
9import androidx.compose.animation.togetherWith
9import androidx.compose.animation.togetherWith
10import androidx.compose.foundation.background
10import androidx.compose.foundation.background
11import androidx.compose.foundation.horizontalScroll
11import androidx.compose.foundation.horizontalScroll
12+import androidx.compose.foundation.interaction.DragInteraction
12import androidx.compose.foundation.layout.Box
13import androidx.compose.foundation.layout.Box
13import androidx.compose.foundation.layout.BoxScope
14import androidx.compose.foundation.layout.BoxScope
14import androidx.compose.foundation.layout.Column
15import androidx.compose.foundation.layout.Column
@@ -35,10 +36,13 @@ import androidx.compose.material.icons.filled.Close
35import androidx.compose.material.icons.filled.ContentCopy
36import androidx.compose.material.icons.filled.ContentCopy
36import androidx.compose.material.icons.filled.KeyboardArrowDown
37import androidx.compose.material.icons.filled.KeyboardArrowDown
37import androidx.compose.material.icons.filled.KeyboardArrowUp
38import androidx.compose.material.icons.filled.KeyboardArrowUp
39+import androidx.compose.material.icons.filled.MoreVert
38import androidx.compose.material.icons.filled.Refresh
40import androidx.compose.material.icons.filled.Refresh
39import androidx.compose.material.icons.filled.Search
41import androidx.compose.material.icons.filled.Search
40import androidx.compose.material.icons.filled.Share
42import androidx.compose.material.icons.filled.Share
41import androidx.compose.material3.CircularProgressIndicator
43import androidx.compose.material3.CircularProgressIndicator
44+import androidx.compose.material3.DropdownMenu
45+import androidx.compose.material3.DropdownMenuItem
42import androidx.compose.material3.ExperimentalMaterial3Api
46import androidx.compose.material3.ExperimentalMaterial3Api
43import androidx.compose.material3.FilterChip
47import androidx.compose.material3.FilterChip
44import androidx.compose.material3.Icon
48import androidx.compose.material3.Icon
@@ -51,6 +55,7 @@ import androidx.compose.material3.TextButton
51import androidx.compose.material3.TopAppBar
55import androidx.compose.material3.TopAppBar
52import androidx.compose.runtime.Composable
56import androidx.compose.runtime.Composable
53import androidx.compose.runtime.LaunchedEffect
57import androidx.compose.runtime.LaunchedEffect
58+import androidx.compose.runtime.MutableState
54import androidx.compose.runtime.getValue
59import androidx.compose.runtime.getValue
55import androidx.compose.runtime.mutableIntStateOf
60import androidx.compose.runtime.mutableIntStateOf
56import androidx.compose.runtime.mutableStateOf
61import androidx.compose.runtime.mutableStateOf
@@ -85,8 +90,14 @@ import soy.iko.opencode.ui.vmFactory
85import soy.iko.opencode.util.runCatchingCancellable
90import soy.iko.opencode.util.runCatchingCancellable
86import kotlinx.coroutines.Dispatchers
91import kotlinx.coroutines.Dispatchers
87import kotlinx.coroutines.delay
92import kotlinx.coroutines.delay
93+import kotlinx.coroutines.flow.collect
88import kotlinx.coroutines.withContext
94import kotlinx.coroutines.withContext
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
90@OptIn(ExperimentalMaterial3Api::class)
101@OptIn(ExperimentalMaterial3Api::class)
91@Composable
102@Composable
92fun FileViewScreen(
103fun FileViewScreen(
@@ -121,7 +132,23 @@ fun FileViewScreen(
121 var wrap by rememberSaveable(path) { mutableStateOf(codeWrap) }
132 var wrap by rememberSaveable(path) { mutableStateOf(codeWrap) }
122 val listState = rememberLazyListState()
133 val listState = rememberLazyListState()
123 val rawText = state.content?.content.orEmpty()
134 val rawText = state.content?.content.orEmpty()
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.
124− val lines = remember(rawText) { rawText.split("\n") }
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
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(
146+ initialLine = initialLine,
147+ content = state.content,
148+ isBinary = state.content?.isBinary == true,
149+ lineCount = lines.size,
150+ listState = listState,
125 // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
152 // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
126 // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
153 // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
127 var debouncedFind by remember { mutableStateOf("") }
154 var debouncedFind by remember { mutableStateOf("") }
@@ -145,19 +172,6 @@ fun FileViewScreen(
145 LaunchedEffect(matchPos, matchIndices) {
172 LaunchedEffect(matchPos, matchIndices) {
146 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
173 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
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
161 Scaffold(
175 Scaffold(
162 topBar = {
176 topBar = {
163 FileViewTopBar(
177 FileViewTopBar(
@@ -165,6 +179,7 @@ fun FileViewScreen(
165 loading = state.loading,
179 loading = state.loading,
166 content = rawText,
180 content = rawText,
167 wrap = wrap,
181 wrap = wrap,
182+ diffShown = showToggle && showDiff,
168 onBack = onBack,
183 onBack = onBack,
169 onReload = { vm.reload() },
184 onReload = { vm.reload() },
170 // Find searches the raw line list, which isn't on screen under a diff. Rather
185 // Find searches the raw line list, which isn't on screen under a diff. Rather
@@ -222,13 +237,16 @@ fun FileViewScreen(
223 findActive = findActive,
238 findActive = findActive,
224 findQuery = findQuery,
239 findQuery = findQuery,
225− onQueryChange = { findQuery = it; matchPos = 0 },
240+ onQueryChange = { findQuery = it; matchPos = 0; if (it.isNotEmpty()) highlightLine.value = null },
226 matchIndices = matchIndices,
241 matchIndices = matchIndices,
227 matchPos = matchPos,
242 matchPos = matchPos,
228 onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
243 onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
229 onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
244 onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
230 onCloseFind = { findActive = false; findQuery = "" },
245 onCloseFind = { findActive = false; findQuery = "" },
231 wrap = wrap,
246 wrap = wrap,
248+ truncated = truncated,
249+ highlightLineIndex = highlightLine.value,
232 listState = listState,
250 listState = listState,
233 onRetry = { vm.reload() },
251 onRetry = { vm.reload() },
@@ -237,6 +255,37 @@ fun FileViewScreen(
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.
262+private fun rememberJumpToLineHighlight(
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
280+ LaunchedEffect(listState) {
281+ if (initialLine == null) return@LaunchedEffect
282+ listState.interactionSource.interactions.collect { interaction ->
283+ if (interaction is DragInteraction.Start) highlight.value = null
240@OptIn(ExperimentalMaterial3Api::class)
289@OptIn(ExperimentalMaterial3Api::class)
241@Composable
290@Composable
242private fun FileViewTopBar(
291private fun FileViewTopBar(
@@ -244,6 +293,7 @@ private fun FileViewTopBar(
244 loading: Boolean,
293 loading: Boolean,
245 content: String,
294 content: String,
246 wrap: Boolean,
295 wrap: Boolean,
247 onBack: () -> Unit,
297 onBack: () -> Unit,
248 onReload: () -> Unit,
298 onReload: () -> Unit,
249 onToggleFind: () -> Unit,
299 onToggleFind: () -> Unit,
@@ -263,9 +313,13 @@ private fun FileViewTopBar(
263 // be editing it), so reload without requiring a back-out. Shows a spinner while
313 // be editing it), so reload without requiring a back-out. Shows a spinner while
264 // loading since the content now stays on screen during a reload (the spinner is
314 // loading since the content now stays on screen during a reload (the spinner is
265 // the only progress signal).
315 // the only progress signal).
316+ val loadingLabel = stringResource(R.string.loading)
266 IconButton(onClick = onReload, enabled = !loading) {
317 IconButton(onClick = onReload, enabled = !loading) {
267 if (loading) {
318 if (loading) {
268− CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
319+ CircularProgressIndicator(
320+ Modifier.size(20.dp).semantics { contentDescription = loadingLabel },
270 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
324 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
@@ -278,19 +332,38 @@ private fun FileViewTopBar(
278 IconButton(onClick = onToggleFind) {
332 IconButton(onClick = onToggleFind) {
279 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
333 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
281− // Wrap long lines instead of horizontal-scrolling, useful for prose.
335+ // Overflow the less-frequent actions (wrap / copy / share) so a long filename
282− IconButton(onClick = onToggleWrap) {
336+ // title isn't crowded off screen by five inline icons on a phone.
283− Icon(
337+ var menuExpanded by remember { mutableStateOf(false) }
284− Icons.AutoMirrored.Filled.WrapText,
338+ IconButton(onClick = { menuExpanded = true }) {
285− contentDescription = stringResource(R.string.wrap_lines),
339+ Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options))
286− tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
289− IconButton(onClick = onCopy) {
290− Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy))
292− IconButton(onClick = onShare) {
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.
346+ text = { Text(stringResource(R.string.wrap_lines)) },
347+ onClick = { onToggleWrap(); menuExpanded = false },
348+ enabled = !diffShown,
351+ Icons.AutoMirrored.Filled.WrapText,
352+ contentDescription = null,
353+ tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
358+ text = { Text(stringResource(R.string.copy)) },
359+ onClick = { onCopy(); menuExpanded = false },
360+ leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) },
363+ text = { Text(stringResource(R.string.share)) },
364+ onClick = { onShare(); menuExpanded = false },
293− Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
365+ leadingIcon = { Icon(Icons.Filled.Share, contentDescription = null) },
@@ -313,6 +386,9 @@ private fun BoxScope.FileViewStateContent(
313 onNext: () -> Unit,
386 onNext: () -> Unit,
314 onCloseFind: () -> Unit,
387 onCloseFind: () -> Unit,
315 wrap: Boolean,
388 wrap: Boolean,
389+ lines: List<String>,
391+ highlightLineIndex: Int?,
316 listState: LazyListState,
392 listState: LazyListState,
317 onRetry: () -> Unit,
393 onRetry: () -> Unit,
@@ -353,6 +429,9 @@ private fun BoxScope.FileViewStateContent(
353 onNext = onNext,
429 onNext = onNext,
354 onCloseFind = onCloseFind,
430 onCloseFind = onCloseFind,
355 wrap = wrap,
431 wrap = wrap,
433+ truncated = truncated,
434+ highlightLineIndex = highlightLineIndex,
356 listState = listState,
435 listState = listState,
@@ -374,6 +453,9 @@ private fun BoxScope.FileViewContentBody(
374 onNext: () -> Unit,
453 onNext: () -> Unit,
375 onCloseFind: () -> Unit,
454 onCloseFind: () -> Unit,
376 wrap: Boolean,
455 wrap: Boolean,
456+ lines: List<String>,
458+ highlightLineIndex: Int?,
377 listState: LazyListState,
459 listState: LazyListState,
379 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
461 // 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(
458 FileTextContent(
540 FileTextContent(
459− text = text,
541+ lines = lines,
460 filename = filename,
542 filename = filename,
461 topInset = topInset,
543 topInset = topInset,
544+ truncated = truncated,
462 wrap = wrap,
545 wrap = wrap,
463 findQuery = findQuery,
546 findQuery = findQuery,
464 matchIndices = matchIndices,
547 matchIndices = matchIndices,
548+ highlightLineIndex = highlightLineIndex,
465 listState = listState,
549 listState = listState,
@@ -522,15 +606,16 @@ private fun FindBar(
523@Composable
607@Composable
524private fun FileTextContent(
608private fun FileTextContent(
525− text: String,
609+ lines: List<String>,
526 filename: String,
610 filename: String,
527 topInset: androidx.compose.ui.unit.Dp,
611 topInset: androidx.compose.ui.unit.Dp,
528 wrap: Boolean,
613 wrap: Boolean,
529 findQuery: String,
614 findQuery: String,
530 matchIndices: List<Int>,
615 matchIndices: List<Int>,
616+ highlightLineIndex: Int?,
531 listState: LazyListState,
617 listState: LazyListState,
533− val lines = remember(text) { text.split("\n") }
534 // Gutter width scales with the digit count and the font scale so accessibility
619 // Gutter width scales with the digit count and the font scale so accessibility
535 // text scaling (e.g. 1.3x) doesn't make line numbers overflow the gutter and
620 // text scaling (e.g. 1.3x) doesn't make line numbers overflow the gutter and
536 // collide with the code text. 10dp/digit covers bodySmall at 1.0x; scale up
621 // collide with the code text. 10dp/digit covers bodySmall at 1.0x; scale up
@@ -545,39 +630,52 @@ private fun FileTextContent(
545 val syntax = remember(filename) { syntaxFor(filename) }
630 val syntax = remember(filename) { syntaxFor(filename) }
546 val q = findQuery.trim()
631 val q = findQuery.trim()
547 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
632 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
552− .padding(top = topInset)
633+ Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
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),
553− .padding(8.dp),
647+ .padding(8.dp),
555− itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
649+ itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
556− val isMatch = q.isNotEmpty() && index in matchSet
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
558− modifier = Modifier
655+ modifier = Modifier
559− .fillMaxWidth()
656+ .fillMaxWidth()
560− .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
657+ .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
561− .then(if (isMatch) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
658+ .then(if (isMatch || isTarget) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
564− "${index + 1}",
661+ "${index + 1}",
565− modifier = Modifier.width(gutterWidth),
662+ modifier = Modifier.width(gutterWidth),
566− style = MaterialTheme.typography.bodySmall,
663+ style = MaterialTheme.typography.bodySmall,
567− fontFamily = FontFamily.Monospace,
664+ fontFamily = FontFamily.Monospace,
568− color = MaterialTheme.colorScheme.onSurfaceVariant,
665+ color = MaterialTheme.colorScheme.onSurfaceVariant,
570− // Heuristic syntax highlighting per line. Falls back to plain text for
667+ // Heuristic syntax highlighting per line. Falls back to plain text for
571− // unknown extensions, so non-code files render unchanged. Memoized so the
668+ // unknown extensions, so non-code files render unchanged. Memoized so the
572− // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
669+ // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
573− // file, or palette actually changes — not on every recomposition (e.g. every
670+ // 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).
671+ // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
575− val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
672+ val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
577− highlighted,
674+ highlighted,
578− modifier = Modifier.padding(start = 8.dp),
675+ modifier = Modifier.padding(start = 8.dp),
579− style = MaterialTheme.typography.bodySmall,
676+ style = MaterialTheme.typography.bodySmall,