diff --git a/src/common/query.rs b/src/common/query.rs index 2b47aed3..d5fc0bc0 100644 --- a/src/common/query.rs +++ b/src/common/query.rs @@ -1,5 +1,45 @@ use crate::actions::Action; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActionFilterMetadata { + pub normalized_id: String, + pub normalized_kind_candidates: Vec, +} + +impl ActionFilterMetadata { + pub fn from_action(action: &Action) -> Self { + let mut normalized_kind_candidates = Vec::new(); + if !action.desc.trim().is_empty() { + normalized_kind_candidates.push(action.desc.trim().to_lowercase()); + } + if let Some(prefix) = action.action.split(':').next() { + if !prefix.trim().is_empty() { + normalized_kind_candidates.push(prefix.trim().to_lowercase()); + } + } + normalized_kind_candidates.sort(); + normalized_kind_candidates.dedup(); + + Self { + normalized_id: action.action.to_lowercase(), + normalized_kind_candidates, + } + } +} + +#[derive(Debug, Clone)] +pub struct ActionWithMetadata { + pub action: Action, + pub metadata: ActionFilterMetadata, +} + +impl ActionWithMetadata { + pub fn from_action(action: Action) -> Self { + let metadata = ActionFilterMetadata::from_action(&action); + Self { action, metadata } + } +} + #[derive(Debug, Default, PartialEq, Eq)] pub struct QueryFilters { pub remaining_tokens: Vec, @@ -245,15 +285,29 @@ pub fn rebuild_query(tokens: &[String]) -> String { } pub fn apply_action_filters(actions: Vec, filters: &QueryFilters) -> Vec { + apply_action_filters_with_metadata( + actions + .into_iter() + .map(ActionWithMetadata::from_action) + .collect(), + filters, + ) +} + +pub fn apply_action_filters_with_metadata( + actions: Vec, + filters: &QueryFilters, +) -> Vec { actions .into_iter() - .filter(|action| action_matches_filters(action, filters)) + .filter(|cached| action_matches_filters(&cached.metadata, filters)) + .map(|cached| cached.action) .collect() } -fn action_matches_filters(action: &Action, filters: &QueryFilters) -> bool { - let action_id = action.action.to_lowercase(); - let kind_candidates = action_kind_candidates(action); +pub fn action_matches_filters(metadata: &ActionFilterMetadata, filters: &QueryFilters) -> bool { + let action_id = &metadata.normalized_id; + let kind_candidates = &metadata.normalized_kind_candidates; if !filters.include_kinds.is_empty() && !filters @@ -272,32 +326,17 @@ fn action_matches_filters(action: &Action, filters: &QueryFilters) -> bool { return false; } - if !filters.include_ids.is_empty() && !filters.include_ids.iter().any(|id| action_id == *id) { + if !filters.include_ids.is_empty() && !filters.include_ids.iter().any(|id| action_id == id) { return false; } - if filters.exclude_ids.iter().any(|id| action_id == *id) { + if filters.exclude_ids.iter().any(|id| action_id == id) { return false; } true } -fn action_kind_candidates(action: &Action) -> Vec { - let mut kinds = Vec::new(); - if !action.desc.trim().is_empty() { - kinds.push(action.desc.trim().to_lowercase()); - } - if let Some(prefix) = action.action.split(':').next() { - if !prefix.trim().is_empty() { - kinds.push(prefix.trim().to_lowercase()); - } - } - kinds.sort(); - kinds.dedup(); - kinds -} - fn split_negation(token: &str) -> (&str, bool) { token .strip_prefix('!') @@ -383,4 +422,62 @@ mod tests { assert_eq!(filters.include_kinds, vec!["todo"]); assert_eq!(filters.exclude_ids, vec!["todo:done:1"]); } + + #[test] + fn action_matches_filters_uses_metadata_for_id_and_kind() { + let action = Action { + label: "Task".into(), + desc: "Todo".into(), + action: "todo:item:1".into(), + args: None, + }; + let metadata = ActionFilterMetadata::from_action(&action); + + let include_match = QueryFilters { + include_ids: vec!["todo:item:1".into()], + include_kinds: vec!["todo".into()], + ..QueryFilters::default() + }; + assert!(action_matches_filters(&metadata, &include_match)); + + let include_miss = QueryFilters { + include_ids: vec!["todo:item:2".into()], + ..QueryFilters::default() + }; + assert!(!action_matches_filters(&metadata, &include_miss)); + + let exclude_hit = QueryFilters { + exclude_ids: vec!["todo:item:1".into()], + ..QueryFilters::default() + }; + assert!(!action_matches_filters(&metadata, &exclude_hit)); + } + #[test] + fn apply_action_filters_with_metadata_filters_without_recomputing() { + let keep = Action { + label: "Keep".into(), + desc: "Todo".into(), + action: "todo:item:1".into(), + args: None, + }; + let drop = Action { + label: "Drop".into(), + desc: "Note".into(), + action: "note:item:2".into(), + args: None, + }; + let actions = vec![ + ActionWithMetadata::from_action(keep.clone()), + ActionWithMetadata::from_action(drop), + ]; + let filters = QueryFilters { + include_ids: vec!["todo:item:1".into()], + include_kinds: vec!["todo".into()], + ..QueryFilters::default() + }; + + let filtered = apply_action_filters_with_metadata(actions, &filters); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].action, keep.action); + } } diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 82643e39..5ae8c4ad 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -75,7 +75,7 @@ pub use volume_dialog::VolumeDialog; use crate::actions::folders; use crate::actions::{load_actions, Action}; use crate::actions_editor::ActionsEditor; -use crate::common::query::split_action_filters; +use crate::common::query::{action_matches_filters, split_action_filters, ActionFilterMetadata}; use crate::dashboard::config::DashboardConfig; use crate::dashboard::widgets::{WidgetRegistry, WidgetSettingsContext}; use crate::dashboard::{ @@ -436,6 +436,7 @@ pub struct LauncherApp { /// actions are edited the entire `Arc` is replaced with a new one. pub actions: Arc>, action_cache: Vec, + action_filter_metadata: Vec, actions_by_id: HashMap, command_cache: Vec, command_search_cache: Vec, @@ -724,6 +725,11 @@ impl LauncherApp { .iter() .map(CachedSearchEntry::from_action) .collect(); + self.action_filter_metadata = self + .actions + .iter() + .map(ActionFilterMetadata::from_action) + .collect(); self.actions_by_id = self .actions .iter() @@ -1644,6 +1650,7 @@ impl LauncherApp { confirm_modal: ConfirmationModal::default(), pending_confirm: None, action_cache: Vec::new(), + action_filter_metadata: Vec::new(), actions_by_id, command_cache: Vec::new(), command_search_cache: Vec::new(), @@ -1759,12 +1766,26 @@ impl LauncherApp { self.recompute_query_results_layout(); } - fn search_actions(&self, query: &str, query_lc: &str) -> Vec<(Action, f32)> { + fn search_actions(&self, query: &str, _query_lc: &str) -> Vec<(Action, f32)> { + let (filtered_query, filters) = split_action_filters(query); + let filtered_query = filtered_query.trim(); + let filtered_query_lc = filtered_query.to_lowercase(); + let query = filtered_query; + let query_lc = filtered_query_lc.as_str(); + let mut res = Vec::new(); if query.is_empty() { - res.extend(self.actions.iter().cloned().map(|a| (a, 0.0))); + for (i, a) in self.actions.iter().enumerate() { + if action_matches_filters(&self.action_filter_metadata[i], &filters) { + res.push((a.clone(), 0.0)); + } + } } else { for (i, a) in self.actions.iter().enumerate() { + if !action_matches_filters(&self.action_filter_metadata[i], &filters) { + continue; + } + let cached = &self.action_cache[i]; if self.is_exact_match_mode() { let alias_match = self.alias_matches_lc(&a.action, query_lc); diff --git a/src/plugins/omni_search.rs b/src/plugins/omni_search.rs index 8c167e84..0431b902 100644 --- a/src/plugins/omni_search.rs +++ b/src/plugins/omni_search.rs @@ -378,7 +378,7 @@ mod tests { let mut value = json!("invalid"); let ctx = egui::Context::default(); - ctx.run(Default::default(), |ctx| { + let _ = ctx.run(Default::default(), |ctx| { egui::CentralPanel::default().show(ctx, |ui| { plugin.settings_ui(ui, &mut value); });