Skip to content

Commit 8856ea0

Browse files
authored
Merge branch 'main' into feature/cursor-cli-support
2 parents 2129611 + cb0bc04 commit 8856ea0

6 files changed

Lines changed: 357 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: 146 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -314,22 +314,33 @@ 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;
322+
self.search_query = Input::default();
323+
self.search_matches.clear();
324+
self.search_match_index = 0;
321325
}
322326
_ => {
323327
self.search_query
324328
.handle_event(&crossterm::event::Event::Key(key));
325-
self.update_filter();
329+
self.update_search();
326330
}
327331
}
328332
return None;
329333
}
330334

331335
// Normal mode keybindings
332336
match key.code {
337+
KeyCode::Esc => {
338+
if !self.search_matches.is_empty() {
339+
self.search_matches.clear();
340+
self.search_match_index = 0;
341+
self.search_query = Input::default();
342+
}
343+
}
333344
KeyCode::Char('q') => return Some(Action::Quit),
334345
KeyCode::Char('?') => {
335346
self.show_help = true;
@@ -368,20 +379,38 @@ impl HomeView {
368379
self.search_query = Input::default();
369380
}
370381
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-
));
382+
if !self.search_matches.is_empty() {
383+
self.search_match_index =
384+
(self.search_match_index + 1) % self.search_matches.len();
385+
self.cursor = self.search_matches[self.search_match_index];
386+
self.update_selected();
387+
} else {
388+
let existing_titles: Vec<String> =
389+
self.instances.iter().map(|i| i.title.clone()).collect();
390+
let existing_groups: Vec<String> = self
391+
.group_tree
392+
.get_all_groups()
393+
.iter()
394+
.map(|g| g.path.clone())
395+
.collect();
396+
self.new_dialog = Some(NewSessionDialog::new(
397+
self.available_tools.clone(),
398+
existing_titles,
399+
existing_groups,
400+
self.storage.profile(),
401+
));
402+
}
403+
}
404+
KeyCode::Char('N') => {
405+
if !self.search_matches.is_empty() {
406+
self.search_match_index = if self.search_match_index == 0 {
407+
self.search_matches.len() - 1
408+
} else {
409+
self.search_match_index - 1
410+
};
411+
self.cursor = self.search_matches[self.search_match_index];
412+
self.update_selected();
413+
}
385414
}
386415
KeyCode::Char('s') => {
387416
// Open settings view with selected session's project path (if any)
@@ -604,44 +633,30 @@ impl HomeView {
604633
}
605634

606635
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 {
636+
if self.flat_items.is_empty() {
614637
return;
615638
}
616639

617640
let new_cursor = if delta < 0 {
618641
self.cursor.saturating_sub((-delta) as usize)
619642
} else {
620-
(self.cursor + delta as usize).min(items - 1)
643+
(self.cursor + delta as usize).min(self.flat_items.len() - 1)
621644
};
622645

623646
self.cursor = new_cursor;
624647
self.update_selected();
625648
}
626649

627650
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-
}
651+
if let Some(item) = self.flat_items.get(self.cursor) {
652+
match item {
653+
Item::Session { id, .. } => {
654+
self.selected_session = Some(id.clone());
655+
self.selected_group = None;
656+
}
657+
Item::Group { path, .. } => {
658+
self.selected_session = None;
659+
self.selected_group = Some(path.clone());
645660
}
646661
}
647662
}
@@ -658,38 +673,111 @@ impl HomeView {
658673
}
659674
}
660675

661-
pub(super) fn update_filter(&mut self) {
662-
if self.search_query.value().is_empty() {
663-
self.filtered_items = None;
676+
/// Re-score matches after a reload without moving the cursor.
677+
pub(super) fn refresh_search_matches(&mut self) {
678+
let query = self.search_query.value();
679+
if query.is_empty() {
680+
self.search_matches.clear();
681+
self.search_match_index = 0;
664682
return;
665683
}
666684

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

670700
for (idx, item) in self.flat_items.iter().enumerate() {
671-
match item {
701+
let haystack = match item {
672702
Item::Session { id, .. } => {
673703
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-
}
704+
format!("{} {}", inst.title, inst.project_path)
705+
} else {
706+
continue;
679707
}
680708
}
681709
Item::Group { name, path, .. } => {
682-
if name.to_lowercase().contains(&query) || path.to_lowercase().contains(&query)
683-
{
684-
matches.push(idx);
710+
format!("{} {}", name, path)
711+
}
712+
};
713+
714+
let haystack_utf32 = Utf32Str::new(&haystack, &mut buf);
715+
if let Some(score) = atom.score(haystack_utf32, &mut matcher) {
716+
scored.push((idx, score));
717+
}
718+
}
719+
720+
scored.sort_by(|a, b| b.1.cmp(&a.1));
721+
self.search_matches = scored.into_iter().map(|(idx, _)| idx).collect();
722+
// Clamp match_index in case matches shrank
723+
if self.search_matches.is_empty() {
724+
self.search_match_index = 0;
725+
} else if self.search_match_index >= self.search_matches.len() {
726+
self.search_match_index = self.search_matches.len() - 1;
727+
}
728+
}
729+
730+
pub(super) fn update_search(&mut self) {
731+
self.search_matches.clear();
732+
self.search_match_index = 0;
733+
734+
let query = self.search_query.value();
735+
if query.is_empty() {
736+
return;
737+
}
738+
739+
use nucleo_matcher::pattern::{Atom, AtomKind, CaseMatching, Normalization};
740+
use nucleo_matcher::{Config, Matcher, Utf32Str};
741+
742+
let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
743+
let atom = Atom::new(
744+
query,
745+
CaseMatching::Ignore,
746+
Normalization::Smart,
747+
AtomKind::Fuzzy,
748+
false,
749+
);
750+
751+
let mut scored: Vec<(usize, u16)> = Vec::new();
752+
let mut buf = Vec::new();
753+
754+
for (idx, item) in self.flat_items.iter().enumerate() {
755+
let haystack = match item {
756+
Item::Session { id, .. } => {
757+
if let Some(inst) = self.instance_map.get(id) {
758+
format!("{} {}", inst.title, inst.project_path)
759+
} else {
760+
continue;
685761
}
686762
}
763+
Item::Group { name, path, .. } => {
764+
format!("{} {}", name, path)
765+
}
766+
};
767+
768+
let haystack_utf32 = Utf32Str::new(&haystack, &mut buf);
769+
if let Some(score) = atom.score(haystack_utf32, &mut matcher) {
770+
scored.push((idx, score));
687771
}
688772
}
689773

690-
self.filtered_items = Some(matches);
691-
self.cursor = 0;
692-
self.update_selected();
774+
scored.sort_by(|a, b| b.1.cmp(&a.1));
775+
self.search_matches = scored.into_iter().map(|(idx, _)| idx).collect();
776+
777+
if let Some(&best) = self.search_matches.first() {
778+
self.cursor = best;
779+
self.update_selected();
780+
}
693781
}
694782

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

0 commit comments

Comments
 (0)