From ef699c088e014a3f8fad9ff9874d0b1f1f9b6c5e Mon Sep 17 00:00:00 2001 From: tacogips Date: Sat, 21 Mar 2026 21:13:40 +0900 Subject: [PATCH] fix: make file browser selection stateless and add browser regression coverage 1. Primary Changes and Intent: Refactor the file browser so the frontend owns row selection while the Tauri backend returns stateless, sorted, paginated directory snapshots; add browser-level regression coverage for h/j/k/l navigation and the file-browser harness needed to reproduce focus behavior outside Tauri. 2. Key Technical Concepts: - Stateless Tauri directory listing contracts - Frontend-owned selected path and absolute selected index - Server-side sorting with offset/limit paging - Browser keyboard/focus regression testing with Playwright - Lazy app entry loading to avoid importing Tauri-only modules in browser harness mode 3. Files and Code Sections: - src-tauri/src/viewer/service.rs, src-tauri/src/viewer/types.rs, src-tauri/src/commands/document.rs, src/lib/tauri/document.ts: remove backend-owned selection fields from the directory API and keep list responses page-oriented - src/features/workspace/WorkspaceShell.tsx, src/features/file-view/FileBrowserPane.tsx: move selection ownership to the frontend and update keyboard/focus handling around list navigation - src/features/file-view/FileBrowserHarness.tsx, e2e/file-browser-keyboard.pw.ts, playwright.config.ts, src/app/App.tsx: add a browser harness route and end-to-end coverage for file-tree keyboard behavior on this Nix environment - src/features/file-view/sort.ts, src/features/file-view/sort.test.ts, src/features/preview/PreviewPane.tsx, src/app/App.css, README.md, flake.nix, package.json, bun.lock, src-tauri/Cargo.toml, impl-plans/active/file-viewer-mode.md: supporting updates for sorting, preview behavior, tooling, dependencies, and plan/docs 4. Problem Solving: Remove the dual source of truth between frontend and backend selection state, make the browser harness mount without importing Tauri window APIs, and make Playwright use the system Chromium plus a Bun/Vite startup configuration that works in this repository's Nix setup. 5. Impact: The file tree API is simpler, the frontend can reason about selection without backend echo state, and the repository now has repeatable browser coverage for keyboard navigation and focus behavior. 6. Unresolved TODOs: - [ ] None --- README.md | 14 +- bun.lock | 9 + e2e/file-browser-keyboard.pw.ts | 69 ++++ flake.nix | 14 +- impl-plans/active/file-viewer-mode.md | 16 + package.json | 3 +- playwright.config.ts | 40 +++ src-tauri/Cargo.toml | 2 +- src-tauri/src/commands/document.rs | 15 +- src-tauri/src/viewer/service.rs | 311 +++++++++++------- src-tauri/src/viewer/types.rs | 30 +- src/app/App.css | 31 +- src/app/App.tsx | 22 +- src/features/file-view/FileBrowserHarness.tsx | 172 ++++++++++ .../file-view/FileBrowserPane.test.ts | 59 ++++ src/features/file-view/FileBrowserPane.tsx | 221 +++++++++---- src/features/file-view/sort.test.ts | 96 +----- src/features/file-view/sort.ts | 111 +------ src/features/preview/PreviewPane.tsx | 12 +- src/features/workspace/WorkspaceShell.tsx | 278 +++++++++++++++- src/lib/tauri/document.ts | 26 +- 21 files changed, 1127 insertions(+), 424 deletions(-) create mode 100644 e2e/file-browser-keyboard.pw.ts create mode 100644 playwright.config.ts create mode 100644 src/features/file-view/FileBrowserHarness.tsx create mode 100644 src/features/file-view/FileBrowserPane.test.ts diff --git a/README.md b/README.md index 7cee32c..4596c00 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Global shortcuts: - `J` or `ArrowDown`: scroll the active file view down one line when the file tree is hidden - `K` or `ArrowUp`: scroll the active file view up one line when the file tree is hidden - `Shift+L`: toggle file tree -- `Y`: copy the current file's absolute path +- `Y`: copy the current file's absolute path when the file tree is hidden - `R`: reload the current file - `Shift+T`: toggle table of contents for Markdown - `Shift+P`: switch Markdown raw/preview pane @@ -69,6 +69,7 @@ File tree shortcuts: - `S`: sort by size descending - `H` or `ArrowLeft`: go to parent directory - `L`, `ArrowRight`, `Enter`: open or confirm selection +- `Y`: copy the selected file or directory absolute path, or the current directory path when no row is selected - `Ctrl+M`: same as `Enter` in the filter field Video preview: @@ -129,6 +130,17 @@ The repository is set up for `nix develop`, which provides Bun, Cargo, Tauri-rel nix develop ``` +### Linux GDK / GTK environment constraints + +On Linux, `chilla` depends on the GTK/WebKitGTK runtime prepared by `flake.nix`. In practice, that means: + +- Prefer running the app from `nix develop`, `task dev`, or `nix run .` so the wrapped GTK/GIO/GStreamer environment is applied. +- Do not mix host GTK-related overrides into the dev shell. In particular, `GTK_PATH`, `GI_TYPELIB_PATH`, `GTK_IM_MODULE`, `QT_IM_MODULE`, and `XMODIFIERS` are intentionally unset to avoid loading incompatible host modules. +- The Linux WebKitGTK renderer is currently run with `WEBKIT_DISABLE_DMABUF_RENDERER=1` because DMA-BUF rendering is not treated as stable for this app's environment. +- If Linux media playback behaves differently from macOS, assume the Linux path is constrained by the WebKitGTK + GStreamer stack rather than the frontend alone. + +If you launch Tauri commands outside the Nix shell, you are responsible for providing a compatible GTK/WebKitGTK/GStreamer runtime yourself. + ### Common commands ```bash diff --git a/bun.lock b/bun.lock index e7656d2..f3f67d7 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "solid-js": "^1.9.3", }, "devDependencies": { + "@playwright/test": "^1.58.2", "@tauri-apps/cli": "^2", "@types/bun": "^1.3.3", "typescript": "~5.6.2", @@ -138,6 +139,8 @@ "@mermaid-js/parser": ["@mermaid-js/parser@1.0.1", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ=="], + "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="], @@ -476,6 +479,10 @@ "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], + + "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], @@ -552,6 +559,8 @@ "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], diff --git a/e2e/file-browser-keyboard.pw.ts b/e2e/file-browser-keyboard.pw.ts new file mode 100644 index 0000000..709fedc --- /dev/null +++ b/e2e/file-browser-keyboard.pw.ts @@ -0,0 +1,69 @@ +import { expect, test } from "@playwright/test"; + +const harnessPath = "/?harness=file-browser"; + +test.describe("file browser keyboard navigation", () => { + test("moves focus and selection with j/k and confirms with l", async ({ page }) => { + await page.goto(harnessPath); + + await page.waitForSelector('[data-testid="selected-path"]'); + await expect(page.getByTestId("selected-path")).toHaveText( + "/workspace/bravo.md", + ); + await expect(page.getByTestId("selected-index")).toHaveText("2"); + await expect(page.locator(".file-browser__button:focus")).toHaveAttribute( + "data-path", + "/workspace/bravo.md", + ); + + await page.keyboard.press("j"); + await expect(page.getByTestId("selected-path")).toHaveText( + "/workspace/delta.md", + ); + await expect(page.locator(".file-browser__button:focus")).toHaveAttribute( + "data-path", + "/workspace/delta.md", + ); + + await page.keyboard.press("j"); + await expect(page.getByTestId("selected-path")).toHaveText( + "/workspace/echo.txt", + ); + await expect(page.locator(".file-browser__button:focus")).toHaveAttribute( + "data-path", + "/workspace/echo.txt", + ); + + await page.keyboard.press("k"); + await expect(page.getByTestId("selected-path")).toHaveText( + "/workspace/delta.md", + ); + await expect(page.locator(".file-browser__button:focus")).toHaveAttribute( + "data-path", + "/workspace/delta.md", + ); + + await page.keyboard.press("l"); + await expect(page.getByTestId("confirmed-path")).toHaveText( + "/workspace/delta.md", + ); + }); + + test("h triggers parent navigation while preserving list keyboard handling", async ({ + page, + }) => { + await page.goto(harnessPath); + await expect(page.locator(".file-browser__button:focus")).toHaveAttribute( + "data-path", + "/workspace/bravo.md", + ); + + await page.keyboard.press("h"); + await expect(page.getByTestId("parent-count")).toHaveText("1"); + + await page.keyboard.press("j"); + await expect(page.getByTestId("selected-path")).toHaveText( + "/workspace/delta.md", + ); + }); +}); diff --git a/flake.nix b/flake.nix index dfa9fbd..4b6e44a 100644 --- a/flake.nix +++ b/flake.nix @@ -200,11 +200,23 @@ postFixup = lib.optionalString pkgs.stdenv.isLinux '' wrapProgram $out/bin/chilla \ --prefix LD_LIBRARY_PATH : "${linuxRuntimeLibraryPath}" \ + --set GIO_EXTRA_MODULES "${linuxGioModulePath}" \ + --set XDG_DATA_DIRS "${linuxXdgDataDirs}" \ + --set WEBKIT_DISABLE_DMABUF_RENDERER 1 \ --set GST_PLUGIN_SCANNER "${linuxGStreamerPluginScanner}" \ --set GST_PLUGIN_SYSTEM_PATH "${linuxGStreamerPluginPath}" \ --prefix GST_PLUGIN_SYSTEM_PATH_1_0 : "${linuxGStreamerPluginPath}" \ --set GST_PLUGIN_PATH "${linuxGStreamerPluginPath}" \ - --set GST_PLUGIN_PATH_1_0 "${linuxGStreamerPluginPath}" + --set GST_PLUGIN_PATH_1_0 "${linuxGStreamerPluginPath}" \ + --unset GTK_PATH \ + --unset GI_TYPELIB_PATH \ + --unset GTK_IM_MODULE \ + --unset GTK_IM_MODULE_FILE \ + --unset QT_IM_MODULE \ + --unset XMODIFIERS \ + --unset GIO_MODULE_DIR \ + --unset GTK_EXE_PREFIX \ + --unset GTK_DATA_PREFIX ''; }; diff --git a/impl-plans/active/file-viewer-mode.md b/impl-plans/active/file-viewer-mode.md index 56251d6..eedbe4e 100644 --- a/impl-plans/active/file-viewer-mode.md +++ b/impl-plans/active/file-viewer-mode.md @@ -218,6 +218,10 @@ export interface StartupContext { **Notes**: The current app is centered on a single Markdown startup path. The feature will be implemented by adding startup context plus separate directory-listing/file-preview commands, while preserving the existing Markdown editor flow for Markdown mode. Image and video previews are part of the file-view contract. ### Session: 2026-03-21 JST +**Tasks Completed**: Refactored directory listing contract to keep selection frontend-owned +**Tasks In Progress**: TASK-002 Rust viewer service, TASK-003 frontend file view mode +**Blockers**: Runtime keyboard behavior still needs manual validation in the app +**Notes**: Removed `selected_path` / `selected_index` from the `list_directory` Tauri contract so the backend acts as a stateless page query service over `path`, `offset`, `limit`, `query`, and `sort`. Frontend selection is now owned locally in `WorkspaceShell`, including page-boundary keyboard navigation. Verified with `bun run typecheck`, `bun test`, `CARGO_TERM_QUIET=true cargo check -p chilla`, and `CARGO_TERM_QUIET=true cargo test -p chilla`. **Tasks Completed**: Extended the directory-entry contract with file size and modified-time metadata; added frontend file-tree sort modes and keyboard shortcuts for name, mtime, and size. **Tasks In Progress**: TASK-002 Rust viewer service, TASK-003 frontend file view mode, TASK-004 verification **Blockers**: None @@ -228,3 +232,15 @@ export interface StartupContext { **Tasks In Progress**: TASK-003 frontend file view mode, TASK-004 verification **Blockers**: None **Notes**: The play overlay remains keyboard reachable, and the autoplay request id is now consumed once per preview load so repeated reactive updates do not retrigger stale playback requests. + +### Session: 2026-03-21 JST +**Tasks Completed**: Tuned large-directory file-tree rendering by virtualizing the visible rows, moving scrolling into a dedicated viewport, and removing per-row truncation measurement observers that scaled with entry count. +**Tasks In Progress**: TASK-003 frontend file view mode, TASK-004 verification +**Blockers**: None +**Notes**: The file tree now renders only the visible slice plus overscan, which keeps navigation responsive for large directories while preserving existing keyboard and focus behavior. + +### Session: 2026-03-21 JST +**Tasks Completed**: Moved file-tree sort and filter evaluation to Rust, changed the directory-list contract to return a paged window with total counts and selection metadata, and updated the frontend file browser to request new windows as scroll/keyboard navigation moves across large directories. +**Tasks In Progress**: TASK-003 frontend file view mode, TASK-004 verification +**Blockers**: None +**Notes**: Large directories such as `/nix/store` no longer require sending the entire entry list over Tauri in one response. The frontend now treats the Rust response as a movable window over the full sorted directory. diff --git a/package.json b/package.json index 68ad707..b71e2a7 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,9 @@ "solid-js": "^1.9.3" }, "devDependencies": { - "@types/bun": "^1.3.3", + "@playwright/test": "^1.58.2", "@tauri-apps/cli": "^2", + "@types/bun": "^1.3.3", "typescript": "~5.6.2", "vite": "^6.0.3", "vite-plugin-solid": "^2.11.0" diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..285dea9 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,40 @@ +import { execSync } from "node:child_process"; +import { defineConfig } from "@playwright/test"; + +function resolveChromiumExecutablePath(): string | undefined { + if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE) { + return process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE; + } + + try { + const value = execSync("which chromium", { + stdio: ["ignore", "pipe", "ignore"], + }) + .toString() + .trim(); + return value === "" ? undefined : value; + } catch { + return undefined; + } +} + +const chromiumExecutablePath = resolveChromiumExecutablePath(); + +export default defineConfig({ + testDir: "./e2e", + testMatch: "**/*.pw.ts", + timeout: 30_000, + use: { + baseURL: "http://localhost:1420", + headless: true, + launchOptions: chromiumExecutablePath + ? { executablePath: chromiumExecutablePath } + : undefined, + }, + webServer: { + command: "bun run dev -- --host localhost", + url: "http://localhost:1420", + reuseExistingServer: true, + timeout: 120_000, + }, +}); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e7f2ef3..fd86902 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,7 +22,7 @@ syntect = { version = "5.3", default-features = false, features = [ "html", "dump-load", ] } -tauri = { version = "2", features = ["protocol-asset"] } +tauri = { version = "2", features = ["custom-protocol", "protocol-asset"] } tauri-plugin-opener = "2.5.3" thiserror = "2" tree_magic_mini = "3.2.2" diff --git a/src-tauri/src/commands/document.rs b/src-tauri/src/commands/document.rs index d8ba9df..e537b46 100644 --- a/src-tauri/src/commands/document.rs +++ b/src-tauri/src/commands/document.rs @@ -7,7 +7,7 @@ use crate::{ document::types::{DocumentSnapshot, HeadingNode}, markdown::render_markdown, syntax_highlight::SyntaxUiTheme, - viewer::types::{DirectorySnapshot, FilePreview, StartupContext}, + viewer::types::{DirectorySnapshot, DirectorySort, FilePreview, StartupContext}, }; fn format_command_error(error: impl std::fmt::Display) -> String { @@ -48,12 +48,21 @@ pub fn get_startup_context(state: State<'_, AppState>) -> Result, + offset: Option, + limit: Option, + query: Option, + sort: Option, state: State<'_, AppState>, ) -> Result { state .viewer_service() - .list_directory(Path::new(&path), selected_path.as_deref().map(Path::new)) + .list_directory( + Path::new(&path), + offset, + limit, + query.as_deref(), + sort, + ) .map_err(format_command_error) } diff --git a/src-tauri/src/viewer/service.rs b/src-tauri/src/viewer/service.rs index 20da726..ebcaf4e 100644 --- a/src-tauri/src/viewer/service.rs +++ b/src-tauri/src/viewer/service.rs @@ -10,7 +10,8 @@ use crate::{ error::{AppError, AppResult}, syntax_highlight::{self, SyntaxUiTheme}, viewer::types::{ - DirectoryEntry, DirectorySnapshot, FilePreview, StartupContext, WorkspaceMode, + DirectoryEntry, DirectorySnapshot, DirectorySort, DirectorySortDirection, + DirectorySortField, FilePreview, StartupContext, WorkspaceMode, }, }; @@ -58,6 +59,8 @@ const VIDEO_EXTENSION_MIME_TYPES: [(&str, &str); 5] = [ ("webm", "video/webm"), ]; const PDF_EXTENSION_MIME_TYPES: [(&str, &str); 1] = [("pdf", "application/pdf")]; +const DEFAULT_DIRECTORY_PAGE_LIMIT: usize = 100; +const MAX_DIRECTORY_PAGE_LIMIT: usize = 2_000; #[derive(Clone, Default)] pub struct ViewerService; @@ -93,10 +96,22 @@ impl ViewerService { pub fn list_directory( &self, path: &Path, - selected_path: Option<&Path>, + offset: Option, + limit: Option, + query: Option<&str>, + sort: Option, ) -> AppResult { let current_directory_path = canonicalize_directory_path(path)?; let parent_directory_path = current_directory_path.parent().map(display_path); + let query = query.unwrap_or("").trim().to_string(); + let query_lower = query.to_lowercase(); + let sort = sort.unwrap_or(DirectorySort { + field: DirectorySortField::Name, + direction: DirectorySortDirection::Asc, + }); + let page_limit = limit + .unwrap_or(DEFAULT_DIRECTORY_PAGE_LIMIT) + .clamp(1, MAX_DIRECTORY_PAGE_LIMIT); let mut entries = fs::read_dir(¤t_directory_path) .map_err(|source| AppError::io("read directory", ¤t_directory_path, source))? @@ -105,6 +120,9 @@ impl ViewerService { AppError::io("read directory entry", ¤t_directory_path, source) })?; let entry_path = entry.path(); + let entry_file_type = entry + .file_type() + .map_err(|source| AppError::io("read file type for", &entry_path, source))?; let entry_metadata = entry .metadata() .map_err(|source| AppError::io("read metadata for", &entry_path, source))?; @@ -119,33 +137,43 @@ impl ViewerService { // each row is unique and keyboard navigation matches the focused item. path: display_path(&entry_path), name: entry_name, - is_directory: entry_metadata.is_dir(), + is_directory: entry_file_type.is_dir(), size_bytes: entry_metadata.len(), modified_at_unix_ms, }) }) - .collect::>>()?; - - entries.sort_by(|left, right| { - right - .is_directory - .cmp(&left.is_directory) - .then_with(|| { - left.name - .to_ascii_lowercase() - .cmp(&right.name.to_ascii_lowercase()) - }) - .then_with(|| left.name.cmp(&right.name)) - }); + .collect::>>()? + .into_iter() + .filter(|entry| { + query_lower.is_empty() || entry.name.to_lowercase().contains(&query_lower) + }) + .collect::>(); - let selected_path = - resolve_directory_selected_path(¤t_directory_path, &entries, selected_path); + entries.sort_by(|left, right| compare_directory_entries(left, right, sort)); + + let total_entries = entries.len(); + let page_offset = offset.unwrap_or(0); + let page_offset = page_offset.min(total_entries.saturating_sub(1)); + let page_offset = if total_entries == 0 { + 0 + } else { + page_offset.min(total_entries.saturating_sub(page_limit)) + }; + let entries = entries + .into_iter() + .skip(page_offset) + .take(page_limit) + .collect::>(); Ok(DirectorySnapshot { current_directory_path: display_path(¤t_directory_path), parent_directory_path, entries, - selected_path, + total_entries, + offset: page_offset, + limit: page_limit, + query, + sort, }) } @@ -279,6 +307,94 @@ impl ViewerService { } } +fn compare_directory_entries( + left: &DirectoryEntry, + right: &DirectoryEntry, + sort: DirectorySort, +) -> std::cmp::Ordering { + compare_directory_priority(left, right) + .then_with(|| compare_by_requested_field(left, right, sort)) + .then_with(|| compare_names(left, right, DirectorySortDirection::Asc)) + .then_with(|| left.path.cmp(&right.path)) +} + +fn compare_directory_priority(left: &DirectoryEntry, right: &DirectoryEntry) -> std::cmp::Ordering { + right.is_directory.cmp(&left.is_directory) +} + +fn compare_by_requested_field( + left: &DirectoryEntry, + right: &DirectoryEntry, + sort: DirectorySort, +) -> std::cmp::Ordering { + match sort.field { + DirectorySortField::Name => compare_names(left, right, sort.direction), + DirectorySortField::Mtime => { + compare_numbers(left.modified_at_unix_ms, right.modified_at_unix_ms, sort.direction) + } + DirectorySortField::Size => { + compare_numbers(left.size_bytes, right.size_bytes, sort.direction) + } + DirectorySortField::Extension => compare_extensions(left, right, sort.direction), + } +} + +fn compare_names( + left: &DirectoryEntry, + right: &DirectoryEntry, + direction: DirectorySortDirection, +) -> std::cmp::Ordering { + let normalized = left + .name + .to_lowercase() + .cmp(&right.name.to_lowercase()) + .then_with(|| left.name.cmp(&right.name)); + + match direction { + DirectorySortDirection::Asc => normalized, + DirectorySortDirection::Desc => normalized.reverse(), + } +} + +fn compare_numbers(left: T, right: T, direction: DirectorySortDirection) -> std::cmp::Ordering +where + T: Ord, +{ + let normalized = left.cmp(&right); + + match direction { + DirectorySortDirection::Asc => normalized, + DirectorySortDirection::Desc => normalized.reverse(), + } +} + +fn compare_extensions( + left: &DirectoryEntry, + right: &DirectoryEntry, + direction: DirectorySortDirection, +) -> std::cmp::Ordering { + let normalized = file_extension(&left.name) + .cmp(&file_extension(&right.name)) + .then_with(|| compare_names(left, right, DirectorySortDirection::Asc)); + + match direction { + DirectorySortDirection::Asc => normalized, + DirectorySortDirection::Desc => normalized.reverse(), + } +} + +fn file_extension(name: &str) -> String { + let Some((_, extension)) = name.rsplit_once('.') else { + return String::new(); + }; + + if extension == name { + return String::new(); + } + + extension.to_lowercase() +} + pub fn resolve_startup_target(path: &Path) -> AppResult { let canonical_path = canonicalize_path(path)?; let metadata = fs::metadata(&canonical_path) @@ -327,60 +443,6 @@ fn parent_directory_path(path: &Path) -> AppResult { .ok_or_else(|| AppError::NotADirectory(display_path(path))) } -fn default_list_focus_path(entries: &[DirectoryEntry]) -> Option { - entries - .iter() - .find(|entry| !entry.is_directory) - .or_else(|| entries.first()) - .map(|entry| entry.path.clone()) -} - -/// Keeps list focus stable across reloads. Matches either the logical listing path (per row) or the -/// canonical target (e.g. startup file path, symlinks), without requiring the selection to be a -/// direct child in canonical form. -fn resolve_directory_selected_path( - current_directory_path: &Path, - entries: &[DirectoryEntry], - selected_path: Option<&Path>, -) -> Option { - let current_display = display_path(current_directory_path); - - let Some(requested) = selected_path else { - return default_list_focus_path(entries); - }; - - let requested_display = display_path(requested); - if requested_display == current_display { - return default_list_focus_path(entries); - } - - let requested_canonical = canonicalize_path(requested).ok(); - - if let Some(ref canon) = requested_canonical { - if canon == current_directory_path { - return default_list_focus_path(entries); - } - } - - for entry in entries { - if entry.path == requested_display { - return Some(entry.path.clone()); - } - } - - if let Some(ref req_canon) = requested_canonical { - for entry in entries { - if let Ok(entry_canon) = canonicalize_path(Path::new(&entry.path)) { - if entry_canon == *req_canon { - return Some(entry.path.clone()); - } - } - } - } - - default_list_focus_path(entries) -} - fn is_markdown_path(path: &Path) -> bool { path.extension() .and_then(std::ffi::OsStr::to_str) @@ -557,7 +619,7 @@ mod tests { fs::write(test_dir.path().join("Bravo.txt"), "bravo").expect("write bravo"); let snapshot = ViewerService::new() - .list_directory(test_dir.path(), None) + .list_directory(test_dir.path(), None, None, None, None) .expect("directory snapshot"); let names = snapshot @@ -567,12 +629,8 @@ mod tests { .collect::>(); assert_eq!(names, vec!["Alpha", "beta", "Bravo.txt", "zeta.txt"]); - let bravo_logical = test_dir.path().join("Bravo.txt"); - assert_eq!( - snapshot.selected_path, - Some(bravo_logical.display().to_string()), - "default selection is first file after directories", - ); + assert_eq!(snapshot.total_entries, 4); + assert_eq!(snapshot.offset, 0); let bravo_entry = snapshot .entries @@ -580,34 +638,11 @@ mod tests { .find(|entry| entry.name == "Bravo.txt") .expect("bravo entry"); let bravo_metadata = - fs::metadata(bravo_logical).expect("metadata for bravo logical file path"); + fs::metadata(test_dir.path().join("Bravo.txt")).expect("metadata for bravo file path"); assert_eq!(bravo_entry.size_bytes, bravo_metadata.len()); assert!(bravo_entry.modified_at_unix_ms > 0); } - #[test] - fn list_directory_never_selects_current_directory_path_defaults_to_first_file() { - let test_dir = TestDir::new(); - fs::write(test_dir.path().join("a.txt"), "a").expect("write file"); - let canonical = test_dir.path().canonicalize().expect("canonical path"); - - let a_txt_logical = test_dir.path().join("a.txt"); - - let snapshot = ViewerService::new() - .list_directory(test_dir.path(), Some(canonical.as_path())) - .expect("directory snapshot"); - - assert_ne!( - snapshot.selected_path, - Some(canonical.display().to_string()), - "current-directory path must not be the list selection", - ); - assert_eq!( - snapshot.selected_path, - Some(a_txt_logical.display().to_string()) - ); - } - #[cfg(unix)] #[test] fn list_directory_symlink_entry_path_is_the_link_not_the_target() { @@ -620,7 +655,7 @@ mod tests { symlink(&target, &link).expect("symlink"); let snapshot = ViewerService::new() - .list_directory(test_dir.path(), None) + .list_directory(test_dir.path(), None, None, None, None) .expect("directory snapshot"); let paths: Vec = snapshot.entries.iter().map(|e| e.path.clone()).collect(); @@ -638,32 +673,56 @@ mod tests { "sanity: link path differs from target path", ); - let target_row_path = snapshot + } + + #[test] + fn list_directory_returns_a_sorted_page_window() { + let test_dir = TestDir::new(); + for index in 0..20 { + fs::write(test_dir.path().join(format!("file-{index:02}.txt")), "x") + .expect("write file"); + } + + let snapshot = ViewerService::new() + .list_directory(test_dir.path(), Some(8), Some(5), None, None) + .expect("directory snapshot"); + + let names = snapshot .entries .iter() - .find(|e| e.name == "target.txt") - .map(|e| e.path.clone()) - .expect("target row"); - let target_canonical = target.canonicalize().expect("canonical"); - let resolved = ViewerService::new() - .list_directory(test_dir.path(), Some(target_canonical.as_path())) - .expect("resolve by canonical target"); + .map(|entry| entry.name.clone()) + .collect::>(); + + assert_eq!(snapshot.total_entries, 20); + assert_eq!(snapshot.limit, 5); + assert_eq!(snapshot.offset, 8); assert_eq!( - resolved.selected_path, - Some(target_row_path), - "selection by canonical path must map back to a listing row", + names, + vec![ + "file-08.txt", + "file-09.txt", + "file-10.txt", + "file-11.txt", + "file-12.txt", + ] ); + } - let link_row_path = snapshot - .entries - .iter() - .find(|e| e.name == "via_link.txt") - .map(|e| e.path.clone()) - .expect("symlink row"); - let resolved_link = ViewerService::new() - .list_directory(test_dir.path(), Some(Path::new(&link_row_path))) - .expect("resolve by symlink path as shown in listing"); - assert_eq!(resolved_link.selected_path, Some(link_row_path)); + #[test] + fn list_directory_uses_100_entries_as_the_default_page_limit() { + let test_dir = TestDir::new(); + for index in 0..150 { + fs::write(test_dir.path().join(format!("file-{index:03}.txt")), "x") + .expect("write file"); + } + + let snapshot = ViewerService::new() + .list_directory(test_dir.path(), None, None, None, None) + .expect("directory snapshot"); + + assert_eq!(snapshot.total_entries, 150); + assert_eq!(snapshot.limit, 100); + assert_eq!(snapshot.entries.len(), 100); } #[test] diff --git a/src-tauri/src/viewer/types.rs b/src-tauri/src/viewer/types.rs index 0bae050..c96ebcc 100644 --- a/src-tauri/src/viewer/types.rs +++ b/src-tauri/src/viewer/types.rs @@ -1,4 +1,4 @@ -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::document::types::DocumentSnapshot; @@ -21,7 +21,11 @@ pub struct DirectorySnapshot { pub current_directory_path: String, pub parent_directory_path: Option, pub entries: Vec, - pub selected_path: Option, + pub total_entries: usize, + pub offset: usize, + pub limit: usize, + pub query: String, + pub sort: DirectorySort, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] @@ -33,6 +37,28 @@ pub struct DirectoryEntry { pub modified_at_unix_ms: u64, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DirectorySortField { + Name, + Mtime, + Size, + Extension, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DirectorySortDirection { + Asc, + Desc, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub struct DirectorySort { + pub field: DirectorySortField, + pub direction: DirectorySortDirection, +} + #[derive(Debug, Clone, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum FilePreview { diff --git a/src/app/App.css b/src/app/App.css index cbe5b96..c207786 100644 --- a/src/app/App.css +++ b/src/app/App.css @@ -5,6 +5,9 @@ --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; --font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Fira Code", Menlo, Consolas, monospace; + --preview-content-min-width: 42rem; + --preview-content-inline-padding: clamp(0.2rem, 2vw, 0.9rem); + --preview-content-block-padding: 0.35rem; } [data-theme="dark"] { @@ -547,11 +550,16 @@ button { .preview { margin: 0; - padding: 0.35rem; + padding: var(--preview-content-block-padding) + var(--preview-content-inline-padding); color: var(--text-muted); line-height: 1.65; } +.preview__content { + min-width: var(--preview-content-min-width); +} + .preview a { color: var(--preview-link); text-decoration: none; @@ -753,9 +761,11 @@ button { .file-browser { display: flex; flex-direction: column; + min-height: 0; gap: 0; margin: 0; padding: 0; + overflow: hidden; } .file-browser__path { @@ -827,6 +837,22 @@ button { list-style: none; } +.file-browser__list-viewport { + flex: 1; + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + outline: none; +} + +.file-browser__virtual-spacer { + flex: 0 0 auto; +} + +.file-browser__row { + height: 28px; +} + .file-browser__button { display: flex; align-items: center; @@ -837,6 +863,8 @@ button { border: 0; border-radius: 0; background: transparent; + min-height: 28px; + height: 28px; text-align: left; font-size: 0.78rem; line-height: 1.3; @@ -900,6 +928,7 @@ button { display: block; overflow: hidden; white-space: nowrap; + text-overflow: ellipsis; } .file-browser__name-tooltip { diff --git a/src/app/App.tsx b/src/app/App.tsx index 9321b4f..1359663 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,6 +1,24 @@ -import { WorkspaceShell } from "../features/workspace/WorkspaceShell"; +import { Suspense, lazy } from "solid-js"; import "./App.css"; +const WorkspaceShell = lazy(async () => { + const module = await import("../features/workspace/WorkspaceShell"); + return { default: module.WorkspaceShell }; +}); + +const FileBrowserHarness = lazy(async () => { + const module = await import("../features/file-view/FileBrowserHarness"); + return { default: module.FileBrowserHarness }; +}); + export default function App() { - return ; + const Component = window.location.search.includes("harness=file-browser") + ? FileBrowserHarness + : WorkspaceShell; + + return ( + }> + + + ); } diff --git a/src/features/file-view/FileBrowserHarness.tsx b/src/features/file-view/FileBrowserHarness.tsx new file mode 100644 index 0000000..73438a6 --- /dev/null +++ b/src/features/file-view/FileBrowserHarness.tsx @@ -0,0 +1,172 @@ +import { createMemo, createSignal } from "solid-js"; +import type { + DirectoryEntry, + DirectorySnapshot, + DirectorySort, +} from "../../lib/tauri/document"; +import { FileBrowserPane } from "./FileBrowserPane"; + +const BASE_ENTRIES: readonly DirectoryEntry[] = [ + { + path: "/workspace/alpha", + name: "alpha", + is_directory: true, + size_bytes: 0, + modified_at_unix_ms: 10, + }, + { + path: "/workspace/bravo.md", + name: "bravo.md", + is_directory: false, + size_bytes: 10, + modified_at_unix_ms: 20, + }, + { + path: "/workspace/charlie", + name: "charlie", + is_directory: true, + size_bytes: 0, + modified_at_unix_ms: 30, + }, + { + path: "/workspace/delta.md", + name: "delta.md", + is_directory: false, + size_bytes: 10, + modified_at_unix_ms: 40, + }, + { + path: "/workspace/echo.txt", + name: "echo.txt", + is_directory: false, + size_bytes: 10, + modified_at_unix_ms: 50, + }, +]; + +function compareBySort( + left: DirectoryEntry, + right: DirectoryEntry, + sort: DirectorySort, +): number { + if (left.is_directory !== right.is_directory) { + return left.is_directory ? -1 : 1; + } + + const normalizeName = (value: string) => value.toLowerCase(); + const compareName = () => + normalizeName(left.name).localeCompare(normalizeName(right.name)) || + left.name.localeCompare(right.name); + const compareNumber = (a: number, b: number) => a - b; + const extensionOf = (name: string) => { + const parts = name.split("."); + return parts.length > 1 ? (parts[parts.length - 1] ?? "").toLowerCase() : ""; + }; + + let result = 0; + + switch (sort.field) { + case "name": + result = compareName(); + break; + case "mtime": + result = compareNumber(left.modified_at_unix_ms, right.modified_at_unix_ms); + break; + case "size": + result = compareNumber(left.size_bytes, right.size_bytes); + break; + case "extension": + result = + extensionOf(left.name).localeCompare(extensionOf(right.name)) || + compareName(); + break; + } + + return sort.direction === "asc" ? result : -result; +} + +export function FileBrowserHarness() { + const [query, setQuery] = createSignal(""); + const [sort, setSort] = createSignal({ + field: "name", + direction: "asc", + }); + const [selectedPath, setSelectedPath] = createSignal( + "/workspace/bravo.md", + ); + const [selectedIndex, setSelectedIndex] = createSignal(2); + const [lastConfirmedPath, setLastConfirmedPath] = createSignal( + null, + ); + const [parentNavigationCount, setParentNavigationCount] = createSignal(0); + + const entries = createMemo(() => { + const loweredQuery = query().trim().toLowerCase(); + return BASE_ENTRIES + .filter((entry) => + loweredQuery === "" || entry.name.toLowerCase().includes(loweredQuery) + ) + .slice() + .sort((left, right) => compareBySort(left, right, sort())); + }); + + const snapshot = createMemo(() => ({ + current_directory_path: "/workspace", + parent_directory_path: "/", + entries: entries(), + total_entries: entries().length, + offset: 0, + limit: entries().length, + query: query(), + sort: sort(), + })); + + return ( +
+
{selectedPath() ?? ""}
+
{selectedIndex()?.toString() ?? ""}
+
{lastConfirmedPath() ?? ""}
+
{parentNavigationCount().toString()}
+ { + setQuery(value); + setSelectedIndex(0); + const first = entries()[0]; + setSelectedPath(first?.path ?? null); + }} + onChangeSort={(value) => { + setSort(value); + setSelectedIndex(0); + const first = entries()[0]; + setSelectedPath(first?.path ?? null); + }} + onSelectEntry={(entry) => { + const nextIndex = entries().findIndex((candidate) => + candidate.path === entry.path + ); + setSelectedPath(entry.path); + setSelectedIndex(nextIndex === -1 ? null : nextIndex); + }} + onSelectIndex={(index) => { + const entry = entries()[index]; + setSelectedIndex(index); + setSelectedPath(entry?.path ?? null); + }} + onConfirmEntry={(entry) => { + setLastConfirmedPath(entry.path); + }} + onCopyPath={() => {}} + onNavigateToParent={() => { + setParentNavigationCount((count) => count + 1); + }} + /> +
+ ); +} diff --git a/src/features/file-view/FileBrowserPane.test.ts b/src/features/file-view/FileBrowserPane.test.ts new file mode 100644 index 0000000..d3f3caa --- /dev/null +++ b/src/features/file-view/FileBrowserPane.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "bun:test"; +import type { DirectorySnapshot } from "../../lib/tauri/document"; +import { resolveAbsoluteSelectedIndex } from "./FileBrowserPane"; + +const snapshot: DirectorySnapshot = { + current_directory_path: "/workspace", + parent_directory_path: "/", + entries: [ + { + path: "/workspace/blocked", + name: "blocked", + is_directory: true, + size_bytes: 0, + modified_at_unix_ms: 1, + }, + { + path: "/workspace/next.md", + name: "next.md", + is_directory: false, + size_bytes: 10, + modified_at_unix_ms: 2, + }, + ], + total_entries: 50, + offset: 10, + limit: 20, + query: "", + sort: { + field: "name", + direction: "asc", + }, +}; + +describe("resolveAbsoluteSelectedIndex", () => { + it("prefers the frontend-owned selected index when available", () => { + expect( + resolveAbsoluteSelectedIndex(snapshot, "/workspace/next.md", 10), + ).toBe(10); + expect( + resolveAbsoluteSelectedIndex(snapshot, "/workspace/blocked", 11), + ).toBe(11); + }); + + it("falls back to the loaded row path when the snapshot index is absent", () => { + expect( + resolveAbsoluteSelectedIndex(snapshot, "/workspace/blocked", null), + ).toBe(10); + expect( + resolveAbsoluteSelectedIndex(snapshot, "/workspace/next.md", null), + ).toBe(11); + }); + + it("returns -1 when neither an index nor a loaded path is available", () => { + expect( + resolveAbsoluteSelectedIndex(snapshot, "/workspace/missing.md", null), + ).toBe(-1); + expect(resolveAbsoluteSelectedIndex(null, null, null)).toBe(-1); + }); +}); diff --git a/src/features/file-view/FileBrowserPane.tsx b/src/features/file-view/FileBrowserPane.tsx index 45363da..c39c306 100644 --- a/src/features/file-view/FileBrowserPane.tsx +++ b/src/features/file-view/FileBrowserPane.tsx @@ -13,13 +13,13 @@ import { Portal } from "solid-js/web"; import { isEditableKeyboardTarget } from "../../lib/keyboard"; import type { DirectoryEntry, + DirectorySort, DirectorySnapshot, } from "../../lib/tauri/document"; import { middleEllipsisForWidth } from "./middleEllipsis"; import { DEFAULT_FILE_TREE_SORT, describeFileTreeSort, - sortDirectoryEntries, type FileTreeSortState, } from "./sort"; @@ -59,9 +59,7 @@ function FileGlyph() { } export interface FileBrowserSelectOptions { - /** Skip selection debounce and open the preview immediately (Enter / Ctrl+M from filter). */ readonly immediatePreview?: boolean; - /** Request playback after the preview opens when the selected entry is a video file. */ readonly playVideo?: boolean; } @@ -69,14 +67,22 @@ interface FileBrowserPaneProps { readonly active: boolean; readonly directory: DirectorySnapshot | null; readonly selectedPath: string | null; + readonly selectedIndex: number | null; + readonly query: string; + readonly sort: DirectorySort; + readonly isLoading: boolean; + readonly onChangeQuery: (query: string) => void; + readonly onChangeSort: (sort: FileTreeSortState) => void; readonly onSelectEntry: ( entry: DirectoryEntry, options?: FileBrowserSelectOptions, ) => void; + readonly onSelectIndex: (index: number) => void; readonly onConfirmEntry: ( entry: DirectoryEntry, options?: FileBrowserSelectOptions, ) => void; + readonly onCopyPath: (path: string) => void | Promise; readonly onNavigateToParent: () => void; } @@ -94,12 +100,13 @@ function FileBrowserEntryName(props: { readonly name: string }) { const [shown, setShown] = createSignal(props.name); const recompute = () => { - const el = outer; - if (el === undefined) { + const element = outer; + if (element === undefined) { return; } - const width = el.clientWidth; - const font = getComputedStyle(el).font; + + const width = element.clientWidth; + const font = getComputedStyle(element).font; setShown( width <= 0 ? props.name : middleEllipsisForWidth(props.name, width, font), ); @@ -110,13 +117,14 @@ function FileBrowserEntryName(props: { readonly name: string }) { queueMicrotask(recompute); }); - const setOuterRef = (el: HTMLSpanElement | undefined) => { + const setOuterRef = (element: HTMLSpanElement | undefined) => { observer?.disconnect(); observer = undefined; - outer = el; - if (el !== undefined) { + outer = element; + + if (element !== undefined) { observer = new ResizeObserver(recompute); - observer.observe(el); + observer.observe(element); queueMicrotask(recompute); } }; @@ -179,6 +187,8 @@ function focusListButtonForPath( return true; } } + + return false; } const first = buttons.item(0); @@ -192,13 +202,43 @@ function focusListButtonForPath( return false; } +function listButtons( + list: HTMLUListElement | undefined, +): readonly HTMLButtonElement[] { + if (list === undefined) { + return []; + } + + return Array.from( + list.querySelectorAll(".file-browser__button"), + ); +} + +export function resolveAbsoluteSelectedIndex( + directory: DirectorySnapshot | null, + selectedPath: string | null, + selectedIndex: number | null, +): number { + if (selectedIndex !== null) { + return selectedIndex; + } + + if (directory !== null && selectedPath !== null) { + const localIndex = directory.entries.findIndex((entry) => + entry.path === selectedPath + ); + + if (localIndex !== -1) { + return directory.offset + localIndex; + } + } + + return -1; +} + export function FileBrowserPane(props: FileBrowserPaneProps) { let listEl: HTMLUListElement | undefined; const filterInputId = createUniqueId(); - const [filterText, setFilterText] = createSignal(""); - const [sortState, setSortState] = createSignal( - DEFAULT_FILE_TREE_SORT, - ); const [nameTooltip, setNameTooltip] = createSignal( null, ); @@ -220,40 +260,32 @@ export function FileBrowserPane(props: FileBrowserPaneProps) { ); }; - const filteredEntries = createMemo(() => { - const entries = props.directory?.entries ?? []; - - const q = filterText().trim().toLowerCase(); - const sorted = sortDirectoryEntries(entries, sortState()); - - if (q === "") { - return sorted; - } - - return sorted.filter((entry) => entry.name.toLowerCase().includes(q)); - }); - + const loadedEntries = createMemo(() => props.directory?.entries ?? []); const totalEntryCount = createMemo( - () => props.directory?.entries.length ?? 0, + () => props.directory?.total_entries ?? loadedEntries().length, ); const filterSummary = createMemo(() => { const total = totalEntryCount(); - const shown = filteredEntries().length; - const trimmed = filterText().trim(); + const loaded = loadedEntries().length; + const trimmed = props.query.trim(); if (total === 0) { return "0 entries"; } if (trimmed === "") { - return `${total} ${total === 1 ? "entry" : "entries"}`; + return `${total} ${total === 1 ? "entry" : "entries"}${ + props.isLoading ? " | loading..." : "" + }`; } - return `${shown} of ${total} ${total === 1 ? "entry" : "entries"}`; + return `${total} ${total === 1 ? "entry" : "entries"} matched | ${loaded} loaded${ + props.isLoading ? " | loading..." : "" + }`; }); - const sortSummary = createMemo(() => describeFileTreeSort(sortState())); + const sortSummary = createMemo(() => describeFileTreeSort(props.sort)); const isFileBrowserShortcutTarget = (target: EventTarget | null): boolean => { return ( @@ -270,7 +302,7 @@ export function FileBrowserPane(props: FileBrowserPaneProps) { } event.preventDefault(); - setSortState(nextSort); + props.onChangeSort(nextSort); return true; }; @@ -314,12 +346,10 @@ export function FileBrowserPane(props: FileBrowserPaneProps) { immediatePreview?: boolean, ) => { if (clearFilter) { - setFilterText(""); + props.onChangeQuery(""); } - const entries = clearFilter - ? (props.directory?.entries ?? []) - : filteredEntries(); + const entries = loadedEntries(); if (entries.length === 0) { blurFilterInput(); @@ -334,17 +364,6 @@ export function FileBrowserPane(props: FileBrowserPaneProps) { ); }; - createEffect( - on( - () => props.directory?.current_directory_path ?? null, - (cwd, previousCwd) => { - if (previousCwd !== undefined && cwd !== previousCwd) { - setFilterText(""); - } - }, - ), - ); - createEffect( on( () => @@ -352,7 +371,7 @@ export function FileBrowserPane(props: FileBrowserPaneProps) { ? { cwd: props.directory?.current_directory_path ?? null, selectedPath: props.selectedPath, - filteredCount: filteredEntries().length, + loadedCount: loadedEntries().length, } : null, (state) => { @@ -452,32 +471,76 @@ export function FileBrowserPane(props: FileBrowserPaneProps) { return; } - const entries = filteredEntries(); + const entries = loadedEntries(); + const total = totalEntryCount(); - if (entries.length === 0) { + if (total === 0) { return; } - const selectedIndex = entries.findIndex( - (entry) => entry.path === props.selectedPath, - ); + const loadedOffset = props.directory?.offset ?? 0; + const buttons = listButtons(resolveFileBrowserListEl()); + const focusedButton = + document.activeElement instanceof HTMLButtonElement && + buttons.includes(document.activeElement) + ? document.activeElement + : null; + const focusedPath = focusedButton?.getAttribute("data-path") ?? null; + const focusedLocalIndex = + focusedPath === null + ? -1 + : entries.findIndex((entry) => entry.path === focusedPath); + const selectedIndex = + focusedLocalIndex !== -1 + ? loadedOffset + focusedLocalIndex + : resolveAbsoluteSelectedIndex( + props.directory, + props.selectedPath, + props.selectedIndex, + ); + const currentPath = + focusedLocalIndex !== -1 ? focusedPath : props.selectedPath; const currentIndex = selectedIndex === -1 ? 0 : selectedIndex; - const selectedEntry = entries[currentIndex]; + const selectedEntry = + currentIndex >= loadedOffset && + currentIndex < loadedOffset + entries.length + ? entries[currentIndex - loadedOffset] + : undefined; + const pathToCopy = + currentPath ?? props.directory?.current_directory_path ?? null; const moveSelection = (nextIndex: number) => { - const nextEntry = entries[nextIndex]; + event.preventDefault(); + const localIndex = nextIndex - loadedOffset; + const nextEntry = entries[localIndex]; if (nextEntry !== undefined) { - event.preventDefault(); props.onSelectEntry(nextEntry); + queueMicrotask(() => { + requestAnimationFrame(() => { + focusListButtonForPath( + resolveFileBrowserListEl(), + nextEntry.path, + ); + }); + }); + return; } + + props.onSelectIndex(nextIndex); }; + if (key === "y" && pathToCopy !== null) { + event.preventDefault(); + void props.onCopyPath(pathToCopy); + return; + } + if (key === "j" || event.key === "ArrowDown") { moveSelection( selectedIndex === -1 ? 0 - : Math.min(entries.length - 1, currentIndex + 1), + : Math.min(total - 1, currentIndex + 1), ); return; } @@ -528,10 +591,12 @@ export function FileBrowserPane(props: FileBrowserPaneProps) { const display = button.querySelector( "[data-file-name-display]", ); + if (display?.dataset["truncated"] !== "true") { setNameTooltip(null); return; } + const rect = button.getBoundingClientRect(); setNameTooltip({ name: fullName, @@ -546,19 +611,19 @@ export function FileBrowserPane(props: FileBrowserPaneProps) { {(tip) => { - const t = tip(); + const tooltip = tip(); return ( ); }} @@ -595,9 +660,9 @@ export function FileBrowserPane(props: FileBrowserPaneProps) { title="Focus filter: / First row: Enter or Ctrl+M Clear filter & first row: Esc Sort: a/A name, e/E extension, m/M mtime, s/S size" autocomplete="off" spellcheck={false} - value={filterText()} + value={props.query} onInput={(event) => { - setFilterText(event.currentTarget.value); + props.onChangeQuery(event.currentTarget.value); }} onKeyDown={(event) => { if (event.key === "Escape") { @@ -607,8 +672,6 @@ export function FileBrowserPane(props: FileBrowserPaneProps) { return; } - // Enter / Ctrl+M: same as "first row" when the list has focus (window - // handler ignores keys while typing in this field unless we handle here). if (event.key === "Enter" && !event.isComposing) { event.preventDefault(); event.stopPropagation(); @@ -629,12 +692,23 @@ export function FileBrowserPane(props: FileBrowserPaneProps) { when={totalEntryCount() > 0} fallback={
- No entries in this directory. Use h to move up. + {props.query.trim() === "" + ? ( + <> + No entries in this directory. Use h to move up. + + ) + : ( + <> + No file or folder names match this filter. Use h to + move up. + + )}
} > 0} + when={loadedEntries().length > 0} fallback={
No file or folder names match this filter. Use h to @@ -648,7 +722,7 @@ export function FileBrowserPane(props: FileBrowserPaneProps) { listEl = element ?? undefined; }} > - + {(entry) => (