diff --git a/src/dashboard/widgets/command_history.rs b/src/dashboard/widgets/command_history.rs index ac30a14..54a6457 100644 --- a/src/dashboard/widgets/command_history.rs +++ b/src/dashboard/widgets/command_history.rs @@ -42,6 +42,7 @@ struct DisplayEntry { query: String, timestamp: i64, pinned: bool, + missing: bool, } pub struct CommandHistoryWidget { @@ -114,21 +115,162 @@ impl CommandHistoryWidget { || entry.query.to_lowercase().contains(&filter) } - fn resolve_action(ctx: &DashboardContext<'_>, action_id: &str, fallback: &Action) -> Action { - ctx.actions_by_id - .get(action_id) - .cloned() - .unwrap_or_else(|| fallback.clone()) + fn resolve_action( + ctx: &DashboardContext<'_>, + action_id: &str, + args: Option<&str>, + ) -> Option { + if let Some(action) = ctx.actions_by_id.get(action_id) { + return Some(action.clone()); + } + + let commands = ctx.plugins.commands_filtered(ctx.enabled_plugins); + if let Some(action) = commands + .into_iter() + .find(|action| action.action == action_id && action.args.as_deref() == args) + { + return Some(action); + } + + let snapshot = ctx.data_cache.snapshot(); + if let Some(action) = snapshot + .processes + .iter() + .find(|action| action.action == action_id && action.args.as_deref() == args) + { + return Some(action.clone()); + } + + if let Some(fav) = snapshot + .favorites + .iter() + .find(|fav| fav.action == action_id && fav.args.as_deref() == args) + { + return Some(Action { + label: fav.label.clone(), + desc: "Fav".into(), + action: fav.action.clone(), + args: fav.args.clone(), + }); + } + + if let Some(slug) = action_id.strip_prefix("note:open:") { + if let Some(note) = snapshot.notes.iter().find(|note| note.slug == slug) { + return Some(Action { + label: note.alias.as_ref().unwrap_or(¬e.title).clone(), + desc: "Note".into(), + action: action_id.to_string(), + args: None, + }); + } + } + + if let Some(idx) = action_id + .strip_prefix("clipboard:copy:") + .and_then(|s| s.parse::().ok()) + { + if let Some(entry) = snapshot.clipboard_history.get(idx) { + return Some(Action { + label: entry.clone(), + desc: "Clipboard".into(), + action: action_id.to_string(), + args: None, + }); + } + } + + if let Some(idx) = action_id + .strip_prefix("todo:done:") + .and_then(|s| s.parse::().ok()) + { + if let Some(todo) = snapshot.todos.get(idx) { + return Some(Action { + label: format!("{} {}", if todo.done { "[x]" } else { "[ ]" }, todo.text), + desc: "Todo".into(), + action: action_id.to_string(), + args: None, + }); + } + } + + if let Some(idx) = action_id + .strip_prefix("todo:edit:") + .and_then(|s| s.parse::().ok()) + { + if let Some(todo) = snapshot.todos.get(idx) { + return Some(Action { + label: format!("{} {}", if todo.done { "[x]" } else { "[ ]" }, todo.text), + desc: "Todo".into(), + action: action_id.to_string(), + args: None, + }); + } + } + + if let Some(idx) = action_id + .strip_prefix("todo:remove:") + .and_then(|s| s.parse::().ok()) + { + if let Some(todo) = snapshot.todos.get(idx) { + return Some(Action { + label: format!("Remove todo {}", todo.text), + desc: "Todo".into(), + action: action_id.to_string(), + args: None, + }); + } + } + + for snippet in snapshot.snippets.iter() { + if action_id == format!("clipboard:{}", snippet.text) { + return Some(Action { + label: snippet.alias.clone(), + desc: "Snippet".into(), + action: action_id.to_string(), + args: None, + }); + } + } + + if let Some(alias) = action_id.strip_prefix("snippet:edit:") { + if snapshot.snippets.iter().any(|s| s.alias == alias) { + return Some(Action { + label: format!("Edit snippet {alias}"), + desc: "Snippet".into(), + action: action_id.to_string(), + args: None, + }); + } + } + + if let Some(alias) = action_id.strip_prefix("snippet:remove:") { + if snapshot.snippets.iter().any(|s| s.alias == alias) { + return Some(Action { + label: format!("Remove snippet {alias}"), + desc: "Snippet".into(), + action: action_id.to_string(), + args: None, + }); + } + } + + None } fn entry_from_history(ctx: &DashboardContext<'_>, entry: &HistoryEntry) -> DisplayEntry { - let action = Self::resolve_action(ctx, &entry.action.action, &entry.action); + let resolved = Self::resolve_action( + ctx, + &entry.action.action, + entry.action.args.as_deref(), + ); + let action = resolved.unwrap_or_else(|| entry.action.clone()); DisplayEntry { action_id: entry.action.action.clone(), action, query: entry.query.clone(), timestamp: entry.timestamp, pinned: false, + missing: false, } } @@ -139,13 +281,15 @@ impl CommandHistoryWidget { action: pin.action_id.clone(), args: pin.args.clone(), }; - let action = Self::resolve_action(ctx, &pin.action_id, &fallback); + let resolved = Self::resolve_action(ctx, &pin.action_id, pin.args.as_deref()); + let action = resolved.clone().unwrap_or(fallback); DisplayEntry { action_id: pin.action_id.clone(), action, query: pin.query.clone(), timestamp: pin.timestamp, pinned: true, + missing: resolved.is_none(), } } @@ -238,12 +382,28 @@ impl Widget for CommandHistoryWidget { } } - if ui.button(&entry.action.label).clicked() { + let action_label = if entry.missing { + format!("{} (missing)", entry.action.label) + } else { + entry.action.label.clone() + }; + if entry.missing { + ui.colored_label(egui::Color32::YELLOW, action_label); + } else if ui.button(&action_label).clicked() { clicked = Some(WidgetAction { action: entry.action.clone(), query_override: Some(entry.query.clone()), }); } + if entry.missing && ui.button("Unpin").clicked() { + let _ = crate::history::remove_pin( + HISTORY_PINS_FILE, + &entry.action_id, + entry.action.args.as_deref(), + ); + self.cached_pins + .retain(|p| p.action_id != entry.action_id || p.args != entry.action.args); + } ui.label(timestamp); }); } diff --git a/src/gui/mod.rs b/src/gui/mod.rs index cb931f9..161f8db 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -70,7 +70,7 @@ use crate::dashboard::{ Dashboard, DashboardContext, DashboardDataCache, DashboardEvent, WidgetActivation, }; use crate::help_window::HelpWindow; -use crate::history::{self, HistoryEntry}; +use crate::history::{self, HistoryEntry, HistoryPin, HISTORY_PINS_FILE}; use crate::indexer; use crate::launcher::launch_action; use crate::plugin::PluginManager; @@ -1505,6 +1505,261 @@ impl LauncherApp { self.focus_query = true; } + fn resolve_pin_action(&self, pin: &HistoryPin) -> Option { + if let Some(action) = self.actions_by_id.get(&pin.action_id) { + return Some(action.clone()); + } + + let commands = self.plugins.commands_filtered(self.enabled_plugins.as_ref()); + if let Some(action) = commands.into_iter().find(|action| { + action.action == pin.action_id && action.args.as_deref() == pin.args.as_deref() + }) { + return Some(action); + } + + let snapshot = self.dashboard_data_cache.snapshot(); + if let Some(action) = snapshot.processes.iter().find(|action| { + action.action == pin.action_id && action.args.as_deref() == pin.args.as_deref() + }) { + return Some(action.clone()); + } + + if let Some(fav) = snapshot.favorites.iter().find(|fav| { + fav.action == pin.action_id && fav.args.as_deref() == pin.args.as_deref() + }) { + return Some(Action { + label: fav.label.clone(), + desc: "Fav".into(), + action: fav.action.clone(), + args: fav.args.clone(), + }); + } + + if let Some(slug) = pin.action_id.strip_prefix("note:open:") { + if let Some(note) = snapshot.notes.iter().find(|note| note.slug == slug) { + return Some(Action { + label: note.alias.as_ref().unwrap_or(¬e.title).clone(), + desc: "Note".into(), + action: pin.action_id.clone(), + args: None, + }); + } + } + + if let Some(idx) = pin + .action_id + .strip_prefix("clipboard:copy:") + .and_then(|s| s.parse::().ok()) + { + if let Some(entry) = snapshot.clipboard_history.get(idx) { + return Some(Action { + label: entry.clone(), + desc: "Clipboard".into(), + action: pin.action_id.clone(), + args: None, + }); + } + } + + if let Some(idx) = pin + .action_id + .strip_prefix("todo:done:") + .and_then(|s| s.parse::().ok()) + { + if let Some(todo) = snapshot.todos.get(idx) { + return Some(Action { + label: format!("{} {}", if todo.done { "[x]" } else { "[ ]" }, todo.text), + desc: "Todo".into(), + action: pin.action_id.clone(), + args: None, + }); + } + } + + if let Some(idx) = pin + .action_id + .strip_prefix("todo:edit:") + .and_then(|s| s.parse::().ok()) + { + if let Some(todo) = snapshot.todos.get(idx) { + return Some(Action { + label: format!("{} {}", if todo.done { "[x]" } else { "[ ]" }, todo.text), + desc: "Todo".into(), + action: pin.action_id.clone(), + args: None, + }); + } + } + + if let Some(idx) = pin + .action_id + .strip_prefix("todo:remove:") + .and_then(|s| s.parse::().ok()) + { + if let Some(todo) = snapshot.todos.get(idx) { + return Some(Action { + label: format!("Remove todo {}", todo.text), + desc: "Todo".into(), + action: pin.action_id.clone(), + args: None, + }); + } + } + + for snippet in snapshot.snippets.iter() { + if pin.action_id == format!("clipboard:{}", snippet.text) { + return Some(Action { + label: snippet.alias.clone(), + desc: "Snippet".into(), + action: pin.action_id.clone(), + args: None, + }); + } + } + + if let Some(alias) = pin.action_id.strip_prefix("snippet:edit:") { + if snapshot.snippets.iter().any(|s| s.alias == alias) { + return Some(Action { + label: format!("Edit snippet {alias}"), + desc: "Snippet".into(), + action: pin.action_id.clone(), + args: None, + }); + } + } + + if let Some(alias) = pin.action_id.strip_prefix("snippet:remove:") { + if snapshot.snippets.iter().any(|s| s.alias == alias) { + return Some(Action { + label: format!("Remove snippet {alias}"), + desc: "Snippet".into(), + action: pin.action_id.clone(), + args: None, + }); + } + } + + None + } + + fn pin_result_menu(&mut self, ui: &mut egui::Ui, action: &Action) { + ui.separator(); + let pins = history::load_pins(HISTORY_PINS_FILE).unwrap_or_default(); + let is_pinned = pins.iter().any(|pin| pin.matches_action(action)); + let pin = HistoryPin { + action_id: action.action.clone(), + label: action.label.clone(), + desc: action.desc.clone(), + args: action.args.clone(), + query: self.query.clone(), + timestamp: chrono::Utc::now().timestamp(), + }; + + if !is_pinned { + if ui.button("Pin current query result").clicked() { + match history::upsert_pin(HISTORY_PINS_FILE, &pin) { + Ok(_) => { + if self.enable_toasts { + push_toast( + &mut self.toasts, + Toast { + text: format!("Pinned {}", action.label).into(), + kind: ToastKind::Success, + options: ToastOptions::default() + .duration_in_seconds(self.toast_duration as f64), + }, + ); + } + } + Err(e) => { + self.error = Some(format!("Failed to pin result: {e}")); + } + } + ui.close_menu(); + } + } else { + if ui.button("Unpin result").clicked() { + if let Err(e) = history::remove_pin( + HISTORY_PINS_FILE, + &action.action, + action.args.as_deref(), + ) { + self.error = Some(format!("Failed to unpin result: {e}")); + } else if self.enable_toasts { + push_toast( + &mut self.toasts, + Toast { + text: format!("Unpinned {}", action.label).into(), + kind: ToastKind::Success, + options: ToastOptions::default() + .duration_in_seconds(self.toast_duration as f64), + }, + ); + } + ui.close_menu(); + } + if ui.button("Replace pin with current result").clicked() { + match history::upsert_pin(HISTORY_PINS_FILE, &pin) { + Ok(_) => { + if self.enable_toasts { + push_toast( + &mut self.toasts, + Toast { + text: format!("Updated pin for {}", action.label).into(), + kind: ToastKind::Success, + options: ToastOptions::default() + .duration_in_seconds(self.toast_duration as f64), + }, + ); + } + } + Err(e) => { + self.error = Some(format!("Failed to update pin: {e}")); + } + } + ui.close_menu(); + } + } + + if ui.button("Recompute pinned results").clicked() { + match history::recompute_pins(HISTORY_PINS_FILE, |pin| self.resolve_pin_action(pin)) { + Ok(report) => { + if self.enable_toasts { + let text = if report.updated == 0 && report.missing == 0 { + "Pinned results are up to date.".to_string() + } else if report.updated > 0 && report.missing > 0 { + format!( + "Updated {} pinned results ({} missing).", + report.updated, report.missing + ) + } else if report.updated > 0 { + format!("Updated {} pinned results.", report.updated) + } else { + format!("{} pinned results missing.", report.missing) + }; + push_toast( + &mut self.toasts, + Toast { + text: text.into(), + kind: if report.missing > 0 { + ToastKind::Warning + } else { + ToastKind::Success + }, + options: ToastOptions::default() + .duration_in_seconds(self.toast_duration as f64), + }, + ); + } + } + Err(e) => { + self.error = Some(format!("Failed to recompute pins: {e}")); + } + } + ui.close_menu(); + } + } + pub fn set_last_search_query(&mut self, s: String) { self.last_search_query = s; } @@ -3369,6 +3624,7 @@ impl eframe::App for LauncherApp { .iter() .take(self.custom_len) .position(|act| act.action == a.action && act.label == a.label); + let mut menu_added = false; if self.folder_aliases.contains_key(&a.action) && !a.action.starts_with("folder:") { @@ -3399,7 +3655,17 @@ impl eframe::App for LauncherApp { } ui.close_menu(); } + if let Some(idx_act) = custom_idx { + if ui.button("Edit App").clicked() { + self.editor + .open_edit(idx_act, &self.actions[idx_act]); + self.show_editor = true; + ui.close_menu(); + } + } + self.pin_result_menu(ui, &a); }); + menu_added = true; } else if self.bookmark_aliases.contains_key(&a.action) { menu_resp.clone().context_menu(|ui| { if ui.button("Set Alias").clicked() { @@ -3428,7 +3694,17 @@ impl eframe::App for LauncherApp { } ui.close_menu(); } + if let Some(idx_act) = custom_idx { + if ui.button("Edit App").clicked() { + self.editor + .open_edit(idx_act, &self.actions[idx_act]); + self.show_editor = true; + ui.close_menu(); + } + } + self.pin_result_menu(ui, &a); }); + menu_added = true; } else if a.desc == "Timer" && a.action.starts_with("timer:show:") { if let Ok(id) = a.action[11..].parse::() { let query = self.query.trim().to_string(); @@ -3467,7 +3743,17 @@ impl eframe::App for LauncherApp { } ui.close_menu(); } + if let Some(idx_act) = custom_idx { + if ui.button("Edit App").clicked() { + self.editor + .open_edit(idx_act, &self.actions[idx_act]); + self.show_editor = true; + ui.close_menu(); + } + } + self.pin_result_menu(ui, &a); }); + menu_added = true; } } else if a.desc == "Stopwatch" && a.action.starts_with("stopwatch:show:") { if let Ok(id) = a.action["stopwatch:show:".len()..].parse::() { @@ -3541,7 +3827,17 @@ impl eframe::App for LauncherApp { } ui.close_menu(); } + if let Some(idx_act) = custom_idx { + if ui.button("Edit App").clicked() { + self.editor + .open_edit(idx_act, &self.actions[idx_act]); + self.show_editor = true; + ui.close_menu(); + } + } + self.pin_result_menu(ui, &a); }); + menu_added = true; } } else if a.desc == "Snippet" { menu_resp.clone().context_menu(|ui| { @@ -3568,7 +3864,17 @@ impl eframe::App for LauncherApp { } ui.close_menu(); } + if let Some(idx_act) = custom_idx { + if ui.button("Edit App").clicked() { + self.editor + .open_edit(idx_act, &self.actions[idx_act]); + self.show_editor = true; + ui.close_menu(); + } + } + self.pin_result_menu(ui, &a); }); + menu_added = true; } else if a.desc == "Tempfile" && !a.action.starts_with("tempfile:") { let file_path = a.action.clone(); menu_resp.clone().context_menu(|ui| { @@ -3597,7 +3903,17 @@ impl eframe::App for LauncherApp { } ui.close_menu(); } + if let Some(idx_act) = custom_idx { + if ui.button("Edit App").clicked() { + self.editor + .open_edit(idx_act, &self.actions[idx_act]); + self.show_editor = true; + ui.close_menu(); + } + } + self.pin_result_menu(ui, &a); }); + menu_added = true; } else if a.desc == "Note" && a.action.starts_with("note:open:") { @@ -3647,7 +3963,17 @@ impl eframe::App for LauncherApp { set_focus = true; ui.close_menu(); } + if let Some(idx_act) = custom_idx { + if ui.button("Edit App").clicked() { + self.editor + .open_edit(idx_act, &self.actions[idx_act]); + self.show_editor = true; + ui.close_menu(); + } + } + self.pin_result_menu(ui, &a); }); + menu_added = true; } else if a.desc == "Clipboard" && a.action.starts_with("clipboard:copy:") { @@ -3681,7 +4007,17 @@ impl eframe::App for LauncherApp { } ui.close_menu(); } + if let Some(idx_act) = custom_idx { + if ui.button("Edit App").clicked() { + self.editor + .open_edit(idx_act, &self.actions[idx_act]); + self.show_editor = true; + ui.close_menu(); + } + } + self.pin_result_menu(ui, &a); }); + menu_added = true; } } else if a.desc == "Todo" && a.action.starts_with("todo:done:") { let idx_str = a.action.rsplit(':').next().unwrap_or(""); @@ -3691,16 +4027,30 @@ impl eframe::App for LauncherApp { self.todo_view_dialog.open_edit(todo_idx); ui.close_menu(); } + if let Some(idx_act) = custom_idx { + if ui.button("Edit App").clicked() { + self.editor + .open_edit(idx_act, &self.actions[idx_act]); + self.show_editor = true; + ui.close_menu(); + } + } + self.pin_result_menu(ui, &a); }); + menu_added = true; } } - if let Some(idx_act) = custom_idx { + if !menu_added { menu_resp.clone().context_menu(|ui| { - if ui.button("Edit App").clicked() { - self.editor.open_edit(idx_act, &self.actions[idx_act]); - self.show_editor = true; - ui.close_menu(); + if let Some(idx_act) = custom_idx { + if ui.button("Edit App").clicked() { + self.editor + .open_edit(idx_act, &self.actions[idx_act]); + self.show_editor = true; + ui.close_menu(); + } } + self.pin_result_menu(ui, &a); }); } resp = menu_resp; diff --git a/src/history.rs b/src/history.rs index 5d0414a..863b9ec 100644 --- a/src/history.rs +++ b/src/history.rs @@ -39,13 +39,36 @@ impl HistoryPin { timestamp: entry.timestamp, } } + + pub fn matches_action(&self, action: &Action) -> bool { + self.matches_id(&action.action, action.args.as_deref()) + } + + pub fn matches_id(&self, action_id: &str, args: Option<&str>) -> bool { + self.action_id == action_id && self.args.as_deref() == args + } + + pub fn update_from_action(&mut self, action: &Action) -> bool { + let mut changed = false; + if self.label != action.label { + self.label = action.label.clone(); + changed = true; + } + if self.desc != action.desc { + self.desc = action.desc.clone(); + changed = true; + } + if self.args != action.args { + self.args = action.args.clone(); + changed = true; + } + changed + } } impl PartialEq for HistoryPin { fn eq(&self, other: &Self) -> bool { - self.action_id == other.action_id - && self.query == other.query - && self.timestamp == other.timestamp + self.action_id == other.action_id && self.args == other.args } } @@ -163,9 +186,75 @@ pub fn toggle_pin(path: &str, pin: &HistoryPin) -> anyhow::Result { } } +pub fn upsert_pin(path: &str, pin: &HistoryPin) -> anyhow::Result { + let mut pins = load_pins(path).unwrap_or_default(); + if let Some(existing) = pins + .iter_mut() + .find(|p| p.matches_id(&pin.action_id, pin.args.as_deref())) + { + existing.label = pin.label.clone(); + existing.desc = pin.desc.clone(); + existing.args = pin.args.clone(); + existing.query = pin.query.clone(); + existing.timestamp = pin.timestamp; + save_pins(path, &pins)?; + Ok(false) + } else { + pins.push(pin.clone()); + save_pins(path, &pins)?; + Ok(true) + } +} + +pub fn remove_pin(path: &str, action_id: &str, args: Option<&str>) -> anyhow::Result { + let mut pins = load_pins(path).unwrap_or_default(); + if let Some(idx) = pins + .iter() + .position(|p| p.matches_id(action_id, args)) + { + pins.remove(idx); + save_pins(path, &pins)?; + Ok(true) + } else { + Ok(false) + } +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct PinRecomputeReport { + pub updated: usize, + pub missing: usize, +} + +pub fn recompute_pins(path: &str, mut resolve: F) -> anyhow::Result +where + F: FnMut(&HistoryPin) -> Option, +{ + let mut pins = load_pins(path).unwrap_or_default(); + let mut report = PinRecomputeReport::default(); + let mut changed = false; + for pin in &mut pins { + if let Some(action) = resolve(pin) { + if pin.update_from_action(&action) { + report.updated += 1; + changed = true; + } + } else { + report.missing += 1; + } + } + if changed { + save_pins(path, &pins)?; + } + Ok(report) +} + #[cfg(test)] mod tests { - use super::{load_pins, save_pins, toggle_pin, HistoryPin}; + use super::{ + load_pins, recompute_pins, remove_pin, save_pins, toggle_pin, upsert_pin, HistoryPin, + }; + use crate::actions::Action; use tempfile::tempdir; #[test] @@ -195,4 +284,87 @@ mod tests { let reloaded = load_pins(path.to_str().unwrap()).expect("load after add"); assert_eq!(reloaded, vec![pin]); } + + #[test] + fn pin_identity_uses_action_id_and_args() { + let pin = HistoryPin { + action_id: "action:one".into(), + label: "One".into(), + desc: "Test".into(), + args: Some("--flag".into()), + query: "one".into(), + timestamp: 1, + }; + let same_action = HistoryPin { + action_id: "action:one".into(), + label: "One Updated".into(), + desc: "Other".into(), + args: Some("--flag".into()), + query: "two".into(), + timestamp: 2, + }; + let different_args = HistoryPin { + action_id: "action:one".into(), + label: "One".into(), + desc: "Test".into(), + args: Some("--other".into()), + query: "one".into(), + timestamp: 1, + }; + assert_eq!(pin, same_action); + assert_ne!(pin, different_args); + } + + #[test] + fn upsert_and_recompute_pins() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("pins.json"); + let pin = HistoryPin { + action_id: "action:one".into(), + label: "One".into(), + desc: "Old".into(), + args: None, + query: "one".into(), + timestamp: 10, + }; + let added = upsert_pin(path.to_str().unwrap(), &pin).expect("upsert add"); + assert!(added); + + let updated_pin = HistoryPin { + action_id: "action:one".into(), + label: "One Updated".into(), + desc: "New".into(), + args: None, + query: "two".into(), + timestamp: 11, + }; + let added = upsert_pin(path.to_str().unwrap(), &updated_pin).expect("upsert update"); + assert!(!added); + + let report = recompute_pins(path.to_str().unwrap(), |pin| { + if pin.action_id == "action:one" { + Some(Action { + label: "One Fresh".into(), + desc: "Fresh".into(), + action: pin.action_id.clone(), + args: None, + }) + } else { + None + } + }) + .expect("recompute"); + assert_eq!(report.updated, 1); + assert_eq!(report.missing, 0); + + let pins = load_pins(path.to_str().unwrap()).expect("reload pins"); + assert_eq!(pins.len(), 1); + assert_eq!(pins[0].label, "One Fresh"); + assert_eq!(pins[0].desc, "Fresh"); + + let removed = remove_pin(path.to_str().unwrap(), "action:one", None).expect("remove pin"); + assert!(removed); + let pins = load_pins(path.to_str().unwrap()).expect("reload pins"); + assert!(pins.is_empty()); + } }