From 2c8d8b101faa0d360c6938b7f7fd4a30906c5b4a Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 3 Jun 2026 08:48:32 -0700 Subject: [PATCH] =?UTF-8?q?feat(tui):=20delete/tombstone=20a=20task=20with?= =?UTF-8?q?=20D=20(y/N=20confirm)=20(=C2=A78.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `D` arms a delete on the highlighted task; the status line shows "Delete \"title\"? (y / N)" and the next key confirms (y) or cancels (anything else). Confirming calls node.tombstone — a true soft-delete that removes the task from every view, recurring tasks included (unlike `x` done, which rolls a recurring task forward, or `d` dropped, which keeps it in the store). Backend gains `tombstone`. Tests: confirm-flow unit test against a recording fake (arm → cancel keeps it; arm → confirm tombstones), plus a real-daemon integration test that deleting a recurring task drops it from the view and sets the node's tombstoned flag. 186 workspace tests; clippy/fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/heph-tui/src/app.rs | 35 +++++++++++++++++++++++++++++ crates/heph-tui/src/backend.rs | 8 +++++++ crates/heph-tui/src/main.rs | 10 +++++++++ crates/heph-tui/src/ui.rs | 13 ++++++++++- crates/heph-tui/tests/agenda.rs | 26 +++++++++++++++++++++ crates/heph-tui/tests/navigation.rs | 29 ++++++++++++++++++++++++ 6 files changed, 120 insertions(+), 1 deletion(-) diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index e5cbc1e..c6446ff 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -44,6 +44,13 @@ pub struct SearchView { pub cursor: usize, } +/// A pending delete awaiting y/N confirmation (the most destructive gesture). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingDelete { + pub task_id: String, + pub title: String, +} + /// The attention cycle for the `A` gesture: default → top-of-mind → consequence /// → on-deck → back. Mirrors the §6.2 white/orange/red/blue progression. pub fn next_attention(current: Option) -> Attention { @@ -106,6 +113,8 @@ pub struct App { pub mode: Mode, /// When `Some`, a full-text search overlays the task list. pub search: Option, + /// When `Some`, a delete is awaiting y/N confirmation. + pub pending_delete: Option, pub status: String, pub should_quit: bool, } @@ -143,6 +152,7 @@ impl App { focus: Focus::Sidebar, mode: Mode::Normal, search: None, + pending_delete: None, status: String::new(), should_quit: false, }; @@ -353,6 +363,31 @@ impl App { }); } + /// Arm a delete on the highlighted task (awaits y/N confirmation). + pub fn begin_delete(&mut self) { + if let Some(t) = self.selected_task() { + self.pending_delete = Some(PendingDelete { + task_id: t.node_id.clone(), + title: t.title.clone(), + }); + } + } + + /// Confirm the armed delete: tombstone the task and reload. + pub fn confirm_delete(&mut self) { + if let Some(pd) = self.pending_delete.take() { + self.mutate(format!("deleted: {}", pd.title), |b| { + b.tombstone(&pd.task_id) + }); + } + } + + /// Cancel the armed delete. + pub fn cancel_delete(&mut self) { + self.pending_delete = None; + self.status = "delete cancelled".into(); + } + // --- input modal (T2c: guided add + reschedule) --- fn current_project_id(&self) -> Option { diff --git a/crates/heph-tui/src/backend.rs b/crates/heph-tui/src/backend.rs index 2664948..60d9e80 100644 --- a/crates/heph-tui/src/backend.rs +++ b/crates/heph-tui/src/backend.rs @@ -50,6 +50,9 @@ pub trait Backend { fn set_state(&mut self, task_id: &str, state: &str) -> Result<()>; /// Skip a recurring task to its next occurrence (no completion logged). fn skip(&mut self, task_id: &str) -> Result<()>; + /// Tombstone (soft-delete) a task node — removes it from every view, + /// including recurring roll-forward. Distinct from `done`/`dropped`. + fn tombstone(&mut self, node_id: &str) -> Result<()>; /// Set a task's attention band. fn set_attention(&mut self, task_id: &str, attention: Attention) -> Result<()>; /// Patch a task's schedule (do-date / late-on / recurrence), §6 double-option. @@ -153,6 +156,11 @@ impl Backend for ClientBackend { Ok(()) } + fn tombstone(&mut self, node_id: &str) -> Result<()> { + self.call("node.tombstone", json!({ "id": node_id }))?; + Ok(()) + } + fn set_attention(&mut self, task_id: &str, attention: Attention) -> Result<()> { self.call( "task.set_attention", diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs index 6a651a7..7fa989a 100644 --- a/crates/heph-tui/src/main.rs +++ b/crates/heph-tui/src/main.rs @@ -98,6 +98,15 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option app.confirm_delete(), + _ => app.cancel_delete(), + } + return None; + } + // While collecting input, all keys go to the prompt. if matches!(app.mode, Mode::Input(_)) { match key.code { @@ -145,6 +154,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option app.skip_selected(), KeyCode::Char('A') => app.cycle_attention_selected(), KeyCode::Char('b') => app.push_to_blue_selected(), + KeyCode::Char('D') => app.begin_delete(), // open the task's context doc in nvim (handled by the event loop) KeyCode::Char('o') => return app.selected_context_id().map(Action::EditContext), _ => {} diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index 808c2ea..8ede817 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -15,7 +15,7 @@ use crate::backend::Backend; use crate::fmt::{fmt_date, today_local}; const HINTS: &str = - " j/k move Tab pane a add x done e date A attn b→blue o edit / search q quit"; + " j/k move a add x done e date A attn b→blue D del o edit / search q quit"; const SEARCH_HINTS: &str = " j/k move Enter open Esc exit search"; @@ -321,6 +321,17 @@ fn render_preview(frame: &mut Frame, app: &App, area: Rect) { } fn render_status(frame: &mut Frame, app: &App, area: Rect) { + // A pending delete confirmation takes over the status line. + if let Some(pd) = &app.pending_delete { + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + format!(" Delete \"{}\"? (y / N)", pd.title), + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ))), + area, + ); + return; + } let hints = if app.search.is_some() { SEARCH_HINTS } else { diff --git a/crates/heph-tui/tests/agenda.rs b/crates/heph-tui/tests/agenda.rs index 60b4cf1..56fa7ed 100644 --- a/crates/heph-tui/tests/agenda.rs +++ b/crates/heph-tui/tests/agenda.rs @@ -253,6 +253,32 @@ fn reschedule_sets_a_do_date_on_the_task() { assert!(!got["do_date"].is_null(), "do_date was not set: {got}"); } +#[test] +fn deleting_a_recurring_task_tombstones_it_and_drops_it_from_the_view() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + let task = c + .call( + "task.create", + json!({ "title": "Daily ritual", "attention": "red", "recurrence": "FREQ=DAILY" }), + ) + .unwrap(); + let id = task["node_id"].as_str().unwrap().to_string(); + + let mut app = App::new(ClientBackend::new(client(&socket))).unwrap(); + assert_eq!(app.tasks.len(), 1); + + app.begin_delete(); + app.confirm_delete(); + + assert!(app.status.contains("deleted"), "status: {}", app.status); + assert!(app.tasks.is_empty(), "deleted task still listed"); + // The node is tombstoned, not merely rolled forward (node.get includes + // tombstoned rows, with the flag set). + let got = c.call("node.get", json!({ "id": id })).unwrap(); + assert_eq!(got["tombstoned"], true, "task node not tombstoned: {got}"); +} + #[test] fn pushing_to_blue_moves_a_task_out_of_top_of_mind() { let (socket, _dir) = spawn_daemon(); diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index 704e383..effcc8c 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -27,6 +27,7 @@ type CreatedTask = ( struct Recorder { created: Vec, scheduled: Vec<(String, SchedulePatch)>, + tombstoned: Vec, } fn task(id: &str, title: &str, attention: Attention, ctx: Option<&str>) -> RankedTask { @@ -90,6 +91,10 @@ impl Backend for Fake { fn skip(&mut self, _t: &str) -> Result<()> { Ok(()) } + fn tombstone(&mut self, node_id: &str) -> Result<()> { + self.rec.borrow_mut().tombstoned.push(node_id.into()); + Ok(()) + } fn set_attention(&mut self, _t: &str, _a: Attention) -> Result<()> { Ok(()) } @@ -233,6 +238,30 @@ fn quick_add_files_under_the_current_project_when_no_tag_given() { assert_eq!(created[0].4.as_deref(), Some("p1")); // current project (Camano) } +#[test] +fn delete_requires_confirmation_then_tombstones() { + let rec = Rc::new(RefCell::new(Recorder::default())); + let mut fake = fixture(); + fake.rec = rec.clone(); + let mut app = App::new(fake).unwrap(); // starts on ToM, first task = t1 + + // Arming a delete doesn't tombstone yet. + app.begin_delete(); + assert!(app.pending_delete.is_some()); + assert!(rec.borrow().tombstoned.is_empty()); + + // Cancelling clears it without tombstoning. + app.cancel_delete(); + assert!(app.pending_delete.is_none()); + assert!(rec.borrow().tombstoned.is_empty()); + + // Arming then confirming tombstones the selected task. + app.begin_delete(); + app.confirm_delete(); + assert!(app.pending_delete.is_none()); + assert_eq!(rec.borrow().tombstoned, vec!["t1".to_string()]); +} + #[test] fn search_populates_results_and_task_hits_open_their_context() { let mut fake = fixture();