@@ -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