Skip to content

Commit 415db3f

Browse files
committed
Search: Case sensitivity toggle, dialog UX fixes
- Add per-query `caseSensitive: Option<bool>` (`None` = platform default) to `SearchQuery`, MCP schema, AI prompt, and TypeScript types - `Aa` toggle button in the pattern row, same style as the glob/regex toggle - Frontend only sends `caseSensitive` when explicitly enabled, preserving platform defaults - Remove `backdrop-filter: blur()` from overlay so files stay visible behind the dialog - Fix ESC not closing: capture-phase `keydown` listener on `window` fires before native elements (selects, date pickers) can consume it - Replace inline absolute-positioned scope tooltip with global `use:tooltip` action — viewport-aware, no clipping
1 parent 6342552 commit 415db3f

7 files changed

Lines changed: 147 additions & 92 deletions

File tree

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ pub(crate) struct AiSearchQuery {
273273
pub(crate) is_directory: Option<bool>,
274274
pub(crate) search_paths: Option<Vec<String>>,
275275
pub(crate) exclude_dirs: Option<Vec<String>>,
276+
pub(crate) case_sensitive: Option<bool>,
276277
}
277278

278279
/// Human-readable field values returned alongside the structured query.
@@ -296,6 +297,7 @@ pub struct TranslatedQuery {
296297
pub is_directory: Option<bool>,
297298
pub include_paths: Option<Vec<String>>,
298299
pub exclude_dir_names: Option<Vec<String>>,
300+
pub case_sensitive: Option<bool>,
299301
}
300302

301303
/// Human-readable values so the frontend can populate filter UI.
@@ -311,6 +313,7 @@ pub struct TranslateDisplay {
311313
pub is_directory: Option<bool>,
312314
pub include_paths: Option<Vec<String>>,
313315
pub exclude_dir_names: Option<Vec<String>>,
316+
pub case_sensitive: Option<bool>,
314317
}
315318

316319
/// Converts an ISO date string (YYYY-MM-DD) to a unix timestamp (seconds since epoch).
@@ -344,6 +347,7 @@ pub(crate) fn build_search_system_prompt() -> String {
344347
- \"isDirectory\": true for folders only, false for files only, omit for both\n\
345348
- \"searchPaths\": array of paths to search within (for example, [\"~/projects\"])\n\
346349
- \"excludeDirs\": array of directory names to exclude (for example, [\"node_modules\", \".git\"])\n\
350+
- \"caseSensitive\": true when exact casing matters (default: omit for platform default)\n\
347351
\n\
348352
Glob only supports * and ?. For multiple extensions or alternation, use regex.\n\
349353
Regex: Rust `regex` crate syntax (no lookahead/lookbehind, no backreferences, \
@@ -422,6 +426,7 @@ pub(crate) fn build_translate_result(ai_query: AiSearchQuery) -> Result<Translat
422426
is_directory: ai_query.is_directory,
423427
include_paths: include_paths.clone(),
424428
exclude_dir_names: exclude_dir_names.clone(),
429+
case_sensitive: ai_query.case_sensitive,
425430
},
426431
display: TranslateDisplay {
427432
name_pattern: ai_query.name_pattern,
@@ -433,6 +438,7 @@ pub(crate) fn build_translate_result(ai_query: AiSearchQuery) -> Result<Translat
433438
is_directory: ai_query.is_directory,
434439
include_paths,
435440
exclude_dir_names,
441+
case_sensitive: ai_query.case_sensitive,
436442
},
437443
})
438444
}
@@ -610,6 +616,7 @@ mod tests {
610616
is_directory: None,
611617
include_paths: None,
612618
exclude_dir_names: None,
619+
case_sensitive: None,
613620
},
614621
display: TranslateDisplay {
615622
name_pattern: Some("*.pdf".to_string()),
@@ -621,6 +628,7 @@ mod tests {
621628
is_directory: None,
622629
include_paths: None,
623630
exclude_dir_names: None,
631+
case_sensitive: None,
624632
},
625633
};
626634
let json = serde_json::to_string(&result).unwrap();
@@ -677,6 +685,7 @@ mod tests {
677685
is_directory: None,
678686
search_paths: None,
679687
exclude_dirs: None,
688+
case_sensitive: None,
680689
};
681690
assert!(validate_regex_pattern(&q).is_ok());
682691
}
@@ -693,6 +702,7 @@ mod tests {
693702
is_directory: None,
694703
search_paths: None,
695704
exclude_dirs: None,
705+
case_sensitive: None,
696706
};
697707
assert!(validate_regex_pattern(&q).is_err());
698708
}
@@ -709,6 +719,7 @@ mod tests {
709719
is_directory: None,
710720
search_paths: None,
711721
exclude_dirs: None,
722+
case_sensitive: None,
712723
};
713724
// Glob patterns aren't validated as regex
714725
assert!(validate_regex_pattern(&q).is_ok());
@@ -726,6 +737,7 @@ mod tests {
726737
is_directory: None,
727738
search_paths: None,
728739
exclude_dirs: None,
740+
case_sensitive: None,
729741
};
730742
assert!(validate_regex_pattern(&q).is_ok());
731743
}
@@ -742,6 +754,7 @@ mod tests {
742754
is_directory: None,
743755
search_paths: None,
744756
exclude_dirs: None,
757+
case_sensitive: None,
745758
};
746759
let result = build_translate_result(q).unwrap();
747760
assert_eq!(result.query.pattern_type, "regex");
@@ -779,6 +792,7 @@ mod tests {
779792
is_directory: None,
780793
search_paths: Some(vec!["~/projects".to_string()]),
781794
exclude_dirs: Some(vec!["node_modules".to_string(), ".git".to_string()]),
795+
case_sensitive: None,
782796
};
783797
let result = build_translate_result(q).unwrap();
784798

@@ -802,5 +816,6 @@ mod tests {
802816
assert!(prompt.contains("searchPaths"));
803817
assert!(prompt.contains("excludeDirs"));
804818
assert!(prompt.contains("node_modules"));
819+
assert!(prompt.contains("caseSensitive"));
805820
}
806821
}

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

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,10 @@ pub struct SearchQuery {
172172
pub exclude_dir_names: Option<Vec<String>>,
173173
#[serde(default = "default_limit")]
174174
pub limit: u32,
175+
/// Per-query case sensitivity override.
176+
/// `None` = platform default (false on macOS, true on Linux).
177+
#[serde(default)]
178+
pub case_sensitive: Option<bool>,
175179
}
176180

177181
fn default_limit() -> u32 {
@@ -257,6 +261,13 @@ pub fn summarize_query(query: &SearchQuery) -> String {
257261
None => {}
258262
}
259263

264+
// Case sensitivity (only show when explicitly set)
265+
match query.case_sensitive {
266+
Some(true) => parts.push("case-sensitive".to_string()),
267+
Some(false) => parts.push("case-insensitive".to_string()),
268+
None => {}
269+
}
270+
260271
if parts.is_empty() {
261272
"(all entries)".to_string()
262273
} else {
@@ -549,10 +560,12 @@ fn prepare_scope_filter(query: &SearchQuery, index: &SearchIndex) -> ScopeFilter
549560
} else {
550561
// Bare name: compile as glob pattern
551562
let regex_str = glob_to_regex(pattern);
552-
if let Ok(re) = RegexBuilder::new(&regex_str)
553-
.case_insensitive(cfg!(target_os = "macos"))
554-
.build()
555-
{
563+
let case_insensitive = match query.case_sensitive {
564+
Some(true) => false,
565+
Some(false) => true,
566+
None => cfg!(target_os = "macos"),
567+
};
568+
if let Ok(re) = RegexBuilder::new(&regex_str).case_insensitive(case_insensitive).build() {
556569
exclude_name_patterns.push(re);
557570
}
558571
}
@@ -633,8 +646,13 @@ pub fn search(index: &SearchIndex, query: &SearchQuery) -> Result<SearchResult,
633646
}
634647
PatternType::Regex => pattern.to_string(),
635648
};
649+
let case_insensitive = match query.case_sensitive {
650+
Some(true) => false,
651+
Some(false) => true,
652+
None => cfg!(target_os = "macos"),
653+
};
636654
let re = RegexBuilder::new(&regex_str)
637-
.case_insensitive(cfg!(target_os = "macos"))
655+
.case_insensitive(case_insensitive)
638656
.build()
639657
.map_err(|e| format!("Invalid pattern: {e}"))?;
640658
Some(re)
@@ -969,6 +987,7 @@ mod tests {
969987
include_paths: None,
970988
exclude_dir_names: None,
971989
limit: 30,
990+
case_sensitive: None,
972991
};
973992
let result = search(&index, &query).unwrap();
974993
// "ote" should match "notes.txt" as a substring
@@ -990,6 +1009,7 @@ mod tests {
9901009
include_paths: None,
9911010
exclude_dir_names: None,
9921011
limit: 30,
1012+
case_sensitive: None,
9931013
};
9941014
let result = search(&index, &query).unwrap();
9951015
// "repo" should match "report.pdf" and "Q1-report.pdf"
@@ -1012,6 +1032,7 @@ mod tests {
10121032
include_paths: None,
10131033
exclude_dir_names: None,
10141034
limit: 30,
1035+
case_sensitive: None,
10151036
};
10161037
let result = search(&index, &query).unwrap();
10171038
// "report*" matches "report.pdf" but NOT "Q1-report.pdf"
@@ -1145,6 +1166,7 @@ mod tests {
11451166
include_paths: None,
11461167
exclude_dir_names: None,
11471168
limit: 30,
1169+
case_sensitive: None,
11481170
};
11491171
let result = search(&index, &query).unwrap();
11501172
assert_eq!(result.total_count, 2);
@@ -1165,6 +1187,7 @@ mod tests {
11651187
include_paths: None,
11661188
exclude_dir_names: None,
11671189
limit: 30,
1190+
case_sensitive: None,
11681191
};
11691192
let result = search(&index, &query).unwrap();
11701193
assert_eq!(result.total_count, 1);
@@ -1188,6 +1211,7 @@ mod tests {
11881211
include_paths: None,
11891212
exclude_dir_names: None,
11901213
limit: 30,
1214+
case_sensitive: None,
11911215
};
11921216
let result = search(&index, &query).unwrap();
11931217
// On macOS, matching is case-insensitive
@@ -1211,6 +1235,7 @@ mod tests {
12111235
include_paths: None,
12121236
exclude_dir_names: None,
12131237
limit: 30,
1238+
case_sensitive: None,
12141239
};
12151240
let result = search(&index, &query).unwrap();
12161241
assert_eq!(result.total_count, 1);
@@ -1231,6 +1256,7 @@ mod tests {
12311256
include_paths: None,
12321257
exclude_dir_names: None,
12331258
limit: 30,
1259+
case_sensitive: None,
12341260
};
12351261
let result = search(&index, &query);
12361262
assert!(result.is_err());
@@ -1253,6 +1279,7 @@ mod tests {
12531279
include_paths: None,
12541280
exclude_dir_names: None,
12551281
limit: 30,
1282+
case_sensitive: None,
12561283
};
12571284
let result = search(&index, &query).unwrap();
12581285
// photo.jpg (5M) and Q1-report.pdf (2M)
@@ -1274,6 +1301,7 @@ mod tests {
12741301
include_paths: None,
12751302
exclude_dir_names: None,
12761303
limit: 30,
1304+
case_sensitive: None,
12771305
};
12781306
let result = search(&index, &query).unwrap();
12791307
assert_eq!(result.total_count, 1);
@@ -1294,6 +1322,7 @@ mod tests {
12941322
include_paths: None,
12951323
exclude_dir_names: None,
12961324
limit: 30,
1325+
case_sensitive: None,
12971326
};
12981327
let result = search(&index, &query).unwrap();
12991328
// report.pdf (1M) and Q1-report.pdf (2M)
@@ -1316,6 +1345,7 @@ mod tests {
13161345
include_paths: None,
13171346
exclude_dir_names: None,
13181347
limit: 30,
1348+
case_sensitive: None,
13191349
};
13201350
let result = search(&index, &query).unwrap();
13211351
// photo.jpg (4000), notes.txt (5000), Q1-report.pdf (6000)
@@ -1336,6 +1366,7 @@ mod tests {
13361366
include_paths: None,
13371367
exclude_dir_names: None,
13381368
limit: 30,
1369+
case_sensitive: None,
13391370
};
13401371
let result = search(&index, &query).unwrap();
13411372
// Users (1000), alice (2000), Documents (1500)
@@ -1356,6 +1387,7 @@ mod tests {
13561387
include_paths: None,
13571388
exclude_dir_names: None,
13581389
limit: 30,
1390+
case_sensitive: None,
13591391
};
13601392
let result = search(&index, &query).unwrap();
13611393
// report.pdf (3000), photo.jpg (4000), notes.txt (5000)
@@ -1378,6 +1410,7 @@ mod tests {
13781410
include_paths: None,
13791411
exclude_dir_names: None,
13801412
limit: 30,
1413+
case_sensitive: None,
13811414
};
13821415
let result = search(&index, &query).unwrap();
13831416
assert_eq!(result.total_count, 1);
@@ -1400,6 +1433,7 @@ mod tests {
14001433
include_paths: None,
14011434
exclude_dir_names: None,
14021435
limit: 30,
1436+
case_sensitive: None,
14031437
};
14041438
let result = search(&index, &query).unwrap();
14051439
// All entries except root sentinel (7 entries)
@@ -1424,6 +1458,7 @@ mod tests {
14241458
include_paths: None,
14251459
exclude_dir_names: None,
14261460
limit: 3,
1461+
case_sensitive: None,
14271462
};
14281463
let result = search(&index, &query).unwrap();
14291464
assert_eq!(result.entries.len(), 3);
@@ -1446,6 +1481,7 @@ mod tests {
14461481
include_paths: None,
14471482
exclude_dir_names: None,
14481483
limit: 30,
1484+
case_sensitive: None,
14491485
};
14501486
let result = search(&index, &query).unwrap();
14511487
// Users, alice, Documents (root excluded)
@@ -1467,6 +1503,7 @@ mod tests {
14671503
include_paths: None,
14681504
exclude_dir_names: None,
14691505
limit: 30,
1506+
case_sensitive: None,
14701507
};
14711508
let result = search(&index, &query).unwrap();
14721509
assert_eq!(result.total_count, 4);
@@ -1533,6 +1570,7 @@ mod tests {
15331570
include_paths: None,
15341571
exclude_dir_names: None,
15351572
limit: 30,
1573+
case_sensitive: None,
15361574
};
15371575
let json = serde_json::to_string(&query).unwrap();
15381576
assert!(json.contains("namePattern"));
@@ -1643,6 +1681,7 @@ mod tests {
16431681
include_paths: None,
16441682
exclude_dir_names: None,
16451683
limit: 30,
1684+
case_sensitive: None,
16461685
};
16471686
let result = search(&index, &query).unwrap();
16481687
assert_eq!(result.total_count, 1);
@@ -1687,6 +1726,7 @@ mod tests {
16871726
include_paths: None,
16881727
exclude_dir_names: None,
16891728
limit: 30,
1729+
case_sensitive: None,
16901730
}
16911731
}
16921732

@@ -2038,6 +2078,7 @@ mod tests {
20382078
include_paths: Some(vec!["/Users/alice/projects".to_string()]),
20392079
exclude_dir_names: None,
20402080
limit: 30,
2081+
case_sensitive: None,
20412082
};
20422083
let result = search(&index, &query).unwrap();
20432084
// Should find app.rs and pkg.json (both under /Users/alice/projects)
@@ -2062,6 +2103,7 @@ mod tests {
20622103
include_paths: None,
20632104
exclude_dir_names: Some(vec!["node_modules".to_string()]),
20642105
limit: 30,
2106+
case_sensitive: None,
20652107
};
20662108
let result = search(&index, &query).unwrap();
20672109
// Should find app.rs and config, but NOT pkg.json (under node_modules)
@@ -2086,6 +2128,7 @@ mod tests {
20862128
include_paths: Some(vec!["/Users/alice/projects".to_string()]),
20872129
exclude_dir_names: Some(vec!["node_modules".to_string()]),
20882130
limit: 30,
2131+
case_sensitive: None,
20892132
};
20902133
let result = search(&index, &query).unwrap();
20912134
// Only app.rs: under projects but not under node_modules
@@ -2107,6 +2150,7 @@ mod tests {
21072150
include_paths: None,
21082151
exclude_dir_names: Some(vec![".*".to_string()]),
21092152
limit: 30,
2153+
case_sensitive: None,
21102154
};
21112155
let result = search(&index, &query).unwrap();
21122156
// Should exclude config (under .git) but keep app.rs and pkg.json

0 commit comments

Comments
 (0)