From 183587872fb2e46db8db6a672277dd1bea31b873 Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:14:38 -0500 Subject: [PATCH] Add destructive confirmations for note and gesture deletes --- src/gui/confirmation_modal.rs | 26 +++++ src/gui/mod.rs | 72 +++++++++++++ src/gui/mouse_gestures_dialog.rs | 174 ++++++++++++++++++++++++++----- 3 files changed, 247 insertions(+), 25 deletions(-) diff --git a/src/gui/confirmation_modal.rs b/src/gui/confirmation_modal.rs index bcdff326..5a57a804 100644 --- a/src/gui/confirmation_modal.rs +++ b/src/gui/confirmation_modal.rs @@ -15,6 +15,8 @@ pub enum DestructiveAction { ClearHistory, ClearTodos, DeleteTodo, + DeleteNote, + DeleteGesture, ClearTempfiles, ClearBrowserTabCache, EmptyRecycleBin, @@ -31,6 +33,7 @@ impl DestructiveAction { "tab:clear" => Some(Self::ClearBrowserTabCache), "recycle:clean" => Some(Self::EmptyRecycleBin), _ if action.action.starts_with("todo:remove:") => Some(Self::DeleteTodo), + _ if action.action.starts_with("note:remove:") => Some(Self::DeleteNote), _ => None, } } @@ -41,6 +44,8 @@ impl DestructiveAction { Self::ClearHistory => "Clear search history", Self::ClearTodos => "Clear completed todos", Self::DeleteTodo => "Delete todo", + Self::DeleteNote => "Delete note", + Self::DeleteGesture => "Delete gesture", Self::ClearTempfiles => "Clear temp files", Self::ClearBrowserTabCache => "Clear browser tab cache", Self::EmptyRecycleBin => "Empty recycle bin", @@ -53,6 +58,27 @@ impl DestructiveAction { } } +#[cfg(test)] +mod tests { + use super::DestructiveAction; + use crate::actions::Action; + + #[test] + fn from_action_maps_note_remove() { + let action = Action { + label: "Delete note".into(), + desc: "Notes".into(), + action: "note:remove:project-idea".into(), + args: None, + }; + + assert_eq!( + DestructiveAction::from_action(&action), + Some(DestructiveAction::DeleteNote) + ); + } +} + #[derive(Debug, Clone)] pub struct ConfirmationModal { open: bool, diff --git a/src/gui/mod.rs b/src/gui/mod.rs index ab847857..e3651515 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -5323,6 +5323,78 @@ mod tests { std::env::set_current_dir(orig_dir).unwrap(); } + #[test] + fn destructive_note_delete_is_queued_when_confirmation_required() { + let _lock = TEST_MUTEX.lock().unwrap(); + let dir = tempdir().unwrap(); + let notes_dir = dir.path().join("notes"); + std::fs::create_dir_all(¬es_dir).unwrap(); + std::env::set_var("ML_NOTES_DIR", ¬es_dir); + std::env::set_var("HOME", dir.path()); + let orig_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + std::env::set_current_dir(dir.path()).unwrap(); + save_notes(&[]).unwrap(); + reset_slug_lookup(); + append_note("alpha", "# alpha\n\ncontent").unwrap(); + + let ctx = egui::Context::default(); + let mut app = new_app(&ctx); + app.require_confirm_destructive = true; + + app.activate_action( + Action { + label: "Delete note".into(), + desc: "Notes".into(), + action: "note:remove:alpha".into(), + args: None, + }, + None, + ActivationSource::Enter, + ); + + assert!(app.pending_confirm.is_some()); + assert_eq!(load_notes().unwrap().len(), 1); + + std::env::set_current_dir(orig_dir).unwrap(); + } + + #[test] + fn destructive_note_delete_executes_only_after_confirmation() { + let _lock = TEST_MUTEX.lock().unwrap(); + let dir = tempdir().unwrap(); + let notes_dir = dir.path().join("notes"); + std::fs::create_dir_all(¬es_dir).unwrap(); + std::env::set_var("ML_NOTES_DIR", ¬es_dir); + std::env::set_var("HOME", dir.path()); + let orig_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + std::env::set_current_dir(dir.path()).unwrap(); + save_notes(&[]).unwrap(); + reset_slug_lookup(); + append_note("alpha", "# alpha\n\ncontent").unwrap(); + + let ctx = egui::Context::default(); + let mut app = new_app(&ctx); + app.require_confirm_destructive = true; + + app.activate_action( + Action { + label: "Delete note".into(), + desc: "Notes".into(), + action: "note:remove:alpha".into(), + args: None, + }, + None, + ActivationSource::Enter, + ); + + assert_eq!(load_notes().unwrap().len(), 1); + let pending = app.pending_confirm.take().expect("pending confirm action"); + app.activate_action_confirmed(pending.action, pending.query_override, pending.source); + assert!(load_notes().unwrap().is_empty()); + + std::env::set_current_dir(orig_dir).unwrap(); + } + #[test] fn note_graph_dialog_action_opens_dialog() { let ctx = egui::Context::default(); diff --git a/src/gui/mouse_gestures_dialog.rs b/src/gui/mouse_gestures_dialog.rs index b4f3bf86..b25da0a2 100644 --- a/src/gui/mouse_gestures_dialog.rs +++ b/src/gui/mouse_gestures_dialog.rs @@ -1,3 +1,4 @@ +use crate::gui::confirmation_modal::{ConfirmationModal, ConfirmationResult, DestructiveAction}; use crate::gui::LauncherApp; use crate::mouse_gestures::db::{ format_gesture_label, load_gestures, save_gestures, BindingEntry, BindingKind, GestureDb, @@ -284,6 +285,15 @@ pub struct MgGesturesDialog { recorder: GestureRecorder, token_buffer: String, binding_dialog: BindingDialog, + delete_confirm_modal: ConfirmationModal, + pending_delete: Option, +} + +#[derive(Debug, Clone)] +struct PendingGestureDelete { + idx: usize, + label: String, + tokens: String, } impl Default for MgGesturesDialog { @@ -298,6 +308,8 @@ impl Default for MgGesturesDialog { recorder: GestureRecorder::new(DirMode::Four, config), token_buffer: String::new(), binding_dialog: BindingDialog::default(), + delete_confirm_modal: ConfirmationModal::default(), + pending_delete: None, } } } @@ -381,6 +393,73 @@ impl MgGesturesDialog { self.binding_dialog.open = false; } + fn queue_gesture_delete(&mut self, idx: usize) -> bool { + let Some(entry) = self.db.gestures.get(idx) else { + return false; + }; + self.pending_delete = Some(PendingGestureDelete { + idx, + label: entry.label.clone(), + tokens: entry.tokens.clone(), + }); + self.delete_confirm_modal + .open_for(DestructiveAction::DeleteGesture); + true + } + + fn resolve_pending_delete_index(&self, pending: &PendingGestureDelete) -> Option { + if self + .db + .gestures + .get(pending.idx) + .is_some_and(|g| g.label == pending.label && g.tokens == pending.tokens) + { + return Some(pending.idx); + } + self.db + .gestures + .iter() + .position(|g| g.label == pending.label && g.tokens == pending.tokens) + } + + fn adjust_indices_after_delete(&mut self, idx: usize) { + if let Some(selected) = self.selected_idx { + if selected == idx { + self.selected_idx = None; + } else if selected > idx { + self.selected_idx = Some(selected - 1); + } + } + + if let Some(rename) = self.rename_idx { + if rename == idx { + self.rename_idx = None; + } else if rename > idx { + self.rename_idx = Some(rename - 1); + } + } + + self.ensure_selection(); + self.binding_dialog.editor.reset(); + self.binding_dialog.open = false; + } + + fn apply_pending_gesture_delete(&mut self) -> bool { + let Some(pending) = self.pending_delete.take() else { + return false; + }; + let Some(idx) = self.resolve_pending_delete_index(&pending) else { + return false; + }; + self.db.gestures.remove(idx); + self.adjust_indices_after_delete(idx); + true + } + + fn cancel_pending_gesture_delete(&mut self) { + self.pending_delete = None; + } + fn save(&mut self, app: &mut LauncherApp) { if let Err(e) = save_gestures(GESTURES_FILE, &self.db) { app.set_error(format!("Failed to save mouse gestures: {e}")); @@ -821,7 +900,7 @@ impl MgGesturesDialog { // ScrollArea creates its own child Ui; re-apply the left clip // so horizontally-wide rows can't paint into the right panel. ui.set_clip_rect(left_clip); - let mut remove_idx: Option = None; + let mut request_delete_idx: Option = None; let gesture_order = self.sorted_gesture_indices(); for idx in gesture_order { let selected = self.selected_idx == Some(idx); @@ -845,7 +924,7 @@ impl MgGesturesDialog { self.rename_label = entry.label.clone(); } if ui.button("Delete").clicked() { - remove_idx = Some(idx); + request_delete_idx = Some(idx); } }); if self.rename_idx == Some(idx) { @@ -873,29 +952,8 @@ impl MgGesturesDialog { }); } } - if let Some(idx) = remove_idx { - self.db.gestures.remove(idx); - - if let Some(selected) = self.selected_idx { - if selected == idx { - self.selected_idx = None; - } else if selected > idx { - self.selected_idx = Some(selected - 1); - } - } - - if let Some(rename) = self.rename_idx { - if rename == idx { - self.rename_idx = None; - } else if rename > idx { - self.rename_idx = Some(rename - 1); - } - } - - self.ensure_selection(); - self.binding_dialog.editor.reset(); - self.binding_dialog.open = false; - save_now = true; + if let Some(idx) = request_delete_idx { + let _ = self.queue_gesture_delete(idx); } }); ui.separator(); @@ -1074,6 +1132,15 @@ impl MgGesturesDialog { }); }); self.binding_dialog_ui(ctx, app, &mut save_now); + match self.delete_confirm_modal.ui(ctx) { + ConfirmationResult::Confirmed => { + if self.apply_pending_gesture_delete() { + save_now = true; + } + } + ConfirmationResult::Cancelled => self.cancel_pending_gesture_delete(), + ConfirmationResult::None => {} + } if save_now { self.save(app); } @@ -1084,3 +1151,60 @@ impl MgGesturesDialog { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn gesture(label: &str, tokens: &str) -> GestureEntry { + GestureEntry { + label: label.into(), + tokens: tokens.into(), + dir_mode: DirMode::Four, + stroke: Vec::new(), + enabled: true, + bindings: Vec::new(), + } + } + + #[test] + fn gesture_delete_is_queued_until_confirmed() { + let mut dlg = MgGesturesDialog::default(); + dlg.db.gestures = vec![gesture("A", "R")]; + dlg.selected_idx = Some(0); + + assert!(dlg.queue_gesture_delete(0)); + assert_eq!(dlg.db.gestures.len(), 1); + assert!(dlg.pending_delete.is_some()); + } + + #[test] + fn cancelling_gesture_delete_keeps_db_and_selection() { + let mut dlg = MgGesturesDialog::default(); + dlg.db.gestures = vec![gesture("A", "R"), gesture("B", "L")]; + dlg.selected_idx = Some(1); + dlg.rename_idx = Some(1); + + assert!(dlg.queue_gesture_delete(1)); + dlg.cancel_pending_gesture_delete(); + + assert_eq!(dlg.db.gestures.len(), 2); + assert_eq!(dlg.selected_idx, Some(1)); + assert_eq!(dlg.rename_idx, Some(1)); + } + + #[test] + fn confirmed_gesture_delete_adjusts_indices() { + let mut dlg = MgGesturesDialog::default(); + dlg.db.gestures = vec![gesture("A", "R"), gesture("B", "L"), gesture("C", "U")]; + dlg.selected_idx = Some(2); + dlg.rename_idx = Some(2); + + assert!(dlg.queue_gesture_delete(1)); + assert!(dlg.apply_pending_gesture_delete()); + + assert_eq!(dlg.db.gestures.len(), 2); + assert_eq!(dlg.selected_idx, Some(1)); + assert_eq!(dlg.rename_idx, Some(1)); + } +}