generated from eblume/project-template
feat(tui): undoable delete, rename, project re-parent, conflicts view, full log view
All checks were successful
Build / validate (pull_request) Successful in 9m21s
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:
parent
3db026f6e5
commit
8417f70326
5 changed files with 952 additions and 74 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue