diff --git a/src/dashboard/widgets/command_history.rs b/src/dashboard/widgets/command_history.rs new file mode 100644 index 0000000..ac30a14 --- /dev/null +++ b/src/dashboard/widgets/command_history.rs @@ -0,0 +1,253 @@ +use super::{edit_typed_settings, Widget, WidgetAction, WidgetSettingsContext, WidgetSettingsUiResult}; +use crate::actions::Action; +use crate::dashboard::dashboard::{DashboardContext, WidgetActivation}; +use crate::history::{toggle_pin, HistoryEntry, HistoryPin, HISTORY_PINS_FILE}; +use chrono::TimeZone; +use eframe::egui; +use serde::{Deserialize, Serialize}; +use std::time::{Duration, Instant}; + +fn default_count() -> usize { + 8 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommandHistoryConfig { + #[serde(default = "default_count")] + pub count: usize, + #[serde(default)] + pub show_pinned_only: bool, + #[serde(default = "default_show_filter")] + pub show_filter: bool, +} + +impl Default for CommandHistoryConfig { + fn default() -> Self { + Self { + count: default_count(), + show_pinned_only: false, + show_filter: default_show_filter(), + } + } +} + +fn default_show_filter() -> bool { + true +} + +#[derive(Clone)] +struct DisplayEntry { + action_id: String, + action: Action, + query: String, + timestamp: i64, + pinned: bool, +} + +pub struct CommandHistoryWidget { + cfg: CommandHistoryConfig, + filter: String, + cached_pins: Vec, + last_pins_load: Instant, +} + +impl CommandHistoryWidget { + pub fn new(cfg: CommandHistoryConfig) -> Self { + Self { + cfg, + filter: String::new(), + cached_pins: Vec::new(), + last_pins_load: Instant::now() - Duration::from_secs(10), + } + } + + pub fn settings_ui( + ui: &mut egui::Ui, + value: &mut serde_json::Value, + ctx: &WidgetSettingsContext<'_>, + ) -> WidgetSettingsUiResult { + edit_typed_settings( + ui, + value, + ctx, + |ui, cfg: &mut CommandHistoryConfig, _ctx| { + let mut changed = false; + ui.horizontal(|ui| { + ui.label("Count"); + changed |= ui + .add(egui::DragValue::new(&mut cfg.count).clamp_range(1..=50)) + .changed(); + }); + changed |= ui + .checkbox(&mut cfg.show_pinned_only, "Show pinned only") + .changed(); + changed |= ui.checkbox(&mut cfg.show_filter, "Show filter").changed(); + changed + }, + ) + } + + fn refresh_pins(&mut self) { + if self.last_pins_load.elapsed() > Duration::from_secs(2) { + self.cached_pins = crate::history::load_pins(HISTORY_PINS_FILE).unwrap_or_default(); + self.last_pins_load = Instant::now(); + } + } + + fn format_timestamp(ts: i64) -> String { + if ts <= 0 { + return "Unknown time".into(); + } + chrono::Local + .timestamp_opt(ts, 0) + .single() + .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) + .unwrap_or_else(|| "Unknown time".into()) + } + + fn entry_matches_filter(entry: &DisplayEntry, filter: &str) -> bool { + if filter.is_empty() { + return true; + } + let filter = filter.to_lowercase(); + entry.action.label.to_lowercase().contains(&filter) + || 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 entry_from_history(ctx: &DashboardContext<'_>, entry: &HistoryEntry) -> DisplayEntry { + let action = Self::resolve_action(ctx, &entry.action.action, &entry.action); + DisplayEntry { + action_id: entry.action.action.clone(), + action, + query: entry.query.clone(), + timestamp: entry.timestamp, + pinned: false, + } + } + + fn entry_from_pin(ctx: &DashboardContext<'_>, pin: &HistoryPin) -> DisplayEntry { + let fallback = Action { + label: pin.label.clone(), + desc: pin.desc.clone(), + action: pin.action_id.clone(), + args: pin.args.clone(), + }; + let action = Self::resolve_action(ctx, &pin.action_id, &fallback); + DisplayEntry { + action_id: pin.action_id.clone(), + action, + query: pin.query.clone(), + timestamp: pin.timestamp, + pinned: true, + } + } + + fn is_pinned(pins: &[HistoryPin], entry: &HistoryEntry) -> bool { + let pin = HistoryPin::from_history(entry); + pins.iter().any(|p| p == &pin) + } +} + +impl Default for CommandHistoryWidget { + fn default() -> Self { + Self::new(CommandHistoryConfig::default()) + } +} + +impl Widget for CommandHistoryWidget { + fn render( + &mut self, + ui: &mut egui::Ui, + ctx: &DashboardContext<'_>, + _activation: WidgetActivation, + ) -> Option { + self.refresh_pins(); + let mut clicked = None; + ui.label("Command history"); + + if self.cfg.show_filter { + ui.horizontal(|ui| { + ui.label("Filter"); + ui.text_edit_singleline(&mut self.filter); + }); + } + + let history_entries = crate::history::with_history(|h| h.iter().cloned().collect::>()) + .unwrap_or_default(); + + let mut entries: Vec = Vec::new(); + if self.cfg.show_pinned_only { + entries.extend( + self.cached_pins + .iter() + .map(|pin| Self::entry_from_pin(ctx, pin)), + ); + } else { + let mut pinned: Vec = self + .cached_pins + .iter() + .map(|pin| Self::entry_from_pin(ctx, pin)) + .collect(); + pinned.sort_by_key(|entry| std::cmp::Reverse(entry.timestamp)); + entries.extend(pinned); + + for entry in &history_entries { + if Self::is_pinned(&self.cached_pins, entry) { + continue; + } + entries.push(Self::entry_from_history(ctx, entry)); + } + } + + let filtered = entries + .into_iter() + .filter(|entry| Self::entry_matches_filter(entry, &self.filter)) + .take(self.cfg.count) + .collect::>(); + + if filtered.is_empty() { + ui.label("No history entries."); + } + + for entry in filtered { + let timestamp = Self::format_timestamp(entry.timestamp); + ui.horizontal(|ui| { + let pin_label = if entry.pinned { "★" } else { "☆" }; + if ui.button(pin_label).clicked() { + let pin = HistoryPin { + action_id: entry.action_id.clone(), + label: entry.action.label.clone(), + desc: entry.action.desc.clone(), + args: entry.action.args.clone(), + query: entry.query.clone(), + timestamp: entry.timestamp, + }; + if let Ok(pinned) = toggle_pin(HISTORY_PINS_FILE, &pin) { + if pinned { + self.cached_pins.push(pin); + } else { + self.cached_pins.retain(|p| p != &pin); + } + } + } + + if ui.button(&entry.action.label).clicked() { + clicked = Some(WidgetAction { + action: entry.action.clone(), + query_override: Some(entry.query.clone()), + }); + } + ui.label(timestamp); + }); + } + + clicked + } +} diff --git a/src/dashboard/widgets/mod.rs b/src/dashboard/widgets/mod.rs index 3a62f96..e85c998 100644 --- a/src/dashboard/widgets/mod.rs +++ b/src/dashboard/widgets/mod.rs @@ -12,6 +12,7 @@ mod browser_tabs; mod calendar; mod clipboard_recent; mod clipboard_snippets; +mod command_history; mod frequent_commands; mod layouts; mod notes_recent; @@ -43,6 +44,7 @@ pub use browser_tabs::BrowserTabsWidget; pub use calendar::CalendarWidget; pub use clipboard_recent::ClipboardRecentWidget; pub use clipboard_snippets::ClipboardSnippetsWidget; +pub use command_history::CommandHistoryWidget; pub use frequent_commands::FrequentCommandsWidget; pub use layouts::LayoutsWidget; pub use notes_recent::NotesRecentWidget; @@ -211,6 +213,11 @@ impl WidgetRegistry { WidgetFactory::new(PluginHomeWidget::new) .with_settings_ui(PluginHomeWidget::settings_ui), ); + reg.register( + "command_history", + WidgetFactory::new(CommandHistoryWidget::new) + .with_settings_ui(CommandHistoryWidget::settings_ui), + ); reg.register( "recent_commands", WidgetFactory::new(RecentCommandsWidget::new) diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 147d2d9..cb931f9 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -2014,6 +2014,7 @@ impl LauncherApp { query: current.clone(), query_lc: String::new(), action: a.clone(), + timestamp: 0, }, self.history_limit, ); @@ -2039,6 +2040,7 @@ impl LauncherApp { query: current.clone(), query_lc: String::new(), action: a.clone(), + timestamp: 0, }, self.history_limit, ); @@ -2087,6 +2089,7 @@ impl LauncherApp { query: current.clone(), query_lc: String::new(), action: a.clone(), + timestamp: 0, }, self.history_limit, ); diff --git a/src/history.rs b/src/history.rs index 32e6cff..5d0414a 100644 --- a/src/history.rs +++ b/src/history.rs @@ -10,9 +10,46 @@ pub struct HistoryEntry { #[serde(skip)] pub query_lc: String, pub action: Action, + #[serde(default)] + pub timestamp: i64, } const HISTORY_FILE: &str = "history.json"; +pub const HISTORY_PINS_FILE: &str = "history_pins.json"; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct HistoryPin { + pub action_id: String, + pub label: String, + pub desc: String, + pub args: Option, + pub query: String, + #[serde(default)] + pub timestamp: i64, +} + +impl HistoryPin { + pub fn from_history(entry: &HistoryEntry) -> Self { + Self { + action_id: entry.action.action.clone(), + label: entry.action.label.clone(), + desc: entry.action.desc.clone(), + args: entry.action.args.clone(), + query: entry.query.clone(), + timestamp: entry.timestamp, + } + } +} + +impl PartialEq for HistoryPin { + fn eq(&self, other: &Self) -> bool { + self.action_id == other.action_id + && self.query == other.query + && self.timestamp == other.timestamp + } +} + +impl Eq for HistoryPin {} static HISTORY: Lazy>> = Lazy::new(|| { let hist = load_history_internal().unwrap_or_else(|e| { @@ -57,6 +94,9 @@ pub fn save_history() -> anyhow::Result<()> { /// specifies the maximum number of entries kept. pub fn append_history(mut entry: HistoryEntry, limit: usize) -> anyhow::Result<()> { entry.query_lc = entry.query.to_lowercase(); + if entry.timestamp == 0 { + entry.timestamp = chrono::Utc::now().timestamp(); + } { let Some(mut h) = HISTORY.write().ok() else { return Ok(()); @@ -94,3 +134,65 @@ pub fn clear_history() -> anyhow::Result<()> { } save_history() } + +pub fn load_pins(path: &str) -> anyhow::Result> { + let content = std::fs::read_to_string(path).unwrap_or_default(); + if content.is_empty() { + return Ok(Vec::new()); + } + let list: Vec = serde_json::from_str(&content)?; + Ok(list) +} + +pub fn save_pins(path: &str, pins: &[HistoryPin]) -> anyhow::Result<()> { + let json = serde_json::to_string_pretty(pins)?; + std::fs::write(path, json)?; + Ok(()) +} + +pub fn toggle_pin(path: &str, pin: &HistoryPin) -> anyhow::Result { + let mut pins = load_pins(path).unwrap_or_default(); + if let Some(idx) = pins.iter().position(|p| p == pin) { + pins.remove(idx); + save_pins(path, &pins)?; + Ok(false) + } else { + pins.push(pin.clone()); + save_pins(path, &pins)?; + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::{load_pins, save_pins, toggle_pin, HistoryPin}; + use tempfile::tempdir; + + #[test] + fn pin_roundtrip_and_toggle() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("pins.json"); + let pin = HistoryPin { + action_id: "action:one".into(), + label: "One".into(), + desc: "Test".into(), + args: Some("--flag".into()), + query: "one".into(), + timestamp: 123, + }; + + save_pins(path.to_str().unwrap(), &[pin.clone()]).expect("save pins"); + let loaded = load_pins(path.to_str().unwrap()).expect("load pins"); + assert_eq!(loaded, vec![pin.clone()]); + + let now_pinned = toggle_pin(path.to_str().unwrap(), &pin).expect("toggle off"); + assert!(!now_pinned); + let cleared = load_pins(path.to_str().unwrap()).expect("load after clear"); + assert!(cleared.is_empty()); + + let now_pinned = toggle_pin(path.to_str().unwrap(), &pin).expect("toggle on"); + assert!(now_pinned); + let reloaded = load_pins(path.to_str().unwrap()).expect("load after add"); + assert_eq!(reloaded, vec![pin]); + } +} diff --git a/tests/history.rs b/tests/history.rs index 3caf6fd..19f02f7 100644 --- a/tests/history.rs +++ b/tests/history.rs @@ -8,7 +8,17 @@ fn clear_history_empties_file() { let dir = tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); - let entry = HistoryEntry { query: "test".into(), query_lc: String::new(), action: Action { label: "l".into(), desc: "".into(), action: "run".into(), args: None } }; + let entry = HistoryEntry { + query: "test".into(), + query_lc: String::new(), + action: Action { + label: "l".into(), + desc: "".into(), + action: "run".into(), + args: None, + }, + timestamp: 0, + }; append_history(entry, 10).unwrap(); assert!(!get_history().is_empty()); diff --git a/tests/resilience.rs b/tests/resilience.rs index 4d5b9c3..f017592 100644 --- a/tests/resilience.rs +++ b/tests/resilience.rs @@ -46,6 +46,7 @@ fn history_poisoned_lock_does_not_panic() { query: "q".into(), query_lc: String::new(), action, + timestamp: 0, }; assert!(std::panic::catch_unwind(|| { let _ = append_history(entry.clone(), 10);