From 7c8eedb69da18f3ddb543d78d3a98eaf3727db18 Mon Sep 17 00:00:00 2001 From: Francisco X Carrasco Date: Sun, 2 Aug 2026 08:20:34 -0300 Subject: [PATCH 1/5] fix(test): restore jsdom localStorage under Node 22+, and correct the pinned toolchain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node 22+ defines its own global `localStorage` accessor that returns undefined unless `--localstorage-file` is passed. Vitest's populateGlobal skips copying a jsdom key when one already exists on globalThis unless it is in an internal allowlist, and `localStorage`/`sessionStorage` are not on it (`navigator` is). So jsdom's storage never reaches window, and every test touching it fails — 51 of them — while CI on Node 20 stays green. CI moves to Node 24 because Node 20 rejects the flag outright, and reached end of life in April 2026. AGENTS.md also named nightly-2025-05-09 while rust-toolchain.toml pins nightly-2025-12-11. Following the doc and forcing the old toolchain onto PATH builds fine from a warm target dir, then fails the moment a proc-macro has to be rebuilt: rustc 1.88 emits dylibs that recent macOS dyld rejects with "mis-aligned LINKEDIT string pool". --- .github/workflows/frontend-ci.yml | 4 +- AGENTS.md | 3 +- cardinal/package-lock.json | 16 +- cardinal/package.json | 5 +- cardinal/src-tauri/capabilities/default.json | 3 +- cardinal/src-tauri/src/commands.rs | 20 ++- cardinal/src-tauri/src/lib.rs | 1 + cardinal/src/App.css | 169 ++++++++++++++++++ cardinal/src/App.tsx | 51 +++++- .../src/__tests__/App.contextMenu.test.tsx | 1 + .../__tests__/App.searchNavigation.test.tsx | 1 + cardinal/src/components/ColumnHeader.tsx | 142 +++++++++++++-- cardinal/src/components/FileRow.tsx | 92 +++++++--- cardinal/src/components/FilesTabContent.tsx | 21 ++- .../components/MiddleEllipsisHighlight.tsx | 40 ++++- cardinal/src/components/SearchBar.tsx | 111 +++++++++++- cardinal/src/components/VirtualList.tsx | 11 +- .../src/components/__tests__/FileRow.test.tsx | 10 +- .../components/__tests__/SearchBar.test.tsx | 78 +++++++- .../hooks/__tests__/useColumnOrder.test.ts | 41 +++++ .../src/hooks/__tests__/useDataLoader.test.ts | 30 ++++ cardinal/src/hooks/useColumnOrder.ts | 76 ++++++++ cardinal/src/hooks/useDataLoader.ts | 18 +- cardinal/src/hooks/useFileSearch.ts | 15 +- cardinal/src/i18n/resources/ar-SA.json | 32 +++- cardinal/src/i18n/resources/de-DE.json | 32 +++- cardinal/src/i18n/resources/en-US.json | 32 +++- cardinal/src/i18n/resources/es-ES.json | 32 +++- cardinal/src/i18n/resources/fr-FR.json | 32 +++- cardinal/src/i18n/resources/hi-IN.json | 32 +++- cardinal/src/i18n/resources/it-IT.json | 32 +++- cardinal/src/i18n/resources/ja-JP.json | 32 +++- cardinal/src/i18n/resources/ko-KR.json | 32 +++- cardinal/src/i18n/resources/pt-BR.json | 32 +++- cardinal/src/i18n/resources/ru-RU.json | 32 +++- cardinal/src/i18n/resources/tr-TR.json | 32 +++- cardinal/src/i18n/resources/uk-UA.json | 32 +++- cardinal/src/i18n/resources/zh-CN.json | 32 +++- cardinal/src/i18n/resources/zh-TW.json | 32 +++- cardinal/src/types/ipc.ts | 1 + cardinal/src/types/search.ts | 2 + .../utils/__tests__/searchBarQuery.test.ts | 75 ++++++++ cardinal/src/utils/searchBarQuery.ts | 144 +++++++++++++++ doc/pub/search-syntax.md | 1 + search-cache/src/cache.rs | 38 ++++ search-cache/src/highlight.rs | 62 ++++++- search-cache/src/lib.rs | 2 + search-cache/src/query.rs | 114 ++++++++++++ search-cache/src/tests/type_filters.rs | 17 ++ 49 files changed, 1762 insertions(+), 133 deletions(-) create mode 100644 cardinal/src/hooks/__tests__/useColumnOrder.test.ts create mode 100644 cardinal/src/hooks/useColumnOrder.ts create mode 100644 cardinal/src/utils/__tests__/searchBarQuery.test.ts create mode 100644 cardinal/src/utils/searchBarQuery.ts diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 9a87bf94..a575a9c4 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -27,7 +27,9 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + # 22+: the test script passes --no-experimental-webstorage, which Node 20 rejects as an + # unknown option. Node 20 reached end of life in April 2026. + node-version: 24 cache: npm cache-dependency-path: cardinal/package-lock.json diff --git a/AGENTS.md b/AGENTS.md index 4ad23657..c349786f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,8 @@ - Desktop app lives in `cardinal/` (React UI in `src/`, Tauri/native glue in `src-tauri/`, build output in `cardinal/dist/`). - Workspace crates (root `Cargo.toml`): `lsf/` (CLI), `cardinal-sdk/` (shared types), `fswalk/`, `fs-icon/`, `namepool/`, `query-segmentation/`, `search-cache/`, `search-cancel/`, `cardinal-syntax/`, `slab-mmap/` (mmap-backed slab for the cache), `was/` (CLI that streams FSEvents via the SDK). - Tests sit next to code; cross-crate cases belong in each crate’s `tests/` directory. Generated outputs (`target/`, `cardinal/dist/`, vendor bundles) stay out of commits. -- Toolchain pinned via `rust-toolchain.toml` (`nightly-2025-05-09`); install with `rustup toolchain install nightly-2025-05-09`. +- Toolchain pinned via `rust-toolchain.toml` (`nightly-2025-12-11`); install with `rustup toolchain install nightly-2025-12-11`. Let the rustup shim pick it up rather than putting a toolchain's `bin/` on `PATH`: an older rustc still builds here from cache, but the proc-macro dylibs it emits are rejected by recent macOS dyld with `mis-aligned LINKEDIT string pool` as soon as anything has to be rebuilt. +- Keep `target/` out of iCloud-synced folders (`CARGO_TARGET_DIR`). If iCloud evicts a fingerprint file, cargo blocks forever in `read()` waiting for a download that never lands. ## Build, Test, and Development Commands - `cargo check --workspace` — fast compile validation for all crates. diff --git a/cardinal/package-lock.json b/cardinal/package-lock.json index 597030bf..f67b0b67 100644 --- a/cardinal/package-lock.json +++ b/cardinal/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@crabnebula/tauri-plugin-drag": "^2.1.0", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.7.2", "@tauri-apps/plugin-global-shortcut": "^2.3.1", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-window-state": "^2.4.1", @@ -1348,9 +1349,9 @@ "license": "MIT" }, "node_modules/@tauri-apps/api": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", - "integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", + "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", "license": "Apache-2.0 OR MIT", "funding": { "type": "opencollective", @@ -1574,6 +1575,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz", + "integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, "node_modules/@tauri-apps/plugin-global-shortcut": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-global-shortcut/-/plugin-global-shortcut-2.3.1.tgz", diff --git a/cardinal/package.json b/cardinal/package.json index e5fe8f2d..5a86a199 100644 --- a/cardinal/package.json +++ b/cardinal/package.json @@ -8,8 +8,8 @@ "build": "vite build", "preview": "vite preview", "tauri": "tauri", - "test": "vitest --watch=false", - "test:watch": "vitest --watch", + "test": "NODE_OPTIONS=--no-experimental-webstorage vitest --watch=false", + "test:watch": "NODE_OPTIONS=--no-experimental-webstorage vitest --watch", "typecheck": "tsc --noEmit", "format": "prettier --write .", "format:check": "prettier --check ." @@ -17,6 +17,7 @@ "dependencies": { "@crabnebula/tauri-plugin-drag": "^2.1.0", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.7.2", "@tauri-apps/plugin-global-shortcut": "^2.3.1", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-window-state": "^2.4.1", diff --git a/cardinal/src-tauri/capabilities/default.json b/cardinal/src-tauri/capabilities/default.json index ce8f857a..ce95cafe 100644 --- a/cardinal/src-tauri/capabilities/default.json +++ b/cardinal/src-tauri/capabilities/default.json @@ -12,6 +12,7 @@ "drag:default", "global-shortcut:allow-is-registered", "global-shortcut:allow-register", - "global-shortcut:allow-unregister" + "global-shortcut:allow-unregister", + "dialog:allow-open" ] } diff --git a/cardinal/src-tauri/src/commands.rs b/cardinal/src-tauri/src/commands.rs index ae863611..5ebb8d0a 100644 --- a/cardinal/src-tauri/src/commands.rs +++ b/cardinal/src-tauri/src/commands.rs @@ -19,8 +19,10 @@ use objc2::{ use objc2_app_kit::{NSPasteboard, NSPasteboardItem, NSPasteboardTypeString, NSPasteboardWriting}; use objc2_foundation::{NSArray, NSString, NSURL}; use parking_lot::Mutex; +use rayon::prelude::*; use search_cache::{ SearchOptions, SearchOutcome, SearchQuery, SearchResultNode, SlabIndex, SlabNodeMetadata, + content_snippet, content_terms_of_query, }; use search_cancel::CancellationToken; use serde::{Deserialize, Serialize}; @@ -217,6 +219,8 @@ pub struct NodeInfo { pub path: String, pub metadata: Option, pub icon: Option, + #[serde(rename = "contentContext")] + pub content_context: Option, } #[derive(Serialize, Default)] @@ -224,6 +228,9 @@ pub struct NodeInfo { pub struct SearchResponse { pub results: Vec, pub highlights: Vec, + /// `content:` terms of this query, so the UI highlights in a snippet exactly what the search + /// looked for inside files. + pub content_terms: Vec, pub status_code: u8, } @@ -291,6 +298,7 @@ pub async fn search( search_activity::note_search_activity(); let options = options.unwrap_or_default(); + let content_terms = query.as_deref().map(content_terms_of_query).unwrap_or_default(); let cancellation_token = CancellationToken::new_search(); let (result_tx, result_rx) = bounded(1); if let Err(e) = state.search_tx.send(SearchJob { @@ -325,6 +333,7 @@ pub async fn search( SearchResponse { results, highlights, + content_terms, status_code, } }) @@ -335,6 +344,8 @@ pub async fn search( pub fn get_nodes_info( results: Vec, include_icons: Option, + content_terms: Option>, + case_insensitive: Option, state: State<'_, SearchState>, ) -> Vec { if results.is_empty() { @@ -342,11 +353,17 @@ pub fn get_nodes_info( } let include_icons = include_icons.unwrap_or(true); + let content_terms = content_terms.unwrap_or_default(); + let case_insensitive = case_insensitive.unwrap_or_default(); let nodes = state.request_nodes(results); + // Rows are independent, and each one may read a file for its icon and its content snippet. nodes - .into_iter() + .into_par_iter() .map(|SearchResultNode { path, metadata }| { + let content_context = content_terms + .iter() + .find_map(|term| content_snippet(&path, term, case_insensitive)); let path = path.to_string_lossy().into_owned(); let icon = if include_icons { fs_icon::icon_of_path_ns(&path).map(|data| { @@ -362,6 +379,7 @@ pub fn get_nodes_info( path, icon, metadata: metadata.as_ref().map(NodeInfoMetadata::from_metadata), + content_context, } }) .collect() diff --git a/cardinal/src-tauri/src/lib.rs b/cardinal/src-tauri/src/lib.rs index 920840c0..57fa33c0 100644 --- a/cardinal/src-tauri/src/lib.rs +++ b/cardinal/src-tauri/src/lib.rs @@ -79,6 +79,7 @@ pub fn run() -> Result<()> { builder = builder .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_drag::init()) + .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_macos_permissions::init()) .plugin(tauri_plugin_window_state::Builder::new().build()) diff --git a/cardinal/src/App.css b/cardinal/src/App.css index 5d6af384..34b0ab93 100644 --- a/cardinal/src/App.css +++ b/cardinal/src/App.css @@ -117,6 +117,98 @@ main, height: 100vh; } +/* === Reordenar columnas arrastrando el título === */ +.header-cell { + cursor: grab; +} + +.header-cell--dragging { + opacity: 0.45; +} + +/* La línea marca dónde va a caer la columna. */ +.header-cell--drop-target { + box-shadow: inset 2px 0 0 var(--color-accent); +} + +/* El asa de redimensionar vive dentro de la celda arrastrable: sin esto, tomarla arrastra + la columna entera en vez de cambiarle el ancho. */ +.col-resizer { + cursor: col-resize; +} + +/* === Campo "Contiene": busca dentro de los archivos === */ +.content-search-segment { + flex: 0 1 min(15rem, 26vw); + min-width: 6rem; + gap: 0; + padding-left: 0.6rem; + overflow: hidden; +} + +.content-search-label { + flex: 0 0 auto; + padding-right: 0.4rem; + color: var(--color-muted); + font-size: 0.78rem; + white-space: nowrap; + border-right: 1px solid var(--color-border); + cursor: text; +} + +#content-search-input { + height: 100%; + width: 100%; + min-width: 0; + padding: 0.45rem 0.55rem; + border: none; + background: transparent; + box-shadow: none; + font-size: 0.92rem; + overflow: hidden; +} + +#content-search-input:focus { + outline: none; +} + +#content-search-input::placeholder { + color: var(--color-muted); +} + +#content-search-input:disabled { + cursor: not-allowed; + font-style: italic; +} + +/* Cierra el campo de carpeta; el icono de carpeta ahora abre el selector del sistema. */ +.directory-scope-close { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 1.1rem; + width: 1.1rem; + height: var(--search-segment-height); + padding: 0; + background: transparent; + color: var(--color-muted); + cursor: pointer; +} + +.directory-scope-close svg { + width: 0.62rem; + height: 0.62rem; + fill: none; + stroke: currentColor; + stroke-width: 2.4; + stroke-linecap: round; + stroke-linejoin: round; +} + +.directory-scope-close:hover { + color: var(--color-text); +} + /* === Search Bar === */ .search-container { margin: var(--container-padding) var(--container-padding) 0; @@ -356,6 +448,57 @@ main, font-size: 0.78rem; } +/* Native +
+ {fileTypeEnabled ? ( + + ) : null}