From ca564177fa5d5d8a6af49163c7ded2d6c86a6ec0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Jan 2026 00:40:54 +0000 Subject: [PATCH 01/16] Feature: Add file viewer with search (F3) Add a text file viewer that opens in a separate window when pressing F3. Includes case-insensitive search with match highlighting, line numbers, and keyboard navigation (Cmd+F to search, ESC to close). --- .../src-tauri/src/commands/file_system.rs | 143 ++++++ apps/desktop/src-tauri/src/lib.rs | 1 + .../lib/file-explorer/DualPaneExplorer.svelte | 27 ++ .../lib/file-explorer/FunctionKeyBar.svelte | 5 +- .../lib/file-explorer/FunctionKeyBar.test.ts | 22 +- .../src/lib/file-viewer/open-viewer.ts | 22 + .../src/lib/file-viewer/viewer-search.test.ts | 86 ++++ .../src/lib/file-viewer/viewer-search.ts | 39 ++ apps/desktop/src/lib/tauri-commands.ts | 17 + apps/desktop/src/routes/+page.svelte | 7 + apps/desktop/src/routes/viewer/+page.svelte | 408 ++++++++++++++++++ docs/features/file-viewer.md | 58 +++ docs/specs/viewer-tasks.md | 19 + 13 files changed, 846 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/lib/file-viewer/open-viewer.ts create mode 100644 apps/desktop/src/lib/file-viewer/viewer-search.test.ts create mode 100644 apps/desktop/src/lib/file-viewer/viewer-search.ts create mode 100644 apps/desktop/src/routes/viewer/+page.svelte create mode 100644 docs/features/file-viewer.md create mode 100644 docs/specs/viewer-tasks.md diff --git a/apps/desktop/src-tauri/src/commands/file_system.rs b/apps/desktop/src-tauri/src/commands/file_system.rs index 61b96075..e2539d2c 100644 --- a/apps/desktop/src-tauri/src/commands/file_system.rs +++ b/apps/desktop/src-tauri/src/commands/file_system.rs @@ -561,6 +561,70 @@ pub fn start_selection_drag( Err("Drag operation is not yet supported on this platform".to_string()) } +// ============================================================================ +// File viewer +// ============================================================================ + +/// Result of reading a file's content for the viewer. +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileContentResult { + /// The text content of the file (UTF-8 lossy decoded). + pub content: String, + /// Total number of lines. + pub line_count: usize, + /// File size in bytes. + pub size: u64, + /// The file name (last component of the path). + pub file_name: String, +} + +/// Reads a file's content as text for the file viewer. +/// +/// Returns the full file content as a UTF-8 string (lossy — invalid bytes become replacement chars). +/// Not optimized for huge files; intended for reasonably-sized text files. +/// +/// # Arguments +/// * `path` - Absolute path to the file. Supports tilde expansion (~). +#[tauri::command] +pub fn read_file_content(path: String) -> Result { + let expanded = expand_tilde(&path); + let file_path = PathBuf::from(&expanded); + + if !file_path.exists() { + return Err(format!("File not found: {}", path)); + } + if file_path.is_dir() { + return Err("Cannot view a directory".to_string()); + } + + let metadata = std::fs::metadata(&file_path).map_err(|e| match e.kind() { + std::io::ErrorKind::PermissionDenied => format!("Permission denied: {}", path), + _ => format!("Cannot read file metadata: {}", e), + })?; + + let size = metadata.len(); + let bytes = std::fs::read(&file_path).map_err(|e| match e.kind() { + std::io::ErrorKind::PermissionDenied => format!("Permission denied: {}", path), + _ => format!("Failed to read file: {}", e), + })?; + + let content = String::from_utf8_lossy(&bytes).into_owned(); + let line_count = content.lines().count().max(1); + + let file_name = file_path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.clone()); + + Ok(FileContentResult { + content, + line_count, + size, + file_name, + }) +} + /// Expands tilde (~) to the user's home directory. fn expand_tilde(path: &str) -> String { if (path.starts_with("~/") || path == "~") @@ -660,4 +724,83 @@ mod tests { let result = create_directory("/nonexistent_path_12345".to_string(), "test".to_string()); assert!(result.is_err()); } + + // ======================================================================== + // read_file_content tests + // ======================================================================== + + #[test] + fn test_read_file_content_success() { + let tmp = create_test_dir("read_content_ok"); + let file_path = tmp.join("hello.txt"); + fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap(); + + let result = read_file_content(file_path.to_string_lossy().to_string()); + assert!(result.is_ok()); + let res = result.unwrap(); + assert_eq!(res.file_name, "hello.txt"); + assert_eq!(res.line_count, 3); + assert!(res.content.starts_with("line 1")); + assert_eq!(res.size, 21); + cleanup_test_dir(&tmp); + } + + #[test] + fn test_read_file_content_empty_file() { + let tmp = create_test_dir("read_content_empty"); + let file_path = tmp.join("empty.txt"); + fs::write(&file_path, "").unwrap(); + + let result = read_file_content(file_path.to_string_lossy().to_string()); + assert!(result.is_ok()); + let res = result.unwrap(); + assert_eq!(res.content, ""); + assert_eq!(res.line_count, 1); // At least 1 + assert_eq!(res.size, 0); + cleanup_test_dir(&tmp); + } + + #[test] + fn test_read_file_content_not_found() { + let result = read_file_content("/nonexistent_file_12345.txt".to_string()); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("File not found")); + } + + #[test] + fn test_read_file_content_directory() { + let tmp = create_test_dir("read_content_dir"); + let result = read_file_content(tmp.to_string_lossy().to_string()); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Cannot view a directory")); + cleanup_test_dir(&tmp); + } + + #[test] + fn test_read_file_content_binary() { + let tmp = create_test_dir("read_content_binary"); + let file_path = tmp.join("binary.bin"); + fs::write(&file_path, b"\x00\x01\x02\xff\xfe").unwrap(); + + let result = read_file_content(file_path.to_string_lossy().to_string()); + assert!(result.is_ok()); + // Binary content is lossy-decoded, should contain replacement characters + let res = result.unwrap(); + assert!(res.content.contains('\u{FFFD}')); + cleanup_test_dir(&tmp); + } + + #[test] + fn test_read_file_content_single_line_no_newline() { + let tmp = create_test_dir("read_content_single"); + let file_path = tmp.join("single.txt"); + fs::write(&file_path, "hello world").unwrap(); + + let result = read_file_content(file_path.to_string_lossy().to_string()); + assert!(result.is_ok()); + let res = result.unwrap(); + assert_eq!(res.line_count, 1); + assert_eq!(res.content, "hello world"); + cleanup_test_dir(&tmp); + } } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 7b8e580a..c473f8d9 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -280,6 +280,7 @@ pub fn run() { commands::file_system::get_operation_status, commands::file_system::get_listing_stats, commands::file_system::start_selection_drag, + commands::file_system::read_file_content, commands::font_metrics::store_font_metrics, commands::font_metrics::has_font_metrics, commands::icons::get_icons, diff --git a/apps/desktop/src/lib/file-explorer/DualPaneExplorer.svelte b/apps/desktop/src/lib/file-explorer/DualPaneExplorer.svelte index 4f87291d..85e1b5df 100644 --- a/apps/desktop/src/lib/file-explorer/DualPaneExplorer.svelte +++ b/apps/desktop/src/lib/file-explorer/DualPaneExplorer.svelte @@ -48,6 +48,7 @@ type NavigationHistory, } from './navigation-history' import { initNetworkDiscovery, cleanupNetworkDiscovery } from '$lib/network-store.svelte' + import { openFileViewer } from '$lib/file-viewer/open-viewer' import { getAppLogger } from '$lib/logger' const log = getAppLogger('fileExplorer') @@ -539,6 +540,13 @@ return } + // F3 - View file + if (e.key === 'F3') { + e.preventDefault() + void openViewerForCursor() + return + } + // F5 - Copy dialog if (e.key === 'F5') { e.preventDefault() @@ -921,6 +929,25 @@ containerElement?.focus() } + /** Opens the file viewer for the file under the cursor. */ + export async function openViewerForCursor() { + const paneRef = focusedPane === 'left' ? leftPaneRef : rightPaneRef + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const listingId = paneRef?.getListingId?.() as string | undefined + if (!listingId) return + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const cursorIndex = paneRef?.getCursorIndex?.() as number | undefined + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const hasParent = paneRef?.hasParentEntry?.() as boolean | undefined + const backendIndex = toBackendCursorIndex(cursorIndex ?? -1, hasParent ?? false) + if (backendIndex === null) return + + const file = await getFileAt(listingId, backendIndex, showHiddenFiles) + if (!file || file.isDirectory || file.name === '..') return + + void openFileViewer(file.path) + } + /** Opens the copy dialog with the current selection info. */ export async function openCopyDialog() { const isLeft = focusedPane === 'left' diff --git a/apps/desktop/src/lib/file-explorer/FunctionKeyBar.svelte b/apps/desktop/src/lib/file-explorer/FunctionKeyBar.svelte index 7e3a26e1..04a8925d 100644 --- a/apps/desktop/src/lib/file-explorer/FunctionKeyBar.svelte +++ b/apps/desktop/src/lib/file-explorer/FunctionKeyBar.svelte @@ -1,12 +1,13 @@ {#if visible} @@ -17,7 +18,7 @@ e.preventDefault() }} > - + + + + {/if} + + {#if loading} +
Loading...
+ {:else if error} +
{error}
+ {:else} +
+ {#each lines as line, idx} +
+ + {#each getHighlightedSegments(idx, line) as seg}{#if seg.highlight}{seg.text}{:else}{seg.text}{/if}{/each}{#if line === ''}{'\n'}{/if} +
+ {/each} +
+ {/if} + +
+ {fileName} + {lineCount} {lineCount === 1 ? 'line' : 'lines'} + {formatSize(fileSize)} + Ctrl+F search · Esc close +
+ + + diff --git a/docs/features/file-viewer.md b/docs/features/file-viewer.md new file mode 100644 index 00000000..c6d58bd8 --- /dev/null +++ b/docs/features/file-viewer.md @@ -0,0 +1,58 @@ +# File viewer + +View a file's text content in a dedicated window via F3. Includes search with match highlighting. + +## User interaction + +Press **F3** to open the file under the cursor in a new viewer window. Multiple viewer windows can be open at the same +time. Each window remembers its size and position between sessions. + +### Viewing + +- File content is displayed with line numbers in a monospace font. +- A status bar shows the file name, line count, and file size. +- Text is selectable for copying. + +### Search + +- **Cmd+F** (or **Ctrl+F**): Open the search bar. +- Type to find matches (case-insensitive). The match count is shown. +- **Enter**: Jump to next match. +- **Shift+Enter**: Jump to previous match. +- Matches are highlighted in the content. The active match uses a distinct color. +- Search wraps around at the end/beginning of the file. + +### Keyboard shortcuts + +- **Escape**: Close search bar if open, or close the viewer window. +- **Cmd+F / Ctrl+F**: Open or focus the search bar. +- **Enter / Shift+Enter**: Next/previous match (when search is open). + +### Limitations + +- Not optimized for very large files. The entire file is loaded into memory. +- Binary files are shown with lossy UTF-8 decoding (replacement characters for invalid bytes). +- Directories can't be viewed (F3 does nothing when the cursor is on a folder). + +## Implementation + +### Backend + +- **Command**: `read_file_content(path)` in `apps/desktop/src-tauri/src/commands/file_system.rs` +- Returns `FileContentResult { content, lineCount, size, fileName }` +- Uses `String::from_utf8_lossy` for binary-safe reading +- Supports tilde expansion for the path + +### Frontend + +- **Route**: `apps/desktop/src/routes/viewer/+page.svelte` — the viewer window's page +- **Search utilities**: `apps/desktop/src/lib/file-viewer/viewer-search.ts` +- **Window opener**: `apps/desktop/src/lib/file-viewer/open-viewer.ts` +- **Key binding**: F3 handler in `DualPaneExplorer.svelte` calls `openViewerForCursor()` +- **Function key bar**: F3 button in `FunctionKeyBar.svelte` +- **Tauri wrapper**: `readFileContent()` in `$lib/tauri-commands.ts` + +### Window management + +Each viewer opens as a new `WebviewWindow` with a unique label (`viewer-{timestamp}`). The file path is passed as a URL +query parameter. Window size and position are persisted by `tauri-plugin-window-state`. diff --git a/docs/specs/viewer-tasks.md b/docs/specs/viewer-tasks.md new file mode 100644 index 00000000..ddfb4b04 --- /dev/null +++ b/docs/specs/viewer-tasks.md @@ -0,0 +1,19 @@ +# File viewer — task list + +## Tasks + +- [x] Create this task list +- [x] Add `read_file_content` Rust command (reads file, returns text + metadata) +- [x] Add Rust tests for the command +- [x] Create `/viewer` SvelteKit route with `FileViewer` component +- [x] Wire F3 key binding in `DualPaneExplorer` and enable F3 in `FunctionKeyBar` +- [x] Open viewer in a new Tauri window (unique label per file, multiple allowed) +- [x] Persist window size/position via `tauri-plugin-window-state` +- [x] Display file content with line numbers and monospace font +- [x] Add search functionality (Cmd+F / Ctrl+F, find next/previous, match count) +- [x] ESC closes the viewer window (or closes search bar if open) +- [x] Add `readFileContent` wrapper in `tauri-commands.ts` +- [x] Write Vitest unit tests for viewer search utilities +- [x] Document in `docs/features/file-viewer.md` +- [x] Run checker script, fix any issues +- [x] Create PR From 5a81a52e4c941a43d4f2e10031301316884cd78b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Jan 2026 00:51:53 +0000 Subject: [PATCH 02/16] Fix ESLint errors in file viewer - Extract function key handling to reduce handleKeyDown complexity - Fix template literal type in open-viewer.ts - Add keys to {#each} blocks in viewer page - Remove useless mustache interpolation --- .../lib/file-explorer/DualPaneExplorer.svelte | 69 ++++++++----------- .../src/lib/file-viewer/open-viewer.ts | 2 +- apps/desktop/src/routes/viewer/+page.svelte | 6 +- 3 files changed, 34 insertions(+), 43 deletions(-) diff --git a/apps/desktop/src/lib/file-explorer/DualPaneExplorer.svelte b/apps/desktop/src/lib/file-explorer/DualPaneExplorer.svelte index 85e1b5df..9987cdf1 100644 --- a/apps/desktop/src/lib/file-explorer/DualPaneExplorer.svelte +++ b/apps/desktop/src/lib/file-explorer/DualPaneExplorer.svelte @@ -505,6 +505,35 @@ return false } + /** Handles function key shortcuts (F1-F7). Returns true if a function key was handled. */ + function handleFunctionKey(e: KeyboardEvent): boolean { + switch (e.key) { + case 'F1': + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + rightPaneRef?.closeVolumeChooser() + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + leftPaneRef?.toggleVolumeChooser() + return true + case 'F2': + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + leftPaneRef?.closeVolumeChooser() + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + rightPaneRef?.toggleVolumeChooser() + return true + case 'F3': + void openViewerForCursor() + return true + case 'F5': + void openCopyDialog() + return true + case 'F7': + void openNewFolderDialog() + return true + default: + return false + } + } + function handleKeyDown(e: KeyboardEvent) { if (e.key === 'Tab') { e.preventDefault() @@ -518,46 +547,8 @@ return } - // F1 or ⌥F1 - Open left pane volume chooser - if (e.key === 'F1') { - e.preventDefault() - // Close right pane's volume chooser before toggling left (only one can be open) - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - rightPaneRef?.closeVolumeChooser() - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - leftPaneRef?.toggleVolumeChooser() - return - } - - // F2 or ⌥F2 - Open right pane volume chooser - if (e.key === 'F2') { - e.preventDefault() - // Close left pane's volume chooser before toggling right (only one can be open) - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - leftPaneRef?.closeVolumeChooser() - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - rightPaneRef?.toggleVolumeChooser() - return - } - - // F3 - View file - if (e.key === 'F3') { - e.preventDefault() - void openViewerForCursor() - return - } - - // F5 - Copy dialog - if (e.key === 'F5') { - e.preventDefault() - void openCopyDialog() - return - } - - // F7 - New folder dialog - if (e.key === 'F7') { + if (handleFunctionKey(e)) { e.preventDefault() - void openNewFolderDialog() return } diff --git a/apps/desktop/src/lib/file-viewer/open-viewer.ts b/apps/desktop/src/lib/file-viewer/open-viewer.ts index b41f436a..1d064ad4 100644 --- a/apps/desktop/src/lib/file-viewer/open-viewer.ts +++ b/apps/desktop/src/lib/file-viewer/open-viewer.ts @@ -3,7 +3,7 @@ export async function openFileViewer(filePath: string): Promise { const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow') // Use a unique label per viewer instance (timestamp-based) - const label = `viewer-${Date.now()}` + const label = `viewer-${String(Date.now())}` const encodedPath = encodeURIComponent(filePath) new WebviewWindow(label, { diff --git a/apps/desktop/src/routes/viewer/+page.svelte b/apps/desktop/src/routes/viewer/+page.svelte index 7d716901..f11270ea 100644 --- a/apps/desktop/src/routes/viewer/+page.svelte +++ b/apps/desktop/src/routes/viewer/+page.svelte @@ -227,13 +227,13 @@
{error}
{:else}
- {#each lines as line, idx} + {#each lines as line, idx (idx)}
{#each getHighlightedSegments(idx, line) as seg}{#if seg.highlight}{#each getHighlightedSegments(idx, line) as seg, segIdx (segIdx)}{#if seg.highlight}{seg.text}{:else}{seg.text}{/if}{/each}{#if line === ''}{'\n'}{/if}{:else}{seg.text}{/if}{/each}
{/each} From 2963ee788ff2fd2c0c85c798ddff41671fd7ab7a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Jan 2026 06:53:04 +0000 Subject: [PATCH 03/16] Add open-viewer.ts to coverage allowlist Depends on Tauri WebviewWindow API, not unit-testable. --- apps/desktop/coverage-allowlist.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/coverage-allowlist.json b/apps/desktop/coverage-allowlist.json index 9bcec038..c0b0fb83 100644 --- a/apps/desktop/coverage-allowlist.json +++ b/apps/desktop/coverage-allowlist.json @@ -18,6 +18,7 @@ "file-explorer/SelectionInfo.svelte": { "reason": "Logic tested in selection-info-utils.ts, DOM-dependent" }, "file-explorer/ShareBrowser.svelte": { "reason": "Network component, needs integration tests" }, "file-explorer/SortableHeader.svelte": { "reason": "Simple UI component, display only" }, + "file-viewer/open-viewer.ts": { "reason": "Depends on Tauri WebviewWindow API" }, "font-metrics/measure.ts": { "reason": "Depends on DOM APIs" }, "icon-cache.ts": { "reason": "Depends on Tauri APIs" }, "licensing-store.svelte.ts": { "reason": "Depends on Tauri store APIs" }, From c7a1336132fc626d612daf26dd3565ae8ff8b68f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Jan 2026 16:47:52 +0000 Subject: [PATCH 04/16] Feature: Three-backend viewer architecture with virtual scrolling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/desktop/src-tauri/Cargo.lock | 1 + apps/desktop/src-tauri/Cargo.toml | 1 + .../src-tauri/src/commands/file_system.rs | 143 ------- .../src-tauri/src/commands/file_viewer.rs | 66 ++++ apps/desktop/src-tauri/src/commands/mod.rs | 1 + .../src-tauri/src/file_viewer/byte_seek.rs | 279 ++++++++++++++ .../src/file_viewer/byte_seek_test.rs | 280 ++++++++++++++ .../src-tauri/src/file_viewer/full_load.rs | 174 +++++++++ .../src/file_viewer/full_load_test.rs | 238 ++++++++++++ .../src-tauri/src/file_viewer/line_index.rs | 337 +++++++++++++++++ .../src/file_viewer/line_index_test.rs | 269 +++++++++++++ apps/desktop/src-tauri/src/file_viewer/mod.rs | 139 +++++++ .../src-tauri/src/file_viewer/session.rs | 324 ++++++++++++++++ .../src-tauri/src/file_viewer/session_test.rs | 254 +++++++++++++ apps/desktop/src-tauri/src/lib.rs | 8 +- .../src/lib/file-viewer/viewer-search.test.ts | 86 ----- .../src/lib/file-viewer/viewer-search.ts | 39 -- apps/desktop/src/lib/tauri-commands.ts | 81 +++- apps/desktop/src/routes/viewer/+page.svelte | 357 ++++++++++++++---- docs/features/file-viewer.md | 65 +++- docs/specs/viewer-tasks.md | 19 +- 21 files changed, 2794 insertions(+), 367 deletions(-) create mode 100644 apps/desktop/src-tauri/src/commands/file_viewer.rs create mode 100644 apps/desktop/src-tauri/src/file_viewer/byte_seek.rs create mode 100644 apps/desktop/src-tauri/src/file_viewer/byte_seek_test.rs create mode 100644 apps/desktop/src-tauri/src/file_viewer/full_load.rs create mode 100644 apps/desktop/src-tauri/src/file_viewer/full_load_test.rs create mode 100644 apps/desktop/src-tauri/src/file_viewer/line_index.rs create mode 100644 apps/desktop/src-tauri/src/file_viewer/line_index_test.rs create mode 100644 apps/desktop/src-tauri/src/file_viewer/mod.rs create mode 100644 apps/desktop/src-tauri/src/file_viewer/session.rs create mode 100644 apps/desktop/src-tauri/src/file_viewer/session_test.rs delete mode 100644 apps/desktop/src/lib/file-viewer/viewer-search.test.ts delete mode 100644 apps/desktop/src/lib/file-viewer/viewer-search.ts diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 6cf132e3..7854ff19 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -1012,6 +1012,7 @@ dependencies = [ "image", "libc", "log", + "memchr", "notify", "notify-debouncer-full", "objc2 0.6.3", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 6bc176b6..6f68d928 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -60,6 +60,7 @@ futures-util = "0.3" tower-http = { version = "0.6", features = ["cors"] } tauri-plugin-updater = "2" tauri-plugin-process = "2" +memchr = "2" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/apps/desktop/src-tauri/src/commands/file_system.rs b/apps/desktop/src-tauri/src/commands/file_system.rs index e2539d2c..61b96075 100644 --- a/apps/desktop/src-tauri/src/commands/file_system.rs +++ b/apps/desktop/src-tauri/src/commands/file_system.rs @@ -561,70 +561,6 @@ pub fn start_selection_drag( Err("Drag operation is not yet supported on this platform".to_string()) } -// ============================================================================ -// File viewer -// ============================================================================ - -/// Result of reading a file's content for the viewer. -#[derive(Debug, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct FileContentResult { - /// The text content of the file (UTF-8 lossy decoded). - pub content: String, - /// Total number of lines. - pub line_count: usize, - /// File size in bytes. - pub size: u64, - /// The file name (last component of the path). - pub file_name: String, -} - -/// Reads a file's content as text for the file viewer. -/// -/// Returns the full file content as a UTF-8 string (lossy — invalid bytes become replacement chars). -/// Not optimized for huge files; intended for reasonably-sized text files. -/// -/// # Arguments -/// * `path` - Absolute path to the file. Supports tilde expansion (~). -#[tauri::command] -pub fn read_file_content(path: String) -> Result { - let expanded = expand_tilde(&path); - let file_path = PathBuf::from(&expanded); - - if !file_path.exists() { - return Err(format!("File not found: {}", path)); - } - if file_path.is_dir() { - return Err("Cannot view a directory".to_string()); - } - - let metadata = std::fs::metadata(&file_path).map_err(|e| match e.kind() { - std::io::ErrorKind::PermissionDenied => format!("Permission denied: {}", path), - _ => format!("Cannot read file metadata: {}", e), - })?; - - let size = metadata.len(); - let bytes = std::fs::read(&file_path).map_err(|e| match e.kind() { - std::io::ErrorKind::PermissionDenied => format!("Permission denied: {}", path), - _ => format!("Failed to read file: {}", e), - })?; - - let content = String::from_utf8_lossy(&bytes).into_owned(); - let line_count = content.lines().count().max(1); - - let file_name = file_path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| path.clone()); - - Ok(FileContentResult { - content, - line_count, - size, - file_name, - }) -} - /// Expands tilde (~) to the user's home directory. fn expand_tilde(path: &str) -> String { if (path.starts_with("~/") || path == "~") @@ -724,83 +660,4 @@ mod tests { let result = create_directory("/nonexistent_path_12345".to_string(), "test".to_string()); assert!(result.is_err()); } - - // ======================================================================== - // read_file_content tests - // ======================================================================== - - #[test] - fn test_read_file_content_success() { - let tmp = create_test_dir("read_content_ok"); - let file_path = tmp.join("hello.txt"); - fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap(); - - let result = read_file_content(file_path.to_string_lossy().to_string()); - assert!(result.is_ok()); - let res = result.unwrap(); - assert_eq!(res.file_name, "hello.txt"); - assert_eq!(res.line_count, 3); - assert!(res.content.starts_with("line 1")); - assert_eq!(res.size, 21); - cleanup_test_dir(&tmp); - } - - #[test] - fn test_read_file_content_empty_file() { - let tmp = create_test_dir("read_content_empty"); - let file_path = tmp.join("empty.txt"); - fs::write(&file_path, "").unwrap(); - - let result = read_file_content(file_path.to_string_lossy().to_string()); - assert!(result.is_ok()); - let res = result.unwrap(); - assert_eq!(res.content, ""); - assert_eq!(res.line_count, 1); // At least 1 - assert_eq!(res.size, 0); - cleanup_test_dir(&tmp); - } - - #[test] - fn test_read_file_content_not_found() { - let result = read_file_content("/nonexistent_file_12345.txt".to_string()); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("File not found")); - } - - #[test] - fn test_read_file_content_directory() { - let tmp = create_test_dir("read_content_dir"); - let result = read_file_content(tmp.to_string_lossy().to_string()); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Cannot view a directory")); - cleanup_test_dir(&tmp); - } - - #[test] - fn test_read_file_content_binary() { - let tmp = create_test_dir("read_content_binary"); - let file_path = tmp.join("binary.bin"); - fs::write(&file_path, b"\x00\x01\x02\xff\xfe").unwrap(); - - let result = read_file_content(file_path.to_string_lossy().to_string()); - assert!(result.is_ok()); - // Binary content is lossy-decoded, should contain replacement characters - let res = result.unwrap(); - assert!(res.content.contains('\u{FFFD}')); - cleanup_test_dir(&tmp); - } - - #[test] - fn test_read_file_content_single_line_no_newline() { - let tmp = create_test_dir("read_content_single"); - let file_path = tmp.join("single.txt"); - fs::write(&file_path, "hello world").unwrap(); - - let result = read_file_content(file_path.to_string_lossy().to_string()); - assert!(result.is_ok()); - let res = result.unwrap(); - assert_eq!(res.line_count, 1); - assert_eq!(res.content, "hello world"); - cleanup_test_dir(&tmp); - } } diff --git a/apps/desktop/src-tauri/src/commands/file_viewer.rs b/apps/desktop/src-tauri/src/commands/file_viewer.rs new file mode 100644 index 00000000..bc9b333f --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/file_viewer.rs @@ -0,0 +1,66 @@ +//! Tauri commands for the file viewer. + +use crate::file_viewer::{self, LineChunk, SearchPollResult, SeekTarget, ViewerOpenResult}; + +/// Opens a viewer session for the given file. +/// Returns session metadata + initial lines from the start of the file. +#[tauri::command] +pub fn viewer_open(path: String) -> Result { + file_viewer::open_session(&path).map_err(|e| e.to_string()) +} + +/// Fetches a range of lines from a viewer session. +/// +/// # Arguments +/// * `session_id` - The session ID from `viewer_open`. +/// * `target_type` - One of "line", "byte", or "fraction". +/// * `target_value` - The seek value (line number, byte offset, or fraction 0.0-1.0). +/// * `count` - Number of lines to fetch. +#[tauri::command] +pub fn viewer_get_lines( + session_id: String, + target_type: String, + target_value: f64, + count: usize, +) -> Result { + let target = match target_type.as_str() { + "line" => SeekTarget::Line(target_value as usize), + "byte" => SeekTarget::ByteOffset(target_value as u64), + "fraction" => SeekTarget::Fraction(target_value), + other => { + return Err(format!( + "Unknown target type: {}. Use 'line', 'byte', or 'fraction'.", + other + )); + } + }; + file_viewer::get_lines(&session_id, target, count).map_err(|e| e.to_string()) +} + +/// Starts a background search in the viewer session. +/// Poll with `viewer_search_poll` to get results. +#[tauri::command] +pub fn viewer_search_start(session_id: String, query: String) -> Result<(), String> { + if query.is_empty() { + return Err("Search query cannot be empty".to_string()); + } + file_viewer::search_start(&session_id, query).map_err(|e| e.to_string()) +} + +/// Polls search progress and current matches. +#[tauri::command] +pub fn viewer_search_poll(session_id: String) -> Result { + file_viewer::search_poll(&session_id).map_err(|e| e.to_string()) +} + +/// Cancels an ongoing search. +#[tauri::command] +pub fn viewer_search_cancel(session_id: String) -> Result<(), String> { + file_viewer::search_cancel(&session_id).map_err(|e| e.to_string()) +} + +/// Closes a viewer session and frees resources. +#[tauri::command] +pub fn viewer_close(session_id: String) -> Result<(), String> { + file_viewer::close_session(&session_id).map_err(|e| e.to_string()) +} diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index 3c856815..d120d4e7 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ //! Tauri commands module. pub mod file_system; +pub mod file_viewer; pub mod font_metrics; pub mod icons; pub mod licensing; diff --git a/apps/desktop/src-tauri/src/file_viewer/byte_seek.rs b/apps/desktop/src-tauri/src/file_viewer/byte_seek.rs new file mode 100644 index 00000000..6b0e6d4f --- /dev/null +++ b/apps/desktop/src-tauri/src/file_viewer/byte_seek.rs @@ -0,0 +1,279 @@ +//! ByteSeekBackend — byte-offset seeking with no pre-scan. +//! +//! Opens the file and can immediately serve lines at any byte position. +//! Scans backward up to MAX_BACKWARD_SCAN bytes to find a newline boundary. +//! If no newline is found (for example, in a binary file), treats the seek position as a line start. +//! +//! Supports Fraction seeking by multiplying fraction × total_bytes. +//! Does NOT support Line seeking (use LineIndexBackend for that). + +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; + +use memchr::memchr; + +use super::{ + BackendCapabilities, FileViewerBackend, LineChunk, MAX_BACKWARD_SCAN, SearchMatch, SeekTarget, ViewerError, +}; + +pub struct ByteSeekBackend { + path: std::path::PathBuf, + total_bytes: u64, + file_name: String, +} + +impl ByteSeekBackend { + pub fn open(path: &Path) -> Result { + let metadata = std::fs::metadata(path).map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => ViewerError::NotFound(path.display().to_string()), + _ => ViewerError::from(e), + })?; + if metadata.is_dir() { + return Err(ViewerError::IsDirectory); + } + + let file_name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + + Ok(Self { + path: path.to_path_buf(), + total_bytes: metadata.len(), + file_name, + }) + } + + /// Given a byte offset, scan backward to find the start of the line containing that offset. + /// Returns the byte offset of the line start. + fn find_line_start(&self, file: &mut File, offset: u64) -> std::io::Result { + if offset == 0 { + return Ok(0); + } + + // How far back can we go? + let scan_len = (offset as usize).min(MAX_BACKWARD_SCAN); + let scan_start = offset - scan_len as u64; + + file.seek(SeekFrom::Start(scan_start))?; + let mut buf = vec![0u8; scan_len]; + let bytes_read = file.read(&mut buf)?; + let buf = &buf[..bytes_read]; + + // Search backward for '\n' — the line starts right after the last newline before offset. + // We search in reverse using memchr on the reversed slice approach. + if let Some(pos) = buf.iter().rposition(|&b| b == b'\n') { + // Line starts right after this newline + Ok(scan_start + pos as u64 + 1) + } else { + // No newline found within MAX_BACKWARD_SCAN — treat scan_start as line start + // (or if scan_start == 0, the file starts here) + Ok(scan_start) + } + } + + /// Read `count` lines starting from `byte_offset`. + /// Returns the lines and the byte offset just past the last line read. + fn read_lines_from(&self, start_offset: u64, count: usize) -> Result<(Vec, u64), ViewerError> { + let mut file = File::open(&self.path)?; + file.seek(SeekFrom::Start(start_offset))?; + + let mut lines = Vec::with_capacity(count); + let mut current_offset = start_offset; + + // Read in chunks for efficiency + let chunk_size: usize = 64 * 1024; // 64 KB read buffer + let mut buf = vec![0u8; chunk_size]; + let mut leftover = Vec::new(); + + 'outer: while lines.len() < count && current_offset < self.total_bytes { + let bytes_read = file.read(&mut buf)?; + if bytes_read == 0 { + break; + } + + // Prepend any leftover from previous chunk + let mut combined = Vec::new(); + let data: &[u8] = if leftover.is_empty() { + &buf[..bytes_read] + } else { + combined.reserve(leftover.len() + bytes_read); + combined.extend_from_slice(&leftover); + combined.extend_from_slice(&buf[..bytes_read]); + leftover.clear(); + &combined + }; + + let mut pos = 0; + while pos < data.len() && lines.len() < count { + if let Some(nl_pos) = memchr(b'\n', &data[pos..]) { + let line_bytes = &data[pos..pos + nl_pos]; + let line = String::from_utf8_lossy(line_bytes).into_owned(); + lines.push(line); + current_offset += (nl_pos + 1) as u64; // +1 for newline + pos += nl_pos + 1; + } else { + // No newline in remaining data — save as leftover + leftover.extend_from_slice(&data[pos..]); + continue 'outer; + } + } + } + + // If there's leftover data (last line without newline), add it + if !leftover.is_empty() && lines.len() < count { + let line = String::from_utf8_lossy(&leftover).into_owned(); + current_offset += leftover.len() as u64; + lines.push(line); + } + + Ok((lines, current_offset)) + } + + fn resolve_byte_offset(&self, target: &SeekTarget) -> u64 { + match target { + SeekTarget::ByteOffset(offset) => (*offset).min(self.total_bytes), + SeekTarget::Fraction(f) => { + let f = f.clamp(0.0, 1.0); + (f * self.total_bytes as f64) as u64 + } + SeekTarget::Line(_) => { + // ByteSeek doesn't support line-based seeking; default to start + 0 + } + } + } +} + +impl FileViewerBackend for ByteSeekBackend { + fn get_lines(&self, target: &SeekTarget, count: usize) -> Result { + let raw_offset = self.resolve_byte_offset(target); + + // Find the actual line start by scanning backward + let mut file = File::open(&self.path)?; + let line_start = self.find_line_start(&mut file, raw_offset)?; + + let (lines, _end_offset) = self.read_lines_from(line_start, count)?; + + Ok(LineChunk { + lines, + // We don't know the line number in byte-seek mode + first_line_number: 0, + byte_offset: line_start, + total_lines: None, + total_bytes: self.total_bytes, + }) + } + + fn search(&self, query: &str, cancel: &AtomicBool, results: &Mutex>) -> Result { + let query_lower = query.to_lowercase(); + let mut file = File::open(&self.path)?; + file.seek(SeekFrom::Start(0))?; + + let chunk_size: usize = 1024 * 1024; // 1 MB chunks + let mut buf = vec![0u8; chunk_size]; + let mut line_number: usize = 0; + let mut scanned: u64 = 0; + let mut leftover = Vec::new(); + + loop { + if cancel.load(Ordering::Relaxed) { + break; + } + + let bytes_read = file.read(&mut buf)?; + if bytes_read == 0 { + break; + } + + // Combine leftover + new data into a working buffer + let mut combined = Vec::new(); + let data: &[u8] = if leftover.is_empty() { + &buf[..bytes_read] + } else { + combined.reserve(leftover.len() + bytes_read); + combined.extend_from_slice(&leftover); + combined.extend_from_slice(&buf[..bytes_read]); + leftover.clear(); + &combined + }; + + let mut pos = 0; + while pos < data.len() { + if cancel.load(Ordering::Relaxed) { + return Ok(scanned); + } + + if let Some(nl_pos) = memchr(b'\n', &data[pos..]) { + let line_bytes = &data[pos..pos + nl_pos]; + let line = String::from_utf8_lossy(line_bytes); + let line_lower = line.to_lowercase(); + + let mut search_start = 0; + while let Some(match_pos) = line_lower[search_start..].find(&query_lower) { + let col = search_start + match_pos; + let mut matches = results.lock().unwrap(); + matches.push(SearchMatch { + line: line_number, + column: col, + length: query.len(), + }); + search_start = col + 1; + } + + scanned += (nl_pos + 1) as u64; + pos += nl_pos + 1; + line_number += 1; + } else { + // Incomplete line — save as leftover for next iteration + leftover.extend_from_slice(&data[pos..]); + break; + } + } + } + + // Handle last line without newline + if !leftover.is_empty() { + let line = String::from_utf8_lossy(&leftover); + let line_lower = line.to_lowercase(); + let mut search_start = 0; + while let Some(match_pos) = line_lower[search_start..].find(&query_lower) { + let col = search_start + match_pos; + let mut matches = results.lock().unwrap(); + matches.push(SearchMatch { + line: line_number, + column: col, + length: query.len(), + }); + search_start = col + 1; + } + scanned += leftover.len() as u64; + } + + Ok(scanned) + } + + fn capabilities(&self) -> BackendCapabilities { + BackendCapabilities { + supports_line_seek: false, + supports_byte_seek: true, + supports_fraction_seek: true, + knows_total_lines: false, + } + } + + fn total_bytes(&self) -> u64 { + self.total_bytes + } + + fn total_lines(&self) -> Option { + None + } + + fn file_name(&self) -> &str { + &self.file_name + } +} diff --git a/apps/desktop/src-tauri/src/file_viewer/byte_seek_test.rs b/apps/desktop/src-tauri/src/file_viewer/byte_seek_test.rs new file mode 100644 index 00000000..1b53592c --- /dev/null +++ b/apps/desktop/src-tauri/src/file_viewer/byte_seek_test.rs @@ -0,0 +1,280 @@ +//! Tests for ByteSeekBackend. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::sync::atomic::AtomicBool; + +use super::byte_seek::ByteSeekBackend; +use super::{FileViewerBackend, SearchMatch, SeekTarget}; + +fn create_test_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("cmdr_viewer_byte_{}", name)); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("Failed to create test directory"); + dir +} + +fn cleanup(path: &Path) { + let _ = fs::remove_dir_all(path); +} + +fn write_test_file(dir: &Path, name: &str, content: &str) -> PathBuf { + let file = dir.join(name); + fs::write(&file, content).unwrap(); + file +} + +#[test] +fn open_succeeds() { + let dir = create_test_dir("open"); + let file = write_test_file(&dir, "test.txt", "hello world\n"); + + let backend = ByteSeekBackend::open(&file).unwrap(); + assert_eq!(backend.file_name(), "test.txt"); + assert_eq!(backend.total_bytes(), 12); + assert_eq!(backend.total_lines(), None); // ByteSeek doesn't know total lines + + cleanup(&dir); +} + +#[test] +fn open_not_found() { + let result = ByteSeekBackend::open(&PathBuf::from("/nonexistent_byte_seek_test.txt")); + assert!(result.is_err()); +} + +#[test] +fn open_directory_fails() { + let dir = create_test_dir("open_dir"); + let result = ByteSeekBackend::open(&dir); + assert!(result.is_err()); + cleanup(&dir); +} + +#[test] +fn get_lines_from_start() { + let dir = create_test_dir("lines_start"); + let file = write_test_file(&dir, "test.txt", "line 1\nline 2\nline 3\nline 4\n"); + + let backend = ByteSeekBackend::open(&file).unwrap(); + let chunk = backend.get_lines(&SeekTarget::ByteOffset(0), 3).unwrap(); + + assert_eq!(chunk.lines, vec!["line 1", "line 2", "line 3"]); + assert_eq!(chunk.byte_offset, 0); + assert_eq!(chunk.total_lines, None); + + cleanup(&dir); +} + +#[test] +fn get_lines_from_middle_byte_offset() { + let dir = create_test_dir("lines_mid"); + // "line 1\n" = 7 bytes, so byte 7 starts "line 2" + let file = write_test_file(&dir, "test.txt", "line 1\nline 2\nline 3\nline 4\n"); + + let backend = ByteSeekBackend::open(&file).unwrap(); + let chunk = backend.get_lines(&SeekTarget::ByteOffset(7), 2).unwrap(); + + assert_eq!(chunk.lines, vec!["line 2", "line 3"]); + assert_eq!(chunk.byte_offset, 7); + + cleanup(&dir); +} + +#[test] +fn get_lines_with_backward_scan() { + let dir = create_test_dir("backward_scan"); + // Seeking to byte 10 (middle of "line 2") should scan back to start of "line 2" + let file = write_test_file(&dir, "test.txt", "line 1\nline 2\nline 3\n"); + + let backend = ByteSeekBackend::open(&file).unwrap(); + let chunk = backend.get_lines(&SeekTarget::ByteOffset(10), 2).unwrap(); + + // Should find start of "line 2" (byte 7) + assert_eq!(chunk.byte_offset, 7); + assert_eq!(chunk.lines[0], "line 2"); + + cleanup(&dir); +} + +#[test] +fn get_lines_by_fraction() { + let dir = create_test_dir("fraction"); + let content = "line 1\nline 2\nline 3\nline 4\nline 5\n"; + let file = write_test_file(&dir, "test.txt", content); + + let backend = ByteSeekBackend::open(&file).unwrap(); + + // Fraction 0.0 should start at beginning + let chunk = backend.get_lines(&SeekTarget::Fraction(0.0), 1).unwrap(); + assert_eq!(chunk.byte_offset, 0); + assert_eq!(chunk.lines[0], "line 1"); + + cleanup(&dir); +} + +#[test] +fn get_lines_fraction_end() { + let dir = create_test_dir("fraction_end"); + let content = "line 1\nline 2\nline 3\n"; + let file = write_test_file(&dir, "test.txt", content); + + let backend = ByteSeekBackend::open(&file).unwrap(); + + // Fraction 1.0 should go to end (byte 21) + let chunk = backend.get_lines(&SeekTarget::Fraction(1.0), 1).unwrap(); + // Should find the last line or be at/near end + assert!(chunk.byte_offset > 0); + + cleanup(&dir); +} + +#[test] +fn get_lines_line_target_defaults_to_start() { + let dir = create_test_dir("line_target"); + let file = write_test_file(&dir, "test.txt", "a\nb\nc\n"); + + let backend = ByteSeekBackend::open(&file).unwrap(); + // ByteSeek doesn't support line seeking — defaults to start + let chunk = backend.get_lines(&SeekTarget::Line(5), 2).unwrap(); + assert_eq!(chunk.byte_offset, 0); + assert_eq!(chunk.lines[0], "a"); + + cleanup(&dir); +} + +#[test] +fn get_lines_last_line_no_newline() { + let dir = create_test_dir("no_trailing_nl"); + let file = write_test_file(&dir, "test.txt", "line 1\nline 2"); + + let backend = ByteSeekBackend::open(&file).unwrap(); + let chunk = backend.get_lines(&SeekTarget::ByteOffset(0), 10).unwrap(); + + assert_eq!(chunk.lines, vec!["line 1", "line 2"]); + + cleanup(&dir); +} + +#[test] +fn search_finds_matches() { + let dir = create_test_dir("search"); + let file = write_test_file(&dir, "test.txt", "hello world\nfoo bar\nhello again\n"); + + let backend = ByteSeekBackend::open(&file).unwrap(); + let cancel = AtomicBool::new(false); + let results: Mutex> = Mutex::new(Vec::new()); + + backend.search("hello", &cancel, &results).unwrap(); + let matches = results.lock().unwrap(); + + assert_eq!(matches.len(), 2); + assert_eq!(matches[0].line, 0); + assert_eq!(matches[0].column, 0); + assert_eq!(matches[1].line, 2); + + cleanup(&dir); +} + +#[test] +fn search_case_insensitive() { + let dir = create_test_dir("search_case"); + let file = write_test_file(&dir, "test.txt", "Hello\nHELLO\nhello\n"); + + let backend = ByteSeekBackend::open(&file).unwrap(); + let cancel = AtomicBool::new(false); + let results: Mutex> = Mutex::new(Vec::new()); + + backend.search("hello", &cancel, &results).unwrap(); + let matches = results.lock().unwrap(); + + assert_eq!(matches.len(), 3); + + cleanup(&dir); +} + +#[test] +fn search_cancellation() { + let dir = create_test_dir("search_cancel"); + let content = "hello world\n".repeat(10000); + let file = write_test_file(&dir, "test.txt", &content); + + let backend = ByteSeekBackend::open(&file).unwrap(); + let cancel = AtomicBool::new(true); // Pre-cancelled + let results: Mutex> = Mutex::new(Vec::new()); + + backend.search("hello", &cancel, &results).unwrap(); + let matches = results.lock().unwrap(); + + // Should stop early + assert!(matches.len() < 10000); + + cleanup(&dir); +} + +#[test] +fn search_no_matches() { + let dir = create_test_dir("search_none"); + let file = write_test_file(&dir, "test.txt", "abc\ndef\n"); + + let backend = ByteSeekBackend::open(&file).unwrap(); + let cancel = AtomicBool::new(false); + let results: Mutex> = Mutex::new(Vec::new()); + + backend.search("xyz", &cancel, &results).unwrap(); + let matches = results.lock().unwrap(); + + assert_eq!(matches.len(), 0); + + cleanup(&dir); +} + +#[test] +fn capabilities_correct() { + let dir = create_test_dir("caps"); + let file = write_test_file(&dir, "test.txt", "test\n"); + + let backend = ByteSeekBackend::open(&file).unwrap(); + let caps = backend.capabilities(); + + assert!(!caps.supports_line_seek); + assert!(caps.supports_byte_seek); + assert!(caps.supports_fraction_seek); + assert!(!caps.knows_total_lines); + + cleanup(&dir); +} + +#[test] +fn backward_scan_with_no_newline_caps_at_max() { + let dir = create_test_dir("no_nl"); + // Write a file with no newlines (simulates binary) + let content = "x".repeat(20000); + let file = write_test_file(&dir, "test.bin", &content); + + let backend = ByteSeekBackend::open(&file).unwrap(); + + // Seek to byte 15000 — backward scan of 8192 bytes won't find '\n' + let chunk = backend.get_lines(&SeekTarget::ByteOffset(15000), 1).unwrap(); + + // Should fall back to scan_start = 15000 - 8192 = 6808 + assert_eq!(chunk.byte_offset, 15000 - 8192); + + cleanup(&dir); +} + +#[test] +fn empty_file() { + let dir = create_test_dir("empty"); + let file = write_test_file(&dir, "empty.txt", ""); + + let backend = ByteSeekBackend::open(&file).unwrap(); + assert_eq!(backend.total_bytes(), 0); + + let chunk = backend.get_lines(&SeekTarget::ByteOffset(0), 10).unwrap(); + // Empty file should produce empty lines + assert!(chunk.lines.is_empty() || (chunk.lines.len() == 1 && chunk.lines[0].is_empty())); + + cleanup(&dir); +} diff --git a/apps/desktop/src-tauri/src/file_viewer/full_load.rs b/apps/desktop/src-tauri/src/file_viewer/full_load.rs new file mode 100644 index 00000000..333e15f6 --- /dev/null +++ b/apps/desktop/src-tauri/src/file_viewer/full_load.rs @@ -0,0 +1,174 @@ +//! FullLoadBackend — loads entire file into memory. +//! +//! Best for files under FULL_LOAD_THRESHOLD (1 MB). Provides instant random +//! access by line number and fast search since all content is in RAM. + +use std::path::Path; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; + +use super::{BackendCapabilities, FileViewerBackend, LineChunk, SearchMatch, SeekTarget, ViewerError}; + +pub struct FullLoadBackend { + lines: Vec, + /// Byte offset of each line start (parallel to `lines`). + line_offsets: Vec, + total_bytes: u64, + file_name: String, +} + +impl FullLoadBackend { + pub fn open(path: &Path) -> Result { + let metadata = std::fs::metadata(path).map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => ViewerError::NotFound(path.display().to_string()), + _ => ViewerError::from(e), + })?; + if metadata.is_dir() { + return Err(ViewerError::IsDirectory); + } + + let total_bytes = metadata.len(); + let bytes = std::fs::read(path)?; + let content = String::from_utf8_lossy(&bytes); + + let mut lines = Vec::new(); + let mut line_offsets = Vec::new(); + let mut offset: u64 = 0; + + for line in content.split('\n') { + line_offsets.push(offset); + lines.push(line.to_string()); + // +1 for the '\n' delimiter (even if last line has none, offset won't be used beyond) + offset += line.len() as u64 + 1; + } + + // If file ends without newline and content is non-empty, the split gives correct result. + // If file is empty, we still want at least one empty line. + if lines.is_empty() { + lines.push(String::new()); + line_offsets.push(0); + } + + let file_name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + + Ok(Self { + lines, + line_offsets, + total_bytes, + file_name, + }) + } + + /// Create from in-memory content (for testing). + #[cfg(test)] + pub fn from_content(content: &str, file_name: &str) -> Self { + let total_bytes = content.len() as u64; + let mut lines = Vec::new(); + let mut line_offsets = Vec::new(); + let mut offset: u64 = 0; + + for line in content.split('\n') { + line_offsets.push(offset); + lines.push(line.to_string()); + offset += line.len() as u64 + 1; + } + + if lines.is_empty() { + lines.push(String::new()); + line_offsets.push(0); + } + + Self { + lines, + line_offsets, + total_bytes, + file_name: file_name.to_string(), + } + } + + fn resolve_target(&self, target: &SeekTarget) -> usize { + match target { + SeekTarget::Line(n) => (*n).min(self.lines.len().saturating_sub(1)), + SeekTarget::ByteOffset(offset) => { + // Binary search for the line containing this byte offset + match self.line_offsets.binary_search(offset) { + Ok(idx) => idx, + Err(idx) => idx.saturating_sub(1), + } + } + SeekTarget::Fraction(f) => { + let f = f.clamp(0.0, 1.0); + let max_line = self.lines.len().saturating_sub(1); + (f * max_line as f64).round() as usize + } + } + } +} + +impl FileViewerBackend for FullLoadBackend { + fn get_lines(&self, target: &SeekTarget, count: usize) -> Result { + let start = self.resolve_target(target); + let end = (start + count).min(self.lines.len()); + let chunk_lines: Vec = self.lines[start..end].to_vec(); + + Ok(LineChunk { + lines: chunk_lines, + first_line_number: start, + byte_offset: self.line_offsets.get(start).copied().unwrap_or(0), + total_lines: Some(self.lines.len()), + total_bytes: self.total_bytes, + }) + } + + fn search(&self, query: &str, cancel: &AtomicBool, results: &Mutex>) -> Result { + let query_lower = query.to_lowercase(); + let mut scanned: u64 = 0; + + for (line_idx, line) in self.lines.iter().enumerate() { + if cancel.load(Ordering::Relaxed) { + break; + } + + let line_lower = line.to_lowercase(); + let mut search_start = 0; + while let Some(pos) = line_lower[search_start..].find(&query_lower) { + let col = search_start + pos; + let mut matches = results.lock().unwrap(); + matches.push(SearchMatch { + line: line_idx, + column: col, + length: query.len(), + }); + search_start = col + 1; + } + + scanned += line.len() as u64 + 1; // +1 for newline + } + + Ok(scanned) + } + + fn capabilities(&self) -> BackendCapabilities { + BackendCapabilities { + supports_line_seek: true, + supports_byte_seek: true, + supports_fraction_seek: true, + knows_total_lines: true, + } + } + + fn total_bytes(&self) -> u64 { + self.total_bytes + } + + fn total_lines(&self) -> Option { + Some(self.lines.len()) + } + + fn file_name(&self) -> &str { + &self.file_name + } +} diff --git a/apps/desktop/src-tauri/src/file_viewer/full_load_test.rs b/apps/desktop/src-tauri/src/file_viewer/full_load_test.rs new file mode 100644 index 00000000..c5f32bed --- /dev/null +++ b/apps/desktop/src-tauri/src/file_viewer/full_load_test.rs @@ -0,0 +1,238 @@ +//! Tests for FullLoadBackend. + +use std::fs; +use std::path::PathBuf; +use std::sync::Mutex; +use std::sync::atomic::AtomicBool; + +use super::full_load::FullLoadBackend; +use super::{FileViewerBackend, SearchMatch, SeekTarget}; + +fn create_test_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("cmdr_viewer_full_{}", name)); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("Failed to create test directory"); + dir +} + +fn cleanup(path: &PathBuf) { + let _ = fs::remove_dir_all(path); +} + +#[test] +fn open_reads_lines_correctly() { + let dir = create_test_dir("open_lines"); + let file = dir.join("test.txt"); + fs::write(&file, "line 1\nline 2\nline 3\n").unwrap(); + + let backend = FullLoadBackend::open(&file).unwrap(); + assert_eq!(backend.total_lines(), Some(4)); // 3 lines + trailing empty + assert_eq!(backend.file_name(), "test.txt"); + assert_eq!(backend.total_bytes(), 21); + + cleanup(&dir); +} + +#[test] +fn open_empty_file() { + let dir = create_test_dir("open_empty"); + let file = dir.join("empty.txt"); + fs::write(&file, "").unwrap(); + + let backend = FullLoadBackend::open(&file).unwrap(); + assert_eq!(backend.total_lines(), Some(1)); // At least one line + assert_eq!(backend.total_bytes(), 0); + + cleanup(&dir); +} + +#[test] +fn open_not_found() { + let result = FullLoadBackend::open(&PathBuf::from("/nonexistent_viewer_test_12345.txt")); + assert!(result.is_err()); +} + +#[test] +fn open_directory_fails() { + let dir = create_test_dir("open_dir"); + let result = FullLoadBackend::open(&dir); + assert!(result.is_err()); + cleanup(&dir); +} + +#[test] +fn get_lines_from_start() { + let backend = FullLoadBackend::from_content("alpha\nbeta\ngamma\ndelta\nepsilon", "test.txt"); + + let chunk = backend.get_lines(&SeekTarget::Line(0), 3).unwrap(); + assert_eq!(chunk.lines, vec!["alpha", "beta", "gamma"]); + assert_eq!(chunk.first_line_number, 0); + assert_eq!(chunk.total_lines, Some(5)); +} + +#[test] +fn get_lines_from_middle() { + let backend = FullLoadBackend::from_content("a\nb\nc\nd\ne\nf\ng", "test.txt"); + + let chunk = backend.get_lines(&SeekTarget::Line(3), 2).unwrap(); + assert_eq!(chunk.lines, vec!["d", "e"]); + assert_eq!(chunk.first_line_number, 3); +} + +#[test] +fn get_lines_past_end() { + let backend = FullLoadBackend::from_content("a\nb\nc", "test.txt"); + + let chunk = backend.get_lines(&SeekTarget::Line(10), 5).unwrap(); + // Should clamp to last line + assert_eq!(chunk.first_line_number, 2); + assert_eq!(chunk.lines, vec!["c"]); +} + +#[test] +fn get_lines_by_byte_offset() { + let backend = FullLoadBackend::from_content("abc\ndef\nghi", "test.txt"); + + // Byte offset 4 is start of "def" + let chunk = backend.get_lines(&SeekTarget::ByteOffset(4), 2).unwrap(); + assert_eq!(chunk.first_line_number, 1); + assert_eq!(chunk.lines, vec!["def", "ghi"]); +} + +#[test] +fn get_lines_by_fraction() { + let backend = FullLoadBackend::from_content("a\nb\nc\nd\ne", "test.txt"); + + // Fraction 0.5 on 5 lines = line 2 or 3 + let chunk = backend.get_lines(&SeekTarget::Fraction(0.5), 1).unwrap(); + assert!(chunk.first_line_number == 2 || chunk.first_line_number == 3); +} + +#[test] +fn get_lines_fraction_zero() { + let backend = FullLoadBackend::from_content("a\nb\nc", "test.txt"); + + let chunk = backend.get_lines(&SeekTarget::Fraction(0.0), 1).unwrap(); + assert_eq!(chunk.first_line_number, 0); + assert_eq!(chunk.lines, vec!["a"]); +} + +#[test] +fn get_lines_fraction_one() { + let backend = FullLoadBackend::from_content("a\nb\nc", "test.txt"); + + let chunk = backend.get_lines(&SeekTarget::Fraction(1.0), 1).unwrap(); + assert_eq!(chunk.first_line_number, 2); + assert_eq!(chunk.lines, vec!["c"]); +} + +#[test] +fn search_finds_matches() { + let backend = FullLoadBackend::from_content("hello world\nfoo bar\nhello again", "test.txt"); + + let cancel = AtomicBool::new(false); + let results: Mutex> = Mutex::new(Vec::new()); + + let scanned = backend.search("hello", &cancel, &results).unwrap(); + let matches = results.lock().unwrap(); + + assert_eq!(matches.len(), 2); + assert_eq!(matches[0].line, 0); + assert_eq!(matches[0].column, 0); + assert_eq!(matches[1].line, 2); + assert_eq!(matches[1].column, 0); + assert!(scanned > 0); +} + +#[test] +fn search_case_insensitive() { + let backend = FullLoadBackend::from_content("Hello World\nHELLO\nhello", "test.txt"); + + let cancel = AtomicBool::new(false); + let results: Mutex> = Mutex::new(Vec::new()); + + backend.search("hello", &cancel, &results).unwrap(); + let matches = results.lock().unwrap(); + + assert_eq!(matches.len(), 3); +} + +#[test] +fn search_multiple_per_line() { + let backend = FullLoadBackend::from_content("aaa", "test.txt"); + + let cancel = AtomicBool::new(false); + let results: Mutex> = Mutex::new(Vec::new()); + + backend.search("aa", &cancel, &results).unwrap(); + let matches = results.lock().unwrap(); + + // "aaa" contains "aa" at positions 0 and 1 + assert_eq!(matches.len(), 2); + assert_eq!(matches[0].column, 0); + assert_eq!(matches[1].column, 1); +} + +#[test] +fn search_cancellation() { + let backend = FullLoadBackend::from_content(&"line with hello\n".repeat(10000), "test.txt"); + + let cancel = AtomicBool::new(true); // Already cancelled + let results: Mutex> = Mutex::new(Vec::new()); + + backend.search("hello", &cancel, &results).unwrap(); + let matches = results.lock().unwrap(); + + // Should find zero or very few matches since cancelled immediately + assert!(matches.len() < 10000); +} + +#[test] +fn search_no_matches() { + let backend = FullLoadBackend::from_content("abc\ndef\nghi", "test.txt"); + + let cancel = AtomicBool::new(false); + let results: Mutex> = Mutex::new(Vec::new()); + + backend.search("xyz", &cancel, &results).unwrap(); + let matches = results.lock().unwrap(); + + assert_eq!(matches.len(), 0); +} + +#[test] +fn capabilities_correct() { + let backend = FullLoadBackend::from_content("test", "test.txt"); + let caps = backend.capabilities(); + + assert!(caps.supports_line_seek); + assert!(caps.supports_byte_seek); + assert!(caps.supports_fraction_seek); + assert!(caps.knows_total_lines); +} + +#[test] +fn binary_content_handled() { + let dir = create_test_dir("binary"); + let file = dir.join("binary.bin"); + fs::write(&file, b"\x00\x01\x02\xff\xfe\n\x03\x04").unwrap(); + + let backend = FullLoadBackend::open(&file).unwrap(); + let chunk = backend.get_lines(&SeekTarget::Line(0), 10).unwrap(); + + // Should have 2 lines (split on \n) + assert_eq!(chunk.lines.len(), 2); + // Binary bytes become replacement characters + assert!(chunk.lines[0].contains('\u{FFFD}')); + + cleanup(&dir); +} + +#[test] +fn single_line_no_newline() { + let backend = FullLoadBackend::from_content("just one line", "test.txt"); + + assert_eq!(backend.total_lines(), Some(1)); + let chunk = backend.get_lines(&SeekTarget::Line(0), 10).unwrap(); + assert_eq!(chunk.lines, vec!["just one line"]); +} diff --git a/apps/desktop/src-tauri/src/file_viewer/line_index.rs b/apps/desktop/src-tauri/src/file_viewer/line_index.rs new file mode 100644 index 00000000..cc01ad41 --- /dev/null +++ b/apps/desktop/src-tauri/src/file_viewer/line_index.rs @@ -0,0 +1,337 @@ +//! LineIndexBackend — sparse line-offset index for efficient line-based seeking. +//! +//! Stores byte offsets every INDEX_CHECKPOINT_INTERVAL lines (256 by default). +//! Memory: O(total_lines / 256) — a 10M-line file uses ~40 KB of index. +//! +//! The index is built by scanning the file for newlines using memchr (SIMD-accelerated). +//! After scanning, supports O(1) line-based seeking via the checkpoint array. + +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; + +use memchr::memchr; + +use super::{ + BackendCapabilities, FileViewerBackend, INDEX_CHECKPOINT_INTERVAL, LineChunk, SearchMatch, SeekTarget, ViewerError, +}; + +/// A checkpoint in the line index: (line_number, byte_offset). +#[derive(Debug, Clone)] +struct Checkpoint { + line: usize, + offset: u64, +} + +pub struct LineIndexBackend { + path: std::path::PathBuf, + total_bytes: u64, + file_name: String, + /// Sparse index: one checkpoint every INDEX_CHECKPOINT_INTERVAL lines. + checkpoints: Vec, + /// Total lines discovered during scan. + total_lines: usize, +} + +impl LineIndexBackend { + /// Build the line index by scanning the file. This is blocking and should be run + /// in a background thread for large files. Checks `cancel` periodically. + pub fn open(path: &Path, cancel: &AtomicBool) -> Result { + let metadata = std::fs::metadata(path).map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => ViewerError::NotFound(path.display().to_string()), + _ => ViewerError::from(e), + })?; + if metadata.is_dir() { + return Err(ViewerError::IsDirectory); + } + + let total_bytes = metadata.len(); + let file_name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + + // Scan the file to build the sparse line index + let mut file = File::open(path)?; + let mut checkpoints = Vec::new(); + let chunk_size: usize = 256 * 1024; // 256 KB scan buffer + let mut buf = vec![0u8; chunk_size]; + let mut line_number: usize = 0; + let mut byte_offset: u64 = 0; + + // First line always starts at offset 0 + checkpoints.push(Checkpoint { line: 0, offset: 0 }); + + loop { + if cancel.load(Ordering::Relaxed) { + return Err(ViewerError::Io("Scan cancelled".to_string())); + } + + let bytes_read = file.read(&mut buf)?; + if bytes_read == 0 { + break; + } + + let data = &buf[..bytes_read]; + let mut pos = 0; + + while pos < data.len() { + if let Some(nl_pos) = memchr(b'\n', &data[pos..]) { + line_number += 1; + let nl_byte_offset = byte_offset + (pos + nl_pos) as u64 + 1; + + // Store checkpoint every N lines + if line_number.is_multiple_of(INDEX_CHECKPOINT_INTERVAL) { + checkpoints.push(Checkpoint { + line: line_number, + offset: nl_byte_offset, + }); + } + + pos += nl_pos + 1; + } else { + break; + } + } + + byte_offset += bytes_read as u64; + } + + // total_lines is line_number + 1 (for the last line, which may not end with \n) + // But if the file ends with \n, the last "line" is empty — we still count it. + let total_lines = line_number + 1; + + Ok(Self { + path: path.to_path_buf(), + total_bytes, + file_name, + checkpoints, + total_lines, + }) + } + + /// Find the checkpoint at or before the given line number. + fn find_checkpoint(&self, target_line: usize) -> &Checkpoint { + // Binary search for the largest checkpoint with line <= target_line + let idx = match self.checkpoints.binary_search_by_key(&target_line, |cp| cp.line) { + Ok(i) => i, + Err(i) => i.saturating_sub(1), + }; + &self.checkpoints[idx] + } + + /// Read forward from a byte offset, skipping `lines_to_skip` lines, + /// then returning the next `count` lines. + fn read_lines_from_checkpoint( + &self, + start_offset: u64, + lines_to_skip: usize, + count: usize, + ) -> Result, ViewerError> { + let mut file = File::open(&self.path)?; + file.seek(SeekFrom::Start(start_offset))?; + + let chunk_size: usize = 64 * 1024; + let mut buf = vec![0u8; chunk_size]; + let mut lines = Vec::new(); + let mut skipped: usize = 0; + let mut leftover = Vec::new(); + + 'outer: loop { + let bytes_read = file.read(&mut buf)?; + if bytes_read == 0 { + break; + } + + let mut combined = Vec::new(); + let data: &[u8] = if leftover.is_empty() { + &buf[..bytes_read] + } else { + combined.reserve(leftover.len() + bytes_read); + combined.extend_from_slice(&leftover); + combined.extend_from_slice(&buf[..bytes_read]); + leftover.clear(); + &combined + }; + + let mut pos = 0; + while pos < data.len() { + if let Some(nl_pos) = memchr(b'\n', &data[pos..]) { + if skipped < lines_to_skip { + skipped += 1; + pos += nl_pos + 1; + continue; + } + + let line_bytes = &data[pos..pos + nl_pos]; + lines.push(String::from_utf8_lossy(line_bytes).into_owned()); + pos += nl_pos + 1; + + if lines.len() >= count { + break 'outer; + } + } else { + leftover.extend_from_slice(&data[pos..]); + continue 'outer; + } + } + } + + // Handle last line without newline + if !leftover.is_empty() && lines.len() < count && skipped >= lines_to_skip { + lines.push(String::from_utf8_lossy(&leftover).into_owned()); + } + + Ok(lines) + } + + fn resolve_target(&self, target: &SeekTarget) -> usize { + match target { + SeekTarget::Line(n) => (*n).min(self.total_lines.saturating_sub(1)), + SeekTarget::ByteOffset(offset) => { + // Find the checkpoint closest to this byte offset + let idx = match self.checkpoints.binary_search_by_key(offset, |cp| cp.offset) { + Ok(i) => i, + Err(i) => i.saturating_sub(1), + }; + self.checkpoints[idx].line + } + SeekTarget::Fraction(f) => { + let f = f.clamp(0.0, 1.0); + let max_line = self.total_lines.saturating_sub(1); + (f * max_line as f64).round() as usize + } + } + } +} + +impl FileViewerBackend for LineIndexBackend { + fn get_lines(&self, target: &SeekTarget, count: usize) -> Result { + let target_line = self.resolve_target(target); + let checkpoint = self.find_checkpoint(target_line); + let lines_to_skip = target_line - checkpoint.line; + + let lines = self.read_lines_from_checkpoint(checkpoint.offset, lines_to_skip, count)?; + + // Calculate byte offset of the target line (approximate — it's the checkpoint offset) + let byte_offset = checkpoint.offset; + + Ok(LineChunk { + lines, + first_line_number: target_line, + byte_offset, + total_lines: Some(self.total_lines), + total_bytes: self.total_bytes, + }) + } + + fn search(&self, query: &str, cancel: &AtomicBool, results: &Mutex>) -> Result { + // Stream through file in 1 MB chunks, same as ByteSeekBackend + let query_lower = query.to_lowercase(); + let mut file = File::open(&self.path)?; + file.seek(SeekFrom::Start(0))?; + + let chunk_size: usize = 1024 * 1024; + let mut buf = vec![0u8; chunk_size]; + let mut line_number: usize = 0; + let mut scanned: u64 = 0; + let mut leftover = Vec::new(); + + loop { + if cancel.load(Ordering::Relaxed) { + break; + } + + let bytes_read = file.read(&mut buf)?; + if bytes_read == 0 { + break; + } + + let mut combined = Vec::new(); + let data: &[u8] = if leftover.is_empty() { + &buf[..bytes_read] + } else { + combined.reserve(leftover.len() + bytes_read); + combined.extend_from_slice(&leftover); + combined.extend_from_slice(&buf[..bytes_read]); + leftover.clear(); + &combined + }; + + let mut pos = 0; + while pos < data.len() { + if cancel.load(Ordering::Relaxed) { + return Ok(scanned); + } + + if let Some(nl_pos) = memchr(b'\n', &data[pos..]) { + let line_bytes = &data[pos..pos + nl_pos]; + let line = String::from_utf8_lossy(line_bytes); + let line_lower = line.to_lowercase(); + + let mut search_start = 0; + while let Some(match_pos) = line_lower[search_start..].find(&query_lower) { + let col = search_start + match_pos; + let mut matches = results.lock().unwrap(); + matches.push(SearchMatch { + line: line_number, + column: col, + length: query.len(), + }); + search_start = col + 1; + } + + scanned += (nl_pos + 1) as u64; + pos += nl_pos + 1; + line_number += 1; + } else { + leftover.extend_from_slice(&data[pos..]); + break; + } + } + } + + // Handle last line + if !leftover.is_empty() { + let line = String::from_utf8_lossy(&leftover); + let line_lower = line.to_lowercase(); + let mut search_start = 0; + while let Some(match_pos) = line_lower[search_start..].find(&query_lower) { + let col = search_start + match_pos; + let mut matches = results.lock().unwrap(); + matches.push(SearchMatch { + line: line_number, + column: col, + length: query.len(), + }); + search_start = col + 1; + } + scanned += leftover.len() as u64; + } + + Ok(scanned) + } + + fn capabilities(&self) -> BackendCapabilities { + BackendCapabilities { + supports_line_seek: true, + supports_byte_seek: true, + supports_fraction_seek: true, + knows_total_lines: true, + } + } + + fn total_bytes(&self) -> u64 { + self.total_bytes + } + + fn total_lines(&self) -> Option { + Some(self.total_lines) + } + + fn file_name(&self) -> &str { + &self.file_name + } +} diff --git a/apps/desktop/src-tauri/src/file_viewer/line_index_test.rs b/apps/desktop/src-tauri/src/file_viewer/line_index_test.rs new file mode 100644 index 00000000..a4fd759a --- /dev/null +++ b/apps/desktop/src-tauri/src/file_viewer/line_index_test.rs @@ -0,0 +1,269 @@ +//! Tests for LineIndexBackend. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::sync::atomic::AtomicBool; + +use super::line_index::LineIndexBackend; +use super::{FileViewerBackend, INDEX_CHECKPOINT_INTERVAL, SearchMatch, SeekTarget}; + +fn create_test_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("cmdr_viewer_lidx_{}", name)); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("Failed to create test directory"); + dir +} + +fn cleanup(path: &Path) { + let _ = fs::remove_dir_all(path); +} + +fn write_test_file(dir: &Path, name: &str, content: &str) -> PathBuf { + let file = dir.join(name); + fs::write(&file, content).unwrap(); + file +} + +#[test] +fn open_builds_index() { + let dir = create_test_dir("open"); + let file = write_test_file(&dir, "test.txt", "line 1\nline 2\nline 3\n"); + + let cancel = AtomicBool::new(false); + let backend = LineIndexBackend::open(&file, &cancel).unwrap(); + + assert_eq!(backend.total_lines(), Some(4)); // 3 lines + trailing empty + assert_eq!(backend.file_name(), "test.txt"); + assert_eq!(backend.total_bytes(), 21); + + cleanup(&dir); +} + +#[test] +fn open_not_found() { + let cancel = AtomicBool::new(false); + let result = LineIndexBackend::open(&PathBuf::from("/nonexistent_lidx_test.txt"), &cancel); + assert!(result.is_err()); +} + +#[test] +fn open_directory_fails() { + let dir = create_test_dir("open_dir"); + let cancel = AtomicBool::new(false); + let result = LineIndexBackend::open(&dir, &cancel); + assert!(result.is_err()); + cleanup(&dir); +} + +#[test] +fn open_cancellation() { + let dir = create_test_dir("cancel"); + // Create a file with enough lines to potentially hit the cancel check + let content = "line\n".repeat(1000); + let file = write_test_file(&dir, "test.txt", &content); + + let cancel = AtomicBool::new(true); // Pre-cancelled + let result = LineIndexBackend::open(&file, &cancel); + assert!(result.is_err()); + + cleanup(&dir); +} + +#[test] +fn get_lines_from_start() { + let dir = create_test_dir("lines_start"); + let file = write_test_file(&dir, "test.txt", "alpha\nbeta\ngamma\ndelta\n"); + + let cancel = AtomicBool::new(false); + let backend = LineIndexBackend::open(&file, &cancel).unwrap(); + + let chunk = backend.get_lines(&SeekTarget::Line(0), 3).unwrap(); + assert_eq!(chunk.lines, vec!["alpha", "beta", "gamma"]); + assert_eq!(chunk.first_line_number, 0); + assert_eq!(chunk.total_lines, Some(5)); + + cleanup(&dir); +} + +#[test] +fn get_lines_from_middle() { + let dir = create_test_dir("lines_mid"); + let file = write_test_file(&dir, "test.txt", "a\nb\nc\nd\ne\nf\n"); + + let cancel = AtomicBool::new(false); + let backend = LineIndexBackend::open(&file, &cancel).unwrap(); + + let chunk = backend.get_lines(&SeekTarget::Line(3), 2).unwrap(); + assert_eq!(chunk.lines, vec!["d", "e"]); + assert_eq!(chunk.first_line_number, 3); + + cleanup(&dir); +} + +#[test] +fn get_lines_past_end() { + let dir = create_test_dir("lines_end"); + let file = write_test_file(&dir, "test.txt", "a\nb\nc\n"); + + let cancel = AtomicBool::new(false); + let backend = LineIndexBackend::open(&file, &cancel).unwrap(); + + let chunk = backend.get_lines(&SeekTarget::Line(10), 5).unwrap(); + // Should clamp to last line + assert_eq!(chunk.first_line_number, 3); // 4 lines (including trailing empty), last is index 3 + + cleanup(&dir); +} + +#[test] +fn get_lines_by_fraction() { + let dir = create_test_dir("fraction"); + let content = "line 1\nline 2\nline 3\nline 4\nline 5\n"; + let file = write_test_file(&dir, "test.txt", content); + + let cancel = AtomicBool::new(false); + let backend = LineIndexBackend::open(&file, &cancel).unwrap(); + + // Fraction 0.0 = first line + let chunk = backend.get_lines(&SeekTarget::Fraction(0.0), 1).unwrap(); + assert_eq!(chunk.first_line_number, 0); + assert_eq!(chunk.lines[0], "line 1"); + + cleanup(&dir); +} + +#[test] +fn get_lines_no_trailing_newline() { + let dir = create_test_dir("no_trail"); + let file = write_test_file(&dir, "test.txt", "a\nb\nc"); + + let cancel = AtomicBool::new(false); + let backend = LineIndexBackend::open(&file, &cancel).unwrap(); + + let chunk = backend.get_lines(&SeekTarget::Line(0), 10).unwrap(); + assert_eq!(chunk.lines, vec!["a", "b", "c"]); + assert_eq!(backend.total_lines(), Some(3)); + + cleanup(&dir); +} + +#[test] +fn sparse_index_checkpoints() { + let dir = create_test_dir("checkpoints"); + // Create a file with more than INDEX_CHECKPOINT_INTERVAL lines + let line_count = INDEX_CHECKPOINT_INTERVAL * 3 + 50; + let content: String = (0..line_count).map(|i| format!("line {:06}\n", i)).collect(); + let file = write_test_file(&dir, "test.txt", &content); + + let cancel = AtomicBool::new(false); + let backend = LineIndexBackend::open(&file, &cancel).unwrap(); + + // Should have total_lines correct + assert_eq!(backend.total_lines(), Some(line_count + 1)); // +1 for trailing empty + + // Seek to a line past the first checkpoint + let target_line = INDEX_CHECKPOINT_INTERVAL + 10; + let chunk = backend.get_lines(&SeekTarget::Line(target_line), 3).unwrap(); + assert_eq!(chunk.first_line_number, target_line); + assert_eq!(chunk.lines[0], format!("line {:06}", target_line)); + + // Seek to a line past the second checkpoint + let target_line2 = INDEX_CHECKPOINT_INTERVAL * 2 + 5; + let chunk2 = backend.get_lines(&SeekTarget::Line(target_line2), 2).unwrap(); + assert_eq!(chunk2.first_line_number, target_line2); + assert_eq!(chunk2.lines[0], format!("line {:06}", target_line2)); + + cleanup(&dir); +} + +#[test] +fn search_finds_matches() { + let dir = create_test_dir("search"); + let file = write_test_file(&dir, "test.txt", "hello world\nfoo bar\nhello again\n"); + + let cancel_scan = AtomicBool::new(false); + let backend = LineIndexBackend::open(&file, &cancel_scan).unwrap(); + + let cancel = AtomicBool::new(false); + let results: Mutex> = Mutex::new(Vec::new()); + + backend.search("hello", &cancel, &results).unwrap(); + let matches = results.lock().unwrap(); + + assert_eq!(matches.len(), 2); + assert_eq!(matches[0].line, 0); + assert_eq!(matches[1].line, 2); + + cleanup(&dir); +} + +#[test] +fn search_case_insensitive() { + let dir = create_test_dir("search_case"); + let file = write_test_file(&dir, "test.txt", "Hello\nHELLO\nhello\n"); + + let cancel_scan = AtomicBool::new(false); + let backend = LineIndexBackend::open(&file, &cancel_scan).unwrap(); + + let cancel = AtomicBool::new(false); + let results: Mutex> = Mutex::new(Vec::new()); + + backend.search("hello", &cancel, &results).unwrap(); + let matches = results.lock().unwrap(); + + assert_eq!(matches.len(), 3); + + cleanup(&dir); +} + +#[test] +fn search_cancellation() { + let dir = create_test_dir("search_cancel"); + let content = "hello world\n".repeat(10000); + let file = write_test_file(&dir, "test.txt", &content); + + let cancel_scan = AtomicBool::new(false); + let backend = LineIndexBackend::open(&file, &cancel_scan).unwrap(); + + let cancel = AtomicBool::new(true); + let results: Mutex> = Mutex::new(Vec::new()); + + backend.search("hello", &cancel, &results).unwrap(); + let matches = results.lock().unwrap(); + + assert!(matches.len() < 10000); + + cleanup(&dir); +} + +#[test] +fn capabilities_correct() { + let dir = create_test_dir("caps"); + let file = write_test_file(&dir, "test.txt", "test\n"); + + let cancel = AtomicBool::new(false); + let backend = LineIndexBackend::open(&file, &cancel).unwrap(); + let caps = backend.capabilities(); + + assert!(caps.supports_line_seek); + assert!(caps.supports_byte_seek); + assert!(caps.supports_fraction_seek); + assert!(caps.knows_total_lines); + + cleanup(&dir); +} + +#[test] +fn empty_file() { + let dir = create_test_dir("empty"); + let file = write_test_file(&dir, "test.txt", ""); + + let cancel = AtomicBool::new(false); + let backend = LineIndexBackend::open(&file, &cancel).unwrap(); + + assert_eq!(backend.total_bytes(), 0); + assert_eq!(backend.total_lines(), Some(1)); // One empty line + + cleanup(&dir); +} diff --git a/apps/desktop/src-tauri/src/file_viewer/mod.rs b/apps/desktop/src-tauri/src/file_viewer/mod.rs new file mode 100644 index 00000000..b64a0e9b --- /dev/null +++ b/apps/desktop/src-tauri/src/file_viewer/mod.rs @@ -0,0 +1,139 @@ +//! File viewer module — on-demand line serving with three backend strategies. +//! +//! Backends: +//! - `FullLoadBackend`: loads entire file into memory (small files, <1 MB) +//! - `LineIndexBackend`: sparse line-offset index, O(lines/256) memory +//! - `ByteSeekBackend`: byte-offset seeking, no pre-scan needed (instant open) + +mod byte_seek; +mod full_load; +mod line_index; +mod session; + +#[cfg(test)] +mod byte_seek_test; +#[cfg(test)] +mod full_load_test; +#[cfg(test)] +mod line_index_test; +#[cfg(test)] +mod session_test; + +pub use session::{ + SearchPollResult, ViewerOpenResult, close_session, get_lines, open_session, search_cancel, search_poll, + search_start, +}; + +use serde::Serialize; + +/// Maximum file size for FullLoadBackend (1 MB). +const FULL_LOAD_THRESHOLD: u64 = 1024 * 1024; + +/// Interval between line index checkpoints (every 256 lines). +const INDEX_CHECKPOINT_INTERVAL: usize = 256; + +/// Maximum bytes to scan backward when seeking by byte offset. +const MAX_BACKWARD_SCAN: usize = 8192; + +/// Where to seek in the file. +#[derive(Debug, Clone)] +pub enum SeekTarget { + /// Jump to a specific line number (0-based). + Line(usize), + /// Jump to a byte offset and find the surrounding line. + ByteOffset(u64), + /// Jump to a fraction of the file (0.0 = start, 1.0 = end). + Fraction(f64), +} + +/// A chunk of lines returned by a backend. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LineChunk { + /// The lines of text. + pub lines: Vec, + /// The 0-based line number of the first line in this chunk. + pub first_line_number: usize, + /// Byte offset of the first line in the file. + pub byte_offset: u64, + /// Total number of lines in the file (known after full scan or full load). + pub total_lines: Option, + /// Total file size in bytes. + pub total_bytes: u64, +} + +/// A search match found by a backend. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchMatch { + /// 0-based line number. + pub line: usize, + /// Column (byte offset within the line) where the match starts. + pub column: usize, + /// Length of the match in bytes. + pub length: usize, +} + +/// What a backend can do. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BackendCapabilities { + pub supports_line_seek: bool, + pub supports_byte_seek: bool, + pub supports_fraction_seek: bool, + pub knows_total_lines: bool, +} + +/// Errors from the viewer backends. +#[derive(Debug, Clone)] +pub enum ViewerError { + Io(String), + NotFound(String), + IsDirectory, + SessionNotFound(String), +} + +impl std::fmt::Display for ViewerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(msg) => write!(f, "{}", msg), + Self::NotFound(path) => write!(f, "File not found: {}", path), + Self::IsDirectory => write!(f, "Cannot view a directory"), + Self::SessionNotFound(id) => write!(f, "Viewer session not found: {}", id), + } + } +} + +impl From for ViewerError { + fn from(e: std::io::Error) -> Self { + Self::Io(e.to_string()) + } +} + +/// The interface all viewer backends implement. +pub trait FileViewerBackend: Send + Sync { + /// Fetch a range of lines starting from the given target. + fn get_lines(&self, target: &SeekTarget, count: usize) -> Result; + + /// Search for a query string, populating matches into the provided vec. + /// Checks the cancel flag periodically and stops early if set. + /// Returns the total number of bytes scanned (for progress reporting). + fn search( + &self, + query: &str, + cancel: &std::sync::atomic::AtomicBool, + matches: &std::sync::Mutex>, + ) -> Result; + + /// What this backend can do. + fn capabilities(&self) -> BackendCapabilities; + + /// Total file size in bytes. + fn total_bytes(&self) -> u64; + + /// Total lines if known (only FullLoad and completed LineIndex know this). + fn total_lines(&self) -> Option; + + /// File name (last path component). + fn file_name(&self) -> &str; +} diff --git a/apps/desktop/src-tauri/src/file_viewer/session.rs b/apps/desktop/src-tauri/src/file_viewer/session.rs new file mode 100644 index 00000000..f1420f35 --- /dev/null +++ b/apps/desktop/src-tauri/src/file_viewer/session.rs @@ -0,0 +1,324 @@ +//! ViewerSession — orchestrates file viewer backends and manages session lifecycle. +//! +//! Opens a file, picks the right backend based on file size, and provides a session-based +//! API for the frontend. Sessions are cached by ID and cleaned up on close. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, LazyLock, Mutex}; +use std::thread; + +use serde::Serialize; + +use super::byte_seek::ByteSeekBackend; +use super::full_load::FullLoadBackend; +use super::line_index::LineIndexBackend; +use super::{ + BackendCapabilities, FULL_LOAD_THRESHOLD, FileViewerBackend, LineChunk, SearchMatch, SeekTarget, ViewerError, +}; + +/// Which backend strategy is active for a session. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum BackendType { + FullLoad, + ByteSeek, + LineIndex, +} + +/// Result returned when opening a viewer session. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ViewerOpenResult { + pub session_id: String, + pub file_name: String, + pub total_bytes: u64, + pub total_lines: Option, + pub backend_type: BackendType, + pub capabilities: BackendCapabilities, + /// Initial chunk of lines from the start of the file. + pub initial_lines: LineChunk, +} + +/// Status of an ongoing search. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SearchStatus { + /// Search is running. `bytes_scanned` shows progress. + Running, + /// Search completed. + Done, + /// Search was cancelled. + Cancelled, + /// No search is active. + Idle, +} + +/// Result from polling search progress. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchPollResult { + pub status: SearchStatus, + pub matches: Vec, + pub total_bytes: u64, + pub bytes_scanned: u64, +} + +/// Internal state for an active search. +struct SearchState { + cancel: Arc, + matches: Arc>>, + bytes_scanned: Arc>, + status: Arc>, +} + +/// A viewer session wraps a backend and tracks search state. +struct ViewerSession { + backend: Box, + backend_type: BackendType, + search: Option, + /// Set when upgrading from ByteSeek to LineIndex in the background. + upgrading: Option>, + path: PathBuf, +} + +/// Global session cache. +static SESSIONS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Number of initial lines to return on open. +const INITIAL_LINE_COUNT: usize = 200; + +/// Generates a unique session ID. +fn generate_session_id() -> String { + uuid::Uuid::new_v4().to_string() +} + +/// Expands tilde (~) to the user's home directory. +fn expand_tilde(path: &str) -> String { + if (path.starts_with("~/") || path == "~") + && let Some(home) = dirs::home_dir() + { + return path.replacen("~", &home.to_string_lossy(), 1); + } + path.to_string() +} + +/// Opens a viewer session for the given file path. +/// Picks the backend based on file size: +/// - Under 1 MB: FullLoad (instant, full random access) +/// - Over 1 MB: ByteSeek first (instant open), then upgrades to LineIndex in background +pub fn open_session(path: &str) -> Result { + let expanded = expand_tilde(path); + let file_path = PathBuf::from(&expanded); + + if !file_path.exists() { + return Err(ViewerError::NotFound(path.to_string())); + } + if file_path.is_dir() { + return Err(ViewerError::IsDirectory); + } + + let metadata = std::fs::metadata(&file_path)?; + let file_size = metadata.len(); + + let (backend, backend_type, upgrading): (Box, BackendType, Option>) = + if file_size <= FULL_LOAD_THRESHOLD { + let b = FullLoadBackend::open(&file_path)?; + (Box::new(b), BackendType::FullLoad, None) + } else { + // Start with ByteSeek (instant), then upgrade to LineIndex in background + let b = ByteSeekBackend::open(&file_path)?; + let cancel = Arc::new(AtomicBool::new(false)); + (Box::new(b), BackendType::ByteSeek, Some(cancel)) + }; + + // Get initial lines + let initial_lines = backend.get_lines(&SeekTarget::Line(0), INITIAL_LINE_COUNT)?; + let capabilities = backend.capabilities(); + let total_bytes = backend.total_bytes(); + let total_lines = backend.total_lines(); + let file_name = backend.file_name().to_string(); + + let session_id = generate_session_id(); + + // If we're using ByteSeek, start background upgrade to LineIndex + let upgrade_cancel = upgrading.clone(); + if upgrade_cancel.is_some() { + let session_id_clone = session_id.clone(); + let path_clone = file_path.clone(); + let cancel_clone = upgrade_cancel.clone().unwrap(); + + thread::spawn(move || { + let cancel_flag = &cancel_clone; + match LineIndexBackend::open(&path_clone, cancel_flag) { + Ok(new_backend) => { + if !cancel_flag.load(Ordering::Relaxed) { + let mut sessions = SESSIONS.lock().unwrap(); + if let Some(session) = sessions.get_mut(&session_id_clone) { + session.backend = Box::new(new_backend); + session.backend_type = BackendType::LineIndex; + session.upgrading = None; + } + } + } + Err(_) => { + // If upgrade fails, keep using ByteSeek — it still works fine + } + } + }); + } + + let session = ViewerSession { + backend, + backend_type: backend_type.clone(), + search: None, + upgrading: upgrade_cancel, + path: file_path, + }; + + let result = ViewerOpenResult { + session_id: session_id.clone(), + file_name, + total_bytes, + total_lines, + backend_type, + capabilities, + initial_lines, + }; + + SESSIONS.lock().unwrap().insert(session_id, session); + + Ok(result) +} + +/// Gets a range of lines from a session. +pub fn get_lines(session_id: &str, target: SeekTarget, count: usize) -> Result { + let sessions = SESSIONS.lock().unwrap(); + let session = sessions + .get(session_id) + .ok_or(ViewerError::SessionNotFound(session_id.to_string()))?; + session.backend.get_lines(&target, count) +} + +/// Starts a background search in the given session. +/// Any previous search is cancelled first. +pub fn search_start(session_id: &str, query: String) -> Result<(), ViewerError> { + // First, cancel any existing search + search_cancel(session_id)?; + + let cancel = Arc::new(AtomicBool::new(false)); + let matches: Arc>> = Arc::new(Mutex::new(Vec::new())); + let bytes_scanned: Arc> = Arc::new(Mutex::new(0)); + let status: Arc> = Arc::new(Mutex::new(SearchStatus::Running)); + + let search_state = SearchState { + cancel: cancel.clone(), + matches: matches.clone(), + bytes_scanned: bytes_scanned.clone(), + status: status.clone(), + }; + + // Get the file path from the session to open a fresh file handle in the search thread + let path = { + let mut sessions = SESSIONS.lock().unwrap(); + let session = sessions + .get_mut(session_id) + .ok_or(ViewerError::SessionNotFound(session_id.to_string()))?; + session.search = Some(search_state); + session.path.clone() + }; + + // Spawn search thread — creates its own backend for searching + let cancel_clone = cancel.clone(); + let matches_clone = matches; + let bytes_scanned_clone = bytes_scanned; + let status_clone = status; + + thread::spawn(move || { + // Use ByteSeekBackend for streaming search (low memory, works on any file) + let backend = match ByteSeekBackend::open(&path) { + Ok(b) => b, + Err(_) => { + *status_clone.lock().unwrap() = SearchStatus::Done; + return; + } + }; + + let result = backend.search(&query, &cancel_clone, &matches_clone); + let final_scanned: u64 = result.unwrap_or_default(); + + *bytes_scanned_clone.lock().unwrap() = final_scanned; + + let final_status = if cancel_clone.load(Ordering::Relaxed) { + SearchStatus::Cancelled + } else { + SearchStatus::Done + }; + *status_clone.lock().unwrap() = final_status; + }); + + Ok(()) +} + +/// Polls search progress for a session. +pub fn search_poll(session_id: &str) -> Result { + let sessions = SESSIONS.lock().unwrap(); + let session = sessions + .get(session_id) + .ok_or(ViewerError::SessionNotFound(session_id.to_string()))?; + + let total_bytes = session.backend.total_bytes(); + + match &session.search { + None => Ok(SearchPollResult { + status: SearchStatus::Idle, + matches: Vec::new(), + total_bytes, + bytes_scanned: 0, + }), + Some(search) => { + let status = search.status.lock().unwrap().clone(); + let matches = search.matches.lock().unwrap().clone(); + let bytes_scanned = *search.bytes_scanned.lock().unwrap(); + + Ok(SearchPollResult { + status, + matches, + total_bytes, + bytes_scanned, + }) + } + } +} + +/// Cancels an ongoing search. +pub fn search_cancel(session_id: &str) -> Result<(), ViewerError> { + let mut sessions = SESSIONS.lock().unwrap(); + let session = sessions + .get_mut(session_id) + .ok_or(ViewerError::SessionNotFound(session_id.to_string()))?; + + if let Some(search) = &session.search { + search.cancel.store(true, Ordering::Relaxed); + } + session.search = None; + + Ok(()) +} + +/// Closes a viewer session and frees resources. +pub fn close_session(session_id: &str) -> Result<(), ViewerError> { + let mut sessions = SESSIONS.lock().unwrap(); + if let Some(session) = sessions.remove(session_id) { + // Cancel any ongoing search + if let Some(search) = &session.search { + search.cancel.store(true, Ordering::Relaxed); + } + // Cancel any ongoing upgrade + if let Some(upgrade_cancel) = &session.upgrading { + upgrade_cancel.store(true, Ordering::Relaxed); + } + } + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/file_viewer/session_test.rs b/apps/desktop/src-tauri/src/file_viewer/session_test.rs new file mode 100644 index 00000000..0e0c1aea --- /dev/null +++ b/apps/desktop/src-tauri/src/file_viewer/session_test.rs @@ -0,0 +1,254 @@ +//! Tests for ViewerSession orchestrator. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::Duration; + +use super::FULL_LOAD_THRESHOLD; +use super::session::{self, SearchStatus}; + +fn create_test_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("cmdr_viewer_session_{}", name)); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("Failed to create test directory"); + dir +} + +fn cleanup(path: &Path) { + let _ = fs::remove_dir_all(path); +} + +fn write_test_file(dir: &Path, name: &str, content: &str) -> PathBuf { + let file = dir.join(name); + fs::write(&file, content).unwrap(); + file +} + +#[test] +fn open_small_file_uses_full_load() { + let dir = create_test_dir("small"); + let file = write_test_file(&dir, "test.txt", "hello\nworld\n"); + + let result = session::open_session(file.to_str().unwrap()).unwrap(); + assert_eq!(result.file_name, "test.txt"); + assert_eq!(result.total_bytes, 12); + assert_eq!(result.total_lines, Some(3)); + assert!(matches!(result.backend_type, session::BackendType::FullLoad)); + assert!(result.capabilities.supports_line_seek); + assert!(result.capabilities.knows_total_lines); + + // Initial lines should be populated + assert!(!result.initial_lines.lines.is_empty()); + assert_eq!(result.initial_lines.first_line_number, 0); + + // Cleanup session + session::close_session(&result.session_id).unwrap(); + cleanup(&dir); +} + +#[test] +fn open_large_file_uses_byte_seek() { + let dir = create_test_dir("large"); + // Create a file larger than FULL_LOAD_THRESHOLD + let line = "x".repeat(100) + "\n"; + let line_count = (FULL_LOAD_THRESHOLD as usize / line.len()) + 100; + let content: String = line.repeat(line_count); + let file = write_test_file(&dir, "big.txt", &content); + + let result = session::open_session(file.to_str().unwrap()).unwrap(); + assert!(result.total_bytes > FULL_LOAD_THRESHOLD); + assert!(matches!(result.backend_type, session::BackendType::ByteSeek)); + assert!(!result.capabilities.supports_line_seek); + assert!(!result.capabilities.knows_total_lines); + + // Should still have initial lines + assert!(!result.initial_lines.lines.is_empty()); + + session::close_session(&result.session_id).unwrap(); + cleanup(&dir); +} + +#[test] +fn open_not_found() { + let result = session::open_session("/nonexistent_session_test.txt"); + assert!(result.is_err()); +} + +#[test] +fn open_directory_fails() { + let dir = create_test_dir("dir_fail"); + let result = session::open_session(dir.to_str().unwrap()); + assert!(result.is_err()); + cleanup(&dir); +} + +#[test] +fn get_lines_after_open() { + let dir = create_test_dir("get_lines"); + let file = write_test_file(&dir, "test.txt", "a\nb\nc\nd\ne\n"); + + let open_result = session::open_session(file.to_str().unwrap()).unwrap(); + + let chunk = session::get_lines(&open_result.session_id, super::SeekTarget::Line(2), 3).unwrap(); + assert_eq!(chunk.first_line_number, 2); + assert_eq!(chunk.lines, vec!["c", "d", "e"]); + + session::close_session(&open_result.session_id).unwrap(); + cleanup(&dir); +} + +#[test] +fn get_lines_invalid_session() { + let result = session::get_lines("nonexistent-session-id", super::SeekTarget::Line(0), 10); + assert!(result.is_err()); +} + +#[test] +fn close_session_cleans_up() { + let dir = create_test_dir("close"); + let file = write_test_file(&dir, "test.txt", "test\n"); + + let open_result = session::open_session(file.to_str().unwrap()).unwrap(); + let sid = open_result.session_id.clone(); + + // Session should work + assert!(session::get_lines(&sid, super::SeekTarget::Line(0), 1).is_ok()); + + // Close it + session::close_session(&sid).unwrap(); + + // Now it should fail + assert!(session::get_lines(&sid, super::SeekTarget::Line(0), 1).is_err()); + + cleanup(&dir); +} + +#[test] +fn search_start_and_poll() { + let dir = create_test_dir("search"); + let file = write_test_file(&dir, "test.txt", "hello world\nfoo bar\nhello again\n"); + + let open_result = session::open_session(file.to_str().unwrap()).unwrap(); + let sid = &open_result.session_id; + + // Start search + session::search_start(sid, "hello".to_string()).unwrap(); + + // Poll until done (with timeout) + let mut done = false; + for _ in 0..100 { + let poll = session::search_poll(sid).unwrap(); + if matches!(poll.status, SearchStatus::Done) { + assert_eq!(poll.matches.len(), 2); + done = true; + break; + } + thread::sleep(Duration::from_millis(10)); + } + assert!(done, "Search did not complete in time"); + + session::close_session(sid).unwrap(); + cleanup(&dir); +} + +#[test] +fn search_cancel_works() { + let dir = create_test_dir("search_cancel"); + let content = "hello world\n".repeat(100000); + let file = write_test_file(&dir, "test.txt", &content); + + let open_result = session::open_session(file.to_str().unwrap()).unwrap(); + let sid = &open_result.session_id; + + session::search_start(sid, "hello".to_string()).unwrap(); + + // Cancel immediately + session::search_cancel(sid).unwrap(); + + // Poll should show cancelled or idle (since we removed the search state) + let poll = session::search_poll(sid).unwrap(); + assert!(matches!(poll.status, SearchStatus::Idle)); + + session::close_session(sid).unwrap(); + cleanup(&dir); +} + +#[test] +fn search_poll_no_active_search() { + let dir = create_test_dir("poll_idle"); + let file = write_test_file(&dir, "test.txt", "test\n"); + + let open_result = session::open_session(file.to_str().unwrap()).unwrap(); + let sid = &open_result.session_id; + + let poll = session::search_poll(sid).unwrap(); + assert!(matches!(poll.status, SearchStatus::Idle)); + + session::close_session(sid).unwrap(); + cleanup(&dir); +} + +#[test] +fn tilde_expansion() { + // open_session should handle ~ paths + let result = session::open_session("~/nonexistent_file_tilde_test.txt"); + // Should get NotFound rather than a panic/crash + assert!(result.is_err()); +} + +#[test] +fn large_file_upgrades_to_line_index() { + let dir = create_test_dir("upgrade"); + // Create a file larger than FULL_LOAD_THRESHOLD (1 MB) with known lines. + // Each line is ~115 bytes ("line 00000000 " + 100 x's + "\n"). + let padding = "x".repeat(100); + let line_count = (FULL_LOAD_THRESHOLD as usize / 100) + 200; + let content: String = (0..line_count) + .map(|i| format!("line {:08} {}\n", i, padding)) + .collect(); + let file = write_test_file(&dir, "upgrade.txt", &content); + + let open_result = session::open_session(file.to_str().unwrap()).unwrap(); + let sid = &open_result.session_id; + + // Initially ByteSeek + assert!(matches!(open_result.backend_type, session::BackendType::ByteSeek)); + + // Wait for upgrade to complete (should be fast for a ~1MB file) + thread::sleep(Duration::from_millis(500)); + + // After upgrade, get_lines with Line target should work correctly + let chunk = session::get_lines(sid, super::SeekTarget::Line(10), 3).unwrap(); + // If upgraded to LineIndex, first_line_number should be correct + // If still ByteSeek, it will default to 0 + // We check that it works either way + assert!(!chunk.lines.is_empty()); + + session::close_session(sid).unwrap(); + cleanup(&dir); +} + +#[test] +fn multiple_sessions() { + let dir = create_test_dir("multi"); + let file1 = write_test_file(&dir, "a.txt", "file a\n"); + let file2 = write_test_file(&dir, "b.txt", "file b\n"); + + let res1 = session::open_session(file1.to_str().unwrap()).unwrap(); + let res2 = session::open_session(file2.to_str().unwrap()).unwrap(); + + assert_ne!(res1.session_id, res2.session_id); + assert_eq!(res1.file_name, "a.txt"); + assert_eq!(res2.file_name, "b.txt"); + + // Both should work independently + let chunk1 = session::get_lines(&res1.session_id, super::SeekTarget::Line(0), 1).unwrap(); + let chunk2 = session::get_lines(&res2.session_id, super::SeekTarget::Line(0), 1).unwrap(); + assert_eq!(chunk1.lines[0], "file a"); + assert_eq!(chunk2.lines[0], "file b"); + + session::close_session(&res1.session_id).unwrap(); + session::close_session(&res2.session_id).unwrap(); + cleanup(&dir); +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index c473f8d9..3f5325d9 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -45,6 +45,7 @@ pub mod benchmark; mod commands; pub mod config; mod file_system; +pub(crate) mod file_viewer; mod font_metrics; pub mod icons; pub mod licensing; @@ -280,7 +281,12 @@ pub fn run() { commands::file_system::get_operation_status, commands::file_system::get_listing_stats, commands::file_system::start_selection_drag, - commands::file_system::read_file_content, + commands::file_viewer::viewer_open, + commands::file_viewer::viewer_get_lines, + commands::file_viewer::viewer_search_start, + commands::file_viewer::viewer_search_poll, + commands::file_viewer::viewer_search_cancel, + commands::file_viewer::viewer_close, commands::font_metrics::store_font_metrics, commands::font_metrics::has_font_metrics, commands::icons::get_icons, diff --git a/apps/desktop/src/lib/file-viewer/viewer-search.test.ts b/apps/desktop/src/lib/file-viewer/viewer-search.test.ts deleted file mode 100644 index 9dd26c93..00000000 --- a/apps/desktop/src/lib/file-viewer/viewer-search.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { findMatches, nextMatchIndex, prevMatchIndex } from './viewer-search' - -describe('findMatches', () => { - it('returns empty array for empty query', () => { - expect(findMatches(['hello world'], '')).toEqual([]) - }) - - it('finds a single match on a single line', () => { - const matches = findMatches(['hello world'], 'world') - expect(matches).toEqual([{ line: 0, start: 6, length: 5 }]) - }) - - it('finds multiple matches on the same line', () => { - const matches = findMatches(['foo bar foo baz foo'], 'foo') - expect(matches).toHaveLength(3) - expect(matches[0]).toEqual({ line: 0, start: 0, length: 3 }) - expect(matches[1]).toEqual({ line: 0, start: 8, length: 3 }) - expect(matches[2]).toEqual({ line: 0, start: 16, length: 3 }) - }) - - it('finds matches across multiple lines', () => { - const matches = findMatches(['first line', 'second test', 'third line'], 'line') - expect(matches).toHaveLength(2) - expect(matches[0]).toEqual({ line: 0, start: 6, length: 4 }) - expect(matches[1]).toEqual({ line: 2, start: 6, length: 4 }) - }) - - it('is case-insensitive', () => { - const matches = findMatches(['Hello WORLD'], 'hello') - expect(matches).toHaveLength(1) - expect(matches[0]).toEqual({ line: 0, start: 0, length: 5 }) - }) - - it('returns empty for no matches', () => { - expect(findMatches(['hello world'], 'xyz')).toEqual([]) - }) - - it('handles empty lines', () => { - const matches = findMatches(['', 'hello', ''], 'hello') - expect(matches).toEqual([{ line: 1, start: 0, length: 5 }]) - }) - - it('handles overlapping potential matches (non-overlapping search)', () => { - // "aaa" in "aaaa" should find at 0 and 1 (overlapping start positions) - const matches = findMatches(['aaaa'], 'aaa') - expect(matches).toHaveLength(2) - expect(matches[0]).toEqual({ line: 0, start: 0, length: 3 }) - expect(matches[1]).toEqual({ line: 0, start: 1, length: 3 }) - }) - - it('handles special regex characters in query', () => { - const matches = findMatches(['price is $10.00'], '$10') - expect(matches).toEqual([{ line: 0, start: 9, length: 3 }]) - }) -}) - -describe('nextMatchIndex', () => { - it('returns -1 for zero total', () => { - expect(nextMatchIndex(0, 0)).toBe(-1) - }) - - it('advances to the next index', () => { - expect(nextMatchIndex(0, 5)).toBe(1) - expect(nextMatchIndex(3, 5)).toBe(4) - }) - - it('wraps around at the end', () => { - expect(nextMatchIndex(4, 5)).toBe(0) - }) -}) - -describe('prevMatchIndex', () => { - it('returns -1 for zero total', () => { - expect(prevMatchIndex(0, 0)).toBe(-1) - }) - - it('moves to the previous index', () => { - expect(prevMatchIndex(3, 5)).toBe(2) - expect(prevMatchIndex(1, 5)).toBe(0) - }) - - it('wraps around at the beginning', () => { - expect(prevMatchIndex(0, 5)).toBe(4) - }) -}) diff --git a/apps/desktop/src/lib/file-viewer/viewer-search.ts b/apps/desktop/src/lib/file-viewer/viewer-search.ts deleted file mode 100644 index 7316e655..00000000 --- a/apps/desktop/src/lib/file-viewer/viewer-search.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** A single search match location. */ -export interface SearchMatch { - /** 0-based line index */ - line: number - /** 0-based character offset within the line */ - start: number - /** Length of the match in characters */ - length: number -} - -/** Finds all occurrences of a query in the given lines (case-insensitive). */ -export function findMatches(lines: string[], query: string): SearchMatch[] { - if (!query) return [] - const lowerQuery = query.toLowerCase() - const matches: SearchMatch[] = [] - for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { - const lowerLine = lines[lineIdx].toLowerCase() - let offset = 0 - while (offset < lowerLine.length) { - const idx = lowerLine.indexOf(lowerQuery, offset) - if (idx === -1) break - matches.push({ line: lineIdx, start: idx, length: query.length }) - offset = idx + 1 - } - } - return matches -} - -/** Navigates to the next match index, wrapping around. */ -export function nextMatchIndex(current: number, total: number): number { - if (total === 0) return -1 - return (current + 1) % total -} - -/** Navigates to the previous match index, wrapping around. */ -export function prevMatchIndex(current: number, total: number): number { - if (total === 0) return -1 - return (current - 1 + total) % total -} diff --git a/apps/desktop/src/lib/tauri-commands.ts b/apps/desktop/src/lib/tauri-commands.ts index 416a2812..6bcbcac3 100644 --- a/apps/desktop/src/lib/tauri-commands.ts +++ b/apps/desktop/src/lib/tauri-commands.ts @@ -287,17 +287,82 @@ export async function createDirectory(parentPath: string, name: string): Promise // File viewer // ============================================================================ -/** Result of reading a file's content for the viewer. */ -export interface FileContentResult { - content: string - lineCount: number - size: number +/** A chunk of lines returned by the viewer backend. */ +export interface LineChunk { + lines: string[] + firstLineNumber: number + byteOffset: number + totalLines: number | null + totalBytes: number +} + +/** Backend capabilities. */ +export interface BackendCapabilities { + supportsLineSeek: boolean + supportsByteSeek: boolean + supportsFractionSeek: boolean + knowsTotalLines: boolean +} + +/** Result from opening a viewer session. */ +export interface ViewerOpenResult { + sessionId: string fileName: string + totalBytes: number + totalLines: number | null + backendType: 'fullLoad' | 'byteSeek' | 'lineIndex' + capabilities: BackendCapabilities + initialLines: LineChunk +} + +/** A search match found in the file. */ +export interface ViewerSearchMatch { + line: number + column: number + length: number +} + +/** Result from polling search progress. */ +export interface SearchPollResult { + status: 'running' | 'done' | 'cancelled' | 'idle' + matches: ViewerSearchMatch[] + totalBytes: number + bytesScanned: number +} + +/** Opens a viewer session for a file. Returns session metadata + initial lines. */ +export async function viewerOpen(path: string): Promise { + return invoke('viewer_open', { path }) +} + +/** Fetches lines from a viewer session. */ +export async function viewerGetLines( + sessionId: string, + targetType: 'line' | 'byte' | 'fraction', + targetValue: number, + count: number, +): Promise { + return invoke('viewer_get_lines', { sessionId, targetType, targetValue, count }) +} + +/** Starts a background search in the viewer session. */ +export async function viewerSearchStart(sessionId: string, query: string): Promise { + return invoke('viewer_search_start', { sessionId, query }) +} + +/** Polls search progress and matches. */ +export async function viewerSearchPoll(sessionId: string): Promise { + return invoke('viewer_search_poll', { sessionId }) +} + +/** Cancels an ongoing search. */ +export async function viewerSearchCancel(sessionId: string): Promise { + return invoke('viewer_search_cancel', { sessionId }) } -/** Reads a file's text content for the file viewer. Binary bytes are lossy-decoded. */ -export async function readFileContent(path: string): Promise { - return invoke('read_file_content', { path }) +/** Closes a viewer session and frees resources. */ +export async function viewerClose(sessionId: string): Promise { + return invoke('viewer_close', { sessionId }) } /** diff --git a/apps/desktop/src/routes/viewer/+page.svelte b/apps/desktop/src/routes/viewer/+page.svelte index f11270ea..0454a6f1 100644 --- a/apps/desktop/src/routes/viewer/+page.svelte +++ b/apps/desktop/src/routes/viewer/+page.svelte @@ -5,57 +5,178 @@ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB` return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB` } + + const LINE_HEIGHT = 18 + const BUFFER_LINES = 50 + const FETCH_BATCH = 500 + const SEARCH_POLL_INTERVAL = 100 -
+
{#if searchVisible}