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')}

-
+