feat(tui): pane-specific keys, undo/redo, project delete, sidebar refresh (§8.1)
Some checks failed
Build / validate (pull_request) Failing after 12s

Triage gestures (x/d/S/A/b/e/m/D) now fire only when the task pane is
focused, so a stray key while in the sidebar can't drop or delete a task;
hints are focus-aware. `u` undoes the last triage action (drop/done/skip/
attention/move) and Ctrl-z redoes it, restoring from a pre-action snapshot
(multi-level, cap 200; tombstone-delete excluded — no restore path yet). `D`
in the sidebar deletes the highlighted project (y/N), unfiling its tasks to
the Inbox. The sidebar's Projects section now rebuilds after create/delete,
so a new project appears without a restart. Tests cover undo/redo, the
empty-undo no-op, and sidebar project delete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-03 18:37:16 -07:00
commit 9511f6a009
5 changed files with 320 additions and 28 deletions

View file

@ -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<Attention>,
do_date: Option<i64>,
late_on: Option<i64>,
recurrence: Option<String>,
project_id: Option<String>,
}
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<String>), // 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<Project>,
/// The live filter query (fzf-style subsequence match).
@ -278,6 +348,9 @@ pub struct App<B: Backend> {
pub search: Option<SearchView>,
/// When `Some`, a delete is awaiting y/N confirmation.
pub pending_delete: Option<PendingDelete>,
/// Reversible triage history (`u` undoes, Ctrl-z redoes).
undo_stack: Vec<UndoEntry>,
redo_stack: Vec<UndoEntry>,
pub status: String,
pub should_quit: bool,
}
@ -317,6 +390,8 @@ impl<B: Backend> App<B> {
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<B: Backend> App<B> {
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<B: Backend> App<B> {
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<B: Backend> App<B> {
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<B: Backend> App<B> {
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<B: Backend> App<B> {
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<B: Backend> App<B> {
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<B: Backend> App<B> {
};
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<SidebarEntry> = 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;

View file

@ -154,6 +154,9 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
// Any other keypress clears a stale status message.
app.status.clear();
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
// Keys that work regardless of which pane has focus.
match key.code {
KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
KeyCode::Char('r') => app.reload(),
@ -164,20 +167,30 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
KeyCode::Char('l') | KeyCode::Right => 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
}

View file

@ -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<B: Backend>(frame: &mut Frame, app: &App<B>, 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<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
}
let hints = if app.search.is_some() {
SEARCH_HINTS
} else if app.focus == Focus::Sidebar {
SIDEBAR_HINTS
} else {
HINTS
};

View file

@ -30,6 +30,7 @@ struct Recorder {
tombstoned: Vec<String>,
refiled: Vec<(String, Option<String>)>,
created_projects: Vec<String>,
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<Option<String>> {
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;

View file

@ -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).