generated from eblume/project-template
feat(tui): pane-specific keys, undo/redo, project delete, sidebar refresh (§8.1)
Some checks failed
Build / validate (pull_request) Failing after 12s
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:
parent
9c932c8d9a
commit
9511f6a009
5 changed files with 320 additions and 28 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
5
docs/changelog.d/v1-tui-undo-panels.feature.md
Normal file
5
docs/changelog.d/v1-tui-undo-panels.feature.md
Normal 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).
|
||||
Loading…
Add table
Add a link
Reference in a new issue