From 8d5464957ac417c55451977cccafb23d9e9c77aa Mon Sep 17 00:00:00 2001 From: Francisco X Carrasco Date: Sun, 31 May 2026 19:28:57 -0300 Subject: [PATCH 01/10] Add context snippets for content searches --- cardinal/src-tauri/src/commands.rs | 123 +++++++++++++++++- cardinal/src/App.css | 26 ++++ cardinal/src/App.tsx | 19 ++- cardinal/src/components/ColumnHeader.tsx | 14 +- cardinal/src/components/FileRow.tsx | 17 +++ cardinal/src/components/FilesTabContent.tsx | 11 +- .../components/MiddleEllipsisHighlight.tsx | 40 +++++- cardinal/src/components/VirtualList.tsx | 11 +- .../src/hooks/__tests__/useDataLoader.test.ts | 30 +++++ cardinal/src/hooks/useDataLoader.ts | 18 ++- cardinal/src/i18n/resources/ar-SA.json | 3 +- cardinal/src/i18n/resources/de-DE.json | 3 +- cardinal/src/i18n/resources/en-US.json | 3 +- cardinal/src/i18n/resources/es-ES.json | 3 +- cardinal/src/i18n/resources/fr-FR.json | 3 +- cardinal/src/i18n/resources/hi-IN.json | 3 +- cardinal/src/i18n/resources/it-IT.json | 3 +- cardinal/src/i18n/resources/ja-JP.json | 3 +- cardinal/src/i18n/resources/ko-KR.json | 3 +- cardinal/src/i18n/resources/pt-BR.json | 3 +- cardinal/src/i18n/resources/ru-RU.json | 3 +- cardinal/src/i18n/resources/tr-TR.json | 3 +- cardinal/src/i18n/resources/uk-UA.json | 3 +- cardinal/src/i18n/resources/zh-CN.json | 3 +- cardinal/src/i18n/resources/zh-TW.json | 3 +- cardinal/src/types/search.ts | 2 + .../src/utils/__tests__/contentQuery.test.ts | 18 +++ cardinal/src/utils/contentQuery.ts | 64 +++++++++ 28 files changed, 413 insertions(+), 25 deletions(-) create mode 100644 cardinal/src/utils/__tests__/contentQuery.test.ts create mode 100644 cardinal/src/utils/contentQuery.ts diff --git a/cardinal/src-tauri/src/commands.rs b/cardinal/src-tauri/src/commands.rs index ae863611..c7fc7ad3 100644 --- a/cardinal/src-tauri/src/commands.rs +++ b/cardinal/src-tauri/src/commands.rs @@ -24,7 +24,7 @@ use search_cache::{ }; use search_cancel::CancellationToken; use serde::{Deserialize, Serialize}; -use std::{cell::LazyCell, process::Command}; +use std::{cell::LazyCell, fs::File, io::Read, process::Command}; use tauri::{ActivationPolicy, AppHandle, State}; use tracing::{error, info, warn}; @@ -217,6 +217,8 @@ pub struct NodeInfo { pub path: String, pub metadata: Option, pub icon: Option, + #[serde(rename = "contentContext")] + pub content_context: Option, } #[derive(Serialize, Default)] @@ -335,6 +337,8 @@ pub async fn search( pub fn get_nodes_info( results: Vec, include_icons: Option, + content_terms: Option>, + case_insensitive: Option, state: State<'_, SearchState>, ) -> Vec { if results.is_empty() { @@ -342,12 +346,24 @@ pub fn get_nodes_info( } let include_icons = include_icons.unwrap_or(true); + let content_terms = content_terms + .unwrap_or_default() + .into_iter() + .map(|term| term.trim().to_string()) + .filter(|term| !term.is_empty()) + .collect::>(); + let case_insensitive = case_insensitive.unwrap_or_default(); let nodes = state.request_nodes(results); nodes .into_iter() .map(|SearchResultNode { path, metadata }| { let path = path.to_string_lossy().into_owned(); + let content_context = if content_terms.is_empty() { + None + } else { + content_context_for_path(&path, &content_terms, case_insensitive) + }; let icon = if include_icons { fs_icon::icon_of_path_ns(&path).map(|data| { format!( @@ -362,6 +378,7 @@ pub fn get_nodes_info( path, icon, metadata: metadata.as_ref().map(NodeInfoMetadata::from_metadata), + content_context, } }) .collect() @@ -397,6 +414,110 @@ pub fn update_icon_viewport(id: u64, viewport: Vec, state: State<'_, } } +const CONTENT_CONTEXT_BEFORE_BYTES: usize = 24; +const CONTENT_CONTEXT_AFTER_BYTES: usize = 160; +const CONTENT_SNIPPET_BUFFER_BYTES: usize = 64 * 1024; + +fn content_context_for_path( + path: &str, + content_terms: &[String], + case_insensitive: bool, +) -> Option { + content_terms + .iter() + .find_map(|term| content_context_for_term(path, term, case_insensitive)) +} + +fn content_context_for_term(path: &str, term: &str, case_insensitive: bool) -> Option { + let needle = if case_insensitive { + term.to_ascii_lowercase().into_bytes() + } else { + term.as_bytes().to_vec() + }; + if needle.is_empty() { + return None; + } + + let mut file = File::open(path).ok()?; + let max_context_bytes = CONTENT_CONTEXT_BEFORE_BYTES.max(CONTENT_CONTEXT_AFTER_BYTES); + let overlap = needle.len().saturating_sub(1).max(max_context_bytes); + let mut buffer = vec![0u8; CONTENT_SNIPPET_BUFFER_BYTES + overlap]; + let mut carry_len = 0usize; + let mut consumed_bytes = 0usize; + + loop { + let read = file.read(&mut buffer[carry_len..]).ok()?; + if read == 0 { + return None; + } + + let chunk_len = carry_len + read; + let chunk = &buffer[..chunk_len]; + let mut searchable; + let haystack = if case_insensitive { + searchable = chunk.to_vec(); + searchable.make_ascii_lowercase(); + searchable.as_slice() + } else { + chunk + }; + + if let Some(match_index) = find_bytes(haystack, &needle) { + let before_start = match_index.saturating_sub(CONTENT_CONTEXT_BEFORE_BYTES); + let after_end = + (match_index + needle.len() + CONTENT_CONTEXT_AFTER_BYTES).min(chunk_len); + let mut snippet = chunk[before_start..after_end].to_vec(); + + let desired_after = match_index + needle.len() + CONTENT_CONTEXT_AFTER_BYTES; + let mut has_suffix = after_end < chunk_len; + if desired_after > chunk_len { + let mut extra = vec![0u8; desired_after - chunk_len]; + if let Ok(extra_read) = file.read(&mut extra) { + snippet.extend_from_slice(&extra[..extra_read]); + has_suffix = extra_read == extra.len(); + } + } + + let chunk_start = consumed_bytes.saturating_sub(carry_len); + let absolute_match_index = chunk_start + match_index; + let has_prefix = absolute_match_index > CONTENT_CONTEXT_BEFORE_BYTES; + return Some(format_context_snippet(snippet, has_prefix, has_suffix)); + } + + let keep = overlap.min(chunk_len); + if keep > 0 { + let start = chunk_len - keep; + buffer.copy_within(start..chunk_len, 0); + } + consumed_bytes += read; + carry_len = keep; + } +} + +fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +fn format_context_snippet(snippet: Vec, has_prefix: bool, has_suffix: bool) -> String { + let normalized = String::from_utf8_lossy(&snippet) + .chars() + .map(|ch| match ch { + '\r' | '\n' | '\t' => ' ', + _ => ch, + }) + .collect::(); + let compact = normalized.split_whitespace().collect::>().join(" "); + + match (has_prefix, has_suffix) { + (true, true) => format!("...{compact}..."), + (true, false) => format!("...{compact}"), + (false, true) => format!("{compact}..."), + (false, false) => compact, + } +} + #[tauri::command] pub async fn get_app_status() -> String { load_app_state().as_str().to_string() diff --git a/cardinal/src/App.css b/cardinal/src/App.css index 5d6af384..c1f15401 100644 --- a/cardinal/src/App.css +++ b/cardinal/src/App.css @@ -461,6 +461,7 @@ button:active:not(:disabled) { --columns-total: calc( var(--w-filename) + var(--w-path) + var(--w-size) + var(--w-modified) + var(--w-created) ); + --columns-total-with-context: calc(var(--columns-total) + var(--w-context)); } .scroll-area { @@ -597,6 +598,14 @@ button:active:not(:disabled) { min-width: var(--columns-total); } +.scroll-area--with-context .columns { + grid-template-columns: + var(--w-context) var(--w-filename) var(--w-path) var(--w-size) var(--w-modified) + var(--w-created); + width: var(--columns-total-with-context); + min-width: var(--columns-total-with-context); +} + /* === Header Row === */ .header-row-container { overflow: hidden; @@ -628,6 +637,16 @@ button:active:not(:disabled) { var(--virtual-scrollbar-width); } +.scroll-area--with-context .header-row { + width: calc(var(--columns-total-with-context) + var(--virtual-scrollbar-width)); +} + +.scroll-area--with-context .header-row.columns { + grid-template-columns: + var(--w-context) var(--w-filename) var(--w-path) var(--w-size) var(--w-modified) + var(--w-created) var(--virtual-scrollbar-width); +} + .header { font-weight: 600; color: var(--color-header); @@ -933,6 +952,13 @@ button:active:not(:disabled) { padding: 0 var(--cell-hpad); } +.context-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + padding: 0 var(--cell-hpad); +} + .filename-text { flex: 1; min-width: 0; diff --git a/cardinal/src/App.tsx b/cardinal/src/App.tsx index 16cd5e68..38250588 100644 --- a/cardinal/src/App.tsx +++ b/cardinal/src/App.tsx @@ -30,6 +30,7 @@ import { useAppPreferences } from './hooks/useAppPreferences'; import { useAppWindowListeners } from './hooks/useAppWindowListeners'; import { useFilesTabEffects } from './hooks/useFilesTabEffects'; import { useFilesTabState } from './hooks/useFilesTabState'; +import { extractContentTerms } from './utils/contentQuery'; function App() { const { @@ -262,6 +263,11 @@ function App() { }, []); const selectedIndexSet = useMemo(() => new Set(selectedIndices), [selectedIndices]); + const contentTerms = useMemo(() => extractContentTerms(currentQuery), [currentQuery]); + const showContentContext = contentTerms.length > 0; + const fileRowsWidth = showContentContext + ? 'var(--columns-total-with-context)' + : 'var(--columns-total)'; const handleRowContextMenu = useCallback( (event: ReactMouseEvent, path: string, rowIndex: number) => { @@ -289,7 +295,7 @@ function App() {
); } @@ -299,11 +305,13 @@ function App() { key={item.path} rowIndex={rowIndex} item={item} - style={{ ...rowStyle, width: 'var(--columns-total)' }} + style={{ ...rowStyle, width: fileRowsWidth }} isSelected={selectedIndexSet.has(rowIndex)} selectedPathsForDrag={selectedPaths} caseInsensitive={!caseSensitive} highlightTerms={highlightTerms} + contentTerms={contentTerms} + showContentContext={showContentContext} onContextMenu={handleRowContextMenu} onSelect={handleRowSelect} onOpen={openResultPath} @@ -314,6 +322,9 @@ function App() { handleRowContextMenu, handleRowSelect, highlightTerms, + contentTerms, + showContentContext, + fileRowsWidth, caseSensitive, selectedIndexSet, selectedPaths, @@ -335,6 +346,7 @@ function App() { ({ '--w-filename': `${colWidths.filename}px`, '--w-path': `${colWidths.path}px`, + '--w-context': `${Math.max(420, Math.floor(window.innerWidth * 0.4))}px`, '--w-size': `${colWidths.size}px`, '--w-modified': `${colWidths.modified}px`, '--w-created': `${colWidths.created}px`, @@ -426,6 +438,9 @@ function App() { onSortToggle={handleSortToggle} sortDisabled={sortButtonsDisabled} sortDisabledTooltip={sortDisabledTooltip} + showContentContext={showContentContext} + contentTerms={contentTerms} + caseInsensitive={!caseSensitive} /> )}
diff --git a/cardinal/src/components/ColumnHeader.tsx b/cardinal/src/components/ColumnHeader.tsx index 7de4f9a8..071c85a3 100644 --- a/cardinal/src/components/ColumnHeader.tsx +++ b/cardinal/src/components/ColumnHeader.tsx @@ -27,18 +27,30 @@ type ColumnHeaderProps = { onSortToggle: (sortKey: SortKey) => void; sortDisabled: boolean; sortDisabledTooltip: string | null; + showContentContext: boolean; }; // Column widths are applied via CSS vars on container; no need to pass colWidths prop. export const ColumnHeader = forwardRef( ( - { onResizeStart, onContextMenu, sortState, onSortToggle, sortDisabled, sortDisabledTooltip }, + { + onResizeStart, + onContextMenu, + sortState, + onSortToggle, + sortDisabled, + sortDisabledTooltip, + showContentContext, + }, ref, ) => { const { t } = useTranslation(); return (
+ {showContentContext ? ( + {t('columns.context')} + ) : null} {columns.map(({ key, labelKey, className }) => { const label = t(labelKey); const sortKey = sortableColumns[key]; diff --git a/cardinal/src/components/FileRow.tsx b/cardinal/src/components/FileRow.tsx index 695dba11..ee077fd9 100644 --- a/cardinal/src/components/FileRow.tsx +++ b/cardinal/src/components/FileRow.tsx @@ -21,6 +21,8 @@ type FileRowProps = { selectedPathsForDrag?: string[]; caseInsensitive?: boolean; highlightTerms?: readonly string[]; + contentTerms?: readonly string[]; + showContentContext?: boolean; }; export const FileRow = memo(function FileRow({ @@ -34,6 +36,8 @@ export const FileRow = memo(function FileRow({ selectedPathsForDrag = [], caseInsensitive, highlightTerms, + contentTerms, + showContentContext = false, }: FileRowProps): React.JSX.Element { const pendingSelectRef = useRef<{ isShift: boolean; @@ -148,6 +152,19 @@ export const FileRow = memo(function FileRow({ aria-selected={isSelected} title={path} > + {showContentContext ? ( + item.contentContext ? ( + + ) : ( + + ) + ) : null}
{item.icon ? ( icon diff --git a/cardinal/src/components/FilesTabContent.tsx b/cardinal/src/components/FilesTabContent.tsx index 267e2ad7..caaeef0a 100644 --- a/cardinal/src/components/FilesTabContent.tsx +++ b/cardinal/src/components/FilesTabContent.tsx @@ -36,6 +36,9 @@ type FilesTabContentProps = { onSortToggle: (sortKey: SortKey) => void; sortDisabled: boolean; sortDisabledTooltip: string | null; + showContentContext: boolean; + contentTerms: readonly string[]; + caseInsensitive: boolean; }; export function FilesTabContent({ @@ -58,9 +61,12 @@ export function FilesTabContent({ onSortToggle, sortDisabled, sortDisabledTooltip, + showContentContext, + contentTerms, + caseInsensitive, }: FilesTabContentProps): React.JSX.Element { return ( -
+
{displayState !== 'results' ? ( @@ -88,6 +95,8 @@ export function FilesTabContent({ overscan={overscan} renderRow={renderRow} onScrollSync={onScrollSync} + contentTerms={contentTerms} + caseInsensitive={caseInsensitive} /> )}
diff --git a/cardinal/src/components/MiddleEllipsisHighlight.tsx b/cardinal/src/components/MiddleEllipsisHighlight.tsx index b024bc46..d086e7c8 100644 --- a/cardinal/src/components/MiddleEllipsisHighlight.tsx +++ b/cardinal/src/components/MiddleEllipsisHighlight.tsx @@ -131,11 +131,44 @@ function applyMiddleEllipsis(parts: HighlightSegment[], maxChars: number): Highl return [...leftParts, { text: '…', isHighlight: false }, ...rightParts]; } +function applyEndEllipsis(parts: HighlightSegment[], maxChars: number): HighlightSegment[] { + if (maxChars <= 1) { + return [{ text: '…', isHighlight: false }]; + } + + const totalLength = parts.reduce((sum, part) => sum + part.text.length, 0); + if (totalLength <= maxChars) { + return parts; + } + + const visibleChars = maxChars - 1; + const visibleParts: HighlightSegment[] = []; + let visibleCount = 0; + for (const part of parts) { + const remainingSpace = visibleChars - visibleCount; + if (remainingSpace <= 0) break; + + if (part.text.length <= remainingSpace) { + visibleParts.push(part); + visibleCount += part.text.length; + } else { + visibleParts.push({ + text: part.text.slice(0, remainingSpace), + isHighlight: part.isHighlight, + }); + break; + } + } + + return [...visibleParts, { text: '…', isHighlight: false }]; +} + type MiddleEllipsisHighlightProps = { text: string; className?: string; highlightTerms?: readonly string[]; caseInsensitive?: boolean; + ellipsisMode?: 'middle' | 'end'; }; export function MiddleEllipsisHighlight({ @@ -143,6 +176,7 @@ export function MiddleEllipsisHighlight({ className, highlightTerms, caseInsensitive, + ellipsisMode = 'middle', }: MiddleEllipsisHighlightProps): React.JSX.Element { const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(0); @@ -157,8 +191,10 @@ export function MiddleEllipsisHighlight({ if (!containerWidth || highlightedParts.length === 0) return highlightedParts; const maxChars = Math.floor(containerWidth / CHAR_WIDTH) - 1; - return applyMiddleEllipsis(highlightedParts, maxChars); - }, [highlightedParts, containerWidth]); + return ellipsisMode === 'end' + ? applyEndEllipsis(highlightedParts, maxChars) + : applyMiddleEllipsis(highlightedParts, maxChars); + }, [highlightedParts, containerWidth, ellipsisMode]); // Measure before paint so result refreshes do not flash the full text before truncation. // ResizeObserver keeps truncation in sync with later layout shifts. diff --git a/cardinal/src/components/VirtualList.tsx b/cardinal/src/components/VirtualList.tsx index 838ac2a5..713eb49c 100644 --- a/cardinal/src/components/VirtualList.tsx +++ b/cardinal/src/components/VirtualList.tsx @@ -39,6 +39,8 @@ type VirtualListProps = { rowStyle: CSSProperties, ) => React.ReactNode; onScrollSync: (scrollLeft: number) => void; + contentTerms?: readonly string[]; + caseInsensitive?: boolean; }; // Virtualized list with lazy row hydration plus a short-lived frozen viewport during @@ -52,6 +54,8 @@ export const VirtualList = forwardRef(funct overscan, renderRow, onScrollSync, + contentTerms = [], + caseInsensitive = false, }, ref, ) { @@ -67,7 +71,12 @@ export const VirtualList = forwardRef(funct const rowCount = results.length; // ----- data loader ----- - const { cache, ensureRangeLoaded } = useDataLoader(results, dataResultsVersion); + const { cache, ensureRangeLoaded } = useDataLoader( + results, + dataResultsVersion, + contentTerms, + caseInsensitive, + ); // Virtualized height powers the scrollbar math const totalHeight = rowCount * rowHeight; diff --git a/cardinal/src/hooks/__tests__/useDataLoader.test.ts b/cardinal/src/hooks/__tests__/useDataLoader.test.ts index 28daba49..2bc8d445 100644 --- a/cardinal/src/hooks/__tests__/useDataLoader.test.ts +++ b/cardinal/src/hooks/__tests__/useDataLoader.test.ts @@ -32,6 +32,17 @@ const renderDataLoader = (initialProps: HookProps) => initialProps, }); +const renderDataLoaderWithContent = ( + initialProps: HookProps & { contentTerms: string[]; caseInsensitive: boolean }, +) => + renderHook( + ({ results, version, contentTerms, caseInsensitive }) => + useDataLoader(results, version, contentTerms, caseInsensitive), + { + initialProps, + }, + ); + const createDeferred = () => { let resolve!: (value: T) => void; const promise = new Promise((res) => { @@ -159,4 +170,23 @@ describe('useDataLoader', () => { expect(iconUpdateUnlisten).toHaveBeenCalled(); }); + + it('passes content snippet options to node info requests', async () => { + const { result } = renderDataLoaderWithContent({ + results: [11 as SlabIndex], + version: 1, + contentTerms: ['needle'], + caseInsensitive: true, + }); + + await act(async () => { + await result.current.ensureRangeLoaded(0, 0); + }); + + expect(mockedInvoke).toHaveBeenCalledWith('get_nodes_info', { + results: [11], + contentTerms: ['needle'], + caseInsensitive: true, + }); + }); }); diff --git a/cardinal/src/hooks/useDataLoader.ts b/cardinal/src/hooks/useDataLoader.ts index 2b2b19ec..2871c4bf 100644 --- a/cardinal/src/hooks/useDataLoader.ts +++ b/cardinal/src/hooks/useDataLoader.ts @@ -15,11 +15,17 @@ const fromNodeInfo = (node: NodeInfoResponse): SearchResultItem => ({ mtime: node.mtime ?? node.metadata?.mtime, ctime: node.ctime ?? node.metadata?.ctime, icon: node.icon ?? undefined, + contentContext: node.contentContext ?? undefined, }); // Data-only loader for visible rows. It owns row metadata caching and stale-request rejection; // VirtualList handles any temporary frozen-view rendering during result-set swaps. -export function useDataLoader(results: SlabIndex[], dataResultsVersion: number) { +export function useDataLoader( + results: SlabIndex[], + dataResultsVersion: number, + contentTerms: readonly string[] = [], + caseInsensitive = false, +) { const loadingRef = useRef>(new Set()); // Monotonic epoch for range-load requests. A new search result-set bumps this value so // late `get_nodes_info` responses from the previous result-set can be ignored safely. @@ -32,7 +38,11 @@ export function useDataLoader(results: SlabIndex[], dataResultsVersion: number) return initial; }); const resultsRef = useRef([]); + const contentTermsRef = useRef([]); + const caseInsensitiveRef = useRef(caseInsensitive); resultsRef.current = results; + contentTermsRef.current = contentTerms; + caseInsensitiveRef.current = caseInsensitive; // Reset cache state whenever the backing result-set changes so slab-index reuse in the // backend cannot surface stale row data for a newer search result-set. @@ -102,7 +112,11 @@ export function useDataLoader(results: SlabIndex[], dataResultsVersion: number) } if (needLoading.length === 0) return; const versionAtRequest = versionRef.current; - const fetched = await invoke('get_nodes_info', { results: needLoading }); + const fetched = await invoke('get_nodes_info', { + results: needLoading, + contentTerms: contentTermsRef.current, + caseInsensitive: caseInsensitiveRef.current, + }); if (versionRef.current !== versionAtRequest) { // The result-set changed while this request was in flight. Drop the payload instead of // merging stale rows into the cache for the new query. diff --git a/cardinal/src/i18n/resources/ar-SA.json b/cardinal/src/i18n/resources/ar-SA.json index 3abea4ba..1daa88dc 100644 --- a/cardinal/src/i18n/resources/ar-SA.json +++ b/cardinal/src/i18n/resources/ar-SA.json @@ -22,7 +22,8 @@ "path": "المسار", "size": "الحجم", "modified": "تاريخ التعديل", - "created": "تاريخ الإنشاء" + "created": "تاريخ الإنشاء", + "context": "Context" }, "sorting": { "disabled": "تم تعطيل الفرز. كثرة النتائج ({{limit}}+) قد تؤثر على الأداء. يمكنك تعديل العتبة في التفضيلات." diff --git a/cardinal/src/i18n/resources/de-DE.json b/cardinal/src/i18n/resources/de-DE.json index 021f41c9..36256494 100644 --- a/cardinal/src/i18n/resources/de-DE.json +++ b/cardinal/src/i18n/resources/de-DE.json @@ -22,7 +22,8 @@ "path": "Pfad", "size": "Größe", "modified": "Geändert", - "created": "Erstellt" + "created": "Erstellt", + "context": "Context" }, "sorting": { "disabled": "Sortieren ist deaktiviert. Zu viele Ergebnisse ({{limit}}+) können die Leistung beeinträchtigen. Sie können den Schwellenwert in den Einstellungen anpassen." diff --git a/cardinal/src/i18n/resources/en-US.json b/cardinal/src/i18n/resources/en-US.json index bd8065a1..b1db6d17 100644 --- a/cardinal/src/i18n/resources/en-US.json +++ b/cardinal/src/i18n/resources/en-US.json @@ -22,7 +22,8 @@ "path": "Path", "size": "Size", "modified": "Modified", - "created": "Created" + "created": "Created", + "context": "Context" }, "sorting": { "disabled": "Sorting is disabled. Too many results ({{limit}}+) may impact performance. You can adjust the threshold in Preferences." diff --git a/cardinal/src/i18n/resources/es-ES.json b/cardinal/src/i18n/resources/es-ES.json index 3403a72b..eea48f25 100644 --- a/cardinal/src/i18n/resources/es-ES.json +++ b/cardinal/src/i18n/resources/es-ES.json @@ -22,7 +22,8 @@ "path": "Ruta", "size": "Tamaño", "modified": "Modificado", - "created": "Creado" + "created": "Creado", + "context": "Contexto" }, "sorting": { "disabled": "La ordenación está deshabilitada. Demasiados resultados ({{limit}}+) pueden afectar el rendimiento. Puede ajustar el umbral en Preferencias." diff --git a/cardinal/src/i18n/resources/fr-FR.json b/cardinal/src/i18n/resources/fr-FR.json index 6c190379..8d86a781 100644 --- a/cardinal/src/i18n/resources/fr-FR.json +++ b/cardinal/src/i18n/resources/fr-FR.json @@ -22,7 +22,8 @@ "path": "Chemin", "size": "Taille", "modified": "Modifié", - "created": "Créé" + "created": "Créé", + "context": "Context" }, "sorting": { "disabled": "Le tri est désactivé. Trop de résultats ({{limit}}+) peuvent affecter les performances. Vous pouvez ajuster le seuil dans les Préférences." diff --git a/cardinal/src/i18n/resources/hi-IN.json b/cardinal/src/i18n/resources/hi-IN.json index aad3774e..b5a59086 100644 --- a/cardinal/src/i18n/resources/hi-IN.json +++ b/cardinal/src/i18n/resources/hi-IN.json @@ -22,7 +22,8 @@ "path": "पथ", "size": "आकार", "modified": "संशोधित", - "created": "निर्मित" + "created": "निर्मित", + "context": "Context" }, "sorting": { "disabled": "सॉर्टिंग बंद है। बहुत अधिक परिणाम ({{limit}}+) प्रदर्शन को प्रभावित कर सकते हैं। आप सीमा को प्राथमिकताओं में बदल सकते हैं।" diff --git a/cardinal/src/i18n/resources/it-IT.json b/cardinal/src/i18n/resources/it-IT.json index 045e09f6..0df142f0 100644 --- a/cardinal/src/i18n/resources/it-IT.json +++ b/cardinal/src/i18n/resources/it-IT.json @@ -22,7 +22,8 @@ "path": "Percorso", "size": "Dimensione", "modified": "Modificato", - "created": "Creato" + "created": "Creato", + "context": "Context" }, "sorting": { "disabled": "L'ordinamento è disattivato. Troppi risultati ({{limit}}+) potrebbero influire sulle prestazioni. Puoi regolare la soglia in Preferenze." diff --git a/cardinal/src/i18n/resources/ja-JP.json b/cardinal/src/i18n/resources/ja-JP.json index 26ad1f4b..b39adab4 100644 --- a/cardinal/src/i18n/resources/ja-JP.json +++ b/cardinal/src/i18n/resources/ja-JP.json @@ -22,7 +22,8 @@ "path": "パス", "size": "サイズ", "modified": "更新日時", - "created": "作成日時" + "created": "作成日時", + "context": "Context" }, "sorting": { "disabled": "並べ替えは無効化されています。結果が多すぎる ({{limit}}+) とパフォーマンスに影響する可能性があります。環境設定でしきい値を調整できます。" diff --git a/cardinal/src/i18n/resources/ko-KR.json b/cardinal/src/i18n/resources/ko-KR.json index 9b53098e..afc826ae 100644 --- a/cardinal/src/i18n/resources/ko-KR.json +++ b/cardinal/src/i18n/resources/ko-KR.json @@ -22,7 +22,8 @@ "path": "경로", "size": "크기", "modified": "수정됨", - "created": "생성됨" + "created": "생성됨", + "context": "Context" }, "sorting": { "disabled": "정렬이 비활성화되었습니다. 결과가 너무 많으면 ({{limit}}+) 성능에 영향을 줄 수 있습니다. 환경설정에서 임계값을 조정할 수 있습니다." diff --git a/cardinal/src/i18n/resources/pt-BR.json b/cardinal/src/i18n/resources/pt-BR.json index 9b9f383c..34ce83dd 100644 --- a/cardinal/src/i18n/resources/pt-BR.json +++ b/cardinal/src/i18n/resources/pt-BR.json @@ -22,7 +22,8 @@ "path": "Caminho", "size": "Tamanho", "modified": "Modificado", - "created": "Criado" + "created": "Criado", + "context": "Context" }, "sorting": { "disabled": "A ordenação está desativada. Muitos resultados ({{limit}}+) podem afetar o desempenho. Ajuste o limite em Preferências." diff --git a/cardinal/src/i18n/resources/ru-RU.json b/cardinal/src/i18n/resources/ru-RU.json index 275ff36e..cb64b462 100644 --- a/cardinal/src/i18n/resources/ru-RU.json +++ b/cardinal/src/i18n/resources/ru-RU.json @@ -22,7 +22,8 @@ "path": "Путь", "size": "Размер", "modified": "Изменено", - "created": "Создано" + "created": "Создано", + "context": "Context" }, "sorting": { "disabled": "Сортировка отключена. Слишком много результатов ({{limit}}+) может повлиять на производительность. Вы можете изменить порог в Настройках." diff --git a/cardinal/src/i18n/resources/tr-TR.json b/cardinal/src/i18n/resources/tr-TR.json index 02fea62a..748de385 100644 --- a/cardinal/src/i18n/resources/tr-TR.json +++ b/cardinal/src/i18n/resources/tr-TR.json @@ -22,7 +22,8 @@ "path": "Yol", "size": "Boyut", "modified": "Değiştirildi", - "created": "Oluşturuldu" + "created": "Oluşturuldu", + "context": "Context" }, "sorting": { "disabled": "Sıralama devre dışı. Çok fazla sonuç ({{limit}}+) performansı etkileyebilir. Eşiği Tercihler'de ayarlayabilirsiniz." diff --git a/cardinal/src/i18n/resources/uk-UA.json b/cardinal/src/i18n/resources/uk-UA.json index 977579d9..4b1fa725 100644 --- a/cardinal/src/i18n/resources/uk-UA.json +++ b/cardinal/src/i18n/resources/uk-UA.json @@ -22,7 +22,8 @@ "path": "Шлях", "size": "Розмір", "modified": "Змінено", - "created": "Створено" + "created": "Створено", + "context": "Context" }, "sorting": { "disabled": "Сортування вимкнено. Забагато результатів ({{limit}}+) може вплинути на продуктивність. Ви можете змінити поріг у Налаштуваннях." diff --git a/cardinal/src/i18n/resources/zh-CN.json b/cardinal/src/i18n/resources/zh-CN.json index e0441f6f..dca52ff4 100644 --- a/cardinal/src/i18n/resources/zh-CN.json +++ b/cardinal/src/i18n/resources/zh-CN.json @@ -22,7 +22,8 @@ "path": "路径", "size": "大小", "modified": "修改时间", - "created": "创建时间" + "created": "创建时间", + "context": "Context" }, "sorting": { "disabled": "排序已禁用。结果超过 {{limit}} 个可能影响性能。您可以在偏好设置中调整阈值。" diff --git a/cardinal/src/i18n/resources/zh-TW.json b/cardinal/src/i18n/resources/zh-TW.json index 95b35586..2f0bbe9c 100644 --- a/cardinal/src/i18n/resources/zh-TW.json +++ b/cardinal/src/i18n/resources/zh-TW.json @@ -22,7 +22,8 @@ "path": "路徑", "size": "大小", "modified": "修改時間", - "created": "建立時間" + "created": "建立時間", + "context": "Context" }, "sorting": { "disabled": "排序已停用。結果超過 {{limit}} 個可能影響效能。你可以在偏好設定中調整門檻。" diff --git a/cardinal/src/types/search.ts b/cardinal/src/types/search.ts index 3b3d3a7e..7867cc76 100644 --- a/cardinal/src/types/search.ts +++ b/cardinal/src/types/search.ts @@ -12,6 +12,7 @@ export type SearchResultItem = Readonly<{ mtime?: number; ctime?: number; icon?: string; + contentContext?: string; }>; export type NodeInfoResponse = Readonly<{ @@ -21,4 +22,5 @@ export type NodeInfoResponse = Readonly<{ size?: number | null; mtime?: number | null; ctime?: number | null; + contentContext?: string | null; }>; diff --git a/cardinal/src/utils/__tests__/contentQuery.test.ts b/cardinal/src/utils/__tests__/contentQuery.test.ts new file mode 100644 index 00000000..240a2a20 --- /dev/null +++ b/cardinal/src/utils/__tests__/contentQuery.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { extractContentTerms } from '../contentQuery'; + +describe('extractContentTerms', () => { + it('extracts bare and quoted content filters', () => { + expect(extractContentTerms('report content:foo content:"bar baz"')).toEqual(['foo', 'bar baz']); + }); + + it('unescapes quoted values and ignores empty filters', () => { + expect(extractContentTerms(String.raw`content:"foo\"bar" content:"" content:`)).toEqual([ + 'foo"bar', + ]); + }); + + it('deduplicates repeated values while preserving order', () => { + expect(extractContentTerms('content:foo content:bar content:foo')).toEqual(['foo', 'bar']); + }); +}); diff --git a/cardinal/src/utils/contentQuery.ts b/cardinal/src/utils/contentQuery.ts new file mode 100644 index 00000000..413f9381 --- /dev/null +++ b/cardinal/src/utils/contentQuery.ts @@ -0,0 +1,64 @@ +const CONTENT_PREFIX = 'content:'; + +const readQuotedValue = (query: string, start: number): { value: string; end: number } => { + let value = ''; + let index = start; + + while (index < query.length) { + const char = query[index]; + if (char === '\\' && index + 1 < query.length) { + value += query[index + 1]; + index += 2; + continue; + } + if (char === '"') { + return { value, end: index + 1 }; + } + value += char; + index += 1; + } + + return { value, end: index }; +}; + +const readBareValue = (query: string, start: number): { value: string; end: number } => { + let index = start; + while (index < query.length && !/\s/.test(query[index])) { + index += 1; + } + return { value: query.slice(start, index), end: index }; +}; + +export const extractContentTerms = (query: string | null | undefined): string[] => { + if (!query) { + return []; + } + + const terms: string[] = []; + const seen = new Set(); + const lowerQuery = query.toLocaleLowerCase(); + let index = 0; + + while (index < query.length) { + const matchIndex = lowerQuery.indexOf(CONTENT_PREFIX, index); + if (matchIndex === -1) { + break; + } + + const valueStart = matchIndex + CONTENT_PREFIX.length; + const parsed = + query[valueStart] === '"' + ? readQuotedValue(query, valueStart + 1) + : readBareValue(query, valueStart); + const value = parsed.value.trim(); + + if (value && !seen.has(value)) { + seen.add(value); + terms.push(value); + } + + index = Math.max(parsed.end, valueStart + 1); + } + + return terms; +}; From 012a83fc77ad21d12a819a4153b97b33fd94a779 Mon Sep 17 00:00:00 2001 From: Francisco X Carrasco Date: Sat, 1 Aug 2026 13:02:35 -0300 Subject: [PATCH 02/10] fix(test): restore jsdom localStorage under Node 22+ Node 22+ defines its own global `localStorage` accessor that returns undefined unless `--localstorage-file` is passed. Vitest's populateGlobal skips copying a jsdom key when one already exists on globalThis unless it is in an internal allowlist, and `localStorage`/`sessionStorage` are not on it (`navigator` is). So jsdom's storage never reached window and every test touching it failed on 51 assertions, while CI on Node 20 stayed green. Dropping Node's own web storage lets vitest install jsdom's, which the Storage.prototype spies in i18n/config.test.ts depend on. CI moves to Node 24 because Node 20 rejects the flag outright. Diagnosis by a prior session; re-applied after its worktree was pruned. Co-Authored-By: Claude Opus 5 --- .github/workflows/frontend-ci.yml | 4 +++- cardinal/package.json | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 9a87bf94..a575a9c4 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -27,7 +27,9 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + # 22+: the test script passes --no-experimental-webstorage, which Node 20 rejects as an + # unknown option. Node 20 reached end of life in April 2026. + node-version: 24 cache: npm cache-dependency-path: cardinal/package-lock.json diff --git a/cardinal/package.json b/cardinal/package.json index e5fe8f2d..0a62b646 100644 --- a/cardinal/package.json +++ b/cardinal/package.json @@ -8,8 +8,8 @@ "build": "vite build", "preview": "vite preview", "tauri": "tauri", - "test": "vitest --watch=false", - "test:watch": "vitest --watch", + "test": "NODE_OPTIONS=--no-experimental-webstorage vitest --watch=false", + "test:watch": "NODE_OPTIONS=--no-experimental-webstorage vitest --watch", "typecheck": "tsc --noEmit", "format": "prettier --write .", "format:check": "prettier --check ." From 19fc6eba5d9ab9c88f8cc75204b3857870091f3e Mon Sep 17 00:00:00 2001 From: Francisco X Carrasco Date: Sat, 1 Aug 2026 13:03:15 -0300 Subject: [PATCH 03/10] feat(cardinal): expose file types and make content matches legible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes that share the search bar and the query pipeline: - File-type dropdown next to the search bar. Picking a type writes it into the query (`type:image`) and the control reads its state back from the text, so the two can never disagree and the syntax stays visible instead of hidden behind a menu. A query it cannot represent faithfully — negated, duplicated, or inside a boolean group — reads as "custom" and is left untouched. Native : the popup, keyboard handling and RTL layout come from the platform. Only the + default macOS chrome is stripped so it reads as part of the toolbar. */ +.file-type-select { + appearance: none; + max-width: 9rem; + height: 1.8rem; + padding: 0 1.1rem 0 0.5rem; + border: none; + border-radius: 0.55rem; + background-color: transparent; + /* Chevron drawn inline so no asset is needed; currentColor keeps it on-theme. */ + background-image: + linear-gradient(45deg, transparent 50%, currentColor 50%), + linear-gradient(135deg, currentColor 50%, transparent 50%); + background-position: + right 0.5rem top 55%, + right 0.26rem top 55%; + background-size: + 0.24rem 0.24rem, + 0.24rem 0.24rem; + background-repeat: no-repeat; + color: var(--color-muted); + font: inherit; + font-size: 0.78rem; + line-height: 1; + text-overflow: ellipsis; + cursor: pointer; +} + +.file-type-select:hover { + background-color: var(--search-toggle-hover-bg); +} + +/* Active filter reads like the case toggle when it is on. React sets the value as a DOM property, + not an attribute, so this needs a class rather than a [value] selector. */ +.file-type-select.is-active { + color: var(--color-accent); +} + +.file-type-select:focus-visible { + outline: 2px solid rgba(var(--color-accent-rgb), 0.5); + outline-offset: 2px; +} + +.file-type-select option { + color: var(--color-text); + background-color: var(--color-bg); +} + .search-option input:focus-visible + .search-option__display { outline: 2px solid rgba(var(--color-accent-rgb), 0.5); outline-offset: 2px; diff --git a/cardinal/src/App.tsx b/cardinal/src/App.tsx index 38250588..511d87db 100644 --- a/cardinal/src/App.tsx +++ b/cardinal/src/App.tsx @@ -30,7 +30,6 @@ import { useAppPreferences } from './hooks/useAppPreferences'; import { useAppWindowListeners } from './hooks/useAppWindowListeners'; import { useFilesTabEffects } from './hooks/useFilesTabEffects'; import { useFilesTabState } from './hooks/useFilesTabState'; -import { extractContentTerms } from './utils/contentQuery'; function App() { const { @@ -53,6 +52,7 @@ function App() { currentQuery, currentDirectoryQuery, highlightTerms, + contentTerms, showLoadingUI, initialFetchCompleted, durationMs, @@ -263,7 +263,6 @@ function App() { }, []); const selectedIndexSet = useMemo(() => new Set(selectedIndices), [selectedIndices]); - const contentTerms = useMemo(() => extractContentTerms(currentQuery), [currentQuery]); const showContentContext = contentTerms.length > 0; const fileRowsWidth = showContentContext ? 'var(--columns-total-with-context)' @@ -361,6 +360,13 @@ function App() { [colWidths, eventColWidths], ); + // The file-type dropdown rewrites the query text, so it commits like pressing Enter rather than + // waiting out the keystroke debounce: a click should show results now. + const onFileTypeQueryChange = useCallback( + (nextQuery: string) => submitFilesQuery(nextQuery, { immediate: true }), + [submitFilesQuery], + ); + const showFullDiskAccessOverlay = fullDiskAccessStatus === 'denied'; const overlayStatusMessage = isCheckingFullDiskAccess ? t('app.fullDiskAccess.status.checking') @@ -400,6 +406,8 @@ function App() { caseSensitive={caseSensitive} onToggleCaseSensitive={onToggleCaseSensitive} caseSensitiveLabel={caseSensitiveLabel} + fileTypeEnabled={activeTab === 'files'} + onQueryValueChange={onFileTypeQueryChange} onFocus={handleSearchFocus} onBlur={handleSearchBlur} /> diff --git a/cardinal/src/__tests__/App.contextMenu.test.tsx b/cardinal/src/__tests__/App.contextMenu.test.tsx index 2a20b2f5..abfaa416 100644 --- a/cardinal/src/__tests__/App.contextMenu.test.tsx +++ b/cardinal/src/__tests__/App.contextMenu.test.tsx @@ -113,6 +113,7 @@ vi.mock('../hooks/useFileSearch', () => ({ currentQuery: '', currentDirectoryQuery: '', highlightTerms: [], + contentTerms: [], showLoadingUI: false, initialFetchCompleted: true, durationMs: 0, diff --git a/cardinal/src/__tests__/App.searchNavigation.test.tsx b/cardinal/src/__tests__/App.searchNavigation.test.tsx index fd49492c..783d9d5d 100644 --- a/cardinal/src/__tests__/App.searchNavigation.test.tsx +++ b/cardinal/src/__tests__/App.searchNavigation.test.tsx @@ -104,6 +104,7 @@ vi.mock('../hooks/useFileSearch', () => ({ currentQuery: 'needle', currentDirectoryQuery: 'Work/Docs', highlightTerms: [], + contentTerms: [], showLoadingUI: false, initialFetchCompleted: true, durationMs: 0, diff --git a/cardinal/src/components/SearchBar.tsx b/cardinal/src/components/SearchBar.tsx index d0989b77..28c527ed 100644 --- a/cardinal/src/components/SearchBar.tsx +++ b/cardinal/src/components/SearchBar.tsx @@ -1,6 +1,14 @@ import React, { useCallback, useEffect, useRef } from 'react'; import type { ChangeEvent, FocusEventHandler } from 'react'; +import { useTranslation } from 'react-i18next'; import { hasModifierKey } from '../utils/keyboard'; +import { + CUSTOM_FILE_TYPE, + FILE_TYPE_VALUES, + readFileType, + setFileType, + type FileTypeValue, +} from '../utils/fileTypeQuery'; const MACOS_FOLDER_ICON = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAIAAAAACshmLzAAADGUlEQVRYCe1XsW4TQRCdPZ9tEsuxiEmTABIFSijpKKClooiEhNKAlJJIFBRUfAEVEgW/QEoKkCiQKEAUNDRE0EBQFKBACbIT49z57pb39rx3Z0exneSiUGSsOe/tzs68ndmZ3RM5oWP2gMrYd9Eud7mQ6e9vRujwutzpH9zvuwVA47W7r78/Gps4fdsRXRqgKPK9ztsn16YWIPMH7IP1APmBQxZA7c7L1celanWR0lpraLRD6XzFXqWkgDGtvW/NlU/3l5euf4BEmEoNbFHOei+gpLVy7uarX18dkaJWWiKshyD6icYddLsQdAuOFCDr4KfYOYwgEumo7W01l5/Nzz2AeAMc0PWk8a22V6SBvVYfixExjAJ2AQ/+GwBDIkC9Xe+NSbGyeGP5i/tiYe4edDYsgMJ2G6FUWNoIZFesdKx46BSoJVACJpDIcW9hzkNwAkC1PWxo2tejgRhqtE+AoC1gtJht4xSxHpC/vi9n6xNyabouk9US4mu3R5+mA76G2FObW758/rkh65vb1GJSPQFQr47LlYvTRn0A4c6o+3pEQIxurVI2Nt6srHOWWWECYHbmjHgB0g87e9hGHNFmj5gK4xRmGGZnJuV9dzQBMHaqJF4YLxuiGI6B9Gg54AuzgKSERVSEtiwlALBsAUhTaLSOBa3QYf9tTbFpntWXAAgiFCYESkX5br6sMeNVhFgyC0wARPS+sT1CVevVuv+3jIkEQIj6q1S+rt8LmQ0Jx3sAAMFec/Ltz5wzPQCO1QORSZGjKcO73ZeG+j/yQBggCwfdxHav46A92qRcPDvxgOnToSlER3UiosjEF500AmkWBPSAOd/hBQdFIe9jmSlOtXhoXrm6ZD0Qtdo7UnZdhAGCuG4pVqu89iRU8ZCTEB6Aq73AXAfRm9aBnUajuVafqJw3wFiSc64Jpvh0S3Cj2VqDnR3aspWnjvaFy0/fPa+UyjPFYl5Lp4mUOp1IWr734+PS1Xn0roI3LACejwQxBa6BmQ4cs+NoHooYdDJPHN6Gf4M3wH7WAEHYL6M8jUOtIQvCfhfwg+aE5B+lBx09YnlGKQAAAABJRU5ErkJggg=='; @@ -22,6 +30,8 @@ type SearchBarProps = { caseSensitive: boolean; onToggleCaseSensitive: (event: ChangeEvent) => void; caseSensitiveLabel: string; + fileTypeEnabled: boolean; + onQueryValueChange: (value: string) => void; onFocus: FocusEventHandler; onBlur: FocusEventHandler; }; @@ -51,10 +61,27 @@ export function SearchBar({ caseSensitive, onToggleCaseSensitive, caseSensitiveLabel, + fileTypeEnabled, + onQueryValueChange, onFocus, onBlur, }: SearchBarProps): React.JSX.Element { + const { t } = useTranslation(); const directoryInputRef = useRef(null); + const fileType = readFileType(value); + + const handleFileTypeChange = useCallback( + (event: ChangeEvent) => { + const next = event.target.value; + // Selecting the read-only "custom" entry would have nothing to write; the query already + // says something this control cannot express, so leave it untouched. + if (next === CUSTOM_FILE_TYPE) { + return; + } + onQueryValueChange(setFileType(value, next as FileTypeValue | '')); + }, + [onQueryValueChange, value], + ); useEffect(() => { if (directoryScopeOpen) { @@ -173,6 +200,27 @@ export function SearchBar({ />
+ {fileTypeEnabled ? ( + + ) : null}