feat(tui): undoable delete, rename, project re-parent, conflicts view, full log view
All checks were successful
Build / validate (pull_request) Successful in 9m21s

- D (task) and D (project) are now undoable with u: task delete restores
  via node.restore; project delete restores the node and re-files the
  tasks the delete unfiled. Redo re-deletes.
- R renames the highlighted task in place (prefilled input modal),
  keeping its canonical-context doc's title in step; undoable.
- m on a sidebar project opens the move picker in re-parent mode —
  self + descendants excluded, "(Move to root)" detaches; undoable.
- C opens a conflicts review in the center pane: per-row node title,
  field, and both values; l/r keeps local/remote (applies the value via
  the new conflicts.resolve semantics) and refreshes the chip.
- L opens the highlighted task's full scrollable log (the preview pane
  shows only the last five lines).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-09 11:12:06 -07:00
commit 8417f70326
5 changed files with 952 additions and 74 deletions

View file

@ -107,6 +107,12 @@ enum InputKind {
},
/// Full-text search query.
Search,
/// Rename the task (and its same-named canonical-context doc).
Rename {
task_id: String,
context_id: Option<String>,
before: String,
},
}
/// An active full-text search: the query, its hits, and the highlighted row.
@ -118,6 +124,31 @@ pub struct SearchView {
pub cursor: usize,
}
/// One row of the conflicts view: the conflict + the node's title for display.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConflictItem {
pub conflict: heph_core::Conflict,
pub title: String,
}
/// The open-conflicts review (`C`): the rows and the highlighted one. While
/// `Some`, the center pane lists conflicts; `l`/`r` settle the highlighted one.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConflictsView {
pub items: Vec<ConflictItem>,
pub cursor: usize,
}
/// The full task-log view (`L`): every log line for one task, scrollable —
/// the preview pane shows only the last few. While `Some`, it replaces the
/// center pane.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogView {
pub title: String,
pub lines: Vec<String>,
pub scroll: usize,
}
/// 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)]
@ -173,8 +204,7 @@ impl From<&RankedTask> for TaskSnapshot {
}
/// 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.)
/// re-apply it without re-reading state.
#[derive(Debug, Clone, PartialEq, Eq)]
enum TriageAction {
State(&'static str), // "done" | "dropped"
@ -183,22 +213,53 @@ enum TriageAction {
Move(Option<String>), // re-file (or unfile) to a project id
}
/// One reversible step: the task state before it + the action that changed it.
/// One reversible step. Scalar triage carries a before-snapshot; the
/// structural actions (delete, rename, re-parent) carry exactly what their
/// inverse needs.
#[derive(Debug, Clone, PartialEq, Eq)]
struct UndoEntry {
before: TaskSnapshot,
action: TriageAction,
enum UndoEntry {
/// A scalar triage action: the task state before it + the action.
Triage {
before: TaskSnapshot,
action: TriageAction,
},
/// A task delete; undone by `node.restore` (which also revives the
/// canonical-context doc).
DeleteTask { task_id: String, title: String },
/// A project delete; undone by restoring the project node and re-filing
/// the tasks that were unfiled by the delete.
DeleteProject {
project_id: String,
title: String,
filed: Vec<String>,
},
/// A rename of a task (and its same-named context doc, when it has one).
Rename {
task_id: String,
context_id: Option<String>,
before: String,
after: String,
},
/// A project re-parent; undone by re-parenting back.
Reparent {
project_id: String,
title: String,
before_parent: Option<String>,
after_parent: Option<String>,
},
}
/// 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.
/// One choice in the move picker.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MoveOption {
/// Remove the task from any project.
Unfile,
/// File under an existing project.
/// Detach the project to the root (re-parent mode's "no parent").
Root,
/// File under (or re-parent to) an existing project.
Project { id: String, title: String },
/// Create a new project named after the filter text, then file under it.
Create { name: String },
@ -209,40 +270,59 @@ impl MoveOption {
pub fn label(&self) -> String {
match self {
MoveOption::Unfile => "(Unfile)".to_string(),
MoveOption::Root => "(Move to root)".to_string(),
MoveOption::Project { title, .. } => title.clone(),
MoveOption::Create { name } => format!("+ New project \"{name}\""),
}
}
}
/// The move-to-project picker state: which task is being re-filed, a live filter
/// over the projects, the matching choices, and the highlighted row. The picker
/// is fzf-style — typing narrows the list; a non-matching name offers to create.
/// What the move picker is moving — a task being re-filed, or a project being
/// re-parented. Each carries what its undo needs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MoveKind {
/// Re-file a task; `before` is its snapshot for undo.
Task { before: TaskSnapshot },
/// Re-parent a project; `current_parent` for undo.
Project { current_parent: Option<String> },
}
/// The move picker state: the subject (task or project), a live filter over the
/// candidate projects, the matching choices, and the highlighted row. The picker
/// is fzf-style — typing narrows the list; in task mode a non-matching name
/// offers to create.
#[derive(Debug, Clone, PartialEq, Eq)]
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.
/// The node being moved (a task id in task mode, a project id otherwise).
pub subject_id: String,
pub subject_title: String,
pub kind: MoveKind,
/// The candidate projects, title-sorted — the source the `filter` narrows.
/// In re-parent mode the subject and its descendants are pre-excluded.
projects: Vec<Project>,
/// The live filter query (fzf-style subsequence match).
pub filter: String,
/// The currently visible choices (`(Unfile)` + matching projects + an
/// optional "create" row), recomputed whenever `filter` changes.
/// The currently visible choices, recomputed whenever `filter` changes.
pub options: Vec<MoveOption>,
pub cursor: usize,
}
impl MoveState {
/// Rebuild `options` from `projects` + `filter`: `(Unfile)` (when it matches
/// or the filter is empty), the fuzzy-matching projects, and — when the text
/// names no existing project — a "create" row. Clamps the cursor.
/// Rebuild `options` from `projects` + `filter`: the no-project row
/// (`(Unfile)` / `(Move to root)`), the fuzzy-matching projects, and — in
/// task mode, when the text names no existing project — a "create" row.
/// Clamps the cursor.
fn recompute(&mut self) {
let f = self.filter.trim();
let is_task = matches!(self.kind, MoveKind::Task { .. });
let (none_opt, none_label) = if is_task {
(MoveOption::Unfile, "Unfile")
} else {
(MoveOption::Root, "Root")
};
let mut opts = Vec::new();
if f.is_empty() || fuzzy_match(f, "Unfile") {
opts.push(MoveOption::Unfile);
if f.is_empty() || fuzzy_match(f, none_label) {
opts.push(none_opt);
}
for p in &self.projects {
if f.is_empty() || fuzzy_match(f, &p.title) {
@ -252,7 +332,8 @@ impl MoveState {
});
}
}
if !f.is_empty()
if is_task
&& !f.is_empty()
&& !self
.projects
.iter()
@ -428,6 +509,10 @@ pub struct App<B: Backend> {
pub sort_mode: SortMode,
/// When `Some`, a full-text search overlays the task list.
pub search: Option<SearchView>,
/// When `Some`, the open-conflicts review overlays the task list.
pub conflicts_view: Option<ConflictsView>,
/// When `Some`, a task's full log overlays the task list.
pub log_view: Option<LogView>,
/// When `Some`, a delete is awaiting y/N confirmation.
pub pending_delete: Option<PendingDelete>,
/// When `true`, an attention chord is in progress: `a` was pressed and the
@ -474,6 +559,8 @@ impl<B: Backend> App<B> {
mode: Mode::Normal,
sort_mode: SortMode::Default,
search: None,
conflicts_view: None,
log_view: None,
pending_delete: None,
pending_attention: false,
undo_stack: Vec::new(),
@ -772,22 +859,79 @@ impl<B: Backend> App<B> {
// --- undo / redo (`u` / Ctrl-z) ---
/// Record a reversible step and invalidate the redo stack.
/// Record a reversible scalar-triage step.
fn push_undo(&mut self, before: TaskSnapshot, action: TriageAction) {
self.undo_stack.push(UndoEntry { before, action });
self.push_entry(UndoEntry::Triage { before, action });
}
/// Record any reversible step and invalidate the redo stack.
fn push_entry(&mut self, entry: UndoEntry) {
self.undo_stack.push(entry);
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).
/// Undo the last reversible action.
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));
match &entry {
UndoEntry::Triage { before, .. } => {
let (before, status) = (before.clone(), format!("undo: {}", before.title));
self.restore(&before, status);
}
UndoEntry::DeleteTask { task_id, title } => {
let id = task_id.clone();
self.mutate(format!("undo delete: {title}"), move |b| b.restore(&id));
}
UndoEntry::DeleteProject {
project_id,
title,
filed,
} => {
let (id, filed) = (project_id.clone(), filed.clone());
self.mutate(format!("undo delete project: {title}"), move |b| {
b.restore(&id)?;
// The delete unfiled these tasks; put them back.
for t in &filed {
b.set_project(t, Some(&id))?;
}
Ok(())
});
self.rebuild_projects();
}
UndoEntry::Rename {
task_id,
context_id,
before,
..
} => {
let (id, ctx, title) = (task_id.clone(), context_id.clone(), before.clone());
self.mutate(format!("undo rename: {title}"), move |b| {
b.rename(&id, &title)?;
if let Some(ctx) = &ctx {
b.rename(ctx, &title)?;
}
Ok(())
});
}
UndoEntry::Reparent {
project_id,
title,
before_parent,
..
} => {
let (id, parent) = (project_id.clone(), before_parent.clone());
self.mutate(format!("undo move: {title}"), move |b| {
b.reparent(&id, parent.as_deref())
});
self.rebuild_projects();
}
}
self.redo_stack.push(entry);
}
@ -797,8 +941,52 @@ impl<B: Backend> App<B> {
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);
match &entry {
UndoEntry::Triage { before, action } => {
let status = format!("redo: {}", before.title);
self.apply_action(before.task_id.clone(), action.clone(), status);
}
UndoEntry::DeleteTask { task_id, title } => {
let id = task_id.clone();
self.mutate(format!("redo delete: {title}"), move |b| b.tombstone(&id));
}
UndoEntry::DeleteProject {
project_id, title, ..
} => {
let id = project_id.clone();
self.mutate(format!("redo delete project: {title}"), move |b| {
b.delete_project(&id)
});
self.rebuild_projects();
}
UndoEntry::Rename {
task_id,
context_id,
after,
..
} => {
let (id, ctx, title) = (task_id.clone(), context_id.clone(), after.clone());
self.mutate(format!("redo rename: {title}"), move |b| {
b.rename(&id, &title)?;
if let Some(ctx) = &ctx {
b.rename(ctx, &title)?;
}
Ok(())
});
}
UndoEntry::Reparent {
project_id,
title,
after_parent,
..
} => {
let (id, parent) = (project_id.clone(), after_parent.clone());
self.mutate(format!("redo move: {title}"), move |b| {
b.reparent(&id, parent.as_deref())
});
self.rebuild_projects();
}
}
self.undo_stack.push(entry);
}
@ -862,16 +1050,39 @@ impl<B: Backend> App<B> {
}
}
/// Confirm the armed delete: tombstone the task or project and reload.
/// Confirm the armed delete: tombstone the task or project, record the
/// undo entry, and reload.
pub fn confirm_delete(&mut self) {
match self.pending_delete.take() {
Some(PendingDelete::Task { task_id, title }) => {
self.mutate(format!("deleted: {title}"), |b| b.tombstone(&task_id));
self.push_entry(UndoEntry::DeleteTask {
task_id: task_id.clone(),
title: title.clone(),
});
self.mutate(format!("deleted: {title} (u to undo)"), |b| {
b.tombstone(&task_id)
});
}
Some(PendingDelete::Project { project_id, title }) => {
self.mutate(format!("deleted project: {title} (tasks → Inbox)"), |b| {
b.delete_project(&project_id)
// Snapshot which tasks the delete is about to unfile, so undo
// can re-file them after restoring the project node.
let filed: Vec<String> = self
.backend
.list(&heph_core::ListFilter {
scope: vec![project_id.clone()],
..Default::default()
})
.map(|ts| ts.into_iter().map(|t| t.node_id).collect())
.unwrap_or_default();
self.push_entry(UndoEntry::DeleteProject {
project_id: project_id.clone(),
title: title.clone(),
filed,
});
self.mutate(
format!("deleted project: {title} (tasks → Inbox; u to undo)"),
|b| b.delete_project(&project_id),
);
self.rebuild_projects();
self.reload();
}
@ -895,9 +1106,11 @@ impl<B: Backend> App<B> {
return;
};
let mut state = MoveState {
task_id: t.node_id.clone(),
task_title: t.title.clone(),
before: TaskSnapshot::from(&t),
subject_id: t.node_id.clone(),
subject_title: t.title.clone(),
kind: MoveKind::Task {
before: TaskSnapshot::from(&t),
},
projects: self.project_list(),
filter: String::new(),
options: Vec::new(),
@ -916,6 +1129,64 @@ impl<B: Backend> App<B> {
self.mode = Mode::MoveToProject(state);
}
/// Open the picker in re-parent mode for the sidebar-selected project.
/// Candidates exclude the project itself and its descendants (a tree can't
/// contain itself); "(Move to root)" detaches it.
pub fn begin_reparent(&mut self) {
let Some(SidebarEntry::Project { id, title, .. }) =
self.sidebar.get(self.sidebar_cursor).cloned()
else {
self.status = "select a project in the sidebar to move".into();
return;
};
let overview = match self.backend.project_overview() {
Ok(o) => o,
Err(e) => {
self.status = format!("error: {e}");
return;
}
};
let current_parent = overview
.iter()
.find(|p| p.id == id)
.and_then(|p| p.parent_id.clone());
// The subject's subtree (itself + descendants) can't be its new parent.
let mut excluded: std::collections::HashSet<String> = [id.clone()].into();
loop {
let before = excluded.len();
for p in &overview {
if p.parent_id
.as_deref()
.is_some_and(|pp| excluded.contains(pp))
{
excluded.insert(p.id.clone());
}
}
if excluded.len() == before {
break;
}
}
let projects: Vec<Project> = overview
.into_iter()
.filter(|p| !excluded.contains(&p.id))
.map(|p| Project {
id: p.id,
title: p.title,
})
.collect();
let mut state = MoveState {
subject_id: id,
subject_title: title,
kind: MoveKind::Project { current_parent },
projects,
filter: String::new(),
options: Vec::new(),
cursor: 0,
};
state.recompute();
self.mode = Mode::MoveToProject(state);
}
/// Move the picker cursor by `delta` (clamped).
pub fn move_picker_move(&mut self, delta: isize) {
if let Mode::MoveToProject(m) = &mut self.mode {
@ -942,8 +1213,8 @@ impl<B: Backend> App<B> {
}
}
/// Apply the highlighted choice: unfile, re-file, or create-then-file, and
/// reload.
/// Apply the highlighted choice and reload. Task mode: unfile, re-file, or
/// create-then-file. Project mode: re-parent (or detach to the root).
pub fn move_picker_submit(&mut self) {
let Mode::MoveToProject(m) = &self.mode else {
return;
@ -951,30 +1222,53 @@ impl<B: Backend> App<B> {
let Some(choice) = m.options.get(m.cursor).cloned() else {
return;
};
let task_id = m.task_id.clone();
let title = m.task_title.clone();
let before = m.before.clone();
let subject_id = m.subject_id.clone();
let title = m.subject_title.clone();
let kind = m.kind.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))
match kind {
MoveKind::Task { before } => match choice {
MoveOption::Unfile => {
self.push_undo(before, TriageAction::Move(None));
self.mutate(format!("→ (Unfile): {title}"), |b| {
b.set_project(&subject_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(&subject_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(&subject_id, Some(&id))
});
self.rebuild_projects();
}
MoveOption::Root => {}
},
MoveKind::Project { current_parent } => {
let after = match choice {
MoveOption::Root => None,
MoveOption::Project { id, .. } => Some(id),
// Unfile/Create are not offered in re-parent mode.
_ => return,
};
self.push_entry(UndoEntry::Reparent {
project_id: subject_id.clone(),
title: title.clone(),
before_parent: current_parent,
after_parent: after.clone(),
});
let status = match &after {
Some(_) => format!("moved project: {title}"),
None => format!("moved project to root: {title}"),
};
self.mutate(status, move |b| b.reparent(&subject_id, after.as_deref()));
self.rebuild_projects();
}
}
@ -1064,6 +1358,23 @@ impl<B: Backend> App<B> {
});
}
/// Start renaming the highlighted task in place (`R`). The buffer is
/// prefilled with the current title for editing.
pub fn begin_rename(&mut self) {
let Some(t) = self.selected_task() else {
return;
};
self.mode = Mode::Input(InputState {
prompt: "Rename task".into(),
buffer: t.title.clone(),
kind: InputKind::Rename {
task_id: t.node_id.clone(),
context_id: t.canonical_context_id.clone(),
before: t.title.clone(),
},
});
}
/// Open the full-text search prompt.
pub fn begin_search(&mut self) {
self.mode = Mode::Input(InputState {
@ -1078,6 +1389,114 @@ impl<B: Backend> App<B> {
self.search = None;
}
// --- conflicts review (`C`) ---
/// Open (or refresh) the conflicts view: fetch the open conflicts and label
/// each with its node's title.
pub fn open_conflicts(&mut self) {
match self.backend.conflicts() {
Ok(rows) => {
let items: Vec<ConflictItem> = rows
.into_iter()
.map(|conflict| {
let title = self
.backend
.node_title(&conflict.node_id)
.ok()
.flatten()
.unwrap_or_else(|| conflict.node_id.clone());
ConflictItem { conflict, title }
})
.collect();
let cursor = self
.conflicts_view
.as_ref()
.map(|v| v.cursor.min(items.len().saturating_sub(1)))
.unwrap_or(0);
self.status = if items.is_empty() {
"no open conflicts".into()
} else {
format!("{} open conflict(s)", items.len())
};
self.conflicts_view = Some(ConflictsView { items, cursor });
}
Err(e) => self.status = format!("error: {e}"),
}
}
/// Close the conflicts view.
pub fn close_conflicts(&mut self) {
self.conflicts_view = None;
}
/// Move the conflicts cursor by `delta` (clamped).
pub fn conflicts_move(&mut self, delta: isize) {
if let Some(v) = &mut self.conflicts_view {
if v.items.is_empty() {
return;
}
let max = v.items.len() as isize - 1;
v.cursor = (v.cursor as isize + delta).clamp(0, max) as usize;
}
}
/// Settle the highlighted conflict keeping the `"local"` or `"remote"`
/// value, then refresh the list (and the status-line conflict chip).
pub fn conflicts_resolve(&mut self, choice: &str) {
let Some(item) = self
.conflicts_view
.as_ref()
.and_then(|v| v.items.get(v.cursor))
.cloned()
else {
return;
};
match self.backend.resolve_conflict(&item.conflict.id, choice) {
Ok(()) => {
self.status = format!("kept {choice}: {} ({})", item.title, item.conflict.field);
self.open_conflicts();
self.refresh_sync();
self.reload();
}
Err(e) => self.status = format!("error: {e}"),
}
}
// --- full task log (`L`) ---
/// How much history the full log view fetches (the preview shows 5 lines).
const LOG_VIEW_LINES: usize = 1000;
/// Open the full, scrollable log of the highlighted task.
pub fn open_log(&mut self) {
let Some(t) = self.selected_task().cloned() else {
return;
};
match self.backend.log_tail(&t.node_id, Self::LOG_VIEW_LINES) {
Ok(lines) => {
self.log_view = Some(LogView {
title: t.title,
lines,
scroll: 0,
});
}
Err(e) => self.status = format!("error: {e}"),
}
}
/// Close the log view.
pub fn close_log(&mut self) {
self.log_view = None;
}
/// Scroll the log view by `delta` lines (clamped).
pub fn log_scroll(&mut self, delta: isize) {
if let Some(v) = &mut self.log_view {
let max = v.lines.len().saturating_sub(1) as isize;
v.scroll = (v.scroll as isize + delta).clamp(0, max) as usize;
}
}
/// Move the search-results cursor by `delta` (clamped).
pub fn search_move(&mut self, delta: isize) {
if let Some(s) = &mut self.search {
@ -1187,6 +1606,32 @@ impl<B: Backend> App<B> {
Err(e) => self.status = format!("error: {e}"),
}
}
InputKind::Rename {
task_id,
context_id,
before,
} => {
let after = buf;
if after.is_empty() || after == before {
self.status = "rename cancelled".into();
return;
}
self.push_entry(UndoEntry::Rename {
task_id: task_id.clone(),
context_id: context_id.clone(),
before,
after: after.clone(),
});
self.mutate(format!("renamed: {after} (u to undo)"), move |b| {
b.rename(&task_id, &after)?;
// The context doc is created with the task's title — keep
// them in step so resolution/search stay coherent.
if let Some(ctx) = &context_id {
b.rename(ctx, &after)?;
}
Ok(())
});
}
InputKind::Reschedule { task_id } => {
let patch = if buf.is_empty() {
SchedulePatch {

View file

@ -90,6 +90,18 @@ pub trait Backend {
fn sync_status(&mut self) -> Result<SyncStatus> {
Ok(SyncStatus::default())
}
/// A node's title, for labelling conflict rows. `None` if it's missing.
fn node_title(&mut self, _id: &str) -> Result<Option<String>> {
Ok(None)
}
/// Open merge conflicts (the `conflicts.list` RPC). Default: none.
fn conflicts(&mut self) -> Result<Vec<heph_core::Conflict>> {
Ok(Vec::new())
}
/// Settle a conflict keeping the `"local"` or `"remote"` value.
fn resolve_conflict(&mut self, _id: &str, _choice: &str) -> Result<()> {
Ok(())
}
// --- triage mutations (T2) ---
@ -100,6 +112,13 @@ pub trait Backend {
/// 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<()>;
/// Restore (un-tombstone) a node — the undo of [`Backend::tombstone`] and
/// of project deletion.
fn restore(&mut self, node_id: &str) -> Result<()>;
/// Rename a node (title only; body untouched).
fn rename(&mut self, node_id: &str, title: &str) -> Result<()>;
/// Re-parent a project (`None` detaches it to the root).
fn reparent(&mut self, project_id: &str, parent_id: Option<&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.
@ -224,6 +243,43 @@ impl Backend for ClientBackend {
Ok(())
}
fn restore(&mut self, node_id: &str) -> Result<()> {
self.call("node.restore", json!({ "id": node_id }))?;
Ok(())
}
fn rename(&mut self, node_id: &str, title: &str) -> Result<()> {
self.call("node.update", json!({ "id": node_id, "title": title }))?;
Ok(())
}
fn reparent(&mut self, project_id: &str, parent_id: Option<&str>) -> Result<()> {
self.call(
"project.reparent",
json!({ "id": project_id, "parent_id": parent_id }),
)?;
Ok(())
}
fn node_title(&mut self, id: &str) -> Result<Option<String>> {
let v = self.call("node.get", json!({ "id": id }))?;
if v.is_null() {
return Ok(None);
}
let node: heph_core::Node = serde_json::from_value(v)?;
Ok(Some(node.title))
}
fn conflicts(&mut self) -> Result<Vec<heph_core::Conflict>> {
let v = self.call("conflicts.list", json!({}))?;
Ok(serde_json::from_value(v)?)
}
fn resolve_conflict(&mut self, id: &str, choice: &str) -> Result<()> {
self.call("conflicts.resolve", json!({ "id": id, "choice": choice }))?;
Ok(())
}
fn set_attention(&mut self, task_id: &str, attention: Attention) -> Result<()> {
self.call(
"task.set_attention",

View file

@ -159,6 +159,34 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
return None;
}
// While the full task log is shown, j/k scroll it.
if app.log_view.is_some() {
app.status.clear();
match key.code {
KeyCode::Esc | KeyCode::Char('L') => app.close_log(),
KeyCode::Char('j') | KeyCode::Down => app.log_scroll(1),
KeyCode::Char('k') | KeyCode::Up => app.log_scroll(-1),
KeyCode::Char('q') => app.should_quit = true,
_ => {}
}
return None;
}
// While the conflicts review is shown, the center pane navigates/settles it.
if app.conflicts_view.is_some() {
app.status.clear();
match key.code {
KeyCode::Esc | KeyCode::Char('C') => app.close_conflicts(),
KeyCode::Char('j') | KeyCode::Down => app.conflicts_move(1),
KeyCode::Char('k') | KeyCode::Up => app.conflicts_move(-1),
KeyCode::Char('l') => app.conflicts_resolve("local"),
KeyCode::Char('r') => app.conflicts_resolve("remote"),
KeyCode::Char('q') => app.should_quit = true,
_ => {}
}
return None;
}
// While search results are shown, the center pane navigates them.
if app.search.is_some() {
app.status.clear();
@ -194,6 +222,7 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
KeyCode::Char('s') => app.toggle_sort(),
KeyCode::Char('u') => app.undo(),
KeyCode::Char('z') if ctrl => app.redo(),
KeyCode::Char('C') => app.open_conflicts(),
// 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 {
@ -204,14 +233,16 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
KeyCode::Char('a') => app.begin_attention(),
KeyCode::Char('e') => app.begin_reschedule(),
KeyCode::Char('m') => app.begin_move(),
KeyCode::Char('R') => app.begin_rename(),
KeyCode::Char('L') => app.open_log(),
KeyCode::Char('D') => app.begin_delete(),
_ => {}
},
Focus::Sidebar => {
if let KeyCode::Char('D') = key.code {
app.begin_delete_project()
}
}
Focus::Sidebar => match key.code {
KeyCode::Char('m') => app.begin_reparent(),
KeyCode::Char('D') => app.begin_delete_project(),
_ => {}
},
},
}
None

View file

@ -19,14 +19,18 @@ use crate::fmt::{fmt_age, fmt_date, now_ms, project_color, today_local};
// Task-pane gestures (the focused pane shows its own hints, §8.1).
const HINTS: &str =
" j/k move ⏎ edit n add x done d drop S skip e date a 1-4 attn m move D del u undo / search q quit";
" j/k move ⏎ edit n add x done d drop S skip e date a 1-4 attn m move R rename L log 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 n add D del-project u undo s sort / search Tab tasks q quit";
" j/k move ⏎ open n add m move-project D del-project u undo s sort / search Tab tasks q quit";
const SEARCH_HINTS: &str = " j/k move Enter open Esc exit search";
const CONFLICT_HINTS: &str = " j/k move l keep local r keep remote Esc close";
const LOG_HINTS: &str = " j/k scroll Esc close";
/// Draw the whole UI for the current frame.
pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) {
let outer = Layout::default()
@ -44,7 +48,11 @@ pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) {
.split(outer[0]);
render_sidebar(frame, app, panes[0]);
if app.search.is_some() {
if app.log_view.is_some() {
render_log(frame, app, panes[1]);
} else if app.conflicts_view.is_some() {
render_conflicts(frame, app, panes[1]);
} else if app.search.is_some() {
render_search(frame, app, panes[1]);
} else {
render_tasks(frame, app, panes[1]);
@ -87,7 +95,7 @@ fn render_move(frame: &mut Frame, state: &MoveState) {
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(format!(" Move \"{}\" to ", state.task_title)),
.title(format!(" Move \"{}\" to ", state.subject_title)),
);
frame.render_widget(input, chunks[0]);
@ -474,6 +482,86 @@ fn render_search<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
frame.render_widget(list, area);
}
/// The open-conflicts review in the center pane: one row per conflict, the
/// node's title + which field diverged + both values.
fn render_conflicts<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
let Some(v) = &app.conflicts_view else { return };
let today = today_local();
let items: Vec<ListItem> = if v.items.is_empty() {
vec![ListItem::new(Line::from(Span::styled(
" (no open conflicts — Esc to close)",
Style::default().fg(Color::DarkGray),
)))]
} else {
v.items
.iter()
.enumerate()
.map(|(i, item)| {
let selected = i == v.cursor;
let title_style = if selected {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
let cursor = if selected { "" } else { " " };
let c = &item.conflict;
let val = |v: &Option<String>| match (c.field.as_str(), v) {
("do_date", Some(ms)) => ms
.parse::<i64>()
.map(|ms| fmt_date(ms, today))
.unwrap_or_else(|_| ms.clone()),
(_, Some(s)) => s.clone(),
(_, None) => "(unset)".into(),
};
ListItem::new(Line::from(vec![
Span::styled(cursor, Style::default().fg(Color::Cyan)),
Span::styled(item.title.clone(), title_style),
Span::styled(
format!(
" {}: local {} · remote {}",
c.field,
val(&c.local_val),
val(&c.remote_val)
),
Style::default().fg(Color::DarkGray),
),
]))
})
.collect()
};
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(format!(" Conflicts ({}) ", v.items.len())),
);
frame.render_widget(list, area);
}
/// A task's full log in the center pane, scrollable (the preview shows only
/// the last few lines).
fn render_log<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
let Some(v) = &app.log_view else { return };
let lines: Vec<Line> = if v.lines.is_empty() {
vec![Line::from(Span::styled(
" (no log entries — Esc to close)",
Style::default().fg(Color::DarkGray),
))]
} else {
v.lines.iter().map(|l| Line::from(l.clone())).collect()
};
let para = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.scroll((v.scroll as u16, 0))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(format!(" Log: {} ({} lines) ", v.title, v.lines.len())),
);
frame.render_widget(para, area);
}
fn render_preview<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
let mut lines: Vec<Line> = Vec::new();
if !app.preview.title.is_empty() {
@ -521,7 +609,11 @@ fn render_status<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
);
return;
}
let hints = if app.search.is_some() {
let hints = if app.log_view.is_some() {
LOG_HINTS
} else if app.conflicts_view.is_some() {
CONFLICT_HINTS
} else if app.search.is_some() {
SEARCH_HINTS
} else if app.focus == Focus::Sidebar {
SIDEBAR_HINTS

View file

@ -28,9 +28,13 @@ struct Recorder {
created: Vec<CreatedTask>,
scheduled: Vec<(String, SchedulePatch)>,
tombstoned: Vec<String>,
restored: Vec<String>,
renamed: Vec<(String, String)>,
reparented: Vec<(String, Option<String>)>,
refiled: Vec<(String, Option<String>)>,
created_projects: Vec<String>,
states: Vec<(String, String)>,
resolved: Vec<(String, String)>,
}
fn task(id: &str, title: &str, attention: Attention, ctx: Option<&str>) -> RankedTask {
@ -57,6 +61,8 @@ struct Fake {
bodies: HashMap<String, String>,
search_hits: Vec<SearchHit>,
contexts: HashMap<String, String>,
conflicts: Vec<heph_core::Conflict>,
logs: HashMap<String, Vec<String>>,
rec: Rc<RefCell<Recorder>>,
}
@ -74,8 +80,23 @@ impl Backend for Fake {
fn node_body(&mut self, id: &str) -> Result<String> {
Ok(self.bodies.get(id).cloned().unwrap_or_default())
}
fn log_tail(&mut self, _task_id: &str, _n: usize) -> Result<Vec<String>> {
Ok(Vec::new())
fn log_tail(&mut self, task_id: &str, _n: usize) -> Result<Vec<String>> {
Ok(self.logs.get(task_id).cloned().unwrap_or_default())
}
fn conflicts(&mut self) -> Result<Vec<heph_core::Conflict>> {
Ok(self
.conflicts
.iter()
.filter(|c| !self.rec.borrow().resolved.iter().any(|(id, _)| id == &c.id))
.cloned()
.collect())
}
fn resolve_conflict(&mut self, id: &str, choice: &str) -> Result<()> {
self.rec
.borrow_mut()
.resolved
.push((id.into(), choice.into()));
Ok(())
}
fn search(&mut self, query: &str) -> Result<Vec<SearchHit>> {
Ok(self
@ -99,6 +120,24 @@ impl Backend for Fake {
self.rec.borrow_mut().tombstoned.push(node_id.into());
Ok(())
}
fn restore(&mut self, node_id: &str) -> Result<()> {
self.rec.borrow_mut().restored.push(node_id.into());
Ok(())
}
fn rename(&mut self, node_id: &str, title: &str) -> Result<()> {
self.rec
.borrow_mut()
.renamed
.push((node_id.into(), title.into()));
Ok(())
}
fn reparent(&mut self, project_id: &str, parent_id: Option<&str>) -> Result<()> {
self.rec
.borrow_mut()
.reparented
.push((project_id.into(), parent_id.map(str::to_string)));
Ok(())
}
fn set_attention(&mut self, _t: &str, _a: Attention) -> Result<()> {
Ok(())
}
@ -357,7 +396,7 @@ fn move_to_project_picker_refiles_the_selected_task() {
app.begin_move();
match &app.mode {
Mode::MoveToProject(m) => {
assert_eq!(m.task_id, "t1");
assert_eq!(m.subject_id, "t1");
assert_eq!(
m.options.iter().map(|o| o.label()).collect::<Vec<_>>(),
vec!["(Unfile)", "Camano"]
@ -568,3 +607,218 @@ fn reschedule_with_blank_clears_the_do_date() {
// do_date present-and-null = "clear" (the double-option).
assert_eq!(scheduled[0].1.do_date, Some(None));
}
#[test]
fn deleted_task_is_undoable_via_restore_and_redoable() {
let rec = Rc::new(RefCell::new(Recorder::default()));
let mut fake = fixture();
fake.rec = rec.clone();
let mut app = App::new(fake).unwrap();
app.begin_delete();
app.confirm_delete(); // tombstones t1
assert_eq!(rec.borrow().tombstoned, vec!["t1".to_string()]);
app.undo(); // restores it
assert_eq!(rec.borrow().restored, vec!["t1".to_string()]);
app.redo(); // tombstones it again
assert_eq!(
rec.borrow().tombstoned,
vec!["t1".to_string(), "t1".to_string()]
);
}
#[test]
fn deleted_project_undo_restores_node_and_refiles_its_tasks() {
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 (it holds task "pt").
for _ in 0..6 {
app.move_sidebar(1);
}
app.begin_delete_project();
app.confirm_delete();
assert_eq!(rec.borrow().tombstoned, vec!["p1".to_string()]);
app.undo();
assert_eq!(rec.borrow().restored, vec!["p1".to_string()]);
// The task that was filed under it is re-filed.
assert_eq!(
rec.borrow().refiled.last().unwrap(),
&("pt".to_string(), Some("p1".to_string()))
);
}
#[test]
fn rename_prefills_renames_task_and_context_and_is_undoable() {
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.begin_rename(); // t1, title "red one", ctx c1
// The buffer is prefilled; clear it and type a new title.
for _ in 0.."red one".len() {
app.input_backspace();
}
type_and_submit(&mut app, "crimson one");
{
let renamed = &rec.borrow().renamed;
assert_eq!(
renamed.as_slice(),
[
("t1".to_string(), "crimson one".to_string()),
("c1".to_string(), "crimson one".to_string()),
]
);
}
app.undo(); // back to "red one" on both nodes
assert_eq!(
rec.borrow().renamed.last().unwrap(),
&("c1".to_string(), "red one".to_string())
);
}
#[test]
fn rename_to_same_or_empty_title_is_a_noop() {
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.begin_rename();
app.input_submit(); // unchanged prefill
assert!(rec.borrow().renamed.is_empty());
app.begin_rename();
for _ in 0.."red one".len() {
app.input_backspace();
}
app.input_submit(); // emptied
assert!(rec.borrow().renamed.is_empty());
}
#[test]
fn reparent_picker_excludes_subtree_and_reparents_with_undo() {
use heph_tui::app::{Mode, MoveOption};
let rec = Rc::new(RefCell::new(Recorder::default()));
let mut fake = fixture();
// A second project to be the new parent.
fake.projects.push(Project {
id: "p2".into(),
title: "Coding".into(),
});
fake.rec = rec.clone();
let mut app = App::new(fake).unwrap();
// Sidebar → Camano (p1).
for _ in 0..6 {
app.move_sidebar(1);
}
assert_eq!(app.task_pane_title(), "Camano");
app.begin_reparent();
match &app.mode {
Mode::MoveToProject(m) => {
assert_eq!(m.subject_id, "p1");
let labels: Vec<String> = m.options.iter().map(|o| o.label()).collect();
assert!(labels.contains(&"(Move to root)".to_string()), "{labels:?}");
assert!(labels.contains(&"Coding".to_string()), "{labels:?}");
assert!(
!labels.contains(&"Camano".to_string()),
"a project can't be its own parent: {labels:?}"
);
assert!(
!m.options
.iter()
.any(|o| matches!(o, MoveOption::Create { .. })),
"no create row in re-parent mode"
);
}
_ => panic!("expected the move picker"),
}
// Pick Coding and submit.
let coding_idx = match &app.mode {
Mode::MoveToProject(m) => m
.options
.iter()
.position(|o| matches!(o, MoveOption::Project { title, .. } if title == "Coding"))
.unwrap(),
_ => unreachable!(),
};
app.move_picker_move(coding_idx as isize);
app.move_picker_submit();
assert_eq!(
rec.borrow().reparented.last().unwrap(),
&("p1".to_string(), Some("p2".to_string()))
);
app.undo(); // back to the root (no parent in the fixture overview)
assert_eq!(
rec.borrow().reparented.last().unwrap(),
&("p1".to_string(), None)
);
}
#[test]
fn conflicts_view_lists_and_resolves() {
let mut fake = fixture();
fake.conflicts = vec![heph_core::Conflict {
id: "cf1".into(),
node_id: "t1".into(),
field: "do_date".into(),
local_val: Some("1000".into()),
remote_val: Some("2000".into()),
local_hlc: String::new(),
remote_hlc: String::new(),
status: "open".into(),
created_at: 0,
}];
let rec = Rc::new(RefCell::new(Recorder::default()));
fake.rec = rec.clone();
let mut app = App::new(fake).unwrap();
app.open_conflicts();
let v = app.conflicts_view.as_ref().expect("conflicts view open");
assert_eq!(v.items.len(), 1);
app.conflicts_resolve("remote");
assert_eq!(
rec.borrow().resolved.as_slice(),
[("cf1".to_string(), "remote".to_string())]
);
app.close_conflicts();
assert!(app.conflicts_view.is_none());
}
#[test]
fn log_view_opens_scrolls_and_closes() {
let mut fake = fixture();
fake.logs.insert(
"t1".into(),
vec!["one".into(), "two".into(), "three".into()],
);
let mut app = App::new(fake).unwrap();
app.focus_tasks();
app.open_log();
assert_eq!(app.log_view.as_ref().unwrap().lines.len(), 3);
app.log_scroll(5); // clamped to the last line
assert_eq!(app.log_view.as_ref().unwrap().scroll, 2);
app.log_scroll(-9);
assert_eq!(app.log_view.as_ref().unwrap().scroll, 0);
app.close_log();
assert!(app.log_view.is_none());
}