Skip to content

Commit d15ecde

Browse files
committed
File viewer: fix search nav in ByteSeek mode
- Add byte_offset to SearchMatch so the frontend knows where each match is in the file - scrollToMatch converts byte offset to scroll position when line seek is unavailable, fixing drift in estimated line coordinates
1 parent 41912ea commit d15ecde

12 files changed

Lines changed: 50 additions & 10 deletions

File tree

apps/desktop/src-tauri/src/file_viewer/CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ if file_size < 1MB {
4848
**Decision**: `SearchMatch.column` and `.length` use UTF-16 code units instead of byte or char offsets.
4949
**Why**: The frontend is JavaScript, where `String.prototype.length` and `String.prototype.substring()` count UTF-16 code units. If the backend returned byte offsets or Unicode scalar offsets, the frontend would need to convert on every match highlight, which is error-prone for text with emoji or CJK characters. Matching the JS string model eliminates an entire class of off-by-one bugs in the highlight rendering.
5050

51+
**Decision**: `SearchMatch.byte_offset` stores the byte offset of the line start for each match.
52+
**Why**: In ByteSeek mode (when line indexing timed out), search returns exact line numbers but the virtual scroll uses estimated line counts for fraction-based seeking. The byte offset lets the frontend convert to scroll position via `(byteOffset / totalBytes) * estimatedTotalLines`, which is the same fraction the virtual scroll uses for fetching. Without this, navigating to a search match scrolls to the wrong part of the file.
53+
5154
**Decision**: Sparse checkpoints every 256 lines instead of indexing every line.
5255
**Why**: Indexing every line in a 100M-line file would need ~800 MB of offset data (8 bytes each). At 256-line intervals, the same file needs ~3 MB. The trade-off is that seeking to a specific line requires reading forward up to 255 lines from the nearest checkpoint, which takes <1ms on any modern disk — well within the 16ms frame budget for 60fps scrolling.
5356

apps/desktop/src-tauri/src/file_viewer/byte_seek.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ impl FileViewerBackend for ByteSeekBackend {
223223
let mut buf = vec![0u8; chunk_size];
224224
let mut line_number: usize = 0;
225225
let mut scanned: u64 = 0;
226+
let mut line_byte_offset: u64 = 0;
226227
let mut leftover = Vec::new();
227228
let mut limit_reached = false;
228229

@@ -263,14 +264,14 @@ impl FileViewerBackend for ByteSeekBackend {
263264
let mut search_start = 0;
264265
while let Some(match_pos) = line_lower[search_start..].find(&query_lower) {
265266
let col_bytes = search_start + match_pos;
266-
let col_utf16: usize =
267-
line_lower[..col_bytes].chars().map(|c| c.len_utf16()).sum();
267+
let col_utf16: usize = line_lower[..col_bytes].chars().map(|c| c.len_utf16()).sum();
268268
let len_utf16: usize = query_lower.chars().map(|c| c.len_utf16()).sum();
269269
let mut matches = results.lock_ignore_poison();
270270
matches.push(SearchMatch {
271271
line: line_number,
272272
column: col_utf16,
273273
length: len_utf16,
274+
byte_offset: line_byte_offset,
274275
});
275276
if matches.len() >= MAX_SEARCH_MATCHES {
276277
limit_reached = true;
@@ -281,6 +282,7 @@ impl FileViewerBackend for ByteSeekBackend {
281282

282283
scanned += (nl_pos + 1) as u64;
283284
pos += nl_pos + 1;
285+
line_byte_offset = scanned;
284286
line_number += 1;
285287
} else {
286288
// Incomplete line — save as leftover for next iteration
@@ -307,6 +309,7 @@ impl FileViewerBackend for ByteSeekBackend {
307309
line: line_number,
308310
column: col_utf16,
309311
length: len_utf16,
312+
byte_offset: line_byte_offset,
310313
});
311314
if matches.len() >= MAX_SEARCH_MATCHES {
312315
break;

apps/desktop/src-tauri/src/file_viewer/byte_seek_test.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,10 @@ fn search_finds_matches() {
180180
assert_eq!(matches.len(), 2);
181181
assert_eq!(matches[0].line, 0);
182182
assert_eq!(matches[0].column, 0);
183+
assert_eq!(matches[0].byte_offset, 0); // First line starts at byte 0
183184
assert_eq!(matches[1].line, 2);
185+
// "hello world\n" = 12 bytes, "foo bar\n" = 8 bytes → line 2 starts at byte 20
186+
assert_eq!(matches[1].byte_offset, 20);
184187

185188
// Progress should equal total bytes after search completes
186189
assert_eq!(*progress.lock().unwrap(), backend.total_bytes());

apps/desktop/src-tauri/src/file_viewer/full_load.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ use std::path::Path;
88
use std::sync::Mutex;
99
use std::sync::atomic::{AtomicBool, Ordering};
1010

11-
use super::{BackendCapabilities, FileViewerBackend, LineChunk, MAX_SEARCH_MATCHES, SearchMatch, SeekTarget, ViewerError};
11+
use super::{
12+
BackendCapabilities, FileViewerBackend, LineChunk, MAX_SEARCH_MATCHES, SearchMatch, SeekTarget, ViewerError,
13+
};
1214

1315
pub struct FullLoadBackend {
1416
lines: Vec<String>,
@@ -151,6 +153,7 @@ impl FileViewerBackend for FullLoadBackend {
151153
line: line_idx,
152154
column: col_utf16,
153155
length: len_utf16,
156+
byte_offset: self.line_offsets[line_idx],
154157
});
155158
if matches.len() >= MAX_SEARCH_MATCHES {
156159
limit_reached = true;

apps/desktop/src-tauri/src/file_viewer/full_load_test.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,11 @@ fn search_finds_matches() {
140140
assert_eq!(matches.len(), 2);
141141
assert_eq!(matches[0].line, 0);
142142
assert_eq!(matches[0].column, 0);
143+
assert_eq!(matches[0].byte_offset, 0); // First line starts at byte 0
143144
assert_eq!(matches[1].line, 2);
144145
assert_eq!(matches[1].column, 0);
146+
// "hello world\n" = 12 bytes, "foo bar\n" = 8 bytes → line 2 starts at byte 20
147+
assert_eq!(matches[1].byte_offset, 20);
145148
assert!(scanned > 0);
146149
// Progress should equal total bytes after search completes
147150
assert_eq!(*progress.lock().unwrap(), scanned);

apps/desktop/src-tauri/src/file_viewer/line_index.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ impl FileViewerBackend for LineIndexBackend {
245245
let mut buf = vec![0u8; chunk_size];
246246
let mut line_number: usize = 0;
247247
let mut scanned: u64 = 0;
248+
let mut line_byte_offset: u64 = 0;
248249
let mut leftover = Vec::new();
249250
let mut limit_reached = false;
250251

@@ -289,6 +290,7 @@ impl FileViewerBackend for LineIndexBackend {
289290
line: line_number,
290291
column: col,
291292
length: query.len(),
293+
byte_offset: line_byte_offset,
292294
});
293295
if matches.len() >= MAX_SEARCH_MATCHES {
294296
limit_reached = true;
@@ -299,6 +301,7 @@ impl FileViewerBackend for LineIndexBackend {
299301

300302
scanned += (nl_pos + 1) as u64;
301303
pos += nl_pos + 1;
304+
line_byte_offset = scanned;
302305
line_number += 1;
303306
} else {
304307
leftover.extend_from_slice(&data[pos..]);
@@ -322,6 +325,7 @@ impl FileViewerBackend for LineIndexBackend {
322325
line: line_number,
323326
column: col,
324327
length: query.len(),
328+
byte_offset: line_byte_offset,
325329
});
326330
if matches.len() >= MAX_SEARCH_MATCHES {
327331
break;

apps/desktop/src-tauri/src/file_viewer/line_index_test.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,10 @@ fn search_finds_matches() {
194194

195195
assert_eq!(matches.len(), 2);
196196
assert_eq!(matches[0].line, 0);
197+
assert_eq!(matches[0].byte_offset, 0); // First line starts at byte 0
197198
assert_eq!(matches[1].line, 2);
199+
// "hello world\n" = 12 bytes, "foo bar\n" = 8 bytes → line 2 starts at byte 20
200+
assert_eq!(matches[1].byte_offset, 20);
198201

199202
cleanup(&dir);
200203
}

apps/desktop/src-tauri/src/file_viewer/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ pub struct SearchMatch {
7474
pub column: usize,
7575
/// Length in UTF-16 code units (matches JS string indexing).
7676
pub length: usize,
77+
/// Byte offset of the start of the line containing this match.
78+
/// Used by the frontend to scroll accurately in ByteSeek mode where line numbers
79+
/// don't map to the virtual scroll coordinate system.
80+
pub byte_offset: u64,
7781
}
7882

7983
/// What a backend can do.

apps/desktop/src-tauri/src/file_viewer/session.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ use super::byte_seek::ByteSeekBackend;
1818
use super::full_load::FullLoadBackend;
1919
use super::line_index::LineIndexBackend;
2020
use super::{
21-
BackendCapabilities, FULL_LOAD_THRESHOLD, FileViewerBackend, LineChunk, MAX_SEARCH_MATCHES, SearchMatch, SeekTarget,
22-
ViewerError,
21+
BackendCapabilities, FULL_LOAD_THRESHOLD, FileViewerBackend, LineChunk, MAX_SEARCH_MATCHES, SearchMatch,
22+
SeekTarget, ViewerError,
2323
};
2424

2525
/// Which backend strategy is active for a session.

apps/desktop/src-tauri/src/file_viewer/session_test.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ use std::path::{Path, PathBuf};
55
use std::thread;
66
use std::time::Duration;
77

8-
use super::{FULL_LOAD_THRESHOLD, MAX_SEARCH_MATCHES};
98
use super::session::{self, SearchStatus};
9+
use super::{FULL_LOAD_THRESHOLD, MAX_SEARCH_MATCHES};
1010

1111
fn create_test_dir(name: &str) -> PathBuf {
1212
let dir = std::env::temp_dir().join(format!("cmdr_viewer_session_{}", name));

0 commit comments

Comments
 (0)