From 56c8e35d6f005f2c9aca9c377340c3606e421f1c Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 11:32:22 +0100 Subject: [PATCH] feat(maestro): wire worktrees view into the tui loop (#85) w opens the view; r confirms reclaim and says the branch survives. The in-the-way summary uses InTheWay's Display impl so the TUI cannot drift from the CLI's wording. --- crates/maestro-tui/src/action.rs | 3 + crates/maestro-tui/src/app.rs | 270 ++++++++++++++++++++++++++++++- crates/maestro-tui/src/config.rs | 5 + crates/maestro-tui/src/render.rs | 20 +++ crates/maestro-tui/src/state.rs | 11 ++ 5 files changed, 305 insertions(+), 4 deletions(-) diff --git a/crates/maestro-tui/src/action.rs b/crates/maestro-tui/src/action.rs index 98d5354..0efba87 100644 --- a/crates/maestro-tui/src/action.rs +++ b/crates/maestro-tui/src/action.rs @@ -43,6 +43,8 @@ pub enum Action { /// resolved; unresolvable defaults open a pre-filled draft instead. FastForwardSpawn, KillHighlighted, + /// Open the retained-worktrees view (#85). + ShowWorktrees, // Tabs NewTab, @@ -84,6 +86,7 @@ impl Action { "NewDraftPane" => return Ok(Action::NewDraftPane), "FastForwardSpawn" => return Ok(Action::FastForwardSpawn), "KillHighlighted" => return Ok(Action::KillHighlighted), + "ShowWorktrees" => return Ok(Action::ShowWorktrees), "NewTab" => return Ok(Action::NewTab), "CloseTab" => return Ok(Action::CloseTab), "NextTab" => return Ok(Action::NextTab), diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 96b5375..1f93e76 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -18,7 +18,9 @@ use std::time::Duration; use chrono::Utc; use crossterm::event::{Event as CtEvent, EventStream}; use futures_util::StreamExt; -use maestro_protocol::{routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch}; +use maestro_protocol::{ + routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch, RetainedWorktree, +}; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; use uuid::Uuid; @@ -37,6 +39,7 @@ use crate::session_task::{SessionInput, SessionTask}; use crate::sidebar::Sidebar; use crate::state::{AppState, Focus, OverlayKind, PaneRef}; use crate::tabs::TabBar; +use crate::worktrees::WorktreesView; use crate::TuiError; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -55,7 +58,8 @@ pub struct App { help: HelpOverlay, chord: ChordState, daemon: DaemonClient, - /// Results of draft data loads, delivered back to the event loop. + /// Results of draft data loads (and the worktrees view fetch, #85), + /// delivered back to the event loop. /// /// These are network calls — `/api/projects` on a cold cache walks all of /// `$HOME` — and awaiting them inside `perform` froze the whole TUI: no @@ -66,6 +70,9 @@ pub struct App { /// Direct HTTP for the one endpoint [`DaemonClient`] doesn't cover yet: /// `GET /api/names` (#64). http: reqwest::Client, + /// Entries of the retained-worktrees view (#85). Daemon data, kept here + /// rather than in `AppState` so the overlay kind stays payload-free. + worktrees: WorktreesView, region: RegionMap, last_sent_size: HashMap, clicks: ClickTracker, @@ -109,6 +116,11 @@ pub enum Effect { }, /// Fetch the draft target's branches from `/api/branches` (#65). LoadBranches(usize), + /// Fetch `/api/worktrees` for the retained-worktrees view (#85). + LoadWorktrees, + /// Reclaim one retained worktree (#85) — removes its directory; the + /// branch is never deleted. + ReclaimWorktree(Uuid), } impl App { @@ -132,6 +144,7 @@ impl App { chord: ChordState::new(chord_timeout), daemon, http, + worktrees: WorktreesView::default(), draft_tx, draft_rx, region: RegionMap::default(), @@ -225,6 +238,8 @@ impl App { self.spawn_probe_git_root(tab, seq, path) } Effect::LoadBranches(tab) => self.spawn_load_branches(tab), + Effect::LoadWorktrees => self.spawn_load_worktrees(), + Effect::ReclaimWorktree(id) => self.submit_reclaim(id).await, Effect::Forward { id, bytes } => { if let Some(handle) = self.emulators.get(id) { let _ = handle.tx.send(SessionInput::Bytes(bytes)).await; @@ -247,6 +262,44 @@ impl App { } _ => (OverlayDecision::Consumed, Vec::new()), }, + Some(OverlayKind::Worktrees) => match key { + InputKey::Char { c: 'j', .. } | InputKey::Down => { + self.worktrees.move_highlight(1); + (OverlayDecision::Consumed, Vec::new()) + } + InputKey::Char { c: 'k', .. } | InputKey::Up => { + self.worktrees.move_highlight(-1); + (OverlayDecision::Consumed, Vec::new()) + } + InputKey::Char { c: 'r', .. } => { + if let Some(wt) = self.worktrees.selected() { + self.state.show_overlay(OverlayKind::ReclaimConfirm { + id: wt.id, + branch: wt.branch.clone(), + // Shared phrasing: InTheWay's Display impl is what + // the kill refusal and the web client use too. + blockers: wt.in_the_way.to_string(), + }); + } + (OverlayDecision::Consumed, Vec::new()) + } + InputKey::Char { c: 'q', .. } | InputKey::Esc => { + self.state.dismiss_overlay(); + (OverlayDecision::Consumed, Vec::new()) + } + _ => (OverlayDecision::Consumed, Vec::new()), + }, + Some(OverlayKind::ReclaimConfirm { id, .. }) => match key { + InputKey::Char { c: 'y', .. } => { + self.state.dismiss_overlay(); + (OverlayDecision::Consumed, vec![Effect::ReclaimWorktree(id)]) + } + InputKey::Char { c: 'n', .. } | InputKey::Esc => { + self.state.show_overlay(OverlayKind::Worktrees); + (OverlayDecision::Consumed, Vec::new()) + } + _ => (OverlayDecision::Consumed, Vec::new()), + }, Some(OverlayKind::Help) => { self.state.dismiss_overlay(); (OverlayDecision::Consumed, Vec::new()) @@ -317,6 +370,10 @@ impl App { self.state.show_overlay(OverlayKind::KillConfirm(sid)); } } + Action::ShowWorktrees => { + self.state.show_overlay(OverlayKind::Worktrees); + effects.push(Effect::LoadWorktrees); + } } effects } @@ -505,13 +562,22 @@ impl App { self.fast_forward_spawn(info, projects, names).await; return; } + if let DraftUpdate::WorktreesLoaded { + worktrees, + home_dir, + } = update + { + self.worktrees.apply(worktrees); + self.worktrees.home_dir = home_dir; + return; + } let want = match &update { DraftUpdate::Data { draft, .. } | DraftUpdate::Names { draft, .. } | DraftUpdate::GitRoot { draft, .. } | DraftUpdate::Branches { draft, .. } => *draft, // Handled above; it targets no particular draft. - DraftUpdate::FastForward { .. } => unreachable!(), + DraftUpdate::FastForward { .. } | DraftUpdate::WorktreesLoaded { .. } => unreachable!(), }; // Find it by identity: closing a tab shifts every index to its right, // so an in-flight load addressed by index would be dropped or land on @@ -566,7 +632,7 @@ impl App { DraftUpdate::Branches { cwd, branches, .. } => { draft.set_branches(&cwd, branches); } - DraftUpdate::FastForward { .. } => unreachable!(), + DraftUpdate::FastForward { .. } | DraftUpdate::WorktreesLoaded { .. } => unreachable!(), } if follow_up_branches { self.spawn_load_branches(tab_idx); @@ -673,6 +739,38 @@ impl App { } } + /// Fetch the retained-worktree store without blocking the loop (#85) — + /// same off-loop pattern as the draft loaders. + fn spawn_load_worktrees(&self) { + let (daemon, _, _, tx) = self.loader_handles(); + tokio::spawn(async move { + let (worktrees, info) = tokio::join!(daemon.list_worktrees(), daemon.daemon_info()); + let _ = tx.send(DraftUpdate::WorktreesLoaded { + worktrees: worktrees.unwrap_or_default(), + home_dir: info.map(|i| i.home_dir).unwrap_or_default(), + }); + }); + } + + /// Reclaim one retained worktree (#85): the directory goes, the branch + /// never does. The open view drops the entry so the list tells the truth + /// without waiting for a re-fetch. + async fn submit_reclaim(&mut self, id: Uuid) { + match self.daemon.reclaim_worktree(id).await { + Ok(()) => { + self.worktrees.remove(id); + self.state.toast("worktree reclaimed; branch kept"); + } + Err(e) => { + let message = e + .daemon_message() + .map(String::from) + .unwrap_or_else(|| e.to_string()); + self.state.toast(format!("reclaim failed: {message}")); + } + } + } + fn redraw( &mut self, terminal: &mut Terminal>, @@ -713,6 +811,7 @@ impl App { state: &self.state, draft, help: &self.help, + worktrees: Some(&self.worktrees), }; let mut new_region = RegionMap::default(); @@ -857,6 +956,11 @@ enum DraftUpdate { projects: Option>, names: Vec, }, + /// The retained-worktree store arrived for the worktrees view (#85). + WorktreesLoaded { + worktrees: Vec, + home_dir: String, + }, } /// `GET /api/names?count=N`. Free function: the spawned loaders have no `self`. @@ -1509,4 +1613,162 @@ mod tests { app.dispatch(Action::KillHighlighted); assert!(matches!(app.state.overlay(), Some(OverlayKind::KillConfirm(s)) if *s == sid)); } + + // -- retained worktrees (#85) ------------------------------------------- + + fn sample_worktree(id: u128) -> maestro_protocol::RetainedWorktree { + use chrono::TimeZone; + maestro_protocol::RetainedWorktree { + id: Uuid::from_u128(id), + path: "/home/naps/tea/maestro/worktrees/feat/login".into(), + base_repo: "/home/naps/tea/maestro".into(), + branch: "feat/login".into(), + reason: maestro_protocol::RetainedReason::ForceKilled, + retained_at: chrono::Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(), + in_the_way: maestro_protocol::InTheWay { + uncommitted_files: 2, + untracked_files: 1, + unpushed_commits: 3, + }, + } + } + + fn app_with_one_retained_worktree() -> App { + let mut app = make_app(); + app.worktrees.home_dir = "/home/naps".into(); + app.worktrees.apply(vec![sample_worktree(42)]); + app.state.show_overlay(OverlayKind::Worktrees); + app + } + + #[test] + fn show_worktrees_opens_the_view_and_fetches() { + let mut app = make_app(); + assert_eq!( + app.dispatch(Action::ShowWorktrees), + vec![Effect::LoadWorktrees] + ); + assert_eq!(app.state.overlay(), Some(&OverlayKind::Worktrees)); + } + + #[test] + fn r_over_the_highlighted_worktree_asks_for_a_confirmation() { + let mut app = app_with_one_retained_worktree(); + + let (decision, effects) = app.handle_overlay_input(&InputKey::char('r')); + assert_eq!(decision, OverlayDecision::Consumed); + assert!(effects.is_empty()); + match app.state.overlay() { + Some(OverlayKind::ReclaimConfirm { id, branch, .. }) => { + assert_eq!(*id, Uuid::from_u128(42)); + assert_eq!(branch, "feat/login"); + } + other => panic!("expected a reclaim confirm, got {other:?}"), + } + } + + /// The confirmation names what is in the way using the shared phrasing, + /// so the confirm step cannot drift from the list or the CLI. + #[test] + fn the_reclaim_confirmation_carries_the_shared_in_the_way_phrasing() { + let mut app = app_with_one_retained_worktree(); + app.handle_overlay_input(&InputKey::char('r')); + match app.state.overlay() { + Some(OverlayKind::ReclaimConfirm { blockers, .. }) => { + assert_eq!( + blockers, + "2 uncommitted files, 1 untracked file, 3 unpushed commits" + ); + } + other => panic!("expected a reclaim confirm, got {other:?}"), + } + } + + #[test] + fn confirming_a_reclaim_asks_for_the_reclaim() { + let mut app = app_with_one_retained_worktree(); + app.handle_overlay_input(&InputKey::char('r')); + + let (decision, effects) = app.handle_overlay_input(&InputKey::char('y')); + assert_eq!(decision, OverlayDecision::Consumed); + assert_eq!(effects, vec![Effect::ReclaimWorktree(Uuid::from_u128(42))]); + assert!(app.state.overlay().is_none()); + } + + #[test] + fn declining_a_reclaim_returns_to_the_list() { + let mut app = app_with_one_retained_worktree(); + app.handle_overlay_input(&InputKey::char('r')); + + let (decision, effects) = app.handle_overlay_input(&InputKey::char('n')); + assert_eq!(decision, OverlayDecision::Consumed); + assert!(effects.is_empty()); + assert_eq!(app.state.overlay(), Some(&OverlayKind::Worktrees)); + } + + #[test] + fn j_and_k_move_the_highlight_inside_the_view() { + let mut app = make_app(); + app.worktrees + .apply(vec![sample_worktree(1), sample_worktree(2)]); + // Distinct ids so movement is observable. + app.worktrees.items[1].branch = "other".into(); + app.state.show_overlay(OverlayKind::Worktrees); + + app.handle_overlay_input(&InputKey::char('j')); + assert_eq!(app.worktrees.selected().unwrap().branch, "other"); + app.handle_overlay_input(&InputKey::char('k')); + assert_eq!(app.worktrees.selected().unwrap().branch, "feat/login"); + } + + #[tokio::test] + async fn loading_worktrees_fills_the_view_from_the_daemon() { + use axum::routing::{delete, get}; + use axum::Json; + use serde_json::json; + + async fn info() -> Json { + Json(json!({"hostname": "box", "version": "0.1.0", "home_dir": "/home/naps"})) + } + async fn worktrees() -> Json { + Json(json!([{ + "id": Uuid::from_u128(42).to_string(), + "path": "/home/naps/tea/maestro/worktrees/feat/login", + "base_repo": "/home/naps/tea/maestro", + "branch": "feat/login", + "reason": "force-killed", + "retained_at": "2026-08-24T10:00:00Z", + "in_the_way": {"uncommitted_files": 2, "untracked_files": 1, "unpushed_commits": 3}, + }])) + } + async fn reclaim() -> Json { + Json(json!(null)) + } + + let app_router = axum::Router::new() + .route(maestro_protocol::routes::INFO, get(info)) + .route(maestro_protocol::routes::WORKTREES, get(worktrees)) + .route("/api/worktrees/:id", delete(reclaim)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() }); + + let mut app = make_app_at(&base); + let effects = app.dispatch(Action::ShowWorktrees); + app.perform(effects).await; + let update = tokio::time::timeout(Duration::from_secs(5), app.draft_rx.recv()) + .await + .expect("load completed") + .expect("channel open"); + app.apply_draft_update(update).await; + + assert_eq!(app.state.overlay(), Some(&OverlayKind::Worktrees)); + assert_eq!(app.worktrees.items.len(), 1); + assert_eq!(app.worktrees.items[0].branch, "feat/login"); + assert_eq!(app.worktrees.home_dir, "/home/naps"); + + // Reclaiming drops the entry from the open view. + app.submit_reclaim(Uuid::from_u128(42)).await; + assert!(app.worktrees.items.is_empty()); + } } diff --git a/crates/maestro-tui/src/config.rs b/crates/maestro-tui/src/config.rs index 26b0251..9f4eb2b 100644 --- a/crates/maestro-tui/src/config.rs +++ b/crates/maestro-tui/src/config.rs @@ -50,6 +50,7 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[ // thing with every default resolved and no form. ("S", "FastForwardSpawn"), ("x", "KillHighlighted"), + ("w", "ShowWorktrees"), // Tabs ("ctrl-t", "NewTab"), ("g t", "NextTab"), @@ -166,6 +167,10 @@ mod tests { cfg.keymap.resolve(Mode::Normal, "S"), Some(Action::FastForwardSpawn), ); + assert_eq!( + cfg.keymap.resolve(Mode::Normal, "w"), + Some(Action::ShowWorktrees), + ); } #[test] diff --git a/crates/maestro-tui/src/render.rs b/crates/maestro-tui/src/render.rs index 78e4dee..01e3c9b 100644 --- a/crates/maestro-tui/src/render.rs +++ b/crates/maestro-tui/src/render.rs @@ -15,6 +15,7 @@ use crate::pane::Pane; use crate::sidebar::Sidebar; use crate::state::{AppState, Focus, OverlayKind}; use crate::tabs::TabBar; +use crate::worktrees::{ReclaimConfirm, WorktreesView}; /// Tagged regions for mouse hit-testing. The input router resolves a mouse /// coordinate to one of these via [`RegionMap::region_at`]. @@ -67,6 +68,8 @@ pub struct RenderInputs<'a> { pub draft: Option<&'a crate::draft::DraftPane>, pub state: &'a AppState, pub help: &'a HelpOverlay, + /// Retained-worktree entries for the worktrees view (#85). + pub worktrees: Option<&'a WorktreesView>, } /// Draw one frame. Returns the `RegionMap` the input router will consult for @@ -153,6 +156,22 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { inputs.help.render(f, area); map.modal = Some(area); } + Some(OverlayKind::Worktrees) => { + if let Some(view) = inputs.worktrees { + view.render(f, area); + map.modal = Some(area); + } + } + Some(OverlayKind::ReclaimConfirm { + branch, blockers, .. + }) => { + let rc = ReclaimConfirm { + branch: branch.clone(), + blockers: blockers.clone(), + }; + rc.render(f, area); + map.modal = Some(area); + } Some(OverlayKind::Toast(text)) => { let t = Toast { text: text.clone() }; t.render(f, area); @@ -298,6 +317,7 @@ mod tests { draft: Some(draft), state: &state, help: &help, + worktrees: None, }; let backend = ratatui::backend::TestBackend::new(80, 24); diff --git a/crates/maestro-tui/src/state.rs b/crates/maestro-tui/src/state.rs index b77eb92..4e487c8 100644 --- a/crates/maestro-tui/src/state.rs +++ b/crates/maestro-tui/src/state.rs @@ -50,6 +50,17 @@ pub enum OverlayKind { KillConfirm(Uuid), Help, Toast(String), + /// The retained-worktrees view (#85); its entries live on `App` because + /// they are daemon data, not UI state. + Worktrees, + /// Reclaim confirmation for the worktree with this id (#85), guarding + /// the destructive key like [`OverlayKind::KillConfirm`] does. + ReclaimConfirm { + id: Uuid, + branch: String, + /// [`maestro_protocol::InTheWay`] as of when the confirm was opened. + blockers: String, + }, } #[derive(Debug)]