@@ -54,6 +54,7 @@ import androidx.compose.runtime.LaunchedEffect
54import androidx.compose.runtime.getValue
54import androidx.compose.runtime.getValue
55import androidx.compose.runtime.mutableIntStateOf
55import androidx.compose.runtime.mutableIntStateOf
56import androidx.compose.runtime.mutableStateOf
56import androidx.compose.runtime.mutableStateOf
57+import androidx.compose.runtime.produceState
57import androidx.compose.runtime.remember
58import androidx.compose.runtime.remember
58import androidx.compose.runtime.saveable.rememberSaveable
59import androidx.compose.runtime.saveable.rememberSaveable
59import androidx.compose.runtime.setValue
60import androidx.compose.runtime.setValue
@@ -77,9 +78,13 @@ import soy.iko.opencode.R
77import soy.iko.opencode.ui.components.DiffView
78import soy.iko.opencode.ui.components.DiffView
78import soy.iko.opencode.ui.components.copyToClipboard
79import soy.iko.opencode.ui.components.copyToClipboard
79import soy.iko.opencode.ui.components.highlightLine
80import soy.iko.opencode.ui.components.highlightLine
81+import soy.iko.opencode.ui.components.syntaxFor
80import soy.iko.opencode.ui.components.rememberHighlightPalette
82import soy.iko.opencode.ui.components.rememberHighlightPalette
81import soy.iko.opencode.ui.vmFactory
83import soy.iko.opencode.ui.vmFactory
82import soy.iko.opencode.util.runCatchingCancellable
84import soy.iko.opencode.util.runCatchingCancellable
85+import kotlinx.coroutines.Dispatchers
86+import kotlinx.coroutines.delay
87+import kotlinx.coroutines.withContext
84@OptIn(ExperimentalMaterial3Api::class)
89@OptIn(ExperimentalMaterial3Api::class)
85@Composable
90@Composable
@@ -110,11 +115,19 @@ fun FileViewScreen(
110 val listState = rememberLazyListState()
115 val listState = rememberLazyListState()
111 val rawText = state.content?.content.orEmpty()
116 val rawText = state.content?.content.orEmpty()
112 val lines = remember(rawText) { rawText.split("\n") }
117 val lines = remember(rawText) { rawText.split("\n") }
113− val matchIndices = remember(lines, findQuery) {
118+ // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
119+ // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
120+ var debouncedFind by remember { mutableStateOf("") }
121+ LaunchedEffect(findQuery) {
114 val q = findQuery.trim()
122 val q = findQuery.trim()
115− if (q.isEmpty()) emptyList()
123+ if (q.isEmpty()) debouncedFind = "" else { delay(150); debouncedFind = q }
125+ val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind) {
126+ val q = debouncedFind
127+ value = if (q.isEmpty()) emptyList()
128+ else withContext(Dispatchers.Default) {
116− else lines.mapIndexed { index, line -> if (line.contains(q, ignoreCase = true)) index else -1 }
129+ lines.mapIndexedNotNull { index, line -> index.takeIf { line.contains(q, ignoreCase = true) } }
117− .filter { it >= 0 }
130+ }
119 // Keep the current match index in range when the match set shrinks (e.g. the file
132 // Keep the current match index in range when the match set shrinks (e.g. the file
120 // reloaded with fewer matches), so the counter never shows a stale "10 / 3".
133 // reloaded with fewer matches), so the counter never shows a stale "10 / 3".
@@ -163,12 +176,17 @@ fun FileViewScreen(
163 // state. During the crossfade the exiting layer still renders the old
176 // state. During the crossfade the exiting layer still renders the old
164 // content; passing the outer matchIndices (computed from the new content)
177 // content; passing the outer matchIndices (computed from the new content)
165 // would highlight and scroll the wrong lines until the animation settles.
178 // would highlight and scroll the wrong lines until the animation settles.
166− val sMatchIndices = remember(s.content?.content, findQuery) {
179+ val sMatchIndices = remember(s.content?.content, debouncedFind, lines, rawText) {
167− val q = findQuery.trim()
180+ val q = debouncedFind
168 if (q.isEmpty()) emptyList()
181 if (q.isEmpty()) emptyList()
183+ // Reuse the already-split `lines` when this layer shows the current
184+ // content (the common case); only re-split for the exiting layer's
185+ // stale content during the crossfade.
169− else (s.content?.content.orEmpty()).split("\n")
186+ val sContent = s.content?.content
187+ val sLines = if (sContent == rawText) lines else sContent.orEmpty().split("\n")
170− .mapIndexed { index, line -> if (line.contains(q, ignoreCase = true)) index else -1 }
188+ sLines.mapIndexedNotNull { index, line -> index.takeIf { line.contains(q, ignoreCase = true) } }
171− .filter { it >= 0 }
189+ }
173 FileViewStateContent(
191 FileViewStateContent(
174 state = s,
192 state = s,
@@ -498,6 +516,8 @@ private fun FileTextContent(
499 val hScrollState = rememberScrollState()
517 val hScrollState = rememberScrollState()
500 val palette = rememberHighlightPalette()
518 val palette = rememberHighlightPalette()
519+ // Resolve the file's language once instead of re-parsing the extension for every line.
520+ val syntax = remember(filename) { syntaxFor(filename) }
501 val q = findQuery.trim()
521 val q = findQuery.trim()
502 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
522 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
503 LazyColumn(
523 LazyColumn(
@@ -527,7 +547,7 @@ private fun FileTextContent(
527 // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
547 // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
528 // file, or palette actually changes — not on every recomposition (e.g. every
548 // file, or palette actually changes — not on every recomposition (e.g. every
529 // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
549 // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
530− val highlighted = remember(line, filename, palette) { highlightLine(line, filename, palette) }
550+ val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
532 highlighted,
552 highlighted,
533 modifier = Modifier.padding(start = 8.dp),
553 modifier = Modifier.padding(start = 8.dp),