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) => (