Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 172 additions & 13 deletions src-tauri/src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -555,21 +561,46 @@ pub fn search_workspace_with_metadata(
break;
}

let line_text = line.trim_end().to_string();

for (match_start, match_end) in
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());

Comment on lines +564 to +574
// 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<usize> = 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")
};

// 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 {
break 'line_matches;
}

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, match_start),
match_start: utf16_offset_of(match_start),
match_end: utf16_offset_of(match_end),
});
}
}
Expand Down Expand Up @@ -875,12 +906,80 @@ fn is_known_binary_path(path: &Path) -> bool {
)
}

fn utf16_offset_for_byte_index(value: &str, byte_index: usize) -> usize {
value
// 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<usize>) -> 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, 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();
}

let match_start_byte = match_start_byte.min(line_text.len());

// 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()
.take_while(|(index, _)| *index < byte_index)
.map(|(_, character)| character.len_utf16())
.sum()
.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 {
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)> {
Expand Down Expand Up @@ -2284,4 +2383,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);
}
}
65 changes: 65 additions & 0 deletions src/currentFileSearch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
currentFileMatches,
currentFileResultWindow,
nextCurrentFileMatchIndex,
PREVIEW_CONTEXT_BEFORE_CHARS,
} from "./currentFileSearch";

describe("current file search", () => {
Expand Down Expand Up @@ -90,6 +91,70 @@ describe("current file search", () => {
expect(matches).toHaveLength(2);
});

it("caps preview text on a huge minified line without touching match offsets", () => {
// ~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");

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("never splits a surrogate pair at a preview window edge", () => {
const loneSurrogate =
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;

// Window start lands mid-emoji: the emoji sits exactly where the 160-unit
// lookback boundary falls, one unit into the pair.
const startSplit =
"a".repeat(200) + "😀" + "b".repeat(PREVIEW_CONTEXT_BEFORE_CHARS - 1) + "needle" + "c".repeat(600);
// Window end lands mid-emoji: pad so the 500-unit window ends one unit
// into the pair.
const endPad = 500 - (PREVIEW_CONTEXT_BEFORE_CHARS + "needle".length) - 1;
const endSplit =
"a".repeat(400) + "needle" + "b".repeat(endPad) + "😀" + "c".repeat(600);

for (const line of [startSplit, endSplit]) {
const matches = currentFileMatches("emoji.txt", line, "needle");
expect(matches).toHaveLength(1);
expect(matches[0].lineText.length).toBeLessThanOrEqual(502);
expect(matches[0].lineText).toContain("needle");
expect(loneSurrogate.test(matches[0].lineText)).toBe(false);
}
});

it("cycles current-file match selection in both directions", () => {
expect(nextCurrentFileMatchIndex(-1, 1, 3)).toBe(0);
expect(nextCurrentFileMatchIndex(0, 1, 3)).toBe(1);
Expand Down
42 changes: 41 additions & 1 deletion src/currentFileSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,46 @@ 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.
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;

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.

// 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;
Comment thread
GordonBeeming marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Comment thread
GordonBeeming marked this conversation as resolved.

export function currentFileMatches(
path: string,
contents: string,
Expand All @@ -25,7 +65,7 @@ export function currentFileMatches(
matches.push({
path,
lineNumber: index + 1,
lineText: line,
lineText: previewWindow(line, matchStart),
matchStart,
matchEnd,
});
Expand Down
Loading