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/AGENTS.md b/AGENTS.md
index 4ad23657..c349786f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,7 +4,8 @@
- Desktop app lives in `cardinal/` (React UI in `src/`, Tauri/native glue in `src-tauri/`, build output in `cardinal/dist/`).
- Workspace crates (root `Cargo.toml`): `lsf/` (CLI), `cardinal-sdk/` (shared types), `fswalk/`, `fs-icon/`, `namepool/`, `query-segmentation/`, `search-cache/`, `search-cancel/`, `cardinal-syntax/`, `slab-mmap/` (mmap-backed slab for the cache), `was/` (CLI that streams FSEvents via the SDK).
- Tests sit next to code; cross-crate cases belong in each crate’s `tests/` directory. Generated outputs (`target/`, `cardinal/dist/`, vendor bundles) stay out of commits.
-- Toolchain pinned via `rust-toolchain.toml` (`nightly-2025-05-09`); install with `rustup toolchain install nightly-2025-05-09`.
+- Toolchain pinned via `rust-toolchain.toml` (`nightly-2025-12-11`); install with `rustup toolchain install nightly-2025-12-11`. Let the rustup shim pick it up rather than putting a toolchain's `bin/` on `PATH`: an older rustc still builds here from cache, but the proc-macro dylibs it emits are rejected by recent macOS dyld with `mis-aligned LINKEDIT string pool` as soon as anything has to be rebuilt.
+- Keep `target/` out of iCloud-synced folders (`CARGO_TARGET_DIR`). If iCloud evicts a fingerprint file, cargo blocks forever in `read()` waiting for a download that never lands.
## Build, Test, and Development Commands
- `cargo check --workspace` — fast compile validation for all crates.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cbaa1e34..aa1140a8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
# Changelog
+## 0.2.0 — 2026-08-01
+- Add an accessible name to the search input, so assistive technologies can announce it. Thanks to [@dkattan](https://github.com/dkattan) ([#220](https://github.com/cardisoft/cardinal/pull/220)).
+- Add a file-type dropdown next to the search bar. Picking a type writes it into the query (`type:image`), so the control and the search bar never disagree — and the syntax stays visible instead of hidden behind a menu.
+- Add the `type:email` category (`.eml`, `.emlx`, `.emlxpart`, `.msg`, `.mbox`), with `mail`/`message` as synonyms.
+- Rename "folder scope" to plain "Search in" across all 15 languages.
+- Add a context column for `content:` searches, showing the matching text inside each file with the searched term highlighted.
+- Derive the highlighted content terms from the query parser itself, so negated terms (`!content:`) no longer highlight and `content:"Bearer "` keeps its trailing space.
+- Hydrate result rows in parallel, so icon and content-snippet reads no longer queue up behind each other.
+
## 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.
diff --git a/README.md b/README.md
index cd68fab2..2e28a121 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,12 @@
Cardinal
Fastest and most accurate file search app for macOS.
+
+
+
+
+ Signed and notarized · Apple silicon · macOS 12+
+
Using Cardinal ·
Building Cardinal
@@ -17,13 +23,9 @@
### Download
-Use homebrew:
-
-```bash
-brew install --cask cardinal-search
-```
+[**Download Cardinal 0.2.0 for macOS**](https://github.com/franciscoxc/cardinal/releases/download/v0.2.0/Cardinal_0.2.0_aarch64.dmg) — signed with a Developer ID and notarized by Apple, so it opens without Gatekeeper warnings.
-You can also grab the latest packaged builds from [GitHub Releases](https://github.com/cardisoft/cardinal/releases/).
+Every build lives in [Releases](https://github.com/franciscoxc/cardinal/releases). Open the DMG, drag Cardinal to Applications, and grant Full Disk Access when macOS asks — Cardinal needs it to index and watch your files.
### i18n support
diff --git a/cardinal/app-icon.png b/cardinal/app-icon.png
index 7034823c..97fac2cc 100644
Binary files a/cardinal/app-icon.png and b/cardinal/app-icon.png differ
diff --git a/cardinal/mac-icon_1024x1024.png b/cardinal/mac-icon_1024x1024.png
index 0e4a2119..97fac2cc 100644
Binary files a/cardinal/mac-icon_1024x1024.png and b/cardinal/mac-icon_1024x1024.png differ
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 ."
diff --git a/cardinal/src-tauri/Cargo.lock b/cardinal/src-tauri/Cargo.lock
index e19f1ca0..798d827f 100644
--- a/cardinal/src-tauri/Cargo.lock
+++ b/cardinal/src-tauri/Cargo.lock
@@ -382,7 +382,7 @@ dependencies = [
[[package]]
name = "cardinal"
-version = "0.1.23"
+version = "0.2.0"
dependencies = [
"anyhow",
"base64 0.22.1",
diff --git a/cardinal/src-tauri/Cargo.toml b/cardinal/src-tauri/Cargo.toml
index 386b1944..a113e525 100644
--- a/cardinal/src-tauri/Cargo.toml
+++ b/cardinal/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "cardinal"
-version = "0.1.23"
+version = "0.2.0"
description = "Cardinal is a cross platform file searching tool"
authors = ["ldm2993593805@163.com"]
edition = "2024"
diff --git a/cardinal/src-tauri/icons/128x128.png b/cardinal/src-tauri/icons/128x128.png
index 9c9ea0f8..df1b3ad5 100644
Binary files a/cardinal/src-tauri/icons/128x128.png and b/cardinal/src-tauri/icons/128x128.png differ
diff --git a/cardinal/src-tauri/icons/128x128@2x.png b/cardinal/src-tauri/icons/128x128@2x.png
index db741556..c13417df 100644
Binary files a/cardinal/src-tauri/icons/128x128@2x.png and b/cardinal/src-tauri/icons/128x128@2x.png differ
diff --git a/cardinal/src-tauri/icons/32x32.png b/cardinal/src-tauri/icons/32x32.png
index bb6d7bf5..9e6e4975 100644
Binary files a/cardinal/src-tauri/icons/32x32.png and b/cardinal/src-tauri/icons/32x32.png differ
diff --git a/cardinal/src-tauri/icons/Square107x107Logo.png b/cardinal/src-tauri/icons/Square107x107Logo.png
index cfe1b543..eb21b4d3 100644
Binary files a/cardinal/src-tauri/icons/Square107x107Logo.png and b/cardinal/src-tauri/icons/Square107x107Logo.png differ
diff --git a/cardinal/src-tauri/icons/Square142x142Logo.png b/cardinal/src-tauri/icons/Square142x142Logo.png
index 8dee4711..bc2d6942 100644
Binary files a/cardinal/src-tauri/icons/Square142x142Logo.png and b/cardinal/src-tauri/icons/Square142x142Logo.png differ
diff --git a/cardinal/src-tauri/icons/Square150x150Logo.png b/cardinal/src-tauri/icons/Square150x150Logo.png
index 80047483..41ea6eed 100644
Binary files a/cardinal/src-tauri/icons/Square150x150Logo.png and b/cardinal/src-tauri/icons/Square150x150Logo.png differ
diff --git a/cardinal/src-tauri/icons/Square284x284Logo.png b/cardinal/src-tauri/icons/Square284x284Logo.png
index 2bfa6d48..7dbb8635 100644
Binary files a/cardinal/src-tauri/icons/Square284x284Logo.png and b/cardinal/src-tauri/icons/Square284x284Logo.png differ
diff --git a/cardinal/src-tauri/icons/Square30x30Logo.png b/cardinal/src-tauri/icons/Square30x30Logo.png
index a036fe0a..356f48d4 100644
Binary files a/cardinal/src-tauri/icons/Square30x30Logo.png and b/cardinal/src-tauri/icons/Square30x30Logo.png differ
diff --git a/cardinal/src-tauri/icons/Square310x310Logo.png b/cardinal/src-tauri/icons/Square310x310Logo.png
index 4bc2ea01..edf3403e 100644
Binary files a/cardinal/src-tauri/icons/Square310x310Logo.png and b/cardinal/src-tauri/icons/Square310x310Logo.png differ
diff --git a/cardinal/src-tauri/icons/Square44x44Logo.png b/cardinal/src-tauri/icons/Square44x44Logo.png
index ca3eaacb..dd815fef 100644
Binary files a/cardinal/src-tauri/icons/Square44x44Logo.png and b/cardinal/src-tauri/icons/Square44x44Logo.png differ
diff --git a/cardinal/src-tauri/icons/Square71x71Logo.png b/cardinal/src-tauri/icons/Square71x71Logo.png
index e2c439a5..12e9480f 100644
Binary files a/cardinal/src-tauri/icons/Square71x71Logo.png and b/cardinal/src-tauri/icons/Square71x71Logo.png differ
diff --git a/cardinal/src-tauri/icons/Square89x89Logo.png b/cardinal/src-tauri/icons/Square89x89Logo.png
index 379dc71f..e88f9ff9 100644
Binary files a/cardinal/src-tauri/icons/Square89x89Logo.png and b/cardinal/src-tauri/icons/Square89x89Logo.png differ
diff --git a/cardinal/src-tauri/icons/StoreLogo.png b/cardinal/src-tauri/icons/StoreLogo.png
index f45cf5fe..641c2661 100644
Binary files a/cardinal/src-tauri/icons/StoreLogo.png and b/cardinal/src-tauri/icons/StoreLogo.png differ
diff --git a/cardinal/src-tauri/icons/icon.icns b/cardinal/src-tauri/icons/icon.icns
index d4192237..15905057 100644
Binary files a/cardinal/src-tauri/icons/icon.icns and b/cardinal/src-tauri/icons/icon.icns differ
diff --git a/cardinal/src-tauri/icons/icon.ico b/cardinal/src-tauri/icons/icon.ico
index acac2af1..edcf2b27 100644
Binary files a/cardinal/src-tauri/icons/icon.ico and b/cardinal/src-tauri/icons/icon.ico differ
diff --git a/cardinal/src-tauri/icons/icon.png b/cardinal/src-tauri/icons/icon.png
index 81dbaa62..06e9cc46 100644
Binary files a/cardinal/src-tauri/icons/icon.png and b/cardinal/src-tauri/icons/icon.png differ
diff --git a/cardinal/src-tauri/src/commands.rs b/cardinal/src-tauri/src/commands.rs
index ae863611..5ebb8d0a 100644
--- a/cardinal/src-tauri/src/commands.rs
+++ b/cardinal/src-tauri/src/commands.rs
@@ -19,8 +19,10 @@ use objc2::{
use objc2_app_kit::{NSPasteboard, NSPasteboardItem, NSPasteboardTypeString, NSPasteboardWriting};
use objc2_foundation::{NSArray, NSString, NSURL};
use parking_lot::Mutex;
+use rayon::prelude::*;
use search_cache::{
SearchOptions, SearchOutcome, SearchQuery, SearchResultNode, SlabIndex, SlabNodeMetadata,
+ content_snippet, content_terms_of_query,
};
use search_cancel::CancellationToken;
use serde::{Deserialize, Serialize};
@@ -217,6 +219,8 @@ pub struct NodeInfo {
pub path: String,
pub metadata: Option,
pub icon: Option,
+ #[serde(rename = "contentContext")]
+ pub content_context: Option,
}
#[derive(Serialize, Default)]
@@ -224,6 +228,9 @@ pub struct NodeInfo {
pub struct SearchResponse {
pub results: Vec,
pub highlights: Vec,
+ /// `content:` terms of this query, so the UI highlights in a snippet exactly what the search
+ /// looked for inside files.
+ pub content_terms: Vec,
pub status_code: u8,
}
@@ -291,6 +298,7 @@ pub async fn search(
search_activity::note_search_activity();
let options = options.unwrap_or_default();
+ let content_terms = query.as_deref().map(content_terms_of_query).unwrap_or_default();
let cancellation_token = CancellationToken::new_search();
let (result_tx, result_rx) = bounded(1);
if let Err(e) = state.search_tx.send(SearchJob {
@@ -325,6 +333,7 @@ pub async fn search(
SearchResponse {
results,
highlights,
+ content_terms,
status_code,
}
})
@@ -335,6 +344,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,11 +353,17 @@ pub fn get_nodes_info(
}
let include_icons = include_icons.unwrap_or(true);
+ let content_terms = content_terms.unwrap_or_default();
+ let case_insensitive = case_insensitive.unwrap_or_default();
let nodes = state.request_nodes(results);
+ // Rows are independent, and each one may read a file for its icon and its content snippet.
nodes
- .into_iter()
+ .into_par_iter()
.map(|SearchResultNode { path, metadata }| {
+ let content_context = content_terms
+ .iter()
+ .find_map(|term| content_snippet(&path, term, case_insensitive));
let path = path.to_string_lossy().into_owned();
let icon = if include_icons {
fs_icon::icon_of_path_ns(&path).map(|data| {
@@ -362,6 +379,7 @@ pub fn get_nodes_info(
path,
icon,
metadata: metadata.as_ref().map(NodeInfoMetadata::from_metadata),
+ content_context,
}
})
.collect()
diff --git a/cardinal/src-tauri/tauri.conf.json b/cardinal/src-tauri/tauri.conf.json
index 6b674bef..40d1f524 100644
--- a/cardinal/src-tauri/tauri.conf.json
+++ b/cardinal/src-tauri/tauri.conf.json
@@ -1,8 +1,8 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cardinal",
- "version": "0.1.23",
- "identifier": "com.cardinal.one",
+ "version": "0.2.0",
+ "identifier": "com.franxc.cardinal",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
@@ -26,7 +26,7 @@
"targets": "all",
"category": "Utilities",
"macOS": {
- "signingIdentity": "-",
+ "signingIdentity": "Developer ID Application: Francisco Carrasco (993439K8XB)",
"minimumSystemVersion": "12.0"
},
"icon": [
diff --git a/cardinal/src/App.css b/cardinal/src/App.css
index 5d6af384..94bd6407 100644
--- a/cardinal/src/App.css
+++ b/cardinal/src/App.css
@@ -356,6 +356,57 @@ main,
font-size: 0.78rem;
}
+/* 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.
+ ponytail-keep: matched on a class, not `:not([value=''])`. React sets a controlled select's
+ value as a DOM property and never writes the attribute, so the attribute selector matches on
+ first paint and never updates — the accent colour stays on after switching back to "All". */
+.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;
@@ -461,6 +512,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 +649,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 +688,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 +1003,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..325250a4 100644
--- a/cardinal/src/App.tsx
+++ b/cardinal/src/App.tsx
@@ -52,6 +52,7 @@ function App() {
currentQuery,
currentDirectoryQuery,
highlightTerms,
+ contentTerms,
showLoadingUI,
initialFetchCompleted,
durationMs,
@@ -262,6 +263,10 @@ function App() {
}, []);
const selectedIndexSet = useMemo(() => new Set(selectedIndices), [selectedIndices]);
+ 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 +294,7 @@ function App() {
);
}
@@ -299,11 +304,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 +321,9 @@ function App() {
handleRowContextMenu,
handleRowSelect,
highlightTerms,
+ contentTerms,
+ showContentContext,
+ fileRowsWidth,
caseSensitive,
selectedIndexSet,
selectedPaths,
@@ -335,6 +345,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`,
@@ -349,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')
@@ -358,6 +376,7 @@ function App() {
const searchPlaceholder =
activeTab === 'files' ? t('search.placeholder.files') : t('search.placeholder.events');
const directorySearchPlaceholder = t('search.placeholder.directory');
+ const searchAriaLabel = t('search.aria.searchInput');
const permissionSteps = [
t('app.fullDiskAccess.steps.one'),
t('app.fullDiskAccess.steps.two'),
@@ -374,6 +393,7 @@ function App() {
@@ -426,6 +448,9 @@ function App() {
onSortToggle={handleSortToggle}
sortDisabled={sortButtonsDisabled}
sortDisabledTooltip={sortDisabledTooltip}
+ showContentContext={showContentContext}
+ contentTerms={contentTerms}
+ caseInsensitive={!caseSensitive}
/>
)}
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/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 ? (
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/SearchBar.tsx b/cardinal/src/components/SearchBar.tsx
index d0989b77..856aab9d 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==';
@@ -8,6 +16,7 @@ const MACOS_FOLDER_ICON =
type SearchBarProps = {
inputRef: React.RefObject;
placeholder: string;
+ ariaLabel: string;
value: string;
onChange: (event: ChangeEvent) => void;
onKeyDown: (event: React.KeyboardEvent) => void;
@@ -22,6 +31,8 @@ type SearchBarProps = {
caseSensitive: boolean;
onToggleCaseSensitive: (event: ChangeEvent) => void;
caseSensitiveLabel: string;
+ fileTypeEnabled: boolean;
+ onQueryValueChange: (value: string) => void;
onFocus: FocusEventHandler;
onBlur: FocusEventHandler;
};
@@ -37,6 +48,7 @@ const isCollapsedAtEnd = (input: HTMLInputElement): boolean => {
export function SearchBar({
inputRef,
placeholder,
+ ariaLabel,
value,
onChange,
onKeyDown,
@@ -51,10 +63,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) {
@@ -164,6 +193,7 @@ export function SearchBar({
onChange={onChange}
onKeyDown={handleQueryKeyDown}
placeholder={placeholder}
+ aria-label={ariaLabel}
spellCheck={false}
autoCorrect="off"
autoComplete="off"
@@ -173,6 +203,27 @@ export function SearchBar({
/>
+ {fileTypeEnabled ? (
+
+ {t('search.fileType.label')}
+
+ {t('search.fileType.all')}
+ {FILE_TYPE_VALUES.map((option) => (
+
+ {t(`search.fileType.${option}`)}
+
+ ))}
+ {fileType === CUSTOM_FILE_TYPE ? (
+ {t('search.fileType.custom')}
+ ) : null}
+
+
+ ) : null}
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/components/__tests__/SearchBar.test.tsx b/cardinal/src/components/__tests__/SearchBar.test.tsx
index c5c9cc6c..40eda989 100644
--- a/cardinal/src/components/__tests__/SearchBar.test.tsx
+++ b/cardinal/src/components/__tests__/SearchBar.test.tsx
@@ -4,10 +4,16 @@ import type { ComponentProps } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { SearchBar } from '../SearchBar';
+// The bar now labels the file-type dropdown through i18n; keys are enough for these assertions.
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
const renderSearchBar = (overrides: Partial> = {}) => {
const props: ComponentProps = {
inputRef: createRef(),
placeholder: 'Search',
+ ariaLabel: 'Search input',
value: '',
onChange: vi.fn(),
onKeyDown: vi.fn(),
@@ -22,6 +28,8 @@ const renderSearchBar = (overrides: Partial> =
caseSensitive: false,
onToggleCaseSensitive: vi.fn(),
caseSensitiveLabel: 'Case sensitive',
+ fileTypeEnabled: true,
+ onQueryValueChange: vi.fn(),
onFocus: vi.fn(),
onBlur: vi.fn(),
...overrides,
@@ -196,4 +204,26 @@ describe('SearchBar', () => {
expect(onDirectoryKeyDown).toHaveBeenCalledTimes(1);
expect(document.activeElement).not.toBe(queryInput);
});
+
+ it('writes the picked file type into the query and reflects what is already there', () => {
+ const onQueryValueChange = vi.fn();
+ renderSearchBar({ value: 'informe', onQueryValueChange });
+
+ const select = screen.getByLabelText('search.fileType.label') as HTMLSelectElement;
+ expect(select.value).toBe('');
+
+ fireEvent.change(select, { target: { value: 'image' } });
+ expect(onQueryValueChange).toHaveBeenCalledWith('informe type:image');
+ });
+
+ it('shows a custom entry, and changes nothing, for a query it cannot represent', () => {
+ const onQueryValueChange = vi.fn();
+ renderSearchBar({ value: 'informe !type:image', onQueryValueChange });
+
+ const select = screen.getByLabelText('search.fileType.label') as HTMLSelectElement;
+ expect(select.value).toBe('custom');
+
+ fireEvent.change(select, { target: { value: 'custom' } });
+ expect(onQueryValueChange).not.toHaveBeenCalled();
+ });
});
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/hooks/useFileSearch.ts b/cardinal/src/hooks/useFileSearch.ts
index e9edf73f..68abd82b 100644
--- a/cardinal/src/hooks/useFileSearch.ts
+++ b/cardinal/src/hooks/useFileSearch.ts
@@ -20,6 +20,7 @@ type SearchState = {
currentQuery: string;
currentDirectoryQuery: string;
highlightTerms: string[];
+ contentTerms: string[];
showLoadingUI: boolean;
initialFetchCompleted: boolean;
durationMs: number | null;
@@ -58,6 +59,7 @@ type SearchAction =
duration: number;
count: number;
highlightTerms: string[];
+ contentTerms: string[];
};
}
| {
@@ -79,6 +81,7 @@ const initialSearchState: SearchState = {
currentQuery: '',
currentDirectoryQuery: '',
highlightTerms: [],
+ contentTerms: [],
showLoadingUI: false,
initialFetchCompleted: false,
durationMs: null,
@@ -152,6 +155,7 @@ function reducer(state: SearchState, action: SearchAction): SearchState {
currentQuery: action.payload.query,
currentDirectoryQuery: action.payload.directoryQuery,
highlightTerms: action.payload.highlightTerms,
+ contentTerms: action.payload.contentTerms,
showLoadingUI: false,
initialFetchCompleted: true,
durationMs: action.payload.duration,
@@ -167,6 +171,7 @@ function reducer(state: SearchState, action: SearchAction): SearchState {
durationMs: action.payload.duration,
resultCount: 0,
highlightTerms: [],
+ contentTerms: [],
};
case 'SEARCH_CANCELLED':
return {
@@ -313,9 +318,12 @@ export function useFileSearch(): UseFileSearchResult {
}
const searchResults = rawResults.results as SlabIndex[];
- const highlightTerms = Array.isArray(rawResults.highlights)
- ? rawResults.highlights.filter((term): term is string => typeof term === 'string')
- : [];
+ const stringTerms = (value: unknown): string[] =>
+ Array.isArray(value)
+ ? value.filter((term): term is string => typeof term === 'string')
+ : [];
+ const highlightTerms = stringTerms(rawResults.highlights);
+ const contentTerms = stringTerms(rawResults.contentTerms);
cancelTimer(loadingDelayTimerRef);
@@ -331,6 +339,7 @@ export function useFileSearch(): UseFileSearchResult {
duration,
count: searchResults.length,
highlightTerms,
+ contentTerms,
},
});
} catch (error) {
diff --git a/cardinal/src/i18n/resources/ar-SA.json b/cardinal/src/i18n/resources/ar-SA.json
index 3abea4ba..137fa6de 100644
--- a/cardinal/src/i18n/resources/ar-SA.json
+++ b/cardinal/src/i18n/resources/ar-SA.json
@@ -3,11 +3,31 @@
"placeholder": {
"files": "ابحث عن الملفات والمجلدات...",
"events": "رشّح الأحداث حسب المسار أو الاسم...",
- "directory": "نطاق المجلد..."
+ "directory": "البحث في..."
},
"options": {
"caseSensitive": "تفعيل/إيقاف حساسية حالة الأحرف",
- "directoryScope": "تبديل نطاق المجلد"
+ "directoryScope": "تبديل البحث في المجلد"
+ },
+ "fileType": {
+ "label": "نوع الملف",
+ "custom": "مخصص",
+ "all": "كل الأنواع",
+ "image": "صورة",
+ "video": "فيديو",
+ "audio": "صوت",
+ "doc": "مستند",
+ "pdf": "PDF",
+ "presentation": "عرض تقديمي",
+ "spreadsheet": "جدول بيانات",
+ "email": "بريد",
+ "archive": "أرشيف",
+ "code": "شيفرة",
+ "app": "تطبيق",
+ "folder": "مجلد"
+ },
+ "aria": {
+ "searchInput": "إدخال البحث"
}
},
"stateDisplay": {
@@ -15,14 +35,15 @@
"error": "خطأ في البحث",
"emptyTitle": "لا نتائج لـ \"{{query}}\"",
"emptyMessage": "جرّب تعديل الكلمات المفتاحية أو عوامل التصفية.",
- "emptyTitleWithDirectory": "لا نتائج لـ \"{{query}}\" ضمن نطاق المجلد \"{{directoryQuery}}\""
+ "emptyTitleWithDirectory": "لا توجد نتائج لـ \"{{query}}\" في \"{{directoryQuery}}\""
},
"columns": {
"filename": "اسم الملف",
"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..b21ddc8a 100644
--- a/cardinal/src/i18n/resources/de-DE.json
+++ b/cardinal/src/i18n/resources/de-DE.json
@@ -3,11 +3,31 @@
"placeholder": {
"files": "Nach Dateien und Ordnern suchen...",
"events": "Ereignisse nach Pfad oder Name filtern...",
- "directory": "Ordnerbereich..."
+ "directory": "Suchen in..."
},
"options": {
"caseSensitive": "Groß-/Kleinschreibung beachten",
- "directoryScope": "Ordnerbereich umschalten"
+ "directoryScope": "Suche im Ordner umschalten"
+ },
+ "fileType": {
+ "label": "Dateityp",
+ "custom": "Benutzerdefiniert",
+ "all": "Alle Typen",
+ "image": "Bild",
+ "video": "Video",
+ "audio": "Audio",
+ "doc": "Dokument",
+ "pdf": "PDF",
+ "presentation": "Präsentation",
+ "spreadsheet": "Tabelle",
+ "email": "E-Mail",
+ "archive": "Archiv",
+ "code": "Code",
+ "app": "App",
+ "folder": "Ordner"
+ },
+ "aria": {
+ "searchInput": "Sucheingabe"
}
},
"stateDisplay": {
@@ -15,14 +35,15 @@
"error": "Suchfehler",
"emptyTitle": "Keine Ergebnisse für \"{{query}}\"",
"emptyMessage": "Passe deine Suchbegriffe oder Filter an.",
- "emptyTitleWithDirectory": "Keine Ergebnisse für \"{{query}}\" im Ordnerbereich \"{{directoryQuery}}\""
+ "emptyTitleWithDirectory": "Keine Ergebnisse für \"{{query}}\" in \"{{directoryQuery}}\""
},
"columns": {
"filename": "Dateiname",
"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..a330bd4c 100644
--- a/cardinal/src/i18n/resources/en-US.json
+++ b/cardinal/src/i18n/resources/en-US.json
@@ -3,11 +3,31 @@
"placeholder": {
"files": "Search for files and folders...",
"events": "Filter events by path or name...",
- "directory": "Folder scope..."
+ "directory": "Search in..."
},
"options": {
"caseSensitive": "Toggle case-sensitive matching",
- "directoryScope": "Toggle folder scope"
+ "directoryScope": "Toggle search in folder"
+ },
+ "fileType": {
+ "label": "File type",
+ "custom": "Custom",
+ "all": "All types",
+ "image": "Image",
+ "video": "Video",
+ "audio": "Audio",
+ "doc": "Document",
+ "pdf": "PDF",
+ "presentation": "Presentation",
+ "spreadsheet": "Spreadsheet",
+ "email": "Email",
+ "archive": "Archive",
+ "code": "Code",
+ "app": "App",
+ "folder": "Folder"
+ },
+ "aria": {
+ "searchInput": "Search input"
}
},
"stateDisplay": {
@@ -15,14 +35,15 @@
"error": "Search Error",
"emptyTitle": "No results for \"{{query}}\"",
"emptyMessage": "Try adjusting your keywords or filters.",
- "emptyTitleWithDirectory": "No results for \"{{query}}\" in folder scope \"{{directoryQuery}}\""
+ "emptyTitleWithDirectory": "No results for \"{{query}}\" in \"{{directoryQuery}}\""
},
"columns": {
"filename": "Filename",
"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..62588949 100644
--- a/cardinal/src/i18n/resources/es-ES.json
+++ b/cardinal/src/i18n/resources/es-ES.json
@@ -3,11 +3,31 @@
"placeholder": {
"files": "Buscar archivos y carpetas...",
"events": "Filtrar eventos por ruta o nombre...",
- "directory": "Ámbito de carpeta..."
+ "directory": "Buscar en..."
},
"options": {
"caseSensitive": "Activar coincidencia sensible a mayúsculas",
- "directoryScope": "Alternar ámbito de carpeta"
+ "directoryScope": "Alternar buscar en carpeta"
+ },
+ "fileType": {
+ "label": "Tipo de archivo",
+ "custom": "Personalizado",
+ "all": "Todos los tipos",
+ "image": "Imagen",
+ "video": "Video",
+ "audio": "Audio",
+ "doc": "Documento",
+ "pdf": "PDF",
+ "presentation": "Presentación",
+ "spreadsheet": "Hoja de cálculo",
+ "email": "Email",
+ "archive": "Comprimido",
+ "code": "Código",
+ "app": "Aplicación",
+ "folder": "Carpeta"
+ },
+ "aria": {
+ "searchInput": "Entrada de búsqueda"
}
},
"stateDisplay": {
@@ -15,14 +35,15 @@
"error": "Error de búsqueda",
"emptyTitle": "Sin resultados para \"{{query}}\"",
"emptyMessage": "Prueba ajustando tus palabras clave o filtros.",
- "emptyTitleWithDirectory": "Sin resultados para \"{{query}}\" en el ámbito de carpeta \"{{directoryQuery}}\""
+ "emptyTitleWithDirectory": "Sin resultados para \"{{query}}\" en \"{{directoryQuery}}\""
},
"columns": {
"filename": "Nombre de archivo",
"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..353f6f6c 100644
--- a/cardinal/src/i18n/resources/fr-FR.json
+++ b/cardinal/src/i18n/resources/fr-FR.json
@@ -3,11 +3,31 @@
"placeholder": {
"files": "Rechercher des fichiers et des dossiers...",
"events": "Filtrer les événements par chemin ou nom...",
- "directory": "Portée du dossier..."
+ "directory": "Rechercher dans..."
},
"options": {
"caseSensitive": "Activer la correspondance sensible à la casse",
- "directoryScope": "Afficher la portée du dossier"
+ "directoryScope": "Activer la recherche dans un dossier"
+ },
+ "fileType": {
+ "label": "Type de fichier",
+ "custom": "Personnalisé",
+ "all": "Tous les types",
+ "image": "Image",
+ "video": "Vidéo",
+ "audio": "Audio",
+ "doc": "Document",
+ "pdf": "PDF",
+ "presentation": "Présentation",
+ "spreadsheet": "Tableur",
+ "email": "E-mail",
+ "archive": "Archive",
+ "code": "Code",
+ "app": "Application",
+ "folder": "Dossier"
+ },
+ "aria": {
+ "searchInput": "Entrée de recherche"
}
},
"stateDisplay": {
@@ -15,14 +35,15 @@
"error": "Erreur de recherche",
"emptyTitle": "Aucun résultat pour \"{{query}}\"",
"emptyMessage": "Essayez d'ajuster vos mots-clés ou filtres.",
- "emptyTitleWithDirectory": "Aucun résultat pour \"{{query}}\" dans la portée de dossier \"{{directoryQuery}}\""
+ "emptyTitleWithDirectory": "Aucun résultat pour \"{{query}}\" dans \"{{directoryQuery}}\""
},
"columns": {
"filename": "Nom de fichier",
"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..567589f5 100644
--- a/cardinal/src/i18n/resources/hi-IN.json
+++ b/cardinal/src/i18n/resources/hi-IN.json
@@ -3,11 +3,31 @@
"placeholder": {
"files": "फ़ाइलों और फ़ोल्डरों में खोजें...",
"events": "पथ या नाम से ईवेंट फ़िल्टर करें...",
- "directory": "फ़ोल्डर स्कोप..."
+ "directory": "इसमें खोजें..."
},
"options": {
"caseSensitive": "केस-सेंसिटिव मिलान टॉगल करें",
- "directoryScope": "फ़ोल्डर स्कोप टॉगल करें"
+ "directoryScope": "फ़ोल्डर में खोज टॉगल करें"
+ },
+ "fileType": {
+ "label": "फ़ाइल प्रकार",
+ "custom": "कस्टम",
+ "all": "सभी प्रकार",
+ "image": "छवि",
+ "video": "वीडियो",
+ "audio": "ऑडियो",
+ "doc": "दस्तावेज़",
+ "pdf": "PDF",
+ "presentation": "प्रस्तुति",
+ "spreadsheet": "स्प्रेडशीट",
+ "email": "ईमेल",
+ "archive": "संग्रह",
+ "code": "कोड",
+ "app": "ऐप",
+ "folder": "फ़ोल्डर"
+ },
+ "aria": {
+ "searchInput": "खोज इनपुट"
}
},
"stateDisplay": {
@@ -15,14 +35,15 @@
"error": "खोज त्रुटि",
"emptyTitle": "\"{{query}}\" के लिए कोई परिणाम नहीं",
"emptyMessage": "अपनी कुंजियाँ या फ़िल्टर बदलकर देखिए।",
- "emptyTitleWithDirectory": "फ़ोल्डर स्कोप \"{{directoryQuery}}\" में \"{{query}}\" के लिए कोई परिणाम नहीं"
+ "emptyTitleWithDirectory": "\"{{directoryQuery}}\" में \"{{query}}\" के लिए कोई परिणाम नहीं"
},
"columns": {
"filename": "फ़ाइल नाम",
"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..d4897808 100644
--- a/cardinal/src/i18n/resources/it-IT.json
+++ b/cardinal/src/i18n/resources/it-IT.json
@@ -3,11 +3,31 @@
"placeholder": {
"files": "Cerca file e cartelle...",
"events": "Filtra eventi per percorso o nome...",
- "directory": "Ambito cartella..."
+ "directory": "Cerca in..."
},
"options": {
"caseSensitive": "Attiva/disattiva distinzione tra maiuscole e minuscole",
- "directoryScope": "Mostra ambito cartella"
+ "directoryScope": "Attiva ricerca nella cartella"
+ },
+ "fileType": {
+ "label": "Tipo di file",
+ "custom": "Personalizzato",
+ "all": "Tutti i tipi",
+ "image": "Immagine",
+ "video": "Video",
+ "audio": "Audio",
+ "doc": "Documento",
+ "pdf": "PDF",
+ "presentation": "Presentazione",
+ "spreadsheet": "Foglio di calcolo",
+ "email": "Email",
+ "archive": "Archivio",
+ "code": "Codice",
+ "app": "App",
+ "folder": "Cartella"
+ },
+ "aria": {
+ "searchInput": "Input di ricerca"
}
},
"stateDisplay": {
@@ -15,14 +35,15 @@
"error": "Errore di ricerca",
"emptyTitle": "Nessun risultato per \"{{query}}\"",
"emptyMessage": "Prova a modificare parole chiave o filtri.",
- "emptyTitleWithDirectory": "Nessun risultato per \"{{query}}\" nell'ambito cartella \"{{directoryQuery}}\""
+ "emptyTitleWithDirectory": "Nessun risultato per \"{{query}}\" in \"{{directoryQuery}}\""
},
"columns": {
"filename": "Nome file",
"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..6eb393a0 100644
--- a/cardinal/src/i18n/resources/ja-JP.json
+++ b/cardinal/src/i18n/resources/ja-JP.json
@@ -3,11 +3,31 @@
"placeholder": {
"files": "ファイルやフォルダを検索...",
"events": "パスまたは名前でイベントを絞り込む...",
- "directory": "フォルダ範囲..."
+ "directory": "検索する場所..."
},
"options": {
"caseSensitive": "大文字と小文字を区別する",
- "directoryScope": "フォルダ範囲を切り替え"
+ "directoryScope": "フォルダ内検索を切り替え"
+ },
+ "fileType": {
+ "label": "ファイルの種類",
+ "custom": "カスタム",
+ "all": "すべての種類",
+ "image": "画像",
+ "video": "動画",
+ "audio": "オーディオ",
+ "doc": "書類",
+ "pdf": "PDF",
+ "presentation": "プレゼンテーション",
+ "spreadsheet": "スプレッドシート",
+ "email": "メール",
+ "archive": "アーカイブ",
+ "code": "コード",
+ "app": "アプリ",
+ "folder": "フォルダ"
+ },
+ "aria": {
+ "searchInput": "検索入力"
}
},
"stateDisplay": {
@@ -15,14 +35,15 @@
"error": "検索エラー",
"emptyTitle": "\"{{query}}\" に一致する結果がありません",
"emptyMessage": "キーワードやフィルターを調整してください。",
- "emptyTitleWithDirectory": "フォルダースコープ \"{{directoryQuery}}\" 内で \"{{query}}\" に一致する結果がありません"
+ "emptyTitleWithDirectory": "\"{{directoryQuery}}\" に \"{{query}}\" の結果はありません"
},
"columns": {
"filename": "ファイル名",
"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..01825376 100644
--- a/cardinal/src/i18n/resources/ko-KR.json
+++ b/cardinal/src/i18n/resources/ko-KR.json
@@ -3,11 +3,31 @@
"placeholder": {
"files": "파일과 폴더 검색...",
"events": "경로 또는 이름으로 이벤트 필터링...",
- "directory": "폴더 범위..."
+ "directory": "검색 위치..."
},
"options": {
"caseSensitive": "대소문자 구분 검색 전환",
- "directoryScope": "폴더 범위 전환"
+ "directoryScope": "폴더 내 검색 전환"
+ },
+ "fileType": {
+ "label": "파일 종류",
+ "custom": "사용자 지정",
+ "all": "모든 종류",
+ "image": "이미지",
+ "video": "비디오",
+ "audio": "오디오",
+ "doc": "문서",
+ "pdf": "PDF",
+ "presentation": "프레젠테이션",
+ "spreadsheet": "스프레드시트",
+ "email": "이메일",
+ "archive": "압축 파일",
+ "code": "코드",
+ "app": "앱",
+ "folder": "폴더"
+ },
+ "aria": {
+ "searchInput": "검색 입력"
}
},
"stateDisplay": {
@@ -15,14 +35,15 @@
"error": "검색 오류",
"emptyTitle": "\"{{query}}\"에 대한 결과가 없습니다",
"emptyMessage": "키워드나 필터를 조정해 보세요.",
- "emptyTitleWithDirectory": "폴더 범위 \"{{directoryQuery}}\"에서 \"{{query}}\"에 대한 결과가 없습니다"
+ "emptyTitleWithDirectory": "\"{{directoryQuery}}\"에서 \"{{query}}\" 결과가 없습니다"
},
"columns": {
"filename": "파일 이름",
"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..370f328d 100644
--- a/cardinal/src/i18n/resources/pt-BR.json
+++ b/cardinal/src/i18n/resources/pt-BR.json
@@ -3,11 +3,31 @@
"placeholder": {
"files": "Buscar por arquivos e pastas...",
"events": "Filtrar eventos por caminho ou nome...",
- "directory": "Escopo de pasta..."
+ "directory": "Buscar em..."
},
"options": {
"caseSensitive": "Alternar correspondência sensível a maiúsculas",
- "directoryScope": "Alternar escopo de pasta"
+ "directoryScope": "Alternar buscar na pasta"
+ },
+ "fileType": {
+ "label": "Tipo de arquivo",
+ "custom": "Personalizado",
+ "all": "Todos os tipos",
+ "image": "Imagem",
+ "video": "Vídeo",
+ "audio": "Áudio",
+ "doc": "Documento",
+ "pdf": "PDF",
+ "presentation": "Apresentação",
+ "spreadsheet": "Planilha",
+ "email": "E-mail",
+ "archive": "Compactado",
+ "code": "Código",
+ "app": "Aplicativo",
+ "folder": "Pasta"
+ },
+ "aria": {
+ "searchInput": "Entrada de pesquisa"
}
},
"stateDisplay": {
@@ -15,14 +35,15 @@
"error": "Erro na busca",
"emptyTitle": "Nenhum resultado para \"{{query}}\"",
"emptyMessage": "Tente ajustar suas palavras-chave ou filtros.",
- "emptyTitleWithDirectory": "Nenhum resultado para \"{{query}}\" no escopo de pasta \"{{directoryQuery}}\""
+ "emptyTitleWithDirectory": "Sem resultados para \"{{query}}\" em \"{{directoryQuery}}\""
},
"columns": {
"filename": "Nome do arquivo",
"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..0b0b9651 100644
--- a/cardinal/src/i18n/resources/ru-RU.json
+++ b/cardinal/src/i18n/resources/ru-RU.json
@@ -3,11 +3,31 @@
"placeholder": {
"files": "Поиск файлов и папок...",
"events": "Фильтруйте события по пути или имени...",
- "directory": "Область папок..."
+ "directory": "Искать в..."
},
"options": {
"caseSensitive": "Включить учет регистра",
- "directoryScope": "Переключить область папок"
+ "directoryScope": "Переключить поиск в папке"
+ },
+ "fileType": {
+ "label": "Тип файла",
+ "custom": "Другое",
+ "all": "Все типы",
+ "image": "Изображение",
+ "video": "Видео",
+ "audio": "Аудио",
+ "doc": "Документ",
+ "pdf": "PDF",
+ "presentation": "Презентация",
+ "spreadsheet": "Таблица",
+ "email": "Письмо",
+ "archive": "Архив",
+ "code": "Код",
+ "app": "Программа",
+ "folder": "Папка"
+ },
+ "aria": {
+ "searchInput": "Поле поиска"
}
},
"stateDisplay": {
@@ -15,14 +35,15 @@
"error": "Ошибка поиска",
"emptyTitle": "Нет результатов для \"{{query}}\"",
"emptyMessage": "Попробуйте изменить ключевые слова или фильтры.",
- "emptyTitleWithDirectory": "Нет результатов для \"{{query}}\" в области папки \"{{directoryQuery}}\""
+ "emptyTitleWithDirectory": "Нет результатов для \"{{query}}\" в \"{{directoryQuery}}\""
},
"columns": {
"filename": "Имя файла",
"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..103c1334 100644
--- a/cardinal/src/i18n/resources/tr-TR.json
+++ b/cardinal/src/i18n/resources/tr-TR.json
@@ -3,11 +3,31 @@
"placeholder": {
"files": "Dosya ve klasör arayın...",
"events": "Olayları yol veya ada göre filtreleyin...",
- "directory": "Klasör kapsamı..."
+ "directory": "Şurada ara..."
},
"options": {
"caseSensitive": "Büyük/küçük harfe duyarlı eşleşmeyi değiştir",
- "directoryScope": "Klasör kapsamını değiştir"
+ "directoryScope": "Klasörde aramayı aç/kapat"
+ },
+ "fileType": {
+ "label": "Dosya türü",
+ "custom": "Özel",
+ "all": "Tüm türler",
+ "image": "Görsel",
+ "video": "Video",
+ "audio": "Ses",
+ "doc": "Belge",
+ "pdf": "PDF",
+ "presentation": "Sunum",
+ "spreadsheet": "Elektronik tablo",
+ "email": "E-posta",
+ "archive": "Arşiv",
+ "code": "Kod",
+ "app": "Uygulama",
+ "folder": "Klasör"
+ },
+ "aria": {
+ "searchInput": "Arama girişi"
}
},
"stateDisplay": {
@@ -15,14 +35,15 @@
"error": "Arama hatası",
"emptyTitle": "\"{{query}}\" için sonuç yok",
"emptyMessage": "Anahtar kelimeleri veya filtreleri değiştirmeyi deneyin.",
- "emptyTitleWithDirectory": "\"{{directoryQuery}}\" klasör kapsamında \"{{query}}\" için sonuç yok"
+ "emptyTitleWithDirectory": "\"{{directoryQuery}}\" içinde \"{{query}}\" için sonuç yok"
},
"columns": {
"filename": "Dosya adı",
"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..7b292ffc 100644
--- a/cardinal/src/i18n/resources/uk-UA.json
+++ b/cardinal/src/i18n/resources/uk-UA.json
@@ -3,11 +3,31 @@
"placeholder": {
"files": "Пошук файлів і папок...",
"events": "Фільтруйте події за шляхом чи назвою...",
- "directory": "Область папок..."
+ "directory": "Шукати в..."
},
"options": {
"caseSensitive": "Перемкнути врахування регістру",
- "directoryScope": "Перемкнути область папок"
+ "directoryScope": "Перемкнути пошук у теці"
+ },
+ "fileType": {
+ "label": "Тип файлу",
+ "custom": "Інше",
+ "all": "Усі типи",
+ "image": "Зображення",
+ "video": "Відео",
+ "audio": "Аудіо",
+ "doc": "Документ",
+ "pdf": "PDF",
+ "presentation": "Презентація",
+ "spreadsheet": "Таблиця",
+ "email": "Лист",
+ "archive": "Архів",
+ "code": "Код",
+ "app": "Програма",
+ "folder": "Тека"
+ },
+ "aria": {
+ "searchInput": "Поле пошуку"
}
},
"stateDisplay": {
@@ -15,14 +35,15 @@
"error": "Помилка пошуку",
"emptyTitle": "Немає результатів для «{{query}}»",
"emptyMessage": "Спробуйте змінити ключові слова чи фільтри.",
- "emptyTitleWithDirectory": "Немає результатів для «{{query}}» в області папки «{{directoryQuery}}»"
+ "emptyTitleWithDirectory": "Немає результатів для \"{{query}}\" у \"{{directoryQuery}}\""
},
"columns": {
"filename": "Назва файла",
"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..f01ff4b5 100644
--- a/cardinal/src/i18n/resources/zh-CN.json
+++ b/cardinal/src/i18n/resources/zh-CN.json
@@ -3,11 +3,31 @@
"placeholder": {
"files": "搜索文件和文件夹…",
"events": "按路径或名称筛选事件…",
- "directory": "限定文件夹范围…"
+ "directory": "在此处搜索..."
},
"options": {
"caseSensitive": "切换区分大小写匹配",
- "directoryScope": "切换文件夹范围"
+ "directoryScope": "切换在文件夹中搜索"
+ },
+ "fileType": {
+ "label": "文件类型",
+ "custom": "自定义",
+ "all": "所有类型",
+ "image": "图片",
+ "video": "视频",
+ "audio": "音频",
+ "doc": "文档",
+ "pdf": "PDF",
+ "presentation": "演示文稿",
+ "spreadsheet": "电子表格",
+ "email": "邮件",
+ "archive": "压缩包",
+ "code": "代码",
+ "app": "应用",
+ "folder": "文件夹"
+ },
+ "aria": {
+ "searchInput": "搜索输入"
}
},
"stateDisplay": {
@@ -15,14 +35,15 @@
"error": "搜索出现错误",
"emptyTitle": "\"{{query}}\" 没有匹配结果",
"emptyMessage": "尝试调整关键字或筛选条件。",
- "emptyTitleWithDirectory": "在文件夹范围 \"{{directoryQuery}}\" 中没有找到 \"{{query}}\""
+ "emptyTitleWithDirectory": "在 \"{{directoryQuery}}\" 中没有 \"{{query}}\" 的结果"
},
"columns": {
"filename": "文件名",
"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..5e248a4e 100644
--- a/cardinal/src/i18n/resources/zh-TW.json
+++ b/cardinal/src/i18n/resources/zh-TW.json
@@ -3,11 +3,31 @@
"placeholder": {
"files": "搜尋檔案和資料夾…",
"events": "依路徑或名稱篩選事件…",
- "directory": "限定資料夾範圍…"
+ "directory": "在此處搜尋..."
},
"options": {
"caseSensitive": "切換區分大小寫比對",
- "directoryScope": "切換資料夾範圍"
+ "directoryScope": "切換在資料夾中搜尋"
+ },
+ "fileType": {
+ "label": "檔案類型",
+ "custom": "自訂",
+ "all": "所有類型",
+ "image": "圖片",
+ "video": "影片",
+ "audio": "音訊",
+ "doc": "文件",
+ "pdf": "PDF",
+ "presentation": "簡報",
+ "spreadsheet": "試算表",
+ "email": "郵件",
+ "archive": "壓縮檔",
+ "code": "程式碼",
+ "app": "應用程式",
+ "folder": "資料夾"
+ },
+ "aria": {
+ "searchInput": "搜尋輸入"
}
},
"stateDisplay": {
@@ -15,14 +35,15 @@
"error": "搜尋發生錯誤",
"emptyTitle": "「{{query}}」沒有符合結果",
"emptyMessage": "試著調整關鍵字或篩選條件。",
- "emptyTitleWithDirectory": "在資料夾範圍「{{directoryQuery}}」中沒有找到「{{query}}」"
+ "emptyTitleWithDirectory": "在 \"{{directoryQuery}}\" 中沒有 \"{{query}}\" 的結果"
},
"columns": {
"filename": "檔名",
"path": "路徑",
"size": "大小",
"modified": "修改時間",
- "created": "建立時間"
+ "created": "建立時間",
+ "context": "Context"
},
"sorting": {
"disabled": "排序已停用。結果超過 {{limit}} 個可能影響效能。你可以在偏好設定中調整門檻。"
diff --git a/cardinal/src/types/ipc.ts b/cardinal/src/types/ipc.ts
index b25c391c..20c03da8 100644
--- a/cardinal/src/types/ipc.ts
+++ b/cardinal/src/types/ipc.ts
@@ -33,5 +33,6 @@ export enum SearchStatusCode {
export type SearchResponsePayload = {
results: number[];
highlights?: string[];
+ contentTerms?: string[];
statusCode: SearchStatusCode;
};
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__/fileTypeQuery.test.ts b/cardinal/src/utils/__tests__/fileTypeQuery.test.ts
new file mode 100644
index 00000000..e2a866bb
--- /dev/null
+++ b/cardinal/src/utils/__tests__/fileTypeQuery.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest';
+import { CUSTOM_FILE_TYPE, readFileType, setFileType } from '../fileTypeQuery';
+
+describe('readFileType', () => {
+ it('reads the token the dropdown writes', () => {
+ expect(readFileType('informe type:image')).toBe('image');
+ expect(readFileType('TYPE:PDF')).toBe('pdf');
+ expect(readFileType('informe')).toBe('');
+ });
+
+ it('reports custom when it cannot represent the query faithfully', () => {
+ expect(readFileType('informe !type:image')).toBe(CUSTOM_FILE_TYPE);
+ expect(readFileType('type:image type:video')).toBe(CUSTOM_FILE_TYPE);
+ expect(readFileType('(type:image | *.png)')).toBe(CUSTOM_FILE_TYPE);
+ expect(readFileType('type:unknownthing')).toBe(CUSTOM_FILE_TYPE);
+ });
+
+ it('ignores a type: that is part of a quoted value, not a filter', () => {
+ expect(readFileType('content:"type:image"')).toBe('');
+ });
+});
+
+describe('setFileType', () => {
+ it('replaces its own token and leaves the rest of the query alone', () => {
+ expect(setFileType('informe type:image', 'video')).toBe('informe type:video');
+ expect(setFileType('informe type:image', '')).toBe('informe');
+ expect(setFileType('', 'pdf')).toBe('type:pdf');
+ expect(setFileType('informe', 'email')).toBe('informe type:email');
+ });
+
+ it('keeps tokens it did not write', () => {
+ expect(setFileType('!type:image informe', 'pdf')).toBe('!type:image informe type:pdf');
+ });
+});
diff --git a/cardinal/src/utils/fileTypeQuery.ts b/cardinal/src/utils/fileTypeQuery.ts
new file mode 100644
index 00000000..1fbc8857
--- /dev/null
+++ b/cardinal/src/utils/fileTypeQuery.ts
@@ -0,0 +1,76 @@
+// The file-type dropdown edits the query text instead of holding its own filter state, so the
+// search bar stays the single source of truth and the user sees the syntax it writes.
+//
+// This reads back only the token the dropdown itself can author: one bare `type:` from the
+// closed list below. Anything the widget cannot represent faithfully — a negated `!type:`, two
+// `type:` tokens, or one inside a boolean group — reports CUSTOM_FILE_TYPE so the control says
+// "custom" rather than lying about what the query does. It is deliberately not a query parser:
+// the real parsing lives in cardinal-syntax, and duplicating it here would drift.
+
+/// Values written into the query. Each is the primary alias of a group in `lookup_type_group`
+/// (search-cache/src/query.rs), so what the dropdown writes is exactly what the engine matches.
+export const FILE_TYPE_VALUES = [
+ 'image',
+ 'video',
+ 'audio',
+ 'doc',
+ 'pdf',
+ 'presentation',
+ 'spreadsheet',
+ 'email',
+ 'archive',
+ 'code',
+ 'app',
+ 'folder',
+] as const;
+
+export type FileTypeValue = (typeof FILE_TYPE_VALUES)[number];
+
+export const CUSTOM_FILE_TYPE = 'custom';
+
+// Leading `\s` (or string start) keeps this from matching inside a quoted phrase such as
+// `content:"type:image"`, where the preceding character is a quote.
+const TYPE_TOKEN = /(^|\s)(!?)type:([A-Za-z]+)(?=\s|$)/gi;
+
+// Boolean grouping means a `type:` token may apply to only part of the query, so the dropdown
+// cannot claim it describes the whole result set.
+const HAS_GROUPING = /[()|]|\bOR\b/i;
+
+// ponytail-keep: this looks redundant next to TYPE_TOKEN and is not. TYPE_TOKEN requires
+// whitespace before `type:`, so `(type:image | *.png)` matches zero tokens and readFileType
+// returned '' — the dropdown showed "All types" for a query that filters to images.
+const MENTIONS_TYPE = /(^|[\s(!])type:/i;
+
+const isKnown = (value: string): value is FileTypeValue =>
+ (FILE_TYPE_VALUES as readonly string[]).includes(value.toLowerCase());
+
+/**
+ * The type the dropdown should display: a `FileTypeValue`, `''` for "all", or `CUSTOM_FILE_TYPE`
+ * when the query filters by type in a way the dropdown cannot represent.
+ */
+export const readFileType = (query: string): FileTypeValue | '' | typeof CUSTOM_FILE_TYPE => {
+ if (!MENTIONS_TYPE.test(query)) {
+ return '';
+ }
+ const matches = [...query.matchAll(TYPE_TOKEN)];
+ if (matches.length !== 1 || matches[0][2] === '!' || HAS_GROUPING.test(query)) {
+ return CUSTOM_FILE_TYPE;
+ }
+ const value = matches[0][3].toLowerCase();
+ return isKnown(value) ? (value as FileTypeValue) : CUSTOM_FILE_TYPE;
+};
+
+/**
+ * Query with its file-type filter set to `value` (or removed when `value` is empty). Only tokens
+ * this module recognises are dropped, so a hand-written `!type:` or a grouped one survives.
+ */
+export const setFileType = (query: string, value: FileTypeValue | ''): string => {
+ const withoutKnown = query.replace(TYPE_TOKEN, (token, lead, negation, name: string) =>
+ negation === '' && isKnown(name) ? (lead as string) : token,
+ );
+ const base = withoutKnown.replace(/\s+/g, ' ').trim();
+ if (!value) {
+ return base;
+ }
+ return base ? `${base} type:${value}` : `type:${value}`;
+};
diff --git a/doc/pub/search-syntax.md b/doc/pub/search-syntax.md
index 011265a2..4d9a8ad9 100644
--- a/doc/pub/search-syntax.md
+++ b/doc/pub/search-syntax.md
@@ -168,6 +168,7 @@ These filters take an absolute path as their argument; a leading `~` is expanded
- Presentations: `type:presentation`, `type:presentations`, `type:ppt`, `type:slides`
- Spreadsheets: `type:spreadsheet`, `type:spreadsheets`, `type:xls`, `type:excel`, `type:sheet`, `type:sheets`
- PDF: `type:pdf`
+- Email: `type:email`, `type:emails`, `type:mail`, `type:mails`, `type:message`, `type:messages` — covers `.eml`, `.emlx`, `.emlxpart` (Apple Mail), `.msg` (Outlook) and `.mbox`
- Archives: `type:archive`, `type:archives`, `type:compressed`, `type:zip`
- Code: `type:code`, `type:source`, `type:dev`
- Executables: `type:exe`, `type:exec`, `type:executable`, `type:executables`, `type:program`, `type:programs`, `type:app`, `type:apps`
diff --git a/search-cache/src/cache.rs b/search-cache/src/cache.rs
index 6ef6cc8a..f3ddcb12 100644
--- a/search-cache/src/cache.rs
+++ b/search-cache/src/cache.rs
@@ -2982,6 +2982,44 @@ mod tests {
assert!(nodes[0].path.ends_with("notes.txt"));
}
+ #[test]
+ fn content_snippet_reads_context_across_chunks() {
+ let temp_dir = TempDir::new("content_snippet_reads_context_across_chunks").unwrap();
+ let path = temp_dir.path().join("large.log");
+
+ // Straddle a read boundary: the match starts one byte before the end of the first chunk,
+ // and its trailing context lives in the next one.
+ let mut payload = vec![b'a'; CONTENT_BUFFER_BYTES - 1];
+ payload.extend_from_slice(b"NeedLe tail text\n");
+ payload.extend(std::iter::repeat_n(b'z', 4096));
+ fs::write(&path, &payload).unwrap();
+
+ let snippet = crate::content_snippet(&path, "needle", true).unwrap();
+ assert!(snippet.starts_with('…'), "elided prefix: {snippet}");
+ assert!(snippet.ends_with('…'), "elided suffix: {snippet}");
+ assert!(
+ snippet.contains("NeedLe tail text"),
+ "original case: {snippet}"
+ );
+ assert!(!snippet.contains('\n'), "single row: {snippet}");
+
+ assert!(crate::content_snippet(&path, "needle", false).is_none());
+ assert!(crate::content_snippet(&path, "absent", true).is_none());
+ }
+
+ #[test]
+ fn content_snippet_marks_only_the_edges_it_cuts() {
+ let temp_dir = TempDir::new("content_snippet_marks_only_the_edges_it_cuts").unwrap();
+ let path = temp_dir.path().join("small.txt");
+ fs::write(&path, b"token here").unwrap();
+
+ // Whole file fits in the context window, so neither edge is elided.
+ assert_eq!(
+ crate::content_snippet(&path, "token", false).unwrap(),
+ "token here"
+ );
+ }
+
#[test]
fn content_filter_matches_across_chunks() {
let temp_dir = TempDir::new("content_filter_matches_across_chunks").unwrap();
diff --git a/search-cache/src/highlight.rs b/search-cache/src/highlight.rs
index ca497f3a..b78c396f 100644
--- a/search-cache/src/highlight.rs
+++ b/search-cache/src/highlight.rs
@@ -1,5 +1,7 @@
-use crate::query_preprocessor::strip_query_quotes_text;
-use cardinal_syntax::{ArgumentKind, Expr, FilterArgument, Term};
+use crate::query_preprocessor::{
+ expand_query_home_dirs, strip_query_quotes, strip_query_quotes_text,
+};
+use cardinal_syntax::{ArgumentKind, Expr, FilterArgument, FilterKind, Term, parse_query};
use query_segmentation::{Segment, query_segmentation};
use std::collections::BTreeSet;
@@ -9,6 +11,46 @@ pub fn derive_highlight_terms(expr: &Expr) -> Vec {
collector.into_terms()
}
+/// The `content:` arguments a query searches file bodies for, in query order, after the same
+/// preprocessing the search itself applies. Callers use these to show why a file matched.
+pub fn content_terms_of_query(line: &str) -> Vec {
+ let Ok(parsed) = parse_query(line) else {
+ return Vec::new();
+ };
+ let query = strip_query_quotes(expand_query_home_dirs(parsed));
+ let mut terms = Vec::new();
+ collect_content_terms(&query.expr, &mut terms);
+ terms
+}
+
+fn collect_content_terms(expr: &Expr, terms: &mut Vec) {
+ match expr {
+ // A negated branch matches files *lacking* the term, so it has no occurrence to point at.
+ Expr::Empty | Expr::Not(_) => {}
+ Expr::Term(Term::Filter(filter)) if matches!(filter.kind, FilterKind::Content) => {
+ // ponytail-keep: verbatim, no `.trim()`. Trimming looks harmless and is wrong:
+ // `content:"Bearer "` searches for the trailing space, so a trimmed term stops
+ // matching what the engine matched and the snippet lookup finds nothing.
+ let Some(value) = filter
+ .argument
+ .as_ref()
+ .map(|argument| argument.raw.as_str())
+ else {
+ return;
+ };
+ if !value.is_empty() && !terms.iter().any(|term| term == value) {
+ terms.push(value.to_string());
+ }
+ }
+ Expr::Term(_) => {}
+ Expr::And(parts) | Expr::Or(parts) => {
+ for part in parts {
+ collect_content_terms(part, terms);
+ }
+ }
+ }
+}
+
#[derive(Default)]
struct HighlightCollector {
terms: BTreeSet,
@@ -2772,4 +2814,20 @@ mod tests {
assert_eq!(terms[2], "mmm");
assert_eq!(terms[3], "zzz");
}
+
+ #[test]
+ fn content_terms_keep_query_order_and_skip_negated() {
+ assert_eq!(
+ content_terms_of_query(r#"*.md content:"Bearer " report content:token"#),
+ vec!["Bearer ", "token"]
+ );
+ // The file matched by *lacking* this term, so there is nothing to point at inside it.
+ assert!(content_terms_of_query("report !content:draft").is_empty());
+ assert!(content_terms_of_query("report").is_empty());
+ assert_eq!(
+ content_terms_of_query("content:dup content:dup").len(),
+ 1,
+ "the same term twice is one snippet lookup"
+ );
+ }
}
diff --git a/search-cache/src/lib.rs b/search-cache/src/lib.rs
index a426ce42..c20c8ca3 100644
--- a/search-cache/src/lib.rs
+++ b/search-cache/src/lib.rs
@@ -15,9 +15,11 @@ mod type_and_size;
pub use cache::*;
pub use file_nodes::*;
pub use fswalk::WalkData;
+pub use highlight::content_terms_of_query;
pub use metadata_cache::*;
pub use name_index::*;
pub use persistent::*;
+pub use query::content_snippet;
pub use segment::*;
pub use slab::*;
pub use slab_node::*;
diff --git a/search-cache/src/query.rs b/search-cache/src/query.rs
index a2c16510..cac1336f 100644
--- a/search-cache/src/query.rs
+++ b/search-cache/src/query.rs
@@ -1052,6 +1052,114 @@ impl SearchCache {
}
}
+/// Bytes of surrounding file text kept around a `content:` match.
+const SNIPPET_BEFORE_BYTES: usize = 24;
+const SNIPPET_AFTER_BYTES: usize = 160;
+
+/// One line of file text around the first occurrence of `term`, for showing *why* a `content:`
+/// search matched. Reads in the same chunks with the same finder as
+/// [`SearchCache::node_content_matches`], so a snippet is found wherever the filter found a match.
+//
+// ponytail: no cancellation token; the caller hydrates visible rows only, over files the content
+// filter just read (so the pages are cached). Wire one in if snippets ever run ahead of the search.
+pub fn content_snippet(path: &Path, 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 finder = rabinkarp::Finder::new(&needle);
+ // Carry enough of each chunk to catch a match split across reads *and* to keep the leading
+ // context of a match that lands at the start of the next one.
+ let overlap = needle.len().saturating_sub(1).max(SNIPPET_BEFORE_BYTES);
+ let mut buffer = vec![0u8; CONTENT_BUFFER_BYTES + overlap];
+ // ponytail-keep: the second buffer is not waste. `node_content_matches` right above folds the
+ // chunk in place, which is cheaper and is wrong here: the snippet is cut from these same bytes,
+ // so folding in place returns the matched line entirely in lowercase.
+ let mut folded = Vec::new();
+ let mut carry_len = 0usize;
+ let mut chunk_offset = 0usize;
+
+ loop {
+ let read = file.read(&mut buffer[carry_len..]).ok()?;
+ if read == 0 {
+ return None;
+ }
+ let chunk_len = carry_len + read;
+
+ let haystack = if case_insensitive {
+ folded.resize(chunk_len, 0);
+ folded[carry_len..].copy_from_slice(&buffer[carry_len..chunk_len]);
+ folded[carry_len..].make_ascii_lowercase();
+ &folded[..chunk_len]
+ } else {
+ &buffer[..chunk_len]
+ };
+
+ if let Some(at) = finder.find(haystack, &needle) {
+ return Some(snippet_at(
+ &mut file,
+ &buffer[..chunk_len],
+ at,
+ needle.len(),
+ chunk_offset,
+ ));
+ }
+
+ let keep = overlap.min(chunk_len);
+ buffer.copy_within(chunk_len - keep..chunk_len, 0);
+ if case_insensitive {
+ folded.copy_within(chunk_len - keep..chunk_len, 0);
+ }
+ chunk_offset += chunk_len - keep;
+ carry_len = keep;
+ }
+}
+
+/// `chunk_offset` is the file offset `chunk` starts at, which decides whether the snippet needs a
+/// leading ellipsis.
+fn snippet_at(
+ file: &mut File,
+ chunk: &[u8],
+ at: usize,
+ needle_len: usize,
+ chunk_offset: usize,
+) -> String {
+ let start = at.saturating_sub(SNIPPET_BEFORE_BYTES);
+ let wanted_end = at + needle_len + SNIPPET_AFTER_BYTES;
+ let end = wanted_end.min(chunk.len());
+ let mut bytes = chunk[start..end].to_vec();
+
+ let mut truncated = end < chunk.len();
+ if wanted_end > chunk.len() {
+ // The trailing context runs past what we read; the rest is the next bytes of the file.
+ let mut tail = vec![0u8; wanted_end - chunk.len()];
+ if let Ok(read) = file.read(&mut tail) {
+ bytes.extend_from_slice(&tail[..read]);
+ truncated = read == tail.len();
+ }
+ }
+
+ // Collapse newlines and runs of spaces so the snippet stays on one row, and drop the partial
+ // UTF-8 sequences that cutting on byte boundaries leaves at either edge.
+ let text = String::from_utf8_lossy(&bytes);
+ let compact = text
+ .split_whitespace()
+ .collect::>()
+ .join(" ")
+ .trim_matches(char::REPLACEMENT_CHARACTER)
+ .to_string();
+
+ let prefix = if chunk_offset + start > 0 { "…" } else { "" };
+ let suffix = if truncated { "…" } else { "" };
+ format!("{prefix}{compact}{suffix}")
+}
+
fn normalize_extensions(argument: &FilterArgument) -> HashSet {
let mut values = HashSet::new();
match &argument.kind {
@@ -1124,6 +1232,9 @@ fn lookup_type_group(name: &str) -> Option {
Some(TypeFilterTarget::Extensions(SPREADSHEET_EXTENSIONS))
}
"pdf" => Some(TypeFilterTarget::Extensions(PDF_EXTENSIONS)),
+ "email" | "emails" | "mail" | "mails" | "message" | "messages" => {
+ Some(TypeFilterTarget::Extensions(EMAIL_EXTENSIONS))
+ }
"archive" | "archives" | "compressed" | "zip" => {
Some(TypeFilterTarget::Extensions(ARCHIVE_EXTENSIONS))
}
@@ -1152,6 +1263,9 @@ const DOCUMENT_EXTENSIONS: &[&str] = &[
const PRESENTATION_EXTENSIONS: &[&str] = &["ppt", "pptx", "key", "odp"];
const SPREADSHEET_EXTENSIONS: &[&str] = &["xls", "xlsx", "csv", "numbers", "ods"];
const PDF_EXTENSIONS: &[&str] = &["pdf"];
+// `emlx`/`emlxpart` are Apple Mail's on-disk format, the one nobody finds by name; `msg` is
+// Outlook, `mbox` a whole mailbox exported as one file.
+const EMAIL_EXTENSIONS: &[&str] = &["eml", "emlx", "emlxpart", "msg", "mbox"];
const ARCHIVE_EXTENSIONS: &[&str] = &[
"zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "xz", "zst", "cab", "iso", "dmg",
];
diff --git a/search-cache/src/tests/type_filters.rs b/search-cache/src/tests/type_filters.rs
index 6ba977f8..9344c5ce 100644
--- a/search-cache/src/tests/type_filters.rs
+++ b/search-cache/src/tests/type_filters.rs
@@ -302,6 +302,23 @@ fn test_type_pdf_filter() {
assert_eq!(pdfs.len(), 3);
}
+#[test]
+fn test_type_email_and_aliases() {
+ let tmp = TempDir::new("type_email").unwrap();
+ fs::write(tmp.path().join("plain.eml"), b"x").unwrap();
+ fs::write(tmp.path().join("apple_mail.emlx"), b"x").unwrap();
+ fs::write(tmp.path().join("attachment.emlxpart"), b"x").unwrap();
+ fs::write(tmp.path().join("outlook.msg"), b"x").unwrap();
+ fs::write(tmp.path().join("mailbox.mbox"), b"x").unwrap();
+ fs::write(tmp.path().join("notes.txt"), b"x").unwrap();
+
+ let mut cache = SearchCache::walk_fs(tmp.path());
+
+ assert_eq!(cache.search("type:email").unwrap().len(), 5);
+ assert_eq!(cache.search("type:mail").unwrap().len(), 5);
+ assert_eq!(cache.search("type:emails").unwrap().len(), 5);
+}
+
#[test]
fn test_type_archive_comprehensive() {
let tmp = TempDir::new("type_archive").unwrap();