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; +};