Skip to content

Commit ecd8c9c

Browse files
authored
feat: better search for quick session access (#319)
1 parent 767f724 commit ecd8c9c

6 files changed

Lines changed: 280 additions & 159 deletions

File tree

src/session/instance.rs

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,6 @@ pub struct Instance {
108108
pub last_start_time: Option<std::time::Instant>,
109109
#[serde(skip)]
110110
pub last_error: Option<String>,
111-
112-
// Search optimization: pre-computed lowercase strings (not serialized)
113-
#[serde(skip)]
114-
pub title_lower: String,
115-
#[serde(skip)]
116-
pub project_path_lower: String,
117111
}
118112

119113
impl Instance {
@@ -136,18 +130,9 @@ impl Instance {
136130
last_error_check: None,
137131
last_start_time: None,
138132
last_error: None,
139-
title_lower: title.to_lowercase(),
140-
project_path_lower: project_path.to_lowercase(),
141133
}
142134
}
143135

144-
/// Update the pre-computed lowercase fields for search optimization.
145-
/// Call this after loading instances from disk or modifying title/path.
146-
pub fn update_search_cache(&mut self) {
147-
self.title_lower = self.title.to_lowercase();
148-
self.project_path_lower = self.project_path.to_lowercase();
149-
}
150-
151136
pub fn is_sub_session(&self) -> bool {
152137
self.parent_session_id.is_some()
153138
}
@@ -772,25 +757,6 @@ mod tests {
772757
assert_eq!(inst.get_tool_command(), "claude --resume abc123");
773758
}
774759

775-
// Tests for update_search_cache
776-
#[test]
777-
fn test_update_search_cache() {
778-
let mut inst = Instance::new("Test Title", "/Path/To/Project");
779-
// Manually modify title
780-
inst.title = "New Title".to_string();
781-
inst.project_path = "/New/Path".to_string();
782-
783-
// Cache is stale
784-
assert_ne!(inst.title_lower, "new title");
785-
assert_ne!(inst.project_path_lower, "/new/path");
786-
787-
// Update cache
788-
inst.update_search_cache();
789-
790-
assert_eq!(inst.title_lower, "new title");
791-
assert_eq!(inst.project_path_lower, "/new/path");
792-
}
793-
794760
// Tests for Status enum
795761
#[test]
796762
fn test_status_default() {

src/tui/components/help.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use ratatui::widgets::*;
66
use crate::tui::styles::Theme;
77

88
const DIALOG_WIDTH: u16 = 50;
9-
const DIALOG_HEIGHT: u16 = 30;
9+
const DIALOG_HEIGHT: u16 = 31;
1010
#[cfg(test)]
1111
const BORDER_HEIGHT: u16 = 2;
1212
#[cfg(test)]
@@ -50,6 +50,7 @@ fn shortcuts() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
5050
"Other",
5151
vec![
5252
("/", "Search"),
53+
("n/N", "Next/prev match"),
5354
("s", "Settings"),
5455
("P", "Next profile"),
5556
("?", "Toggle help"),

src/tui/home/input.rs

Lines changed: 90 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -314,22 +314,30 @@ impl HomeView {
314314
KeyCode::Esc => {
315315
self.search_active = false;
316316
self.search_query = Input::default();
317-
self.filtered_items = None;
317+
self.search_matches.clear();
318+
self.search_match_index = 0;
318319
}
319320
KeyCode::Enter => {
320321
self.search_active = false;
321322
}
322323
_ => {
323324
self.search_query
324325
.handle_event(&crossterm::event::Event::Key(key));
325-
self.update_filter();
326+
self.update_search();
326327
}
327328
}
328329
return None;
329330
}
330331

331332
// Normal mode keybindings
332333
match key.code {
334+
KeyCode::Esc => {
335+
if !self.search_matches.is_empty() {
336+
self.search_matches.clear();
337+
self.search_match_index = 0;
338+
self.search_query = Input::default();
339+
}
340+
}
333341
KeyCode::Char('q') => return Some(Action::Quit),
334342
KeyCode::Char('?') => {
335343
self.show_help = true;
@@ -368,20 +376,38 @@ impl HomeView {
368376
self.search_query = Input::default();
369377
}
370378
KeyCode::Char('n') => {
371-
let existing_titles: Vec<String> =
372-
self.instances.iter().map(|i| i.title.clone()).collect();
373-
let existing_groups: Vec<String> = self
374-
.group_tree
375-
.get_all_groups()
376-
.iter()
377-
.map(|g| g.path.clone())
378-
.collect();
379-
self.new_dialog = Some(NewSessionDialog::new(
380-
self.available_tools.clone(),
381-
existing_titles,
382-
existing_groups,
383-
self.storage.profile(),
384-
));
379+
if !self.search_matches.is_empty() {
380+
self.search_match_index =
381+
(self.search_match_index + 1) % self.search_matches.len();
382+
self.cursor = self.search_matches[self.search_match_index];
383+
self.update_selected();
384+
} else {
385+
let existing_titles: Vec<String> =
386+
self.instances.iter().map(|i| i.title.clone()).collect();
387+
let existing_groups: Vec<String> = self
388+
.group_tree
389+
.get_all_groups()
390+
.iter()
391+
.map(|g| g.path.clone())
392+
.collect();
393+
self.new_dialog = Some(NewSessionDialog::new(
394+
self.available_tools.clone(),
395+
existing_titles,
396+
existing_groups,
397+
self.storage.profile(),
398+
));
399+
}
400+
}
401+
KeyCode::Char('N') => {
402+
if !self.search_matches.is_empty() {
403+
self.search_match_index = if self.search_match_index == 0 {
404+
self.search_matches.len() - 1
405+
} else {
406+
self.search_match_index - 1
407+
};
408+
self.cursor = self.search_matches[self.search_match_index];
409+
self.update_selected();
410+
}
385411
}
386412
KeyCode::Char('s') => {
387413
// Open settings view with selected session's project path (if any)
@@ -604,44 +630,30 @@ impl HomeView {
604630
}
605631

606632
pub(super) fn move_cursor(&mut self, delta: i32) {
607-
let items = if let Some(ref filtered) = self.filtered_items {
608-
filtered.len()
609-
} else {
610-
self.flat_items.len()
611-
};
612-
613-
if items == 0 {
633+
if self.flat_items.is_empty() {
614634
return;
615635
}
616636

617637
let new_cursor = if delta < 0 {
618638
self.cursor.saturating_sub((-delta) as usize)
619639
} else {
620-
(self.cursor + delta as usize).min(items - 1)
640+
(self.cursor + delta as usize).min(self.flat_items.len() - 1)
621641
};
622642

623643
self.cursor = new_cursor;
624644
self.update_selected();
625645
}
626646

627647
pub(super) fn update_selected(&mut self) {
628-
let item_idx = if let Some(ref filtered) = self.filtered_items {
629-
filtered.get(self.cursor).copied()
630-
} else {
631-
Some(self.cursor)
632-
};
633-
634-
if let Some(idx) = item_idx {
635-
if let Some(item) = self.flat_items.get(idx) {
636-
match item {
637-
Item::Session { id, .. } => {
638-
self.selected_session = Some(id.clone());
639-
self.selected_group = None;
640-
}
641-
Item::Group { path, .. } => {
642-
self.selected_session = None;
643-
self.selected_group = Some(path.clone());
644-
}
648+
if let Some(item) = self.flat_items.get(self.cursor) {
649+
match item {
650+
Item::Session { id, .. } => {
651+
self.selected_session = Some(id.clone());
652+
self.selected_group = None;
653+
}
654+
Item::Group { path, .. } => {
655+
self.selected_session = None;
656+
self.selected_group = Some(path.clone());
645657
}
646658
}
647659
}
@@ -658,38 +670,57 @@ impl HomeView {
658670
}
659671
}
660672

661-
pub(super) fn update_filter(&mut self) {
662-
if self.search_query.value().is_empty() {
663-
self.filtered_items = None;
673+
pub(super) fn update_search(&mut self) {
674+
self.search_matches.clear();
675+
self.search_match_index = 0;
676+
677+
let query = self.search_query.value();
678+
if query.is_empty() {
664679
return;
665680
}
666681

667-
let query = self.search_query.value().to_lowercase();
668-
let mut matches = Vec::new();
682+
use nucleo_matcher::pattern::{Atom, AtomKind, CaseMatching, Normalization};
683+
use nucleo_matcher::{Config, Matcher, Utf32Str};
684+
685+
let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
686+
let atom = Atom::new(
687+
query,
688+
CaseMatching::Ignore,
689+
Normalization::Smart,
690+
AtomKind::Fuzzy,
691+
false,
692+
);
693+
694+
let mut scored: Vec<(usize, u16)> = Vec::new();
695+
let mut buf = Vec::new();
669696

670697
for (idx, item) in self.flat_items.iter().enumerate() {
671-
match item {
698+
let haystack = match item {
672699
Item::Session { id, .. } => {
673700
if let Some(inst) = self.instance_map.get(id) {
674-
if inst.title_lower.contains(&query)
675-
|| inst.project_path_lower.contains(&query)
676-
{
677-
matches.push(idx);
678-
}
701+
format!("{} {}", inst.title, inst.project_path)
702+
} else {
703+
continue;
679704
}
680705
}
681706
Item::Group { name, path, .. } => {
682-
if name.to_lowercase().contains(&query) || path.to_lowercase().contains(&query)
683-
{
684-
matches.push(idx);
685-
}
707+
format!("{} {}", name, path)
686708
}
709+
};
710+
711+
let haystack_utf32 = Utf32Str::new(&haystack, &mut buf);
712+
if let Some(score) = atom.score(haystack_utf32, &mut matcher) {
713+
scored.push((idx, score));
687714
}
688715
}
689716

690-
self.filtered_items = Some(matches);
691-
self.cursor = 0;
692-
self.update_selected();
717+
scored.sort_by(|a, b| b.1.cmp(&a.1));
718+
self.search_matches = scored.into_iter().map(|(idx, _)| idx).collect();
719+
720+
if let Some(&best) = self.search_matches.first() {
721+
self.cursor = best;
722+
self.update_selected();
723+
}
693724
}
694725

695726
/// Create a session with optional hooks. Delegates to the background

src/tui/home/mod.rs

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,8 @@ pub struct HomeView {
125125
// Search
126126
pub(super) search_active: bool,
127127
pub(super) search_query: Input,
128-
pub(super) filtered_items: Option<Vec<usize>>,
128+
pub(super) search_matches: Vec<usize>,
129+
pub(super) search_match_index: usize,
129130

130131
// Tool availability
131132
pub(super) available_tools: AvailableTools,
@@ -171,11 +172,7 @@ pub struct HomeView {
171172

172173
impl HomeView {
173174
pub fn new(storage: Storage, available_tools: AvailableTools) -> anyhow::Result<Self> {
174-
let (mut instances, groups) = storage.load_with_groups()?;
175-
176-
for inst in &mut instances {
177-
inst.update_search_cache();
178-
}
175+
let (instances, groups) = storage.load_with_groups()?;
179176

180177
let instance_map: HashMap<String, Instance> = instances
181178
.iter()
@@ -224,7 +221,8 @@ impl HomeView {
224221
pending_stop_session: None,
225222
search_active: false,
226223
search_query: Input::default(),
227-
filtered_items: None,
224+
search_matches: Vec::new(),
225+
search_match_index: 0,
228226
available_tools,
229227
status_poller: StatusPoller::new(),
230228
pending_status_refresh: false,
@@ -262,7 +260,6 @@ impl HomeView {
262260
inst.last_error_check = prev.last_error_check;
263261
inst.last_start_time = prev.last_start_time;
264262
}
265-
inst.update_search_cache();
266263
}
267264

268265
self.instances = instances;
@@ -279,6 +276,10 @@ impl HomeView {
279276
self.cursor = self.flat_items.len() - 1;
280277
}
281278

279+
if !self.search_query.value().is_empty() {
280+
self.update_search();
281+
}
282+
282283
self.update_selected();
283284
Ok(())
284285
}

0 commit comments

Comments
 (0)