|
|
|
@@ -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<RetainedWorktree>,
|
|
|
|
|
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<RetainedWorktree>) {
|
|
|
|
|
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<Line> = 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<F>(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}");
|
|
|
|
|
}
|
|
|
|
|
}
|