Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f698df9f4 | ||
|
|
8e8540460f | ||
|
|
1984bf9472 | ||
|
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
NameBatch,
|
NameBatch,
|
||||||
Projects,
|
Projects,
|
||||||
RecentDirs,
|
RecentDirs,
|
||||||
|
RetainedWorktree,
|
||||||
Session,
|
Session,
|
||||||
Uuid,
|
Uuid,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
@@ -97,4 +98,22 @@ export async function fetchBranches(path: string): Promise<Branches> {
|
|||||||
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
|
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The daemon's retained worktrees (#80): trees kept after a session ended
|
||||||
|
* with unsaved work, with what is currently in the way. Drives the
|
||||||
|
* retained-worktrees view (#84). */
|
||||||
|
export async function listWorktrees(): Promise<RetainedWorktree[]> {
|
||||||
|
return jsonOrError(await fetch(routes.WORKTREES, { credentials: "same-origin" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reclaim a retained worktree: removes its directory and drops the store
|
||||||
|
* entry. Never deletes the branch (docs/WORKTREES.md). */
|
||||||
|
export async function reclaimWorktree(id: Uuid): Promise<void> {
|
||||||
|
await jsonOrError(
|
||||||
|
await fetch(routes.worktree(id), {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "same-origin",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export { eventsWsUrl } from "./routes.js";
|
export { eventsWsUrl } from "./routes.js";
|
||||||
|
|||||||
@@ -180,6 +180,15 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
|
|||||||
renderStatus();
|
renderStatus();
|
||||||
|
|
||||||
const actions = header.querySelector(".app-actions") as HTMLElement;
|
const actions = header.querySelector(".app-actions") as HTMLElement;
|
||||||
|
// Retained worktrees (#84): a section reachable from the header, not a
|
||||||
|
// sidebar fixture — PRODUCT.md's scan is the product, so this must not
|
||||||
|
// compete with the session catalog for attention.
|
||||||
|
const worktreesLink = document.createElement("a");
|
||||||
|
worktreesLink.className = "btn btn-ghost";
|
||||||
|
worktreesLink.href = "#/worktrees";
|
||||||
|
worktreesLink.textContent = "worktrees";
|
||||||
|
worktreesLink.title = "Retained worktrees";
|
||||||
|
actions.appendChild(worktreesLink);
|
||||||
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
|
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
|
||||||
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
|
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import {
|
|||||||
createSession,
|
createSession,
|
||||||
fetchNames,
|
fetchNames,
|
||||||
getDaemonInfo,
|
getDaemonInfo,
|
||||||
|
listWorktrees,
|
||||||
projects as fetchProjects,
|
projects as fetchProjects,
|
||||||
|
reclaimWorktree,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import {
|
import {
|
||||||
buildFastForwardRequest,
|
buildFastForwardRequest,
|
||||||
@@ -22,6 +24,7 @@ import { loadLayout, saveLayout } from "./persistence.js";
|
|||||||
import { OPEN_SPAWN_EVENT } from "./catalog.js";
|
import { OPEN_SPAWN_EVENT } from "./catalog.js";
|
||||||
import { initTheme } from "./theme.js";
|
import { initTheme } from "./theme.js";
|
||||||
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
|
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
|
||||||
|
import { createWorktreesView, type WorktreesViewHandle } from "./worktreesView.js";
|
||||||
import { installViewportSizing } from "./viewport.js";
|
import { installViewportSizing } from "./viewport.js";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
@@ -345,16 +348,47 @@ document.addEventListener("keydown", (e) => {
|
|||||||
type Route =
|
type Route =
|
||||||
| { kind: "catalog" }
|
| { kind: "catalog" }
|
||||||
| { kind: "attach"; sessionId: string }
|
| { kind: "attach"; sessionId: string }
|
||||||
|
| { kind: "worktrees" }
|
||||||
| { kind: "not-found"; raw: string };
|
| { kind: "not-found"; raw: string };
|
||||||
|
|
||||||
function parseRoute(): Route {
|
function parseRoute(): Route {
|
||||||
const hash = location.hash.replace(/^#/, "");
|
const hash = location.hash.replace(/^#/, "");
|
||||||
if (hash === "" || hash === "/") return { kind: "catalog" };
|
if (hash === "" || hash === "/") return { kind: "catalog" };
|
||||||
|
if (hash === "/worktrees") return { kind: "worktrees" };
|
||||||
const m = hash.match(/^\/attach\/([^/?#]+)$/);
|
const m = hash.match(/^\/attach\/([^/?#]+)$/);
|
||||||
if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) };
|
if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) };
|
||||||
return { kind: "not-found", raw: hash };
|
return { kind: "not-found", raw: hash };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retained worktrees (#84): built lazily on first visit, then kept so a
|
||||||
|
// revisit re-enters instantly. The daemon's home dir abbreviates paths once
|
||||||
|
// `/api/info` answers.
|
||||||
|
let worktreesView: WorktreesViewHandle | undefined;
|
||||||
|
let homeDir: string | undefined;
|
||||||
|
void getDaemonInfo()
|
||||||
|
.then((info) => {
|
||||||
|
homeDir = info.home_dir;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* absolute paths are a fine fallback */
|
||||||
|
});
|
||||||
|
|
||||||
|
function showWorktrees(): void {
|
||||||
|
pool.setVisible([]);
|
||||||
|
pool.setFocus(null);
|
||||||
|
contentShowsMv = false;
|
||||||
|
if (!worktreesView) {
|
||||||
|
worktreesView = createWorktreesView({
|
||||||
|
load: listWorktrees,
|
||||||
|
reclaim: reclaimWorktree,
|
||||||
|
homeDir: () => homeDir,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
content.replaceChildren(worktreesView.el);
|
||||||
|
shellEl.dataset.route = "worktrees";
|
||||||
|
void worktreesView.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
function dispatch(): void {
|
function dispatch(): void {
|
||||||
const route = parseRoute();
|
const route = parseRoute();
|
||||||
switch (route.kind) {
|
switch (route.kind) {
|
||||||
@@ -376,6 +410,9 @@ function dispatch(): void {
|
|||||||
// tears panes down.
|
// tears panes down.
|
||||||
showMultiView();
|
showMultiView();
|
||||||
return;
|
return;
|
||||||
|
case "worktrees":
|
||||||
|
showWorktrees();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const PROJECTS = "/api/projects";
|
|||||||
export const GITROOT = "/api/gitroot";
|
export const GITROOT = "/api/gitroot";
|
||||||
export const NAMES = "/api/names";
|
export const NAMES = "/api/names";
|
||||||
export const BRANCHES = "/api/branches";
|
export const BRANCHES = "/api/branches";
|
||||||
|
export const WORKTREES = "/api/worktrees";
|
||||||
export const EVENTS = "/api/events";
|
export const EVENTS = "/api/events";
|
||||||
|
|
||||||
/** Name of the query parameter `DIRS` and `GITROOT` both take. */
|
/** Name of the query parameter `DIRS` and `GITROOT` both take. */
|
||||||
@@ -29,6 +30,11 @@ export function attach(id: Uuid): string {
|
|||||||
return `${API_PREFIX}/attach/${encodeURIComponent(id)}`;
|
return `${API_PREFIX}/attach/${encodeURIComponent(id)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `DELETE /api/worktrees/:id` — reclaim one retained worktree (#80). */
|
||||||
|
export function worktree(id: Uuid): string {
|
||||||
|
return `${WORKTREES}/${encodeURIComponent(id)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
|
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
|
||||||
export function withPathQuery(route: string, value: string): string {
|
export function withPathQuery(route: string, value: string): string {
|
||||||
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
|
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
|
||||||
|
|||||||
@@ -2225,3 +2225,148 @@ textarea {
|
|||||||
background: oklch(0 0 0 / 0.5);
|
background: oklch(0 0 0 / 0.5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- retained worktrees (#84) ---------- */
|
||||||
|
|
||||||
|
.worktrees {
|
||||||
|
padding: var(--sp-4);
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-head {
|
||||||
|
margin-bottom: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-title {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--fs-15);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink);
|
||||||
|
margin: 0 0 var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-sub {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-2) var(--sp-2);
|
||||||
|
border-bottom: 1px solid var(--surface-2);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-branch {
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-path {
|
||||||
|
color: var(--muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-reason {
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status never rides on color alone: the label itself says what blocks. */
|
||||||
|
.wt-way[data-clear] {
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-way:not([data-clear]) {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-row .wt-reclaim-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-row .wt-reclaim-btn::before {
|
||||||
|
content: "[";
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-row .wt-reclaim-btn::after {
|
||||||
|
content: "]";
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-row .wt-reclaim-btn:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
background: var(--danger-tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
color: var(--danger);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: var(--danger-tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The destructive confirmation, centered over the section. */
|
||||||
|
.wt-confirm {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: var(--z-backdrop);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: oklch(0 0 0 / 0.5);
|
||||||
|
padding: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-confirm[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-confirm-panel {
|
||||||
|
pointer-events: auto;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--surface-2);
|
||||||
|
padding: var(--sp-4);
|
||||||
|
max-width: 480px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-confirm-q {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-confirm-way {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-confirm-note {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-confirm-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: var(--sp-2);
|
||||||
|
}
|
||||||
|
|||||||
@@ -133,6 +133,54 @@ export interface BranchInfo {
|
|||||||
/** `GET /api/branches?path=` — the local branches of one repository (#55). */
|
/** `GET /api/branches?path=` — the local branches of one repository (#55). */
|
||||||
export type Branches = BranchInfo[];
|
export type Branches = BranchInfo[];
|
||||||
|
|
||||||
|
/** Why a retained worktree outlived its session (#80). New values are
|
||||||
|
* additive; unknown reasons render generically. */
|
||||||
|
export type RetainedReason = "force-killed" | "exited-with-unsaved-work";
|
||||||
|
|
||||||
|
/** What currently blocks an unforced kill of a worktree's session (#80),
|
||||||
|
* recomputed by the daemon on every read. All counts default to 0 on the
|
||||||
|
* wire for older daemons. */
|
||||||
|
export interface InTheWay {
|
||||||
|
uncommitted_files?: number;
|
||||||
|
untracked_files?: number;
|
||||||
|
unpushed_commits?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One entry of `GET /api/worktrees` (#80): a retained worktree with its
|
||||||
|
* in-the-way state as of this read. Reclaiming it removes the directory but
|
||||||
|
* never the branch. Mirrors `maestro_protocol::RetainedWorktree`. */
|
||||||
|
export interface RetainedWorktree {
|
||||||
|
id: Uuid;
|
||||||
|
path: string;
|
||||||
|
base_repo: string;
|
||||||
|
branch: string;
|
||||||
|
reason: RetainedReason;
|
||||||
|
retained_at: IsoTimestamp;
|
||||||
|
in_the_way?: InTheWay;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The phrasing `maestro_protocol::InTheWay`'s Display impl uses — shared by
|
||||||
|
* the kill refusal, the TUI and this client, so no two clients can describe
|
||||||
|
* the same tree differently. Zero parts are omitted; all-zero renders
|
||||||
|
* "no unsaved work". */
|
||||||
|
export function formatInTheWay(w: InTheWay): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const push = (n: number | undefined, unit: string): void => {
|
||||||
|
if (!n || n <= 0) return;
|
||||||
|
parts.push(n === 1 ? `1 ${unit}` : `${n} ${unit}s`);
|
||||||
|
};
|
||||||
|
push(w.uncommitted_files, "uncommitted file");
|
||||||
|
push(w.untracked_files, "untracked file");
|
||||||
|
push(w.unpushed_commits, "unpushed commit");
|
||||||
|
return parts.length > 0 ? parts.join(", ") : "no unsaved work";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same reason labels the CLI table and the TUI overlay use; unknown future
|
||||||
|
* reasons render their raw wire value. */
|
||||||
|
export function reasonLabel(reason: RetainedReason): string {
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateSessionRequest {
|
export interface CreateSessionRequest {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
command: string;
|
command: string;
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { createWorktreesView } from "./worktreesView.js";
|
||||||
|
import type { RetainedWorktree } from "./types.js";
|
||||||
|
|
||||||
|
function worktree(overrides: Partial<RetainedWorktree>): RetainedWorktree {
|
||||||
|
return {
|
||||||
|
id: "11111111-2222-3333-4444-555555555555",
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mount a view over a scripted store and return (el, reclaimed-ids). */
|
||||||
|
function mount(items: RetainedWorktree[], homeDir = "/home/naps") {
|
||||||
|
const reclaimed: string[] = [];
|
||||||
|
const view = createWorktreesView({
|
||||||
|
load: async () => items,
|
||||||
|
reclaim: async (id) => {
|
||||||
|
reclaimed.push(id);
|
||||||
|
items.splice(
|
||||||
|
items.findIndex((w) => w.id === id),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
homeDir: () => homeDir,
|
||||||
|
});
|
||||||
|
document.body.appendChild(view.el);
|
||||||
|
return { view, reclaimed };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("retained-worktrees view (#84)", () => {
|
||||||
|
it("lists branch, abbreviated path, reason and what is in the way", async () => {
|
||||||
|
const { view } = mount([
|
||||||
|
worktree({
|
||||||
|
path: "/home/naps/tea/maestro/worktrees/feat/login",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
await view.refresh();
|
||||||
|
const text = view.el.textContent ?? "";
|
||||||
|
expect(text).toContain("feat/login");
|
||||||
|
// Sidebar-style abbreviation of the path against the daemon's home dir,
|
||||||
|
// full path kept in the tooltip.
|
||||||
|
expect(text).toContain("t/m/w/f/login");
|
||||||
|
const pathEl = view.el.querySelector<HTMLElement>(".wt-path")!;
|
||||||
|
expect(pathEl.title).toBe("/home/naps/tea/maestro/worktrees/feat/login");
|
||||||
|
// Same reason labels as `maestro worktrees list` and the TUI overlay.
|
||||||
|
expect(text).toContain("force-killed");
|
||||||
|
// Same phrasing as InTheWay's Display impl — shared with the kill refusal.
|
||||||
|
expect(text).toContain("2 uncommitted files, 1 untracked file, 3 unpushed commits");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says a tree whose work has since been pushed is clear", async () => {
|
||||||
|
const { view } = mount([worktree({ in_the_way: {} })]);
|
||||||
|
await view.refresh();
|
||||||
|
expect(view.el.textContent).toContain("no unsaved work");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an empty store as a normal state, not an error", async () => {
|
||||||
|
const { view } = mount([]);
|
||||||
|
await view.refresh();
|
||||||
|
const text = view.el.textContent ?? "";
|
||||||
|
expect(text).toContain("no retained worktrees");
|
||||||
|
expect(text.toLowerCase()).not.toContain("error");
|
||||||
|
expect(text.toLowerCase()).not.toContain("failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guards reclaim with a confirmation that names what is lost and that the branch survives", async () => {
|
||||||
|
const wt = worktree({ branch: "feat/login" });
|
||||||
|
const { view, reclaimed } = mount([wt]);
|
||||||
|
await view.refresh();
|
||||||
|
|
||||||
|
view.el.querySelector<HTMLButtonElement>(".wt-reclaim-btn")!.click();
|
||||||
|
const confirmText = view.el.querySelector<HTMLElement>(".wt-confirm")!.textContent ?? "";
|
||||||
|
expect(confirmText).toContain("Reclaim worktree feat/login?");
|
||||||
|
// Names what will be lost before it happens…
|
||||||
|
expect(confirmText).toContain(
|
||||||
|
"2 uncommitted files, 1 untracked file, 3 unpushed commits",
|
||||||
|
);
|
||||||
|
// …and says reclaim does not take the commits too.
|
||||||
|
expect(confirmText).toContain("never deleted");
|
||||||
|
|
||||||
|
view.el.querySelector<HTMLButtonElement>(".wt-confirm-cancel")!.click();
|
||||||
|
expect(reclaimed).toEqual([]);
|
||||||
|
|
||||||
|
view.el.querySelector<HTMLButtonElement>(".wt-reclaim-btn")!.click();
|
||||||
|
view.el.querySelector<HTMLButtonElement>(".wt-confirm-go")!.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(reclaimed).toEqual(["11111111-2222-3333-4444-555555555555"]);
|
||||||
|
// The row is gone once the refresh lands.
|
||||||
|
expect(view.el.textContent).toContain("no retained worktrees");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts oldest-retained first, like every other client", async () => {
|
||||||
|
const { view } = mount([
|
||||||
|
worktree({ branch: "newer", retained_at: "2026-08-24T12:00:00Z" }),
|
||||||
|
worktree({ branch: "older", retained_at: "2026-08-24T08:00:00Z" }),
|
||||||
|
]);
|
||||||
|
await view.refresh();
|
||||||
|
const branches = [...view.el.querySelectorAll(".wt-branch")].map((n) => n.textContent);
|
||||||
|
expect(branches).toEqual(["older", "newer"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// Retained-worktrees view (#84): every worktree the daemon is holding that no
|
||||||
|
// session uses, listed from `GET /api/worktrees` with a reclaim action.
|
||||||
|
//
|
||||||
|
// Wording is shared with the CLI (`maestro worktrees list`) and the TUI
|
||||||
|
// overlay: same reason labels, same in-the-way phrasing via
|
||||||
|
// `formatInTheWay` (the mirror of `InTheWay`'s Display impl). Reclaim reads
|
||||||
|
// as what it is — destructive — naming what will be lost before it happens
|
||||||
|
// and saying out loud that the branch survives (docs/WORKTREES.md).
|
||||||
|
|
||||||
|
import { escapeHtml } from "./html.js";
|
||||||
|
import {
|
||||||
|
abbreviateCwd,
|
||||||
|
abbreviateFolderName,
|
||||||
|
formatInTheWay,
|
||||||
|
reasonLabel,
|
||||||
|
type RetainedWorktree,
|
||||||
|
type Uuid,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export interface WorktreesDeps {
|
||||||
|
/** `GET /api/worktrees`. */
|
||||||
|
load(): Promise<RetainedWorktree[]>;
|
||||||
|
/** `DELETE /api/worktrees/:id`. */
|
||||||
|
reclaim(id: Uuid): Promise<void>;
|
||||||
|
/** The daemon's home dir, for `~` abbreviation of paths. */
|
||||||
|
homeDir(): string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreesViewHandle {
|
||||||
|
el: HTMLElement;
|
||||||
|
/** Re-fetch the store and re-render. Safe to call repeatedly. */
|
||||||
|
refresh(): Promise<void>;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Oldest-retained first, matching `maestro worktrees list`'s ordering. */
|
||||||
|
function sorted(items: RetainedWorktree[]): RetainedWorktree[] {
|
||||||
|
return [...items].sort(
|
||||||
|
(a, b) => Date.parse(a.retained_at) - Date.parse(b.retained_at),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorktreesView(deps: WorktreesDeps): WorktreesViewHandle {
|
||||||
|
const el = document.createElement("section");
|
||||||
|
el.className = "worktrees";
|
||||||
|
el.innerHTML = `
|
||||||
|
<header class="wt-head">
|
||||||
|
<h2 class="wt-title">retained worktrees</h2>
|
||||||
|
<p class="wt-sub">
|
||||||
|
Kept when a session ended with unsaved work. Reclaiming removes the
|
||||||
|
directory only; branches are never deleted.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<div class="wt-list"></div>
|
||||||
|
<div class="wt-confirm" role="alertdialog" aria-modal="true" hidden></div>
|
||||||
|
`;
|
||||||
|
const listHost = el.querySelector<HTMLElement>(".wt-list")!;
|
||||||
|
const confirmEl = el.querySelector<HTMLElement>(".wt-confirm")!;
|
||||||
|
|
||||||
|
let pendingId: string | null = null;
|
||||||
|
|
||||||
|
const render = (items: RetainedWorktree[]) => {
|
||||||
|
listHost.replaceChildren();
|
||||||
|
if (items.length === 0) {
|
||||||
|
// A drained store is the normal state — say so plainly, not like
|
||||||
|
// something went wrong.
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "empty";
|
||||||
|
empty.innerHTML = `
|
||||||
|
<div class="empty-glyph" aria-hidden="true">⌂</div>
|
||||||
|
<div class="empty-title">no retained worktrees</div>
|
||||||
|
<p class="empty-hint">Nothing is being held. Worktrees kept after a force-kill or an unsaved exit show up here.</p>`;
|
||||||
|
listHost.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const home = deps.homeDir();
|
||||||
|
for (const w of sorted(items)) {
|
||||||
|
listHost.appendChild(renderRow(w, home));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRow = (w: RetainedWorktree, home: string | undefined): HTMLElement => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "wt-row";
|
||||||
|
const clear = formatInTheWay(w.in_the_way ?? {}) === "no unsaved work";
|
||||||
|
row.innerHTML = `
|
||||||
|
<span class="wt-branch">${escapeHtml(w.branch)}</span>
|
||||||
|
<span class="wt-path" title="${escapeHtml(w.path)}">${abbreviateFolderName(abbreviateCwd(w.path, home))}</span>
|
||||||
|
<span class="wt-reason">${escapeHtml(reasonLabel(w.reason))}</span>
|
||||||
|
<span class="wt-way"${clear ? ' data-clear="true"' : ""}>${escapeHtml(formatInTheWay(w.in_the_way ?? {}))}</span>
|
||||||
|
<button class="btn wt-reclaim-btn" type="button">Reclaim</button>
|
||||||
|
`;
|
||||||
|
row.querySelector(".wt-reclaim-btn")!.addEventListener("click", () => {
|
||||||
|
showConfirm(w);
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showConfirm = (w: RetainedWorktree) => {
|
||||||
|
pendingId = w.id;
|
||||||
|
// Mirrors the TUI's ReclaimConfirm line for line: what goes away, what is
|
||||||
|
// in the way right now, and that the branch survives reclaim.
|
||||||
|
confirmEl.innerHTML = `
|
||||||
|
<div class="wt-confirm-panel">
|
||||||
|
<p class="wt-confirm-q">Reclaim worktree ${escapeHtml(w.branch)}?</p>
|
||||||
|
<p class="wt-confirm-way">Currently in the way: ${escapeHtml(formatInTheWay(w.in_the_way ?? {}))}</p>
|
||||||
|
<p class="wt-confirm-note">The directory is removed; the branch is never deleted.</p>
|
||||||
|
<div class="wt-confirm-actions">
|
||||||
|
<button class="btn btn-danger wt-confirm-go" type="button">Reclaim</button>
|
||||||
|
<button class="btn btn-ghost wt-confirm-cancel" type="button">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
confirmEl.hidden = false;
|
||||||
|
confirmEl.querySelector<HTMLButtonElement>(".wt-confirm-go")!.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideConfirm = () => {
|
||||||
|
pendingId = null;
|
||||||
|
confirmEl.hidden = true;
|
||||||
|
confirmEl.replaceChildren();
|
||||||
|
};
|
||||||
|
|
||||||
|
confirmEl.addEventListener("click", async (e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest(".wt-confirm-cancel")) {
|
||||||
|
hideConfirm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!target.closest(".wt-confirm-go") || !pendingId) return;
|
||||||
|
const id = pendingId;
|
||||||
|
hideConfirm();
|
||||||
|
try {
|
||||||
|
await deps.reclaim(id);
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Failed to reclaim: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && !confirmEl.hidden) hideConfirm();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
try {
|
||||||
|
render(await deps.load());
|
||||||
|
} catch (err) {
|
||||||
|
listHost.replaceChildren();
|
||||||
|
const fail = document.createElement("div");
|
||||||
|
fail.className = "empty";
|
||||||
|
fail.innerHTML = `<div class="empty-title">Couldn't load retained worktrees</div><p class="empty-hint"></p>`;
|
||||||
|
fail.querySelector(".empty-hint")!.textContent = (err as Error).message;
|
||||||
|
listHost.appendChild(fail);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
el,
|
||||||
|
refresh,
|
||||||
|
dispose() {
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
el.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user