Skip to content

Commit 600b23c

Browse files
committed
Search + Select: one-click Files/Folders type filter, folder-size matching, and a leaner chip strip
Adds a `Both | Files | Folders` toggle to both query dialogs so users can constrain results to files or folders in one click, makes the size filter work on folders by their recursive size, and removes the "+ Add filter" affordance (all filters are now always visible). - **Type filter (M4).** New cross-consumer core state `typeFilter` (`both`/`file`/`folder`, default `both`) in `createQueryFilterState()`. It maps to the EXISTING IPC `SearchQuery.isDirectory: Option<bool>` in `buildBaseSearchQuery` (`both → null`, `file → false`, `folder → true`): no new `SearchQuery` field, no engine change. Rendered as a `ToggleGroup` leading the shared `FilterChips` strip, so both Search and Select show it. - **Select matcher.** `selection-matching.ts` gains a `type` predicate + a `getIsDirFor` accessor. `SelectionDialog` now builds its `MatchAccessors` through a single `buildAccessors()` helper used at BOTH the `runQuery` preview and `commitMatches` sites, so preview and commit can't drift. `getSizeFor` returns `entry.size` for files and `entry.recursiveSize` for directories (index-derived; `undefined` until the index computes it, in which case the folder honestly can't match a size bound). `hasActiveFilter()` now counts a non-`both` type filter, so a type-only query (empty bar) runs. - **History round-trip.** Additive `is_directory: Option<bool>` on the Rust `HistoryFilters` (shared by Search and Select) with `#[serde(default)]`, so recent-items history round-trips the type filter with NO schema bump (old files load as `None`). Added to both canonical dedupe keys. Bindings regenerated. - **MCP prefill.** `open_search_dialog` accepts an optional `isDirectory` (true = folders, false = files), threaded through `SearchPrefill` / `applySearchPrefill`. - **Remove "+ Add filter".** Deleted the trailing add-filter chip + dropdown from `FilterChips`; all filters are always visible now. Verified: the live Select snapshot (`getFileRange`) carries `recursiveSize` for dirs because listing entries are enriched at cache-write time, so no backend enrichment was needed. TDD: red→green for the matcher type predicate and folder-size fallback; new tests for the `typeFilter → isDirectory` mapping, the history round-trip (asserting no schema bump), the toggle render/selection + a11y, and the removed Add-filter menu.
1 parent 0071a00 commit 600b23c

21 files changed

Lines changed: 430 additions & 237 deletions

apps/desktop/src-tauri/src/mcp/DETAILS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Expose Cmdr functionality to AI agents via the Model Context Protocol (MCP). Age
3434
- File operations (6): `copy`, `move`, `delete`, `mkdir`, `mkfile`, `refresh` (a round-trip that forces a backend re-read of the focused pane's listing — local volumes re-read from disk; watcher-backed MTP/SMB listings short-circuit). `copy`/`move`/`delete` fast-fail with the real cause when there's nothing to act on (no selection and the cursor is on `..`, or the pane shows no files) instead of a misleading ack timeout. `copy`/`move` accept optional `autoConfirm` (bool) and `onConflict` (`skip_all`|`overwrite_all`|`rename_all`). `onConflict` governs clashing **files only** — folders always merge (a source folder landing on a same-named dest folder merges into it; the policy then applies to the files inside). `delete` accepts optional `autoConfirm`. When `autoConfirm` is true, the dialog opens and immediately confirms.
3535
- View (3): `sort`, `toggle_hidden`, `set_view_mode`
3636
- Tabs (1): `tab` (unified: `action` = `new` | `close` | `close_others` | `activate` | `set_pinned`; `tabId` defaults to active tab for close/close_others/set_pinned, required for activate; `pinned` boolean for set_pinned)
37-
- Dialogs (2): `dialog` (unified open/focus/close/confirm). `action: "confirm"` programmatically confirms an open dialog. For `transfer-confirmation`: accepts optional `onConflict`. For `delete-confirmation`: just confirms. `type: "transfer-confirmation"` is the primary name (covers copy and move); `"copy-confirmation"` is accepted as an alias. `open_search_dialog` opens the whole-drive search overlay with optional pre-filled `query`, `mode` (`ai`/`filename`/`regex`), `sizeMin`/`sizeMax` (bytes), `modifiedAfter`/`modifiedBefore` (ISO date), `scope` (chip syntax), `caseSensitive`, `excludeSystemDirs`, and `autoRun` (default true: runs the search after open). Acks on `SoftDialogAppeared("search")` within the 1500 ms budget. **Race-with-close caveat**: if the dialog is mid-close when the event lands, the new mount may race; the ack times out and the tool surfaces a clean failure rather than a false-positive OK (per plan §5.7 risk register).
37+
- Dialogs (2): `dialog` (unified open/focus/close/confirm). `action: "confirm"` programmatically confirms an open dialog. For `transfer-confirmation`: accepts optional `onConflict`. For `delete-confirmation`: just confirms. `type: "transfer-confirmation"` is the primary name (covers copy and move); `"copy-confirmation"` is accepted as an alias. `open_search_dialog` opens the whole-drive search overlay with optional pre-filled `query`, `mode` (`ai`/`filename`/`regex`), `sizeMin`/`sizeMax` (bytes), `modifiedAfter`/`modifiedBefore` (ISO date), `isDirectory` (true = folders only, false = files only, omit for both), `scope` (chip syntax), `caseSensitive`, `excludeSystemDirs`, and `autoRun` (default true: runs the search after open). Acks on `SoftDialogAppeared("search")` within the 1500 ms budget. **Race-with-close caveat**: if the dialog is mid-close when the event lands, the new mount may race; the ack times out and the tool surfaces a clean failure rather than a false-positive OK (per plan §5.7 risk register).
3838
- App (3): `switch_pane`, `swap_panes`, `quit`
3939
- Search (2): `search` (structured file search across the drive index, optional `scope` for path/exclude filtering), `ai_search` (natural language search using configured LLM, optional `scope` merged with AI-inferred scope)
4040
- Settings (1): `set_setting` (change a setting value via round-trip to frontend)

apps/desktop/src-tauri/src/mcp/executor/dialogs.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ pub async fn execute_open_search_dialog<R: Runtime>(app: &AppHandle<R>, params:
347347
"sizeMax",
348348
"modifiedAfter",
349349
"modifiedBefore",
350+
"isDirectory",
350351
"scope",
351352
"caseSensitive",
352353
"excludeSystemDirs",

apps/desktop/src-tauri/src/mcp/tools.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,10 @@ fn get_dialog_tools() -> Vec<Tool> {
403403
"type": "string",
404404
"description": "ISO date string"
405405
},
406+
"isDirectory": {
407+
"type": "boolean",
408+
"description": "Type filter: true = folders only, false = files only, omit for both"
409+
},
406410
"scope": {
407411
"type": "string",
408412
"description": "Scope string, same syntax as the scope chip: comma-separated paths, ! prefix for excludes"
@@ -802,6 +806,7 @@ mod tests {
802806
"sizeMax",
803807
"modifiedAfter",
804808
"modifiedBefore",
809+
"isDirectory",
805810
"scope",
806811
"caseSensitive",
807812
"excludeSystemDirs",

apps/desktop/src-tauri/src/search/history.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ pub struct HistoryFilters {
6363
pub modified_after: Option<String>,
6464
#[serde(default)]
6565
pub modified_before: Option<String>,
66+
/// Type filter, round-tripping the frontend `typeFilter` toggle:
67+
/// `Some(true) = folder`, `Some(false) = file`, `None = both`. Additive with
68+
/// `#[serde(default)]`, so older history files (no `isDirectory` key) load as `None`
69+
/// without a schema bump.
70+
#[serde(default)]
71+
pub is_directory: Option<bool>,
6672
}
6773

6874
/// A single recent-search entry, persisted verbatim.
@@ -148,6 +154,9 @@ fn canonical_key(entry: &HistoryEntry) -> String {
148154
if let Some(ref v) = entry.filters.modified_before {
149155
filter_kv.insert("modifiedBefore", v.clone());
150156
}
157+
if let Some(v) = entry.filters.is_directory {
158+
filter_kv.insert("isDirectory", v.to_string());
159+
}
151160
let filter_str = filter_kv
152161
.iter()
153162
.map(|(k, v)| format!("{k}={v}"))
@@ -651,6 +660,38 @@ mod tests {
651660
assert_eq!(back, e);
652661
}
653662

663+
#[test]
664+
fn is_directory_filter_round_trips_without_schema_bump() {
665+
// The type filter is an additive `#[serde(default)]` field: a new value serializes
666+
// and deserializes cleanly, AND an old file missing the key still loads (as `None`),
667+
// all on schema v1. This pins "no schema bump needed".
668+
assert_eq!(CURRENT_SCHEMA_VERSION, 1, "M4's type filter must NOT bump the schema");
669+
670+
let mut e = entry(HistoryMode::Filename, "*.png");
671+
e.filters.is_directory = Some(true);
672+
let json = serde_json::to_string(&e).unwrap();
673+
assert!(json.contains("\"isDirectory\":true"));
674+
let back: HistoryEntry = serde_json::from_str(&json).unwrap();
675+
assert_eq!(back.filters.is_directory, Some(true));
676+
677+
// An old entry (filters object with no `isDirectory` key) loads as `None`.
678+
let legacy = r#"{"id":"x","timestamp":1,"mode":"filename","query":"*.png","filters":{"sizeMin":1024},"scope":"","caseSensitive":false,"excludeSystemDirs":true,"resultCount":0}"#;
679+
let parsed: HistoryEntry = serde_json::from_str(legacy).unwrap();
680+
assert_eq!(parsed.filters.is_directory, None);
681+
assert_eq!(parsed.filters.size_min, Some(1024));
682+
}
683+
684+
#[test]
685+
fn canonical_key_distinguishes_type_filter() {
686+
let mut folder = entry(HistoryMode::Filename, "*.png");
687+
folder.filters.is_directory = Some(true);
688+
let mut file = entry(HistoryMode::Filename, "*.png");
689+
file.filters.is_directory = Some(false);
690+
let both = entry(HistoryMode::Filename, "*.png");
691+
assert_ne!(canonical_key(&folder), canonical_key(&file));
692+
assert_ne!(canonical_key(&folder), canonical_key(&both));
693+
}
694+
654695
#[test]
655696
fn store_serialization_carries_schema_version() {
656697
let store = HistoryStore::default();

apps/desktop/src-tauri/src/selection/history.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ fn canonical_key(entry: &SelectionHistoryEntry) -> String {
126126
if let Some(ref v) = entry.filters.modified_before {
127127
filter_kv.insert("modifiedBefore", v.clone());
128128
}
129+
if let Some(v) = entry.filters.is_directory {
130+
filter_kv.insert("isDirectory", v.to_string());
131+
}
129132
let filter_str = filter_kv
130133
.iter()
131134
.map(|(k, v)| format!("{k}={v}"))

apps/desktop/src/lib/ipc/bindings.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3273,6 +3273,13 @@ export type HistoryFilters = {
32733273
sizeMax?: number | null
32743274
modifiedAfter?: string | null
32753275
modifiedBefore?: string | null
3276+
/**
3277+
* Type filter, round-tripping the frontend `typeFilter` toggle:
3278+
* `Some(true) = folder`, `Some(false) = file`, `None = both`. Additive with
3279+
* `#[serde(default)]`, so older history files (no `isDirectory` key) load as `None`
3280+
* without a schema bump.
3281+
*/
3282+
isDirectory?: boolean | null
32763283
}
32773284

32783285
// Search modes recorded in history. Mirrors the frontend `SearchMode` union.

apps/desktop/src/lib/query-ui/CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ popover, and the `createQueryFilterState()` factory. Filter-chip internals live
2929
- **`createQueryFilterState()` owns ONLY cross-consumer fields.** When adding a field, ask "would Selection care?" Yes →
3030
core factory. No → the consumer's extras module (`createSearchExtrasState()` etc.). Don't share via the core when
3131
semantics diverge (`lastAiLabel` is the textbook "no").
32+
- **`typeFilter: 'both' | 'file' | 'folder'`** (core, default `'both'`) maps to the existing IPC
33+
`SearchQuery.isDirectory: Option<bool>` in `buildBaseSearchQuery` (`both → null`, `file → false`, `folder → true`): no
34+
new IPC field, no engine change. Selection's matcher reads it via `getIsDirFor`; it round-trips as
35+
`HistoryFilters.isDirectory` (additive `#[serde(default)]`, no schema bump).
3236
- **`recordAiTranslation` (core) writes ONLY `handTyped[mode]`.** The Search-only label/pattern slots live in the extras
3337
and are written separately. Don't fold them into the core method.
3438
- **`stopPropagation()` on every dialog `keydown`** (shields the file explorer behind it; without it, keys trigger

apps/desktop/src/lib/query-ui/QueryDialog.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -763,6 +763,7 @@
763763
dateFilter={config.state.getDateFilter()}
764764
dateValue={config.state.getDateValue()}
765765
dateValueMax={config.state.getDateValueMax()}
766+
typeFilter={config.state.getTypeFilter()}
766767
systemDirExcludeTooltip={config.filterChipsExtras.systemDirExcludeTooltip}
767768
{highlightedFields}
768769
disabled={config.inputsDisabled}

0 commit comments

Comments
 (0)