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.
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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<Uuid, (u16, u16)>,
|
||||
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<CrosstermBackend<Stdout>>,
|
||||
@@ -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<Vec<maestro_protocol::Project>>,
|
||||
names: Vec<String>,
|
||||
},
|
||||
/// The retained-worktree store arrived for the worktrees view (#85).
|
||||
WorktreesLoaded {
|
||||
worktrees: Vec<RetainedWorktree>,
|
||||
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<serde_json::Value> {
|
||||
Json(json!({"hostname": "box", "version": "0.1.0", "home_dir": "/home/naps"}))
|
||||
}
|
||||
async fn worktrees() -> Json<serde_json::Value> {
|
||||
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<serde_json::Value> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user