Skip to content

Commit 9f91bce

Browse files
claudevdavid
authored andcommitted
Feature: Three-backend viewer architecture with virtual scrolling
Replace the simple read_file_content approach with a session-based three-backend architecture that handles files of any size: - FullLoadBackend: files ≤ 1 MB, instant random access in RAM - ByteSeekBackend: instant open for large files, byte-offset seeking - LineIndexBackend: sparse line index (every 256 lines), built in background ViewerSession orchestrator picks the right backend and transparently upgrades from ByteSeek to LineIndex once the background scan completes. Frontend uses virtual scrolling (only renders visible lines + 50-line buffer) with on-demand line fetching from the backend. Search runs server-side with cancellation support and progress reporting. 64 new Rust tests covering all backends and orchestrator. Old client-side search utilities removed.
1 parent a730b5c commit 9f91bce

21 files changed

Lines changed: 2794 additions & 367 deletions

apps/desktop/src-tauri/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src-tauri/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ futures-util = "0.3"
6060
tower-http = { version = "0.6", features = ["cors"] }
6161
tauri-plugin-updater = "2"
6262
tauri-plugin-process = "2"
63+
memchr = "2"
6364

6465
[target.'cfg(unix)'.dependencies]
6566
libc = "0.2"

apps/desktop/src-tauri/src/commands/file_system.rs

Lines changed: 0 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -561,70 +561,6 @@ pub fn start_selection_drag(
561561
Err("Drag operation is not yet supported on this platform".to_string())
562562
}
563563

564-
// ============================================================================
565-
// File viewer
566-
// ============================================================================
567-
568-
/// Result of reading a file's content for the viewer.
569-
#[derive(Debug, serde::Serialize)]
570-
#[serde(rename_all = "camelCase")]
571-
pub struct FileContentResult {
572-
/// The text content of the file (UTF-8 lossy decoded).
573-
pub content: String,
574-
/// Total number of lines.
575-
pub line_count: usize,
576-
/// File size in bytes.
577-
pub size: u64,
578-
/// The file name (last component of the path).
579-
pub file_name: String,
580-
}
581-
582-
/// Reads a file's content as text for the file viewer.
583-
///
584-
/// Returns the full file content as a UTF-8 string (lossy — invalid bytes become replacement chars).
585-
/// Not optimized for huge files; intended for reasonably-sized text files.
586-
///
587-
/// # Arguments
588-
/// * `path` - Absolute path to the file. Supports tilde expansion (~).
589-
#[tauri::command]
590-
pub fn read_file_content(path: String) -> Result<FileContentResult, String> {
591-
let expanded = expand_tilde(&path);
592-
let file_path = PathBuf::from(&expanded);
593-
594-
if !file_path.exists() {
595-
return Err(format!("File not found: {}", path));
596-
}
597-
if file_path.is_dir() {
598-
return Err("Cannot view a directory".to_string());
599-
}
600-
601-
let metadata = std::fs::metadata(&file_path).map_err(|e| match e.kind() {
602-
std::io::ErrorKind::PermissionDenied => format!("Permission denied: {}", path),
603-
_ => format!("Cannot read file metadata: {}", e),
604-
})?;
605-
606-
let size = metadata.len();
607-
let bytes = std::fs::read(&file_path).map_err(|e| match e.kind() {
608-
std::io::ErrorKind::PermissionDenied => format!("Permission denied: {}", path),
609-
_ => format!("Failed to read file: {}", e),
610-
})?;
611-
612-
let content = String::from_utf8_lossy(&bytes).into_owned();
613-
let line_count = content.lines().count().max(1);
614-
615-
let file_name = file_path
616-
.file_name()
617-
.map(|n| n.to_string_lossy().into_owned())
618-
.unwrap_or_else(|| path.clone());
619-
620-
Ok(FileContentResult {
621-
content,
622-
line_count,
623-
size,
624-
file_name,
625-
})
626-
}
627-
628564
/// Expands tilde (~) to the user's home directory.
629565
fn expand_tilde(path: &str) -> String {
630566
if (path.starts_with("~/") || path == "~")
@@ -724,83 +660,4 @@ mod tests {
724660
let result = create_directory("/nonexistent_path_12345".to_string(), "test".to_string());
725661
assert!(result.is_err());
726662
}
727-
728-
// ========================================================================
729-
// read_file_content tests
730-
// ========================================================================
731-
732-
#[test]
733-
fn test_read_file_content_success() {
734-
let tmp = create_test_dir("read_content_ok");
735-
let file_path = tmp.join("hello.txt");
736-
fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap();
737-
738-
let result = read_file_content(file_path.to_string_lossy().to_string());
739-
assert!(result.is_ok());
740-
let res = result.unwrap();
741-
assert_eq!(res.file_name, "hello.txt");
742-
assert_eq!(res.line_count, 3);
743-
assert!(res.content.starts_with("line 1"));
744-
assert_eq!(res.size, 21);
745-
cleanup_test_dir(&tmp);
746-
}
747-
748-
#[test]
749-
fn test_read_file_content_empty_file() {
750-
let tmp = create_test_dir("read_content_empty");
751-
let file_path = tmp.join("empty.txt");
752-
fs::write(&file_path, "").unwrap();
753-
754-
let result = read_file_content(file_path.to_string_lossy().to_string());
755-
assert!(result.is_ok());
756-
let res = result.unwrap();
757-
assert_eq!(res.content, "");
758-
assert_eq!(res.line_count, 1); // At least 1
759-
assert_eq!(res.size, 0);
760-
cleanup_test_dir(&tmp);
761-
}
762-
763-
#[test]
764-
fn test_read_file_content_not_found() {
765-
let result = read_file_content("/nonexistent_file_12345.txt".to_string());
766-
assert!(result.is_err());
767-
assert!(result.unwrap_err().contains("File not found"));
768-
}
769-
770-
#[test]
771-
fn test_read_file_content_directory() {
772-
let tmp = create_test_dir("read_content_dir");
773-
let result = read_file_content(tmp.to_string_lossy().to_string());
774-
assert!(result.is_err());
775-
assert!(result.unwrap_err().contains("Cannot view a directory"));
776-
cleanup_test_dir(&tmp);
777-
}
778-
779-
#[test]
780-
fn test_read_file_content_binary() {
781-
let tmp = create_test_dir("read_content_binary");
782-
let file_path = tmp.join("binary.bin");
783-
fs::write(&file_path, b"\x00\x01\x02\xff\xfe").unwrap();
784-
785-
let result = read_file_content(file_path.to_string_lossy().to_string());
786-
assert!(result.is_ok());
787-
// Binary content is lossy-decoded, should contain replacement characters
788-
let res = result.unwrap();
789-
assert!(res.content.contains('\u{FFFD}'));
790-
cleanup_test_dir(&tmp);
791-
}
792-
793-
#[test]
794-
fn test_read_file_content_single_line_no_newline() {
795-
let tmp = create_test_dir("read_content_single");
796-
let file_path = tmp.join("single.txt");
797-
fs::write(&file_path, "hello world").unwrap();
798-
799-
let result = read_file_content(file_path.to_string_lossy().to_string());
800-
assert!(result.is_ok());
801-
let res = result.unwrap();
802-
assert_eq!(res.line_count, 1);
803-
assert_eq!(res.content, "hello world");
804-
cleanup_test_dir(&tmp);
805-
}
806663
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
//! Tauri commands for the file viewer.
2+
3+
use crate::file_viewer::{self, LineChunk, SearchPollResult, SeekTarget, ViewerOpenResult};
4+
5+
/// Opens a viewer session for the given file.
6+
/// Returns session metadata + initial lines from the start of the file.
7+
#[tauri::command]
8+
pub fn viewer_open(path: String) -> Result<ViewerOpenResult, String> {
9+
file_viewer::open_session(&path).map_err(|e| e.to_string())
10+
}
11+
12+
/// Fetches a range of lines from a viewer session.
13+
///
14+
/// # Arguments
15+
/// * `session_id` - The session ID from `viewer_open`.
16+
/// * `target_type` - One of "line", "byte", or "fraction".
17+
/// * `target_value` - The seek value (line number, byte offset, or fraction 0.0-1.0).
18+
/// * `count` - Number of lines to fetch.
19+
#[tauri::command]
20+
pub fn viewer_get_lines(
21+
session_id: String,
22+
target_type: String,
23+
target_value: f64,
24+
count: usize,
25+
) -> Result<LineChunk, String> {
26+
let target = match target_type.as_str() {
27+
"line" => SeekTarget::Line(target_value as usize),
28+
"byte" => SeekTarget::ByteOffset(target_value as u64),
29+
"fraction" => SeekTarget::Fraction(target_value),
30+
other => {
31+
return Err(format!(
32+
"Unknown target type: {}. Use 'line', 'byte', or 'fraction'.",
33+
other
34+
));
35+
}
36+
};
37+
file_viewer::get_lines(&session_id, target, count).map_err(|e| e.to_string())
38+
}
39+
40+
/// Starts a background search in the viewer session.
41+
/// Poll with `viewer_search_poll` to get results.
42+
#[tauri::command]
43+
pub fn viewer_search_start(session_id: String, query: String) -> Result<(), String> {
44+
if query.is_empty() {
45+
return Err("Search query cannot be empty".to_string());
46+
}
47+
file_viewer::search_start(&session_id, query).map_err(|e| e.to_string())
48+
}
49+
50+
/// Polls search progress and current matches.
51+
#[tauri::command]
52+
pub fn viewer_search_poll(session_id: String) -> Result<SearchPollResult, String> {
53+
file_viewer::search_poll(&session_id).map_err(|e| e.to_string())
54+
}
55+
56+
/// Cancels an ongoing search.
57+
#[tauri::command]
58+
pub fn viewer_search_cancel(session_id: String) -> Result<(), String> {
59+
file_viewer::search_cancel(&session_id).map_err(|e| e.to_string())
60+
}
61+
62+
/// Closes a viewer session and frees resources.
63+
#[tauri::command]
64+
pub fn viewer_close(session_id: String) -> Result<(), String> {
65+
file_viewer::close_session(&session_id).map_err(|e| e.to_string())
66+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Tauri commands module.
22
33
pub mod file_system;
4+
pub mod file_viewer;
45
pub mod font_metrics;
56
pub mod icons;
67
pub mod licensing;

0 commit comments

Comments
 (0)