From 4077817a93752becc90acd2ba2c28e584e3142b1 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Sat, 11 Jul 2026 18:43:56 +1000 Subject: [PATCH 1/3] Cap content-search line previews so huge single-line files don't freeze the UI Searching a workspace containing a big single-line minified file (a 935KB highlight.js in the dotfiles repo) shipped the full line as the preview text for every match: up to 200 matches times 935KB serialized over IPC and rendered into the DOM, freezing the whole webview for minutes. Reopening the search pane re-ran the same query and froze it again. Previews are now capped at 500 chars around the match with an ellipsis on each cut end, in both workspace search (Rust) and single-file search (TS). matchStart/matchEnd stay UTF-16 offsets into the full line, so editor match selection is unchanged. A line's match offsets are also computed in one scan of the line instead of one scan per match. Claude-Session: https://claude.ai/code/session_018TAt3cnJRcK25UqH2YKwNz --- src-tauri/src/workspace.rs | 175 +++++++++++++++++++++++++++++++--- src/currentFileSearch.test.ts | 40 ++++++++ src/currentFileSearch.ts | 27 +++++- 3 files changed, 229 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/workspace.rs b/src-tauri/src/workspace.rs index 08fa67a..d313b2b 100644 --- a/src-tauri/src/workspace.rs +++ b/src-tauri/src/workspace.rs @@ -85,6 +85,12 @@ pub enum WorkspaceError { } const MAX_SEARCH_QUERY_CHARS: usize = 128; +// A minified file can put an entire multi-hundred-KB file on one line; without a cap, +// each match on that line would serialize the whole line to the frontend and freeze +// the webview rendering it. match_start/match_end (into the full line) stay untouched +// so EditorPane can still select the real match in the document. +const SEARCH_PREVIEW_MAX_CHARS: usize = 500; +const SEARCH_PREVIEW_CONTEXT_BEFORE_CHARS: usize = 160; #[cfg(test)] fn scan_workspace( @@ -555,11 +561,26 @@ pub fn search_workspace_with_metadata( break; } + let ranges = case_insensitive_match_byte_ranges(line, &normalized_query); + if ranges.is_empty() { + continue; + } + + // One scan of the line for all its matches, rather than one scan per + // match_start/match_end pair (which was O(matches * line_len)). + let targets: Vec = ranges.iter().flat_map(|&(start, end)| [start, end]).collect(); + let utf16_offsets = utf16_offsets_for_byte_indices(line, targets); + let utf16_offset_of = |byte_index: usize| -> usize { + utf16_offsets + .binary_search_by_key(&byte_index, |&(byte, _)| byte) + .map(|found| utf16_offsets[found].1) + .expect("byte offset was included in the target set") + }; + let line_text = line.trim_end().to_string(); + let total_chars = line_text.chars().count(); - for (match_start, match_end) in - case_insensitive_match_byte_ranges(line, &normalized_query) - { + for (match_start, match_end) in ranges { if matches.len() >= collection_limit { break 'line_matches; } @@ -567,9 +588,9 @@ pub fn search_workspace_with_metadata( matches.push(SearchMatch { path: relative_path.clone(), line_number: index + 1, - line_text: line_text.clone(), - match_start: utf16_offset_for_byte_index(line, match_start), - match_end: utf16_offset_for_byte_index(line, match_end), + line_text: build_search_preview(&line_text, total_chars, match_start), + match_start: utf16_offset_of(match_start), + match_end: utf16_offset_of(match_end), }); } } @@ -875,12 +896,82 @@ fn is_known_binary_path(path: &Path) -> bool { ) } -fn utf16_offset_for_byte_index(value: &str, byte_index: usize) -> usize { - value - .char_indices() - .take_while(|(index, _)| *index < byte_index) - .map(|(_, character)| character.len_utf16()) - .sum() +// Maps each requested byte offset in `value` to its UTF-16 offset, scanning `value` +// once no matter how many offsets are requested or what order they're in (match ranges +// on one line can overlap, e.g. querying "aa" against "aaaa", so callers can't assume +// the requested byte offsets are monotonic). +fn utf16_offsets_for_byte_indices(value: &str, mut targets: Vec) -> Vec<(usize, usize)> { + targets.sort_unstable(); + targets.dedup(); + + let mut offsets = Vec::with_capacity(targets.len()); + let mut target_iter = targets.into_iter().peekable(); + let mut utf16_len = 0usize; + + for (byte_index, character) in value.char_indices() { + while let Some(&target) = target_iter.peek() { + if target > byte_index { + break; + } + offsets.push((target, utf16_len)); + target_iter.next(); + } + utf16_len += character.len_utf16(); + } + for target in target_iter { + offsets.push((target, utf16_len)); + } + + offsets +} + +// Caps a match's preview to SEARCH_PREVIEW_MAX_CHARS around the match start, with up to +// SEARCH_PREVIEW_CONTEXT_BEFORE_CHARS of leading context, so a match on a huge line +// doesn't serialize the whole line. Windows on char boundaries since `line_text` is +// arbitrary UTF-8. `match_start_byte` is a byte offset into `line_text` (case_insensitive_ +// match_byte_ranges finds matches on char boundaries, and line_text only trims trailing +// whitespace off the same source line, so the offset lands on a valid boundary here too). +fn build_search_preview(line_text: &str, total_chars: usize, match_start_byte: usize) -> String { + if total_chars <= SEARCH_PREVIEW_MAX_CHARS { + return line_text.to_string(); + } + + let match_start_byte = match_start_byte.min(line_text.len()); + + let mut window_start_byte = match_start_byte; + let mut before_chars = 0; + while window_start_byte > 0 && before_chars < SEARCH_PREVIEW_CONTEXT_BEFORE_CHARS { + window_start_byte -= 1; + while !line_text.is_char_boundary(window_start_byte) { + window_start_byte -= 1; + } + before_chars += 1; + } + + let mut window_end_byte = window_start_byte; + let mut window_chars = 0; + while window_end_byte < line_text.len() && window_chars < SEARCH_PREVIEW_MAX_CHARS { + let char_len = line_text[window_end_byte..] + .chars() + .next() + .map(char::len_utf8) + .unwrap_or(0); + if char_len == 0 { + break; + } + window_end_byte += char_len; + window_chars += 1; + } + + let mut preview = String::with_capacity(window_end_byte - window_start_byte + 6); + if window_start_byte > 0 { + preview.push('\u{2026}'); + } + preview.push_str(&line_text[window_start_byte..window_end_byte]); + if window_end_byte < line_text.len() { + preview.push('\u{2026}'); + } + preview } fn case_insensitive_match_byte_ranges(line: &str, normalized_query: &str) -> Vec<(usize, usize)> { @@ -2284,4 +2375,64 @@ mod tests { assert!(matches!(result, Err(WorkspaceError::SearchQueryTooLong))); } + + #[test] + fn search_workspace_caps_preview_length_on_huge_lines() { + let dir = tempdir().unwrap(); + // Four 1000-char fillers around three matches means every match's 500-char + // preview window sits fully inside filler on both sides, so every preview + // gets truncated at both ends. + let filler_a = "a".repeat(1000); + let filler_b = "b".repeat(1000); + let filler_c = "c".repeat(1000); + let filler_d = "d".repeat(1000); + let line = format!("{filler_a}needle{filler_b}needle{filler_c}needle{filler_d}"); + fs::write(dir.path().join("huge.txt"), &line).unwrap(); + + let results = search_workspace(dir.path(), "needle", 10, 10_000_000).unwrap(); + + assert_eq!(results.len(), 3); + for result in &results { + let char_count = result.line_text.chars().count(); + assert!(char_count <= 502, "preview too long: {char_count} chars"); + assert!(result.line_text.starts_with('\u{2026}')); + assert!(result.line_text.ends_with('\u{2026}')); + assert!(result.line_text.contains("needle")); + } + } + + #[test] + fn search_workspace_keeps_full_line_utf16_offsets_on_huge_lines() { + let dir = tempdir().unwrap(); + // 1000 astral emoji before the match: 4 bytes and 2 UTF-16 units each, so byte + // offsets, char offsets, and UTF-16 offsets for the match all diverge, and only + // the UTF-16 offset should end up on the wire. + let emoji_prefix = "😀".repeat(1000); + let filler_suffix = "x".repeat(2000); + let line = format!("{emoji_prefix}needle{filler_suffix}"); + fs::write(dir.path().join("huge.txt"), &line).unwrap(); + + let results = search_workspace(dir.path(), "needle", 10, 10_000_000).unwrap(); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].match_start, 1000 * 2); + assert_eq!(results[0].match_end, 1000 * 2 + "needle".len()); + } + + #[test] + fn search_workspace_preview_windows_safely_around_multibyte_chars() { + let dir = tempdir().unwrap(); + // 4-byte emoji before the match and 2-byte é after it, so both the leading + // (160-char) and trailing (500-char) window cuts land next to non-ASCII runs. + let emoji_prefix = "😀".repeat(300); + let accented_suffix = "é".repeat(600); + let line = format!("{emoji_prefix}needle{accented_suffix}"); + fs::write(dir.path().join("multibyte.txt"), &line).unwrap(); + + let results = search_workspace(dir.path(), "needle", 10, 10_000_000).unwrap(); + + assert_eq!(results.len(), 1); + assert!(results[0].line_text.contains("needle")); + assert!(results[0].line_text.chars().count() <= 502); + } } diff --git a/src/currentFileSearch.test.ts b/src/currentFileSearch.test.ts index 11ec25d..bbcf29a 100644 --- a/src/currentFileSearch.test.ts +++ b/src/currentFileSearch.test.ts @@ -90,6 +90,46 @@ describe("current file search", () => { expect(matches).toHaveLength(2); }); + it("caps preview text on a huge minified line without touching match offsets", () => { + // ~600KB line, needle repeated so several hundred matches land deep in it. + const line = `x`.repeat(300_000) + "needle" + `y`.repeat(300_000) + "needle" + "z".repeat(1000); + const matches = currentFileMatches("app.min.js", line, "needle"); + + expect(matches.length).toBeGreaterThan(0); + for (const match of matches) { + expect(match.lineText.length).toBeLessThanOrEqual(502); + expect(match.lineText).toContain("needle"); + expect(match.lineText.startsWith("…")).toBe(true); + // Full-line offsets are untouched, still index into `line` itself. + expect(line.slice(match.matchStart, match.matchEnd)).toBe("needle"); + } + }); + + it("leaves short lines untouched (no ellipsis)", () => { + const shortLine = "const needle = 1;".padEnd(499, " "); + const matches = currentFileMatches("a.ts", shortLine, "needle"); + + expect(matches).toHaveLength(1); + expect(matches[0].lineText).toBe(shortLine); + expect(matches[0].lineText).not.toContain("…"); + }); + + it("only ellipsizes the far end when the match sits at the very start or end of a long line", () => { + const longLine = "needle" + "z".repeat(600); + const startMatches = currentFileMatches("a.ts", longLine, "needle"); + expect(startMatches).toHaveLength(1); + expect(startMatches[0].lineText.startsWith("…")).toBe(false); + expect(startMatches[0].lineText.endsWith("…")).toBe(true); + expect(startMatches[0].lineText.startsWith("needle")).toBe(true); + + const endLine = "z".repeat(600) + "needle"; + const endMatches = currentFileMatches("a.ts", endLine, "needle"); + expect(endMatches).toHaveLength(1); + expect(endMatches[0].lineText.startsWith("…")).toBe(true); + expect(endMatches[0].lineText.endsWith("…")).toBe(false); + expect(endMatches[0].lineText.endsWith("needle")).toBe(true); + }); + it("cycles current-file match selection in both directions", () => { expect(nextCurrentFileMatchIndex(-1, 1, 3)).toBe(0); expect(nextCurrentFileMatchIndex(0, 1, 3)).toBe(1); diff --git a/src/currentFileSearch.ts b/src/currentFileSearch.ts index 4029397..97524c7 100644 --- a/src/currentFileSearch.ts +++ b/src/currentFileSearch.ts @@ -2,6 +2,31 @@ import type { SearchMatch } from "./tauri"; const MAX_CURRENT_FILE_MATCHES = 200; +// A single minified line can be hundreds of KB; shipping the whole line as +// preview text per match (up to MAX_CURRENT_FILE_MATCHES times) freezes the +// webview. Cap the preview to a window around the match instead. matchStart/ +// matchEnd stay as full-line offsets (EditorPane uses them to select in the +// real document) — only the displayed text is windowed. +const PREVIEW_MAX_CHARS = 500; +const PREVIEW_CONTEXT_BEFORE_CHARS = 160; + +function previewWindow(line: string, matchStart: number): string { + if (line.length <= PREVIEW_MAX_CHARS) return line; + + let windowStart = Math.max(0, matchStart - PREVIEW_CONTEXT_BEFORE_CHARS); + let windowEnd = windowStart + PREVIEW_MAX_CHARS; + if (windowEnd > line.length) { + windowEnd = line.length; + windowStart = Math.max(0, windowEnd - PREVIEW_MAX_CHARS); + } + // A match longer than the window is cut too — queries are at most a few + // hundred chars, so this only trims pathological cases. + + const prefix = windowStart > 0 ? "…" : ""; + const suffix = windowEnd < line.length ? "…" : ""; + return prefix + line.slice(windowStart, windowEnd) + suffix; +} + export function currentFileMatches( path: string, contents: string, @@ -25,7 +50,7 @@ export function currentFileMatches( matches.push({ path, lineNumber: index + 1, - lineText: line, + lineText: previewWindow(line, matchStart), matchStart, matchEnd, }); From 2165863e78f1f167da1dfc60b5f3498856dab1ce Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Sun, 12 Jul 2026 00:17:57 +1000 Subject: [PATCH 2/3] Address PR review feedback on search previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Avoid splitting surrogate pairs at preview window edges in currentFileSearch.ts (Gemini, Copilot, CodeRabbit) — boundaries nudge off a low surrogate so no lone surrogate renders in the preview; new test covers both edges. - Truncate match ranges to the remaining result capacity before building the UTF-16 offset table in workspace.rs (Copilot) — a one-letter query on a huge minified line no longer allocates offsets for matches past the page. - Rewrite build_search_preview boundary walks with char_indices iterators (Gemini) and apply cargo fmt (fixes the red Build & Test check). - Correct a misleading test comment about match counts (Copilot). Claude-Session: https://claude.ai/code/session_018TAt3cnJRcK25UqH2YKwNz --- src-tauri/src/workspace.rs | 53 ++++++++++++++++++----------------- src/currentFileSearch.test.ts | 27 +++++++++++++++++- src/currentFileSearch.ts | 19 +++++++++++-- 3 files changed, 70 insertions(+), 29 deletions(-) diff --git a/src-tauri/src/workspace.rs b/src-tauri/src/workspace.rs index d313b2b..409243a 100644 --- a/src-tauri/src/workspace.rs +++ b/src-tauri/src/workspace.rs @@ -561,14 +561,23 @@ pub fn search_workspace_with_metadata( break; } - let ranges = case_insensitive_match_byte_ranges(line, &normalized_query); + let mut ranges = case_insensitive_match_byte_ranges(line, &normalized_query); if ranges.is_empty() { continue; } + // Each range becomes exactly one SearchMatch and the loop below stops at + // collection_limit, so ranges past the remaining capacity would only feed + // offset bookkeeping for matches that never ship. Drop them up front — + // on a huge minified line a one-letter query can produce hundreds of + // thousands of ranges for a 200-result page. + ranges.truncate(collection_limit - matches.len()); // One scan of the line for all its matches, rather than one scan per // match_start/match_end pair (which was O(matches * line_len)). - let targets: Vec = ranges.iter().flat_map(|&(start, end)| [start, end]).collect(); + let targets: Vec = ranges + .iter() + .flat_map(|&(start, end)| [start, end]) + .collect(); let utf16_offsets = utf16_offsets_for_byte_indices(line, targets); let utf16_offset_of = |byte_index: usize| -> usize { utf16_offsets @@ -938,30 +947,22 @@ fn build_search_preview(line_text: &str, total_chars: usize, match_start_byte: u let match_start_byte = match_start_byte.min(line_text.len()); - let mut window_start_byte = match_start_byte; - let mut before_chars = 0; - while window_start_byte > 0 && before_chars < SEARCH_PREVIEW_CONTEXT_BEFORE_CHARS { - window_start_byte -= 1; - while !line_text.is_char_boundary(window_start_byte) { - window_start_byte -= 1; - } - before_chars += 1; - } - - let mut window_end_byte = window_start_byte; - let mut window_chars = 0; - while window_end_byte < line_text.len() && window_chars < SEARCH_PREVIEW_MAX_CHARS { - let char_len = line_text[window_end_byte..] - .chars() - .next() - .map(char::len_utf8) - .unwrap_or(0); - if char_len == 0 { - break; - } - window_end_byte += char_len; - window_chars += 1; - } + // Walk back up to the context-before allowance: the nth char boundary behind + // the match start, or the line start when there's less context than that. + let window_start_byte = line_text[..match_start_byte] + .char_indices() + .rev() + .nth(SEARCH_PREVIEW_CONTEXT_BEFORE_CHARS - 1) + .map(|(index, _)| index) + .unwrap_or(0); + + // The window ends at the char boundary SEARCH_PREVIEW_MAX_CHARS chars in, or + // the line end when the remainder is shorter than the window. + let window_end_byte = line_text[window_start_byte..] + .char_indices() + .nth(SEARCH_PREVIEW_MAX_CHARS) + .map(|(index, _)| window_start_byte + index) + .unwrap_or(line_text.len()); let mut preview = String::with_capacity(window_end_byte - window_start_byte + 6); if window_start_byte > 0 { diff --git a/src/currentFileSearch.test.ts b/src/currentFileSearch.test.ts index bbcf29a..71a6b0a 100644 --- a/src/currentFileSearch.test.ts +++ b/src/currentFileSearch.test.ts @@ -3,6 +3,7 @@ import { currentFileMatches, currentFileResultWindow, nextCurrentFileMatchIndex, + PREVIEW_CONTEXT_BEFORE_CHARS, } from "./currentFileSearch"; describe("current file search", () => { @@ -91,7 +92,8 @@ describe("current file search", () => { }); it("caps preview text on a huge minified line without touching match offsets", () => { - // ~600KB line, needle repeated so several hundred matches land deep in it. + // ~600KB line with two needle occurrences, both deep enough that every + // preview window sits fully inside filler on both sides. const line = `x`.repeat(300_000) + "needle" + `y`.repeat(300_000) + "needle" + "z".repeat(1000); const matches = currentFileMatches("app.min.js", line, "needle"); @@ -130,6 +132,29 @@ describe("current file search", () => { expect(endMatches[0].lineText.endsWith("needle")).toBe(true); }); + it("never splits a surrogate pair at a preview window edge", () => { + const loneSurrogate = + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { expect(nextCurrentFileMatchIndex(-1, 1, 3)).toBe(0); expect(nextCurrentFileMatchIndex(0, 1, 3)).toBe(1); diff --git a/src/currentFileSearch.ts b/src/currentFileSearch.ts index 97524c7..ae7ddb5 100644 --- a/src/currentFileSearch.ts +++ b/src/currentFileSearch.ts @@ -7,8 +7,17 @@ const MAX_CURRENT_FILE_MATCHES = 200; // webview. Cap the preview to a window around the match instead. matchStart/ // matchEnd stay as full-line offsets (EditorPane uses them to select in the // real document) — only the displayed text is windowed. -const PREVIEW_MAX_CHARS = 500; -const PREVIEW_CONTEXT_BEFORE_CHARS = 160; +export const PREVIEW_MAX_CHARS = 500; +export const PREVIEW_CONTEXT_BEFORE_CHARS = 160; + +// True when the code unit at `index` is a low surrogate — i.e. slicing here +// would split an astral character (emoji etc.) and leave a lone surrogate that +// renders as a replacement character in the preview. +function splitsSurrogatePair(line: string, index: number): boolean { + if (index <= 0 || index >= line.length) return false; + const code = line.charCodeAt(index); + return code >= 0xdc00 && code <= 0xdfff; +} function previewWindow(line: string, matchStart: number): string { if (line.length <= PREVIEW_MAX_CHARS) return line; @@ -22,6 +31,12 @@ function previewWindow(line: string, matchStart: number): string { // A match longer than the window is cut too — queries are at most a few // hundred chars, so this only trims pathological cases. + // Nudge boundaries off the middle of surrogate pairs: skip a leading lone + // low surrogate, and end before a half-included pair. Both shrink the window + // by one unit, so the length cap still holds. + if (splitsSurrogatePair(line, windowStart)) windowStart += 1; + if (splitsSurrogatePair(line, windowEnd)) windowEnd -= 1; + const prefix = windowStart > 0 ? "…" : ""; const suffix = windowEnd < line.length ? "…" : ""; return prefix + line.slice(windowStart, windowEnd) + suffix; From f0c864127c885e99be16d7bf4fb30ea56830524a Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Sun, 12 Jul 2026 00:23:28 +1000 Subject: [PATCH 3/3] Borrow the line for previews instead of copying it Copilot follow-up: line_text was an owned copy of the whole (possibly hundreds-of-KB) line even though only the preview window ships, and the char count scanned every line fully. The line is borrowed now, and the short-line check is a byte-length test plus a probe one char past the cap. Claude-Session: https://claude.ai/code/session_018TAt3cnJRcK25UqH2YKwNz --- src-tauri/src/workspace.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/workspace.rs b/src-tauri/src/workspace.rs index 409243a..4121d58 100644 --- a/src-tauri/src/workspace.rs +++ b/src-tauri/src/workspace.rs @@ -586,8 +586,9 @@ pub fn search_workspace_with_metadata( .expect("byte offset was included in the target set") }; - let line_text = line.trim_end().to_string(); - let total_chars = line_text.chars().count(); + // Borrow, don't copy: the full line can be hundreds of KB and only + // the preview window ever needs an owned String. + let line_text = line.trim_end(); for (match_start, match_end) in ranges { if matches.len() >= collection_limit { @@ -597,7 +598,7 @@ pub fn search_workspace_with_metadata( matches.push(SearchMatch { path: relative_path.clone(), line_number: index + 1, - line_text: build_search_preview(&line_text, total_chars, match_start), + line_text: build_search_preview(line_text, match_start), match_start: utf16_offset_of(match_start), match_end: utf16_offset_of(match_end), }); @@ -940,8 +941,14 @@ fn utf16_offsets_for_byte_indices(value: &str, mut targets: Vec) -> Vec<( // arbitrary UTF-8. `match_start_byte` is a byte offset into `line_text` (case_insensitive_ // match_byte_ranges finds matches on char boundaries, and line_text only trims trailing // whitespace off the same source line, so the offset lands on a valid boundary here too). -fn build_search_preview(line_text: &str, total_chars: usize, match_start_byte: usize) -> String { - if total_chars <= SEARCH_PREVIEW_MAX_CHARS { +fn build_search_preview(line_text: &str, match_start_byte: usize) -> String { + // Byte length at or under the cap means the char count is too — no scan needed. + if line_text.len() <= SEARCH_PREVIEW_MAX_CHARS { + return line_text.to_string(); + } + // More bytes than the cap can still be fewer chars (multi-byte text); probing + // one char past the cap costs at most cap+1 chars, never the whole line. + if line_text.chars().nth(SEARCH_PREVIEW_MAX_CHARS).is_none() { return line_text.to_string(); }