Skip to content
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e6f580e
feat(search): add path: substring filter
dkattan Jun 25, 2026
2434973
perf(search): make path: filter allocation-free
dkattan Jun 25, 2026
ae3988d
perf(search): use name pool index for path: filter
dkattan Jun 25, 2026
a12ddd7
feat(search): add FlatIndex data structure
dkattan Jun 25, 2026
40dff7b
feat(fswalk): add walk_flat for flat entry collection
dkattan Jun 25, 2026
b00737f
feat(search): build FlatIndex alongside tree in SearchCache
dkattan Jun 25, 2026
31b35b8
perf(search): use flat index prefix_range for path: descendants
dkattan Jun 25, 2026
1558b03
fix(search): maintain flat index on FS events
dkattan Jun 25, 2026
5ad3f01
fix(search): filter base set in-place for path: to avoid hangs
dkattan Jun 25, 2026
2967a8c
fix(search): don't build flat index from persisted cache
dkattan Jun 25, 2026
d9bd807
feat(app): bump cache version, add granular status messages
dkattan Jun 25, 2026
00bae76
fix(search): simplify path: filter to linear scan with cancellation
dkattan Jun 25, 2026
2ea15d0
test(e2e): add xa11y-based end-to-end tests for Cardinal
dkattan Jun 25, 2026
42c67e5
fix(e2e): use correct xa11y selectors for Tauri accessibility tree
dkattan Jun 25, 2026
5214b66
fix(a11y): add aria-label to search input
dkattan Jun 25, 2026
8430573
perf(search): populate flat index during walk, optimize path: filter
dkattan Jun 25, 2026
6e8e11c
style: fix clippy warnings in path filter
dkattan Jun 25, 2026
38428cf
perf(search): stop maintaining flat index on FS events, simplify status
dkattan Jun 25, 2026
94909c6
test: replace machine-specific 'Ayla' with 'Downloads' in tests and docs
dkattan Jun 25, 2026
cd729de
refactor: remove aria-label changes (split to #220)
dkattan Jun 25, 2026
87ca3fc
fix: resolve CI failures (clippy dead code, fmt, missing statusMessag…
dkattan Jun 25, 2026
2ddaa79
fix(e2e): use noop token for second search in cancellation test
dkattan Jun 25, 2026
173a1b1
fix(e2e): don't assert file count > 0 (CI runners have minimal filesy…
dkattan Jun 25, 2026
feddd18
docs: document !path: negation and add Search Syntax menu item
dkattan Jun 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Changelog

## Unreleased
- Add `path:` filter for substring matching against an item's full absolute path. Multiple `path:` filters combine with AND (e.g. `main.js path:Downloads path:repos`).

## 0.1.23 — 2026-03-25
- Reduce power consumption by expanding the default ignored paths to cover more macOS cache, log, metadata, and runtime directories.
- Further reduce background work by making the filesystem event watcher honor ignored paths.
Expand Down
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ members = [
"cardinal-syntax",
"search-cancel",
"slab-mmap",
"e2e-tests",
]
exclude = ["cardinal"]
12 changes: 11 additions & 1 deletion cardinal-syntax/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ fn reorder_by_priority(parts: &mut Vec<Expr>) {
let priority = |expr: &Expr| -> u8 {
match expr {
Expr::Term(Term::Filter(filter)) => match filter.kind {
FilterKind::InFolder | FilterKind::Parent => 0,
FilterKind::InFolder | FilterKind::Parent | FilterKind::Path => 0,
FilterKind::Tag => 3,
_ => 2,
},
Expand Down Expand Up @@ -355,6 +355,15 @@ pub enum FilterKind {
/// assert!(matches!(filter.kind, FilterKind::InFolder));
/// ```
InFolder,
/// Restrict to items whose full path contains the argument as a substring
/// (`path:`). Multiple `path:` filters combine with AND, each narrowing the
/// result set further. Matching respects the UI case-sensitivity toggle.
/// ```
/// use cardinal_syntax::{parse_query, Expr, Term, FilterKind};
/// let Expr::Term(Term::Filter(filter)) = parse_query("path:repos").unwrap().expr else { panic!() };
/// assert!(matches!(filter.kind, FilterKind::Path));
/// ```
Path,
/// Limit to the folder itself (`nosubfolders:`).
/// ```
/// use cardinal_syntax::{parse_query, Expr, Term, FilterKind};
Expand Down Expand Up @@ -551,6 +560,7 @@ impl FilterKind {
"dr" | "daterun" => FilterKind::DateRun,
"parent" => FilterKind::Parent,
"infolder" | "in" => FilterKind::InFolder,
"path" => FilterKind::Path,
"nosubfolders" => FilterKind::NoSubfolders,
"child" => FilterKind::Child,
"attrib" => FilterKind::Attribute,
Expand Down
1 change: 1 addition & 0 deletions cardinal-syntax/tests/filter_kinds_coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ fn maps_known_filter_names() {
("daterun", FilterKind::DateRun),
("parent", FilterKind::Parent),
("infolder", FilterKind::InFolder),
("path", FilterKind::Path),
("nosubfolders", FilterKind::NoSubfolders),
("child", FilterKind::Child),
("attrib", FilterKind::Attribute),
Expand Down
60 changes: 49 additions & 11 deletions cardinal/src-tauri/src/background.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ use once_cell::sync::Lazy;
use parking_lot::Mutex;
use rayon::spawn;
use search_cache::{
HandleFSEError, SearchCache, SearchOptions, SearchResultNode, SlabIndex, WalkData,
HandleFSEError, SearchCache, SearchOptions, SearchOutcome, SearchResultNode, SlabIndex,
WalkData,
};
use search_cancel::CancellationToken;
use serde::Serialize;
Expand All @@ -30,6 +31,9 @@ pub struct StatusBarUpdate {
pub scanned_files: usize,
pub processed_events: usize,
pub rescan_errors: usize,
/// Human-readable status message (e.g. "Walking filesystem…", "Indexing…").
#[serde(skip_serializing_if = "Option::is_none")]
pub status_message: Option<String>,
}

#[derive(Serialize, Clone)]
Expand Down Expand Up @@ -58,6 +62,7 @@ pub fn reset_status_bar(app_handle: &AppHandle) {
scanned_files: 0,
processed_events: 0,
rescan_errors: 0,
status_message: None,
},
)
.unwrap();
Expand All @@ -68,6 +73,22 @@ pub fn emit_status_bar_update(
scanned_files: usize,
processed_events: usize,
rescan_errors: usize,
) {
emit_status_bar_update_with_message(
app_handle,
scanned_files,
processed_events,
rescan_errors,
None,
);
}

pub fn emit_status_bar_update_with_message(
app_handle: &AppHandle,
scanned_files: usize,
processed_events: usize,
rescan_errors: usize,
status_message: Option<&str>,
) {
static LAST_EMIT: Lazy<Mutex<Instant>> =
Lazy::new(|| Mutex::new(Instant::now() - Duration::from_secs(1)));
Expand All @@ -84,6 +105,7 @@ pub fn emit_status_bar_update(
scanned_files,
processed_events,
rescan_errors,
status_message: status_message.map(|s| s.to_string()),
},
)
.unwrap();
Expand Down Expand Up @@ -329,6 +351,28 @@ pub fn run_background_event_loop(
let flush_ticker = crossbeam_channel::tick(Duration::from_secs(10));

loop {
// Prioritize search requests over FS event processing so the UI
// stays responsive even when there's a large FS event backlog.
// Drain all pending search jobs, keeping only the latest (older
// ones are already cancelled by CancellationToken::new_search()).
if let Ok(mut latest_job) = search_rx.try_recv() {
while let Ok(newer) = search_rx.try_recv() {
// Send cancelled result for the superseded job.
let _ = latest_job.result_tx.send(Ok(SearchOutcome::cancelled()));
latest_job = newer;
}
let SearchJob {
query,
options,
cancellation_token,
result_tx,
} = latest_job;
let opts = SearchOptions::from(options);
let payload = cache.search_query_with_options(query, opts, cancellation_token);
result_tx.send(payload).expect("Failed to send result");
continue;
}

crossbeam_channel::select! {
recv(finish_rx) -> tx => {
let tx = tx.expect("Finish channel closed");
Expand Down Expand Up @@ -435,11 +479,8 @@ pub(crate) fn build_search_cache(
std::thread::scope(|s| {
s.spawn(|| {
while !walking_done.load(Ordering::Relaxed) {
let dirs = walk_data.num_dirs.load(Ordering::Relaxed);
let files = walk_data.num_files.load(Ordering::Relaxed);
let total = dirs + files;
emit_status_bar_update(app_handle, total, 0, 0);
std::thread::sleep(Duration::from_millis(100));
emit_status_bar_update_with_message(app_handle, 0, 0, 0, Some("Indexing…"));
std::thread::sleep(Duration::from_millis(500));
Comment on lines 481 to +483
}
});
let cache = SearchCache::walk_fs_with_walk_data(&walk_data, &APP_QUIT);
Expand Down Expand Up @@ -483,11 +524,8 @@ fn perform_rescan(
let stopped = std::thread::scope(|s| {
s.spawn(|| {
while !walking_done.load(Ordering::Relaxed) {
let dirs = walk_data.num_dirs.load(Ordering::Relaxed);
let files = walk_data.num_files.load(Ordering::Relaxed);
let total = dirs + files;
emit_status_bar_update(app_handle, total, 0, 0);
std::thread::sleep(Duration::from_millis(100));
emit_status_bar_update_with_message(app_handle, 0, 0, 0, Some("Indexing…"));
std::thread::sleep(Duration::from_millis(500));
Comment on lines 526 to +528
}
});
// If rescan is cancelled, we have nothing to do
Expand Down
2 changes: 2 additions & 0 deletions cardinal/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ function App() {
scannedFiles,
processedEvents,
rescanErrors,
statusMessage,
currentQuery,
currentDirectoryQuery,
highlightTerms,
Expand Down Expand Up @@ -439,6 +440,7 @@ function App() {
onTabChange={onTabChange}
onRequestRescan={requestRescan}
rescanErrorCount={rescanErrors}
statusMessage={statusMessage}
/>
</main>
<PreferencesOverlay
Expand Down
4 changes: 3 additions & 1 deletion cardinal/src/components/StatusBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type StatusBarProps = {
onTabChange: (tab: StatusTabKey) => void;
onRequestRescan: () => void;
rescanErrorCount: number;
statusMessage: string | null;
};

const TABS: StatusTabKey[] = ['files', 'events'];
Expand All @@ -36,6 +37,7 @@ const StatusBar = ({
onTabChange,
onRequestRescan,
rescanErrorCount,
statusMessage,
}: StatusBarProps): React.JSX.Element => {
const { t } = useTranslation();
const tabsRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -116,7 +118,7 @@ const StatusBar = ({
>
{lifecycleMeta.icon}
</span>
<span className="status-text">{lifecycleLabel}</span>
<span className="status-text">{statusMessage ?? lifecycleLabel}</span>
</div>
<div
ref={tabsRef}
Expand Down
9 changes: 7 additions & 2 deletions cardinal/src/hooks/__tests__/useAppWindowListeners.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ type HookProps = {
activeTab: 'files' | 'events';
searchInputRef: RefObject<HTMLInputElement>;
focusAndSelectSearchInput: () => void;
handleStatusUpdate: (scannedFiles: number, processedEvents: number, rescanErrors: number) => void;
handleStatusUpdate: (
scannedFiles: number,
processedEvents: number,
rescanErrors: number,
statusMessage?: string,
) => void;
setLifecycleState: (status: 'Initializing' | 'Updating' | 'Ready') => void;
submitFilesQuery: (query: string, options?: { immediate?: boolean }) => void;
setEventFilterQuery: (query: string) => void;
Expand Down Expand Up @@ -113,7 +118,7 @@ describe('useAppWindowListeners', () => {
act(() => {
statusCallback?.({ scannedFiles: 11, processedEvents: 22, rescanErrors: 3 });
});
expect(handleStatusUpdate).toHaveBeenCalledWith(11, 22, 3);
expect(handleStatusUpdate).toHaveBeenCalledWith(11, 22, 3, undefined);

act(() => {
lifecycleCallback?.('Ready');
Expand Down
11 changes: 8 additions & 3 deletions cardinal/src/hooks/useAppWindowListeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ type UseAppWindowListenersOptions = {
activeTab: StatusTabKey;
searchInputRef: RefObject<HTMLInputElement>;
focusAndSelectSearchInput: () => void;
handleStatusUpdate: (scannedFiles: number, processedEvents: number, rescanErrors: number) => void;
handleStatusUpdate: (
scannedFiles: number,
processedEvents: number,
rescanErrors: number,
statusMessage?: string,
) => void;
setLifecycleState: (status: AppLifecycleStatus) => void;
submitFilesQuery: (query: string, options?: QueueSearchOptions) => void;
setEventFilterQuery: (value: string) => void;
Expand Down Expand Up @@ -50,8 +55,8 @@ export function useAppWindowListeners({
});
useEffect(() => {
const unlistenStatus = subscribeStatusBarUpdate((payload: StatusBarUpdatePayload) => {
const { scannedFiles, processedEvents, rescanErrors } = payload;
handleStatusUpdate(scannedFiles, processedEvents, rescanErrors);
const { scannedFiles, processedEvents, rescanErrors, statusMessage } = payload;
handleStatusUpdate(scannedFiles, processedEvents, rescanErrors, statusMessage);
});
return unlistenStatus;
}, [handleStatusUpdate]);
Expand Down
26 changes: 22 additions & 4 deletions cardinal/src/hooks/useFileSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type SearchState = {
scannedFiles: number;
processedEvents: number;
rescanErrors: number;
statusMessage: string | null;
currentQuery: string;
currentDirectoryQuery: string;
highlightTerms: string[];
Expand Down Expand Up @@ -45,7 +46,12 @@ type QueueSearchOptions = {
type SearchAction =
| {
type: 'STATUS_UPDATE';
payload: { scannedFiles: number; processedEvents: number; rescanErrors: number };
payload: {
scannedFiles: number;
processedEvents: number;
rescanErrors: number;
statusMessage?: string;
};
}
| { type: 'SEARCH_REQUEST'; payload: { immediate: boolean } }
| { type: 'SEARCH_LOADING_DELAY' }
Expand Down Expand Up @@ -76,6 +82,7 @@ const initialSearchState: SearchState = {
scannedFiles: 0,
processedEvents: 0,
rescanErrors: 0,
statusMessage: null,
currentQuery: '',
currentDirectoryQuery: '',
highlightTerms: [],
Expand Down Expand Up @@ -132,6 +139,7 @@ function reducer(state: SearchState, action: SearchAction): SearchState {
scannedFiles: action.payload.scannedFiles,
processedEvents: action.payload.processedEvents,
rescanErrors: action.payload.rescanErrors,
statusMessage: action.payload.statusMessage ?? null,
};
case 'SEARCH_REQUEST':
return {
Expand Down Expand Up @@ -200,7 +208,12 @@ type UseFileSearchResult = {
queueSearch: (query: string, options?: QueueSearchOptions) => void;
queueDirectorySearch: (directoryQuery: string, options?: QueueSearchOptions) => void;
queueDirectoryScopeOpen: (directoryScopeOpen: boolean) => void;
handleStatusUpdate: (scannedFiles: number, processedEvents: number, rescanErrors: number) => void;
handleStatusUpdate: (
scannedFiles: number,
processedEvents: number,
rescanErrors: number,
statusMessage?: string,
) => void;
setLifecycleState: (status: AppLifecycleStatus) => void;
requestRescan: () => Promise<void>;
};
Expand Down Expand Up @@ -234,10 +247,15 @@ export function useFileSearch(): UseFileSearchResult {
}, []);

const handleStatusUpdate = useCallback(
(scannedFiles: number, processedEvents: number, rescanErrors: number) => {
(
scannedFiles: number,
processedEvents: number,
rescanErrors: number,
statusMessage?: string,
) => {
dispatch({
type: 'STATUS_UPDATE',
payload: { scannedFiles, processedEvents, rescanErrors },
payload: { scannedFiles, processedEvents, rescanErrors, statusMessage },
});
},
[],
Expand Down
1 change: 1 addition & 0 deletions cardinal/src/types/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type StatusBarUpdatePayload = {
scannedFiles: number;
processedEvents: number;
rescanErrors: number;
statusMessage?: string;
};

export type IconUpdateWirePayload = {
Expand Down
Loading
Loading