diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index d07d1b4..2325278 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use anyhow::Result; use chrono::NaiveDate; -use heph_core::{Attention, RankedTask, SchedulePatch, BUILTIN_VIEWS}; +use heph_core::{Attention, RankedTask, SchedulePatch, TaskState, BUILTIN_VIEWS}; use crate::backend::{Backend, Project, SearchHit}; use crate::fmt::{days_overdue, today_local}; @@ -118,13 +118,81 @@ pub struct SearchView { pub cursor: usize, } -/// A pending delete awaiting y/N confirmation (the most destructive gesture). +/// A pending delete awaiting y/N confirmation (the most destructive gesture) — +/// either a task (from the task pane) or a project (from the sidebar). #[derive(Debug, Clone, PartialEq, Eq)] -pub struct PendingDelete { - pub task_id: String, - pub title: String, +pub enum PendingDelete { + Task { task_id: String, title: String }, + Project { project_id: String, title: String }, } +impl PendingDelete { + /// The name shown in the confirmation prompt. + pub fn title(&self) -> &str { + match self { + PendingDelete::Task { title, .. } | PendingDelete::Project { title, .. } => title, + } + } + /// "task" or "project", for the prompt. + pub fn noun(&self) -> &str { + match self { + PendingDelete::Task { .. } => "task", + PendingDelete::Project { .. } => "project", + } + } +} + +/// A snapshot of a task's reversible fields, captured before a triage action so +/// `u`/Ctrl-z can undo/redo it. Taken from the visible [`RankedTask`], so no +/// extra daemon read is needed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TaskSnapshot { + task_id: String, + title: String, + state: TaskState, + attention: Option, + do_date: Option, + late_on: Option, + recurrence: Option, + project_id: Option, +} + +impl From<&RankedTask> for TaskSnapshot { + fn from(t: &RankedTask) -> Self { + Self { + task_id: t.node_id.clone(), + title: t.title.clone(), + state: t.state, + attention: t.attention, + do_date: t.do_date, + late_on: t.late_on, + recurrence: t.recurrence.clone(), + project_id: t.project_id.clone(), + } + } +} + +/// The original triage action, kept alongside its before-snapshot so redo can +/// re-apply it without re-reading state. (Delete/tombstone is *not* here — it has +/// no restore path yet, so it is excluded from undo and guarded by its y/N prompt.) +#[derive(Debug, Clone, PartialEq, Eq)] +enum TriageAction { + State(&'static str), // "done" | "dropped" + Skip, + Attention(Attention), + Move(Option), // re-file (or unfile) to a project id +} + +/// One reversible step: the task state before it + the action that changed it. +#[derive(Debug, Clone, PartialEq, Eq)] +struct UndoEntry { + before: TaskSnapshot, + action: TriageAction, +} + +/// Cap on the undo history, so a long session can't grow it unbounded. +const UNDO_CAP: usize = 200; + /// One choice in the move-to-project picker. #[derive(Debug, Clone, PartialEq, Eq)] pub enum MoveOption { @@ -154,6 +222,8 @@ impl MoveOption { pub struct MoveState { pub task_id: String, pub task_title: String, + /// The task's state before the move, for undo. + before: TaskSnapshot, /// All projects, title-sorted — the source the `filter` narrows. projects: Vec, /// The live filter query (fzf-style subsequence match). @@ -278,6 +348,9 @@ pub struct App { pub search: Option, /// When `Some`, a delete is awaiting y/N confirmation. pub pending_delete: Option, + /// Reversible triage history (`u` undoes, Ctrl-z redoes). + undo_stack: Vec, + redo_stack: Vec, pub status: String, pub should_quit: bool, } @@ -317,6 +390,8 @@ impl App { sort_mode: SortMode::Default, search: None, pending_delete: None, + undo_stack: Vec::new(), + redo_stack: Vec::new(), status: String::new(), should_quit: false, }; @@ -529,6 +604,7 @@ impl App { let Some(t) = self.selected_task().cloned() else { return; }; + self.push_undo((&t).into(), TriageAction::State("done")); self.mutate(format!("done: {}", t.title), |b| { b.set_state(&t.node_id, "done") }); @@ -539,7 +615,8 @@ impl App { let Some(t) = self.selected_task().cloned() else { return; }; - self.mutate(format!("dropped: {}", t.title), |b| { + self.push_undo((&t).into(), TriageAction::State("dropped")); + self.mutate(format!("dropped: {} (u to undo)", t.title), |b| { b.set_state(&t.node_id, "dropped") }); } @@ -549,6 +626,7 @@ impl App { let Some(t) = self.selected_task().cloned() else { return; }; + self.push_undo((&t).into(), TriageAction::Skip); self.mutate(format!("skipped: {}", t.title), |b| b.skip(&t.node_id)); } @@ -558,6 +636,7 @@ impl App { return; }; let next = next_attention(t.attention); + self.push_undo((&t).into(), TriageAction::Attention(next)); self.mutate(format!("{}: {}", next.as_str(), t.title), |b| { b.set_attention(&t.node_id, next) }); @@ -568,27 +647,118 @@ impl App { let Some(t) = self.selected_task().cloned() else { return; }; + self.push_undo((&t).into(), TriageAction::Attention(Attention::Blue)); self.mutate(format!("→ on deck: {}", t.title), |b| { b.set_attention(&t.node_id, Attention::Blue) }); } + // --- undo / redo (`u` / Ctrl-z) --- + + /// Record a reversible step and invalidate the redo stack. + fn push_undo(&mut self, before: TaskSnapshot, action: TriageAction) { + self.undo_stack.push(UndoEntry { before, action }); + if self.undo_stack.len() > UNDO_CAP { + self.undo_stack.remove(0); + } + self.redo_stack.clear(); + } + + /// Undo the last triage action (restores the task's prior state). + pub fn undo(&mut self) { + let Some(entry) = self.undo_stack.pop() else { + self.status = "nothing to undo".into(); + return; + }; + self.restore(&entry.before, format!("undo: {}", entry.before.title)); + self.redo_stack.push(entry); + } + + /// Redo the last undone action (re-applies it). + pub fn redo(&mut self) { + let Some(entry) = self.redo_stack.pop() else { + self.status = "nothing to redo".into(); + return; + }; + let status = format!("redo: {}", entry.before.title); + self.apply_action(entry.before.task_id.clone(), entry.action.clone(), status); + self.undo_stack.push(entry); + } + + /// Restore a task to a captured snapshot (state + schedule + attention + + /// project). Note: an attention of `None` can't be re-cleared (no backend + /// path), so it's left as-is in that rare case. + fn restore(&mut self, snap: &TaskSnapshot, status: String) { + let id = snap.task_id.clone(); + let state = snap.state.as_str().to_string(); + let (do_date, late_on, recurrence) = (snap.do_date, snap.late_on, snap.recurrence.clone()); + let attention = snap.attention; + let project = snap.project_id.clone(); + self.mutate(status, move |b| { + b.set_state(&id, &state)?; + b.set_schedule( + &id, + SchedulePatch { + do_date: Some(do_date), + late_on: Some(late_on), + recurrence: Some(recurrence), + }, + )?; + if let Some(a) = attention { + b.set_attention(&id, a)?; + } + b.set_project(&id, project.as_deref()) + }); + } + + /// Re-apply a triage action to `id` (used by redo). + fn apply_action(&mut self, id: String, action: TriageAction, status: String) { + match action { + TriageAction::State(s) => self.mutate(status, move |b| b.set_state(&id, s)), + TriageAction::Skip => self.mutate(status, move |b| b.skip(&id)), + TriageAction::Attention(a) => self.mutate(status, move |b| b.set_attention(&id, a)), + TriageAction::Move(p) => self.mutate(status, move |b| b.set_project(&id, p.as_deref())), + } + } + /// 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 { + self.pending_delete = Some(PendingDelete::Task { task_id: t.node_id.clone(), title: t.title.clone(), }); } } - /// Confirm the armed delete: tombstone the task and reload. + /// Arm a delete on the highlighted **project** (sidebar). Tasks filed under it + /// become unfiled (they move to the Inbox), not deleted. + pub fn begin_delete_project(&mut self) { + match self.sidebar.get(self.sidebar_cursor) { + Some(SidebarEntry::Project { id, title }) => { + self.pending_delete = Some(PendingDelete::Project { + project_id: id.clone(), + title: title.clone(), + }); + } + _ => self.status = "select a project in the sidebar to delete".into(), + } + } + + /// Confirm the armed delete: tombstone the task or project 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) - }); + match self.pending_delete.take() { + Some(PendingDelete::Task { task_id, title }) => { + self.mutate(format!("deleted: {title}"), |b| b.tombstone(&task_id)); + } + Some(PendingDelete::Project { project_id, title }) => { + self.mutate(format!("deleted project: {title}"), |b| { + b.tombstone(&project_id) + }); + self.rebuild_projects(); + self.reload(); + } + None => {} } } @@ -608,8 +778,9 @@ impl App { return; }; let mut state = MoveState { - task_id: t.node_id, - task_title: t.title, + task_id: t.node_id.clone(), + task_title: t.title.clone(), + before: TaskSnapshot::from(&t), projects: self.project_list(), filter: String::new(), options: Vec::new(), @@ -665,27 +836,64 @@ impl App { }; let task_id = m.task_id.clone(); let title = m.task_title.clone(); + let before = m.before.clone(); self.mode = Mode::Normal; match choice { MoveOption::Unfile => { + self.push_undo(before, TriageAction::Move(None)); self.mutate(format!("→ (Unfile): {title}"), |b| { b.set_project(&task_id, None) }); } MoveOption::Project { id, title: pt } => { + self.push_undo(before, TriageAction::Move(Some(id.clone()))); self.mutate(format!("→ {pt}: {title}"), move |b| { b.set_project(&task_id, Some(&id)) }); } MoveOption::Create { name } => { + // Creating + filing is constructive; not added to the undo history + // (we'd have to track the new project's id to replay it). self.mutate(format!("→ new project \"{name}\": {title}"), move |b| { let id = b.create_project(&name)?; b.set_project(&task_id, Some(&id)) }); + self.rebuild_projects(); } } } + /// Refetch the project list and rebuild the sidebar's Projects section, + /// keeping the cursor on the same entry when it still exists. Call after any + /// project create/delete so the sidebar reflects reality without a restart. + pub fn rebuild_projects(&mut self) { + let selected = self.sidebar.get(self.sidebar_cursor).cloned(); + let mut rebuilt: Vec = self + .sidebar + .iter() + .filter(|e| !matches!(e, SidebarEntry::Project { .. })) + .cloned() + .collect(); + if let Ok(projects) = self.backend.projects() { + for Project { id, title } in projects { + rebuilt.push(SidebarEntry::Project { id, title }); + } + } + self.sidebar = rebuilt; + // Restore the cursor: same entry if present, else the nearest selectable + // at/above the old index (a deleted project lands you on its header/prev). + self.sidebar_cursor = selected + .as_ref() + .and_then(|sel| self.sidebar.iter().position(|e| e == sel)) + .unwrap_or_else(|| { + let idx = self.sidebar_cursor.min(self.sidebar.len().saturating_sub(1)); + (0..=idx) + .rev() + .find(|&i| self.sidebar[i].selectable()) + .unwrap_or(0) + }); + } + /// Dismiss the picker without re-filing. pub fn move_picker_cancel(&mut self) { self.mode = Mode::Normal; diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs index 930b9ac..6d8b85a 100644 --- a/crates/heph-tui/src/main.rs +++ b/crates/heph-tui/src/main.rs @@ -154,6 +154,9 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option app.should_quit = true, KeyCode::Char('r') => app.reload(), @@ -164,20 +167,30 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option app.focus_tasks(), // Enter: drill sidebar→tasks, or open the selected task's context in nvim. KeyCode::Enter => return app.enter().map(Action::EditContext), - // capture + reschedule + search (open an input prompt) KeyCode::Char('a') => app.begin_add(), - KeyCode::Char('e') => app.begin_reschedule(), KeyCode::Char('/') => app.begin_search(), - // triage mutations (act on the highlighted task) - KeyCode::Char('x') => app.complete_selected(), - KeyCode::Char('d') => app.drop_selected(), - KeyCode::Char('S') => app.skip_selected(), KeyCode::Char('s') => app.toggle_sort(), - KeyCode::Char('A') => app.cycle_attention_selected(), - KeyCode::Char('b') => app.push_to_blue_selected(), - KeyCode::Char('m') => app.begin_move(), - KeyCode::Char('D') => app.begin_delete(), - _ => {} + KeyCode::Char('u') => app.undo(), + KeyCode::Char('z') if ctrl => app.redo(), + // Pane-specific keys: triage acts on the task pane; the sidebar gets + // project actions — so a stray `d`/`D` in the sidebar can't touch a task. + _ => match app.focus { + Focus::Tasks => match key.code { + KeyCode::Char('x') => app.complete_selected(), + KeyCode::Char('d') => app.drop_selected(), + KeyCode::Char('S') => app.skip_selected(), + KeyCode::Char('A') => app.cycle_attention_selected(), + KeyCode::Char('b') => app.push_to_blue_selected(), + KeyCode::Char('e') => app.begin_reschedule(), + KeyCode::Char('m') => app.begin_move(), + KeyCode::Char('D') => app.begin_delete(), + _ => {} + }, + Focus::Sidebar => match key.code { + KeyCode::Char('D') => app.begin_delete_project(), + _ => {} + }, + }, } None } diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index dde3ee4..75f669b 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -17,8 +17,13 @@ use crate::app::{App, Focus, InputState, Mode, MoveOption, MoveState, SidebarEnt use crate::backend::Backend; use crate::fmt::{fmt_date, project_color, today_local}; +// Task-pane gestures (the focused pane shows its own hints, §8.1). const HINTS: &str = - " j/k move ⏎ edit a add x done S skip e date A attn b→blue m move D del s sort / search q quit"; + " j/k move ⏎ edit x done d drop S skip e date A attn b→blue m move D del u undo / search q quit"; + +// Sidebar gestures: navigation + per-project actions (no task triage here). +const SIDEBAR_HINTS: &str = + " j/k move ⏎ open a add D del-project u undo s sort / search Tab tasks q quit"; const SEARCH_HINTS: &str = " j/k move Enter open Esc exit search"; @@ -450,7 +455,7 @@ fn render_status(frame: &mut Frame, app: &App, area: Rect) { if let Some(pd) = &app.pending_delete { frame.render_widget( Paragraph::new(Line::from(Span::styled( - format!(" Delete \"{}\"? (y / N)", pd.title), + format!(" Delete {} \"{}\"? (y / N)", pd.noun(), pd.title()), Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), ))), area, @@ -459,6 +464,8 @@ fn render_status(frame: &mut Frame, app: &App, area: Rect) { } let hints = if app.search.is_some() { SEARCH_HINTS + } else if app.focus == Focus::Sidebar { + SIDEBAR_HINTS } else { HINTS }; diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index bde0a9b..a6f3842 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -30,6 +30,7 @@ struct Recorder { tombstoned: Vec, refiled: Vec<(String, Option)>, created_projects: Vec, + states: Vec<(String, String)>, } fn task(id: &str, title: &str, attention: Attention, ctx: Option<&str>) -> RankedTask { @@ -87,7 +88,8 @@ impl Backend for Fake { fn context_of(&mut self, task_id: &str) -> Result> { Ok(self.contexts.get(task_id).cloned()) } - fn set_state(&mut self, _t: &str, _s: &str) -> Result<()> { + fn set_state(&mut self, t: &str, s: &str) -> Result<()> { + self.rec.borrow_mut().states.push((t.into(), s.into())); Ok(()) } fn skip(&mut self, _t: &str) -> Result<()> { @@ -428,6 +430,63 @@ fn move_picker_creates_a_project_from_the_filter_text() { assert_eq!(refiled.last().unwrap().1.as_deref(), Some("proj:Garden")); } +#[test] +fn undo_restores_a_dropped_task_and_redo_redrops_it() { + let rec = Rc::new(RefCell::new(Recorder::default())); + let mut fake = fixture(); + fake.rec = rec.clone(); + let mut app = App::new(fake).unwrap(); + app.focus_tasks(); + + app.drop_selected(); // drops t1 + app.undo(); // restores it to outstanding + { + let states = rec.borrow(); + assert_eq!(states.states.first().unwrap(), &("t1".into(), "dropped".into())); + assert_eq!( + states.states.last().unwrap(), + &("t1".into(), "outstanding".into()), + "undo restores the prior (outstanding) state" + ); + } + + app.redo(); // re-drops it + assert_eq!( + rec.borrow().states.last().unwrap(), + &("t1".into(), "dropped".into()) + ); +} + +#[test] +fn undo_with_empty_history_is_a_noop() { + let mut app = App::new(fixture()).unwrap(); + app.undo(); + assert_eq!(app.status, "nothing to undo"); +} + +#[test] +fn delete_project_from_sidebar_tombstones_the_project_node() { + use heph_tui::app::PendingDelete; + let rec = Rc::new(RefCell::new(Recorder::default())); + let mut fake = fixture(); + fake.rec = rec.clone(); + let mut app = App::new(fake).unwrap(); + + // Step the sidebar to the Camano project (6 views + header). + for _ in 0..6 { + app.move_sidebar(1); + } + assert_eq!(app.task_pane_title(), "Camano"); + + app.begin_delete_project(); + assert!(matches!( + app.pending_delete, + Some(PendingDelete::Project { .. }) + )); + app.confirm_delete(); + assert_eq!(rec.borrow().tombstoned, vec!["p1".to_string()]); +} + #[test] fn toggle_sort_switches_mode_and_regroups_by_project() { use heph_tui::app::SortMode; diff --git a/docs/changelog.d/v1-tui-undo-panels.feature.md b/docs/changelog.d/v1-tui-undo-panels.feature.md new file mode 100644 index 0000000..0d6b0e6 --- /dev/null +++ b/docs/changelog.d/v1-tui-undo-panels.feature.md @@ -0,0 +1,5 @@ +- `heph-tui` safety + undo wave (§8.1): + - **Pane-specific keys.** Task-triage gestures (`x`/`d`/`S`/`A`/`b`/`e`/`m`/`D`) now fire **only when the task pane is focused**, so a stray keypress while navigating the sidebar can no longer drop or delete a task. The sidebar gets its own actions; the status-line hints are now focus-aware. + - **Undo / redo.** **`u`** undoes the last triage action (drop, done, skip, attention, move) and **Ctrl-z** redoes it, restoring the task's prior state from a snapshot — multi-level, capped at 200 steps. (Tombstone-delete stays excluded — it keeps its y/N prompt — and an attention of "none" can't be re-cleared.) + - **Delete a project** from the sidebar with **`D`** (y/N confirm); its tasks become unfiled (they move to the Inbox), not deleted. + - **Sidebar auto-refreshes** after creating or deleting a project, so a new project shows up immediately (no more quit-and-reload).