Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56c8e35d6f | ||
|
|
0166607064 | ||
|
|
58b4257c92 |
@@ -43,6 +43,8 @@ pub enum Action {
|
|||||||
/// resolved; unresolvable defaults open a pre-filled draft instead.
|
/// resolved; unresolvable defaults open a pre-filled draft instead.
|
||||||
FastForwardSpawn,
|
FastForwardSpawn,
|
||||||
KillHighlighted,
|
KillHighlighted,
|
||||||
|
/// Open the retained-worktrees view (#85).
|
||||||
|
ShowWorktrees,
|
||||||
|
|
||||||
// Tabs
|
// Tabs
|
||||||
NewTab,
|
NewTab,
|
||||||
@@ -84,6 +86,7 @@ impl Action {
|
|||||||
"NewDraftPane" => return Ok(Action::NewDraftPane),
|
"NewDraftPane" => return Ok(Action::NewDraftPane),
|
||||||
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
||||||
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
||||||
|
"ShowWorktrees" => return Ok(Action::ShowWorktrees),
|
||||||
"NewTab" => return Ok(Action::NewTab),
|
"NewTab" => return Ok(Action::NewTab),
|
||||||
"CloseTab" => return Ok(Action::CloseTab),
|
"CloseTab" => return Ok(Action::CloseTab),
|
||||||
"NextTab" => return Ok(Action::NextTab),
|
"NextTab" => return Ok(Action::NextTab),
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ use std::time::Duration;
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use crossterm::event::{Event as CtEvent, EventStream};
|
use crossterm::event::{Event as CtEvent, EventStream};
|
||||||
use futures_util::StreamExt;
|
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::backend::CrosstermBackend;
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -37,6 +39,7 @@ use crate::session_task::{SessionInput, SessionTask};
|
|||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
use crate::state::{AppState, Focus, OverlayKind, PaneRef};
|
use crate::state::{AppState, Focus, OverlayKind, PaneRef};
|
||||||
use crate::tabs::TabBar;
|
use crate::tabs::TabBar;
|
||||||
|
use crate::worktrees::WorktreesView;
|
||||||
use crate::TuiError;
|
use crate::TuiError;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
@@ -55,7 +58,8 @@ pub struct App {
|
|||||||
help: HelpOverlay,
|
help: HelpOverlay,
|
||||||
chord: ChordState,
|
chord: ChordState,
|
||||||
daemon: DaemonClient,
|
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
|
/// These are network calls — `/api/projects` on a cold cache walks all of
|
||||||
/// `$HOME` — and awaiting them inside `perform` froze the whole TUI: no
|
/// `$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:
|
/// Direct HTTP for the one endpoint [`DaemonClient`] doesn't cover yet:
|
||||||
/// `GET /api/names` (#64).
|
/// `GET /api/names` (#64).
|
||||||
http: reqwest::Client,
|
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,
|
region: RegionMap,
|
||||||
last_sent_size: HashMap<Uuid, (u16, u16)>,
|
last_sent_size: HashMap<Uuid, (u16, u16)>,
|
||||||
clicks: ClickTracker,
|
clicks: ClickTracker,
|
||||||
@@ -109,6 +116,11 @@ pub enum Effect {
|
|||||||
},
|
},
|
||||||
/// Fetch the draft target's branches from `/api/branches` (#65).
|
/// Fetch the draft target's branches from `/api/branches` (#65).
|
||||||
LoadBranches(usize),
|
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 {
|
impl App {
|
||||||
@@ -132,6 +144,7 @@ impl App {
|
|||||||
chord: ChordState::new(chord_timeout),
|
chord: ChordState::new(chord_timeout),
|
||||||
daemon,
|
daemon,
|
||||||
http,
|
http,
|
||||||
|
worktrees: WorktreesView::default(),
|
||||||
draft_tx,
|
draft_tx,
|
||||||
draft_rx,
|
draft_rx,
|
||||||
region: RegionMap::default(),
|
region: RegionMap::default(),
|
||||||
@@ -225,6 +238,8 @@ impl App {
|
|||||||
self.spawn_probe_git_root(tab, seq, path)
|
self.spawn_probe_git_root(tab, seq, path)
|
||||||
}
|
}
|
||||||
Effect::LoadBranches(tab) => self.spawn_load_branches(tab),
|
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 } => {
|
Effect::Forward { id, bytes } => {
|
||||||
if let Some(handle) = self.emulators.get(id) {
|
if let Some(handle) = self.emulators.get(id) {
|
||||||
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
||||||
@@ -247,6 +262,44 @@ impl App {
|
|||||||
}
|
}
|
||||||
_ => (OverlayDecision::Consumed, Vec::new()),
|
_ => (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) => {
|
Some(OverlayKind::Help) => {
|
||||||
self.state.dismiss_overlay();
|
self.state.dismiss_overlay();
|
||||||
(OverlayDecision::Consumed, Vec::new())
|
(OverlayDecision::Consumed, Vec::new())
|
||||||
@@ -317,6 +370,10 @@ impl App {
|
|||||||
self.state.show_overlay(OverlayKind::KillConfirm(sid));
|
self.state.show_overlay(OverlayKind::KillConfirm(sid));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Action::ShowWorktrees => {
|
||||||
|
self.state.show_overlay(OverlayKind::Worktrees);
|
||||||
|
effects.push(Effect::LoadWorktrees);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
effects
|
effects
|
||||||
}
|
}
|
||||||
@@ -505,13 +562,22 @@ impl App {
|
|||||||
self.fast_forward_spawn(info, projects, names).await;
|
self.fast_forward_spawn(info, projects, names).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if let DraftUpdate::WorktreesLoaded {
|
||||||
|
worktrees,
|
||||||
|
home_dir,
|
||||||
|
} = update
|
||||||
|
{
|
||||||
|
self.worktrees.apply(worktrees);
|
||||||
|
self.worktrees.home_dir = home_dir;
|
||||||
|
return;
|
||||||
|
}
|
||||||
let want = match &update {
|
let want = match &update {
|
||||||
DraftUpdate::Data { draft, .. }
|
DraftUpdate::Data { draft, .. }
|
||||||
| DraftUpdate::Names { draft, .. }
|
| DraftUpdate::Names { draft, .. }
|
||||||
| DraftUpdate::GitRoot { draft, .. }
|
| DraftUpdate::GitRoot { draft, .. }
|
||||||
| DraftUpdate::Branches { draft, .. } => *draft,
|
| DraftUpdate::Branches { draft, .. } => *draft,
|
||||||
// Handled above; it targets no particular 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,
|
// 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
|
// so an in-flight load addressed by index would be dropped or land on
|
||||||
@@ -566,7 +632,7 @@ impl App {
|
|||||||
DraftUpdate::Branches { cwd, branches, .. } => {
|
DraftUpdate::Branches { cwd, branches, .. } => {
|
||||||
draft.set_branches(&cwd, branches);
|
draft.set_branches(&cwd, branches);
|
||||||
}
|
}
|
||||||
DraftUpdate::FastForward { .. } => unreachable!(),
|
DraftUpdate::FastForward { .. } | DraftUpdate::WorktreesLoaded { .. } => unreachable!(),
|
||||||
}
|
}
|
||||||
if follow_up_branches {
|
if follow_up_branches {
|
||||||
self.spawn_load_branches(tab_idx);
|
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(
|
fn redraw(
|
||||||
&mut self,
|
&mut self,
|
||||||
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||||
@@ -713,6 +811,7 @@ impl App {
|
|||||||
state: &self.state,
|
state: &self.state,
|
||||||
draft,
|
draft,
|
||||||
help: &self.help,
|
help: &self.help,
|
||||||
|
worktrees: Some(&self.worktrees),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut new_region = RegionMap::default();
|
let mut new_region = RegionMap::default();
|
||||||
@@ -857,6 +956,11 @@ enum DraftUpdate {
|
|||||||
projects: Option<Vec<maestro_protocol::Project>>,
|
projects: Option<Vec<maestro_protocol::Project>>,
|
||||||
names: Vec<String>,
|
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`.
|
/// `GET /api/names?count=N`. Free function: the spawned loaders have no `self`.
|
||||||
@@ -1509,4 +1613,162 @@ mod tests {
|
|||||||
app.dispatch(Action::KillHighlighted);
|
app.dispatch(Action::KillHighlighted);
|
||||||
assert!(matches!(app.state.overlay(), Some(OverlayKind::KillConfirm(s)) if *s == sid));
|
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.
|
// thing with every default resolved and no form.
|
||||||
("S", "FastForwardSpawn"),
|
("S", "FastForwardSpawn"),
|
||||||
("x", "KillHighlighted"),
|
("x", "KillHighlighted"),
|
||||||
|
("w", "ShowWorktrees"),
|
||||||
// Tabs
|
// Tabs
|
||||||
("ctrl-t", "NewTab"),
|
("ctrl-t", "NewTab"),
|
||||||
("g t", "NextTab"),
|
("g t", "NextTab"),
|
||||||
@@ -166,6 +167,10 @@ mod tests {
|
|||||||
cfg.keymap.resolve(Mode::Normal, "S"),
|
cfg.keymap.resolve(Mode::Normal, "S"),
|
||||||
Some(Action::FastForwardSpawn),
|
Some(Action::FastForwardSpawn),
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.keymap.resolve(Mode::Normal, "w"),
|
||||||
|
Some(Action::ShowWorktrees),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1186,7 +1186,7 @@ fn draw_text_field(
|
|||||||
/// `~` abbreviation plus leading-segment collapsing, matching the web UI's
|
/// `~` abbreviation plus leading-segment collapsing, matching the web UI's
|
||||||
/// `abbreviateCwd` + `abbreviateFolderName`: `/home/u/tea/Maestro` renders as
|
/// `abbreviateCwd` + `abbreviateFolderName`: `/home/u/tea/Maestro` renders as
|
||||||
/// `/h/u/t/Maestro` given home `/home/u`.
|
/// `/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 raw = path.to_string_lossy();
|
||||||
let abbreviated = if !home.is_empty() {
|
let abbreviated = if !home.is_empty() {
|
||||||
if raw == home {
|
if raw == home {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub mod state;
|
|||||||
pub mod tabs;
|
pub mod tabs;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
pub mod text_input;
|
pub mod text_input;
|
||||||
|
pub mod worktrees;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|||||||
@@ -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 w = w.min(parent.width);
|
||||||
let h = h.min(parent.height);
|
let h = h.min(parent.height);
|
||||||
Rect {
|
Rect {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use crate::pane::Pane;
|
|||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
use crate::state::{AppState, Focus, OverlayKind};
|
use crate::state::{AppState, Focus, OverlayKind};
|
||||||
use crate::tabs::TabBar;
|
use crate::tabs::TabBar;
|
||||||
|
use crate::worktrees::{ReclaimConfirm, WorktreesView};
|
||||||
|
|
||||||
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
|
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
|
||||||
/// coordinate to one of these via [`RegionMap::region_at`].
|
/// 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 draft: Option<&'a crate::draft::DraftPane>,
|
||||||
pub state: &'a AppState,
|
pub state: &'a AppState,
|
||||||
pub help: &'a HelpOverlay,
|
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
|
/// 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);
|
inputs.help.render(f, area);
|
||||||
map.modal = Some(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)) => {
|
Some(OverlayKind::Toast(text)) => {
|
||||||
let t = Toast { text: text.clone() };
|
let t = Toast { text: text.clone() };
|
||||||
t.render(f, area);
|
t.render(f, area);
|
||||||
@@ -298,6 +317,7 @@ mod tests {
|
|||||||
draft: Some(draft),
|
draft: Some(draft),
|
||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
|
worktrees: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
|
|||||||
@@ -50,6 +50,17 @@ pub enum OverlayKind {
|
|||||||
KillConfirm(Uuid),
|
KillConfirm(Uuid),
|
||||||
Help,
|
Help,
|
||||||
Toast(String),
|
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)]
|
#[derive(Debug)]
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user