Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/gui/confirmation_modal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ pub enum DestructiveAction {
ClearHistory,
ClearTodos,
DeleteTodo,
DeleteNote,
DeleteGesture,
ClearTempfiles,
ClearBrowserTabCache,
EmptyRecycleBin,
Expand All @@ -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,
}
}
Expand All @@ -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",
Expand All @@ -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,
Expand Down
72 changes: 72 additions & 0 deletions src/gui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(&notes_dir).unwrap();
std::env::set_var("ML_NOTES_DIR", &notes_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(&notes_dir).unwrap();
std::env::set_var("ML_NOTES_DIR", &notes_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();
Expand Down
174 changes: 149 additions & 25 deletions src/gui/mouse_gestures_dialog.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -284,6 +285,15 @@ pub struct MgGesturesDialog {
recorder: GestureRecorder,
token_buffer: String,
binding_dialog: BindingDialog,
delete_confirm_modal: ConfirmationModal,
pending_delete: Option<PendingGestureDelete>,
}

#[derive(Debug, Clone)]
struct PendingGestureDelete {
idx: usize,
label: String,
tokens: String,
}

impl Default for MgGesturesDialog {
Expand All @@ -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,
}
}
}
Expand Down Expand Up @@ -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<usize> {
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}"));
Expand Down Expand Up @@ -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<usize> = None;
let mut request_delete_idx: Option<usize> = None;
let gesture_order = self.sorted_gesture_indices();
for idx in gesture_order {
let selected = self.selected_idx == Some(idx);
Expand All @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
Expand All @@ -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));
}
}