diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index 51276ea..7f90b34 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -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, + 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, + 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, + 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), // 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, + }, + /// A rename of a task (and its same-named context doc, when it has one). + Rename { + task_id: String, + context_id: Option, + before: String, + after: String, + }, + /// A project re-parent; undone by re-parenting back. + Reparent { + project_id: String, + title: String, + before_parent: Option, + after_parent: Option, + }, } /// 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 }, +} + +/// 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, /// 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, 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 { pub sort_mode: SortMode, /// When `Some`, a full-text search overlays the task list. pub search: Option, + /// When `Some`, the open-conflicts review overlays the task list. + pub conflicts_view: Option, + /// When `Some`, a task's full log overlays the task list. + pub log_view: Option, /// When `Some`, a delete is awaiting y/N confirmation. pub pending_delete: Option, /// When `true`, an attention chord is in progress: `a` was pressed and the @@ -474,6 +559,8 @@ impl App { 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 App { // --- 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 App { 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 App { } } - /// 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 = 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 App { 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 App { 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 = [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 = 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 App { } } - /// 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 App { 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 App { }); } + /// 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 App { 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 = 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 App { 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 { diff --git a/crates/heph-tui/src/backend.rs b/crates/heph-tui/src/backend.rs index a52fd90..9f39d98 100644 --- a/crates/heph-tui/src/backend.rs +++ b/crates/heph-tui/src/backend.rs @@ -90,6 +90,18 @@ pub trait Backend { fn sync_status(&mut self) -> Result { Ok(SyncStatus::default()) } + /// A node's title, for labelling conflict rows. `None` if it's missing. + fn node_title(&mut self, _id: &str) -> Result> { + Ok(None) + } + /// Open merge conflicts (the `conflicts.list` RPC). Default: none. + fn conflicts(&mut self) -> Result> { + 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> { + 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> { + 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", diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs index 34648fa..0d1c38d 100644 --- a/crates/heph-tui/src/main.rs +++ b/crates/heph-tui/src/main.rs @@ -159,6 +159,34 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option 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(app: &mut App, key: KeyEvent) -> Option 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(app: &mut App, key: KeyEvent) -> Option 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 diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index bcd885e..ed3c86e 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -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(frame: &mut Frame, app: &App) { let outer = Layout::default() @@ -44,7 +48,11 @@ pub fn render(frame: &mut Frame, app: &App) { .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(frame: &mut Frame, app: &App, 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(frame: &mut Frame, app: &App, area: Rect) { + let Some(v) = &app.conflicts_view else { return }; + let today = today_local(); + let items: Vec = 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| match (c.field.as_str(), v) { + ("do_date", Some(ms)) => ms + .parse::() + .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(frame: &mut Frame, app: &App, area: Rect) { + let Some(v) = &app.log_view else { return }; + let lines: Vec = 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(frame: &mut Frame, app: &App, area: Rect) { let mut lines: Vec = Vec::new(); if !app.preview.title.is_empty() { @@ -521,7 +609,11 @@ fn render_status(frame: &mut Frame, app: &App, 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 diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index 83ec24e..a6e474d 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -28,9 +28,13 @@ struct Recorder { created: Vec, scheduled: Vec<(String, SchedulePatch)>, tombstoned: Vec, + restored: Vec, + renamed: Vec<(String, String)>, + reparented: Vec<(String, Option)>, refiled: Vec<(String, Option)>, created_projects: Vec, 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, search_hits: Vec, contexts: HashMap, + conflicts: Vec, + logs: HashMap>, rec: Rc>, } @@ -74,8 +80,23 @@ impl Backend for Fake { fn node_body(&mut self, id: &str) -> Result { Ok(self.bodies.get(id).cloned().unwrap_or_default()) } - fn log_tail(&mut self, _task_id: &str, _n: usize) -> Result> { - Ok(Vec::new()) + fn log_tail(&mut self, task_id: &str, _n: usize) -> Result> { + Ok(self.logs.get(task_id).cloned().unwrap_or_default()) + } + fn conflicts(&mut self) -> Result> { + 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> { 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!["(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 = 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()); +}