Skip to content

Commit b49ae3c

Browse files
authored
Merge pull request #822 from multiplex55/codex/add-note-and-gesture-deletion-confirmation
Codex-generated pull request
2 parents 57a5c94 + 1835878 commit b49ae3c

3 files changed

Lines changed: 247 additions & 25 deletions

File tree

src/gui/confirmation_modal.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ pub enum DestructiveAction {
1515
ClearHistory,
1616
ClearTodos,
1717
DeleteTodo,
18+
DeleteNote,
19+
DeleteGesture,
1820
ClearTempfiles,
1921
ClearBrowserTabCache,
2022
EmptyRecycleBin,
@@ -31,6 +33,7 @@ impl DestructiveAction {
3133
"tab:clear" => Some(Self::ClearBrowserTabCache),
3234
"recycle:clean" => Some(Self::EmptyRecycleBin),
3335
_ if action.action.starts_with("todo:remove:") => Some(Self::DeleteTodo),
36+
_ if action.action.starts_with("note:remove:") => Some(Self::DeleteNote),
3437
_ => None,
3538
}
3639
}
@@ -41,6 +44,8 @@ impl DestructiveAction {
4144
Self::ClearHistory => "Clear search history",
4245
Self::ClearTodos => "Clear completed todos",
4346
Self::DeleteTodo => "Delete todo",
47+
Self::DeleteNote => "Delete note",
48+
Self::DeleteGesture => "Delete gesture",
4449
Self::ClearTempfiles => "Clear temp files",
4550
Self::ClearBrowserTabCache => "Clear browser tab cache",
4651
Self::EmptyRecycleBin => "Empty recycle bin",
@@ -53,6 +58,27 @@ impl DestructiveAction {
5358
}
5459
}
5560

61+
#[cfg(test)]
62+
mod tests {
63+
use super::DestructiveAction;
64+
use crate::actions::Action;
65+
66+
#[test]
67+
fn from_action_maps_note_remove() {
68+
let action = Action {
69+
label: "Delete note".into(),
70+
desc: "Notes".into(),
71+
action: "note:remove:project-idea".into(),
72+
args: None,
73+
};
74+
75+
assert_eq!(
76+
DestructiveAction::from_action(&action),
77+
Some(DestructiveAction::DeleteNote)
78+
);
79+
}
80+
}
81+
5682
#[derive(Debug, Clone)]
5783
pub struct ConfirmationModal {
5884
open: bool,

src/gui/mod.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5323,6 +5323,78 @@ mod tests {
53235323
std::env::set_current_dir(orig_dir).unwrap();
53245324
}
53255325

5326+
#[test]
5327+
fn destructive_note_delete_is_queued_when_confirmation_required() {
5328+
let _lock = TEST_MUTEX.lock().unwrap();
5329+
let dir = tempdir().unwrap();
5330+
let notes_dir = dir.path().join("notes");
5331+
std::fs::create_dir_all(&notes_dir).unwrap();
5332+
std::env::set_var("ML_NOTES_DIR", &notes_dir);
5333+
std::env::set_var("HOME", dir.path());
5334+
let orig_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
5335+
std::env::set_current_dir(dir.path()).unwrap();
5336+
save_notes(&[]).unwrap();
5337+
reset_slug_lookup();
5338+
append_note("alpha", "# alpha\n\ncontent").unwrap();
5339+
5340+
let ctx = egui::Context::default();
5341+
let mut app = new_app(&ctx);
5342+
app.require_confirm_destructive = true;
5343+
5344+
app.activate_action(
5345+
Action {
5346+
label: "Delete note".into(),
5347+
desc: "Notes".into(),
5348+
action: "note:remove:alpha".into(),
5349+
args: None,
5350+
},
5351+
None,
5352+
ActivationSource::Enter,
5353+
);
5354+
5355+
assert!(app.pending_confirm.is_some());
5356+
assert_eq!(load_notes().unwrap().len(), 1);
5357+
5358+
std::env::set_current_dir(orig_dir).unwrap();
5359+
}
5360+
5361+
#[test]
5362+
fn destructive_note_delete_executes_only_after_confirmation() {
5363+
let _lock = TEST_MUTEX.lock().unwrap();
5364+
let dir = tempdir().unwrap();
5365+
let notes_dir = dir.path().join("notes");
5366+
std::fs::create_dir_all(&notes_dir).unwrap();
5367+
std::env::set_var("ML_NOTES_DIR", &notes_dir);
5368+
std::env::set_var("HOME", dir.path());
5369+
let orig_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
5370+
std::env::set_current_dir(dir.path()).unwrap();
5371+
save_notes(&[]).unwrap();
5372+
reset_slug_lookup();
5373+
append_note("alpha", "# alpha\n\ncontent").unwrap();
5374+
5375+
let ctx = egui::Context::default();
5376+
let mut app = new_app(&ctx);
5377+
app.require_confirm_destructive = true;
5378+
5379+
app.activate_action(
5380+
Action {
5381+
label: "Delete note".into(),
5382+
desc: "Notes".into(),
5383+
action: "note:remove:alpha".into(),
5384+
args: None,
5385+
},
5386+
None,
5387+
ActivationSource::Enter,
5388+
);
5389+
5390+
assert_eq!(load_notes().unwrap().len(), 1);
5391+
let pending = app.pending_confirm.take().expect("pending confirm action");
5392+
app.activate_action_confirmed(pending.action, pending.query_override, pending.source);
5393+
assert!(load_notes().unwrap().is_empty());
5394+
5395+
std::env::set_current_dir(orig_dir).unwrap();
5396+
}
5397+
53265398
#[test]
53275399
fn note_graph_dialog_action_opens_dialog() {
53285400
let ctx = egui::Context::default();

src/gui/mouse_gestures_dialog.rs

Lines changed: 149 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use crate::gui::confirmation_modal::{ConfirmationModal, ConfirmationResult, DestructiveAction};
12
use crate::gui::LauncherApp;
23
use crate::mouse_gestures::db::{
34
format_gesture_label, load_gestures, save_gestures, BindingEntry, BindingKind, GestureDb,
@@ -284,6 +285,15 @@ pub struct MgGesturesDialog {
284285
recorder: GestureRecorder,
285286
token_buffer: String,
286287
binding_dialog: BindingDialog,
288+
delete_confirm_modal: ConfirmationModal,
289+
pending_delete: Option<PendingGestureDelete>,
290+
}
291+
292+
#[derive(Debug, Clone)]
293+
struct PendingGestureDelete {
294+
idx: usize,
295+
label: String,
296+
tokens: String,
287297
}
288298

289299
impl Default for MgGesturesDialog {
@@ -298,6 +308,8 @@ impl Default for MgGesturesDialog {
298308
recorder: GestureRecorder::new(DirMode::Four, config),
299309
token_buffer: String::new(),
300310
binding_dialog: BindingDialog::default(),
311+
delete_confirm_modal: ConfirmationModal::default(),
312+
pending_delete: None,
301313
}
302314
}
303315
}
@@ -381,6 +393,73 @@ impl MgGesturesDialog {
381393
self.binding_dialog.open = false;
382394
}
383395

396+
fn queue_gesture_delete(&mut self, idx: usize) -> bool {
397+
let Some(entry) = self.db.gestures.get(idx) else {
398+
return false;
399+
};
400+
self.pending_delete = Some(PendingGestureDelete {
401+
idx,
402+
label: entry.label.clone(),
403+
tokens: entry.tokens.clone(),
404+
});
405+
self.delete_confirm_modal
406+
.open_for(DestructiveAction::DeleteGesture);
407+
true
408+
}
409+
410+
fn resolve_pending_delete_index(&self, pending: &PendingGestureDelete) -> Option<usize> {
411+
if self
412+
.db
413+
.gestures
414+
.get(pending.idx)
415+
.is_some_and(|g| g.label == pending.label && g.tokens == pending.tokens)
416+
{
417+
return Some(pending.idx);
418+
}
419+
self.db
420+
.gestures
421+
.iter()
422+
.position(|g| g.label == pending.label && g.tokens == pending.tokens)
423+
}
424+
425+
fn adjust_indices_after_delete(&mut self, idx: usize) {
426+
if let Some(selected) = self.selected_idx {
427+
if selected == idx {
428+
self.selected_idx = None;
429+
} else if selected > idx {
430+
self.selected_idx = Some(selected - 1);
431+
}
432+
}
433+
434+
if let Some(rename) = self.rename_idx {
435+
if rename == idx {
436+
self.rename_idx = None;
437+
} else if rename > idx {
438+
self.rename_idx = Some(rename - 1);
439+
}
440+
}
441+
442+
self.ensure_selection();
443+
self.binding_dialog.editor.reset();
444+
self.binding_dialog.open = false;
445+
}
446+
447+
fn apply_pending_gesture_delete(&mut self) -> bool {
448+
let Some(pending) = self.pending_delete.take() else {
449+
return false;
450+
};
451+
let Some(idx) = self.resolve_pending_delete_index(&pending) else {
452+
return false;
453+
};
454+
self.db.gestures.remove(idx);
455+
self.adjust_indices_after_delete(idx);
456+
true
457+
}
458+
459+
fn cancel_pending_gesture_delete(&mut self) {
460+
self.pending_delete = None;
461+
}
462+
384463
fn save(&mut self, app: &mut LauncherApp) {
385464
if let Err(e) = save_gestures(GESTURES_FILE, &self.db) {
386465
app.set_error(format!("Failed to save mouse gestures: {e}"));
@@ -821,7 +900,7 @@ impl MgGesturesDialog {
821900
// ScrollArea creates its own child Ui; re-apply the left clip
822901
// so horizontally-wide rows can't paint into the right panel.
823902
ui.set_clip_rect(left_clip);
824-
let mut remove_idx: Option<usize> = None;
903+
let mut request_delete_idx: Option<usize> = None;
825904
let gesture_order = self.sorted_gesture_indices();
826905
for idx in gesture_order {
827906
let selected = self.selected_idx == Some(idx);
@@ -845,7 +924,7 @@ impl MgGesturesDialog {
845924
self.rename_label = entry.label.clone();
846925
}
847926
if ui.button("Delete").clicked() {
848-
remove_idx = Some(idx);
927+
request_delete_idx = Some(idx);
849928
}
850929
});
851930
if self.rename_idx == Some(idx) {
@@ -873,29 +952,8 @@ impl MgGesturesDialog {
873952
});
874953
}
875954
}
876-
if let Some(idx) = remove_idx {
877-
self.db.gestures.remove(idx);
878-
879-
if let Some(selected) = self.selected_idx {
880-
if selected == idx {
881-
self.selected_idx = None;
882-
} else if selected > idx {
883-
self.selected_idx = Some(selected - 1);
884-
}
885-
}
886-
887-
if let Some(rename) = self.rename_idx {
888-
if rename == idx {
889-
self.rename_idx = None;
890-
} else if rename > idx {
891-
self.rename_idx = Some(rename - 1);
892-
}
893-
}
894-
895-
self.ensure_selection();
896-
self.binding_dialog.editor.reset();
897-
self.binding_dialog.open = false;
898-
save_now = true;
955+
if let Some(idx) = request_delete_idx {
956+
let _ = self.queue_gesture_delete(idx);
899957
}
900958
});
901959
ui.separator();
@@ -1074,6 +1132,15 @@ impl MgGesturesDialog {
10741132
});
10751133
});
10761134
self.binding_dialog_ui(ctx, app, &mut save_now);
1135+
match self.delete_confirm_modal.ui(ctx) {
1136+
ConfirmationResult::Confirmed => {
1137+
if self.apply_pending_gesture_delete() {
1138+
save_now = true;
1139+
}
1140+
}
1141+
ConfirmationResult::Cancelled => self.cancel_pending_gesture_delete(),
1142+
ConfirmationResult::None => {}
1143+
}
10771144
if save_now {
10781145
self.save(app);
10791146
}
@@ -1084,3 +1151,60 @@ impl MgGesturesDialog {
10841151
}
10851152
}
10861153
}
1154+
1155+
#[cfg(test)]
1156+
mod tests {
1157+
use super::*;
1158+
1159+
fn gesture(label: &str, tokens: &str) -> GestureEntry {
1160+
GestureEntry {
1161+
label: label.into(),
1162+
tokens: tokens.into(),
1163+
dir_mode: DirMode::Four,
1164+
stroke: Vec::new(),
1165+
enabled: true,
1166+
bindings: Vec::new(),
1167+
}
1168+
}
1169+
1170+
#[test]
1171+
fn gesture_delete_is_queued_until_confirmed() {
1172+
let mut dlg = MgGesturesDialog::default();
1173+
dlg.db.gestures = vec![gesture("A", "R")];
1174+
dlg.selected_idx = Some(0);
1175+
1176+
assert!(dlg.queue_gesture_delete(0));
1177+
assert_eq!(dlg.db.gestures.len(), 1);
1178+
assert!(dlg.pending_delete.is_some());
1179+
}
1180+
1181+
#[test]
1182+
fn cancelling_gesture_delete_keeps_db_and_selection() {
1183+
let mut dlg = MgGesturesDialog::default();
1184+
dlg.db.gestures = vec![gesture("A", "R"), gesture("B", "L")];
1185+
dlg.selected_idx = Some(1);
1186+
dlg.rename_idx = Some(1);
1187+
1188+
assert!(dlg.queue_gesture_delete(1));
1189+
dlg.cancel_pending_gesture_delete();
1190+
1191+
assert_eq!(dlg.db.gestures.len(), 2);
1192+
assert_eq!(dlg.selected_idx, Some(1));
1193+
assert_eq!(dlg.rename_idx, Some(1));
1194+
}
1195+
1196+
#[test]
1197+
fn confirmed_gesture_delete_adjusts_indices() {
1198+
let mut dlg = MgGesturesDialog::default();
1199+
dlg.db.gestures = vec![gesture("A", "R"), gesture("B", "L"), gesture("C", "U")];
1200+
dlg.selected_idx = Some(2);
1201+
dlg.rename_idx = Some(2);
1202+
1203+
assert!(dlg.queue_gesture_delete(1));
1204+
assert!(dlg.apply_pending_gesture_delete());
1205+
1206+
assert_eq!(dlg.db.gestures.len(), 2);
1207+
assert_eq!(dlg.selected_idx, Some(1));
1208+
assert_eq!(dlg.rename_idx, Some(1));
1209+
}
1210+
}

0 commit comments

Comments
 (0)