From 65e40833b719fa3cd4737dd5c56c7b896e377170 Mon Sep 17 00:00:00 2001
From: Mohamad Yahia
Date: Sun, 22 Feb 2026 23:56:30 +0400
Subject: [PATCH 01/16] feat: Implement .gitignore-style ignore patterns for
Preferences
- Added support for .gitignore-style matching semantics in ignore paths.
- Introduced a new IgnoreMatcher to handle ignore patterns and their evaluation.
- Updated the useIgnorePaths hook to include a comprehensive set of default ignore paths.
- Enhanced validation and error messages for ignore paths in the UI.
- Implemented tests to verify the behavior of ignore patterns, including negation and directory pruning.
- Documented ignore pattern semantics and requirements in new markdown files.
---
Cargo.lock | 68 ++++++++
cardinal/src-tauri/Cargo.lock | 40 +++++
cardinal/src-tauri/src/commands.rs | 114 ++++++++++--
cardinal/src-tauri/src/lib.rs | 2 +-
cardinal/src/App.css | 33 ++++
.../src/components/PreferencesOverlay.tsx | 22 ++-
.../__tests__/PreferencesOverlay.test.tsx | 64 +++++++
.../hooks/__tests__/useIgnorePaths.test.ts | 29 +++-
cardinal/src/hooks/useIgnorePaths.ts | 74 +++++++-
cardinal/src/i18n/resources/ar-SA.json | 2 +-
cardinal/src/i18n/resources/en-US.json | 4 +-
.../src/utils/__tests__/watchRoot.test.ts | 23 ++-
cardinal/src/utils/watchRoot.ts | 4 +
doc/inner/SUMMARY.md | 2 +
doc/inner/ignore-patterns.md | 162 ++++++++++++++++++
doc/inner/ignore-requirements-implemented.md | 89 ++++++++++
fswalk/Cargo.toml | 1 +
fswalk/src/lib.rs | 113 ++++++++++--
fswalk/tests/deep_walk.rs | 162 +++++++++++++++++-
search-cache/src/cache.rs | 159 +++++++++++++++--
.../tests/fsevent_incremental_tests.rs | 147 +++++++++++++++-
21 files changed, 1237 insertions(+), 77 deletions(-)
create mode 100644 doc/inner/ignore-patterns.md
create mode 100644 doc/inner/ignore-requirements-implemented.md
diff --git a/Cargo.lock b/Cargo.lock
index 6af61aba..60815471 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -118,6 +118,16 @@ dependencies = [
"objc2",
]
+[[package]]
+name = "bstr"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
+dependencies = [
+ "memchr",
+ "serde",
+]
+
[[package]]
name = "bumpalo"
version = "3.19.0"
@@ -414,6 +424,7 @@ name = "fswalk"
version = "0.1.0"
dependencies = [
"enumn",
+ "ignore",
"memchr",
"rayon",
"serde",
@@ -439,6 +450,19 @@ dependencies = [
"wasip2",
]
+[[package]]
+name = "globset"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
+dependencies = [
+ "aho-corasick",
+ "bstr",
+ "log",
+ "regex-automata",
+ "regex-syntax",
+]
+
[[package]]
name = "hash32"
version = "0.2.1"
@@ -505,6 +529,22 @@ dependencies = [
"cc",
]
+[[package]]
+name = "ignore"
+version = "0.4.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a"
+dependencies = [
+ "crossbeam-deque",
+ "globset",
+ "log",
+ "memchr",
+ "regex-automata",
+ "same-file",
+ "walkdir",
+ "winapi-util",
+]
+
[[package]]
name = "indexmap"
version = "2.12.1"
@@ -1195,6 +1235,15 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -1562,6 +1611,16 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
[[package]]
name = "was"
version = "0.1.0"
@@ -1642,6 +1701,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys",
+]
+
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
diff --git a/cardinal/src-tauri/Cargo.lock b/cardinal/src-tauri/Cargo.lock
index 99a28a31..e1494eec 100644
--- a/cardinal/src-tauri/Cargo.lock
+++ b/cardinal/src-tauri/Cargo.lock
@@ -322,6 +322,16 @@ dependencies = [
"alloc-stdlib",
]
+[[package]]
+name = "bstr"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
+dependencies = [
+ "memchr",
+ "serde",
+]
+
[[package]]
name = "bumpalo"
version = "3.19.0"
@@ -1195,6 +1205,7 @@ name = "fswalk"
version = "0.1.0"
dependencies = [
"enumn",
+ "ignore",
"memchr",
"rayon",
"serde",
@@ -1561,6 +1572,19 @@ dependencies = [
"xkeysym",
]
+[[package]]
+name = "globset"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
+dependencies = [
+ "aho-corasick",
+ "bstr",
+ "log",
+ "regex-automata",
+ "regex-syntax",
+]
+
[[package]]
name = "gobject-sys"
version = "0.18.0"
@@ -1928,6 +1952,22 @@ dependencies = [
"icu_properties",
]
+[[package]]
+name = "ignore"
+version = "0.4.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a"
+dependencies = [
+ "crossbeam-deque",
+ "globset",
+ "log",
+ "memchr",
+ "regex-automata",
+ "same-file",
+ "walkdir",
+ "winapi-util",
+]
+
[[package]]
name = "indexmap"
version = "1.9.3"
diff --git a/cardinal/src-tauri/src/commands.rs b/cardinal/src-tauri/src/commands.rs
index 92878e85..164a9fa4 100644
--- a/cardinal/src-tauri/src/commands.rs
+++ b/cardinal/src-tauri/src/commands.rs
@@ -142,12 +142,7 @@ impl SearchState {
}
}
-/// Normalizes user-provided path input into an absolute path string.
-///
-/// Expands a leading `~` component using the current `HOME` directory and rejects
-/// non-absolute paths (including relative paths and unsupported `~user` forms).
-/// Returns `Some` absolute path string when valid, otherwise `None`.
-fn normalize_path_input(raw: &str) -> Option {
+fn expand_path_input(raw: &str) -> Option {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
@@ -167,12 +162,25 @@ fn normalize_path_input(raw: &str) -> Option {
}
}
- let resolved = expanded.into_string();
- if resolved.starts_with('/') {
- Some(resolved)
- } else {
- None
- }
+ Some(expanded.into_string())
+}
+
+/// Normalizes user-provided path input into an absolute path string.
+///
+/// Expands a leading `~` component using the current `HOME` directory and rejects
+/// non-absolute paths (including relative paths and unsupported `~user` forms).
+/// Returns `Some` absolute path string when valid, otherwise `None`.
+fn normalize_path_input(raw: &str) -> Option {
+ let resolved = expand_path_input(raw)?;
+ resolved.starts_with('/').then_some(resolved)
+}
+
+/// Normalizes ignore entries.
+///
+/// Keeps raw entries so matching follows gitignore semantics exactly,
+/// including empty lines and comments.
+fn normalize_ignore_path_input(raw: &str) -> String {
+ raw.to_string()
}
pub(crate) fn normalize_watch_config(
@@ -184,13 +192,7 @@ pub(crate) fn normalize_watch_config(
.or_else(|| fallback_watch_root.and_then(normalize_path_input))?;
let mut ignore_paths = ignore_paths
.into_iter()
- .filter_map(|path| {
- let normalized = normalize_path_input(&path);
- if normalized.is_none() {
- warn!("Ignoring invalid ignore path: {path:?}");
- }
- normalized
- })
+ .map(|path| normalize_ignore_path_input(&path))
.collect::>();
if !ignore_paths
.iter()
@@ -540,4 +542,78 @@ mod tests {
assert_eq!(normalize_path_input("~someone"), None);
assert_eq!(normalize_path_input("~someone/Documents"), None);
}
+
+ #[test]
+ fn normalize_ignore_accepts_absolute_paths_and_globs() {
+ assert_eq!(
+ normalize_ignore_path_input("/tmp/cache"),
+ "/tmp/cache".to_string()
+ );
+ assert_eq!(
+ normalize_ignore_path_input("**/node_modules/**"),
+ "**/node_modules/**".to_string()
+ );
+ assert_eq!(
+ normalize_ignore_path_input("build/*.tmp"),
+ "build/*.tmp".to_string()
+ );
+ assert_eq!(normalize_ignore_path_input(".cache"), ".cache".to_string());
+ assert_eq!(
+ normalize_ignore_path_input("Library/Biome/"),
+ "Library/Biome/".to_string()
+ );
+ }
+
+ #[test]
+ fn normalize_ignore_keeps_gitignore_syntax_verbatim() {
+ assert_eq!(
+ normalize_ignore_path_input("!/important.pyc"),
+ "!/important.pyc".to_string()
+ );
+ assert_eq!(
+ normalize_ignore_path_input("# comment"),
+ "# comment".to_string()
+ );
+ assert_eq!(
+ normalize_ignore_path_input("relative/path"),
+ "relative/path".to_string()
+ );
+ assert_eq!(
+ normalize_ignore_path_input("~someone/**"),
+ "~someone/**".to_string()
+ );
+ assert_eq!(
+ normalize_ignore_path_input("../relative/path"),
+ "../relative/path".to_string()
+ );
+ }
+
+ #[test]
+ fn normalize_ignore_keeps_empty_and_whitespace_entries() {
+ assert_eq!(normalize_ignore_path_input(""), String::new());
+ assert_eq!(normalize_ignore_path_input(" "), " ".to_string());
+ }
+
+ #[test]
+ fn normalize_watch_config_keeps_globs_and_adds_default_ignore_path() {
+ let Some((watch_root, ignore_paths)) = normalize_watch_config(
+ "/",
+ vec![
+ "**/node_modules/**".to_string(),
+ String::new(),
+ "relative/path".to_string(),
+ "/tmp/cache".to_string(),
+ ],
+ None,
+ ) else {
+ panic!("watch config should be valid");
+ };
+
+ assert_eq!(watch_root, "/");
+ assert!(ignore_paths.contains(&"**/node_modules/**".to_string()));
+ assert!(ignore_paths.contains(&String::new()));
+ assert!(ignore_paths.contains(&"relative/path".to_string()));
+ assert!(ignore_paths.contains(&"/tmp/cache".to_string()));
+ assert!(ignore_paths.contains(&DEFAULT_SYSTEM_IGNORE_PATH.to_string()));
+ }
}
diff --git a/cardinal/src-tauri/src/lib.rs b/cardinal/src-tauri/src/lib.rs
index dc362094..01079e5c 100644
--- a/cardinal/src-tauri/src/lib.rs
+++ b/cardinal/src-tauri/src/lib.rs
@@ -37,7 +37,7 @@ use window_controls::{activate_window, hide_window};
static DB_PATH: OnceCell = OnceCell::new();
pub(crate) static LOGIC_START: OnceCell> = OnceCell::new();
-pub(crate) const DEFAULT_SYSTEM_IGNORE_PATH: &str = "/System/Volumes/Data";
+pub(crate) const DEFAULT_SYSTEM_IGNORE_PATH: &str = "/System/Volumes/";
const FSE_LATENCY_SECS: f64 = 0.1;
#[derive(Debug, Clone)]
diff --git a/cardinal/src/App.css b/cardinal/src/App.css
index cbde9aa9..e753836f 100644
--- a/cardinal/src/App.css
+++ b/cardinal/src/App.css
@@ -1675,6 +1675,13 @@ button:active:not(:disabled) {
text-align: right;
}
+.preferences-control--stack {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ text-align: left;
+}
+
.preferences-field {
border: 1px solid var(--color-elevated-border, var(--color-border));
border-radius: 8px;
@@ -1712,6 +1719,32 @@ button:active:not(:disabled) {
margin-bottom: 0;
}
+.preferences-ignore-reset {
+ margin-top: 8px;
+ border: 1px solid var(--color-elevated-border, var(--color-border));
+ background: transparent;
+ color: var(--color-text-secondary);
+ border-radius: 8px;
+ padding: 6px 10px;
+ font-size: 0.82rem;
+ cursor: pointer;
+ transition:
+ border-color 0.2s ease,
+ color 0.2s ease,
+ background-color 0.2s ease;
+}
+
+.preferences-ignore-reset:hover {
+ border-color: var(--color-accent);
+ color: var(--color-text);
+ background: rgba(var(--color-accent-rgb), 0.08);
+}
+
+.preferences-ignore-reset:focus-visible {
+ outline: 2px solid rgba(var(--color-accent-rgb), 0.35);
+ outline-offset: 2px;
+}
+
.preferences-field:focus {
outline: 2px solid rgba(var(--color-accent-rgb), 0.35);
border-color: var(--color-accent);
diff --git a/cardinal/src/components/PreferencesOverlay.tsx b/cardinal/src/components/PreferencesOverlay.tsx
index 99bd3fed..6bf208f9 100644
--- a/cardinal/src/components/PreferencesOverlay.tsx
+++ b/cardinal/src/components/PreferencesOverlay.tsx
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { getWatchRootValidation, isPathInputValid } from '../utils/watchRoot';
+import { getWatchRootValidation, isIgnorePathInputValid } from '../utils/watchRoot';
import ThemeSwitcher from './ThemeSwitcher';
import LanguageSwitcher from './LanguageSwitcher';
@@ -105,12 +105,9 @@ export function PreferencesOverlay({
}
};
- const parsedIgnorePaths = ignorePathsInput
- .split(/\r?\n/)
- .map((line) => line.trim())
- .filter((line) => line.length > 0);
+ const parsedIgnorePaths = ignorePathsInput.split(/\r?\n/);
const ignorePathsErrorMessage = (() => {
- const invalid = parsedIgnorePaths.find((line) => !isPathInputValid(line));
+ const invalid = parsedIgnorePaths.find((line) => !isIgnorePathInputValid(line));
return invalid ? t('ignorePaths.errors.absolute') : null;
})();
@@ -120,6 +117,10 @@ export function PreferencesOverlay({
}
};
+ const handleResetIgnorePaths = (): void => {
+ setIgnorePathsInput(defaultIgnorePaths.join('\n'));
+ };
+
const handleSave = (): void => {
if (watchRootErrorMessage || ignorePathsErrorMessage) {
return;
@@ -235,7 +236,7 @@ export function PreferencesOverlay({
{t('ignorePaths.label')}
-
diff --git a/cardinal/src/components/__tests__/PreferencesOverlay.test.tsx b/cardinal/src/components/__tests__/PreferencesOverlay.test.tsx
index 5331cb89..0153055e 100644
--- a/cardinal/src/components/__tests__/PreferencesOverlay.test.tsx
+++ b/cardinal/src/components/__tests__/PreferencesOverlay.test.tsx
@@ -66,6 +66,70 @@ describe('PreferencesOverlay', () => {
});
});
+ it('accepts glob ignore path updates via onWatchConfigChange', () => {
+ const onWatchConfigChange = vi.fn();
+ render();
+
+ const ignorePathsInput = screen.getByLabelText('ignorePaths.label');
+ fireEvent.change(ignorePathsInput, { target: { value: '**/node_modules/**' } });
+
+ fireEvent.click(screen.getByText('preferences.save'));
+
+ expect(onWatchConfigChange).toHaveBeenCalledWith({
+ watchRoot: baseProps.watchRoot,
+ ignorePaths: ['**/node_modules/**'],
+ });
+ });
+
+ it('accepts relative literal ignore path updates via onWatchConfigChange', () => {
+ const onWatchConfigChange = vi.fn();
+ render();
+
+ const ignorePathsInput = screen.getByLabelText('ignorePaths.label');
+ fireEvent.change(ignorePathsInput, { target: { value: '.cache\n__pycache__' } });
+
+ fireEvent.click(screen.getByText('preferences.save'));
+
+ expect(onWatchConfigChange).toHaveBeenCalledWith({
+ watchRoot: baseProps.watchRoot,
+ ignorePaths: ['.cache', '__pycache__'],
+ });
+ });
+
+ it('preserves blank and whitespace-only ignore lines on save', () => {
+ const onWatchConfigChange = vi.fn();
+ render();
+
+ const ignorePathsInput = screen.getByLabelText('ignorePaths.label');
+ fireEvent.change(ignorePathsInput, { target: { value: '/tmp/one\n\n \n# comment' } });
+
+ fireEvent.click(screen.getByText('preferences.save'));
+
+ expect(onWatchConfigChange).toHaveBeenCalledWith({
+ watchRoot: baseProps.watchRoot,
+ ignorePaths: ['/tmp/one', '', ' ', '# comment'],
+ });
+ });
+
+ it('resets only the ignore textarea to default list', () => {
+ const onWatchConfigChange = vi.fn();
+ render(
+ ,
+ );
+
+ const ignorePathsInput = screen.getByLabelText('ignorePaths.label');
+ fireEvent.change(ignorePathsInput, { target: { value: '/tmp/one\n/tmp/two' } });
+
+ fireEvent.click(screen.getByText('Reset ignores list'));
+
+ expect(ignorePathsInput).toHaveValue('# group\n/default/one\n\n/default/two');
+ expect(onWatchConfigChange).not.toHaveBeenCalled();
+ });
+
it('resets inputs to defaults before invoking onReset', () => {
const onReset = vi.fn();
const onWatchConfigChange = vi.fn();
diff --git a/cardinal/src/hooks/__tests__/useIgnorePaths.test.ts b/cardinal/src/hooks/__tests__/useIgnorePaths.test.ts
index faaa213b..af0e20a6 100644
--- a/cardinal/src/hooks/__tests__/useIgnorePaths.test.ts
+++ b/cardinal/src/hooks/__tests__/useIgnorePaths.test.ts
@@ -18,13 +18,13 @@ describe('useIgnorePaths', () => {
vi.restoreAllMocks();
});
- it('hydrates from stored values and filters invalid entries', async () => {
+ it('hydrates from stored values and preserves blank entries', async () => {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify([' /tmp ', '', 42, ' ', '/var']));
const setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
const { result } = renderHook(() => useIgnorePaths());
- expect(result.current.ignorePaths).toEqual(['/tmp', '/var']);
+ expect(result.current.ignorePaths).toEqual([' /tmp ', '', ' ', '/var']);
await flushEffects();
@@ -46,20 +46,32 @@ describe('useIgnorePaths', () => {
);
});
- it('keeps an empty stored array without writing defaults', async () => {
+ it('ships grouped defaults with comments and blank separators', () => {
+ const { result } = renderHook(() => useIgnorePaths());
+ const defaults = result.current.defaultIgnorePaths;
+
+ expect(defaults[0]).toBe('# Root-anchored system paths');
+ expect(defaults).toContain('');
+ expect(defaults).toContain('# Common project/build caches');
+ expect(defaults).toContain('# Application-specific heavy caches');
+ expect(defaults).toContain('# Basename folders to ignore anywhere');
+ expect(defaults).toContain('# File patterns');
+ });
+
+ it('keeps a whitespace-only stored array without writing defaults', async () => {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(['', ' ']));
const setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
const { result } = renderHook(() => useIgnorePaths());
- expect(result.current.ignorePaths).toEqual([]);
+ expect(result.current.ignorePaths).toEqual(['', ' ']);
await flushEffects();
expect(setItemSpy).not.toHaveBeenCalled();
});
- it('cleans and persists updates', async () => {
+ it('preserves pattern text including blank entries and persists updates', async () => {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify([]));
const setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
@@ -71,8 +83,11 @@ describe('useIgnorePaths', () => {
result.current.setIgnorePaths([' /tmp ', '', '/var', ' ']);
});
- expect(result.current.ignorePaths).toEqual(['/tmp', '/var']);
- expect(setItemSpy).toHaveBeenCalledWith(STORAGE_KEY, JSON.stringify(['/tmp', '/var']));
+ expect(result.current.ignorePaths).toEqual([' /tmp ', '', '/var', ' ']);
+ expect(setItemSpy).toHaveBeenCalledWith(
+ STORAGE_KEY,
+ JSON.stringify([' /tmp ', '', '/var', ' ']),
+ );
});
it('falls back to defaults when stored JSON is invalid', async () => {
diff --git a/cardinal/src/hooks/useIgnorePaths.ts b/cardinal/src/hooks/useIgnorePaths.ts
index 6109854d..74412893 100644
--- a/cardinal/src/hooks/useIgnorePaths.ts
+++ b/cardinal/src/hooks/useIgnorePaths.ts
@@ -2,10 +2,73 @@ import { useCallback } from 'react';
import { useStoredState } from './useStoredState';
const STORAGE_KEY = 'cardinal.ignorePaths';
-const DEFAULT_IGNORE_PATHS = ['/Volumes'];
+const DEFAULT_IGNORE_PATHS = [
+ '# Root-anchored system paths',
+ '/Volumes/',
+ '/cores/',
+ '/dev/',
+ '/private/',
+ '/System/Applications/**/Contents/Resources/',
+ '/System/Volumes/',
+ '/usr/share/',
+ '/xarts/',
-const cleanPaths = (next: string[]): string[] =>
- next.map((item) => item.trim()).filter((item) => item.length > 0);
+ '',
+ '# Common project/build caches',
+ 'node_modules/',
+ '.next/',
+ '.bun/',
+ '.pnpm/',
+ '**/.local/fsindex*',
+
+ '',
+ '# Application-specific heavy caches',
+ '**/com.docker.docker/Data/',
+ '**/Firefox/Profiles/**/sessionstore-backups/',
+ '**/Firefox/Profiles/**/storage/default/',
+ '**/Firefox/Profiles/**/storage/permanent/',
+ '**/Google/Chrome*/Cache/',
+ '**/Google/Chrome*/leveldb/',
+ '**/IconJar*/Backups/',
+ '**/Sublime Text */Index/',
+ '**/var/postgres/base/',
+ '**/var/postgres/pg_stat_tmp/',
+ '**/var/postgres/pg_wal/',
+ '**/Spotify/Users/*/pending-messages*',
+
+ '',
+ '# Root user-library indexing data',
+ '/Library/Biome/',
+ '/Library/DuetExpertCenter/',
+
+ '',
+ '# Basename folders to ignore anywhere',
+ '.cache/',
+ '.cocoapods/',
+ '.git/',
+ '.opam/',
+ '__pycache__/',
+ 'Cache/',
+ 'Caches/',
+ 'doc/',
+ 'Xcode.app/',
+ 'wharf/',
+ 'Index.noindex/',
+ 'TextIndex/',
+ 'io.tailscale.ipn.macos/',
+ '.stversions/',
+
+ '',
+ '# File patterns',
+ '*.com.google.Chrome',
+ '*.pyc',
+ '.dat.nosync*',
+ 'webappsstore.sqlite-wal',
+ '.DS_Store',
+];
+
+const keepStringEntries = (next: unknown[]): string[] =>
+ next.filter((item): item is string => typeof item === 'string');
export function useIgnorePaths() {
const [ignorePaths, setIgnorePathsState] = useStoredState({
@@ -14,7 +77,7 @@ export function useIgnorePaths() {
read: (raw) => {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return null;
- return cleanPaths(parsed.filter((item): item is string => typeof item === 'string'));
+ return keepStringEntries(parsed);
},
write: (value) => JSON.stringify(value),
readErrorMessage: 'Unable to read saved ignore paths',
@@ -23,8 +86,7 @@ export function useIgnorePaths() {
const setIgnorePaths = useCallback(
(next: string[]) => {
- const cleaned = cleanPaths(next);
- setIgnorePathsState(cleaned);
+ setIgnorePathsState(next);
},
[setIgnorePathsState],
);
diff --git a/cardinal/src/i18n/resources/ar-SA.json b/cardinal/src/i18n/resources/ar-SA.json
index 756e2235..e56b560f 100644
--- a/cardinal/src/i18n/resources/ar-SA.json
+++ b/cardinal/src/i18n/resources/ar-SA.json
@@ -164,7 +164,7 @@
"label": "مسارات متجاهلة",
"help": "مسار واحد في كل سطر؛ المسارات المتجاهلة يتم تخطيها أثناء الفهرسة.",
"errors": {
- "absolute": "يجب أن يبدأ كل مسار بـ '/' أو '~'."
+ "absolute": "يرجى إدخال نمط مسار glob غير فارغ."
}
}
}
diff --git a/cardinal/src/i18n/resources/en-US.json b/cardinal/src/i18n/resources/en-US.json
index 4259994c..cdaf9e26 100644
--- a/cardinal/src/i18n/resources/en-US.json
+++ b/cardinal/src/i18n/resources/en-US.json
@@ -162,9 +162,9 @@
},
"ignorePaths": {
"label": "Ignore paths",
- "help": "One path per line; ignored paths are skipped during indexing.",
+ "help": "One glob style pattern per line (Use .gitignore patterns like node_modules, **/node_modules/**, !keep.txt).",
"errors": {
- "absolute": "Each path must start with '/' or '~'."
+ "absolute": "Please enter a non-empty glob path pattern."
}
}
}
diff --git a/cardinal/src/utils/__tests__/watchRoot.test.ts b/cardinal/src/utils/__tests__/watchRoot.test.ts
index af1af536..cde54bf6 100644
--- a/cardinal/src/utils/__tests__/watchRoot.test.ts
+++ b/cardinal/src/utils/__tests__/watchRoot.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { getWatchRootValidation, isPathInputValid } from '../watchRoot';
+import { getWatchRootValidation, isIgnorePathInputValid, isPathInputValid } from '../watchRoot';
describe('isPathInputValid', () => {
it('rejects empty or whitespace-only inputs', () => {
@@ -71,3 +71,24 @@ describe('getWatchRootValidation', () => {
});
});
});
+
+describe('isIgnorePathInputValid', () => {
+ it('accepts gitignore-style entries', () => {
+ expect(isIgnorePathInputValid('/tmp/cache')).toBe(true);
+ expect(isIgnorePathInputValid('~/Library/Caches')).toBe(true);
+ expect(isIgnorePathInputValid('**/node_modules/**')).toBe(true);
+ expect(isIgnorePathInputValid('build/*.tmp')).toBe(true);
+ expect(isIgnorePathInputValid('.cache')).toBe(true);
+ expect(isIgnorePathInputValid('__pycache__')).toBe(true);
+ expect(isIgnorePathInputValid('relative/path')).toBe(true);
+ expect(isIgnorePathInputValid('./relative/path')).toBe(true);
+ expect(isIgnorePathInputValid('Library/Biome/')).toBe(true);
+ expect(isIgnorePathInputValid('!/keep.me')).toBe(true);
+ expect(isIgnorePathInputValid('# comment')).toBe(true);
+ });
+
+ it('accepts empty and whitespace-only entries for formatting', () => {
+ expect(isIgnorePathInputValid('')).toBe(true);
+ expect(isIgnorePathInputValid(' ')).toBe(true);
+ });
+});
diff --git a/cardinal/src/utils/watchRoot.ts b/cardinal/src/utils/watchRoot.ts
index 1734b37a..032f7977 100644
--- a/cardinal/src/utils/watchRoot.ts
+++ b/cardinal/src/utils/watchRoot.ts
@@ -5,6 +5,10 @@ export const isPathInputValid = (input: string): boolean => {
return trimmed === '~' || trimmed.startsWith('~/');
};
+export const isIgnorePathInputValid = (_input: string): boolean => {
+ return true;
+};
+
type WatchRootValidation = {
isValid: boolean;
errorKey: 'watchRoot.errors.required' | 'watchRoot.errors.absolute' | null;
diff --git a/doc/inner/SUMMARY.md b/doc/inner/SUMMARY.md
index 9b0e34d4..5a396139 100644
--- a/doc/inner/SUMMARY.md
+++ b/doc/inner/SUMMARY.md
@@ -9,6 +9,8 @@
- [UI Dataflow](ui-dataflow.md)
- [FS Events SDK](fs-events-sdk.md)
- [FSWalk](fswalk.md)
+- [Ignore Patterns](ignore-patterns.md)
+- [Ignore Requirements Implemented](ignore-requirements-implemented.md)
- [FS Icon](fs-icon.md)
- [Search Cancellation](search-cancellation.md)
- [NamePool](name-pool.md)
diff --git a/doc/inner/ignore-patterns.md b/doc/inner/ignore-patterns.md
new file mode 100644
index 00000000..957c9115
--- /dev/null
+++ b/doc/inner/ignore-patterns.md
@@ -0,0 +1,162 @@
+# Ignore Patterns (`.gitignore` Semantics)
+
+This document describes how Cardinal interprets ignore patterns from Preferences.
+
+The behavior is intended to follow `.gitignore` pattern semantics for matching rules.
+
+---
+
+## Root Definition
+
+Ignore patterns are evaluated relative to the configured **Monitor root path** (`watchRoot`).
+
+Think of it as:
+- One virtual `.gitignore` file located at the watch root.
+- All preferences ignore lines are entries in that one file.
+
+Examples:
+- If watch root is `/`, pattern `/Volumes` matches `/Volumes`.
+- If watch root is `/Users/madda`, pattern `/Library` matches `/Users/madda/Library` (not system `/Library`).
+
+---
+
+## Input Source
+
+Ignore rules come from the Preferences textarea, one line per rule.
+
+Current behavior:
+- Empty/whitespace-only lines are ignored.
+- Non-empty lines are preserved and interpreted using `.gitignore` rule syntax.
+
+---
+
+## Matching Rules
+
+The following semantics apply:
+
+1. Order matters: last matching rule wins.
+2. `#` starts a comment.
+3. `!` negates (unignores) a previous ignore match.
+4. Leading `/` anchors to the watch root.
+5. Trailing `/` means directory-only match.
+6. No `/` in a pattern means basename-style match anywhere under the watch root.
+7. `*`, `?`, `[abc]`, and `**` behave like `.gitignore`.
+
+Important example:
+- `**/Xcode.app/**` ignores descendants of `Xcode.app`, but not `Xcode.app` itself.
+- `/Xcode.app/` ignores `Xcode.app` and everything inside it (when anchored at root).
+- `Xcode.app` (no slash) matches `Xcode.app` directories/files by basename anywhere.
+
+---
+
+## Common Pattern Examples
+
+1. Ignore any `node_modules` directory and its contents:
+`node_modules`
+
+2. Ignore descendants inside any `Xcode.app` bundle, but keep the bundle directory node:
+`**/Xcode.app/**`
+
+3. Ignore only root-level `build` under watch root:
+`/build/`
+
+4. Ignore all `.pyc` files:
+`*.pyc`
+
+5. Ignore all `.DS_Store` files:
+`.DS_Store`
+
+6. Ignore everything in `.cache`, then re-include one file:
+`.cache/`
+`!.cache/keep.me`
+
+---
+
+## Notes on Scope
+
+Cardinal currently uses a single global ruleset (from Preferences) rooted at `watchRoot`.
+
+This means:
+- Pattern syntax matches `.gitignore` semantics.
+- Cardinal does **not** recursively read `.gitignore` files from each directory in the filesystem tree.
+
+---
+
+## Built-in Default Rules
+
+Cardinal ships with the following default ignore entries:
+
+```gitignore
+# Root-anchored system paths
+/Volumes/
+/cores/
+/dev/
+/private/
+/System/Applications/**/Contents/Resources/
+/System/Volumes/
+/usr/share/
+/xarts/
+
+# Common project/build caches
+node_modules/
+.next/
+.bun/
+.pnpm/
+**/.local/fsindex*
+
+# Application-specific heavy caches
+**/com.docker.docker/Data/
+**/Firefox/Profiles/**/sessionstore-backups/
+**/Firefox/Profiles/**/storage/default/
+**/Firefox/Profiles/**/storage/permanent/
+**/Google/Chrome*/Cache/
+**/Google/Chrome*/leveldb/
+**/IconJar*/Backups/
+**/Sublime Text */Index/
+**/var/postgres/base/
+**/var/postgres/pg_stat_tmp/
+**/var/postgres/pg_wal/
+**/Spotify/Users/*/pending-messages*
+
+# Root user-library indexing data
+/Library/Biome/
+/Library/DuetExpertCenter/
+
+# Basename folders to ignore anywhere
+.cache/
+.cocoapods/
+.git/
+.opam/
+__pycache__/
+Cache/
+Caches/
+doc/
+Xcode.app/
+wharf/
+Index.noindex/
+TextIndex/
+io.tailscale.ipn.macos/
+.stversions/
+
+# File patterns
+*.com.google.Chrome
+*.pyc
+.dat.nosync*
+webappsstore.sqlite-wal
+.DS_Store
+```
+
+These defaults are used for first run and reset behavior.
+
+---
+
+## Practical Guidance
+
+1. Prefer basename rules for common folders:
+`node_modules`, `.git`, `__pycache__`, `.cache`
+
+2. Use `/...` when you want strict root anchoring.
+
+3. Use `**/.../**` when you specifically want descendants but not the parent directory node.
+
+4. Use negation (`!`) carefully: if a parent directory is excluded, you may need to unignore parent paths as well, same as `.gitignore`.
diff --git a/doc/inner/ignore-requirements-implemented.md b/doc/inner/ignore-requirements-implemented.md
new file mode 100644
index 00000000..3104cdad
--- /dev/null
+++ b/doc/inner/ignore-requirements-implemented.md
@@ -0,0 +1,89 @@
+# Ignore Requirements Implemented
+
+This document summarizes the final ignore-pattern requirements implemented in this branch and reflected in current git changes.
+
+## High-Level Requirements
+
+1. `.gitignore`-style matching semantics were implemented for Preferences ignore rules.
+2. Negation (`!`) is supported with the same practical limitation as `.gitignore`:
+ - A file cannot be re-included if an ancestor directory was ignored and pruned.
+3. Ignore matching is rooted to the configured `watchRoot` and remains consistent across:
+ - Initial full indexing
+ - Full rescans
+ - Incremental (FSEvents-driven) rescans
+4. Ignore evaluation is performance-oriented:
+ - Ignored directories are pruned early (no subtree walk/read_dir for descendants).
+ - Compiled ignore matcher state is reused instead of rebuilt on each incremental rescan.
+
+## Technical Implementation Details
+
+### Matching Engine and Semantics
+
+- Switched ignore evaluation to `ignore::gitignore` rules and matching behavior.
+- Rules are matched against paths relative to watch root.
+- Matching checks both the path and its parents.
+
+Relevant files:
+- `fswalk/src/lib.rs`
+- `fswalk/Cargo.toml`
+
+### Negation Behavior and Pruned-Parent Limitation
+
+- Negation rules (`!`) are supported.
+- Incremental rescans now enforce the same pruned-parent behavior as full scans:
+ - If an ancestor directory is ignored, descendant paths are not indexed even if a child rule negates them.
+
+Relevant files:
+- `search-cache/src/cache.rs`
+- `fswalk/tests/deep_walk.rs`
+- `search-cache/tests/fsevent_incremental_tests.rs`
+
+### Compile Once, Reuse Always
+
+- Added reusable compiled matcher plumbing via `Arc`.
+- `SearchCache` stores a compiled matcher and reuses it for:
+ - Incremental subtree scans
+ - Full rescans with reused `WalkData`
+
+Relevant files:
+- `fswalk/src/lib.rs`
+- `search-cache/src/cache.rs`
+
+### Early Directory Pruning
+
+- The walker checks ignore status with directory/file context and prunes ignored directories before traversal.
+- This avoids unnecessary `read_dir` and descendant work for ignored subtrees.
+
+Relevant files:
+- `fswalk/src/lib.rs`
+
+### Root-Consistent Matching in Incremental Flow
+
+- Incremental rescans use matcher state rooted at watch root.
+- Added protection to skip incremental scan paths outside watch root.
+
+Relevant files:
+- `search-cache/src/cache.rs`
+
+### UI/Input Pipeline Alignment
+
+- Ignore lines are preserved as raw `.gitignore`-style lines (non-empty), not auto-expanded into custom glob wrappers.
+- Validation/help text now aligns with `.gitignore` expectations.
+
+Relevant files:
+- `cardinal/src-tauri/src/commands.rs`
+- `cardinal/src/utils/watchRoot.ts`
+- `cardinal/src/components/PreferencesOverlay.tsx`
+- `cardinal/src/hooks/useIgnorePaths.ts`
+- `cardinal/src/i18n/resources/en-US.json`
+
+### Verification in Current Git Changes
+
+The current branch includes tests that lock in the implemented requirements:
+
+- `.gitignore` semantics and glob behavior:
+ - `fswalk/tests/deep_walk.rs`
+- Incremental rescan behavior with ignores/negation:
+ - `search-cache/tests/fsevent_incremental_tests.rs`
+
+The implementation and tests are present in modified tracked files visible in `git status`.
diff --git a/fswalk/Cargo.toml b/fswalk/Cargo.toml
index 9d90bc3f..4b3e99a4 100644
--- a/fswalk/Cargo.toml
+++ b/fswalk/Cargo.toml
@@ -9,6 +9,7 @@ serde_repr = "0.1.0"
rayon = "1"
memchr = "2.7.4"
enumn = "0.1.14"
+ignore = "0.4"
[dev-dependencies]
tempdir = "0.3"
diff --git a/fswalk/src/lib.rs b/fswalk/src/lib.rs
index aaca64a1..b248f6f2 100644
--- a/fswalk/src/lib.rs
+++ b/fswalk/src/lib.rs
@@ -1,3 +1,4 @@
+use ignore::gitignore::{Gitignore, GitignoreBuilder};
use rayon::{iter::ParallelBridge, prelude::ParallelIterator};
use serde::{Deserialize, Serialize};
use serde_repr::{Deserialize_repr, Serialize_repr};
@@ -7,7 +8,10 @@ use std::{
num::NonZeroU64,
os::unix::fs::MetadataExt,
path::{Path, PathBuf},
- sync::atomic::{AtomicBool, AtomicUsize, Ordering},
+ sync::{
+ Arc,
+ atomic::{AtomicBool, AtomicUsize, Ordering},
+ },
time::UNIX_EPOCH,
};
@@ -80,6 +84,49 @@ impl From for NodeFileType {
}
}
+#[derive(Debug, Default)]
+pub struct IgnoreMatcher {
+ root_path: PathBuf,
+ gitignore: Option,
+}
+
+impl IgnoreMatcher {
+ pub fn new(root_path: &Path, ignore_directories: &[PathBuf]) -> Self {
+ if ignore_directories.is_empty() {
+ return Self {
+ root_path: root_path.to_path_buf(),
+ gitignore: None,
+ };
+ }
+
+ let mut builder = GitignoreBuilder::new(root_path);
+ for ignore in ignore_directories {
+ let pattern = ignore.to_string_lossy();
+ let _ = builder.add_line(None, &pattern);
+ }
+
+ let gitignore = builder.build().ok();
+ Self {
+ root_path: root_path.to_path_buf(),
+ gitignore,
+ }
+ }
+
+ pub fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
+ let Ok(candidate) = path.strip_prefix(&self.root_path) else {
+ return false;
+ };
+ self.gitignore
+ .as_ref()
+ .map(|gitignore| {
+ gitignore
+ .matched_path_or_any_parents(candidate, is_dir)
+ .is_ignore()
+ })
+ .unwrap_or(false)
+ }
+}
+
#[derive(Debug)]
pub struct WalkData<'w> {
pub num_files: AtomicUsize,
@@ -88,18 +135,20 @@ pub struct WalkData<'w> {
cancel: Option<&'w AtomicBool>,
pub root_path: &'w Path,
pub ignore_directories: &'w [PathBuf],
+ ignore_matcher: Arc,
/// If set, metadata will be collected for each file node(folder node will get free metadata).
need_metadata: bool,
}
impl<'w> WalkData<'w> {
- pub const fn simple(root_path: &'w Path, need_metadata: bool) -> Self {
+ pub fn simple(root_path: &'w Path, need_metadata: bool) -> Self {
Self {
num_files: AtomicUsize::new(0),
num_dirs: AtomicUsize::new(0),
cancel: None,
root_path,
ignore_directories: &[],
+ ignore_matcher: Arc::new(IgnoreMatcher::default()),
need_metadata,
}
}
@@ -109,6 +158,38 @@ impl<'w> WalkData<'w> {
ignore_directories: &'w [PathBuf],
need_metadata: bool,
cancel: Option<&'w AtomicBool>,
+ ) -> Self {
+ Self::new_with_ignore_root(
+ root_path,
+ root_path,
+ ignore_directories,
+ need_metadata,
+ cancel,
+ )
+ }
+
+ pub fn new_with_ignore_root(
+ root_path: &'w Path,
+ ignore_root_path: &'w Path,
+ ignore_directories: &'w [PathBuf],
+ need_metadata: bool,
+ cancel: Option<&'w AtomicBool>,
+ ) -> Self {
+ Self::new_with_ignore_matcher(
+ root_path,
+ ignore_directories,
+ Arc::new(IgnoreMatcher::new(ignore_root_path, ignore_directories)),
+ need_metadata,
+ cancel,
+ )
+ }
+
+ pub fn new_with_ignore_matcher(
+ root_path: &'w Path,
+ ignore_directories: &'w [PathBuf],
+ ignore_matcher: Arc,
+ need_metadata: bool,
+ cancel: Option<&'w AtomicBool>,
) -> Self {
Self {
num_files: AtomicUsize::new(0),
@@ -116,12 +197,17 @@ impl<'w> WalkData<'w> {
cancel,
root_path,
ignore_directories,
+ ignore_matcher,
need_metadata,
}
}
- fn should_ignore(&self, path: &Path) -> bool {
- self.ignore_directories.iter().any(|ignore| ignore == path)
+ pub fn ignore_matcher(&self) -> Arc {
+ Arc::clone(&self.ignore_matcher)
+ }
+
+ fn should_ignore(&self, path: &Path, is_dir: bool) -> bool {
+ self.ignore_matcher.is_ignored(path, is_dir)
}
}
@@ -165,11 +251,12 @@ pub fn walk_it(walk_data: &WalkData) -> Option {
}
fn walk(path: &Path, walk_data: &WalkData) -> Option {
- if walk_data.should_ignore(path) {
+ let metadata = metadata_of_path(path);
+ let is_dir = metadata.as_ref().map(|x| x.is_dir()).unwrap_or_default();
+ if walk_data.should_ignore(path, is_dir) {
return None;
}
- let metadata = metadata_of_path(path);
- let children = if metadata.as_ref().map(|x| x.is_dir()).unwrap_or_default() {
+ let children = if is_dir {
walk_data.num_dirs.fetch_add(1, Ordering::Relaxed);
let read_dir = fs::read_dir(path);
match read_dir {
@@ -186,13 +273,15 @@ fn walk(path: &Path, walk_data: &WalkData) -> Option {
{
return None;
}
- if walk_data.should_ignore(path) {
- return None;
- }
+ let entry_path = entry.path();
// doesn't traverse symlink
if let Ok(data) = entry.file_type() {
- if data.is_dir() {
- return walk(&entry.path(), walk_data);
+ let is_dir = data.is_dir();
+ if walk_data.should_ignore(&entry_path, is_dir) {
+ return None;
+ }
+ if is_dir {
+ return walk(&entry_path, walk_data);
} else {
walk_data.num_files.fetch_add(1, Ordering::Relaxed);
let name = entry
diff --git a/fswalk/tests/deep_walk.rs b/fswalk/tests/deep_walk.rs
index 8036cb5e..f64c5468 100644
--- a/fswalk/tests/deep_walk.rs
+++ b/fswalk/tests/deep_walk.rs
@@ -49,7 +49,7 @@ fn node_for_path<'a>(node: &'a fswalk::Node, path: &Path) -> &'a fswalk::Node {
fn ignores_directories_and_collects_metadata() {
let tmp = TempDir::new("fswalk_deep").unwrap();
build_deep_fixture(tmp.path());
- let ignore = vec![tmp.path().join("skip_dir")];
+ let ignore = vec![std::path::PathBuf::from("/skip_dir")];
let walk_data = WalkData::new(tmp.path(), &ignore, true, None);
let tree = walk_it(&walk_data).expect("root node");
let tree = node_for_path(&tree, tmp.path());
@@ -102,3 +102,163 @@ fn cancellation_stops_traversal_early() {
"expected immediate cancellation to abort traversal"
);
}
+
+#[test]
+fn glob_patterns_ignore_nested_directories() {
+ let tmp = TempDir::new("fswalk_glob_ignore").unwrap();
+ let root = tmp.path();
+
+ fs::create_dir_all(root.join("packages/app/node_modules/pkg")).unwrap();
+ fs::create_dir_all(root.join("src/components")).unwrap();
+ fs::write(
+ root.join("packages/app/node_modules/pkg/ignored.js"),
+ b"console.log('ignored');",
+ )
+ .unwrap();
+ fs::write(
+ root.join("src/components/kept.tsx"),
+ b"export const kept = true;",
+ )
+ .unwrap();
+
+ let ignore = vec![std::path::PathBuf::from("**/node_modules/**")];
+ let walk_data = WalkData::new(root, &ignore, true, None);
+ let tree = walk_it(&walk_data).expect("root node");
+ let tree = node_for_path(&tree, root);
+
+ let packages = tree
+ .children
+ .iter()
+ .find(|child| &*child.name == "packages")
+ .expect("packages directory");
+ let app = packages
+ .children
+ .iter()
+ .find(|child| &*child.name == "app")
+ .expect("app directory");
+ let node_modules = app
+ .children
+ .iter()
+ .find(|child| &*child.name == "node_modules")
+ .expect("node_modules directory should still exist");
+ assert!(
+ node_modules.children.is_empty(),
+ "glob ignore should remove descendants under node_modules"
+ );
+
+ let src = tree
+ .children
+ .iter()
+ .find(|child| &*child.name == "src")
+ .expect("src directory");
+ let components = src
+ .children
+ .iter()
+ .find(|child| &*child.name == "components")
+ .expect("components directory");
+ assert!(
+ components
+ .children
+ .iter()
+ .any(|child| &*child.name == "kept.tsx"),
+ "non-matching files should remain indexed"
+ );
+}
+
+#[test]
+fn globstar_descendant_pattern_does_not_ignore_parent_directory() {
+ let tmp = TempDir::new("fswalk_globstar_parent_semantics").unwrap();
+ let root = tmp.path();
+
+ fs::create_dir_all(root.join("Xcode.app/Contents")).unwrap();
+ fs::write(root.join("Xcode.app/Contents/data.bin"), b"data").unwrap();
+
+ let ignore = vec![std::path::PathBuf::from("**/Xcode.app/**")];
+ let walk_data = WalkData::new(root, &ignore, true, None);
+ let tree = walk_it(&walk_data).expect("root node");
+ let tree = node_for_path(&tree, root);
+
+ let xcode_app = tree
+ .children
+ .iter()
+ .find(|child| &*child.name == "Xcode.app")
+ .expect("Xcode.app should not be ignored by **/Xcode.app/** itself");
+ assert!(
+ xcode_app.children.is_empty(),
+ "descendants under Xcode.app should be ignored by **/Xcode.app/**"
+ );
+}
+
+#[test]
+fn gitignore_negation_reincludes_file_when_parent_is_not_pruned() {
+ let tmp = TempDir::new("fswalk_gitignore_negation_reinclude").unwrap();
+ let root = tmp.path();
+
+ fs::create_dir_all(root.join("workspace/node_modules")).unwrap();
+ fs::write(root.join("workspace/node_modules/included.js"), b"ok").unwrap();
+ fs::write(root.join("workspace/node_modules/ignored.js"), b"no").unwrap();
+
+ let ignore = vec![
+ std::path::PathBuf::from("**/node_modules/**"),
+ std::path::PathBuf::from("!**/node_modules/**/included.js"),
+ ];
+ let walk_data = WalkData::new(root, &ignore, true, None);
+ let tree = walk_it(&walk_data).expect("root node");
+ let tree = node_for_path(&tree, root);
+
+ let workspace = tree
+ .children
+ .iter()
+ .find(|child| &*child.name == "workspace")
+ .expect("workspace directory");
+ let node_modules = workspace
+ .children
+ .iter()
+ .find(|child| &*child.name == "node_modules")
+ .expect("node_modules directory");
+
+ assert!(
+ node_modules
+ .children
+ .iter()
+ .any(|child| &*child.name == "included.js"),
+ "negation should re-include explicit file when parent directory is not pruned"
+ );
+ assert!(
+ node_modules
+ .children
+ .iter()
+ .all(|child| &*child.name != "ignored.js"),
+ "non-negated files should remain ignored"
+ );
+}
+
+#[test]
+fn gitignore_negation_cannot_reinclude_when_parent_directory_is_pruned() {
+ let tmp = TempDir::new("fswalk_gitignore_negation_pruned_parent").unwrap();
+ let root = tmp.path();
+
+ fs::create_dir_all(root.join("workspace/node_modules")).unwrap();
+ fs::write(root.join("workspace/node_modules/included.js"), b"ok").unwrap();
+
+ let ignore = vec![
+ std::path::PathBuf::from("**/node_modules/"),
+ std::path::PathBuf::from("!**/node_modules/**/included.js"),
+ ];
+ let walk_data = WalkData::new(root, &ignore, true, None);
+ let tree = walk_it(&walk_data).expect("root node");
+ let tree = node_for_path(&tree, root);
+
+ let workspace = tree
+ .children
+ .iter()
+ .find(|child| &*child.name == "workspace")
+ .expect("workspace directory");
+ assert!(
+ workspace
+ .children
+ .iter()
+ .all(|child| &*child.name != "node_modules"),
+ "when a parent directory is ignored, its subtree is pruned and negation cannot re-include descendants"
+ );
+}
diff --git a/search-cache/src/cache.rs b/search-cache/src/cache.rs
index 189dcdda..bfac3dfb 100644
--- a/search-cache/src/cache.rs
+++ b/search-cache/src/cache.rs
@@ -8,7 +8,7 @@ use crate::{
use anyhow::{Context, Result, anyhow};
use cardinal_sdk::{EventFlag, FsEvent, ScanType, current_event_id};
use cardinal_syntax::{optimize_query, parse_query};
-use fswalk::{Node, NodeMetadata, WalkData, walk_it, walk_it_without_root_chain};
+use fswalk::{IgnoreMatcher, Node, NodeMetadata, WalkData, walk_it, walk_it_without_root_chain};
use hashbrown::HashSet;
use namepool::NamePool;
use search_cancel::CancellationToken;
@@ -16,7 +16,7 @@ use std::{
ffi::OsStr,
io::ErrorKind,
path::{Path, PathBuf},
- sync::{LazyLock, atomic::AtomicBool},
+ sync::{Arc, LazyLock, atomic::AtomicBool},
time::Instant,
};
use thin_vec::ThinVec;
@@ -28,6 +28,7 @@ pub struct SearchCache {
last_event_id: u64,
rescan_count: u64,
pub(crate) name_index: NameIndex,
+ ignore_matcher: Arc,
stop: Option<&'static AtomicBool>,
}
@@ -180,7 +181,14 @@ impl SearchCache {
slab_root,
);
// metadata cache inits later
- Some(Self::new(slab, last_event_id, 0, name_index, cancel))
+ Some(Self::new_with_ignore_matcher(
+ slab,
+ last_event_id,
+ 0,
+ name_index,
+ walk_data.ignore_matcher(),
+ cancel,
+ ))
}
fn new(
@@ -189,12 +197,32 @@ impl SearchCache {
rescan_count: u64,
name_index: NameIndex,
cancel: Option<&'static AtomicBool>,
+ ) -> Self {
+ let ignore_matcher = Arc::new(IgnoreMatcher::new(slab.path(), slab.ignore_paths()));
+ Self::new_with_ignore_matcher(
+ slab,
+ last_event_id,
+ rescan_count,
+ name_index,
+ ignore_matcher,
+ cancel,
+ )
+ }
+
+ fn new_with_ignore_matcher(
+ slab: FileNodes,
+ last_event_id: u64,
+ rescan_count: u64,
+ name_index: NameIndex,
+ ignore_matcher: Arc,
+ cancel: Option<&'static AtomicBool>,
) -> Self {
Self {
file_nodes: slab,
last_event_id,
rescan_count,
name_index,
+ ignore_matcher,
stop: cancel,
}
}
@@ -347,15 +375,55 @@ impl SearchCache {
current
}
+ fn has_ignored_ancestor_directory(&self, path: &Path) -> bool {
+ let watch_root = self.file_nodes.path();
+ let mut ancestor = path.parent();
+ while let Some(dir) = ancestor {
+ if dir == watch_root {
+ break;
+ }
+ if !dir.starts_with(watch_root) {
+ break;
+ }
+ if self.ignore_matcher.is_ignored(dir, true) {
+ return true;
+ }
+ ancestor = dir.parent();
+ }
+ false
+ }
+
// `Self::scan_path_recursive`function returns index of the constructed node(with metadata provided).
// - If path is not under the watch root, None is returned.
// - Procedure contains metadata fetching, if metadata fetching failed, None is returned.
fn scan_path_recursive(&mut self, path: &Path) -> Option {
+ if !path.starts_with(self.file_nodes.path()) {
+ warn!("skip incremental scan outside watch root: {:?}", path);
+ return None;
+ }
+ if self.has_ignored_ancestor_directory(path) {
+ self.remove_node_path(path);
+ return None;
+ }
// Ensure path is under the watch root
if path.symlink_metadata().err().map(|e| e.kind()) == Some(ErrorKind::NotFound) {
self.remove_node_path(path);
return None;
};
+ let walk_data = WalkData::new_with_ignore_matcher(
+ path,
+ self.file_nodes.ignore_paths(),
+ Arc::clone(&self.ignore_matcher),
+ true,
+ self.stop,
+ );
+ let node = match walk_it_without_root_chain(&walk_data) {
+ Some(node) => node,
+ None => {
+ self.remove_node_path(path);
+ return None;
+ }
+ };
let parent = path.parent().expect(
"scan_path_recursive doesn't expected to scan root(should be filtered outside)",
);
@@ -369,14 +437,10 @@ impl SearchCache {
{
self.remove_node(old_node);
}
- // For incremental data, we need metadata
- let walk_data = WalkData::new(path, self.file_nodes.ignore_paths(), true, self.stop);
- walk_it_without_root_chain(&walk_data).map(|node| {
- let node = self.create_node_slab_update_name_index_and_name_pool(Some(parent), &node);
- // Push the newly created node to the parent's children
- self.file_nodes[parent].add_children(node);
- node
- })
+ let node = self.create_node_slab_update_name_index_and_name_pool(Some(parent), &node);
+ // Push the newly created node to the parent's children
+ self.file_nodes[parent].add_children(node);
+ Some(node)
}
// `Self::scan_path_nonrecursive`function returns index of the constructed node.
@@ -399,7 +463,13 @@ impl SearchCache {
) -> WalkData<'p> {
*phantom1 = self.file_nodes.path().to_path_buf();
*phantom2 = self.file_nodes.ignore_paths().clone();
- WalkData::new(phantom1, phantom2, false, self.stop)
+ WalkData::new_with_ignore_matcher(
+ phantom1,
+ phantom2,
+ Arc::clone(&self.ignore_matcher),
+ false,
+ self.stop,
+ )
}
pub fn rescan_with_walk_data(&mut self, walk_data: &WalkData) -> Option<()> {
@@ -414,9 +484,10 @@ impl SearchCache {
pub fn rescan(&mut self) {
// Remove all memory consuming cache early for memory consumption in Self::walk_fs_new.
let Some(new_cache) = Self::walk_fs_with_walk_data(
- &WalkData::new(
+ &WalkData::new_with_ignore_matcher(
self.file_nodes.path(),
self.file_nodes.ignore_paths(),
+ Arc::clone(&self.ignore_matcher),
false,
self.stop,
),
@@ -478,6 +549,7 @@ impl SearchCache {
last_event_id,
rescan_count,
name_index,
+ ignore_matcher: _,
stop: _,
} = self;
let (path, ignore_paths, slab_root, slab) = file_nodes.into_parts();
@@ -596,7 +668,7 @@ impl SearchCache {
self.rescan_count = self.rescan_count.saturating_add(1);
return Err(HandleFSEError::Rescan);
}
- for scan_path in scan_paths(events) {
+ for scan_path in scan_paths_under_root(events, self.file_nodes.path()) {
info!("Scanning path: {scan_path:?}");
let folder = self.scan_path_recursive(&scan_path);
if folder.is_some() {
@@ -682,6 +754,15 @@ fn scan_paths(events: Vec) -> Vec {
selected
}
+fn scan_paths_under_root(events: Vec, watch_root: &Path) -> Vec {
+ scan_paths(
+ events
+ .into_iter()
+ .filter(|event| event.path.starts_with(watch_root))
+ .collect(),
+ )
+}
+
fn path_depth(path: &Path) -> usize {
path.components().count()
}
@@ -1530,6 +1611,36 @@ mod tests {
assert_eq!(cache.search("new_file.txt").unwrap().len(), 1);
}
+ #[test]
+ fn test_handle_fs_event_out_of_root_ancestor_does_not_mask_in_root_path() {
+ let temp_dir = TempDir::new("test_events").expect("Failed to create temp directory");
+ let temp_path = temp_dir.path();
+ let mut cache = SearchCache::walk_fs(temp_path);
+
+ let in_root_file = temp_path.join("new_file.txt");
+ fs::File::create(&in_root_file).expect("Failed to create file");
+
+ let out_of_root_ancestor = temp_path
+ .parent()
+ .expect("temp dir should always have a parent")
+ .to_path_buf();
+ let mock_events = vec![
+ FsEvent {
+ path: out_of_root_ancestor,
+ id: cache.last_event_id + 1,
+ flag: EventFlag::ItemModified | EventFlag::ItemIsDir,
+ },
+ FsEvent {
+ path: in_root_file,
+ id: cache.last_event_id + 2,
+ flag: EventFlag::ItemCreated | EventFlag::ItemIsFile,
+ },
+ ];
+
+ cache.handle_fs_events(mock_events).unwrap();
+ assert_eq!(cache.search("new_file.txt").unwrap().len(), 1);
+ }
+
// Processing outdated fs event is required to avoid bouncing.
#[test]
fn test_handle_outdated_fs_event() {
@@ -3045,4 +3156,24 @@ mod tests {
let out = scan_paths(events);
assert_eq!(out, vec![PathBuf::from("/long")]);
}
+
+ #[test]
+ fn test_scan_paths_under_root_filters_masking_out_of_root_ancestor() {
+ let watch_root = Path::new("/tmp/watch");
+ let events = vec![
+ FsEvent {
+ path: PathBuf::from("/tmp"),
+ id: 1,
+ flag: EventFlag::ItemModified | EventFlag::ItemIsDir,
+ },
+ FsEvent {
+ path: PathBuf::from("/tmp/watch/new.txt"),
+ id: 2,
+ flag: EventFlag::ItemCreated | EventFlag::ItemIsFile,
+ },
+ ];
+
+ let out = scan_paths_under_root(events, watch_root);
+ assert_eq!(out, vec![PathBuf::from("/tmp/watch/new.txt")]);
+ }
}
diff --git a/search-cache/tests/fsevent_incremental_tests.rs b/search-cache/tests/fsevent_incremental_tests.rs
index 3e5bc431..f74c65fa 100644
--- a/search-cache/tests/fsevent_incremental_tests.rs
+++ b/search-cache/tests/fsevent_incremental_tests.rs
@@ -455,7 +455,7 @@ fn test_events_for_ignored_paths() {
let ignored_dir = root_path.join("ignored");
std::fs::create_dir(&ignored_dir).unwrap();
- let ignore_paths = vec![ignored_dir.clone()];
+ let ignore_paths = vec![PathBuf::from("/ignored")];
let mut cache = SearchCache::walk_fs_with_ignore(&root_path, &ignore_paths);
// Create file in ignored directory
@@ -470,14 +470,149 @@ fn test_events_for_ignored_paths() {
cache.handle_fs_events(vec![event]).unwrap();
- // File in ignored path may or may not be indexed depending on implementation
- // Just verify it doesn't panic
let search_result = cache
.query_files("should_not_index".to_string(), CancellationToken::noop())
.unwrap();
- assert!(
- search_result.is_some(),
- "Search should not panic for ignored paths"
+ assert!(search_result.is_some());
+ assert_eq!(
+ search_result.unwrap().len(),
+ 0,
+ "Files under ignored absolute paths should stay excluded"
+ );
+}
+
+#[test]
+fn test_events_for_glob_ignored_paths() {
+ let temp_dir = TempDir::new("glob_ignored_paths_test").unwrap();
+ let root_path = temp_dir.path().to_path_buf();
+ std::mem::forget(temp_dir);
+
+ std::fs::create_dir_all(root_path.join("workspace/node_modules/pkg")).unwrap();
+ std::fs::File::create(root_path.join("workspace/included.txt")).unwrap();
+
+ let ignore_paths = vec![PathBuf::from("**/node_modules/**")];
+ let mut cache = SearchCache::walk_fs_with_ignore(&root_path, &ignore_paths);
+
+ let ignored_file = root_path.join("workspace/node_modules/pkg/new_ignored.txt");
+ std::fs::File::create(&ignored_file).unwrap();
+
+ cache
+ .handle_fs_events(vec![FsEvent {
+ path: ignored_file,
+ flag: EventFlag::ItemCreated,
+ id: 301,
+ }])
+ .unwrap();
+
+ let ignored_result = cache
+ .query_files("new_ignored".to_string(), CancellationToken::noop())
+ .unwrap();
+ assert!(ignored_result.is_some());
+ assert_eq!(
+ ignored_result.unwrap().len(),
+ 0,
+ "Files matching glob ignored paths should stay excluded"
+ );
+
+ let included_result = cache
+ .query_files("included".to_string(), CancellationToken::noop())
+ .unwrap();
+ assert!(included_result.is_some());
+ assert_eq!(
+ included_result.unwrap().len(),
+ 1,
+ "Non-matching files should remain searchable"
+ );
+}
+
+#[test]
+fn test_events_for_negated_glob_paths() {
+ let temp_dir = TempDir::new("negated_glob_paths_test").unwrap();
+ let root_path = temp_dir.path().to_path_buf();
+ std::mem::forget(temp_dir);
+
+ std::fs::create_dir_all(root_path.join("workspace/node_modules")).unwrap();
+
+ let ignore_paths = vec![
+ PathBuf::from("**/node_modules/**"),
+ PathBuf::from("!**/node_modules/**/included.txt"),
+ ];
+ let mut cache = SearchCache::walk_fs_with_ignore(&root_path, &ignore_paths);
+
+ let included_file = root_path.join("workspace/node_modules/included.txt");
+ std::fs::write(&included_file, b"included").unwrap();
+ let ignored_file = root_path.join("workspace/node_modules/ignored.txt");
+ std::fs::write(&ignored_file, b"ignored").unwrap();
+
+ cache
+ .handle_fs_events(vec![
+ FsEvent {
+ path: included_file,
+ flag: EventFlag::ItemCreated,
+ id: 302,
+ },
+ FsEvent {
+ path: ignored_file,
+ flag: EventFlag::ItemCreated,
+ id: 303,
+ },
+ ])
+ .unwrap();
+
+ let included_result = cache
+ .query_files("included".to_string(), CancellationToken::noop())
+ .unwrap();
+ assert!(included_result.is_some());
+ assert_eq!(
+ included_result.unwrap().len(),
+ 1,
+ "Negated patterns should re-include explicit files when parent directories are not ignored"
+ );
+
+ let ignored_result = cache
+ .query_files("ignored".to_string(), CancellationToken::noop())
+ .unwrap();
+ assert!(ignored_result.is_some());
+ assert_eq!(
+ ignored_result.unwrap().len(),
+ 0,
+ "Non-negated files should remain ignored"
+ );
+}
+
+#[test]
+fn test_events_for_negation_under_pruned_parent_directory() {
+ let temp_dir = TempDir::new("negated_glob_pruned_parent_test").unwrap();
+ let root_path = temp_dir.path().to_path_buf();
+ std::mem::forget(temp_dir);
+
+ std::fs::create_dir_all(root_path.join("workspace/node_modules")).unwrap();
+
+ let ignore_paths = vec![
+ PathBuf::from("**/node_modules/"),
+ PathBuf::from("!**/node_modules/**/included.txt"),
+ ];
+ let mut cache = SearchCache::walk_fs_with_ignore(&root_path, &ignore_paths);
+
+ let included_file = root_path.join("workspace/node_modules/included.txt");
+ std::fs::write(&included_file, b"included").unwrap();
+
+ cache
+ .handle_fs_events(vec![FsEvent {
+ path: included_file,
+ flag: EventFlag::ItemCreated,
+ id: 304,
+ }])
+ .unwrap();
+
+ let included_result = cache
+ .query_files("included".to_string(), CancellationToken::noop())
+ .unwrap();
+ assert!(included_result.is_some());
+ assert_eq!(
+ included_result.unwrap().len(),
+ 0,
+ "Files cannot be re-included when an ancestor directory was ignored and pruned"
);
}
From 59712e22e9b0a2b89e04b39d0512f3ccfa6e2723 Mon Sep 17 00:00:00 2001
From: Mohamad Yahia
Date: Wed, 25 Feb 2026 07:05:16 +0400
Subject: [PATCH 02/16] feat(export): add menu-driven current files list export
---
.gitignore | 3 +-
cardinal/src-tauri/Cargo.lock | 192 ++++++++++++++++++
cardinal/src-tauri/Cargo.toml | 1 +
cardinal/src-tauri/src/commands.rs | 96 ++++++++-
cardinal/src-tauri/src/lib.rs | 13 +-
cardinal/src/App.css | 2 +-
cardinal/src/App.tsx | 83 ++++++++
.../src/__tests__/App.contextMenu.test.tsx | 124 ++++++++++-
cardinal/src/components/StatusBar.tsx | 4 +-
cardinal/src/constants/appEvents.ts | 1 +
cardinal/src/menu.ts | 11 +
.../__tests__/exportListedFilesTsv.test.ts | 85 ++++++++
cardinal/src/utils/exportListedFilesTsv.ts | 83 ++++++++
13 files changed, 687 insertions(+), 11 deletions(-)
create mode 100644 cardinal/src/utils/__tests__/exportListedFilesTsv.test.ts
create mode 100644 cardinal/src/utils/exportListedFilesTsv.ts
diff --git a/.gitignore b/.gitignore
index fd531785..c8084351 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
target
.DS_Store
-.vscode
\ No newline at end of file
+.vscode
+/ignored/
diff --git a/cardinal/src-tauri/Cargo.lock b/cardinal/src-tauri/Cargo.lock
index e1494eec..f271b8e1 100644
--- a/cardinal/src-tauri/Cargo.lock
+++ b/cardinal/src-tauri/Cargo.lock
@@ -53,6 +53,28 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
+[[package]]
+name = "ashpd"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
+dependencies = [
+ "async-fs",
+ "async-net",
+ "enumflags2",
+ "futures-channel",
+ "futures-util",
+ "rand 0.9.2",
+ "raw-window-handle",
+ "serde",
+ "serde_repr",
+ "url",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "zbus",
+]
+
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -91,6 +113,17 @@ dependencies = [
"slab",
]
+[[package]]
+name = "async-fs"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
+dependencies = [
+ "async-lock",
+ "blocking",
+ "futures-lite",
+]
+
[[package]]
name = "async-io"
version = "2.6.0"
@@ -120,6 +153,17 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "async-net"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
+dependencies = [
+ "async-io",
+ "blocking",
+ "futures-lite",
+]
+
[[package]]
name = "async-process"
version = "2.5.0"
@@ -411,6 +455,7 @@ dependencies = [
"once_cell",
"parking_lot",
"rayon",
+ "rfd",
"search-cache",
"search-cancel",
"serde",
@@ -885,6 +930,15 @@ dependencies = [
"syn 2.0.111",
]
+[[package]]
+name = "dlib"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
+dependencies = [
+ "libloading",
+]
+
[[package]]
name = "dlopen2"
version = "0.8.2"
@@ -908,6 +962,12 @@ dependencies = [
"syn 2.0.111",
]
+[[package]]
+name = "downcast-rs"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
+
[[package]]
name = "dpi"
version = "0.1.2"
@@ -3191,6 +3251,12 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "pollster"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
+
[[package]]
name = "portable-atomic"
version = "1.11.1"
@@ -3370,6 +3436,16 @@ dependencies = [
"rand_core 0.6.4",
]
+[[package]]
+name = "rand"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
+dependencies = [
+ "rand_chacha 0.9.0",
+ "rand_core 0.9.5",
+]
+
[[package]]
name = "rand_chacha"
version = "0.2.2"
@@ -3390,6 +3466,16 @@ dependencies = [
"rand_core 0.6.4",
]
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
[[package]]
name = "rand_core"
version = "0.5.1"
@@ -3408,6 +3494,15 @@ dependencies = [
"getrandom 0.2.16",
]
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
[[package]]
name = "rand_hc"
version = "0.2.0"
@@ -3556,6 +3651,30 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "rfd"
+version = "0.15.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"
+dependencies = [
+ "ashpd",
+ "block2 0.6.2",
+ "dispatch2",
+ "js-sys",
+ "log",
+ "objc2 0.6.3",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "pollster",
+ "raw-window-handle",
+ "urlencoding",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "rustc-hash"
version = "2.1.1"
@@ -3656,6 +3775,12 @@ dependencies = [
"syn 2.0.111",
]
+[[package]]
+name = "scoped-tls"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
+
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -5026,6 +5151,12 @@ dependencies = [
"serde",
]
+[[package]]
+name = "urlencoding"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
+
[[package]]
name = "urlpattern"
version = "0.3.0"
@@ -5211,6 +5342,66 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "wayland-backend"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9"
+dependencies = [
+ "cc",
+ "downcast-rs",
+ "rustix",
+ "scoped-tls",
+ "smallvec",
+ "wayland-sys",
+]
+
+[[package]]
+name = "wayland-client"
+version = "0.31.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec"
+dependencies = [
+ "bitflags 2.10.0",
+ "rustix",
+ "wayland-backend",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols"
+version = "0.32.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3"
+dependencies = [
+ "bitflags 2.10.0",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-scanner"
+version = "0.31.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
+dependencies = [
+ "proc-macro2",
+ "quick-xml",
+ "quote",
+]
+
+[[package]]
+name = "wayland-sys"
+version = "0.31.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd"
+dependencies = [
+ "dlib",
+ "log",
+ "pkg-config",
+]
+
[[package]]
name = "web-sys"
version = "0.3.83"
@@ -6157,6 +6348,7 @@ dependencies = [
"endi",
"enumflags2",
"serde",
+ "url",
"winnow 0.7.14",
"zvariant_derive",
"zvariant_utils",
diff --git a/cardinal/src-tauri/Cargo.toml b/cardinal/src-tauri/Cargo.toml
index 9c5dbfc4..92a757eb 100644
--- a/cardinal/src-tauri/Cargo.toml
+++ b/cardinal/src-tauri/Cargo.toml
@@ -39,6 +39,7 @@ tauri-plugin-prevent-default = "4"
tauri-plugin-macos-permissions = "2.3.0"
tauri-plugin-window-state = "2"
once_cell = { version = "1.20", features = ["parking_lot"] }
+rfd = "0.15"
cardinal-sdk.path = "../../cardinal-sdk"
search-cache.path = "../../search-cache"
diff --git a/cardinal/src-tauri/src/commands.rs b/cardinal/src-tauri/src/commands.rs
index 164a9fa4..c3bcd1f1 100644
--- a/cardinal/src-tauri/src/commands.rs
+++ b/cardinal/src-tauri/src/commands.rs
@@ -22,7 +22,12 @@ use parking_lot::Mutex;
use search_cache::{SearchOptions, SearchOutcome, SearchResultNode, SlabIndex, SlabNodeMetadata};
use search_cancel::CancellationToken;
use serde::{Deserialize, Serialize};
-use std::{cell::LazyCell, process::Command};
+use std::{
+ cell::LazyCell,
+ fs::OpenOptions,
+ io::Write,
+ process::Command,
+};
use tauri::{ActivationPolicy, AppHandle, State};
use tracing::{error, info, warn};
@@ -420,6 +425,95 @@ pub async fn open_path(path: String) {
}
}
+#[tauri::command]
+pub async fn prompt_save_listed_files_tsv(
+ app: AppHandle,
+ default_filename: String,
+) -> Result