From 01666070644cc2793d306bd7baef006352f72d6d Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 11:26:56 +0100 Subject: [PATCH] feat(maestro): retained-worktrees view overlay (#85) --- crates/maestro-tui/src/draft.rs | 2 +- crates/maestro-tui/src/lib.rs | 1 + crates/maestro-tui/src/overlays.rs | 2 +- crates/maestro-tui/src/worktrees.rs | 259 ++++++++++++++++++++++++++++ 4 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 crates/maestro-tui/src/worktrees.rs diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs index cf3a165..cd76fd2 100644 --- a/crates/maestro-tui/src/draft.rs +++ b/crates/maestro-tui/src/draft.rs @@ -1186,7 +1186,7 @@ fn draw_text_field( /// `~` abbreviation plus leading-segment collapsing, matching the web UI's /// `abbreviateCwd` + `abbreviateFolderName`: `/home/u/tea/Maestro` renders as /// `/h/u/t/Maestro` given home `/home/u`. -fn abbreviate_path(path: &Path, home: &str) -> String { +pub(crate) fn abbreviate_path(path: &Path, home: &str) -> String { let raw = path.to_string_lossy(); let abbreviated = if !home.is_empty() { if raw == home { diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index 09e58af..c7cfc56 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -22,6 +22,7 @@ pub mod state; pub mod tabs; pub mod terminal; pub mod text_input; +pub mod worktrees; use std::path::PathBuf; use std::time::Duration; diff --git a/crates/maestro-tui/src/overlays.rs b/crates/maestro-tui/src/overlays.rs index a046224..3f540a6 100644 --- a/crates/maestro-tui/src/overlays.rs +++ b/crates/maestro-tui/src/overlays.rs @@ -82,7 +82,7 @@ impl Toast { } } -fn centered(parent: Rect, w: u16, h: u16) -> Rect { +pub(crate) fn centered(parent: Rect, w: u16, h: u16) -> Rect { let w = w.min(parent.width); let h = h.min(parent.height); Rect { diff --git a/crates/maestro-tui/src/worktrees.rs b/crates/maestro-tui/src/worktrees.rs new file mode 100644 index 0000000..fbb640d --- /dev/null +++ b/crates/maestro-tui/src/worktrees.rs @@ -0,0 +1,259 @@ +//! Retained-worktrees view (#85): an overlay listing every retained worktree +//! — branch, abbreviated path, reason, what is in the way — with j/k to move, +//! `r` to reclaim behind a confirmation, esc to close. +//! +//! The in-the-way summary comes straight from [`InTheWay`]'s `Display` impl, +//! the same source the kill refusal and the web client use, so no two clients +//! can describe the same tree differently. + +use ratatui::layout::Rect; +use ratatui::text::Line; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; +use ratatui::Frame; + +use maestro_protocol::{RetainedReason, RetainedWorktree}; + +use crate::draft::abbreviate_path; + +/// The open worktrees overlay: its entries and which one is highlighted. +#[derive(Debug, Clone, Default)] +pub struct WorktreesView { + pub items: Vec, + pub highlight: usize, + /// Daemon-reported home dir, for `~` abbreviation of paths. + pub home_dir: String, +} + +impl WorktreesView { + /// Replace the listing and keep the highlight pointing at something real. + pub fn apply(&mut self, items: Vec) { + self.items = items; + if !self.items.is_empty() { + self.highlight = self.highlight.min(self.items.len() - 1); + } else { + self.highlight = 0; + } + } + + pub fn remove(&mut self, id: uuid::Uuid) { + let idx = self.items.iter().position(|w| w.id == id); + self.items.retain(|w| w.id != id); + if let Some(i) = idx { + self.highlight = self.highlight.min(self.items.len().saturating_sub(1)); + let _ = i; + } + } + + /// Move the highlight by `delta` (positive = down). Wraps. No-op on empty. + pub fn move_highlight(&mut self, delta: i32) { + if self.items.is_empty() { + return; + } + let len = self.items.len() as i32; + let next = (self.highlight as i32 + delta).rem_euclid(len); + self.highlight = next as usize; + } + + pub fn selected(&self) -> Option<&RetainedWorktree> { + self.items.get(self.highlight) + } + + pub fn render(&self, f: &mut Frame, parent: Rect) { + let h = (self.items.len() as u16 + 6).clamp(4, parent.height); + let area = super::overlays::centered(parent, parent.width.saturating_sub(4), h); + f.render_widget(Clear, area); + let block = Block::default() + .title(" retained worktrees ") + .borders(Borders::ALL); + let inner = block.inner(area); + f.render_widget(block, area); + + let mut lines: Vec = Vec::new(); + if self.items.is_empty() { + // A drained store is the normal state — say so plainly, not like + // something went wrong. + lines.push(Line::from("no retained worktrees")); + } else { + lines.push(Line::from(format!( + " {:<26} {:<34} {:<24} IN THE WAY", + "BRANCH", "PATH", "REASON" + ))); + for (i, w) in self.items.iter().enumerate() { + let marker = if i == self.highlight { ">" } else { " " }; + lines.push(Line::from(format!( + "{marker} {:<25} {:<34} {:<24} {}", + w.branch, + abbreviate_path(&w.path, &self.home_dir), + reason_label(w.reason), + w.in_the_way, + ))); + } + } + lines.push(Line::from("")); + lines.push(Line::from("j/k: select r: reclaim esc: close")); + f.render_widget(Paragraph::new(lines), inner); + } +} + +/// The confirmation guarding reclaim, mirroring [`super::overlays::KillConfirm`]. +/// It names the branch and says out loud what reclaim does not take with it — +/// docs/WORKTREES.md: "reclaim" otherwise sounds like it takes the commits too. +#[derive(Debug, Clone)] +pub struct ReclaimConfirm { + pub branch: String, + /// [`maestro_protocol::InTheWay`] rendered via its `Display` impl. + pub blockers: String, +} + +impl ReclaimConfirm { + pub fn render(&self, f: &mut Frame, parent: Rect) { + let area = super::overlays::centered(parent, 70, 7); + f.render_widget(Clear, area); + let block = Block::default().title(" reclaim ").borders(Borders::ALL); + let inner = block.inner(area); + f.render_widget(block, area); + f.render_widget( + ratatui::widgets::Paragraph::new(vec![ + Line::from(format!("Reclaim worktree {}?", self.branch)), + Line::from(format!("Currently in the way: {}", self.blockers)), + Line::from("The directory is removed; the branch is never deleted."), + Line::from(""), + Line::from("[y] yes [n] no"), + ]), + inner, + ); + } +} + +fn reason_label(reason: RetainedReason) -> &'static str { + match reason { + RetainedReason::ForceKilled => "force-killed", + RetainedReason::ExitedWithUnsavedWork => "exited-with-unsaved-work", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Utc}; + use maestro_protocol::{InTheWay, RetainedReason}; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + fn render_to_string(w: u16, h: u16, draw: F) -> String + where + F: FnOnce(&mut Frame), + { + let backend = TestBackend::new(w, h); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(draw).unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut out = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + out + } + + fn worktree(branch: &str, path: &str, id: u128) -> RetainedWorktree { + RetainedWorktree { + id: uuid::Uuid::from_u128(id), + path: path.into(), + base_repo: "/home/naps/tea/maestro".into(), + branch: branch.into(), + reason: RetainedReason::ForceKilled, + retained_at: Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(), + in_the_way: InTheWay::default(), + } + } + + #[test] + fn view_lists_branch_path_reason_and_what_is_in_the_way() { + let mut view = WorktreesView { + home_dir: "/home/naps".into(), + ..Default::default() + }; + let mut wt = worktree( + "feat/login", + "/home/naps/tea/maestro/worktrees/feat/login", + 1, + ); + wt.in_the_way = InTheWay { + uncommitted_files: 2, + untracked_files: 1, + unpushed_commits: 3, + }; + view.apply(vec![wt]); + + let rendered = render_to_string(160, 20, |f| view.render(f, f.area())); + assert!(rendered.contains("feat/login"), "{rendered}"); + // Path abbreviated against the daemon's home dir, same helper as the + // draft pane's project list. + assert!(rendered.contains("~/t/m/w/f/login"), "{rendered}"); + assert!(rendered.contains("force-killed"), "{rendered}"); + // Phrasing comes from InTheWay's Display impl — shared with the kill + // refusal and the web client. + assert!( + rendered.contains("2 uncommitted files, 1 untracked file, 3 unpushed commits"), + "{rendered}" + ); + } + + #[test] + fn empty_store_looks_like_a_normal_state_not_an_error() { + let view = WorktreesView::default(); + let rendered = render_to_string(80, 12, |f| view.render(f, f.area())); + assert!(rendered.contains("no retained worktrees"), "{rendered}"); + assert!(!rendered.contains("error"), "{rendered}"); + assert!(!rendered.contains("failed"), "{rendered}"); + } + + #[test] + fn highlight_moves_and_wraps() { + let mut view = WorktreesView::default(); + view.apply(vec![ + worktree("a", "/r/a", 1), + worktree("b", "/r/b", 2), + worktree("c", "/r/c", 3), + ]); + assert_eq!(view.selected().unwrap().branch, "a"); + view.move_highlight(1); + assert_eq!(view.selected().unwrap().branch, "b"); + view.move_highlight(-1); + assert_eq!(view.selected().unwrap().branch, "a"); + view.move_highlight(-1); + // Wraps backward to the last entry. + assert_eq!(view.selected().unwrap().branch, "c"); + view.move_highlight(1); + assert_eq!(view.selected().unwrap().branch, "a"); + } + + #[test] + fn apply_clamps_a_stale_highlight() { + let mut view = WorktreesView::default(); + view.apply(vec![worktree("a", "/r/a", 1), worktree("b", "/r/b", 2)]); + view.move_highlight(1); + view.apply(vec![worktree("c", "/r/c", 3)]); + assert_eq!(view.selected().unwrap().branch, "c"); + } + + #[test] + fn reclaim_confirm_names_the_branch_and_says_it_survives() { + let confirm = ReclaimConfirm { + branch: "feat/login".into(), + blockers: "2 uncommitted files".into(), + }; + let rendered = render_to_string(80, 14, |f| confirm.render(f, f.area())); + assert!( + rendered.contains("Reclaim worktree feat/login?"), + "{rendered}" + ); + assert!(rendered.contains("2 uncommitted files"), "{rendered}"); + assert!(rendered.contains("never deleted"), "{rendered}"); + assert!(rendered.contains("yes"), "{rendered}"); + assert!(rendered.contains("no"), "{rendered}"); + } +}