@@ -54,6 +54,7 @@ import androidx.compose.runtime.LaunchedEffect
5454import androidx.compose.runtime.getValue
5555import androidx.compose.runtime.mutableIntStateOf
5656import androidx.compose.runtime.mutableStateOf
57+import androidx.compose.runtime.produceState
5758import androidx.compose.runtime.remember
5859import androidx.compose.runtime.saveable.rememberSaveable
5960import androidx.compose.runtime.setValue
@@ -77,9 +78,13 @@ import soy.iko.opencode.R
7778import soy.iko.opencode.ui.components.DiffView
7879import soy.iko.opencode.ui.components.copyToClipboard
7980import soy.iko.opencode.ui.components.highlightLine
81+import soy.iko.opencode.ui.components.syntaxFor
8082import soy.iko.opencode.ui.components.rememberHighlightPalette
8183import soy.iko.opencode.ui.vmFactory
8284import soy.iko.opencode.util.runCatchingCancellable
85+import kotlinx.coroutines.Dispatchers
86+import kotlinx.coroutines.delay
87+import kotlinx.coroutines.withContext
8388
8489@OptIn(ExperimentalMaterial3Api::class)
8590@Composable
@@ -110,11 +115,19 @@ fun FileViewScreen(
110115 val listState = rememberLazyListState()
111116 val rawText = state.content?.content.orEmpty()
112117 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) {
114122 val q = findQuery.trim()
115− if (q.isEmpty()) emptyList()
116− else lines.mapIndexed { index, line -> if (line.contains(q, ignoreCase = true)) index else -1 }
117− .filter { it >= 0 }
123+ if (q.isEmpty()) debouncedFind = "" else { delay(150); debouncedFind = q }
124+ }
125+ val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind) {
126+ val q = debouncedFind
127+ value = if (q.isEmpty()) emptyList()
128+ else withContext(Dispatchers.Default) {
129+ lines.mapIndexedNotNull { index, line -> index.takeIf { line.contains(q, ignoreCase = true) } }
130+ }
118131 }
119132 // Keep the current match index in range when the match set shrinks (e.g. the file
120133 // reloaded with fewer matches), so the counter never shows a stale "10 / 3".
@@ -163,12 +176,17 @@ fun FileViewScreen(
163176 // state. During the crossfade the exiting layer still renders the old
164177 // content; passing the outer matchIndices (computed from the new content)
165178 // would highlight and scroll the wrong lines until the animation settles.
166− val sMatchIndices = remember(s.content?.content, findQuery) {
167− val q = findQuery.trim()
179+ val sMatchIndices = remember(s.content?.content, debouncedFind, lines, rawText) {
180+ val q = debouncedFind
168181 if (q.isEmpty()) emptyList()
169− else (s.content?.content.orEmpty()).split("\n")
170− .mapIndexed { index, line -> if (line.contains(q, ignoreCase = true)) index else -1 }
171− .filter { it >= 0 }
182+ else {
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.
186+ val sContent = s.content?.content
187+ val sLines = if (sContent == rawText) lines else sContent.orEmpty().split("\n")
188+ sLines.mapIndexedNotNull { index, line -> index.takeIf { line.contains(q, ignoreCase = true) } }
189+ }
172190 }
173191 FileViewStateContent(
174192 state = s,
@@ -498,6 +516,8 @@ private fun FileTextContent(
498516 }
499517 val hScrollState = rememberScrollState()
500518 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) }
501521 val q = findQuery.trim()
502522 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
503523 LazyColumn(
@@ -527,7 +547,7 @@ private fun FileTextContent(
527547 // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
528548 // file, or palette actually changes — not on every recomposition (e.g. every
529549 // 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) }
531551 Text(
532552 highlighted,
533553 modifier = Modifier.padding(start = 8.dp),