@@ -435,9 +435,10 @@ pub struct LauncherApp {
435435 /// duplicates the pointer, keeping the action data itself shared. When
436436 /// actions are edited the entire `Arc` is replaced with a new one.
437437 pub actions : Arc < Vec < Action > > ,
438- action_cache : Vec < ( String , String ) > ,
438+ action_cache : Vec < CachedSearchEntry > ,
439439 actions_by_id : HashMap < String , Action > ,
440440 command_cache : Vec < Action > ,
441+ command_search_cache : Vec < CachedSearchEntry > ,
441442 completion_index : Option < Map < Vec < u8 > > > ,
442443 action_completion_dirty : bool ,
443444 command_completion_dirty : bool ,
@@ -593,6 +594,23 @@ pub struct LauncherApp {
593594 pub vim_mode : bool ,
594595}
595596
597+ #[ derive( Clone , Debug , Default , PartialEq , Eq ) ]
598+ struct CachedSearchEntry {
599+ label_lc : String ,
600+ desc_lc : String ,
601+ action_lc : String ,
602+ }
603+
604+ impl CachedSearchEntry {
605+ fn from_action ( action : & Action ) -> Self {
606+ Self {
607+ label_lc : action. label . to_lowercase ( ) ,
608+ desc_lc : action. desc . to_lowercase ( ) ,
609+ action_lc : action. action . to_lowercase ( ) ,
610+ }
611+ }
612+ }
613+
596614impl LauncherApp {
597615 fn normalize_alias ( alias : Option < String > ) -> ( Option < String > , Option < String > ) {
598616 let alias_lc = alias. as_ref ( ) . map ( |text| text. to_lowercase ( ) ) ;
@@ -646,12 +664,12 @@ impl LauncherApp {
646664 self . match_exact || self . fuzzy_weight <= 0.0
647665 }
648666
649- fn matches_exact_display_text ( haystack_label : & str , query : & str ) -> bool {
650- let query_lc = query . trim ( ) . to_lowercase ( ) ;
667+ fn matches_exact_display_text ( cached : & CachedSearchEntry , query_lc : & str ) -> bool {
668+ let query_lc = query_lc . trim ( ) ;
651669 if query_lc. is_empty ( ) {
652670 return true ;
653671 }
654- haystack_label . to_lowercase ( ) . contains ( & query_lc)
672+ cached . label_lc . contains ( query_lc)
655673 }
656674
657675 fn should_bypass_exact_post_filter ( query : & str , action : & str ) -> bool {
@@ -704,7 +722,7 @@ impl LauncherApp {
704722 self . action_cache = self
705723 . actions
706724 . iter ( )
707- . map ( |a| ( a . label . to_lowercase ( ) , a . desc . to_lowercase ( ) ) )
725+ . map ( CachedSearchEntry :: from_action )
708726 . collect ( ) ;
709727 self . actions_by_id = self
710728 . actions
@@ -720,6 +738,7 @@ impl LauncherApp {
720738 . plugins
721739 . commands_filtered ( self . enabled_plugins . as_ref ( ) ) ;
722740 cmds. sort_by_cached_key ( |a| a. label . to_lowercase ( ) ) ;
741+ self . command_search_cache = cmds. iter ( ) . map ( CachedSearchEntry :: from_action) . collect ( ) ;
723742 self . command_cache = cmds;
724743 self . command_completion_dirty = true ;
725744 self . schedule_completion_rebuild ( ) ;
@@ -1072,9 +1091,7 @@ impl LauncherApp {
10721091
10731092 // Keep MG hook in lockstep with whether the plugin is enabled in the UI/settings.
10741093 crate :: plugins:: mouse_gestures:: sync_enabled_plugins ( self . enabled_plugins . as_ref ( ) ) ;
1075- if self . enabled_plugins . is_some ( ) {
1076- self . update_command_cache ( ) ;
1077- }
1094+ self . update_command_cache ( ) ;
10781095 self . enabled_capabilities = enabled_capabilities;
10791096 if let Some ( ( x, y) ) = offscreen_pos {
10801097 self . offscreen_pos = ( x as f32 , y as f32 ) ;
@@ -1629,6 +1646,7 @@ impl LauncherApp {
16291646 action_cache : Vec :: new ( ) ,
16301647 actions_by_id,
16311648 command_cache : Vec :: new ( ) ,
1649+ command_search_cache : Vec :: new ( ) ,
16321650 completion_index : None ,
16331651 action_completion_dirty : false ,
16341652 command_completion_dirty : false ,
@@ -1747,14 +1765,15 @@ impl LauncherApp {
17471765 res. extend ( self . actions . iter ( ) . cloned ( ) . map ( |a| ( a, 0.0 ) ) ) ;
17481766 } else {
17491767 for ( i, a) in self . actions . iter ( ) . enumerate ( ) {
1750- let ( _ , ref desc_lc ) = self . action_cache [ i] ;
1768+ let cached = & self . action_cache [ i] ;
17511769 if self . is_exact_match_mode ( ) {
17521770 let alias_match = self . alias_matches_lc ( & a. action , query_lc) ;
1753- let label_match = Self :: matches_exact_display_text ( & a . label , query ) ;
1771+ let label_match = Self :: matches_exact_display_text ( cached , query_lc ) ;
17541772 // Prefer displayed label text, but keep `desc`/aliases as supplemental
17551773 // filters for compatibility with existing query behavior.
1756- let desc_match = desc_lc. contains ( query_lc) ;
1757- if label_match || desc_match || alias_match {
1774+ let desc_match = cached. desc_lc . contains ( query_lc) ;
1775+ let action_match = cached. action_lc . contains ( query_lc) ;
1776+ if label_match || desc_match || action_match || alias_match {
17581777 let score = if alias_match { 1.0 } else { 0.0 } ;
17591778 res. push ( ( a. clone ( ) , score) ) ;
17601779 }
@@ -1781,7 +1800,7 @@ impl LauncherApp {
17811800 ) ;
17821801 let query_term = trimmed_lc. splitn ( 2 , ' ' ) . nth ( 1 ) . unwrap_or ( "" ) ;
17831802 for a in plugin_results {
1784- let desc_lc = a . desc . to_lowercase ( ) ;
1803+ let cached = CachedSearchEntry :: from_action ( & a ) ;
17851804 if self . is_exact_match_mode ( ) {
17861805 if Self :: should_bypass_exact_post_filter ( trimmed, & a. action ) {
17871806 // Plugin commands like `note today`/`note search <term>` already
@@ -1795,9 +1814,10 @@ impl LauncherApp {
17951814 res. push ( ( a, 0.0 ) ) ;
17961815 } else {
17971816 let alias_match = self . alias_matches_lc ( & a. action , query_term) ;
1798- let label_match = Self :: matches_exact_display_text ( & a. label , query_term) ;
1799- let desc_match = desc_lc. contains ( query_term) ;
1800- if label_match || desc_match || alias_match {
1817+ let label_match = Self :: matches_exact_display_text ( & cached, query_term) ;
1818+ let desc_match = cached. desc_lc . contains ( query_term) ;
1819+ let action_match = cached. action_lc . contains ( query_term) ;
1820+ if label_match || desc_match || action_match || alias_match {
18011821 let score = if alias_match { 1.0 } else { 0.0 } ;
18021822 res. push ( ( a, score) ) ;
18031823 }
@@ -1825,24 +1845,25 @@ impl LauncherApp {
18251845 ) ;
18261846
18271847 if plugin_results. is_empty ( ) && !trimmed. is_empty ( ) {
1828- for a in self
1829- . plugins
1830- . commands_filtered ( self . enabled_plugins . as_ref ( ) )
1848+ for ( a, cached) in self
1849+ . command_cache
1850+ . iter ( )
1851+ . zip ( self . command_search_cache . iter ( ) )
18311852 {
1832- let desc_lc = a. desc . to_lowercase ( ) ;
18331853 if self . is_exact_match_mode ( ) {
18341854 let alias_match = self . alias_matches_lc ( & a. action , trimmed_lc) ;
1835- let label_match = Self :: matches_exact_display_text ( & a. label , trimmed) ;
1836- let desc_match = desc_lc. contains ( trimmed_lc) ;
1837- if label_match || desc_match || alias_match {
1855+ let label_match = Self :: matches_exact_display_text ( cached, trimmed_lc) ;
1856+ let desc_match = cached. desc_lc . contains ( trimmed_lc) ;
1857+ let action_match = cached. action_lc . contains ( trimmed_lc) ;
1858+ if label_match || desc_match || action_match || alias_match {
18381859 let score = if alias_match { 1.0 } else { 0.0 } ;
1839- res. push ( ( a, score) ) ;
1860+ res. push ( ( a. clone ( ) , score) ) ;
18401861 }
18411862 } else {
18421863 let s1 = self . matcher . fuzzy_match ( & a. label , trimmed) ;
18431864 let s2 = self . matcher . fuzzy_match ( & a. desc , trimmed) ;
18441865 if let Some ( score) = s1. max ( s2) {
1845- res. push ( ( a, score as f32 * self . fuzzy_weight ) ) ;
1866+ res. push ( ( a. clone ( ) , score as f32 * self . fuzzy_weight ) ) ;
18461867 }
18471868 }
18481869 }
@@ -1859,7 +1880,7 @@ impl LauncherApp {
18591880 }
18601881 let query_term_lc = query_term. to_lowercase ( ) ;
18611882 for a in plugin_results {
1862- let desc_lc = a . desc . to_lowercase ( ) ;
1883+ let cached = CachedSearchEntry :: from_action ( & a ) ;
18631884 if self . is_exact_match_mode ( ) {
18641885 if Self :: should_bypass_exact_post_filter ( trimmed, & a. action ) {
18651886 // Explicit plugin commands can resolve into result lists/artifacts.
@@ -1872,9 +1893,10 @@ impl LauncherApp {
18721893 res. push ( ( a, 0.0 ) ) ;
18731894 } else {
18741895 let alias_match = self . alias_matches_lc ( & a. action , & query_term_lc) ;
1875- let label_match = Self :: matches_exact_display_text ( & a. label , & query_term) ;
1876- let desc_match = desc_lc. contains ( & query_term_lc) ;
1877- if label_match || desc_match || alias_match {
1896+ let label_match = Self :: matches_exact_display_text ( & cached, & query_term_lc) ;
1897+ let desc_match = cached. desc_lc . contains ( & query_term_lc) ;
1898+ let action_match = cached. action_lc . contains ( & query_term_lc) ;
1899+ if label_match || desc_match || action_match || alias_match {
18781900 let score = if alias_match { 1.0 } else { 0.0 } ;
18791901 res. push ( ( a, score) ) ;
18801902 }
@@ -5667,17 +5689,49 @@ mod tests {
56675689 }
56685690
56695691 #[ test]
5670- fn exact_display_match_is_case_insensitive_substring ( ) {
5671- assert ! ( LauncherApp :: matches_exact_display_text( "eve" , "Eve" ) ) ;
5672- assert ! ( LauncherApp :: matches_exact_display_text( "EVENING" , "Eve" ) ) ;
5692+ fn exact_display_match_uses_pre_normalized_query_substring ( ) {
5693+ let cached = CachedSearchEntry {
5694+ label_lc : "testingeve123" . into ( ) ,
5695+ desc_lc : String :: new ( ) ,
5696+ action_lc : String :: new ( ) ,
5697+ } ;
56735698 assert ! ( LauncherApp :: matches_exact_display_text(
5674- "testingEve123" ,
5675- "eve"
5676- ) ) ;
5677- assert ! ( !LauncherApp :: matches_exact_display_text(
5678- "testing123" ,
5679- "Eve"
5699+ & cached,
5700+ & "Eve" . to_lowercase( )
56805701 ) ) ;
5702+ assert ! ( LauncherApp :: matches_exact_display_text( & cached, "eve" ) ) ;
5703+ assert ! ( !LauncherApp :: matches_exact_display_text( & cached, "night" ) ) ;
5704+ }
5705+
5706+ #[ test]
5707+ fn action_and_command_search_cache_is_normalized ( ) {
5708+ let ctx = egui:: Context :: default ( ) ;
5709+ let mut app = new_app ( & ctx) ;
5710+ app. actions = Arc :: new ( vec ! [ Action {
5711+ label: "MiXeD Label" . into( ) ,
5712+ desc: "MiXeD Desc" . into( ) ,
5713+ action: "Action:ID" . into( ) ,
5714+ args: None ,
5715+ } ] ) ;
5716+ app. update_action_cache ( ) ;
5717+
5718+ assert_eq ! ( app. action_cache. len( ) , 1 ) ;
5719+ assert_eq ! ( app. action_cache[ 0 ] . label_lc, "mixed label" ) ;
5720+ assert_eq ! ( app. action_cache[ 0 ] . desc_lc, "mixed desc" ) ;
5721+ assert_eq ! ( app. action_cache[ 0 ] . action_lc, "action:id" ) ;
5722+
5723+ app. plugins . register ( Box :: new ( ExactFilterPlugin ) ) ;
5724+ app. update_command_cache ( ) ;
5725+ assert_eq ! ( app. command_cache. len( ) , app. command_search_cache. len( ) ) ;
5726+ for ( action, cached) in app
5727+ . command_cache
5728+ . iter ( )
5729+ . zip ( app. command_search_cache . iter ( ) )
5730+ {
5731+ assert_eq ! ( cached. label_lc, action. label. to_lowercase( ) ) ;
5732+ assert_eq ! ( cached. desc_lc, action. desc. to_lowercase( ) ) ;
5733+ assert_eq ! ( cached. action_lc, action. action. to_lowercase( ) ) ;
5734+ }
56815735 }
56825736
56835737 #[ test]
@@ -5730,6 +5784,89 @@ mod tests {
57305784 assert ! ( app. results. is_empty( ) ) ;
57315785 }
57325786
5787+ #[ test]
5788+ fn update_paths_refreshes_command_search_cache_for_plugin_reload ( ) {
5789+ struct CommandPlugin ;
5790+
5791+ impl crate :: plugin:: Plugin for CommandPlugin {
5792+ fn search ( & self , _query : & str ) -> Vec < Action > {
5793+ Vec :: new ( )
5794+ }
5795+
5796+ fn name ( & self ) -> & str {
5797+ "command-plugin"
5798+ }
5799+
5800+ fn description ( & self ) -> & str {
5801+ "command plugin"
5802+ }
5803+
5804+ fn capabilities ( & self ) -> & [ & str ] {
5805+ & [ ]
5806+ }
5807+
5808+ fn commands ( & self ) -> Vec < Action > {
5809+ vec ! [ Action {
5810+ label: "PlUgIn Command" . into( ) ,
5811+ desc: "PlUgIn Desc" . into( ) ,
5812+ action: "Plugin:Command" . into( ) ,
5813+ args: None ,
5814+ } ]
5815+ }
5816+ }
5817+
5818+ let ctx = egui:: Context :: default ( ) ;
5819+ let mut app = new_app ( & ctx) ;
5820+ app. plugins . register ( Box :: new ( CommandPlugin ) ) ;
5821+
5822+ app. update_paths (
5823+ None , // plugin_dirs
5824+ None , // index_paths
5825+ None , // enabled_plugins
5826+ None , // enabled_capabilities
5827+ None , // offscreen_pos
5828+ None , // enable_toasts
5829+ None , // show_inline_errors
5830+ None , // show_error_toasts
5831+ None , // toast_duration
5832+ None , // fuzzy_weight
5833+ None , // usage_weight
5834+ None , // match_exact
5835+ None , // follow_mouse
5836+ None , // static_enabled
5837+ None , // static_pos
5838+ None , // static_size
5839+ None , // hide_after_run
5840+ None , // clear_query_after_run
5841+ None , // require_confirm_destructive
5842+ None , // timer_refresh
5843+ None , // disable_timer_updates
5844+ None , // preserve_command
5845+ None , // query_autocomplete
5846+ None , // net_refresh
5847+ None , // net_unit
5848+ None , // screenshot_dir
5849+ None , // screenshot_save_file
5850+ None , // screenshot_use_editor
5851+ None , // screenshot_auto_save
5852+ None , // always_on_top
5853+ None , // page_jump
5854+ None , // note_panel_default_size
5855+ None , // note_save_on_close
5856+ None , // note_always_overwrite
5857+ None , // note_images_as_links
5858+ None , // note_show_details
5859+ None , // note_more_limit
5860+ None , // show_dashboard_diagnostics
5861+ ) ;
5862+
5863+ assert_eq ! ( app. command_cache. len( ) , 1 ) ;
5864+ assert_eq ! ( app. command_search_cache. len( ) , 1 ) ;
5865+ assert_eq ! ( app. command_search_cache[ 0 ] . label_lc, "plugin command" ) ;
5866+ assert_eq ! ( app. command_search_cache[ 0 ] . desc_lc, "plugin desc" ) ;
5867+ assert_eq ! ( app. command_search_cache[ 0 ] . action_lc, "plugin:command" ) ;
5868+ }
5869+
57335870 #[ test]
57345871 fn malformed_note_new_action_reports_error_and_search_recovers ( ) {
57355872 let ctx = egui:: Context :: default ( ) ;
0 commit comments