Compare commits

..
Author SHA1 Message Date
Miguel Palhas a575b8bef1 feat(maestro): refuse a kill when the worktree holds unsaved work (#79)
The guard moves off the worktree and onto the kill. DELETE
/api/sessions/:id now runs the shared unsaved_work probe before tearing
anything down and answers 409 kill_guarded with the breakdown on the
wire, so a client can say "3 uncommitted files, 2 unpushed commits".
The session keeps running and the tree is untouched.

WorktreeManager::remove is unconditional now — git itself still refuses
a dirty tree as a last line of defense.
2026-08-24 11:03:43 +01:00
Miguel Palhas 898c3cc763 feat(maestro): shared unsaved-work probe for worktrees (#79)
One function, unsaved_work(), returns the structured breakdown —
uncommitted tracked changes, untracked non-ignored files, unpushed
commits — so the kill guard and the natural-exit classifier (#82)
answer from the same place. Ignored files never count: every Rust
checkout has a target/, and counting those would leave no session
killable.

WorktreeManager::remove drops its own dirty-tree guard; git itself
still refuses a dirty tree, and the real guard moves onto the kill.
2026-08-24 10:59:08 +01:00
Miguel Palhas 50a9d35f62 merge: never adopt, sidestep collisions with a suffix
Closes #77
2026-08-24 10:41:17 +01:00
Miguel Palhas a9ef3ff97b merge: retained-worktree store
Closes #78
2026-08-24 10:39:08 +01:00
Miguel Palhas 0457a61a61 refactor(maestro): sidestep worktree path collisions with a numeric suffix
Anything occupying <repo>/worktrees/<branch> — one of ours, one the
operator made, an unrelated directory — is now bypassed by appending
-2, -3, ... to the directory; the branch stays as requested. The same
suffix applies under the [daemon].worktree_root override.

The daemon no longer asks whether an occupant is "ours": delete
registered_for(), the adoption branch in run_add and the
SessionManager::create live-session check, plus WorktreeError::PathExists.
A branch already checked out elsewhere is git's refusal to surface,
already marked by the branch picker's is_head/worktree flags.
2026-08-24 10:38:32 +01:00
Miguel Palhas 132b606830 feat(maestro): retained-worktree store in state_dir (#78)
Per-entry JSON files under <state_dir>/retained-worktrees/, written like
sidecar records: atomic tmp+rename, missing/malformed files tolerated on
read. Entries carry path, base repo, branch, reason (force-killed,
exited-with-unsaved-work), timestamp; they leave on reclaim or when
prune_missing notices the directory was removed by hand.
2026-08-24 10:35:26 +01:00
Miguel Palhas 35c75f9238 docs(maestro): write the worktree lifecycle contract
Closes #89
2026-08-24 10:29:42 +01:00
Miguel Palhas bbbc24a4aa fix(maestro): fifth-review findings, incl. the guarded-worktree dead end
The cleanup guard plus deterministic paths had created a trap: one
untracked file an agent left behind — a log, a .env, a stray artifact —
made the guard refuse removal, the directory stayed, and every later
spawn of that branch hit PathExists. The branch was burned permanently
and the product offered no way to clear it. A leftover tree registered
for the same branch is now adopted, so the operator lands back in their
own unsaved work. A tree a *live* session holds is still rejected, since
two sessions must not share a checkout.

Also:

- Fast-forward still awaited /api/info, /api/projects and /api/names
  inline, the exact freeze the draft loaders were restructured to avoid.
  It posts to the same channel now.
- Draft updates were routed by tab index, which shifts when a tab closes:
  an in-flight load could be dropped or land on the wrong pane. Drafts
  carry an id and are found by it.
- extend_names left the name it had just displayed at the head of the
  queue, so the next reroll produced it again and the key looked dead.
- The fast-forward fallback discarded info.home_dir, so its draft showed
  unabbreviated paths and seeded "Other directory…" with / — disagreeing
  with the normal draft entry point.
- Web fast-forward mounted via openSession, which replaces the focused
  pane: pressing it while attached evicted that session from the layout.
  It reuses an empty pane or splits, as the TUI does.
- branchEdited was inferred from the seeded branch value, but in "new"
  mode that value is the auto-synced session name — so any form rebuild
  froze the branch against later rerolls.
- resolveBaseRepo ignored the worktree checkbox, rebuilding the fieldset
  expanded under an unticked box and probing gitroot for a worktree the
  operator had turned off.
- short_hash used DefaultHasher, whose algorithm is not stable across
  Rust releases, in a directory name — a toolchain bump would orphan
  every worktree under the override root. Now FNV-1a.
2026-08-24 00:28:50 +01:00
Miguel Palhas e9368bb242 fix(maestro): fourth-review findings across daemon, tui and web
The existing-branch picker could never succeed in either client: both
treated the main checkout's HEAD as available, and since that is usually
`main` it became the default pick — git then refuses the second checkout.
A TUI test had baked the bug in by asserting the selection lands on the
`is_head` entry. Both pickers now treat "checked out anywhere" as held.

Worktree paths were still not injective. Flattening `/` and `.` to `-`
made `feat/x`, `feat.x` and `feat-x` share one directory, and the
override root keyed on the repo basename alone, so `~/a/maestro` and
`~/b/maestro` collided. A branch name is already a valid relative path —
git stores it as refs/heads/feat/example — so it is used verbatim and
nests under `worktrees/feat/`; the override root gains a digest of the
full repo path. Git forbids holding both `feat` and `feat/x`, so the
nesting cannot collide for valid refs.

Also:

- Recovered sessions dropped their worktree association, so after a
  restart a kill cleaned up nothing and the leftover directory blocked
  that branch forever. It is persisted on Session now.
- The nesting guard compared git's resolved paths against an unresolved
  one, so it silently passed on any symlinked repo path — and that guard
  is what stands between a kill and an inner tree's uncommitted work.
- Draft loads ran on the TUI event loop: /api/projects on a cold cache
  walks all of $HOME, freezing keys, frames and attached panes for up to
  the request timeout. They are detached tasks posting to a channel now.
- Fast-forward in the web client created the session and never mounted
  it, so `N` looked like a dead key while the TUI attached.
- The fast-forward fallback seed forced the worktree toggle off, against
  both a normal draft and the TUI.
- The projects background refresh honoured the TTL, so two of every three
  ticks were no-ops and requests paid for the rescan instead.
2026-08-24 00:10:35 +01:00
Miguel Palhas 918609574e merge: fast-forward spawn in both clients (#67) 2026-08-23 23:45:18 +01:00
Miguel Palhas bcb365f278 merge: blitz/spawn-v2 into spawn-v2/67-fastforward 2026-08-23 23:39:42 +01:00
Miguel Palhas 586c243056 feat(maestro): bind N to fast-forward spawn in the web client (#67) 2026-08-23 23:39:30 +01:00
Miguel Palhas 1647e64aed feat(maestro): fast-forward spawn resolution for the web client (#67) 2026-08-23 23:37:57 +01:00
Miguel Palhas 5aedaf838a feat(maestro): bind S to fast-forward spawn in the TUI (#67) 2026-08-23 23:03:46 +01:00
Miguel Palhas d922cdaa32 feat(maestro): fast-forward spawn default resolution (#67) 2026-08-23 22:59:26 +01:00
Miguel Palhas b4e57d1a4f merge: spawn-v2 e2e coverage (#66) 2026-08-23 22:58:43 +01:00
Miguel Palhas 74967e45b4 Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/66-e2e 2026-08-23 22:56:01 +01:00
Miguel Palhas 7a2cc30bf9 test(maestro): cover spawn-v2 discovery, worktrees and cleanup e2e (#66) 2026-08-23 22:55:52 +01:00
Miguel Palhas 8ed44e785d test(maestro): add branch/project clients and config seeding to e2e harness 2026-08-23 22:55:52 +01:00
Miguel Palhas 63da845614 merge: tui draft worktree section (#65) 2026-08-23 22:49:57 +01:00
Miguel Palhas d336bd802d Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/65-tui-worktree 2026-08-23 22:45:58 +01:00
Miguel Palhas 67d17f48d0 feat(maestro): app loop serves the draft's worktree fetches (#65)
Two new effects: ProbeGitRoot resolves the draft target's base repo via
/api/gitroot (seq-guarded, a miss unticks the toggle) and LoadBranches
fills the existing-branch picker from /api/branches, both applied back
to the tab that requested them. Branch loads triggered by a successful
probe are served inline so perform() stays recursion-free.
2026-08-23 22:45:49 +01:00
Miguel Palhas ec45312400 merge: reuse draft forms across renders (#68) 2026-08-23 22:45:20 +01:00
Miguel Palhas 525dc9a04a fix(maestro): reuse draft forms across renders (#68)
render() destroyed and rebuilt every draft form, so an unrelated
session exiting tore down a draft mid-edit: focus, caret and
selection were lost and /api/info, /api/projects, /api/names were
refetched for a form already on screen.

Key draft forms by leaf id; rebuild only the pane DOM around them
and destroy a form when its leaf is removed or filled by a session.
2026-08-23 22:43:42 +01:00
Miguel Palhas 264332266c feat(maestro): tui draft form gains a worktree section (#65)
Worktree on by default over a git repository with Enter/Space to spawn
in the checkout itself; base repo inferred from picked projects or a
guarded /api/gitroot probe for raw paths (a miss unticks, web parity);
new-branch name tracking the generated session name until touched;
Left/Right switches to picking an existing branch from GET /api/branches,
with held branches skipped and rendered dimmed beside their holder.
No cleanup option: cleanup is unconditional daemon-side (#56).
2026-08-23 22:42:46 +01:00
Miguel Palhas 37338def71 merge: delete the spawn modal, amend DESIGN.md (#62) 2026-08-23 22:38:53 +01:00
Miguel Palhas 51f91b3f5d docs(maestro): amend DESIGN.md for draft panes (#62)
The spawn modal is no longer one of the three surfaces: the Components
section now describes the draft pane, and the stale ≥600px
bottom-sheet→dialog breakpoint note is dropped.
2026-08-23 22:36:51 +01:00
Miguel Palhas 5c3f9cab9f feat(maestro): replace spawn modal with draft panes (#62)
The draft pane covers every field the modal had; the modal was a second
code path for the same job. The header Spawn button, sidebar empty state,
welcome button, and ?mock&spawn now open a draft pane via
MultiView.openDraft — reusing an empty focused pane, else splitting it.
"Spawn similar" seeds a draft with the session's cwd + command.

Modal-only CSS (backdrop, sheet/dialog chrome, keyframes) is deleted;
.modal-body form styling folds into .spawn-form, which also picks up the
select styling the worktree branch picker was borrowing from the modal.
2026-08-23 22:36:48 +01:00
Miguel Palhas 117f371c41 merge: tui draft form presets, project picker, names, validation (#64) 2026-08-23 22:02:17 +01:00
Miguel Palhas ad690ab2fa feat(maestro): tui draft form gains presets, project picker, names, validation (#64) 2026-08-23 22:01:53 +01:00
Miguel Palhas 7dcb87cd6e feat(maestro): project-list helpers for the tui draft picker (#64) 2026-08-23 21:46:54 +01:00
Miguel Palhas 26b0fa5e8a merge: worktree on by default, new-or-existing branch picker (#61) 2026-08-23 21:36:40 +01:00
Miguel Palhas 8b3c5d73b6 fix(maestro): third-review findings — nested worktrees, staleness, placement
The one that loses data: the project picker lists linked worktrees as
pickable rows, so `base_repo` was easily a worktree rather than the main
checkout, and the new placement then nested the tree inside it. Git hides
a registered worktree's contents from the outer `git status`, so the
outer tree read clean and removing it deleted the inner one's uncommitted
work — while that session could still be running. Placement now resolves
to the main checkout via `--git-common-dir`, and removal refuses outright
when `worktree list` reports anything registered underneath.

Also:

- The `worktree_root` override dropped the old UUID without replacing it,
  so two repos spawning the same branch name targeted one directory and
  the second failed. The repo basename now sits between root and slug.
- Project staleness read `.git/HEAD`'s mtime, which only moves on
  checkout. A repo committed to daily from one long-lived branch reported
  its last branch switch and got dimmed. Prefer `logs/HEAD`.
- The projects cache TTL (5s) was shorter than its own background refresh
  (60s), so nearly every request rescanned all of $HOME under the scanner
  lock — and draft panes re-request on unrelated re-renders. Now 180s.
- onSuccess resolved the leaf against the active tab only, so switching
  tabs mid-POST orphaned the session. Searches every tab.
2026-08-23 21:36:29 +01:00
Miguel Palhas ca4f7cb1d5 feat(maestro): worktree on by default with new-or-existing branch picker (#61)
- toggle defaults on for any target the project scan reports (repo or
  worktree); only the free-text escape hatch pays for /api/gitroot
- branch mode control switches between creating a new branch (named
  after the session name, as before) and picking an existing one from
  GET /api/branches; branches held by another worktree stay visible but
  disabled, labelled with that worktree's path
- emits create_branch: false on the wire for existing-mode picks
- branch-list load follows the form's ready/destroyed discipline so a
  late response cannot corrupt a rebuilt draft
2026-08-23 21:34:35 +01:00
Miguel Palhas cc1b3d45b8 merge: maestro-names crate and GET /api/names (#53)
# Conflicts:
#	crates/maestro-daemon/src/local_api.rs
#	crates/maestro-protocol/src/routes.rs
2026-08-23 21:20:08 +01:00
Miguel Palhas f7a8dcae76 merge: GET /api/branches with worktree occupancy (#55) 2026-08-23 21:17:45 +01:00
Miguel Palhas 818e0cc2db feat(maestro): spawn form rerolls from a fetched name batch (#53) 2026-08-23 21:15:33 +01:00
Miguel Palhas 23f01233b2 merge: blitz/spawn-v2 into spawn-v2/55-branches 2026-08-23 21:14:29 +01:00
Miguel Palhas 2a84e07f7d feat(maestro): GET /api/branches — local branches and worktree occupancy (#55) 2026-08-23 21:14:16 +01:00
Miguel Palhas a970ba89c2 feat(maestro): GET /api/names serves name batches (#53) 2026-08-23 21:13:46 +01:00
Miguel Palhas 17796c1ffa feat(maestro): maestro-names crate with musical wordlists (#53) 2026-08-23 21:11:06 +01:00
Miguel Palhas 01105f9424 merge: project list replaces the recents picker (#59) 2026-08-23 21:07:14 +01:00
Miguel Palhas b9829bc56f feat(maestro): project list replaces recents picker in spawn form (#59) 2026-08-23 21:06:10 +01:00
Miguel Palhas 9f582263b8 feat(maestro): project list sort/group/filter helpers (#59) 2026-08-23 21:02:16 +01:00
Miguel Palhas b5cbf94fa8 merge: project discovery scanner and GET /api/projects (#54) 2026-08-23 20:54:55 +01:00
Miguel Palhas 8376122dfe fix(maestro): second-review findings across daemon, tui and web
Ten findings from reviewing the accumulated blitz/spawn-v2 diff. The two
that could wedge a repo:

- The cleanup guard refused any worktree whose commits were on no other
  ref, which is every ordinary unpushed branch. Paths are deterministic
  now, so the leftover tree blocked the next spawn and each branch became
  a one-shot. `worktree remove` keeps refs/heads/<branch>, so committed
  work is never at risk: guard on an unclean working tree alone.
- A worktree whose directory had vanished failed the git probe and was
  reported as unsaved work, stranding the registration forever. Prune it
  instead, and prune before `worktree add` tests the target path.

Also:

- The CLI hardcoded create_branch: true, so the daemon's existing-branch
  path was unreachable. Added --existing-branch.
- Pastes were forwarded without their DECSET 2004 markers, so a remote
  shell ran a multi-line paste line by line.
- spawnForm emitted before its seed was restored and after destroy(), so
  a rebuilt draft blanked its cwd and a dead form clobbered the live one.
- showRawCwd and the probe's worktree untick never reached the leaf.
- A late autocomplete fetch reopened the popup under a blurred input.
- The "remove worktree when killed" checkbox promised something the
  daemon no longer honours; removed.
2026-08-23 20:54:21 +01:00
Miguel Palhas ec51b0221c merge: dir autocomplete dismissal and key handling (#60) 2026-08-23 20:39:53 +01:00
Miguel Palhas ca585447ff fix(maestro): dir autocomplete dismissal and key handling
Escape closes the popup, refocuses the input and stops propagation so it
can't also discard a surrounding draft pane. Tab commits the highlighted
entry and lets focus move to the next field. Blur / clicking outside
dismisses, and a dismissed popup stays closed until the operator types
again.
2026-08-23 20:38:55 +01:00
Miguel Palhas 71301861c0 chore(maestro): add happy-dom for DOM-level web tests 2026-08-23 20:38:55 +01:00
Miguel Palhas 2ce1dc43fe merge: tui inline draft pane + paste forwarding fix (#63) 2026-08-23 20:31:18 +01:00
Miguel Palhas e99e21260b Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/63-tui-draft-pane 2026-08-23 20:26:26 +01:00
Miguel Palhas cea06f9204 fix(maestro): forward bracketed pastes into the attached session
forward_to_session dropped Paste(_) on the floor, so pastes into a
session vanished once terminal.rs enabled bracketed paste. Split the
arm: Paste forwards the payload verbatim (the remote readline
reassembles it), only Mouse events have no wire encoding.
2026-08-23 20:25:57 +01:00
Miguel Palhas 1199596528 feat(maestro): inline draft pane replaces the SpawnModal overlay
Action::OpenSpawnModal becomes NewDraftPane: pressing s turns the empty
pane into an inline draft form (or opens a new tab when the pane shows a
session). The form is a pane-level widget with its own field focus; a
successful submit replaces the draft with the new session in place,
matching the web client's draft panes. SpawnModal and OverlayKind::Spawn
are removed; kill-confirm and help overlays stay.
2026-08-23 20:25:46 +01:00
Miguel Palhas 95f0e42df5 feat(maestro): add draft-pane widget with its own field focus 2026-08-23 20:16:43 +01:00
75 changed files with 8143 additions and 977 deletions
+4
View File
@@ -62,6 +62,10 @@ nix/ # Home Manager module (`services.maestro`)
See `docs/INSTALL.md` for end-user install instructions.
`docs/WORKTREES.md` is the contract for session worktrees — placement,
collisions, what blocks a kill, what `--force` retains, and what reclaim
touches. Read it before changing anything that creates or removes one.
`nix run .#dev` (or `just dev`) brings up the whole local stack under
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
Generated
+9
View File
@@ -1102,6 +1102,7 @@ dependencies = [
"futures-util",
"libc",
"maestro-config",
"maestro-names",
"maestro-protocol",
"reqwest",
"serde",
@@ -1146,6 +1147,13 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "maestro-names"
version = "0.1.0"
dependencies = [
"rand 0.8.6",
]
[[package]]
name = "maestro-protocol"
version = "0.1.0"
@@ -1185,6 +1193,7 @@ dependencies = [
"maestro-protocol",
"maestro-testing",
"ratatui",
"reqwest",
"serde_json",
"thiserror 1.0.69",
"tokio",
+1
View File
@@ -18,6 +18,7 @@ dirs = "5"
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
libc = "0.2"
maestro-config = { path = "crates/maestro-config" }
maestro-names = { path = "crates/maestro-names" }
maestro-protocol = { path = "crates/maestro-protocol" }
maestro-testing = { path = "crates/maestro-testing" }
maestro-tui = { path = "crates/maestro-tui" }
+8 -7
View File
@@ -79,16 +79,15 @@ no bounce. Tokens `--dur-fast`/`--dur`/`--dur-slow`.
`connecting` state pill pulses.
- Interaction: row hover/focus, button press (1px), input focus ring, chevron
rotation, theme-toggle sun/moon swap.
- Modal: bottom-sheet slide-up on mobile, scale-pop on ≥600px.
- Every animation has a `prefers-reduced-motion: reduce` path (pulses off,
transitions near-instant).
## Layout & structure
Mobile-first, structural responsiveness (not fluid type). Single `≤520px`
breakpoint stacks the header (full-width search, wrapped actions). `≥600px`
switches the spawn modal from bottom-sheet to centered dialog. A faint slate grid texture sits on `--bg` as
terminal atmosphere (very low contrast, not decoration).
breakpoint stacks the header (full-width search, wrapped actions). A faint
slate grid texture sits on `--bg` as terminal atmosphere (very low contrast,
not decoration).
## Components
@@ -101,8 +100,10 @@ Three surfaces, all DOM built in TS, styled by class:
no-sessions, no-search-match.
- **Attach** — header (back, `session <id>`, live/closed/error state pill) over
a framed dark xterm with a green cursor.
- **Spawn modal** — bottom-sheet/dialog form; mono inputs with green focus
ring; grouped worktree fieldset.
- **Draft pane** — the spawn form mounted inline in an empty split pane,
focusable like any session pane; mono inputs with green focus ring; grouped
worktree fieldset. Spawn opens one (reusing an empty focused pane, else
splitting), and "Spawn similar" seeds one from a session's dir + command.
Interactive elements ship default / hover / focus-visible / active / disabled
states; the accent green is used only for action, selection, focus, and live
@@ -111,5 +112,5 @@ status — never decoration.
## Dev review aids (DEV-only, stripped from production)
`web/src/mock.ts` + query params seed a representative catalog without a running
daemon: `?mock` (seed sessions), `?mock&spawn` (open the spawn modal),
daemon: `?mock` (seed sessions), `?mock&spawn` (open a draft pane),
`?theme=light|dark` (pin a theme). Guarded by `import.meta.env.DEV`.
+30
View File
@@ -67,6 +67,11 @@ pub struct SpawnArgs {
/// Branch name for the worktree (required with --worktree-from).
#[arg(long)]
pub branch: Option<String>,
/// Check out an existing branch instead of creating one. Without it the
/// daemon runs `worktree add -b`, which fails if the branch is already
/// there.
#[arg(long)]
pub existing_branch: bool,
/// Remove the worktree when the session is killed. Kept for wire
/// compatibility (#56): the daemon now always cleans up on kill, guarded
/// against unsaved work.
@@ -170,6 +175,31 @@ mod tests {
}
}
/// Review fix: without a flag the CLI always sent `create_branch: true`,
/// so the daemon's existing-branch path was unreachable and spawning onto
/// a branch that already existed just failed.
#[test]
fn parses_spawn_onto_an_existing_branch() {
let cli = Cli::try_parse_from([
"maestro",
"spawn",
"--cwd",
"/tmp",
"--command",
"bash",
"--worktree-from",
"/repo",
"--branch",
"feat/existing",
"--existing-branch",
])
.unwrap();
let Command::Spawn(a) = cli.command else {
panic!("expected spawn")
};
assert!(a.existing_branch);
}
#[test]
fn spawn_worktree_requires_branch() {
let err = Cli::try_parse_from([
+2 -1
View File
@@ -44,7 +44,7 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
base_repo: repo.clone(),
branch: branch.clone(),
cleanup_on_kill: args.cleanup_on_kill,
create_branch: true,
create_branch: !args.existing_branch,
}),
_ => None,
};
@@ -153,6 +153,7 @@ mod tests {
command: command.into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
status: SessionStatus::Running,
created_at: Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap(),
foreground: None,
+2
View File
@@ -18,6 +18,7 @@ dirs.workspace = true
futures-util.workspace = true
libc.workspace = true
maestro-config.workspace = true
maestro-names.workspace = true
maestro-protocol.workspace = true
serde.workspace = true
serde_json.workspace = true
@@ -32,5 +33,6 @@ reqwest.workspace = true
tokio-tungstenite.workspace = true
urlencoding = "2"
tempfile.workspace = true
maestro-names.workspace = true
# `test-util` enables `start_paused` for instant timeout tests (NAP-270).
tokio = { workspace = true, features = ["test-util"] }
+167
View File
@@ -0,0 +1,167 @@
//! `GET /api/branches` — local branches of one repository (#55).
//!
//! Spawning into a worktree on an *existing* branch needs to know which
//! branches the repository has and which are taken: git refuses to check the
//! same branch out in two worktrees at once. Each [`BranchInfo`] answers for
//! one local branch — its name, whether it is the main checkout's HEAD, and
//! which linked worktree (if any) holds it.
//!
//! Read-only, like [`crate::gitroot`]: two git probes (`for-each-ref`,
//! `worktree list --porcelain`) with no write path anywhere. A path outside
//! any repository yields an empty list rather than an error, matching how
//! `/api/gitroot` reports "not a repository".
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use axum::extract::{Query, State};
use axum::response::{IntoResponse, Response};
use axum::Json;
use maestro_protocol::BranchInfo;
use serde::Deserialize;
use crate::local_api::LocalApiState;
/// Query string for `GET /api/branches`. Same `path` convention as
/// `/api/dirs` and `/api/gitroot`: resolved against the daemon's home dir.
#[derive(Debug, Deserialize)]
pub struct BranchesQuery {
#[serde(default)]
pub path: String,
}
pub async fn handler(State(s): State<LocalApiState>, Query(q): Query<BranchesQuery>) -> Response {
let dir = crate::dirlist::resolve(&s.home_dir, &q.path);
Json(list(&dir)).into_response()
}
/// List the enclosing repository's local branches. Empty when `dir` is not
/// inside a repository.
pub fn list(dir: &Path) -> Vec<BranchInfo> {
let Some(root) = crate::gitroot::find_root(dir) else {
return Vec::new();
};
let worktrees = worktree_branches(&root);
// The first porcelain record is always the main checkout; its branch is
// the HEAD answer. Linked worktrees are the occupancy map.
let head_branch = worktrees.first().and_then(|(branch, _)| branch.clone());
let occupied: HashMap<&str, &Path> = worktrees
.iter()
.skip(1)
.filter_map(|(branch, path)| branch.as_deref().map(|b| (b, path.as_path())))
.collect();
branch_names(&root)
.into_iter()
.map(|name| BranchInfo {
is_head: head_branch.as_deref() == Some(name.as_str()),
worktree: occupied.get(name.as_str()).copied().map(Path::to_path_buf),
name,
})
.collect()
}
fn run_git(root: &Path, args: &[&str]) -> Option<String> {
let out = Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn branch_names(root: &Path) -> Vec<String> {
run_git(
root,
&["for-each-ref", "refs/heads", "--format=%(refname:short)"],
)
.map(|out| {
out.lines()
.filter(|l| !l.is_empty())
.map(str::to_owned)
.collect()
})
.unwrap_or_default()
}
fn worktree_branches(root: &Path) -> Vec<(Option<String>, PathBuf)> {
let out = match run_git(root, &["worktree", "list", "--porcelain"]) {
Some(out) => out,
None => return Vec::new(),
};
parse_worktrees(&out)
}
/// Parse `git worktree list --porcelain` into `(branch, toplevel)` records,
/// branch-less for detached/bare checkouts. Records are separated by blank
/// lines; each `worktree` line starts a new one.
fn parse_worktrees(out: &str) -> Vec<(Option<String>, PathBuf)> {
let mut result: Vec<(Option<String>, PathBuf)> = Vec::new();
let mut pending: Option<PathBuf> = None;
let mut branch: Option<String> = None;
for line in out.lines() {
if let Some(path) = line.strip_prefix("worktree ") {
if let Some(prev) = pending.take() {
result.push((branch.take(), prev));
}
pending = Some(PathBuf::from(path));
} else if let Some(b) = line.strip_prefix("branch ") {
branch = Some(b.trim_start_matches("refs/heads/").to_owned());
}
}
if let Some(path) = pending {
result.push((branch.take(), path));
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_main_and_linked_worktrees() {
let out = "\
worktree /repos/maestro
HEAD 0123abcd
branch refs/heads/main
worktree /repos/wt-feature
HEAD 4567abcd
branch refs/heads/spawn-v2
";
assert_eq!(
parse_worktrees(out),
vec![
(Some("main".into()), PathBuf::from("/repos/maestro")),
(Some("spawn-v2".into()), PathBuf::from("/repos/wt-feature")),
]
);
}
#[test]
fn detached_head_yields_no_branch() {
let out = "\
worktree /repos/detached
HEAD 0123abcd
detached
";
assert_eq!(
parse_worktrees(out),
vec![(None, PathBuf::from("/repos/detached"))]
);
}
#[test]
fn empty_output_yields_no_records() {
assert!(parse_worktrees("").is_empty());
}
}
+5
View File
@@ -103,6 +103,11 @@ impl DaemonConfig {
pub fn spawn_history_path(&self) -> PathBuf {
self.state_dir.join("spawn-history.jsonl")
}
/// Retained-worktree store directory (#78): `<state_dir>/retained-worktrees/`.
pub fn retained_worktree_dir(&self) -> PathBuf {
self.state_dir.join("retained-worktrees")
}
}
/// Env-var overrides applied by [`load_config`]. Tests pass explicit
+1
View File
@@ -111,6 +111,7 @@ fn io_error(dir: &Path, e: &std::io::Error) -> ErrorInfo {
ErrorInfo {
code: code.into(),
message: format!("{message}: {}", dir.display()),
unsaved_work: None,
}
}
+2
View File
@@ -9,6 +9,7 @@
pub mod activity;
pub mod attach;
pub mod branches;
pub mod cli;
pub mod config;
pub mod dirlist;
@@ -18,6 +19,7 @@ pub mod panes;
pub mod proc;
pub mod projects;
pub mod pty;
pub mod retained_store;
pub mod session;
pub mod sidecar;
pub mod spawn_history;
+27 -6
View File
@@ -19,7 +19,7 @@ use axum::routing::{any, delete, get};
use axum::{Json, Router};
use maestro_config::SpawnPreset;
use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, Session,
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch, Session,
PROTOCOL_VERSION,
};
use serde::Deserialize;
@@ -48,6 +48,8 @@ pub fn router(state: LocalApiState) -> Router {
.route(routes::RECENT_DIRS, get(recent_dirs))
.route(routes::PROJECTS, get(projects))
.route(routes::GITROOT, get(git_root))
.route(routes::BRANCHES, get(crate::branches::handler))
.route(routes::NAMES, get(names))
.route(routes::INFO, get(daemon_info))
.route(routes::EVENTS, any(events_ws))
.route(routes::patterns::ATTACH, any(attach_ws))
@@ -92,6 +94,8 @@ fn session_error_status(code: &str) -> StatusCode {
match code {
"not_found" => StatusCode::NOT_FOUND,
"invalid_env" => StatusCode::BAD_REQUEST,
// A guard refusal is the daemon doing its job, not a failure (#79).
"kill_guarded" => StatusCode::CONFLICT,
_ => StatusCode::BAD_GATEWAY,
}
}
@@ -121,6 +125,22 @@ async fn git_root(State(s): State<LocalApiState>, Query(q): Query<DirQuery>) ->
.into_response()
}
/// Query string for `GET /api/names`. `count` defaults to 1 and is capped at
/// [`maestro_names::MAX_BATCH`] (#53).
#[derive(Debug, Deserialize)]
struct NamesQuery {
count: Option<usize>,
}
/// Fresh session handles for the spawn form's instant reroll (#53). The web
/// client fetches one batch per draft pane and rerolls locally from it.
async fn names(Query(q): Query<NamesQuery>) -> Json<NameBatch> {
let count = q.count.unwrap_or(1).clamp(1, maestro_names::MAX_BATCH);
Json(NameBatch {
names: maestro_names::generate_batch(count),
})
}
async fn daemon_info(State(s): State<LocalApiState>) -> Json<DaemonInfo> {
Json(DaemonInfo {
hostname: (*s.hostname).clone(),
@@ -150,16 +170,17 @@ async fn create_session(
/// `kill` already reports a missing session as `SessionError::NotFound`, which
/// maps to 404. Checking first would only add a window in which the liveness
/// watcher reaps the session between the two awaits, turning a 404 into a 502.
///
/// The full error info is passed through rather than rebuilt from
/// code + message: a `kill_guarded` refusal (#79) carries the unsaved-work
/// breakdown clients render as "3 uncommitted files, 2 unpushed commits".
async fn kill_session(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
match s.manager.kill(id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => {
let info = e.to_error_info();
error_response(
session_error_status(&info.code),
info.code.as_str(),
info.message.as_str(),
)
let status = session_error_status(&info.code);
(status, Json(ApiError { error: info })).into_response()
}
}
}
+33 -11
View File
@@ -29,9 +29,14 @@ pub const DEFAULT_HOME_IGNORE: &[&str] =
&["node_modules", "Downloads", "Library", "go", "snap", "opt"];
/// How long a discovery snapshot is served as-is before the next request
/// triggers a rescan. Short: a scan is a bounded filesystem walk plus one
/// `git worktree list` per hit.
pub const CACHE_TTL: Duration = Duration::from_secs(5);
/// triggers a rescan.
///
/// A scan walks the roots and runs one `git worktree list` per hit, holding
/// the scanner lock throughout, and clients re-request whenever a draft pane
/// re-mounts — which `multiview.render()` does on unrelated events. Keep this
/// comfortably longer than [`REFRESH_INTERVAL`] so the background timer is
/// what refreshes, and a request only rescans when that timer has failed to.
pub const CACHE_TTL: Duration = Duration::from_secs(180);
/// Background refresh cadence. Repositories appear rarely; this keeps the
/// served list eventually fresh without anyone asking.
pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
@@ -154,9 +159,15 @@ fn list_worktrees(repo: &Path) -> Option<Vec<PathBuf>> {
(!paths.is_empty()).then_some(paths)
}
/// Mtime of the checkout's HEAD file. For a linked worktree `.git` is a file
/// holding a `gitdir:` pointer; resolve it so worktrees report their own HEAD
/// rather than nothing.
/// Mtime of the checkout's reflog, falling back to `HEAD` itself.
///
/// `HEAD` is only rewritten on checkout — committing updates
/// `refs/heads/<branch>` and appends to `logs/HEAD`, leaving `HEAD` alone. A
/// repo worked on daily from one long-lived branch would otherwise report its
/// last *branch switch* as its activity and get dimmed as stale (#54 review).
///
/// For a linked worktree `.git` is a file holding a `gitdir:` pointer;
/// resolve it so worktrees report their own reflog rather than nothing.
fn head_mtime(repo: &Path) -> Option<DateTime<Utc>> {
let dot_git = repo.join(".git");
let head = if dot_git.is_dir() {
@@ -173,6 +184,9 @@ fn head_mtime(repo: &Path) -> Option<DateTime<Utc>> {
};
p.join("HEAD")
};
// Prefer the reflog next to whichever HEAD we resolved.
let reflog = head.with_file_name("logs").join("HEAD");
let head = if reflog.exists() { reflog } else { head };
let mtime = std::fs::metadata(head).ok()?.modified().ok()?;
Some(DateTime::<Utc>::from(mtime))
}
@@ -248,10 +262,16 @@ impl ProjectScanner {
/// Cached snapshot, rescanning (off-thread) when the TTL has lapsed.
pub async fn repos(&self) -> std::sync::Arc<Vec<DiscoveredRepo>> {
self.repos_inner(false).await
}
async fn repos_inner(&self, force: bool) -> std::sync::Arc<Vec<DiscoveredRepo>> {
let mut cache = self.cache.lock().await;
if let Some((at, repos)) = cache.as_ref() {
if at.elapsed() < self.ttl {
return repos.clone();
if !force {
if let Some((at, repos)) = cache.as_ref() {
if at.elapsed() < self.ttl {
return repos.clone();
}
}
}
let roots = self.roots.clone();
@@ -267,9 +287,11 @@ impl ProjectScanner {
repos
}
/// Rescan now, ignoring the TTL. Called by the background timer.
/// Rescan now, ignoring the TTL. Called by the background timer — going
/// through `repos()` would hit the cache and make most ticks no-ops,
/// leaving requests to pay for the rescan instead (#54 review).
async fn refresh(&self) {
self.repos().await;
self.repos_inner(true).await;
}
/// Spawn the slow background refresh task. Drop the returned handle to
+319
View File
@@ -0,0 +1,319 @@
//! Store of retained worktrees (#78): worktrees the daemon created whose
//! session is gone.
//!
//! One JSON file per entry under `<state_dir>/retained-worktrees/`, written
//! the way `sidecar.rs` writes session records — atomic tmp+rename replace,
//! missing or malformed files tolerated on read. The store survives a daemon
//! restart; it is the recovery path for everything else in the worktree
//! lifecycle.
//!
//! It is not an ownership record: it must never be consulted to decide whether
//! a path may be adopted. Its only job is to remember what the daemon is
//! holding so it can be listed and reclaimed.
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
pub enum RetainedStoreError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("json: {0}")]
Json(#[from] serde_json::Error),
}
/// Why a worktree outlived its session. Kebab-case on the wire; new variants
/// are additive but clients should render unknown reasons generically.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RetainedReason {
/// The session was killed with `--force` while its worktree held unsaved
/// work.
ForceKilled,
/// The session ended by itself and its worktree held unsaved work.
ExitedWithUnsavedWork,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct RetainedWorktree {
pub id: Uuid,
pub path: PathBuf,
pub base_repo: PathBuf,
pub branch: String,
pub reason: RetainedReason,
pub retained_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct RetainedStore {
dir: PathBuf,
}
impl RetainedStore {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self { dir: dir.into() }
}
pub fn dir(&self) -> &Path {
&self.dir
}
fn path_for(&self, id: Uuid) -> PathBuf {
self.dir.join(format!("{id}.json"))
}
/// Record a retained worktree and return the entry that was written.
pub fn retain(
&self,
path: &Path,
base_repo: &Path,
branch: &str,
reason: RetainedReason,
) -> Result<RetainedWorktree, RetainedStoreError> {
let entry = RetainedWorktree {
id: Uuid::new_v4(),
path: path.to_path_buf(),
base_repo: base_repo.to_path_buf(),
branch: branch.to_string(),
reason,
retained_at: Utc::now(),
};
self.overwrite(&entry)?;
Ok(entry)
}
/// Atomically write `entry`. Creates the directory if it does not exist.
pub fn overwrite(&self, entry: &RetainedWorktree) -> Result<(), RetainedStoreError> {
std::fs::create_dir_all(&self.dir)?;
let final_path = self.path_for(entry.id);
let tmp = self.dir.join(format!("{}.json.tmp", entry.id));
let json = serde_json::to_vec_pretty(entry)?;
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, &final_path)?;
Ok(())
}
/// Drop one entry. No-op when the id is unknown — reclaiming an entry the
/// store already lost (e.g. pruned) must not fail.
pub fn remove(&self, id: Uuid) -> Result<(), RetainedStoreError> {
match std::fs::remove_file(self.path_for(id)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
/// Read every entry. Files that are missing or malformed are skipped
/// silently: a corrupt record must not hide the others, and an empty or
/// absent directory simply means nothing is retained.
pub fn load_all(&self) -> Result<Vec<RetainedWorktree>, RetainedStoreError> {
let mut out = Vec::new();
let entries = match std::fs::read_dir(&self.dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
Err(e) => return Err(e.into()),
};
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
if let Ok(worktree) = serde_json::from_slice::<RetainedWorktree>(&bytes) {
out.push(worktree);
}
}
out.sort_by_key(|w| w.retained_at);
Ok(out)
}
/// Drop entries whose worktree directory no longer exists — somebody
/// removed it by hand. Returns the ids that were pruned.
pub fn prune_missing(&self) -> Result<Vec<Uuid>, RetainedStoreError> {
let mut pruned = Vec::new();
for entry in self.load_all()? {
if !entry.path.exists() {
self.remove(entry.id)?;
pruned.push(entry.id);
}
}
Ok(pruned)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
use std::path::PathBuf;
fn tmp_dir() -> tempfile::TempDir {
tempfile::tempdir().expect("tempdir")
}
fn sample_path(root: &std::path::Path, name: &str) -> PathBuf {
let p = root.join(name);
std::fs::create_dir_all(&p).expect("mkdir");
p
}
#[test]
fn retain_then_load_round_trips_every_field() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path());
let wt = sample_path(dir.path(), "wt");
let repo = sample_path(dir.path(), "repo");
let entry = store
.retain(&wt, &repo, "feature/login", RetainedReason::ForceKilled)
.expect("retain");
let all = store.load_all().expect("load_all");
assert_eq!(all, vec![entry.clone()]);
assert_eq!(entry.path, wt);
assert_eq!(entry.base_repo, repo);
assert_eq!(entry.branch, "feature/login");
assert_eq!(entry.reason, RetainedReason::ForceKilled);
let age = Utc::now() - entry.retained_at;
assert!(age >= Duration::zero() && age < Duration::minutes(1));
}
#[test]
fn reason_serializes_kebab_case() {
let json =
serde_json::to_string(&RetainedReason::ExitedWithUnsavedWork).expect("serialize");
assert_eq!(json, r#""exited-with-unsaved-work""#);
}
#[test]
fn entries_survive_a_restart() {
let dir = tmp_dir();
let wt = sample_path(dir.path(), "wt");
let first = RetainedStore::new(dir.path());
let entry = first
.retain(&wt, dir.path(), "main", RetainedReason::ForceKilled)
.expect("retain");
// A fresh store over the same state_dir — what a daemon restart sees.
let second = RetainedStore::new(dir.path());
assert_eq!(second.load_all().expect("load_all"), vec![entry]);
}
#[test]
fn missing_store_reads_as_empty() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path().join("does-not-exist"));
assert_eq!(store.load_all().expect("load_all"), Vec::new());
}
#[test]
fn malformed_entries_are_skipped_silently() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path());
let wt = sample_path(dir.path(), "wt");
store
.retain(&wt, dir.path(), "kept", RetainedReason::ForceKilled)
.expect("retain");
std::fs::write(dir.path().join("not-a-uuid.json"), b"{oops").expect("write junk");
let all = store.load_all().expect("load_all");
assert_eq!(all.len(), 1);
assert_eq!(all[0].branch, "kept");
}
#[test]
fn ids_are_unique() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path());
let a = store
.retain(dir.path(), dir.path(), "a", RetainedReason::ForceKilled)
.expect("retain");
let b = store
.retain(dir.path(), dir.path(), "b", RetainedReason::ForceKilled)
.expect("retain");
assert_ne!(a.id, b.id);
}
#[test]
fn remove_drops_the_entry() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path());
let wt = sample_path(dir.path(), "wt");
let entry = store
.retain(&wt, dir.path(), "main", RetainedReason::ForceKilled)
.expect("retain");
store.remove(entry.id).expect("remove");
assert_eq!(store.load_all().expect("load_all"), Vec::new());
// Removing an unknown id is a no-op, not an error.
store.remove(entry.id).expect("remove again");
}
#[test]
fn load_all_is_sorted_by_time() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path());
let mut first = store
.retain(dir.path(), dir.path(), "first", RetainedReason::ForceKilled)
.expect("retain");
first.retained_at -= Duration::hours(1);
store.overwrite(&first).expect("rewrite");
let second = store
.retain(
dir.path(),
dir.path(),
"second",
RetainedReason::ForceKilled,
)
.expect("retain");
let branches: Vec<_> = store
.load_all()
.expect("load_all")
.into_iter()
.map(|e| e.branch)
.collect();
assert_eq!(branches, vec!["first", "second"]);
let _ = second;
}
#[test]
fn prune_missing_drops_gone_directories_only() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path());
let alive = sample_path(dir.path(), "alive");
let gone = sample_path(dir.path(), "gone");
let keep = store
.retain(&alive, dir.path(), "keep", RetainedReason::ForceKilled)
.expect("retain");
let drop = store
.retain(
&gone,
dir.path(),
"drop",
RetainedReason::ExitedWithUnsavedWork,
)
.expect("retain");
std::fs::remove_dir_all(&gone).expect("rm");
let pruned = store.prune_missing().expect("prune_missing");
assert_eq!(pruned, vec![drop.id]);
let branches: Vec<_> = store
.load_all()
.expect("load_all")
.into_iter()
.map(|e| e.branch)
.collect();
assert_eq!(branches, vec!["keep"]);
let _ = keep;
}
}
+63 -12
View File
@@ -38,8 +38,8 @@ pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
pub struct SessionRecord {
pub session: Session,
pub fifo_path: PathBuf,
/// Worktree we created at spawn time; removed on kill unless the
/// dirty-work guard refuses (#56).
/// Worktree we created at spawn time; removed on kill, but only after the
/// kill guard (#79) found nothing worth keeping in it.
pub worktree_path: Option<PathBuf>,
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
/// worktree remove` is invoked from the base repo.
@@ -61,6 +61,11 @@ pub enum SessionError {
NotFound(Uuid),
#[error("invalid environment variable name: {0:?}")]
InvalidEnv(String),
/// The kill was refused (#79): the session's worktree holds unsaved work.
/// Nothing happened — the session keeps running and the tree is untouched,
/// so it stays the operator's handle on their own unfinished work.
#[error("kill refused: the session's worktree holds unsaved work ({0})")]
KillGuarded(maestro_protocol::UnsavedWork),
}
impl SessionError {
@@ -72,10 +77,16 @@ impl SessionError {
SessionError::Io(_) => "io_failed",
SessionError::NotFound(_) => "not_found",
SessionError::InvalidEnv(_) => "invalid_env",
SessionError::KillGuarded(_) => "kill_guarded",
};
let unsaved_work = match self {
SessionError::KillGuarded(b) => Some(*b),
_ => None,
};
ErrorInfo {
code: code.into(),
message: self.to_string(),
unsaved_work,
}
}
}
@@ -226,6 +237,9 @@ impl SessionManager {
let WorktreeSpec {
base_repo, branch, ..
} = spec;
// A path collision is sidestepped inside `run_add` (#77), and
// a branch already checked out elsewhere is git's refusal,
// not ours.
let path = if spec.create_branch {
self.inner.worktree_mgr.add_new(base_repo, branch)
} else {
@@ -290,6 +304,12 @@ impl SessionManager {
command: req.command,
env: req.env,
metadata: req.metadata,
worktree: worktree_path.as_ref().zip(worktree_base_repo.as_ref()).map(
|(path, base_repo)| maestro_protocol::SessionWorktree {
path: path.clone(),
base_repo: base_repo.clone(),
},
),
status: SessionStatus::Running,
created_at: Utc::now(),
// A freshly-spawned session is producing its initial output; the
@@ -361,20 +381,48 @@ impl SessionManager {
}
}
/// Kill a session and remove its on-disk artifacts. The worktree is
/// always pruned -- unless it holds uncommitted changes or commits not on
/// any other ref, in which case the guard leaves it on disk (#56).
/// Kill a session and remove its on-disk artifacts.
///
/// Refused (#79) when the session's worktree holds unsaved work —
/// uncommitted tracked changes, untracked non-ignored files, or commits
/// reachable from no remote-tracking ref. Nothing is killed and nothing
/// is removed; the error names what blocked it and how much of it there
/// is. The session stays as the operator's handle on their own unfinished
/// work, so ending a session that still matters becomes a deliberate
/// `--force`, which retains rather than destroys (WORKTREES.md).
///
/// This is also how an already-dead session is dismissed. tmux is only
/// asked to kill something it still has — calling `kill-session` on a
/// session tmux already lost fails, and that failure is not interesting.
pub async fn kill(&self, id: Uuid) -> Result<(), SessionError> {
let (worktree, alive) = {
let sessions = self.inner.sessions.lock().await;
match sessions.get(&id) {
None => return Err(SessionError::NotFound(id)),
Some(record) => (
record
.worktree_base_repo
.clone()
.zip(record.worktree_path.clone()),
record.session.status == SessionStatus::Running,
),
}
};
// The guard runs before anything is torn down: refusing must leave the
// session running, exactly as the operator left it.
if let Some((_, path)) = worktree {
let probe = tmux_blocking(move || crate::worktree::unsaved_work(&path))
.await
.map_err(SessionError::Worktree)?;
if !probe.is_empty() {
return Err(SessionError::KillGuarded(probe));
}
}
let removed = {
let mut sessions = self.inner.sessions.lock().await;
sessions.remove(&id)
};
let record = removed.ok_or(SessionError::NotFound(id))?;
let alive = record.session.status == SessionStatus::Running;
cleanup_record(&self.inner, id, record, alive).await;
Ok(())
}
@@ -430,6 +478,7 @@ impl SessionManager {
command: String::new(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
status: SessionStatus::Running,
created_at: Utc::now(),
// Activity is unknown on adoption; the watcher fills it in.
@@ -457,8 +506,11 @@ impl SessionManager {
SessionRecord {
session: session.clone(),
fifo_path,
worktree_path: None,
worktree_base_repo: None,
// Restored from the sidecar: without it the recovered
// session cleans up nothing on kill, and the leftover
// directory blocks that branch forever (#54 review).
worktree_path: session.worktree.as_ref().map(|w| w.path.clone()),
worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()),
bridge,
},
);
@@ -707,10 +759,9 @@ async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kil
if let Err(e) = inner.sidecar.delete(id) {
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
}
// Cleanup is unconditional now (#56): `cleanup_on_kill` is no longer a
// user-facing choice. The manager's guard is what protects unsaved work --
// when it refuses, leave the tree on disk and say where it is so the
// operator can find it.
// The kill already passed the unsaved-work guard (#79), so this removal
// is unconditional; the manager's own nested-worktree refusal is all that
// can still leave a tree on disk, and it says where.
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
if let Err(e) = inner.worktree_mgr.remove(base, path) {
tracing::warn!(
+207 -83
View File
@@ -10,11 +10,9 @@ pub enum WorktreeError {
Spawn(#[from] std::io::Error),
#[error("git exited with code {code}: {stderr}")]
NonZero { code: i32, stderr: String },
/// A worktree already occupies the target path. The caller decides what
/// to do; the daemon never relocates to a different directory (#56).
#[error("worktree path already exists: {}", .path.display())]
PathExists { path: PathBuf },
/// The worktree holds unsaved work; removal was refused (#56).
/// A registered worktree lives under the tree about to be removed; git
/// hides its contents from `git status`, so removal would delete its
/// uncommitted work unseen (#54 review).
#[error("left {} on disk: {}", .path.display(), .reason)]
Guarded { path: PathBuf, reason: String },
}
@@ -39,17 +37,48 @@ impl WorktreeManager {
}
/// Where a worktree for `branch` will be created: the override root, or
/// `<base_repo>/worktrees/<sanitized branch>` by default. The path is
/// predictable (no UUID suffix), so an existing directory at the target
/// is an error rather than a silently different location.
/// `<main checkout>/worktrees/<sanitized branch>` by default. If the path
/// is occupied, [`Self::add_new`] / [`Self::add_existing`] sidestep to
/// `<path>-2`, `<path>-3`, ... instead.
///
/// `base_repo` is whatever the client picked, which the project list makes
/// easy to point at a *linked worktree* — placing the new tree inside it
/// would nest one worktree in another, and git hides a registered
/// worktree's contents from the outer `git status`, so the outer tree
/// would later look clean and its removal would delete the inner one's
/// uncommitted work (#54 review). Always place against the main checkout.
///
/// The override root gets the repo's basename too: without it two repos
/// spawning the same branch name target one directory.
fn target_path(&self, base_repo: &Path, branch: &str) -> PathBuf {
let slug = sanitize_branch_for_path(branch);
// An unusable branch name falls back to a digest rather than escaping
// the root; `run_add` surfaces git's own error for it anyway.
let slug = branch_relpath(branch).unwrap_or_else(|| PathBuf::from(short_hash(branch)));
let main = main_checkout(base_repo);
match &self.root_override {
Some(root) => root.join(slug),
None => base_repo.join("worktrees").join(slug),
Some(root) => {
// Basename alone collides for two checkouts of the same name
// (`~/a/maestro` and `~/b/maestro`), so disambiguate with a
// digest of the full path (#54 review).
let base = main
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "repo".into());
let repo = format!("{base}-{}", short_hash(&main.to_string_lossy()));
root.join(repo).join(slug)
}
None => main.join("worktrees").join(slug),
}
}
/// [`Self::target_path`] with `-n` appended to the final component, the
/// collision suffix (#77).
fn target_path_suffixed(&self, base_repo: &Path, branch: &str, n: u32) -> PathBuf {
let mut name = self.target_path(base_repo, branch).into_os_string();
name.push(format!("-{n}"));
PathBuf::from(name)
}
/// `git -C <base_repo> worktree add -b <branch> <path>` — creates the
/// branch. Returns the path of the new worktree on success.
pub fn add_new(&self, base_repo: &Path, branch: &str) -> Result<PathBuf, WorktreeError> {
@@ -68,9 +97,18 @@ impl WorktreeManager {
branch: &str,
create_branch: bool,
) -> Result<PathBuf, WorktreeError> {
let path = self.target_path(base_repo, branch);
if path.symlink_metadata().is_ok() {
return Err(WorktreeError::PathExists { path });
// Clear registrations whose directory is gone first, so a
// previously-leaked entry doesn't push this branch to a suffix.
prune(base_repo)?;
// Anything occupying the target — a tree we left behind, one the
// operator made, an unrelated directory — is sidestepped with a
// numeric suffix on the directory; the branch stays whatever was
// requested (#77). The daemon never adopts what it finds.
let mut path = self.target_path(base_repo, branch);
let mut n = 1;
while path.symlink_metadata().is_ok() {
n += 1;
path = self.target_path_suffixed(base_repo, branch, n);
}
// The parent (`<repo>/worktrees` or the override root) may not exist
// yet; the target itself must not.
@@ -90,17 +128,26 @@ impl WorktreeManager {
Ok(path)
}
/// `git -C <base_repo> worktree remove <path>` — but only when the
/// worktree holds nothing unique (#56). A worktree with uncommitted
/// changes, or with commits reachable from no other ref, is refused and
/// left on disk so the operator can rescue it; cleanup is unconditional,
/// which makes this guard the only thing standing between a stray kill
/// and destroyed work.
/// `git -C <base_repo> worktree remove <path>`.
///
/// Unconditional (#79): the guard moved off the worktree and onto the
/// kill, so by the time removal runs the caller has already chosen to
/// destroy the tree — the kill only gets here after
/// [`unsaved_work`] came back empty. Still refused for a tree holding a
/// nested registered worktree, which `git status` cannot see.
pub fn remove(&self, base_repo: &Path, path: &Path) -> Result<(), WorktreeError> {
if let Some(reason) = unsafe_to_remove(path)? {
// Gone from disk already (operator `rm -rf`, or a create that failed
// half-way). Probing it with git would fail and be misread as unsaved
// work, stranding the registration — and since paths are
// deterministic, `worktree add` would then refuse that branch forever
// with "missing but already registered" (#56 review).
if !path.exists() {
return prune(base_repo);
}
if let Some(inner) = nested_worktree(path) {
return Err(WorktreeError::Guarded {
path: path.to_path_buf(),
reason,
reason: format!("holds a nested worktree at {}", inner.display()),
});
}
let out = Command::new("git")
@@ -113,74 +160,151 @@ impl WorktreeManager {
}
}
fn sanitize_branch_for_path(branch: &str) -> String {
let mut out = String::with_capacity(branch.len());
for c in branch.chars() {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
out.push(c);
} else if c == '/' || c == '.' {
out.push('-');
/// The repository's main checkout. `--git-common-dir` points at the shared
/// `.git` for every linked worktree, so its parent is the main tree. Falls
/// back to the input when git can't answer.
fn main_checkout(path: &Path) -> PathBuf {
let out = Command::new("git")
.arg("-C")
.arg(path)
.args(["rev-parse", "--path-format=absolute", "--git-common-dir"])
.output();
let Ok(out) = out else {
return path.to_path_buf();
};
if !out.status.success() {
return path.to_path_buf();
}
let common = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim().to_string());
common
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| path.to_path_buf())
}
/// `Some(inner)` when a registered worktree lives under `path`. Git omits
/// those directories from `git status`, so the dirty-work guard cannot see
/// them and removal would delete them (#54 review).
fn nested_worktree(path: &Path) -> Option<PathBuf> {
let out = Command::new("git")
.arg("-C")
.arg(path)
.args(["worktree", "list", "--porcelain"])
.output()
.ok()?;
// `worktree list` prints fully-resolved paths while ours comes from
// `--git-common-dir` and may still hold symlinks. Comparing the two forms
// directly makes the guard silently pass on any symlinked repo path — and
// this guard is what stands between a kill and deleting an inner
// worktree's uncommitted work (#54 review).
let needle = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let text = String::from_utf8_lossy(&out.stdout);
text.lines()
.filter_map(|l| l.strip_prefix("worktree "))
.map(PathBuf::from)
.find(|p| {
let real = p.canonicalize().unwrap_or_else(|_| p.clone());
real != needle && real.starts_with(&needle)
})
}
/// Short digest used to disambiguate override roots.
///
/// Hand-rolled FNV-1a rather than `DefaultHasher`: this value becomes a
/// directory name, and `DefaultHasher`'s algorithm is explicitly not
/// guaranteed stable across Rust releases — a toolchain bump would remap every
/// repo to a new directory and orphan the worktrees under the old one
/// (#67 review).
fn short_hash(s: &str) -> String {
let mut h: u32 = 0x811c_9dc5;
for b in s.as_bytes() {
h ^= u32::from(*b);
h = h.wrapping_mul(0x0100_0193);
}
format!("{h:08x}")
}
/// The relative directory a branch's worktree lives in.
///
/// A git branch name is already a valid relative path — git stores it as
/// `.git/refs/heads/feat/example` — so it is used verbatim and `feat/example`
/// nests under `worktrees/feat/`. That keeps the path readable *and* keeps the
/// mapping injective: flattening `/` and `.` to `-` made `feat/x`, `feat.x`
/// and `feat-x` share one directory (#54 review).
///
/// Git already forbids holding both `feat` and `feat/x`, so the directory
/// collision that nesting could cause cannot arise from valid refs. Only
/// traversal and absolute components are rejected.
fn branch_relpath(branch: &str) -> Option<PathBuf> {
use std::path::Component;
let candidate = Path::new(branch);
if branch.is_empty()
|| !candidate
.components()
.all(|c| matches!(c, Component::Normal(_)))
{
return None;
}
Some(candidate.to_path_buf())
}
/// The unsaved-work probe (#79): what would be lost if `path` were deleted.
///
/// One function, three answers, shared by every caller that must make the
/// same decision — the kill guard refuses on any non-empty result and the
/// natural-exit classifier (#82) retains on it — so the two paths cannot
/// drift. A worktree whose directory is already gone holds nothing to save.
pub fn unsaved_work(path: &Path) -> Result<maestro_protocol::UnsavedWork, WorktreeError> {
if std::fs::symlink_metadata(path).is_err() {
return Ok(maestro_protocol::UnsavedWork::default());
}
// `--ignored=no` keeps build output out of it; ignored files never block
// (#79), or no Rust checkout would ever be killable.
let out = run_git(path, &["status", "--porcelain", "--ignored=no"])?;
let status = String::from_utf8_lossy(&out.stdout);
let mut dirty = 0u32;
let mut untracked = 0u32;
for line in status.lines() {
if line.starts_with("??") {
untracked += 1;
} else {
dirty += 1;
}
}
if out.is_empty() {
out.push_str("wt");
}
out
}
/// `Some(reason)` when deleting `path` would destroy work: uncommitted
/// changes, or commits reachable from no ref other than the worktree's own
/// branch (`git worktree remove` keeps the branch, but nothing checks it out
/// anymore — treat its commits as at risk anyway, per #56).
fn unsafe_to_remove(path: &Path) -> Result<Option<String>, WorktreeError> {
let status = run_git(path, ["status", "--porcelain"])?;
if !status.stdout.is_empty() {
return Ok(Some("has uncommitted changes".into()));
}
let head = String::from_utf8_lossy(&run_git(path, ["rev-parse", "HEAD"])?.stdout)
.trim()
.to_string();
let branch =
String::from_utf8_lossy(&run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"])?.stdout)
.trim()
.to_string();
// Every ref except this worktree's own branch counts as a safe home for
// its commits. Detached HEAD excludes everything.
let refs =
String::from_utf8_lossy(&run_git(path, ["for-each-ref", "--format=%(refname)"])?.stdout)
.lines()
.filter(|r| match branch.as_str() {
"HEAD" => true,
b => *r != format!("refs/heads/{b}"),
})
.map(str::to_string)
.collect::<Vec<_>>();
let mut cmd = Command::new("git");
cmd.arg("-C").arg(path).args(["rev-list", "--count", &head]);
if !refs.is_empty() {
cmd.arg("--not").args(&refs);
}
let out = cmd.output()?;
check(out.clone())?;
let count: usize = String::from_utf8_lossy(&out.stdout)
// Detached HEAD has no branch; the tip itself still works as a starting
// point for the reachability query below.
let head = run_git(path, &["rev-parse", "--abbrev-ref", "HEAD"])?;
let branch = String::from_utf8_lossy(&head.stdout).trim().to_string();
let tip: &str = if branch == "HEAD" { "HEAD" } else { &branch };
let count = run_git(path, &["rev-list", "--count", tip, "--not", "--remotes"])?;
let unpushed: u32 = String::from_utf8_lossy(&count.stdout)
.trim()
.parse()
.unwrap_or(0);
if count > 0 {
return Ok(Some(format!(
"has {count} commit(s) not present on any other ref"
)));
}
Ok(None)
.map_err(|e| WorktreeError::NonZero {
code: 0,
stderr: format!("unparseable rev-list count: {e}"),
})?;
Ok(maestro_protocol::UnsavedWork {
dirty_tracked_files: dirty,
untracked_files: untracked,
unpushed_commits: unpushed,
})
}
fn run_git(
dir: &Path,
args: impl IntoIterator<Item = &'static str>,
) -> Result<Output, WorktreeError> {
/// `git -C <base_repo> worktree prune` — drops registrations whose directory
/// no longer exists.
fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
let out = Command::new("git")
.arg("-C")
.arg(base_repo)
.args(["worktree", "prune"])
.output()?;
check(out)
}
fn run_git(dir: &Path, args: &[&str]) -> Result<Output, WorktreeError> {
let mut cmd = Command::new("git");
cmd.arg("-C").arg(dir).args(args);
let out = cmd.output()?;
+141
View File
@@ -0,0 +1,141 @@
//! Integration tests for `GET /api/branches` (#55).
mod common;
use std::net::{Ipv4Addr, SocketAddr};
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use common::DaemonFixture;
use maestro_daemon::local_api;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
struct Harness {
addr: SocketAddr,
_shutdown: oneshot::Sender<()>,
_fx: DaemonFixture,
}
async fn spawn() -> Harness {
let fx = DaemonFixture::new();
let presets = maestro_config::SpawnSection::default().resolve().unwrap();
let router = local_api::router(local_api::state_for(
fx.manager.clone(),
"naps-laptop",
"/home/test",
presets,
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, router)
.with_graceful_shutdown(async move {
let _ = rx.await;
})
.await;
});
Harness {
addr,
_shutdown: tx,
_fx: fx,
}
}
fn git(repo: &Path, args: &[&str]) {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
/// A real repo on `main` with a second branch `feature`, and a linked
/// worktree at `<tmp>/wt-feature` holding `feature`.
fn repo_with_occupied_branch() -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
git(repo, &["init", "-q", "-b", "main"]);
git(
repo,
&[
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-qm",
"init",
"--allow-empty",
],
);
git(repo, &["branch", "feature"]);
let wt = tmp.path().join("wt-feature");
git(repo, &["worktree", "add", &wt.to_string_lossy(), "feature"]);
tmp
}
async fn get_branches(addr: SocketAddr, path: &Path) -> Vec<maestro_protocol::BranchInfo> {
reqwest::get(format!(
"http://{addr}/api/branches?path={}",
urlencoding::encode(&path.to_string_lossy())
))
.await
.unwrap()
.json()
.await
.unwrap()
}
/// #55: every local branch is listed; the main checkout's HEAD reports
/// `is_head`, and a branch held by a linked worktree reports that worktree's
/// path so the client can explain why the entry is disabled.
#[tokio::test]
async fn branches_lists_local_branches_and_worktree_occupancy() {
let tmp = repo_with_occupied_branch();
let h = spawn().await;
let mut branches = get_branches(h.addr, tmp.path()).await;
branches.sort_by(|a, b| a.name.cmp(&b.name));
assert_eq!(
branches.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(),
["feature", "main"]
);
let main = branches.iter().find(|b| b.name == "main").unwrap();
assert!(main.is_head, "main is the main checkout's HEAD");
assert_eq!(main.worktree, None, "HEAD occupancy rides `is_head`");
let feature = branches.iter().find(|b| b.name == "feature").unwrap();
assert!(!feature.is_head);
assert_eq!(
feature.worktree.as_deref(),
Some(tmp.path().join("wt-feature").as_path()),
"the linked worktree holding the branch must be reported"
);
}
/// #55: a path outside any repository is not an error — an empty list,
/// matching how `/api/gitroot` reports "not a repository".
#[tokio::test]
async fn branches_returns_empty_for_a_path_outside_a_repository() {
let h = spawn().await;
let tmp = tempfile::tempdir().unwrap();
let plain = tmp.path().join("plain");
std::fs::create_dir_all(&plain).unwrap();
let branches = get_branches(h.addr, &plain).await;
assert!(branches.is_empty());
}
+32
View File
@@ -93,6 +93,38 @@ fn run(cmd: &mut Command) {
);
}
/// An empty bare repository at `dir`, usable as a push target (#79).
pub fn init_bare_repo(dir: &Path) {
run(Command::new("git")
.arg("init")
.arg("-q")
.arg("--bare")
.arg(dir.to_str().expect("non-utf8 bare repo path")));
}
/// Push every branch of `repo` to `origin`, creating remote-tracking refs.
///
/// The unsaved-work probe (#79) counts commits reachable from no
/// remote-tracking ref as unpushed, so tests that want a killable worktree
/// must give its branch one. Safe to call again: re-adding an existing
/// remote is ignored.
pub fn push_all_to_origin(repo: &Path, origin: &Path) {
let _ = Command::new("git")
.arg("-C")
.arg(repo)
.args([
"remote",
"add",
"origin",
origin.to_str().expect("non-utf8 origin path"),
])
.status();
run(Command::new("git")
.arg("-C")
.arg(repo)
.args(["push", "-q", "--all", "origin"]));
}
/// Async polling sibling of [`wait_for`] for tokio tests.
pub async fn wait_for_async<F, Fut>(timeout: Duration, mut cond: F)
where
+99 -1
View File
@@ -9,7 +9,7 @@ use std::time::Duration;
use common::DaemonFixture;
use futures_util::{SinkExt, StreamExt};
use maestro_daemon::local_api;
use maestro_protocol::{CreateSessionRequest, Session};
use maestro_protocol::{ApiError, CreateSessionRequest, Session, WorktreeSpec};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio_tungstenite::tungstenite::Message as WsMessage;
@@ -232,6 +232,57 @@ async fn projects_returns_empty_for_a_disabled_scanner() {
assert!(projects.is_empty());
}
/// #53: every returned handle splits into a known adjective and noun.
fn assert_valid_names(batch: &maestro_protocol::NameBatch) {
for name in &batch.names {
let (adj, noun) = name.split_once('-').unwrap_or_else(|| {
panic!("handle {name:?} is not adjective-noun");
});
assert!(
maestro_names::ADJECTIVES.contains(&adj),
"{adj} not in ADJECTIVES"
);
assert!(maestro_names::NOUNS.contains(&noun), "{noun} not in NOUNS");
}
}
#[tokio::test]
async fn names_defaults_to_one_handle() {
let h = spawn("naps-laptop").await;
let batch: maestro_protocol::NameBatch = reqwest::get(url(h.addr, "/api/names"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(batch.names.len(), 1);
assert_valid_names(&batch);
}
#[tokio::test]
async fn names_honors_count_and_caps_it() {
use maestro_names::MAX_BATCH;
let h = spawn("naps-laptop").await;
let batch: maestro_protocol::NameBatch = reqwest::get(url(h.addr, "/api/names?count=5"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(batch.names.len(), 5);
assert_valid_names(&batch);
let batch: maestro_protocol::NameBatch =
reqwest::get(url(h.addr, &format!("/api/names?count={}", MAX_BATCH * 10)))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(batch.names.len(), MAX_BATCH);
}
#[tokio::test]
async fn gitroot_probes_path() {
// #26: resolves the query against the daemon home dir and walks up for a
@@ -287,6 +338,53 @@ async fn delete_owned_session_kills_it() {
assert!(!h.manager.has(session.id).await);
}
/// The kill guard on the wire (#79): `DELETE /api/sessions/:id` answers 409
/// with the structured breakdown, and the session keeps running.
#[tokio::test]
async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the_breakdown() {
let h = spawn("naps-laptop").await;
let repo = tempfile::TempDir::new().unwrap();
common::init_repo(repo.path());
let session = h
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/guarded".into(),
cleanup_on_kill: true,
create_branch: true,
}),
})
.await
.unwrap();
std::fs::write(
repo.path()
.join("worktrees")
.join("feat")
.join("guarded")
.join("README"),
b"unsaved edits\n",
)
.unwrap();
let resp = reqwest::Client::new()
.delete(url(h.addr, &format!("/api/sessions/{}", session.id)))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::CONFLICT);
let body: ApiError = resp.json().await.unwrap();
assert_eq!(body.error.code, "kill_guarded");
let breakdown = body.error.unsaved_work.expect("breakdown on the wire");
assert_eq!(breakdown.dirty_tracked_files, 1);
assert!(h.manager.has(session.id).await, "the session keeps running");
}
#[tokio::test]
async fn attach_ws_streams_pty_bytes() {
let h = spawn("naps-laptop").await;
+147 -12
View File
@@ -4,10 +4,12 @@ mod common;
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
use common::{init_repo, wait_for_async, DaemonFixture};
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
use maestro_daemon::SessionError;
use maestro_protocol::CreateSessionRequest;
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
use tempfile::TempDir;
@@ -363,14 +365,15 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
assert!(!fx.fifo_dir().join(format!("{}.fifo", s.id)).exists());
}
/// Cleanup is unconditional (#56): even a spec that says "don't clean up"
/// loses its worktree on kill — the guard, not the flag, protects work.
/// Cleanup on kill is unconditional once the guard passes (#79): even a spec
/// that says "don't clean up" loses its worktree — the branch was pushed, so
/// nothing in the tree is unique and removal goes ahead.
#[tokio::test]
async fn kill_removes_worktree_even_when_spec_says_keep() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let wt_path = repo.path().join("worktrees").join("feat-keep");
let wt_path = repo.path().join("worktrees").join("feat").join("keep");
let s = fx
.manager
@@ -390,18 +393,25 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
.expect("create");
assert!(wt_path.exists());
// Push the branch so its commits are reachable from a remote-tracking ref.
let origin = TempDir::new().unwrap();
init_bare_repo(origin.path());
push_all_to_origin(repo.path(), origin.path());
fx.manager.kill(s.id).await.expect("kill");
assert!(!wt_path.exists(), "worktree must be removed on kill");
}
/// The guard (#56): a worktree holding uncommitted changes survives the kill,
/// left on disk where the operator can find it.
/// The kill guard (#79): a worktree holding uncommitted changes refuses the
/// kill outright. Nothing happens — the session keeps running as the
/// operator's handle on their unfinished work, and the tree is untouched.
/// The refusal names what blocked it and how much of it there is.
#[tokio::test]
async fn kill_leaves_worktree_with_unsaved_work_on_disk() {
async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let wt_path = repo.path().join("worktrees").join("feat-guarded");
let wt_path = repo.path().join("worktrees").join("feat").join("guarded");
let s = fx
.manager
@@ -420,13 +430,138 @@ async fn kill_leaves_worktree_with_unsaved_work_on_disk() {
.await
.expect("create");
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
std::fs::write(wt_path.join("notes.txt"), b"untracked\n").unwrap();
fx.manager.kill(s.id).await.expect("kill");
let err = fx.manager.kill(s.id).await.expect_err("kill must refuse");
let SessionError::KillGuarded(breakdown) = err else {
panic!("expected KillGuarded, got {err:?}");
};
assert_eq!(breakdown.dirty_tracked_files, 1);
assert_eq!(breakdown.untracked_files, 1);
assert!(
wt_path.exists(),
"dirty worktree must be left on disk, not destroyed"
breakdown.unpushed_commits >= 1,
"a fresh branch has no remote-tracking ref"
);
let message = breakdown.to_string();
assert!(message.contains("1 uncommitted file"), "{message}");
assert!(message.contains("1 untracked file"), "{message}");
// Nothing happened: the session survives and so does the tree.
assert!(fx.manager.has(s.id).await, "the session must keep running");
assert!(wt_path.exists(), "the worktree must be untouched");
// A second kill after the work is saved goes through.
run_git_in(&wt_path, &["add", "."]);
run_git_in(&wt_path, &["commit", "-q", "-m", "saved"]);
let origin = TempDir::new().unwrap();
init_bare_repo(origin.path());
push_all_to_origin(repo.path(), origin.path());
fx.manager.kill(s.id).await.expect("kill after saving");
assert!(!fx.manager.has(s.id).await);
}
/// Unpushed commits alone block the kill (#79): a feature branch almost
/// always has them, which is why `--force` (which retains) is the ordinary
/// way to end such a session.
#[tokio::test]
async fn killing_refuses_until_the_branch_is_pushed() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let wt_path = repo.path().join("worktrees").join("feat").join("pushed");
let s = fx
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/pushed".into(),
cleanup_on_kill: true,
create_branch: true,
}),
})
.await
.expect("create");
let err = fx.manager.kill(s.id).await.expect_err("unpushed blocks");
let SessionError::KillGuarded(breakdown) = err else {
panic!("expected KillGuarded, got {err:?}");
};
assert_eq!(breakdown.dirty_tracked_files, 0);
assert_eq!(breakdown.untracked_files, 0);
assert!(breakdown.unpushed_commits >= 1);
let origin = TempDir::new().unwrap();
init_bare_repo(origin.path());
push_all_to_origin(repo.path(), origin.path());
fx.manager.kill(s.id).await.expect("pushed branch kills");
assert!(!wt_path.exists());
assert!(!fx.manager.has(s.id).await);
}
/// Ignored files NEVER block (#79): build output like `target/` exists in
/// every Rust checkout, and an ignored-only worktree stays killable.
#[tokio::test]
async fn killing_an_ignored_only_worktree_succeeds() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
// .gitignore must be committed before the worktree branches off, or it
// would itself read as an untracked file.
std::fs::write(repo.path().join(".gitignore"), b"/target\n").unwrap();
run_git_in(repo.path(), &["add", ".gitignore"]);
run_git_in(repo.path(), &["commit", "-q", "-m", "ignore target"]);
let origin = TempDir::new().unwrap();
init_bare_repo(origin.path());
let wt_path = repo.path().join("worktrees").join("feat").join("rusty");
let s = fx
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/rusty".into(),
cleanup_on_kill: true,
create_branch: true,
}),
})
.await
.expect("create");
std::fs::create_dir_all(wt_path.join("target")).unwrap();
std::fs::write(wt_path.join("target").join("app.o"), b"binary\n").unwrap();
// Branch pushed; the only dirt is ignored output.
push_all_to_origin(repo.path(), origin.path());
fx.manager
.kill(s.id)
.await
.expect("ignored files never block");
assert!(!wt_path.exists());
assert!(!fx.manager.has(s.id).await);
}
fn run_git_in(dir: &std::path::Path, args: &[&str]) {
let out = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("git spawn");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(fx.manager.snapshot().await.is_empty());
}
#[tokio::test]
+1
View File
@@ -19,6 +19,7 @@ fn sample(id: Uuid) -> Session {
command: "bash".into(),
env: HashMap::from([("FOO".into(), "bar".into())]),
metadata: HashMap::from([("name".into(), Value::String("maestro-impl".into()))]),
worktree: None,
status: SessionStatus::Running,
created_at: DateTime::parse_from_rfc3339("2026-05-24T12:00:00Z")
.unwrap()
+340 -27
View File
@@ -6,8 +6,8 @@ mod common;
use std::path::PathBuf;
use std::process::Command;
use common::init_repo;
use maestro_daemon::worktree::WorktreeManager;
use common::{init_bare_repo, init_repo, push_all_to_origin};
use maestro_daemon::worktree::{unsaved_work, WorktreeManager};
use tempfile::TempDir;
fn worktree_branches(repo: &PathBuf) -> Vec<String> {
@@ -53,7 +53,7 @@ fn add_places_worktree_inside_the_base_repo_by_default() {
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
assert_eq!(path, repo.join("worktrees").join("feat-example"));
assert_eq!(path, repo.join("worktrees").join("feat").join("example"));
assert!(path.exists());
assert!(path.join("README").exists());
@@ -70,32 +70,51 @@ fn worktree_root_override_redirects_placement() {
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
// The repo's basename sits between the root and the branch slug, so two
// repos spawning the same branch name don't target one directory.
let repo_name = repo.file_name().unwrap();
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
assert_eq!(path, wt_root.path().join("feat-example"));
// The override root disambiguates same-named checkouts with a digest of
// the full repo path, and the branch keeps its own shape.
// <root>/<repo basename>-<digest>/feat/example
let prefix = format!("{}-", repo_name.to_string_lossy());
let repo_dir_name = path
.strip_prefix(wt_root.path())
.unwrap()
.components()
.next()
.unwrap();
assert!(
repo_dir_name
.as_os_str()
.to_string_lossy()
.starts_with(&prefix),
"{path:?}"
);
assert!(path.ends_with("feat/example"), "{path:?}");
assert!(path.exists());
}
/// Anything at all occupying the target path — ours, the operator's, or an
/// unrelated directory — is sidestepped, not adopted and not reported (#77).
#[test]
fn existing_directory_at_target_path_is_an_error_carrying_the_path() {
fn an_occupied_target_path_gets_a_numeric_suffix() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
// A predictable path means collisions are possible; they must be
// reported, never silently relocated.
std::fs::create_dir_all(repo.join("worktrees").join("feat-collide")).unwrap();
// A non-git directory sits exactly where the worktree would go.
let squatter = repo.join("worktrees").join("feat").join("collide");
std::fs::create_dir_all(&squatter).unwrap();
std::fs::write(squatter.join("operator-notes.txt"), b"mine\n").unwrap();
let mgr = WorktreeManager::new(None);
let err = mgr.add_new(&repo, "feat/collide").unwrap_err();
let msg = format!("{err}");
let path = mgr.add_new(&repo, "feat/collide").expect("sidesteps");
assert_eq!(path, repo.join("worktrees").join("feat").join("collide-2"));
assert!(path.exists());
assert!(
msg.contains(
repo.join("worktrees")
.join("feat-collide")
.to_str()
.unwrap()
),
"{msg}"
squatter.join("operator-notes.txt").exists(),
"the occupant is untouched"
);
}
@@ -115,6 +134,123 @@ fn add_twice_generates_distinct_paths() {
assert!(p2.exists());
}
/// The unsaved-work probe (#79) counts uncommitted tracked changes.
#[test]
fn the_probe_counts_uncommitted_tracked_changes() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/dirty").expect("add");
std::fs::write(path.join("README"), b"unsaved edits\n").unwrap();
let w = unsaved_work(&path).expect("probe");
assert_eq!(w.dirty_tracked_files, 1);
assert_eq!(w.untracked_files, 0);
}
/// Untracked sources count; a staged new file is also unsaved tracked work.
#[test]
fn the_probe_counts_untracked_files() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/untracked").expect("add");
std::fs::write(path.join("notes.txt"), b"wip\n").unwrap();
std::fs::write(path.join("more.txt"), b"also wip\n").unwrap();
let w = unsaved_work(&path).expect("probe");
assert_eq!(w.untracked_files, 2);
assert_eq!(w.dirty_tracked_files, 0);
run_git(&path, &["add", "notes.txt"]);
let w = unsaved_work(&path).expect("probe");
assert_eq!(w.dirty_tracked_files, 1, "staged counts as uncommitted");
assert_eq!(w.untracked_files, 1);
}
/// Ignored files NEVER block: every Rust checkout has a `target/`, and
/// counting ignored output would leave no session killable (#79).
#[test]
fn the_probe_ignores_ignored_files() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
// .gitignore must be committed, or it would itself read as untracked.
std::fs::write(repo.join(".gitignore"), b"/target\n").unwrap();
run_git(&repo, &["add", ".gitignore"]);
run_git(&repo, &["commit", "-q", "-m", "ignore target"]);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/builds").expect("add");
std::fs::create_dir_all(path.join("target")).unwrap();
std::fs::write(path.join("target").join("app.o"), b"binary\n").unwrap();
let w = unsaved_work(&path).expect("probe");
assert_eq!(w.dirty_tracked_files, 0);
assert_eq!(w.untracked_files, 0);
// With the branch pushed too, an ignored-only tree reads fully clean.
let origin_dir = TempDir::new().unwrap();
init_bare_repo(origin_dir.path());
push_all_to_origin(&repo, origin_dir.path());
let w = unsaved_work(&path).expect("probe after push");
assert!(w.is_empty(), "ignored-only worktree must read clean: {w:?}");
}
/// Commits reachable from no remote-tracking ref block; pushing the branch
/// clears them. With no remote at all every commit is unreachable from one,
/// which is exactly why feature branches almost always refuse (#79).
#[test]
fn the_probe_counts_unpushed_commits_until_the_branch_is_pushed() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/branch").expect("add");
let w = unsaved_work(&path).expect("probe");
assert!(
w.unpushed_commits >= 1,
"a fresh branch has no remote-tracking ref: {w:?}"
);
let origin_dir = TempDir::new().unwrap();
init_bare_repo(origin_dir.path());
push_all_to_origin(&repo, origin_dir.path());
let w = unsaved_work(&path).expect("probe after push");
assert_eq!(w.unpushed_commits, 0, "{w:?}");
assert!(w.is_empty(), "pushed and clean must be empty: {w:?}");
}
/// The full breakdown names each blocker with its count.
#[test]
fn the_probe_reports_every_blocker_at_once() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/mixed").expect("add");
std::fs::write(path.join("README"), b"edited\n").unwrap();
std::fs::write(path.join("scratch.txt"), b"untracked\n").unwrap();
let w = unsaved_work(&path).expect("probe");
assert_eq!(w.dirty_tracked_files, 1);
assert_eq!(w.untracked_files, 1);
assert!(w.unpushed_commits >= 1);
let summary = w.to_string();
assert!(summary.contains("1 uncommitted file"), "{summary}");
assert!(summary.contains("1 untracked file"), "{summary}");
assert!(summary.contains("unpushed commit"), "{summary}");
}
#[test]
fn remove_prunes_worktree() {
let repo_dir = TempDir::new().unwrap();
@@ -192,8 +328,14 @@ fn add_existing_checks_out_branch_without_creating_it() {
);
}
/// Removal is guarded upstream now (#79): the kill refuses unsaved work
/// before anything is torn down, so by the time `remove` runs the tree is
/// clean. Git still refuses a dirty tree on its own — this asserts that last
/// line of defense — and committed-but-unpushed work does not block removal,
/// because `worktree remove` keeps `refs/heads/<branch>` and the commits stay
/// reachable.
#[test]
fn remove_refuses_worktree_with_uncommitted_changes() {
fn remove_still_relies_on_git_refusing_a_dirty_tree() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
@@ -206,18 +348,46 @@ fn remove_refuses_worktree_with_uncommitted_changes() {
.expect_err("dirty worktree must be refused");
assert!(path.exists(), "guarded worktree must stay on disk");
// Committing the change doesn't help while it is the only copy…
run_git(&path, &["add", "."]);
run_git(&path, &["commit", "-q", "-m", "unsaved"]);
run_git(&path, &["commit", "-q", "-m", "saved"]);
mgr.remove(&repo, &path).expect("committed work can go");
assert!(!path.exists());
assert!(
worktree_branches(&repo).contains(&"main".to_string()),
"the branch survives removal"
);
let branches = String::from_utf8(
Command::new("git")
.arg("-C")
.arg(&repo)
.args(["for-each-ref", "--format=%(refname:short)", "refs/heads"])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(branches.contains("feat/dirty"), "{branches}");
}
/// A worktree whose directory vanished must be pruned, not misread as holding
/// unsaved work — otherwise the registration is stranded and, because paths
/// are deterministic, that branch can never be spawned again (#56 review).
#[test]
fn remove_prunes_a_worktree_whose_directory_vanished() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/gone").expect("add");
std::fs::remove_dir_all(&path).unwrap();
mgr.remove(&repo, &path)
.expect_err("unique commit must be refused");
assert!(path.exists());
// …but once the commit is reachable from main, removal is safe.
run_git(&repo, &["merge", "-q", "--no-edit", "feat/dirty"]);
mgr.remove(&repo, &path).expect("merged worktree can go");
assert!(!path.exists());
.expect("a vanished worktree prunes");
// The registration is gone too, so the same branch can be spawned again.
mgr.add_existing(&repo, "feat/gone")
.expect("branch is spawnable again");
}
fn run_git(dir: &std::path::Path, args: &[&str]) {
@@ -256,3 +426,146 @@ fn add_fails_when_branch_already_exists() {
let msg = format!("{err}");
assert!(msg.contains("git exited"), "{msg}");
}
/// The project picker lists linked worktrees as pickable rows, so `base_repo`
/// is easily a worktree rather than the main checkout. Placing inside it would
/// nest one worktree in another — and git hides a registered worktree's
/// contents from the outer `git status`, so the outer tree reads clean and
/// removing it deletes the inner one's uncommitted work (#54 review).
#[test]
fn placement_resolves_a_linked_worktree_to_the_main_checkout() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let outer = mgr.add_new(&repo, "feat/outer").expect("add outer");
// Spawn again with the *worktree* as the base, the way the picker would.
let inner = mgr.add_new(&outer, "feat/inner").expect("add inner");
assert!(
!inner.starts_with(&outer),
"must not nest: {inner:?} inside {outer:?}"
);
assert!(
inner.starts_with(repo.canonicalize().unwrap().join("worktrees")),
"placed against the main checkout: {inner:?}"
);
}
/// Belt and braces for a nesting that predates this daemon: refuse to remove
/// a worktree that has another registered underneath it.
#[test]
fn remove_refuses_a_worktree_holding_a_nested_one() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let outer = mgr.add_new(&repo, "feat/outer").expect("add outer");
// Register one by hand inside `outer`, bypassing our placement.
let nested = outer.join("worktrees").join("hand-rolled");
run_git(
&repo,
&[
"worktree",
"add",
"-b",
"feat/hand",
nested.to_str().unwrap(),
],
);
std::fs::write(nested.join("notes.txt"), b"unsaved\n").unwrap();
mgr.remove(&repo, &outer)
.expect_err("a worktree holding a nested one must be refused");
assert!(nested.join("notes.txt").exists(), "inner work survives");
}
/// Two repos spawning the same branch name under one override root must not
/// collide (#54 review).
#[test]
fn override_root_keeps_repos_apart() {
let root_dir = TempDir::new().unwrap();
let a_dir = TempDir::new().unwrap();
let b_dir = TempDir::new().unwrap();
init_repo(a_dir.path());
init_repo(b_dir.path());
let mgr = WorktreeManager::new(Some(root_dir.path().to_path_buf()));
let a = mgr.add_new(a_dir.path(), "feat/shared").expect("repo a");
let b = mgr.add_new(b_dir.path(), "feat/shared").expect("repo b");
assert_ne!(a, b, "same branch name in two repos must not collide");
}
/// Flattening `/` and `.` to `-` made these three share one directory, so the
/// losers could never get a worktree (#54 review).
#[test]
fn similar_branch_names_get_distinct_directories() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let a = mgr.add_new(&repo, "feat/x").expect("feat/x");
let b = mgr.add_new(&repo, "feat.x").expect("feat.x");
let c = mgr.add_new(&repo, "feat-x").expect("feat-x");
assert_ne!(a, b);
assert_ne!(b, c);
assert_ne!(a, c);
assert!(a.ends_with("feat/x"), "{a:?}");
}
/// A guarded leftover stays on disk untouched, and is never adopted (#77).
/// Re-spawning its branch is refused by git itself — the branch is still
/// checked out inside the leftover — which the caller surfaces.
#[test]
fn a_guarded_leftover_is_never_adopted() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let first = mgr.add_new(&repo, "feat/resume").expect("add");
std::fs::write(first.join("notes.txt"), b"unsaved\n").unwrap();
// Removal is refused — git itself refuses a dirty tree — leaving it on
// disk; the daemon-side guard lives on the kill (#79).
mgr.remove(&repo, &first)
.expect_err("dirty tree is guarded");
let err = mgr
.add_existing(&repo, "feat/resume")
.expect_err("a branch already checked out cannot be spawned twice");
assert!(format!("{err}").contains("git exited"), "{err}");
assert!(
first.join("notes.txt").exists(),
"the leftover's unsaved work is untouched"
);
}
/// The `worktree_root` override gets the same collision suffix (#77).
#[test]
fn the_override_root_suffixes_collisions_too() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
// First add settles `<root>/<repo basename>-<digest>/` so the squatter
// can be created at the exact target.
mgr.add_new(&repo, "feat/first").expect("add first");
let repo_dir_in_root = std::fs::read_dir(wt_root.path())
.unwrap()
.next()
.unwrap()
.unwrap()
.path();
let squatter = repo_dir_in_root.join("feat").join("collide");
std::fs::create_dir_all(&squatter).unwrap();
let path = mgr.add_new(&repo, "feat/collide").expect("sidesteps");
assert_eq!(path, squatter.with_file_name("collide-2"), "{path:?}");
}
+11 -1
View File
@@ -11,7 +11,9 @@ use std::sync::Arc;
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{CatalogEvent, CreateSessionRequest, DaemonClient, Session};
use maestro_protocol::{
BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, Project, Session,
};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::Message as WsMessage;
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
@@ -62,6 +64,14 @@ impl TestClient {
Ok(self.daemon.kill_session(id).await?)
}
pub async fn list_projects(&self) -> Result<Vec<Project>, ClientError> {
Ok(self.daemon.list_projects().await?)
}
pub async fn list_branches(&self, path: &str) -> Result<Vec<BranchInfo>, ClientError> {
Ok(self.daemon.list_branches(path).await?)
}
/// Connect to the daemon's per-session WS and return the stream. The
/// caller drives the conversation.
pub async fn attach(
+21
View File
@@ -29,11 +29,32 @@ impl Daemon {
Self::spawn_at(SocketAddr::from((Ipv4Addr::LOCALHOST, free_port()))).await
}
/// Spawn with a config file pre-seeded at the daemon's expected path
/// (`$XDG_CONFIG_HOME/maestro/config.toml`).
pub async fn spawn_with_config(toml: &str) -> Self {
Self::spawn_config_at(
SocketAddr::from((Ipv4Addr::LOCALHOST, free_port())),
Some(toml),
)
.await
}
/// Spawn on a caller-supplied bind address.
pub async fn spawn_at(bind: SocketAddr) -> Self {
Self::spawn_config_at(bind, None).await
}
/// Spawn on a caller-supplied bind address, with an optional config file.
async fn spawn_config_at(bind: SocketAddr, config: impl Into<Option<&str>>) -> Self {
let home = TempDir::new().expect("home tmpdir");
let tmux = TempDir::new().expect("tmux tmpdir");
if let Some(toml) = config.into() {
let dir = home.path().join(".config").join("maestro");
std::fs::create_dir_all(&dir).expect("config dir");
std::fs::write(dir.join("config.toml"), toml).expect("write config.toml");
}
let child = launch(bind, home.path(), tmux.path());
let me = Self {
bind_addr: bind,
+1 -1
View File
@@ -14,5 +14,5 @@ pub mod daemon;
pub mod util;
pub use build::workspace_target_bin;
pub use client::TestClient;
pub use client::{ClientError, TestClient};
pub use daemon::Daemon;
+366 -15
View File
@@ -35,6 +35,22 @@ fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
}
}
/// Spawn request carrying a worktree spec against `base`.
fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest {
CreateSessionRequest {
cwd: base.to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: base.to_path_buf(),
branch: branch.into(),
cleanup_on_kill: true,
create_branch,
}),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn lists_spawned_sessions() {
let (_daemon, client) = bring_up().await;
@@ -140,10 +156,11 @@ async fn kill_removes_clean_worktree() {
assert!(
worktrees
.iter()
.any(|w| w.contains("worktrees/feature-e2e")),
.any(|w| w.contains("worktrees/feature/e2e")),
"expected a worktree for feature/e2e under <repo>/worktrees, got: {worktrees:?}"
);
push_to_throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
// Give the daemon a moment to remove the worktree, then verify.
@@ -161,16 +178,21 @@ async fn kill_removes_clean_worktree() {
);
}
/// The guard (#56): killing a session whose worktree holds uncommitted
/// changes must leave the tree on disk — cleanup is unconditional, so this
/// guard is what keeps a stray kill from destroying work.
/// The kill guard (#79): killing a session whose worktree holds uncommitted
/// changes is refused outright. Nothing happens — the session keeps running
/// as the operator's handle on their unfinished work, the tree stays on disk,
/// and the refusal names what blocked it and how much.
#[tokio::test(flavor = "multi_thread")]
async fn kill_spares_worktree_with_uncommitted_changes() {
async fn kill_refuses_worktree_with_uncommitted_changes() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let wt_path = base.path().join("worktrees").join("feature-unsaved");
let wt_path = base
.path()
.join("worktrees")
.join("feature")
.join("unsaved");
let req = CreateSessionRequest {
cwd: base.path().to_path_buf(),
@@ -189,21 +211,42 @@ async fn kill_spares_worktree_with_uncommitted_changes() {
assert!(wt_path.exists());
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
client.kill_session(s.id).await.expect("kill");
// The session is gone but the dirty worktree stays findable on disk.
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
let sessions = client.list_sessions().await.unwrap_or_default();
if sessions.is_empty() {
break;
let err = client
.kill_session(s.id)
.await
.expect_err("kill must refuse");
match err {
maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. }) => {
assert_eq!(info.code, "kill_guarded", "{info:?}");
let breakdown = info.unsaved_work.expect("breakdown on the wire");
assert_eq!(breakdown.dirty_tracked_files, 1);
}
tokio::time::sleep(Duration::from_millis(100)).await;
other => panic!("expected a kill_guarded refusal, got {other:?}"),
}
// The session survives and the dirty worktree stays findable on disk.
let sessions = client.list_sessions().await.unwrap_or_default();
assert!(
sessions.iter().any(|x| x.id == s.id),
"a refused kill must keep the session alive"
);
assert!(
wt_path.exists(),
"guarded worktree must be left on disk after kill"
);
// Left findable, not silently half-deleted: git still registers it and
// the uncommitted edit survives byte for byte (#66).
assert!(
git_worktree_list(base.path())
.iter()
.any(|w| w.contains("feature/unsaved")),
"guarded worktree must stay registered in git worktree list"
);
assert_eq!(
std::fs::read(wt_path.join("README")).unwrap(),
b"unsaved edits\n",
"uncommitted changes must survive the guarded kill"
);
}
#[tokio::test(flavor = "multi_thread")]
@@ -237,9 +280,290 @@ async fn spawn_on_existing_branch_checks_it_out() {
"expected a worktree checked out at feature/existing, got: {worktrees:?}"
);
push_to_throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
}
/// #66: a fresh branch spawns a worktree at `<repo>/worktrees/<branch>` and
/// the session actually runs inside it — `current_cwd` comes from tmux's live
/// pane state, not from the spawn request.
#[tokio::test(flavor = "multi_thread")]
async fn spawn_new_branch_creates_worktree_and_runs_in_it() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let wt_path = base.path().join("worktrees").join("feature").join("fresh");
let s = client
.spawn_session(&worktree_req(base.path(), "feature/fresh", true))
.await
.expect("spawn new-branch worktree");
assert_eq!(
s.cwd, wt_path,
"session must report the worktree as its cwd"
);
assert!(wt_path.is_dir(), "worktree directory must exist on disk");
// The activity watcher fills `current_cwd` from tmux's pane_current_path,
// so this is the daemon confirming where the process really runs.
wait_for_async(
"session's live cwd is the worktree",
Duration::from_secs(20),
|| {
let client = client.clone();
let id = s.id;
let want = wt_path.clone();
async move {
client
.list_sessions()
.await
.ok()
.and_then(|ss| ss.iter().find(|s| s.id == id).cloned())
.map(|s| s.current_cwd == Some(want.clone()))
.unwrap_or(false)
}
},
)
.await;
push_to_throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
}
/// #66: a second spawn onto a branch that already has a worktree is rejected
/// with a structured worktree error; the first worktree stays intact.
#[tokio::test(flavor = "multi_thread")]
async fn second_spawn_on_same_branch_is_rejected() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let first = client
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
.await
.expect("first spawn");
let dup = client
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
.await;
match dup {
Err(maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. })) => {
assert_eq!(
info.code, "worktree_failed",
"expected worktree error: {info:?}"
);
}
other => panic!("expected rejection for duplicate branch, got {other:?}"),
}
// Exactly one registration, one directory, still usable.
// Count branch records only: the worktree path now contains the branch
// name too (`worktrees/feature/dup`), so a substring match over every line
// would count the same tree twice.
let holders = git_worktree_list(base.path())
.iter()
.filter(|w| w.as_str() == "branch refs/heads/feature/dup")
.count();
assert_eq!(holders, 1, "duplicate spawn must not register another tree");
assert!(
base.path()
.join("worktrees")
.join("feature")
.join("dup")
.is_dir(),
"original worktree must be untouched"
);
push_to_throwaway_origin(base.path());
client.kill_session(first.id).await.expect("kill");
}
/// #66/#56: when the client picks a *linked worktree* as base_repo, placement
/// resolves through `--git-common-dir` to the main checkout, so the new tree
/// lands at `<main>/worktrees/<branch>` instead of nesting inside another
/// worktree.
#[tokio::test(flavor = "multi_thread")]
async fn spawn_from_linked_worktree_places_in_main_checkout() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let linked = base.path().join("linked");
run_git(
&[
"worktree",
"add",
"-q",
"-b",
"other",
&linked.to_string_lossy(),
],
base.path(),
);
let s = client
.spawn_session(&worktree_req(&linked, "feature/nested", true))
.await
.expect("spawn from linked worktree");
let want = base.path().join("worktrees").join("feature").join("nested");
assert_eq!(
s.cwd, want,
"worktree must be placed under the main checkout"
);
assert!(want.is_dir());
assert!(
!s.cwd.starts_with(&linked),
"worktrees must never nest inside another worktree"
);
push_to_throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
}
// ---------------------------------------------------------------------------
// GET /api/projects + /api/branches (#54/#55/#66)
// ---------------------------------------------------------------------------
/// #66: two repos under a configured `[spawn].project_roots` root are both
/// discovered by `GET /api/projects`; git activity gives every repo a
/// `last_activity`, and spawning inside one merges its command into the
/// project entry via spawn history.
#[tokio::test(flavor = "multi_thread")]
async fn projects_endpoint_discovers_roots_and_merges_activity() {
let root = TempDir::new().unwrap();
let alpha = make_repo(root.path(), "alpha");
make_repo(root.path(), "beta");
let daemon = Daemon::spawn_with_config(&format!(
"[spawn]\nproject_roots = [\"{}\"]\nproject_scan_depth = 2\n",
root.path().display()
))
.await;
let client = TestClient::new(&daemon.http_base());
let projects = client.list_projects().await.expect("list projects");
let names: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect();
assert!(
names.contains(&"alpha") && names.contains(&"beta"),
"both repos must be discovered, got: {names:?}"
);
let find = |name: &str| {
projects
.iter()
.find(|p| p.name == name)
.cloned()
.expect("project present")
};
// Both have commits, so git activity alone populates last_activity…
assert!(
find("alpha").last_activity.is_some(),
"git activity must surface"
);
assert!(
find("beta").last_activity.is_some(),
"git activity must surface"
);
// …and neither has been spawned into yet.
assert_eq!(find("alpha").last_command, None);
assert_eq!(find("beta").last_command, None);
// Spawning inside alpha writes durable history; /api/projects folds it in.
let s = client
.spawn_session(&create_req(&alpha, "sleep 30"))
.await
.expect("spawn in alpha");
wait_for_async(
"alpha's project entry reflects spawn history",
Duration::from_secs(5),
|| {
let client = client.clone();
async move {
client
.list_projects()
.await
.ok()
.map(|ps| {
ps.iter()
.find(|p| p.name == "alpha")
.map(|p| p.last_command.as_deref() == Some("sleep 30"))
.unwrap_or(false)
})
.unwrap_or(false)
}
},
)
.await;
// beta was never spawned into: no command recorded there.
let beta_after = client
.list_projects()
.await
.expect("list after spawn")
.into_iter()
.find(|p| p.name == "beta")
.unwrap();
assert_eq!(beta_after.last_command, None);
client.kill_session(s.id).await.expect("kill");
}
/// #66: `GET /api/branches` lists every local branch, flags the main
/// checkout's HEAD with `is_head`, and reports which linked worktree holds an
/// occupied branch.
#[tokio::test(flavor = "multi_thread")]
async fn branches_endpoint_lists_branches_and_flags_occupied() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
run_git(&["branch", "feature/taken"], base.path());
run_git(&["branch", "feature/free"], base.path());
let holder = base.path().join("holder-wt");
run_git(
&[
"worktree",
"add",
"-q",
&holder.to_string_lossy(),
"feature/taken",
],
base.path(),
);
let branches = client
.list_branches(&base.path().to_string_lossy())
.await
.expect("list branches");
let by_name = |name: &str| {
branches
.iter()
.find(|b| b.name == name)
.cloned()
.unwrap_or_else(|| panic!("branch {name} missing from {branches:?}"))
};
let main = by_name("main");
assert!(main.is_head, "main is the main checkout's HEAD");
assert_eq!(main.worktree, None);
let taken = by_name("feature/taken");
assert!(!taken.is_head);
assert_eq!(
taken.worktree.as_deref(),
Some(holder.canonicalize().unwrap().as_path()),
"occupied branch must report its holding worktree"
);
let free = by_name("feature/free");
assert!(!free.is_head);
assert_eq!(free.worktree, None, "unoccupied branch must be spawnable");
}
#[tokio::test(flavor = "multi_thread")]
async fn daemon_restart_recovers_session() {
let (mut daemon, client) = bring_up().await;
@@ -428,6 +752,15 @@ fn init_repo(dir: &std::path::Path) {
run_git(&["commit", "-q", "-m", "initial"], dir);
}
/// Real git repo at `<root>/<name>`, canonicalized (the scanner canonicalizes
/// discovered paths too, so comparisons must agree on the real path).
fn make_repo(root: &std::path::Path, name: &str) -> std::path::PathBuf {
let dir = root.join(name);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
dir.canonicalize().unwrap()
}
fn run_git(args: &[&str], cwd: &std::path::Path) {
let out = Command::new("git")
.arg("-C")
@@ -455,6 +788,24 @@ fn git_worktree_list(dir: &std::path::Path) -> Vec<String> {
.collect()
}
/// Push every branch of `repo` to a throwaway bare remote, so its commits are
/// reachable from remote-tracking refs. Without one the kill guard (#79)
/// reads every commit as unpushed, and any worktree session refuses to die.
fn push_to_throwaway_origin(repo: &std::path::Path) {
let origin = tempfile::TempDir::new().unwrap();
run_git(
&["init", "-q", "--bare", origin.path().to_str().unwrap()],
repo,
);
let _ = Command::new("git")
.arg("-C")
.arg(repo)
.args(["remote", "add", "origin", origin.path().to_str().unwrap()])
.output()
.expect("git remote add");
run_git(&["push", "-q", "--all", "origin"], repo);
}
#[tokio::test(flavor = "multi_thread")]
async fn daemon_events_ws_streams_snapshot_then_session_added() {
// NAP-248: subscribe to the daemon's WS /api/events, expect an initial
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "maestro-names"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
description = "Session-handle generator: musical adjective-noun wordlists"
[dependencies]
rand.workspace = true
+96
View File
@@ -0,0 +1,96 @@
//! The one home of the session-handle wordlists (#53).
//!
//! Previously duplicated in `web/src/names.ts`; every client now gets handles
//! from here, served by `GET /api/names`.
/// Musical adjectives — tempo, dynamics, and articulation markings.
pub const ADJECTIVES: &[&str] = &[
"allegro",
"andante",
"adagio",
"presto",
"vivace",
"largo",
"grave",
"lento",
"moderato",
"crescendo",
"staccato",
"legato",
"forte",
"dolce",
"agitato",
"cantabile",
"sostenuto",
"tremolo",
"rubato",
"marcato",
"brillante",
"maestoso",
"grazioso",
"espressivo",
"tranquillo",
"animato",
"spirito",
"sotto",
"vivo",
"feroce",
];
/// Musical nouns — instruments and forms from the concert hall.
pub const NOUNS: &[&str] = &[
"cello", "violin", "viola", "oboe", "flute", "clarinet", "bassoon", "trumpet", "trombone",
"tuba", "horn", "timpani", "harp", "piano", "organ", "lyre", "lute", "fugue", "sonata",
"octave", "chord", "cadence", "prelude", "rondo", "nocturne", "scherzo", "refrain", "coda",
"motif", "overture",
];
/// Upper bound for a `GET /api/names?count=N` batch.
pub const MAX_BATCH: usize = 100;
/// A fresh `adjective-noun` handle, e.g. `allegro-cello`.
pub fn generate() -> String {
use rand::seq::SliceRandom;
let mut rng = rand::thread_rng();
format!(
"{}-{}",
ADJECTIVES.choose(&mut rng).unwrap(),
NOUNS.choose(&mut rng).unwrap()
)
}
/// `count` fresh handles (the caller clamps; this only trusts its input).
pub fn generate_batch(count: usize) -> Vec<String> {
(0..count).map(|_| generate()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generated_name_is_adjective_dash_noun() {
let name = generate();
let (adj, noun) = name.split_once('-').expect("name contains a dash");
assert!(ADJECTIVES.contains(&adj), "{adj} not in ADJECTIVES");
assert!(NOUNS.contains(&noun), "{noun} not in NOUNS");
}
#[test]
fn batch_returns_the_requested_count() {
assert_eq!(generate_batch(1).len(), 1);
assert_eq!(generate_batch(50).len(), 50);
for name in generate_batch(10) {
let (adj, noun) = name.split_once('-').unwrap();
assert!(ADJECTIVES.contains(&adj) && NOUNS.contains(&noun));
}
}
#[test]
fn rerolls_can_differ() {
// 30x30=900 combinations; 20 draws all matching has probability ~1e-40.
let first = generate();
let differs = (0..20).any(|_| generate() != first);
assert!(differs);
}
}
+7 -2
View File
@@ -15,8 +15,8 @@ use uuid::Uuid;
use crate::routes::{self, UrlError};
use crate::{
ApiError, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, Project,
RecentDirs, Session,
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
Project, RecentDirs, Session,
};
/// Time budget for establishing a TCP connection to the daemon.
@@ -143,6 +143,11 @@ impl DaemonClient {
self.get(routes::PROJECTS, &[]).await
}
pub async fn list_branches(&self, path: &str) -> Result<Vec<BranchInfo>, ClientError> {
self.get(routes::BRANCHES, &[(routes::PATH_QUERY, path)])
.await
}
// -- plumbing -----------------------------------------------------------
async fn get<T: DeserializeOwned>(
+101
View File
@@ -53,6 +53,14 @@ pub struct Session {
pub env: HashMap<String, String>,
#[serde(default)]
pub metadata: HashMap<String, Value>,
/// The worktree this session owns, when it was spawned with one.
///
/// Persisted rather than kept in memory only: after a daemon restart the
/// recovered session must still know what to clean up, and with
/// deterministic worktree paths a forgotten association leaves a directory
/// that blocks that branch from ever being spawned again (#54 review).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worktree: Option<SessionWorktree>,
pub status: SessionStatus,
pub created_at: DateTime<Utc>,
/// Current foreground command in the session's pane (e.g. `claude`,
@@ -127,6 +135,70 @@ fn default_true() -> bool {
true
}
/// The worktree a running session owns, carried on [`Session`] so it survives
/// a daemon restart.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionWorktree {
pub path: PathBuf,
pub base_repo: PathBuf,
}
// ---------------------------------------------------------------------------
// Unsaved-work breakdown (#79)
// ---------------------------------------------------------------------------
/// What a worktree holds that would be lost if its directory were destroyed.
///
/// The kill guard refuses `DELETE /api/sessions/:id` with this breakdown, and
/// natural exits (#82) classify with the same daemon-side probe, so the two
/// paths cannot drift. Ignored files never count: every Rust checkout has a
/// `target/`, and counting those would leave no session killable.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct UnsavedWork {
/// Tracked files with uncommitted modifications or staged changes.
#[serde(default)]
pub dirty_tracked_files: u32,
/// Untracked files that are not ignored.
#[serde(default)]
pub untracked_files: u32,
/// Commits on the branch reachable from no remote-tracking ref.
#[serde(default)]
pub unpushed_commits: u32,
}
impl UnsavedWork {
pub fn is_empty(&self) -> bool {
self.dirty_tracked_files == 0 && self.untracked_files == 0 && self.unpushed_commits == 0
}
}
impl std::fmt::Display for UnsavedWork {
/// "3 uncommitted files, 2 untracked files, 2 unpushed commits" — zero
/// parts are omitted; all-zero renders "no unsaved work".
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let parts = [
(self.dirty_tracked_files, "uncommitted file"),
(self.untracked_files, "untracked file"),
(self.unpushed_commits, "unpushed commit"),
];
let said: Vec<String> = parts
.into_iter()
.filter(|(n, _)| *n > 0)
.map(|(n, unit)| {
if n == 1 {
format!("1 {unit}")
} else {
format!("{n} {unit}s")
}
})
.collect();
if said.is_empty() {
return write!(f, "no unsaved work");
}
write!(f, "{}", said.join(", "))
}
}
/// HTTP `POST /api/sessions` body.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreateSessionRequest {
@@ -212,11 +284,30 @@ pub struct Project {
pub last_command: Option<String>,
}
/// One local branch of a repository for `GET /api/branches` (#55). A client
/// disables the entry when `is_head` is set or `worktree` is present — git
/// refuses to check the same branch out in two worktrees at once.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BranchInfo {
/// Short branch name (`main`, `feature/foo`).
pub name: String,
/// Whether this branch is the main checkout's current HEAD. The main
/// checkout's occupancy reports here rather than through `worktree`.
pub is_head: bool,
/// Toplevel path of the linked worktree holding this branch, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worktree: Option<PathBuf>,
}
/// Structured failure used everywhere we don't want to leak a string-only error.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ErrorInfo {
pub code: String,
pub message: String,
/// Set only by the kill guard (#79): the unsaved work that blocked
/// `DELETE /api/sessions/:id`, so a client can render counts directly.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unsaved_work: Option<UnsavedWork>,
}
/// The envelope every `/api/*` failure is wrapped in: `{"error": {…}}`.
@@ -237,6 +328,7 @@ impl ApiError {
error: ErrorInfo {
code: code.into(),
message: message.into(),
unsaved_work: None,
},
}
}
@@ -279,6 +371,13 @@ pub struct DaemonInfo {
pub presets: Vec<SpawnPreset>,
}
/// Payload of `GET /api/names?count=N` (#53) — a batch of fresh
/// `adjective-noun` session handles generated by the `maestro-names` crate.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NameBatch {
pub names: Vec<String>,
}
// ---------------------------------------------------------------------------
// Catalog events (`WS /api/events`)
// ---------------------------------------------------------------------------
@@ -388,6 +487,7 @@ mod tests {
command: "claude".into(),
env: HashMap::from([("FOO".into(), "bar".into())]),
metadata: HashMap::from([("name".into(), Value::String("maestro-impl".into()))]),
worktree: None,
status: SessionStatus::Running,
created_at: DateTime::parse_from_rfc3339("2026-05-24T12:00:00Z")
.unwrap()
@@ -539,6 +639,7 @@ mod tests {
ClientControl::Error(ErrorInfo {
code: "bad_frame".into(),
message: "decode failed".into(),
unsaved_work: None,
}),
resize,
] {
+20 -2
View File
@@ -21,12 +21,20 @@ pub const RECENT_DIRS: &str = "/api/recent-dirs";
pub const PROJECTS: &str = "/api/projects";
/// `GET` the enclosing repository of a path. Takes a `path` query param.
pub const GITROOT: &str = "/api/gitroot";
/// `GET` one repository's local branches. Takes a `path` query param.
pub const BRANCHES: &str = "/api/branches";
/// `GET` a batch of generated session handles (#53). Takes a `count` query
/// param (default 1, capped at [`maestro_names`]' batch cap).
pub const NAMES: &str = "/api/names";
/// `WS` catalog snapshot + session deltas.
pub const EVENTS: &str = "/api/events";
/// Name of the query parameter [`DIRS`] and [`GITROOT`] both take.
/// Name of the query parameter [`DIRS`], [`GITROOT`], and [`BRANCHES`] take.
pub const PATH_QUERY: &str = "path";
/// Name of the query parameter [`NAMES`] takes.
pub const COUNT_QUERY: &str = "count";
/// `DELETE /api/sessions/:id`.
pub fn session(id: Uuid) -> String {
format!("{SESSIONS}/{id}")
@@ -174,7 +182,17 @@ mod tests {
#[test]
fn every_route_sits_under_the_api_prefix() {
for path in [SESSIONS, INFO, DIRS, RECENT_DIRS, PROJECTS, GITROOT, EVENTS] {
for path in [
SESSIONS,
INFO,
DIRS,
RECENT_DIRS,
PROJECTS,
GITROOT,
BRANCHES,
NAMES,
EVENTS,
] {
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
}
assert!(attach(id()).starts_with(API_PREFIX));
+1
View File
@@ -36,6 +36,7 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
command: command.into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
status: SessionStatus::Running,
created_at: Utc::now(),
foreground: None,
+2
View File
@@ -8,12 +8,14 @@ publish.workspace = true
description = "Terminal UI client over the local maestro daemon's `/api/*` surface."
[dependencies]
chrono.workspace = true
clap.workspace = true
crossterm.workspace = true
futures-util.workspace = true
maestro-config.workspace = true
maestro-protocol = { workspace = true, features = ["client"] }
ratatui.workspace = true
reqwest.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["sync", "time", "macros", "rt"] }
+27 -2
View File
@@ -38,7 +38,10 @@ pub enum Action {
// Catalog actions on the highlighted sidebar row
AttachHighlighted,
ToggleAttachHighlighted,
OpenSpawnModal,
NewDraftPane,
/// Fast-forward spawn (#67): spawn immediately with every default
/// resolved; unresolvable defaults open a pre-filled draft instead.
FastForwardSpawn,
KillHighlighted,
// Tabs
@@ -78,7 +81,8 @@ impl Action {
"SidebarPrev" => return Ok(Action::SidebarPrev),
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
"OpenSpawnModal" => return Ok(Action::OpenSpawnModal),
"NewDraftPane" => return Ok(Action::NewDraftPane),
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
"KillHighlighted" => return Ok(Action::KillHighlighted),
"NewTab" => return Ok(Action::NewTab),
"CloseTab" => return Ok(Action::CloseTab),
@@ -197,11 +201,22 @@ mod tests {
assert_eq!(err, ActionParseError::Malformed("GotoTab(3".into()));
}
/// The fast-forward pair sits next to the draft-pane key (#67).
#[test]
fn fast_forward_spawn_round_trips() {
assert_eq!(
Action::parse("FastForwardSpawn").unwrap(),
Action::FastForwardSpawn
);
assert_eq!(Action::FastForwardSpawn.to_string(), "FastForwardSpawn");
}
#[test]
fn display_round_trips() {
for a in [
Action::EnterInsert,
Action::Quit,
Action::NewDraftPane,
Action::FocusPane(Direction::Right),
Action::GotoTab(7),
] {
@@ -209,4 +224,14 @@ mod tests {
assert_eq!(Action::parse(&s).unwrap(), a, "round-trip for {a:?}");
}
}
/// The spawn modal is gone (issue #63); its old action string must not
/// resolve to anything.
#[test]
fn the_removed_spawn_modal_action_no_longer_parses() {
assert_eq!(
Action::parse("OpenSpawnModal").unwrap_err(),
ActionParseError::Unknown("OpenSpawnModal".into())
);
}
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -188,6 +188,7 @@ mod tests {
cwd: std::path::PathBuf::from("/tmp"),
env: Default::default(),
metadata: Default::default(),
worktree: None,
status: SessionStatus::Running,
created_at: Utc::now(),
foreground: None,
+12 -1
View File
@@ -45,7 +45,10 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
("up", "SidebarPrev"),
// Catalog actions
("enter", "AttachHighlighted"),
("s", "OpenSpawnModal"),
("s", "NewDraftPane"),
// Fast-forward spawn (#67): the draft-pane key's neighbour does the same
// thing with every default resolved and no form.
("S", "FastForwardSpawn"),
("x", "KillHighlighted"),
// Tabs
("ctrl-t", "NewTab"),
@@ -155,6 +158,14 @@ mod tests {
cfg.keymap.resolve(Mode::Normal, "g t"),
Some(Action::NextTab),
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "s"),
Some(Action::NewDraftPane),
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "S"),
Some(Action::FastForwardSpawn),
);
}
#[test]
File diff suppressed because it is too large Load Diff
+283
View File
@@ -0,0 +1,283 @@
//! Fast-forward spawn (#67): one keystroke, every default resolved, no form.
//!
//! The defaults mirror what a draft pane would settle on after the operator
//! picked the most recent project and kept their hands off everything else:
//! its last command (or the first non-custom preset), a freshly generated
//! name, and a worktree with a branch named after that name. Any default
//! that cannot be resolved — no projects yet, no command, no generated
//! name — downgrades the keystroke to a draft pane pre-filled with whatever
//! did resolve ([`FastForwardDefaults::apply_to_draft`]).
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use maestro_protocol::{CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
use crate::draft::DraftPane;
/// What one fast-forward keystroke resolved. Every field optional: `None`
/// anywhere means "open a draft instead of spawning".
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FastForwardDefaults {
pub project: Option<Project>,
pub command: Option<String>,
pub name: Option<String>,
}
/// Resolve the fast-forward defaults from the daemon's answers. `name` is
/// `None` when `/api/names` yielded nothing; `presets` may be empty (the
/// caller falls back to the built-ins it already has).
pub fn resolve(
projects: &[Project],
presets: &[SpawnPreset],
name: Option<String>,
now: DateTime<Utc>,
) -> FastForwardDefaults {
let project = crate::projects::sort_projects(projects.to_vec(), now)
.into_iter()
.next();
let command = project
.as_ref()
.and_then(|p| p.last_command.clone())
.filter(|c| !c.trim().is_empty())
.or_else(|| {
presets
.iter()
.find(|p| !p.command.is_empty())
.map(|p| p.command.clone())
});
FastForwardDefaults {
project,
command,
name,
}
}
impl FastForwardDefaults {
/// The spawn request when *every* default resolved: worktree on, new
/// branch named after the generated session name, base repo from the
/// scan (`None` means the project is itself a main checkout).
pub fn build_request(&self) -> Option<CreateSessionRequest> {
let project = self.project.as_ref()?;
let command = self.command.as_deref()?.trim();
let name = self.name.as_deref()?;
if command.is_empty() || name.is_empty() {
return None;
}
let mut metadata = std::collections::HashMap::new();
metadata.insert(
"name".to_string(),
serde_json::Value::String(name.to_string()),
);
Some(CreateSessionRequest {
cwd: project.path.clone(),
command: command.to_string(),
env: Default::default(),
metadata,
worktree: Some(WorktreeSpec {
base_repo: project
.base_repo
.clone()
.unwrap_or_else(|| project.path.clone()),
branch: name.to_string(),
cleanup_on_kill: true,
create_branch: true,
}),
})
}
/// Seed a fresh draft pane with whatever did resolve — the fallback when
/// [`Self::build_request`] is `None`. The operator fills in the rest.
pub fn apply_to_draft(&self, draft: &mut DraftPane) {
if let Some(project) = &self.project {
draft.picked_cwd = Some(PathBuf::from(&project.path));
// The scan already knows this directory's repo (#61): no probe
// round trip needed, exactly like picking the row by hand.
draft.base_repo = Some(
project
.base_repo
.clone()
.unwrap_or_else(|| project.path.clone()),
);
draft.probed_cwd = Some(PathBuf::from(&project.path));
}
if let Some(command) = &self.command {
if let Some(idx) = draft.presets.iter().position(|p| &p.command == command) {
draft.selected_preset = idx;
} else if !command.trim().is_empty() {
// A last_command no preset advertises lands in custom free text.
draft.selected_preset = draft.presets.len().saturating_sub(1);
draft.custom_command = crate::text_input::TextInput::with_value(command);
}
}
if let Some(name) = &self.name {
draft.set_name(name.clone());
// Seeded, not generated: a later refill must not clobber it.
draft.name_touched = true;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeDelta;
fn project(path: &str, days_ago: i64, cmd: Option<&str>) -> Project {
Project {
path: PathBuf::from(path),
name: path.rsplit('/').next().unwrap_or(path).into(),
is_worktree: false,
base_repo: None,
last_activity: Some(Utc::now() - TimeDelta::days(days_ago)),
last_command: cmd.map(String::from),
}
}
fn repo_project(path: &str, base: &str, days_ago: i64, cmd: Option<&str>) -> Project {
Project {
base_repo: Some(PathBuf::from(base)),
..project(path, days_ago, cmd)
}
}
fn preset(id: &str, command: &str) -> SpawnPreset {
SpawnPreset {
id: id.into(),
label: id.into(),
command: command.into(),
}
}
#[test]
fn resolves_every_default_from_the_most_recent_project() {
let projects = vec![
project("/repos/old", 30, Some("zsh")),
repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")),
];
let presets = vec![preset("aider", "aider"), preset("custom", "")];
let r = resolve(&projects, &presets, Some("amber-falcon".into()), Utc::now());
assert_eq!(
r.project.as_ref().unwrap().path,
PathBuf::from("/repos/fresh")
);
assert_eq!(r.command.as_deref(), Some("claude"));
assert_eq!(r.name.as_deref(), Some("amber-falcon"));
let req = r.build_request().expect("all defaults resolved");
assert_eq!(req.cwd, PathBuf::from("/repos/fresh"));
assert_eq!(req.command, "claude");
assert_eq!(
req.metadata.get("name").and_then(|v| v.as_str()),
Some("amber-falcon")
);
let wt = req.worktree.expect("worktree on");
assert_eq!(wt.base_repo, PathBuf::from("/repos/fresh"));
assert_eq!(wt.branch, "amber-falcon");
assert!(wt.create_branch, "fresh branch named after the session");
}
#[test]
fn worktree_of_the_most_recent_project_targets_its_base_repo() {
let projects = vec![repo_project("/repos/main/wt-x", "/repos/main", 0, None)];
let r = resolve(
&projects,
&[preset("zsh", "zsh")],
Some("s".into()),
Utc::now(),
);
let wt = r.build_request().unwrap().worktree.unwrap();
assert_eq!(wt.base_repo, PathBuf::from("/repos/main"));
}
#[test]
fn missing_last_command_falls_back_to_the_first_non_custom_preset() {
let projects = vec![project("/repos/p", 0, None)];
let presets = vec![preset("custom", ""), preset("claude", "claude")];
let r = resolve(&projects, &presets, Some("n".into()), Utc::now());
assert_eq!(r.command.as_deref(), Some("claude"));
}
#[test]
fn an_empty_last_command_is_no_command_at_all() {
let projects = vec![project("/repos/p", 0, Some(""))];
let presets = vec![preset("custom", "")];
let r = resolve(&projects, &presets, Some("n".into()), Utc::now());
assert_eq!(r.command, None);
assert!(r.build_request().is_none());
}
#[test]
fn nothing_resolves_when_no_projects_are_known_yet() {
let r = resolve(
&[],
&[preset("claude", "claude")],
Some("n".into()),
Utc::now(),
);
assert_eq!(r.project, None);
// No project → no directory → no request, even with command + name.
assert!(r.build_request().is_none());
}
#[test]
fn a_missing_generated_name_downgrades_to_a_draft() {
let projects = vec![project("/repos/p", 0, Some("claude"))];
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
assert!(r.build_request().is_none(), "no name → no immediate spawn");
}
#[test]
fn unresolvable_defaults_still_seed_the_draft_with_what_resolved() {
let projects = vec![repo_project("/repos/p", "/repos/p", 0, Some("aider"))];
let r = resolve(&projects, &[preset("aider", "aider")], None, Utc::now());
let mut draft = DraftPane::default();
r.apply_to_draft(&mut draft);
assert_eq!(draft.cwd(), Some(PathBuf::from("/repos/p")));
assert_eq!(draft.base_repo, Some(PathBuf::from("/repos/p")));
assert_eq!(draft.command(), "aider");
assert!(
draft.name.value().is_empty(),
"nothing to seed the name with"
);
assert!(draft.worktree_enabled);
}
#[test]
fn a_resolved_name_survives_late_refills_and_tracks_the_branch() {
let projects = vec![project("/repos/p", 0, Some("claude"))];
let named = resolve(
&projects,
&[preset("claude", "claude")],
Some("amber-falcon".into()),
Utc::now(),
);
let mut draft = DraftPane::default();
named.apply_to_draft(&mut draft);
// A later batch must not overwrite the name the spawn was seeded with.
draft.seed_names(vec!["late-name".into()]);
assert_eq!(draft.name.value(), "amber-falcon");
assert_eq!(draft.branch.value(), "amber-falcon");
}
#[test]
fn an_unresolved_name_still_gets_generated_by_a_later_batch() {
let projects = vec![project("/repos/p", 0, Some("claude"))];
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
let mut draft = DraftPane::default();
r.apply_to_draft(&mut draft);
draft.seed_names(vec!["late-name".into()]);
assert_eq!(draft.name.value(), "late-name");
}
#[test]
fn a_last_command_no_preset_advertises_lands_in_custom_text() {
let projects = vec![project("/repos/p", 0, Some("nvim -d"))];
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
let mut draft = DraftPane::default();
r.apply_to_draft(&mut draft);
assert_eq!(draft.command(), "nvim -d");
assert!(draft.is_custom());
}
}
+3
View File
@@ -7,11 +7,14 @@ pub mod app;
pub mod catalog;
pub mod cli;
pub mod config;
pub mod draft;
pub mod emulators;
pub mod fast_forward;
pub mod input;
pub mod keymap;
pub mod overlays;
pub mod pane;
pub mod projects;
pub mod render;
pub mod session_task;
pub mod sidebar;
+7 -249
View File
@@ -1,161 +1,16 @@
//! Overlay widgets: spawn modal, kill-confirm prompt, help overlay,
//! transient toast.
//! Overlay widgets: kill-confirm prompt, help overlay, transient toast.
//!
//! The spawn form is no longer an overlay (issue #63) — it lives in draft
//! panes ([`crate::draft::DraftPane`]).
//!
//! All overlays are centered popups drawn over the rest of the UI; the
//! `Clear` widget wipes the underlying buffer before drawing.
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
use ratatui::Frame;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum SpawnField {
#[default]
Cwd,
Command,
Submit,
}
#[derive(Debug, Default, Clone)]
pub struct SpawnModal {
pub cwd: crate::text_input::TextInput,
pub command: crate::text_input::TextInput,
pub focused_field: SpawnField,
}
impl SpawnModal {
pub fn next_field(&mut self) {
self.focused_field = match self.focused_field {
SpawnField::Cwd => SpawnField::Command,
SpawnField::Command => SpawnField::Submit,
SpawnField::Submit => SpawnField::Cwd,
};
}
pub fn prev_field(&mut self) {
self.focused_field = match self.focused_field {
SpawnField::Cwd => SpawnField::Submit,
SpawnField::Command => SpawnField::Cwd,
SpawnField::Submit => SpawnField::Command,
};
}
/// Handle one keystroke while this modal owns input. Returns
/// [`SpawnInputResult::Submit`] when the user accepts; the caller fires
/// the POST and dismisses the modal.
pub fn on_input(&mut self, key: &crate::input::InputKey) -> SpawnInputResult {
use crate::input::InputKey;
match key {
InputKey::Tab => {
self.next_field();
SpawnInputResult::Consumed
}
InputKey::BackTab => {
self.prev_field();
SpawnInputResult::Consumed
}
InputKey::Esc => SpawnInputResult::Cancel,
InputKey::Enter => match self.focused_field {
SpawnField::Submit => SpawnInputResult::Submit,
_ => {
self.next_field();
SpawnInputResult::Consumed
}
},
// Everything else — characters, motion, editing, paste — goes to
// the focused field's text input.
other => {
match self.focused_field {
SpawnField::Cwd => self.cwd.handle_key(other),
SpawnField::Command => self.command.handle_key(other),
SpawnField::Submit => {}
}
SpawnInputResult::Consumed
}
}
}
pub fn render(&self, f: &mut Frame, parent: Rect) {
let area = centered(parent, 60, 10);
f.render_widget(Clear, area);
let block = Block::default().title(" spawn ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let rows = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(1),
])
.split(inner);
let highlight = |field: SpawnField| {
if self.focused_field == field {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
}
};
// Each text field renders through the shared `TextInput`, which
// handles horizontal scrolling and reports where the terminal cursor
// belongs. Label is fixed-width 8 chars + 1 space = column 9.
let label =
|text: &str| Paragraph::new(Line::from(ratatui::text::Span::raw(format!("{text:8} "))));
f.render_widget(label("cwd:"), rows[0]);
f.render_widget(label("command:"), rows[1]);
let draw_field = |f: &mut Frame,
input: &crate::text_input::TextInput,
row: Rect,
focused: bool|
-> u16 {
let style = if focused {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
let area = Rect {
x: row.x.saturating_add(9),
y: row.y,
width: row.width.saturating_sub(9),
height: 1,
};
input.render(area, f.buffer_mut(), style)
};
let cwd_col = draw_field(f, &self.cwd, rows[0], self.focused_field == SpawnField::Cwd);
let cmd_col = draw_field(
f,
&self.command,
rows[1],
self.focused_field == SpawnField::Command,
);
f.render_widget(
Paragraph::new(Line::from(vec![ratatui::text::Span::styled(
"[ Spawn ]",
highlight(SpawnField::Submit),
)])),
rows[2],
);
match self.focused_field {
SpawnField::Cwd => f.set_cursor_position((cwd_col, rows[0].y)),
SpawnField::Command => f.set_cursor_position((cmd_col, rows[1].y)),
SpawnField::Submit => {
// The submit button is indicated by the REVERSED style; no
// cursor placement.
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpawnInputResult {
Consumed,
Cancel,
Submit,
}
#[derive(Debug, Clone)]
pub struct KillConfirm {
pub label: String,
@@ -262,33 +117,6 @@ mod tests {
out
}
#[test]
fn spawn_modal_renders_field_labels() {
let modal = SpawnModal {
cwd: crate::text_input::TextInput::with_value("/x"),
command: crate::text_input::TextInput::with_value("bash"),
focused_field: SpawnField::Cwd,
};
let rendered = render_to_string(80, 20, |f| modal.render(f, f.area()));
for s in ["cwd", "command", "Spawn", "/x", "bash"] {
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
}
}
#[test]
fn spawn_modal_field_rotation() {
let mut modal = SpawnModal::default();
assert_eq!(modal.focused_field, SpawnField::Cwd);
modal.next_field();
assert_eq!(modal.focused_field, SpawnField::Command);
modal.next_field();
assert_eq!(modal.focused_field, SpawnField::Submit);
modal.next_field();
assert_eq!(modal.focused_field, SpawnField::Cwd);
modal.prev_field();
assert_eq!(modal.focused_field, SpawnField::Submit);
}
#[test]
fn kill_confirm_renders_prompt() {
let modal = KillConfirm {
@@ -306,81 +134,11 @@ mod tests {
let help = HelpOverlay {
lines: vec![
"i EnterInsert".into(),
"s OpenSpawnModal".into(),
"s NewDraftPane".into(),
],
};
let rendered = render_to_string(80, 12, |f| help.render(f, f.area()));
assert!(rendered.contains("EnterInsert"));
assert!(rendered.contains("OpenSpawnModal"));
}
/// Shift-Tab walked nowhere: `prev_field` existed with no caller, so the
/// spawn modal could only be cycled forwards.
#[test]
fn shift_tab_walks_the_spawn_modal_backwards() {
let mut modal = SpawnModal::default();
assert_eq!(modal.focused_field, SpawnField::Cwd);
modal.on_input(&crate::input::InputKey::BackTab);
assert_eq!(modal.focused_field, SpawnField::Submit);
modal.on_input(&crate::input::InputKey::BackTab);
assert_eq!(modal.focused_field, SpawnField::Command);
}
#[test]
fn tab_and_shift_tab_are_inverses() {
let mut modal = SpawnModal::default();
for _ in 0..3 {
modal.on_input(&crate::input::InputKey::Tab);
modal.on_input(&crate::input::InputKey::BackTab);
assert_eq!(modal.focused_field, SpawnField::Cwd);
}
}
/// Editing keys must reach the focused field's text input, not a bare
/// String — this is what gives the operator cursor motion and word ops.
#[test]
fn editing_keys_reach_the_focused_text_input() {
let mut modal = SpawnModal::default();
assert_eq!(modal.focused_field, SpawnField::Cwd);
for k in [
crate::input::InputKey::char('/'),
crate::input::InputKey::char('t'),
crate::input::InputKey::char('m'),
crate::input::InputKey::char('p'),
] {
modal.on_input(&k);
}
modal.on_input(&crate::input::InputKey::Left);
modal.on_input(&crate::input::InputKey::Backspace);
assert_eq!(modal.cwd.value(), "/tp");
// Tab to command, then a paste and ctrl-w land there, not in cwd.
modal.on_input(&crate::input::InputKey::Tab);
assert_eq!(modal.focused_field, SpawnField::Command);
modal.on_input(&crate::input::InputKey::Paste("/tmp/foo".into()));
assert_eq!(modal.command.value(), "/tmp/foo");
modal.on_input(&crate::input::InputKey::ctrl_char('w'));
assert_eq!(modal.command.value(), "");
assert_eq!(modal.cwd.value(), "/tp", "cwd untouched while unfocused");
}
/// The terminal cursor must sit right after the typed text in the focused
/// field (label is 8 chars + 1 space, modal is centered on screen).
#[test]
fn render_places_the_cursor_after_the_typed_text() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let modal = SpawnModal {
cwd: crate::text_input::TextInput::with_value("abc"),
..SpawnModal::default()
};
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| modal.render(f, f.area())).unwrap();
// Modal: x = (80-60)/2 = 10; block border → inner.x = 11; label col 9.
let pos = terminal.get_cursor_position().unwrap();
assert_eq!(pos, ratatui::layout::Position::new(11 + 9 + 3, 6));
assert!(rendered.contains("NewDraftPane"));
}
}
+216
View File
@@ -0,0 +1,216 @@
//! Pure helpers behind the draft pane's project picker (#64): staleness,
//! recency ordering, worktree grouping, and free-text filtering. Mirrors
//! `web/src/projectList.ts` so both clients order and dim identically — the
//! daemon reports the timestamps; what "stale" means is a client decision
//! (#54).
use chrono::{DateTime, TimeDelta, Utc};
use maestro_protocol::Project;
/// Projects whose last activity is older than this are dimmed and sort after
/// fresh ones — still reachable, visibly stale.
pub const STALE_AFTER_DAYS: i64 = 7;
/// True when a project has no usable activity timestamp, one in the future,
/// or its last activity is older than [`STALE_AFTER_DAYS`] days.
pub fn is_stale(project: &Project, now: DateTime<Utc>) -> bool {
match project.last_activity {
None => true,
Some(t) => {
let age = now.signed_duration_since(t);
age < TimeDelta::zero() || age > TimeDelta::days(STALE_AFTER_DAYS)
}
}
}
fn time_of(project: &Project) -> DateTime<Utc> {
project.last_activity.unwrap_or(DateTime::UNIX_EPOCH)
}
/// Fresh projects first, each block newest-activity-first; unknown or future
/// timestamps sort as stalest. Ties fall back to name, then path, so the
/// order is stable.
pub fn sort_projects(mut projects: Vec<Project>, now: DateTime<Utc>) -> Vec<Project> {
projects.sort_by(|a, b| {
let stale = is_stale(a, now).cmp(&is_stale(b, now));
stale
.then_with(|| time_of(b).cmp(&time_of(a)))
.then_with(|| a.name.cmp(&b.name))
.then_with(|| a.path.cmp(&b.path))
});
projects
}
/// Substring filter over project name and full path, case-insensitive.
pub fn matches_filter(project: &Project, query: &str) -> bool {
let q = query.trim().to_lowercase();
if q.is_empty() {
return true;
}
project.name.to_lowercase().contains(&q)
|| project.path.to_string_lossy().to_lowercase().contains(&q)
}
/// One repository group: the main checkout followed by its linked worktrees.
/// A group's position follows its freshest member, since a worktree worked on
/// today should pull its (dormant) base repo up with it.
#[derive(Debug)]
pub struct ProjectGroup<'a> {
pub main: &'a Project,
pub worktrees: Vec<&'a Project>,
}
/// Group `sorted` projects by their base repository. A worktree nests under
/// its main checkout even when the main sorts later; a main checkout missing
/// from the list doesn't drop its worktrees.
pub fn group_projects(sorted: &[Project]) -> Vec<ProjectGroup<'_>> {
let mut members: Vec<(String, Vec<&Project>)> = Vec::new();
for p in sorted {
let key = p
.base_repo
.as_ref()
.unwrap_or(&p.path)
.to_string_lossy()
.into_owned();
match members.iter_mut().find(|(k, _)| *k == key) {
Some((_, list)) => list.push(p),
None => members.push((key, vec![p])),
}
}
members
.into_iter()
.map(|(key, mut list)| {
let main_pos = list
.iter()
.position(|p| p.path.to_string_lossy() == key && !p.is_worktree)
.unwrap_or(0);
let main = list.remove(main_pos);
// Keep the daemon's newest-first order within the group.
list.sort_by_key(|p| std::cmp::Reverse(time_of(p)));
ProjectGroup {
main,
worktrees: list,
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn days_ago(days: i64) -> DateTime<Utc> {
Utc::now() - TimeDelta::days(days)
}
fn project(name: &str, path: &str, last_activity: Option<DateTime<Utc>>) -> Project {
Project {
path: PathBuf::from(path),
name: name.into(),
is_worktree: false,
base_repo: None,
last_activity,
last_command: None,
}
}
fn worktree(
name: &str,
path: &str,
base: &str,
last_activity: Option<DateTime<Utc>>,
) -> Project {
Project {
is_worktree: true,
base_repo: Some(PathBuf::from(base)),
..project(name, path, last_activity)
}
}
#[test]
fn unknown_and_future_activity_are_stale() {
let now = Utc::now();
assert!(is_stale(&project("x", "/x", None), now));
assert!(is_stale(
&project("x", "/x", Some(now + TimeDelta::hours(1))),
now
));
}
#[test]
fn activity_within_a_week_is_fresh_beyond_is_stale() {
let now = Utc::now();
assert!(!is_stale(&project("x", "/x", Some(days_ago(6))), now));
assert!(is_stale(&project("x", "/x", Some(days_ago(8))), now));
}
#[test]
fn fresh_sorts_before_stale_then_newest_first() {
let now = Utc::now();
let sorted = sort_projects(
vec![
project("old-fresh", "/a", Some(days_ago(5))),
project("stale", "/b", Some(days_ago(30))),
project("newest", "/c", Some(days_ago(1))),
project("unknown", "/d", None),
],
now,
);
let names: Vec<&str> = sorted.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["newest", "old-fresh", "stale", "unknown"]);
}
#[test]
fn ties_break_by_name_then_path() {
let now = Utc::now();
let t = Some(days_ago(2));
let sorted = sort_projects(vec![project("b", "/b", t), project("a", "/a", t)], now);
let names: Vec<&str> = sorted.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["a", "b"]);
}
#[test]
fn filter_matches_name_or_path_case_insensitively() {
let p = project("Maestro", "/home/u/tea/Maestro", None);
assert!(matches_filter(&p, "maes"));
assert!(matches_filter(&p, " tea "));
assert!(!matches_filter(&p, "zzz"));
assert!(matches_filter(&p, ""), "empty query matches everything");
}
#[test]
fn worktrees_nest_under_their_main_checkout() {
let now = Utc::now();
let main = project("maestro", "/repos/maestro", Some(days_ago(10)));
let wt = worktree("64-tui", "/repos/wt64", "/repos/maestro", Some(days_ago(1)));
let other = project("other", "/repos/other", Some(days_ago(2)));
let sorted = sort_projects(vec![main, wt, other], now);
let groups = group_projects(&sorted);
assert_eq!(groups.len(), 2);
assert_eq!(
groups[0].main.name, "maestro",
"fresh worktree pulls its repo up"
);
assert_eq!(groups[0].worktrees.len(), 1);
assert_eq!(groups[0].worktrees[0].name, "64-tui");
assert_eq!(groups[1].main.name, "other");
}
/// A main checkout absent from the scan must not drop its worktrees.
#[test]
fn worktrees_survive_a_missing_main_checkout() {
let now = Utc::now();
let wt = worktree(
"solo-wt",
"/repos/solo-wt",
"/repos/solo",
Some(days_ago(1)),
);
let sorted = sort_projects(vec![wt], now);
let groups = group_projects(&sorted);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].main.name, "solo-wt");
assert!(groups[0].worktrees.is_empty());
}
}
+67 -7
View File
@@ -10,7 +10,7 @@ use ratatui::Frame;
use crate::input::Region;
use crate::keymap::Mode;
use crate::overlays::{HelpOverlay, KillConfirm, SpawnModal, Toast};
use crate::overlays::{HelpOverlay, KillConfirm, Toast};
use crate::pane::Pane;
use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind};
@@ -63,8 +63,9 @@ pub struct RenderInputs<'a> {
pub sidebar: &'a Sidebar,
pub tab_bar: &'a TabBar,
pub pane: Option<&'a Pane>,
/// Inline spawn form when the active tab's pane is a draft (issue #63).
pub draft: Option<&'a crate::draft::DraftPane>,
pub state: &'a AppState,
pub spawn: &'a SpawnModal,
pub help: &'a HelpOverlay,
}
@@ -131,6 +132,9 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
if let Some(p) = inputs.pane {
p.render(f, pane_area);
}
if let Some(d) = inputs.draft {
d.render(f, pane_area, pane_focused);
}
map.pane = Some(pane_area);
// Status bar — always visible, never eclipsed by overlays so the user can
@@ -138,10 +142,6 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
render_status_bar(f, status_area, inputs.state);
match inputs.state.overlay() {
Some(OverlayKind::Spawn) => {
inputs.spawn.render(f, area);
map.modal = Some(area);
}
Some(OverlayKind::KillConfirm(id)) => {
let kc = KillConfirm {
label: id.to_string(),
@@ -186,7 +186,7 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) {
Focus::Pane => "pane",
};
let hint = match state.mode {
Mode::Normal => "j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit",
Mode::Normal => "j/k: nav • enter: attach • s: new draft • ?: help • ^Wq: quit",
Mode::Insert => "^a: back to normal",
};
let mode_style = match state.mode {
@@ -266,4 +266,64 @@ mod tests {
assert_eq!(map.region_at(15, 10), Region::Modal);
assert_eq!(map.region_at(3, 12), Region::SidebarRow); // outside modal
}
/// Issue #63: an empty pane is a draft — the frame must show the spawn
/// form where the terminal would otherwise be.
#[test]
fn a_draft_pane_renders_the_spawn_form_in_its_pane_area() {
use crate::overlays::HelpOverlay;
use crate::state::PaneRef;
use std::collections::HashSet;
let mut state = AppState::new();
state.open_draft_pane();
let draft = match &state.tabs[0].pane {
Some(PaneRef::Draft(d)) => d,
other => panic!("expected a draft pane, got {other:?}"),
};
let sidebar = Sidebar {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
};
let tab_bar = TabBar {
tab_names: vec!["1".into()],
active_idx: 0,
};
let help = HelpOverlay { lines: Vec::new() };
let inputs = RenderInputs {
sidebar: &sidebar,
tab_bar: &tab_bar,
pane: None,
draft: Some(draft),
state: &state,
help: &help,
};
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| {
draw(f, inputs);
})
.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');
}
for s in [
"draft",
"cmd:",
"dir:",
"name:",
"[ Spawn ]",
"Other directory…",
] {
assert!(out.contains(s), "missing {s:?}:\n{out}");
}
}
}
+2
View File
@@ -92,6 +92,7 @@ mod tests {
cwd: std::path::PathBuf::from("/tmp"),
env: Default::default(),
metadata,
worktree: None,
status: SessionStatus::Running,
created_at: Utc::now(),
foreground: None,
@@ -179,6 +180,7 @@ mod tests {
cwd: std::path::PathBuf::from("/tmp"),
env: Default::default(),
metadata: HashMap::new(),
worktree: None,
status: SessionStatus::Running,
created_at: Utc::now(),
foreground: None,
+112 -15
View File
@@ -1,14 +1,16 @@
//! `AppState` — the top-level mutable state of the running TUI.
//!
//! v1 keeps it small on purpose: a list of tabs (each bound to at most one
//! session), an active tab index, focus split between sidebar and pane, the
//! current input mode, and an optional overlay (spawn modal, kill confirm,
//! help, transient toast).
//! session or a draft form), an active tab index, focus split between sidebar
//! and pane, the current input mode, and an optional overlay (kill confirm,
//! help, transient toast). The spawn form is no longer an overlay — an empty
//! pane is a draft pane (issue #63).
//!
//! v1.5 will replace `Tab::pane: Option<SessionId>` with a split tree.
//! v1.5 will replace `Tab::pane`'s single slot with a split tree.
use uuid::Uuid;
use crate::draft::DraftPane;
use crate::keymap::Mode;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -17,15 +19,34 @@ pub enum Focus {
Pane,
}
/// What a tab's pane slot holds: a live session, or an inline draft form.
// The draft variant dwarfs `Session(Uuid)`; tabs are moved rarely enough
// that boxing every access costs more than the copy does.
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq)]
pub enum PaneRef {
Session(Uuid),
Draft(DraftPane),
}
#[derive(Debug, Clone)]
pub struct Tab {
pub name: String,
pub pane: Option<Uuid>,
pub pane: Option<PaneRef>,
}
impl Tab {
/// The session this tab shows, if it shows one. Draft panes have none.
pub fn session(&self) -> Option<Uuid> {
match &self.pane {
Some(PaneRef::Session(sid)) => Some(*sid),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OverlayKind {
Spawn,
KillConfirm(Uuid),
Help,
Toast(String),
@@ -108,7 +129,8 @@ impl AppState {
///
/// Returns the session that tab was showing, if any, so the caller can
/// release its emulator. Without that the tab's WebSocket task and vt100
/// parser outlive the tab forever.
/// parser outlive the tab forever. A draft pane holds no session and
/// releases nothing.
pub fn close_tab(&mut self) -> Option<Uuid> {
if self.tabs.len() <= 1 {
return None;
@@ -117,9 +139,10 @@ impl AppState {
if self.active_tab_idx >= self.tabs.len() {
self.active_tab_idx = self.tabs.len() - 1;
}
let sid = closed.session()?;
// Only release it if no other tab still shows the same session.
let still_shown = self.tabs.iter().any(|t| t.pane == closed.pane);
closed.pane.filter(|_| !still_shown)
let still_shown = self.tabs.iter().any(|t| t.session() == Some(sid));
(!still_shown).then_some(sid)
}
pub fn next_tab(&mut self) {
@@ -147,11 +170,37 @@ impl AppState {
/// drop straight into Insert mode so keystrokes go to the session
/// without an extra `i` press. The leader returns to Normal.
pub fn attach_to_focused(&mut self, session_id: Uuid) {
self.active_tab_mut().pane = Some(session_id);
self.active_tab_mut().pane = Some(PaneRef::Session(session_id));
self.focus = Focus::Pane;
self.mode = Mode::Insert;
}
/// Open a draft form in a pane (issue #63): reuse the empty active pane,
/// otherwise open a fresh tab for it — v1 has no split tree, so "split"
/// means a new tab. Focus lands in the pane, Insert mode on, so typing
/// starts immediately, same flow as [`Self::attach_to_focused`].
pub fn open_draft_pane(&mut self) {
if self.active_tab().pane.is_some() {
self.new_tab();
}
self.active_tab_mut().pane = Some(PaneRef::Draft(DraftPane::default()));
self.focus = Focus::Pane;
self.mode = Mode::Insert;
}
/// A draft submit succeeded: replace whatever the active tab showed with
/// the new session in place, matching the web client's behaviour (#57).
pub fn complete_spawn(&mut self, sid: Uuid) {
self.active_tab_mut().pane = Some(PaneRef::Session(sid));
self.focus = Focus::Pane;
self.mode = Mode::Insert;
}
/// The session shown by the active tab's pane, if any.
pub fn active_session(&self) -> Option<Uuid> {
self.active_tab().session()
}
/// Move the sidebar highlight by `delta` (positive = down) within the
/// given ordered list of selectable session ids. Wraps. No-op on empty.
pub fn move_sidebar_highlight(&mut self, order: &[Uuid], delta: i32) {
@@ -288,7 +337,7 @@ mod tests {
let mut state = AppState::new();
let sid = Uuid::from_u128(7);
state.attach_to_focused(sid);
assert_eq!(state.tabs[0].pane, Some(sid));
assert_eq!(state.tabs[0].session(), Some(sid));
assert_eq!(state.focus, Focus::Pane);
// The user expects to type into the session right away; the leader
// (default ctrl-a) is the way back.
@@ -314,7 +363,7 @@ mod tests {
let mut state = AppState::new();
let sid = Uuid::from_u128(3);
state.new_tab();
state.active_tab_mut().pane = Some(sid);
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
assert_eq!(state.close_tab(), Some(sid));
}
@@ -324,9 +373,9 @@ mod tests {
fn close_tab_keeps_a_session_another_tab_still_shows() {
let mut state = AppState::new();
let sid = Uuid::from_u128(4);
state.active_tab_mut().pane = Some(sid);
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
state.new_tab();
state.active_tab_mut().pane = Some(sid);
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
assert_eq!(state.close_tab(), None);
}
@@ -334,7 +383,7 @@ mod tests {
#[test]
fn close_tab_is_a_no_op_on_the_last_tab() {
let mut state = AppState::new();
state.active_tab_mut().pane = Some(Uuid::from_u128(5));
state.active_tab_mut().pane = Some(PaneRef::Session(Uuid::from_u128(5)));
assert_eq!(state.close_tab(), None);
assert_eq!(state.tabs.len(), 1);
}
@@ -347,4 +396,52 @@ mod tests {
assert!(state.dismiss_overlay());
assert!(state.overlay().is_none());
}
/// Issue #63: `s` over an empty pane turns that pane into a draft —
/// focused, Insert mode on, typing starts immediately.
#[test]
fn open_draft_reuses_the_empty_active_pane() {
let mut state = AppState::new();
state.open_draft_pane();
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_))));
assert_eq!(state.focus, Focus::Pane);
assert_eq!(state.mode, Mode::Insert);
assert_eq!(state.tabs.len(), 1);
}
/// A tab already showing a session gets a fresh tab for the draft; v1
/// has no split tree, so a new tab is the "split".
#[test]
fn open_draft_over_a_session_opens_a_new_tab() {
let mut state = AppState::new();
state.attach_to_focused(Uuid::from_u128(9));
state.open_draft_pane();
assert_eq!(state.tabs.len(), 2);
assert_eq!(state.active_tab_idx, 1);
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_))));
// The session tab is untouched.
assert_eq!(state.tabs[0].session(), Some(Uuid::from_u128(9)));
}
/// Web parity (#57): submitting fills the pane in place.
#[test]
fn complete_spawn_replaces_the_draft_with_the_session() {
let mut state = AppState::new();
state.open_draft_pane();
let sid = Uuid::from_u128(21);
state.complete_spawn(sid);
assert_eq!(state.active_tab().session(), Some(sid));
assert_eq!(state.focus, Focus::Pane);
assert_eq!(state.mode, Mode::Insert);
}
/// A closed draft tab holds no session, so nothing is released.
#[test]
fn close_tab_reports_nothing_for_a_draft_pane() {
let mut state = AppState::new();
state.open_draft_pane();
state.new_tab();
state.active_tab_idx = 0;
assert_eq!(state.close_tab(), None);
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ use ratatui::style::Style;
use crate::input::InputKey;
#[derive(Debug, Default, Clone)]
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct TextInput {
value: String,
/// Cursor position as a char offset into `value`.
+127
View File
@@ -0,0 +1,127 @@
# Worktrees
Maestro can create a git worktree for a session and is responsible for it from
that moment until the worktree is removed. This document is the contract for
that lifetime: where a worktree goes, what ends a session, what survives, and
what the operator has to do by hand.
It exists because the rules here are not guessable from the code. Every rule
below was decided once, re-litigated in review, and decided the same way again.
The reasoning is written down alongside each rule so the next reader does not
have to reconstruct it.
## Nothing is ever adopted
The daemon only ever removes a worktree it created in this session's lifetime or
recorded in its retained store. It never takes ownership of a directory it finds
on disk, whatever that directory looks like.
There is no reliable way to ask "is this worktree mine?". A git worktree carries
no marker distinguishing one a daemon made from one a human made ten minutes
earlier, and any heuristic that answers the question will eventually answer it
wrong — in the direction of deleting somebody's work.
So the daemon does not ask. If the target path is occupied by anything at all, it
picks a different path.
## Placement
A worktree for branch `<branch>` of repository `<repo>` goes to
`<repo>/worktrees/<branch>`, where `<repo>` is the main checkout — not whichever
worktree the request happened to come from.
The branch name is used verbatim as a relative path, so `feature/login` becomes
`worktrees/feature/login`. A branch name that cannot be a relative path falls
back to a short hash of the branch.
If that path exists, the daemon appends `-2`, then `-3`, and so on until it finds
a free one. The suffix names the **directory only**. The branch is whatever was
requested; a second worktree of the same branch is not what happens here, because
git refuses to check the same branch out twice and the branch picker already
marks branches that are checked out elsewhere.
### The `worktree_root` override
`[daemon].worktree_root` moves every worktree out of the repository and under one
root. The per-repository directory is the repository's basename plus a short
digest of its full path — the digest is what keeps two repositories with the same
basename apart. Inside it the layout and the collision suffix are the same.
## What ends a session
`DELETE /api/sessions/:id` is **refused** when the session's worktree holds
unsaved work, meaning any of:
- uncommitted changes to tracked files
- untracked files that are not ignored
- commits on the branch reachable from no remote-tracking ref
Ignored files never block. Every Rust worktree has a `target/`; if ignored files
counted, no session would ever be killable.
The refusal is a structured error naming what blocked it and how much of it there
is, so a client can say "3 uncommitted files, 2 unpushed commits". The session
keeps running and the worktree is untouched.
The point of refusing rather than cleaning up is that the session stays as the
operator's handle on their own unfinished work. A session that is killed while
its worktree still matters leaves a directory nobody is looking at.
A consequence, accepted deliberately: a feature branch almost always has unpushed
commits, so `--force` is the ordinary way to end a session, not an exception.
That is only safe because force retains.
### `--force`
`--force` overrides the refusal and nothing else. It ends the session and
**retains** the worktree, which enters the retained store with reason
`force-killed`. Force never removes a tree that would have blocked.
Ending a session and destroying its work are two separate destructive acts. Each
is chosen on its own, and the second one — reclaim — is chosen while looking at
exactly what would be lost.
### Exiting on its own
A session that ends by itself — the command finished, the operator typed `exit` —
never reaches the kill path. When the watcher notices the tmux session is gone it
applies the same rule: a pristine worktree is removed, anything else is retained
with reason `exited-with-unsaved-work`.
Nobody is waiting for an answer here, so there is nothing to refuse; the choice is
only between removing and retaining. It uses the same predicate as the kill, from
the same place, so the two paths cannot drift apart.
## The retained store
A `state_dir`-backed record of worktrees the daemon created that no longer have a
session. For each one: the path, the base repository, the branch, why it was
retained, and when.
It survives a daemon restart. It is the recovery path for everything above — a
store that is lost on restart is worse than no store, because the worktrees are
still on disk with nothing pointing at them.
Entries leave when the worktree is reclaimed, and when the daemon notices the
directory is gone because somebody removed it by hand.
**It is not an ownership record.** It must never be consulted to decide whether a
path may be adopted — see the first rule. Its only job is to remember what the
daemon is holding so that it can be listed and reclaimed.
## Reclaim
`GET /api/worktrees` lists the store: path, base repository, branch, reason, when,
and what is currently in the way. The in-the-way breakdown is recomputed on read,
not stored, so a tree whose work has since been pushed says so.
`DELETE /api/worktrees/<id>` reclaims one. It removes the directory and drops the
store entry. It does **not** consult the guard — reclaim is the deliberate
destructive act, and the operator chose it while looking at what was in the way.
It never deletes the branch. `git worktree remove` leaves the ref, and reclaiming
a tree must not throw away commits on it. This is worth saying out loud in every
client, because "reclaim" otherwise sounds like it takes the commits too.
Nothing is reclaimed automatically. There is no age-based expiry and no bulk
reclaim.
+5 -4
View File
@@ -20,9 +20,10 @@ src/
main.ts # entry point, hash-route dispatch
api.ts # daemon HTTP API client
events.ts # `/api/events` WebSocket subscriber
catalog.ts # session tree view (groups by working directory)
attach.ts # xterm.js + attach WS view
spawn.ts # spawn-session modal
catalog.ts # session tree view (groups by working directory)
attach.ts # xterm.js + attach WS view
multiview.ts # tabs + split panes; draft panes host the spawn form
spawnForm.ts # shared spawn form (preset tiles, project picker, worktree)
theme.ts # dark/light theme toggle + persistence
types.ts # mirror of `maestro-protocol` JSON shapes
styles.css # design-system tokens + component styles
@@ -40,7 +41,7 @@ follows the OS by default and can be pinned via the header toggle (persisted in
`mock.ts` is a DEV-only fixture (stripped from production builds): visit the dev
server with `?mock` to seed a representative catalog without a running daemon,
`?mock&spawn` to open the spawn modal, and `?theme=light|dark` to pin a theme.
`?mock&spawn` to open a draft pane, and `?theme=light|dark` to pin a theme.
Routing is hash-based:
- `#/` → catalog
+1
View File
@@ -17,6 +17,7 @@
"@xterm/xterm": "^5.5.0"
},
"devDependencies": {
"happy-dom": "^20.11.6",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vitest": "^2.1.9"
+93 -11
View File
@@ -15,15 +15,18 @@ importers:
specifier: ^5.5.0
version: 5.5.0
devDependencies:
happy-dom:
specifier: ^20.11.6
version: 20.11.6
typescript:
specifier: ^5.6.3
version: 5.9.3
vite:
specifier: ^5.4.10
version: 5.4.21
version: 5.4.21(@types/node@26.2.0)
vitest:
specifier: ^2.1.9
version: 2.1.9
version: 2.1.9(@types/node@26.2.0)(happy-dom@20.11.6)
packages:
@@ -296,6 +299,15 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/node@26.2.0':
resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==}
'@types/whatwg-mimetype@3.0.2':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@vitest/expect@2.1.9':
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
@@ -337,6 +349,10 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
buffer-image-size@0.6.4:
resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
engines: {node: '>=4.0'}
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
@@ -362,6 +378,10 @@ packages:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
@@ -382,6 +402,10 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
happy-dom@20.11.6:
resolution: {integrity: sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg==}
engines: {node: '>=20.0.0'}
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
@@ -451,6 +475,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
vite-node@2.1.9:
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -512,11 +539,27 @@ packages:
jsdom:
optional: true
whatwg-mimetype@3.0.0:
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
engines: {node: '>=12'}
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
ws@8.21.3:
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
snapshots:
'@esbuild/aix-ppc64@0.21.5':
@@ -667,6 +710,16 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/node@26.2.0':
dependencies:
undici-types: 8.3.0
'@types/whatwg-mimetype@3.0.2': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 26.2.0
'@vitest/expect@2.1.9':
dependencies:
'@vitest/spy': 2.1.9
@@ -674,13 +727,13 @@ snapshots:
chai: 5.3.3
tinyrainbow: 1.2.0
'@vitest/mocker@2.1.9(vite@5.4.21)':
'@vitest/mocker@2.1.9(vite@5.4.21(@types/node@26.2.0))':
dependencies:
'@vitest/spy': 2.1.9
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 5.4.21
vite: 5.4.21(@types/node@26.2.0)
'@vitest/pretty-format@2.1.9':
dependencies:
@@ -715,6 +768,10 @@ snapshots:
assertion-error@2.0.1: {}
buffer-image-size@0.6.4:
dependencies:
'@types/node': 26.2.0
cac@6.7.14: {}
chai@5.3.3:
@@ -733,6 +790,8 @@ snapshots:
deep-eql@5.0.2: {}
entities@7.0.1: {}
es-module-lexer@1.7.0: {}
esbuild@0.21.5:
@@ -770,6 +829,19 @@ snapshots:
fsevents@2.3.3:
optional: true
happy-dom@20.11.6:
dependencies:
'@types/node': 26.2.0
'@types/whatwg-mimetype': 3.0.2
'@types/ws': 8.18.1
buffer-image-size: 0.6.4
entities: 7.0.1
whatwg-mimetype: 3.0.0
ws: 8.21.3
transitivePeerDependencies:
- bufferutil
- utf-8-validate
loupe@3.2.1: {}
magic-string@0.30.21:
@@ -843,13 +915,15 @@ snapshots:
typescript@5.9.3: {}
vite-node@2.1.9:
undici-types@8.3.0: {}
vite-node@2.1.9(@types/node@26.2.0):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 1.1.2
vite: 5.4.21
vite: 5.4.21(@types/node@26.2.0)
transitivePeerDependencies:
- '@types/node'
- less
@@ -861,18 +935,19 @@ snapshots:
- supports-color
- terser
vite@5.4.21:
vite@5.4.21(@types/node@26.2.0):
dependencies:
esbuild: 0.21.5
postcss: 8.5.15
rollup: 4.60.4
optionalDependencies:
'@types/node': 26.2.0
fsevents: 2.3.3
vitest@2.1.9:
vitest@2.1.9(@types/node@26.2.0)(happy-dom@20.11.6):
dependencies:
'@vitest/expect': 2.1.9
'@vitest/mocker': 2.1.9(vite@5.4.21)
'@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@26.2.0))
'@vitest/pretty-format': 2.1.9
'@vitest/runner': 2.1.9
'@vitest/snapshot': 2.1.9
@@ -888,9 +963,12 @@ snapshots:
tinyexec: 0.3.2
tinypool: 1.1.1
tinyrainbow: 1.2.0
vite: 5.4.21
vite-node: 2.1.9
vite: 5.4.21(@types/node@26.2.0)
vite-node: 2.1.9(@types/node@26.2.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.2.0
happy-dom: 20.11.6
transitivePeerDependencies:
- less
- lightningcss
@@ -902,7 +980,11 @@ snapshots:
- supports-color
- terser
whatwg-mimetype@3.0.0: {}
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
ws@8.21.3: {}
+24 -2
View File
@@ -1,10 +1,13 @@
import * as routes from "./routes.js";
import type {
ApiError,
Branches,
CreateSessionRequest,
DaemonInfo,
DirListing,
GitRoot,
NameBatch,
Projects,
RecentDirs,
Session,
Uuid,
@@ -61,12 +64,17 @@ export async function getDaemonInfo(): Promise<DaemonInfo> {
return jsonOrError(await fetch(routes.INFO, { credentials: "same-origin" }));
}
/** Recently-used working dirs, newest-first and deduped by dir (#26). Drives the
* spawn form's recents picker. */
/** Recently-used working dirs, newest-first and deduped by dir (#26). */
export async function recentDirs(): Promise<RecentDirs> {
return jsonOrError(await fetch(routes.RECENT_DIRS, { credentials: "same-origin" }));
}
/** Every discovered git repository / worktree, newest-activity-first (#54).
* Drives the spawn form's project picker. */
export async function projects(): Promise<Projects> {
return jsonOrError(await fetch(routes.PROJECTS, { credentials: "same-origin" }));
}
/** Probe the enclosing git repo of `path` (#26). `root` is absent when the path
* isn't inside a repo. `path` may be `~`/relative/absolute; the daemon resolves
* it against its home dir. */
@@ -75,4 +83,18 @@ export async function gitRoot(path: string): Promise<GitRoot> {
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
}
/** A batch of fresh session handles for the spawn form's instant reroll (#53).
* Rerolls consume the batch locally; only exhaustion goes back to the daemon. */
export async function fetchNames(count: number): Promise<NameBatch> {
const url = `${routes.NAMES}?${routes.COUNT_QUERY}=${encodeURIComponent(count)}`;
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
}
/** One repository's local branches, with worktree occupancy (#55). Empty for
* a path outside any repo. Drives the spawn form's existing-branch picker. */
export async function fetchBranches(path: string): Promise<Branches> {
const url = routes.withPathQuery(routes.BRANCHES, path);
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
}
export { eventsWsUrl } from "./routes.js";
+13 -12
View File
@@ -6,9 +6,15 @@ import type { Session } from "./types.js";
import { abbreviateFolderName, sessionName } from "./types.js";
import type { TreeNode } from "./tree.js";
import { buildTree } from "./tree.js";
import { openSpawnModal } from "./spawn.js";
import { createThemeToggle } from "./theme.js";
/** Ask the shell to open a new draft pane (#62). Handled in main.ts, where the
* multi-view controller lives; dispatched from every Spawn affordance. */
export const OPEN_SPAWN_EVENT = "catalog-open-spawn";
export function requestOpenSpawn(): void {
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
}
const collapsed = new Set<string>();
function renderNode(node: TreeNode, depth: number): HTMLElement {
@@ -190,7 +196,9 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
spawnBtn.type = "button";
spawnBtn.className = "btn btn-primary spawn-btn";
spawnBtn.textContent = "+ Spawn";
spawnBtn.addEventListener("click", () => openSpawnModal());
// The keyboard pair sits right here in the tooltip (#67).
spawnBtn.title = "Draft pane: n — fast-forward spawn: N";
spawnBtn.addEventListener("click", () => requestOpenSpawn());
actions.appendChild(spawnBtn);
return header;
}
@@ -342,7 +350,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
treeHost.replaceChildren();
if (sessions.length === 0) {
treeHost.appendChild(
renderEmpty(filter.trim().length > 0, () => openSpawnModal()),
renderEmpty(filter.trim().length > 0, () => requestOpenSpawn()),
);
return;
}
@@ -364,14 +372,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
const rerenderHandler = () => refresh();
document.addEventListener("catalog-rerender", rerenderHandler);
// Spawn similar: reopen the spawn modal pre-seeded from a session's dir and
// command (matched back to a preset by the form) — but with a freshly
// generated name rather than the original's, since it's a new session.
const spawnSimilarHandler = (ev: Event) => {
const s = (ev as CustomEvent<Session>).detail;
openSpawnModal({ cwd: s.cwd, command: s.command });
};
document.addEventListener("catalog-spawn-similar", spawnSimilarHandler);
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
// multi-view controller can seed a draft pane with the session's dir/command.
const unsubscribe = store.subscribe(refresh);
@@ -391,7 +393,6 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
},
dispose() {
document.removeEventListener("catalog-rerender", rerenderHandler);
document.removeEventListener("catalog-spawn-similar", spawnSimilarHandler);
unsubscribe();
},
};
+143
View File
@@ -0,0 +1,143 @@
// @vitest-environment happy-dom
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
vi.mock("./api.js", () => ({ listDir: vi.fn() }));
import { attachDirAutocomplete, type DirAutocompleteHandle } from "./dirAutocomplete.js";
import { listDir } from "./api.js";
const listDirMock = vi.mocked(listDir);
let input: HTMLInputElement;
let nextField: HTMLInputElement;
let docKeydown: ReturnType<typeof vi.fn>;
let ac: DirAutocompleteHandle | undefined;
function popup(): HTMLUListElement {
return document.querySelector(".dir-ac") as HTMLUListElement;
}
function items(): HTMLElement[] {
return [...popup().querySelectorAll<HTMLElement>(".dir-ac-item")];
}
async function type(value: string): Promise<void> {
input.focus();
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
await vi.advanceTimersByTimeAsync(100); // past the fetch debounce
}
/** Dispatch a cancelable, bubbling keydown on the input; report whether the
* default browser behaviour (focus move, form submit) was suppressed. */
function press(key: string): { defaultPrevented: boolean } {
const evt = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
input.dispatchEvent(evt);
return { defaultPrevented: evt.defaultPrevented };
}
beforeEach(() => {
vi.useFakeTimers();
document.body.replaceChildren();
input = document.createElement("input");
nextField = document.createElement("input");
document.body.append(input, nextField);
docKeydown = vi.fn();
document.addEventListener("keydown", docKeydown);
listDirMock.mockReset().mockResolvedValue({ path: "/tmp", entries: ["proj-a", "proj-b", "other"], truncated: false });
});
afterEach(() => {
ac?.destroy();
document.removeEventListener("keydown", docKeydown);
vi.useRealTimers();
});
describe("dirAutocomplete dismissal", () => {
it("Escape closes the popup, keeps the typed value and does not propagate", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/pro");
expect(popup().hidden).toBe(false);
const { defaultPrevented } = press("Escape");
expect(defaultPrevented).toBe(true);
expect(popup().hidden).toBe(true);
expect(input.value).toBe("/tmp/pro");
expect(document.activeElement).toBe(input);
expect(docKeydown).not.toHaveBeenCalled(); // a draft pane's Escape handler must not fire
});
it("blur dismisses the popup", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/pro");
expect(popup().hidden).toBe(false);
input.blur();
await vi.advanceTimersByTimeAsync(150);
expect(popup().hidden).toBe(true);
});
it("does not reopen on refocus until the operator types again", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/pro");
input.blur();
await vi.advanceTimersByTimeAsync(150);
input.focus();
await vi.advanceTimersByTimeAsync(150);
expect(popup().hidden).toBe(true);
await type("/tmp/ot");
expect(popup().hidden).toBe(false);
});
});
describe("dirAutocomplete keyboard model", () => {
it("Tab commits the highlighted entry and lets focus move to the next field", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/pro"); // highlight defaults to the first match: proj-a
nextField.focus();
const { defaultPrevented } = press("Tab");
expect(input.value).toBe("/tmp/proj-a/");
expect(defaultPrevented).toBe(false); // native tab navigation proceeds
expect(document.activeElement).toBe(nextField); // focus was not yanked back
});
it("Tab with nothing highlighted just moves on", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/nope-matches-nothing");
expect(popup().hidden).toBe(true);
const { defaultPrevented } = press("Tab");
expect(defaultPrevented).toBe(false);
expect(input.value).toBe("/tmp/nope-matches-nothing");
});
it("arrows move the highlight and Enter commits it without submitting", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/");
press("ArrowDown");
expect(items()[1].classList.contains("is-active")).toBe(true);
const enter = press("Enter");
expect(enter.defaultPrevented).toBe(true); // no form submit
expect(input.value).toBe("/tmp/proj-b/");
});
});
describe("dirAutocomplete opening", () => {
it("still opens on focus when not previously dismissed", async () => {
ac = attachDirAutocomplete(input);
input.focus();
await vi.advanceTimersByTimeAsync(50);
expect(popup().hidden).toBe(false);
expect(listDirMock).toHaveBeenCalled();
});
});
+44 -27
View File
@@ -4,15 +4,19 @@
// per directory, and filters the trailing path segment locally — so typing within a segment is instant and only crossing a `/`
// hits the network. Keyboard model mirrors `cd dir/sub<tab>`:
//
// Tab complete the longest common prefix; if one match, accept it (and
// append `/` so the next level loads); if nothing to extend, cycle.
// Tab commit the highlighted entry and let focus move on; with nothing
// highlighted, plain tab navigation
// ↑/↓ move the highlight
// Enter accept the highlighted entry (and suppress form submit)
// Esc close the dropdown
// Esc close the dropdown, refocus the input, and swallow the key so it
// can't also discard a surrounding draft pane
//
// Once dismissed (Esc or blur) the popup stays closed until the operator types
// again — refocusing alone doesn't reopen it.
//
// Failures (offline daemon, permission denied) degrade to no suggestions.
import { longestCommonPrefix, splitPath } from "./paths.js";
import { splitPath } from "./paths.js";
import { listDir } from "./api.js";
export interface DirAutocompleteHandle {
@@ -42,6 +46,9 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
let fetchSeq = 0;
let debounce: number | undefined;
let open = false;
// Set when the operator dismissed the popup (Esc, blur, outside click); a
// refocus must not reopen it — only new typing does.
let dismissed = false;
function close(): void {
@@ -54,6 +61,11 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
window.removeEventListener("resize", reposition);
}
function dismiss(): void {
dismissed = true;
close();
}
function reposition(): void {
const r = input.getBoundingClientRect();
dropdown.style.left = `${r.left}px`;
@@ -97,6 +109,14 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
}
function applyFilter(entries: string[], prefix: string): void {
// A fetch can outlive the dismissal that was meant to close this popup
// (Tab-commit re-arms a refresh, then blur dismisses 120ms later). Showing
// it now would anchor a dropdown under an unfocused input that nothing
// closes until the next interaction.
if (dismissed || document.activeElement !== input) {
close();
return;
}
const lower = prefix.toLowerCase();
filtered = entries.filter((e) => e.toLowerCase().startsWith(lower));
highlight = filtered.length > 0 ? 0 : -1;
@@ -132,21 +152,28 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
}
// Accept `name` as the next path segment: complete it and append `/` so the
// child directory's listing loads immediately (the drill-in gesture).
function accept(name: string): void {
// child directory's listing loads on the next interaction (the drill-in
// gesture). Unlike `accept`, doesn't steal focus — Tab uses this to commit
// and move to the next field in one gesture.
function commit(name: string): void {
const { dir } = splitPath(input.value);
input.value = `${dir}${name}/`;
input.focus();
// Notify other listeners (spawn.ts marks the field user-edited) and load
// the child level.
// Notify other listeners (spawnForm marks the field user-edited).
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function accept(name: string): void {
commit(name);
input.focus();
}
function onInput(): void {
dismissed = false;
scheduleRefresh();
}
function onFocus(): void {
if (dismissed) return;
refresh();
}
@@ -154,27 +181,17 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
if (e.key === "Escape") {
if (open) {
e.preventDefault();
close();
// Swallow the key: an Escape that bubbles would also discard the
// draft pane hosting this form.
e.stopPropagation();
dismiss();
input.focus();
}
return;
}
if (e.key === "Tab") {
if (!open || filtered.length === 0) return; // let focus move normally
e.preventDefault();
if (filtered.length === 1) {
accept(filtered[0]);
return;
}
const { dir, prefix } = splitPath(input.value);
const lcp = longestCommonPrefix(filtered);
if (lcp.length > prefix.length) {
input.value = `${dir}${lcp}`;
input.dispatchEvent(new Event("input", { bubbles: true }));
} else {
// Nothing to extend — cycle the highlight like a second shell <tab>.
highlight = (highlight + 1) % filtered.length;
render();
}
if (!open || highlight < 0 || highlight >= filtered.length) return; // plain tab navigation
commit(filtered[highlight]); // no preventDefault — focus moves to the next field
return;
}
if (!open) return;
@@ -196,7 +213,7 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
function onBlur(): void {
// Delay so a dropdown mousedown can complete first.
window.setTimeout(close, 120);
window.setTimeout(dismiss, 120);
}
input.addEventListener("input", onInput);
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import {
buildFastForwardRequest,
fastForwardSeed,
resolveFastForward,
} from "./fastForward.js";
import type { Project, SpawnPreset } from "./types.js";
const presets: SpawnPreset[] = [
{ id: "custom", label: "Custom…", command: "" },
{ id: "claude", label: "Claude", command: "claude" },
];
function project(overrides: Partial<Project>): Project {
return {
path: "/repos/p",
name: "p",
last_activity: new Date().toISOString(),
...overrides,
};
}
describe("resolveFastForward (#67)", () => {
it("resolves every default from the most recent project", () => {
const projects = [
project({ path: "/repos/old", name: "old", last_activity: "2020-01-01T00:00:00Z" }),
project({
path: "/repos/fresh",
name: "fresh",
base_repo: "/repos/fresh",
last_command: "aider",
}),
];
const res = resolveFastForward(projects, presets, "amber-falcon");
expect(res.project?.path).toBe("/repos/fresh");
expect(res.command).toBe("aider");
expect(res.name).toBe("amber-falcon");
const req = buildFastForwardRequest(res)!;
expect(req.cwd).toBe("/repos/fresh");
expect(req.command).toBe("aider");
expect(req.metadata).toEqual({ name: "amber-falcon" });
expect(req.worktree).toEqual({
base_repo: "/repos/fresh",
branch: "amber-falcon",
cleanup_on_kill: true,
create_branch: true,
});
});
it("falls back to the first non-custom preset when there is no last command", () => {
const res = resolveFastForward([project({ last_command: undefined })], presets, "n");
expect(res.command).toBe("claude");
});
it("treats an empty last command as no command", () => {
const res = resolveFastForward([project({ last_command: "" })], [presets[0]], "n");
expect(res.command).toBeUndefined();
expect(buildFastForwardRequest(res)).toBeNull();
});
it("resolves nothing when no projects are known yet", () => {
const res = resolveFastForward([], presets, "n");
expect(res.project).toBeUndefined();
expect(buildFastForwardRequest(res)).toBeNull();
});
it("downgrades to a draft when the generated name is missing", () => {
const res = resolveFastForward([project({ last_command: "claude" })], presets);
expect(res.name).toBeUndefined();
expect(buildFastForwardRequest(res)).toBeNull();
});
it("seeds the fallback draft with whatever did resolve", () => {
const res = resolveFastForward(
[project({ path: "/repos/p", base_repo: "/repos/p", last_command: "aider" })],
presets,
);
expect(fastForwardSeed(res)).toEqual({
cwd: "/repos/p",
command: "aider",
name: "",
worktreeEnabled: true,
worktreeBranch: undefined,
});
});
it("leaves the worktree default alone when not even a project resolved", () => {
// Not `false`: the form defaults the toggle on and a false would stick,
// so the fallback draft would disagree with a normally-opened draft and
// with the TUI's fast path.
const seed = fastForwardSeed(resolveFastForward([], presets));
expect(seed.worktreeEnabled).toBeUndefined();
expect(seed.cwd).toBe("");
});
});
+66
View File
@@ -0,0 +1,66 @@
// Fast-forward spawn (#67): one keystroke, every default resolved, no form.
// Pure resolution only — the caller (main.ts) owns the fetches and the POST,
// and falls back to a seeded draft pane when anything is missing.
import type { CreateSessionRequest, Project, SpawnPreset } from "./types.js";
import { sortProjects } from "./projectList.js";
/** What one fast-forward keystroke resolved. Anything absent means "open a
* draft pre-filled with the rest instead of spawning". */
export interface FastForwardResolution {
project?: Project;
command?: string;
name?: string;
}
/** The defaults: most recent project (fresh-first, same ordering the picker
* shows), its last command falling back to the first non-custom preset, and
* a freshly generated name when the daemon supplied one. */
export function resolveFastForward(
projects: Project[],
presets: SpawnPreset[],
name?: string,
): FastForwardResolution {
const project = sortProjects(projects, new Date())[0];
const lastCommand = project?.last_command?.trim();
const command =
lastCommand || presets.find((p) => p.command !== "")?.command || undefined;
return { project, command, name: name || undefined };
}
/** The immediate spawn request — worktree on, new branch named after the
* generated session name — or `null` when any default is missing. */
export function buildFastForwardRequest(
res: FastForwardResolution,
): CreateSessionRequest | null {
const { project, command, name } = res;
if (!project || !command?.trim() || !name) return null;
return {
cwd: project.path,
command,
metadata: { name },
worktree: {
base_repo: project.base_repo ?? project.path,
branch: name,
cleanup_on_kill: true,
create_branch: true,
},
};
}
/** Seed for the fallback draft pane: whatever did resolve goes into the
* form; the rest stays with its own defaults. */
export function fastForwardSeed(res: FastForwardResolution) {
return {
cwd: res.project?.path ?? "",
command: res.command ?? "",
name: res.name ?? "",
// Left undefined rather than false when nothing resolved: the form
// defaults the toggle on, and `false` would stick, so the fallback draft
// would disagree with both a normally-opened draft and the TUI's fast path
// (#67 review).
worktreeEnabled: res.project ? true : undefined,
worktreeBranch:
res.project && res.name ? res.name : undefined,
};
}
+2 -2
View File
@@ -1,5 +1,5 @@
// One HTML escaper. Previously written four times, in catalog.ts, spawn.ts,
// types.ts and attach.ts.
// One HTML escaper. Previously written four times, in catalog.ts, types.ts
// and attach.ts.
export function escapeHtml(s: string): string {
return s
+1 -1
View File
@@ -102,7 +102,7 @@
background: color-mix(in oklab, var(--surface) 40%, var(--bg));
}
.kit-slot .modal-body {
.kit-slot .spawn-form {
padding: 0;
min-width: 200px;
}
+3 -3
View File
@@ -135,7 +135,7 @@ export function renderKit(): HTMLElement {
{
const { el, body } = section(
"Buttons — current",
"The shipping button styles — now the bracket terminal style, as used across the header, modal, and empty states.",
"The shipping button styles — now the bracket terminal style, as used across the header, panes, and empty states.",
);
body.append(
specimen("btn-primary", btn("btn btn-primary", "+ Spawn")),
@@ -170,12 +170,12 @@ export function renderKit(): HTMLElement {
search.type = "search";
search.className = "search";
search.placeholder = "Search sessions…";
const text = h("div", "modal-body");
const text = h("div", "spawn-form");
const ti = document.createElement("input");
ti.type = "text";
ti.placeholder = "branch-name";
text.appendChild(ti);
const sel = h("div", "modal-body");
const sel = h("div", "spawn-form");
const s = document.createElement("select");
s.innerHTML = `<option>thinkpad</option><option>tower</option><option>pi</option>`;
sel.appendChild(s);
+94 -3
View File
@@ -3,13 +3,25 @@ import { ConnectionPool } from "./connections.js";
import { installHudKeybind } from "./debug.js";
import { CatalogStore } from "./events.js";
import { shouldRetryClose } from "./reconnect.js";
import {
createSession,
fetchNames,
getDaemonInfo,
projects as fetchProjects,
} from "./api.js";
import {
buildFastForwardRequest,
fastForwardSeed,
resolveFastForward,
} from "./fastForward.js";
import { FALLBACK_PRESETS } from "./spawnForm.js";
import { mountHud, type Hud } from "./hud.js";
import { emptyLayout, type Layout, openSessions } from "./layout.js";
import { createMultiView, type MultiView } from "./multiview.js";
import { loadLayout, saveLayout } from "./persistence.js";
import { openSpawnModal } from "./spawn.js";
import { OPEN_SPAWN_EVENT } from "./catalog.js";
import { initTheme } from "./theme.js";
import { sessionName } from "./types.js";
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
import { installViewportSizing } from "./viewport.js";
import "./styles.css";
@@ -212,7 +224,8 @@ function renderWelcome(): HTMLElement {
spawn.type = "button";
spawn.className = "btn btn-primary";
spawn.textContent = "+ Spawn session";
spawn.addEventListener("click", () => openSpawnModal());
spawn.title = "Draft pane: n — fast-forward spawn: N";
spawn.addEventListener("click", () => requestOpenSpawn());
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
return el;
}
@@ -251,6 +264,84 @@ function showMultiView(): void {
updateShellRoute();
}
// Spawn affordances (#62): the header button, the sidebar's empty state, and
// `?mock&spawn` all open a draft pane; "Spawn similar" seeds one with the
// cloned session's dir + command.
function requestOpenSpawn(): void {
showMultiView();
mv.openDraft();
}
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
document.addEventListener("catalog-spawn-similar", (ev) => {
const s = (ev as CustomEvent<Session>).detail;
showMultiView();
mv.openDraft({ cwd: s.cwd, command: s.command });
});
// Fast-forward spawn (#67): the draft pane's keystroke neighbour spawns
// immediately with every default resolved — most recent project, its last
// command (or the first non-custom preset), a fresh name, worktree on with
// a new branch named after that name. Anything unresolvable (no projects
// known yet, no command, no generated name) opens a draft pre-filled with
// what did resolve instead.
async function requestFastForward(): Promise<void> {
showMultiView();
const [discovered, name] = await Promise.all([
fetchProjects().catch(() => [] as Project[]),
fetchNames(1)
.then((batch) => batch.names[0])
.catch(() => undefined),
]);
let presets: SpawnPreset[] = FALLBACK_PRESETS;
try {
const info = await getDaemonInfo();
if (info.presets?.length) presets = info.presets;
} catch {
/* keep fallbacks */
}
const resolved = resolveFastForward(discovered, presets, name);
const req = buildFastForwardRequest(resolved);
if (req) {
try {
const session = await createSession(req);
// Mount it, the way the TUI's fast path attaches. Without this the
// keystroke creates a session and leaves the UI untouched, so it reads
// as a dead key (#67 review).
mv.openSessionBeside(session.id);
updateShellRoute();
return;
} catch {
// A failed POST degrades to the same pre-filled draft as an
// unresolvable default — never a silent dead keystroke.
}
}
mv.openDraft(fastForwardSeed(resolved));
}
// Keyboard pair (#67): `n` opens a draft pane, `N` fast-forwards a spawn.
// Ignored while typing anywhere (form fields, the terminal's hidden
// textarea, search) so an attached session's keys are never hijacked.
document.addEventListener("keydown", (e) => {
if (e.ctrlKey || e.altKey || e.metaKey) return;
const target = e.target as HTMLElement | null;
if (
target &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable)
) {
return;
}
if (e.key === "n") {
e.preventDefault();
requestOpenSpawn();
} else if (e.key === "N") {
e.preventDefault();
void requestFastForward();
}
});
type Route =
| { kind: "catalog" }
| { kind: "attach"; sessionId: string }
+37 -14
View File
@@ -33,8 +33,8 @@ function sess(
};
}
/** Stub the spawn-form backend endpoints (#26) so `?mock&spawn` renders the full
* tile-first flow — presets, recents, gitroot — with no daemon running. Dev-only;
/** Stub the spawn-form backend endpoints (#26/#59) so `?mock&spawn` renders the
* full flow — presets, projects, gitroot — with no daemon running. Dev-only;
* delegates every other request to the real `fetch`. */
function stubSpawnApi(): void {
const real = globalThis.fetch.bind(globalThis);
@@ -61,16 +61,39 @@ function stubSpawnApi(): void {
}),
);
}
if (path.includes("/recent-dirs")) {
if (path.includes("/projects")) {
const now = Date.now();
const iso = (daysAgo: number) => new Date(now - daysAgo * 86_400_000).toISOString();
return Promise.resolve(
json({
dirs: [
{ cwd: "/home/dev/git/maestro", last_command: "claude", last_used: "2026-06-20T10:00:00Z" },
{ cwd: "/home/dev/git/dotfiles", last_command: "zsh", last_used: "2026-06-19T18:00:00Z" },
{ cwd: "/home/dev/work/acme/api", last_command: "opencode", last_used: "2026-06-18T09:00:00Z" },
],
truncated: false,
}),
json([
{
path: "/home/dev/git/maestro",
name: "maestro",
is_worktree: false,
last_activity: iso(0),
last_command: "claude",
},
{
path: "/home/dev/worktrees/maestro-59",
name: "maestro-59",
is_worktree: true,
base_repo: "/home/dev/git/maestro",
last_activity: iso(1),
last_command: "opencode",
},
{
path: "/home/dev/git/dotfiles",
name: "dotfiles",
is_worktree: false,
last_activity: iso(30),
last_command: "zsh",
},
{
path: "/home/dev/work/acme/api",
name: "api",
is_worktree: false,
},
]),
);
}
if (path.includes("/gitroot")) {
@@ -112,10 +135,10 @@ export function seedMock(store: CatalogStore): void {
sessions,
});
// `?mock&spawn` auto-opens the spawn modal for visual review, with the spawn
// backend endpoints stubbed so presets/recents/gitroot all render.
// `?mock&spawn` auto-opens a draft pane for visual review, with the spawn
// backend endpoints stubbed so presets/projects/gitroot all render.
if (location.search.includes("spawn")) {
stubSpawnApi();
void import("./spawn.js").then((m) => m.openSpawnModal());
document.dispatchEvent(new CustomEvent("catalog-open-spawn"));
}
}
+162
View File
@@ -0,0 +1,162 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { emptyLayout, leaves, type Layout } from "./layout.js";
import { splitLeaf } from "./layout.js";
import { createMultiView, type MultiView } from "./multiview.js";
import type { ConnectionPool } from "./connections.js";
import type { SidebarHandle } from "./catalog.js";
function stubFetch(): void {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const body = url.includes("/api/info")
? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }
: url.includes("/api/projects")
? []
: url.includes("/api/names")
? { names: ["allegro-cello"] }
: undefined;
return new Response(JSON.stringify(body), { status: 200 });
}),
);
}
function fakePool(): ConnectionPool {
return {
setVisible() {},
setFocus() {},
} as unknown as ConnectionPool;
}
function fakeSidebar(): SidebarHandle {
return {
el: document.createElement("aside"),
setActive() {},
setHighlights() {},
dispose() {},
};
}
function makeMv(layout: Layout): MultiView {
return createMultiView({
pool: fakePool(),
layout,
sidebar: fakeSidebar(),
infoFor: () => ({ name: "sess", command: "bash" }),
onDisconnect: () => {},
renderWelcome: () => document.createElement("div"),
});
}
/** Drain the microtask queue so the draft form's async seed application lands. */
async function flush(): Promise<void> {
for (let i = 0; i < 20; i++) await Promise.resolve();
}
describe("multiview openDraft (#62)", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("turns an empty tab into a draft pane", async () => {
const layout = emptyLayout();
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.openDraft();
await flush();
const ls = leaves(layout.tabs[0].root);
expect(ls).toHaveLength(1);
expect(ls[0].sessionId).toBeNull();
expect(ls[0].draft).not.toBeNull();
expect(mv.el.querySelector(".mv-draft")).toBeTruthy();
});
it("splits the focused session pane into a fresh draft", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const first = splitLeaf(tab, "", "s1", "row", true);
tab.focusedLeafId = first;
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
mv.openDraft();
await flush();
const ls = leaves(tab.root);
expect(ls).toHaveLength(2);
expect(ls[0].sessionId).toBe("s1");
const draftLeaf = ls.find((l) => l.sessionId === null)!;
expect(draftLeaf.draft).not.toBeNull();
expect(tab.focusedLeafId).toBe(draftLeaf.id);
expect(mv.el.querySelectorAll(".mv-pane")).toHaveLength(2);
});
it("reuses the focused pane when it is already empty", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const first = splitLeaf(tab, "", "s1", "row", true);
const empty = splitLeaf(tab, first, null, "row", true);
tab.focusedLeafId = empty;
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
mv.openDraft();
await flush();
const ls = leaves(tab.root);
expect(ls).toHaveLength(2);
const target = ls.find((l) => l.id === empty)!;
expect(target.draft).not.toBeNull();
expect(tab.focusedLeafId).toBe(empty);
});
it("keeps the draft form instance when an unrelated session closes (#68)", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const s1 = splitLeaf(tab, "", "s1", "row", true);
const draftLeaf = splitLeaf(tab, s1, null, "row", true);
tab.focusedLeafId = draftLeaf;
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
await flush();
const nameInput = mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement;
nameInput.value = "wip";
nameInput.dispatchEvent(new Event("input"));
await flush();
expect(leaves(tab.root).find((l) => l.id === draftLeaf)?.draft?.name).toBe("wip");
const formBefore = mv.el.querySelector(".mv-draft");
mv.closeSession("s1");
await flush();
expect(mv.el.querySelector(".mv-draft")).toBe(formBefore);
expect((mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement).value).toBe("wip");
});
it("seeds a clone's cwd and command into the draft", async () => {
const layout = emptyLayout();
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.openDraft({ cwd: "/home/user/proj", command: "claude" });
await flush();
const leaf = leaves(layout.tabs[0].root)[0];
expect(leaf.draft?.cwd).toBe("/home/user/proj");
expect(leaf.draft?.command).toBe("claude");
});
});
+118 -28
View File
@@ -13,6 +13,7 @@ import {
closeTab,
findLeaf,
type Dir,
type Draft,
type Layout,
leaves,
locateSession,
@@ -48,10 +49,15 @@ export interface MultiView {
/** Bring a session into the layout: focus it if already open, else fill the
* focused pane (or open the first pane in an empty tab). */
openSession: (sessionId: string) => void;
openSessionBeside: (sessionId: string) => void;
/** Drop the pane showing `sessionId` (the session was disconnected/killed). */
closeSession: (sessionId: string) => void;
/** Every session currently shown in some pane, across all tabs. */
openSessions: () => Set<string>;
/** Open a draft pane (issue #62): reuse the focused pane if it is already
* empty, otherwise split the focused pane. `seed` pre-fills the form
* ("Spawn similar" clones a session's cwd + command). */
openDraft: (seed?: Partial<Draft>) => void;
/** Start dragging a sidebar session into the canvas (issue #32). The
* controller owns drag-vs-tap: a tap lets the row's normal click open it, a
* drag shows drop zones to place it. */
@@ -65,9 +71,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts;
const persist = () => opts.onLayoutChange?.();
// Draft-pane forms (#57) carry global listeners (dir autocomplete), so each
// render tears down the previous generation before rebuilding the canvas.
const draftForms = new Set<SpawnFormHandle>();
// Draft-pane forms (#57) carry global listeners (dir autocomplete), so forms
// whose leaves are gone are destroyed on each render. Forms for draft leaves
// that survive the render are reused in place (#68): rebuilding one mid-edit
// would drop focus, caret and selection, and re-fetch info/projects/names.
const draftForms = new Map<string, SpawnFormHandle>();
const el = document.createElement("div");
el.className = "mv";
@@ -145,29 +153,35 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
// Draft pane (#57): an empty pane is a spawn form. Values hang off the
// leaf (never persisted) so each draft keeps its own half-filled state;
// submitting fills this leaf with the new session in place.
const form = createSpawnForm({
seed: leaf.draft ?? undefined,
class: "mv-draft",
onChange: (values) => {
leaf.draft = values;
},
onSuccess: (session) => {
// The pane can be closed while the POST is in flight. Mounting the
// session onto a leaf no longer in the tree would strand it in no
// pane at all, so only claim the leaf while it is still there.
const t = activeTab(layout);
const attached = leaves(t.root).includes(leaf);
if (attached) {
leaf.sessionId = session.id;
leaf.draft = null;
t.focusedLeafId = leaf.id;
}
render();
opts.onOpenSessionsChange?.();
},
});
draftForms.add(form);
host.appendChild(form.el);
const form = draftForms.get(leaf.id);
if (form) {
host.appendChild(form.el);
} else {
const created = createSpawnForm({
seed: leaf.draft ?? undefined,
class: "mv-draft",
onChange: (values) => {
leaf.draft = values;
},
onSuccess: (session) => {
// The pane can be closed while the POST is in flight. Mounting the
// session onto a leaf no longer in the tree would strand it in no
// pane at all, so only claim the leaf while it is still there —
// searching every tab, since the operator may have switched tabs
// while the request was in flight.
const host = layout.tabs.find((t) => leaves(t.root).includes(leaf));
if (host) {
leaf.sessionId = session.id;
leaf.draft = null;
host.focusedLeafId = leaf.id;
}
render();
opts.onOpenSessionsChange?.();
},
});
draftForms.set(leaf.id, created);
host.appendChild(created.el);
}
}
const tools = document.createElement("div");
@@ -365,11 +379,82 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
render();
}
/// Mount a session without evicting one.
///
/// `openSession` deliberately replaces the focused pane (VS Code style) for
/// a sidebar click, but the fast path must never do that: pressing the key
/// while attached would drop the running session out of the layout
/// entirely. Follows `openDraft`'s rule instead — reuse an empty pane, else
/// split beside the focused one (#67 review).
function openSessionBeside(sessionId: string): void {
const loc = locateSession(layout, sessionId);
if (loc) {
layout.activeTabId = loc.tab.id;
loc.tab.focusedLeafId = loc.leaf.id;
render();
return;
}
const tab = activeTab(layout);
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
render();
opts.onOpenSessionsChange?.();
return;
}
const target = findLeaf(tab.root, tab.focusedLeafId)!;
if (!target.sessionId) {
target.sessionId = sessionId;
target.draft = null;
} else {
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
}
render();
opts.onOpenSessionsChange?.();
}
function openDraft(seed?: Partial<Draft>): void {
const tab = activeTab(layout);
const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed });
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
// Empty tab (or stale focus): the draft becomes the first pane.
tab.focusedLeafId = splitLeaf(tab, "", null, "row", true);
findLeaf(tab.root!, tab.focusedLeafId)!.draft = draft();
render();
return;
}
const target = findLeaf(tab.root, tab.focusedLeafId)!;
if (!target.sessionId && !target.draft) {
// An already-empty pane becomes the draft in place.
target.draft = draft();
} else {
// Split a fresh draft beside the focused pane.
tab.focusedLeafId = splitLeaf(tab, target.id, null, "row", true);
findLeaf(tab.root, tab.focusedLeafId)!.draft = draft();
}
render();
}
// ---- top-level render ----
/** Destroy forms whose leaf is no longer a draft (leaf removed or filled by
* a session). Surviving draft leaves keep their form instance (#68). */
function pruneForms(tab: { root: Node | null }): void {
const alive = new Set(
tab.root
? leaves(tab.root)
.filter((l) => !l.sessionId)
.map((l) => l.id)
: [],
);
for (const [id, f] of draftForms) {
if (!alive.has(id)) {
f.destroy();
draftForms.delete(id);
}
}
}
function render(): void {
for (const f of draftForms) f.destroy();
draftForms.clear();
renderTabs();
canvas.replaceChildren();
const tab = activeTab(layout);
@@ -378,6 +463,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
el.classList.toggle("mv-mobile-solo", isMobile && layout.tabs.length === 1);
if (!tab.root) {
pruneForms(tab);
pool.setVisible([]);
pool.setFocus(null);
canvas.appendChild(renderWelcome());
@@ -393,12 +479,14 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
if (isMobile) {
renderMobile(tab);
pruneForms(tab);
persist();
return;
}
const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = [];
canvas.appendChild(buildTree(tab.root, hosts));
pruneForms(tab);
pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })));
syncFocus();
persist();
@@ -639,6 +727,8 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
return {
el,
openSession,
openSessionBeside,
openDraft,
closeSession,
openSessions: () => openSessions(layout),
beginSessionDrag,
-29
View File
@@ -1,29 +0,0 @@
// Client-side session-handle generator for the spawn form's instant reroll
// (#23/#27). The spawn form rerolls without a round trip, so the wordlists
// live here; nothing server-side generates handles.
/** Musical adjectives — tempo, dynamics, and articulation markings. */
const ADJECTIVES = [
"allegro", "andante", "adagio", "presto", "vivace", "largo", "grave", "lento",
"moderato", "crescendo", "staccato", "legato", "forte", "dolce", "agitato",
"cantabile", "sostenuto", "tremolo", "rubato", "marcato", "brillante",
"maestoso", "grazioso", "espressivo", "tranquillo", "animato", "spirito",
"sotto", "vivo", "feroce",
];
/** Musical nouns — instruments and forms from the concert hall. */
const NOUNS = [
"cello", "violin", "viola", "oboe", "flute", "clarinet", "bassoon", "trumpet",
"trombone", "tuba", "horn", "timpani", "harp", "piano", "organ", "lyre",
"lute", "fugue", "sonata", "octave", "chord", "cadence", "prelude", "rondo",
"nocturne", "scherzo", "refrain", "coda", "motif", "overture",
];
function pick<T>(list: T[]): T {
return list[Math.floor(Math.random() * list.length)];
}
/** A fresh `adjective-noun` handle, e.g. `allegro-cello`. */
export function generateName(): string {
return `${pick(ADJECTIVES)}-${pick(NOUNS)}`;
}
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
import type { Project } from "./types.js";
const NOW = new Date("2026-08-23T12:00:00Z");
function project(overrides: Partial<Project>): Project {
return {
path: `/home/user/${overrides.name ?? "proj"}`,
name: "proj",
is_worktree: false,
last_activity: undefined,
last_command: undefined,
...overrides,
} as Project;
}
describe("isStale", () => {
it("marks projects with no known activity stale", () => {
expect(isStale(project({ name: "never" }), NOW)).toBe(true);
});
it("is fresh within 7 days and stale beyond", () => {
const fresh = project({ name: "fresh", last_activity: "2026-08-17T00:00:00Z" });
const stale = project({ name: "stale", last_activity: "2026-08-16T11:59:00Z" });
expect(isStale(fresh, NOW)).toBe(false);
expect(isStale(stale, NOW)).toBe(true);
});
});
describe("sortProjects", () => {
it("sorts newest-activity first", () => {
const a = project({ name: "a", last_activity: "2026-08-20T00:00:00Z" });
const b = project({ name: "b", last_activity: "2026-08-22T00:00:00Z" });
expect(sortProjects([a, b], NOW).map((p) => p.name)).toEqual(["b", "a"]);
});
it("sorts stale rows after fresh ones, each block newest first", () => {
const freshOld = project({ name: "fresh-old", last_activity: "2026-08-18T00:00:00Z" });
const freshNew = project({ name: "fresh-new", last_activity: "2026-08-23T00:00:00Z" });
const staleNew = project({ name: "stale-new", last_activity: "2026-08-10T00:00:00Z" });
const staleNone = project({ name: "stale-none" });
const sorted = sortProjects([staleNew, freshOld, staleNone, freshNew], NOW);
expect(sorted.map((p) => p.name)).toEqual(["fresh-new", "fresh-old", "stale-new", "stale-none"]);
});
it("does not mutate the input", () => {
const a = project({ name: "a", last_activity: "2026-08-01T00:00:00Z" });
const b = project({ name: "b", last_activity: "2026-08-02T00:00:00Z" });
sortProjects([a, b], NOW);
expect(a.name).toBe("a");
});
});
describe("groupProjects", () => {
it("keeps plain repositories as their own single-entry group", () => {
const solo = project({ name: "solo" });
expect(groupProjects(sortProjects([solo], NOW))).toEqual([{ main: solo, worktrees: [] }]);
});
it("nests worktrees under their base repository even when fresher than it", () => {
const main = project({ name: "maestro" });
const wt = project({
name: "wt",
path: "/elsewhere/wt",
is_worktree: true,
base_repo: main.path,
last_activity: "2026-08-23T00:00:00Z",
});
const groups = groupProjects(sortProjects([wt, main], NOW));
expect(groups).toHaveLength(1);
expect(groups[0].main).toBe(main);
expect(groups[0].worktrees).toEqual([wt]);
});
it("orders groups by the freshest member and worktrees by activity", () => {
const mainA = project({ name: "a", path: "/w/a", last_activity: "2026-08-01T00:00:00Z" });
const wtA = project({
name: "a-wt",
path: "/w/a-wt",
is_worktree: true,
base_repo: "/w/a",
last_activity: "2026-08-20T00:00:00Z",
});
const mainB = project({ name: "b", path: "/w/b", last_activity: "2026-08-15T00:00:00Z" });
const wtB1 = project({
name: "b-wt1",
path: "/w/b-wt1",
is_worktree: true,
base_repo: "/w/b",
last_activity: "2026-08-19T00:00:00Z",
});
const wtB2 = project({
name: "b-wt2",
path: "/w/b-wt2",
is_worktree: true,
base_repo: "/w/b",
last_activity: "2026-08-21T00:00:00Z",
});
const groups = groupProjects(sortProjects([mainA, mainB, wtB1, wtA, wtB2], NOW));
expect(groups.map((g) => g.main.name)).toEqual(["b", "a"]);
const gA = groups.find((g) => g.main.name === "a")!;
const gB = groups.find((g) => g.main.name === "b")!;
expect(gA.worktrees.map((p) => p.name)).toEqual(["a-wt"]);
expect(gB.worktrees.map((p) => p.name)).toEqual(["b-wt2", "b-wt1"]);
});
});
describe("filterProjects", () => {
const maestro = project({ name: "maestro", path: "/home/u/git/maestro" });
const tea = project({ name: "tea", path: "/home/u/work/tea" });
it("matches on name case-insensitively", () => {
expect(filterProjects([maestro, tea], "MAES")).toEqual([maestro]);
});
it("matches on the full path", () => {
expect(filterProjects([maestro, tea], "work")).toEqual([tea]);
});
it("returns everything unchanged for an empty query", () => {
expect(filterProjects([maestro, tea], " ")).toEqual([maestro, tea]);
});
});
+76
View File
@@ -0,0 +1,76 @@
// Pure helpers behind the spawn form's project picker (#59): staleness,
// recency ordering, worktree grouping, and free-text filtering. The daemon
// reports the timestamps; what "stale" means is a client decision (#54).
import type { Project } from "./types.js";
/** Projects whose last activity is older than this are dimmed and sort after
* fresh ones — still reachable, visibly stale (#59). */
export const STALE_AFTER_DAYS = 7;
export function isStale(project: Project, now: Date): boolean {
if (!project.last_activity) return true;
const age = now.getTime() - Date.parse(project.last_activity);
return !(age >= 0) || age > STALE_AFTER_DAYS * 24 * 60 * 60 * 1000;
}
/** Fresh rows first, each block newest-activity-first; unknown or future
* timestamps sort as stalest. Ties fall back to name so the order is stable. */
export function sortProjects(projects: Project[], now: Date): Project[] {
return [...projects].sort((a, b) => {
const staleDiff = (isStale(a, now) ? 1 : 0) - (isStale(b, now) ? 1 : 0);
if (staleDiff !== 0) return staleDiff;
const ta = timeOf(a);
const tb = timeOf(b);
if (ta !== tb) return tb - ta;
return a.name.localeCompare(b.name) || a.path.localeCompare(b.path);
});
}
function timeOf(project: Project): number {
return project.last_activity ? Date.parse(project.last_activity) : Number.NEGATIVE_INFINITY;
}
/** One repository group: the main checkout followed by its linked worktrees.
* A group's position follows its freshest member, since a worktree worked on
* today should pull its (dormant) base repo up with it. */
export interface ProjectGroup {
main: Project;
worktrees: Project[];
}
/** Group `sorted` projects by their base repository. A worktree nests under
* its main checkout even when the main sorts later; a main checkout missing
* from the list doesn't drop its worktrees. */
export function groupProjects(sorted: Project[]): ProjectGroup[] {
const members = new Map<string, Project[]>();
const keys: string[] = [];
for (const p of sorted) {
const key = p.base_repo ?? p.path;
let list = members.get(key);
if (!list) {
list = [];
members.set(key, list);
keys.push(key);
}
list.push(p);
}
return keys.map((key) => {
const list = members.get(key)!;
const main = list.find((p) => p.path === key && !p.is_worktree) ?? list[0];
// Keep the daemon's newest-first order within the group.
const worktrees = list
.filter((p) => p !== main)
.sort((a, b) => timeOf(b) - timeOf(a));
return { main, worktrees };
});
}
/** Substring filter over project name and full path, case-insensitive. */
export function filterProjects(projects: Project[], query: string): Project[] {
const q = query.trim().toLowerCase();
if (!q) return [...projects];
return projects.filter(
(p) => p.name.toLowerCase().includes(q) || p.path.toLowerCase().includes(q),
);
}
+165
View File
@@ -0,0 +1,165 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
import type { Project } from "./types.js";
const NOW = "2026-08-23T12:00:00Z";
function project(overrides: Partial<Project>): Project {
return {
path: `/home/user/${overrides.name ?? "proj"}`,
name: "proj",
is_worktree: false,
...overrides,
};
}
/** Three fetches gate the form's initial load: /api/info, /api/projects, and
* the /api/names batch (#53). */
function stubFetch(info: unknown, projects: Project[]): void {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const body = url.includes("/api/info")
? info
: url.includes("/api/projects")
? projects
: url.includes("/api/names")
? { names: ["allegro-cello", "presto-fugue", "adagio-lyre"] }
: undefined;
return new Response(JSON.stringify(body), { status: 200 });
}),
);
}
function rowPaths(form: HTMLFormElement): string[] {
return [...form.querySelectorAll<HTMLButtonElement>(".project-row:not(.project-other)")].map(
(r) => r.dataset.cwd ?? "",
);
}
describe("spawnForm project picker", () => {
let handle: SpawnFormHandle | null = null;
beforeEach(() => {
vi.useFakeTimers({ now: Date.parse(NOW) });
});
afterEach(() => {
handle?.destroy();
handle = null;
vi.unstubAllGlobals();
vi.useRealTimers();
});
async function mount(projects: Project[], seed?: Parameters<typeof createSpawnForm>[0]["seed"]) {
stubFetch({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }, projects);
handle = createSpawnForm({
seed,
onSuccess() {},
});
document.body.appendChild(handle.el);
for (let i = 0; i < 20; i++) await Promise.resolve();
return handle;
}
it("renders one row per project, fresh before stale", async () => {
const fresh = project({ name: "fresh", path: "/home/user/fresh", last_activity: NOW });
const stale = project({
name: "stale",
path: "/home/user/stale",
last_activity: "2026-01-01T00:00:00Z",
});
const form = (await mount([stale, fresh])).el;
expect(rowPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
const rows = [...form.querySelectorAll<HTMLButtonElement>(".project-row")];
expect(rows[1].classList.contains("is-stale")).toBe(true);
expect(rows[0].classList.contains("is-stale")).toBe(false);
});
it("nests worktrees under their base repository", async () => {
const main = project({ name: "maestro", path: "/home/user/maestro" });
const wt = project({
name: "wt",
path: "/elsewhere/wt",
is_worktree: true,
base_repo: main.path,
});
const form = (await mount([wt, main])).el;
expect(rowPaths(form)).toEqual(["/home/user/maestro", "/elsewhere/wt"]);
const [mainRow, wtRow] = [
...form.querySelectorAll(".project-row:not(.project-other)"),
];
expect(wtRow.classList.contains("is-worktree")).toBe(true);
expect(mainRow.classList.contains("is-worktree")).toBe(false);
});
it("filters rows as the operator types", async () => {
const a = project({ name: "maestro", path: "/home/user/maestro" });
const b = project({ name: "tea", path: "/home/user/work/tea" });
const form = (await mount([a, b])).el;
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
filter.value = "tea";
filter.dispatchEvent(new Event("input"));
expect(rowPaths(form)).toEqual(["/home/user/work/tea"]);
filter.value = "zzz";
filter.dispatchEvent(new Event("input"));
expect(rowPaths(form)).toEqual([]);
expect(form.querySelector(".picker-empty")).toBeTruthy();
});
it("picking a project selects its cwd and pre-selects its last command", async () => {
const p = project({
name: "maestro",
path: "/home/user/maestro",
last_command: "claude",
});
const onChange = vi.fn();
stubFetch(
{
hostname: "h",
version: "0",
home_dir: "/home/user",
presets: [
{ id: "claude", label: "Claude", command: "claude" },
{ id: "custom", label: "Custom…", command: "" },
],
},
[p],
);
handle = createSpawnForm({ onSuccess() {}, onChange });
const form = handle.el;
document.body.appendChild(form);
for (let i = 0; i < 20; i++) await Promise.resolve();
onChange.mockClear();
const row = form.querySelector<HTMLButtonElement>(".project-row")!;
row.click();
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
const last = onChange.mock.lastCall![0];
expect(last.cwd).toBe("/home/user/maestro");
expect(last.command).toBe("claude");
expect(values).toBeTruthy();
// The raw input stays hidden — the picked row is the source of truth.
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(true);
expect(row.classList.contains("is-active")).toBe(true);
});
it("keeps an 'Other directory…' escape hatch that reveals the raw input", async () => {
const p = project({ name: "maestro", path: "/home/user/maestro" });
const form = (await mount([p])).el;
const other = form.querySelector<HTMLButtonElement>(".project-other")!;
other.click();
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(false);
});
it("falls back to the raw input when no projects exist", async () => {
const form = (await mount([])).el;
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(false);
const raw = form.querySelector<HTMLInputElement>(".cwd-raw")!;
expect(raw.value).toBe("/home/user");
});
});
+6
View File
@@ -9,12 +9,18 @@ export const SESSIONS = "/api/sessions";
export const INFO = "/api/info";
export const DIRS = "/api/dirs";
export const RECENT_DIRS = "/api/recent-dirs";
export const PROJECTS = "/api/projects";
export const GITROOT = "/api/gitroot";
export const NAMES = "/api/names";
export const BRANCHES = "/api/branches";
export const EVENTS = "/api/events";
/** Name of the query parameter `DIRS` and `GITROOT` both take. */
export const PATH_QUERY = "path";
/** Name of the query parameter `NAMES` takes. */
export const COUNT_QUERY = "count";
export function session(id: Uuid): string {
return `${SESSIONS}/${encodeURIComponent(id)}`;
}
-68
View File
@@ -1,68 +0,0 @@
// The spawn modal (issue #27): bottom-sheet/dialog chrome around the shared
// spawn form (spawnForm.ts). The modal coexists with the inline draft panes
// (#57) until a later issue deletes it.
import { createSpawnForm, type SpawnFormSeed } from "./spawnForm.js";
/** Seed values for the spawn form — used by "clone" to reopen the dialog with
* the same cwd, command, and name as an existing session. */
export type SpawnPrefill = SpawnFormSeed;
export function openSpawnModal(prefill?: SpawnPrefill): void {
const existing = document.getElementById("modal-host");
if (existing) existing.remove();
const host = document.createElement("div");
host.id = "modal-host";
host.className = "modal-backdrop";
host.innerHTML = `
<div class="modal spawn-modal" role="dialog" aria-modal="true" aria-labelledby="spawn-title">
<header class="modal-header">
<h2 id="spawn-title">Spawn session</h2>
<button type="button" class="btn btn-icon close" aria-label="Close">✕</button>
</header>
<div class="form-slot"></div>
</div>
`;
document.body.appendChild(host);
const formHandle = createSpawnForm({
seed: prefill,
class: "modal-body",
onSuccess(session) {
close();
location.hash = `#/attach/${encodeURIComponent(session.id)}`;
},
});
(host.querySelector(".form-slot") as HTMLElement).replaceWith(formHandle.el);
const close = () => {
formHandle.destroy();
host.remove();
};
// The modal keeps its Cancel affordance; the shared form ships only Submit.
const actions = formHandle.el.querySelector(".form-actions") as HTMLElement;
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "btn btn-ghost cancel";
cancel.textContent = "Cancel";
cancel.addEventListener("click", close);
actions.prepend(cancel);
host.querySelector(".close")?.addEventListener("click", close);
host.addEventListener("click", (e) => {
if (e.target === host) close();
});
window.addEventListener(
"keydown",
(e) => {
if (e.key === "Escape") close();
},
{ once: true },
);
// ---- clone seed ------------------------------------------------------
if (prefill) {
(host.querySelector("#spawn-title") as HTMLElement).textContent = "Clone session";
}
}
+44 -4
View File
@@ -8,7 +8,8 @@ const base: SpawnFormValues = {
name: "amber-fox",
worktreeEnabled: false,
worktreeBranch: "",
worktreeCleanup: false,
worktreeBranchMode: "new",
worktreeBranchEdited: false,
};
describe("buildSpawnRequest", () => {
@@ -24,9 +25,9 @@ describe("buildSpawnRequest", () => {
expect(res).toEqual({ req: { cwd: base.cwd, command: base.command, metadata: {} } });
});
it("attaches a trimmed branch and cleanup flag when worktree is enabled", () => {
it("attaches a trimmed branch when worktree is enabled, and always cleans up", () => {
const res = buildSpawnRequest(
{ ...base, worktreeEnabled: true, worktreeBranch: " feat/foo ", worktreeCleanup: true },
{ ...base, worktreeEnabled: true, worktreeBranch: " feat/foo " },
"/home/user/project",
);
expect(res).toEqual({
@@ -34,11 +35,50 @@ describe("buildSpawnRequest", () => {
cwd: base.cwd,
command: base.command,
metadata: { name: "amber-fox" },
worktree: { base_repo: "/home/user/project", branch: "feat/foo", cleanup_on_kill: true },
worktree: {
base_repo: "/home/user/project",
branch: "feat/foo",
cleanup_on_kill: true,
create_branch: true,
},
},
});
});
it("checks out an existing branch when the mode is existing (#61)", () => {
const res = buildSpawnRequest(
{
...base,
worktreeEnabled: true,
worktreeBranch: "spawn-v2",
worktreeBranchMode: "existing",
},
"/home/user/project",
);
expect(res).toEqual({
req: {
cwd: base.cwd,
command: base.command,
metadata: { name: "amber-fox" },
worktree: {
base_repo: "/home/user/project",
branch: "spawn-v2",
cleanup_on_kill: true,
create_branch: false,
},
},
});
});
it("rejects an existing-mode worktree with no branch picked", () => {
expect(
buildSpawnRequest(
{ ...base, worktreeEnabled: true, worktreeBranch: "", worktreeBranchMode: "existing" },
"/repo",
),
).toHaveProperty("error");
});
it("rejects a missing working directory", () => {
expect(buildSpawnRequest({ ...base, cwd: "" }, null)).toHaveProperty("error");
});
+336 -97
View File
@@ -1,20 +1,35 @@
import { escapeHtml } from "./html.js";
import { createSession, getDaemonInfo, recentDirs, gitRoot } from "./api.js";
import {
createSession,
fetchBranches,
fetchNames,
getDaemonInfo,
projects as fetchProjects,
gitRoot,
} from "./api.js";
import { attachDirAutocomplete } from "./dirAutocomplete.js";
import { generateName } from "./names.js";
import type { CreateSessionRequest, RecentDir, Session, SpawnPreset } from "./types.js";
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
import { abbreviateCwd, abbreviateFolderName } from "./types.js";
/** Seed values for the spawn form — used by "clone" to reopen with the same
* cwd, command, and name as an existing session, and by draft panes to restore
* their own half-filled state after a re-render. */
/** Where a worktree's branch comes from: freshly created (default) or picked
* from the repository's existing branches (#61). */
export type WorktreeBranchMode = "new" | "existing";
/** Seed values for the spawn form — used by "clone" to open a draft with the
* same cwd, command, and name as an existing session, and by draft panes to
* restore their own half-filled state after a re-render. */
export interface SpawnFormSeed {
command?: string;
cwd?: string;
name?: string;
worktreeEnabled?: boolean;
worktreeBranch?: string;
worktreeCleanup?: boolean;
/** Whether the operator typed the branch themselves, rather than it having
* been auto-synced from the session name. Carried separately because the
* value alone cannot distinguish the two. */
worktreeBranchEdited?: boolean;
worktreeBranchMode?: WorktreeBranchMode;
cwdRawVisible?: boolean;
}
@@ -26,7 +41,9 @@ export interface SpawnFormValues {
name: string;
worktreeEnabled: boolean;
worktreeBranch: string;
worktreeCleanup: boolean;
worktreeBranchEdited: boolean;
/** "new" creates the branch; "existing" checks out a picked one (#61). */
worktreeBranchMode: WorktreeBranchMode;
/** Whether the cwd lives in the raw text input (vs a picked recent row). */
cwdRawVisible?: boolean;
}
@@ -49,13 +66,21 @@ export function buildSpawnRequest(
};
if (values.worktreeEnabled) {
const mode = values.worktreeBranchMode ?? "new";
const branch = values.worktreeBranch.trim();
if (!baseRepo) return { error: "Worktree needs a directory inside a git repository." };
if (!branch) return { error: "Worktree requires a branch name." };
if (!branch) {
return { error: mode === "existing" ? "Pick an existing branch." : "Worktree requires a branch name." };
}
req.worktree = {
base_repo: baseRepo,
branch,
cleanup_on_kill: values.worktreeCleanup,
// The daemon always cleans up, guarded against unsaved work (#56);
// the wire field stays for older daemons.
cleanup_on_kill: true,
// Existing-mode picks must check out as-is; the daemon creates
// anything else with `worktree add -b` (#56/#61).
create_branch: mode !== "existing",
};
}
@@ -63,8 +88,9 @@ export function buildSpawnRequest(
}
/** Built-ins used when the daemon's `/api/info` presets can't be fetched
* (older daemon, transient error). Mirrors `maestro_config::builtin_presets`. */
const FALLBACK_PRESETS: SpawnPreset[] = [
* (older daemon, transient error). Mirrors `maestro_config::builtin_presets`.
* Exported for the fast-forward spawn's preset fallback (#67). */
export const FALLBACK_PRESETS: SpawnPreset[] = [
{ id: "claude", label: "Claude", command: "claude" },
{ id: "opencode", label: "opencode", command: "opencode" },
{ id: "zsh", label: "Shell", command: "zsh" },
@@ -79,7 +105,7 @@ export interface SpawnFormHandle {
export interface SpawnFormOptions {
seed?: SpawnFormSeed;
/** Extra class(es) for the <form> root (e.g. `modal-body` in the modal). */
/** Extra class(es) for the <form> root (e.g. `mv-draft` in a draft pane). */
class?: string;
/** Fired whenever any value changes (input, tile pick, checkbox). */
onChange?: (values: SpawnFormValues) => void;
@@ -87,8 +113,8 @@ export interface SpawnFormOptions {
onSuccess: (session: Session) => void;
}
/** The spawn form itself — preset tiles, recent-dirs picker, name, worktree.
* Shared by the modal (`spawn.ts`) and inline draft panes (issue #57). */
/** The spawn form itself — preset tiles, project picker, name, worktree.
* Mounted inline by draft panes (issue #57). */
export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
const prefill = opts.seed ?? {};
@@ -103,7 +129,9 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
</div>
<div class="field">
<span class="field-label">Working directory</span>
<div class="picker recents"></div>
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
autocomplete="off" />
<div class="picker projects"></div>
<div class="raw-wrap cwd-raw-wrap" hidden>
<input class="raw-input cwd-raw" name="cwd_raw" type="text"
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
@@ -126,13 +154,23 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
<p class="wt-hint" hidden></p>
<div class="wt-body" hidden>
<p class="wt-repo"></p>
<label>
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
<label class="row-inline">
<input type="radio" name="worktree_branch_mode" value="new" />
New branch
</label>
<label class="row-inline">
<input type="radio" name="worktree_branch_mode" value="existing" />
Existing branch
</label>
</div>
<label class="wt-new-wrap">
Branch
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
</label>
<label class="row-inline">
<input type="checkbox" name="worktree_cleanup_on_kill" />
Remove worktree when session is killed
<label class="wt-exist-wrap" hidden>
Branch
<select class="branch-select"></select>
</label>
</div>
</fieldset>
@@ -142,22 +180,26 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
</div>
`;
// Field queries are scoped to this form instance — several forms can be
// mounted at once (modal + draft panes), so ids would collide.
// mounted at once (one per draft pane), so ids would collide.
const errEl = form.querySelector(".form-error") as HTMLElement;
const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement;
const cmdRaw = form.querySelector(".cmd-raw") as HTMLInputElement;
const recentsEl = form.querySelector(".recents") as HTMLElement;
const recentsEl = form.querySelector(".projects") as HTMLElement;
const filterInput = form.querySelector(".project-filter") as HTMLInputElement;
const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement;
const wtHint = form.querySelector(".wt-hint") as HTMLElement;
const wtBody = form.querySelector(".wt-body") as HTMLElement;
const wtRepo = form.querySelector(".wt-repo") as HTMLElement;
const modeRadios = [
...form.querySelectorAll<HTMLInputElement>("input[name=worktree_branch_mode]"),
];
const newWrap = form.querySelector(".wt-new-wrap") as HTMLElement;
const existWrap = form.querySelector(".wt-exist-wrap") as HTMLElement;
const branchInput = form.querySelector("input[name=worktree_branch]") as HTMLInputElement;
const cleanupInput = form.querySelector(
"input[name=worktree_cleanup_on_kill]",
) as HTMLInputElement;
const branchSelect = form.querySelector(".branch-select") as HTMLSelectElement;
const nameInput = form.querySelector("input[name=name]") as HTMLInputElement;
// ---- form state ------------------------------------------------------
@@ -166,20 +208,35 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
let selectedPresetId = "";
let command = ""; // resolved command (preset's, or the raw custom input)
let cwd = ""; // chosen working directory
let inferredBaseRepo: string | null = null; // from the gitroot probe
let inferredBaseRepo: string | null = null; // from the gitroot probe or the project scan
let probeToken = 0; // guards async gitroot probes against races
// A seed's cwd, applied once the (async) recents load for the initial host
// A seed's cwd, applied once the (async) projects load for the initial host
// settles — so the fetch can't race past and clobber the seeded directory.
let seedCwd: string | null = prefill.cwd ?? null;
let branchMode: WorktreeBranchMode = prefill.worktreeBranchMode ?? "new";
let branchToken = 0; // guards async branch-list loads against races
// Branch list for the current cwd — refetched whenever the target changes.
let branchCache: { cwd: string; branches: BranchInfo[] } | null = null;
// `emit` stays silent until the initial load settles and goes silent again
// once the form is destroyed. Construction runs with `cwd === ""` and the
// seed is only restored two fetches later, so emitting in between would
// blank the leaf's draft; and `multiview.render()` rebuilds forms while the
// old one's fetches are still in flight, so a destroyed form emitting would
// clobber the live one's leaf (#57 review).
let ready = false;
let destroyed = false;
const branchValue = () => (branchMode === "existing" ? branchSelect.value : branchInput.value);
const emit = () => {
if (!ready || destroyed) return;
opts.onChange?.({
command,
cwd,
name: nameInput.value,
worktreeEnabled: wtEnabled.checked,
worktreeBranch: branchInput.value,
worktreeCleanup: cleanupInput.checked,
worktreeBranch: branchValue(),
worktreeBranchEdited: branchEdited,
worktreeBranchMode: branchMode,
cwdRawVisible: !cwdRawWrap.hidden,
});
};
@@ -187,13 +244,55 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
// ---- name (also seeds the worktree branch) ---------------------------
// The worktree branch defaults to the session name — including a clone's
// prefilled name — and tracks it on reroll/edit until the user types their
// own branch.
let branchEdited = Boolean(prefill.worktreeBranch);
// own branch. Only in "new" mode: an existing-branch pick is not a name.
// True only once the operator types their own branch. It cannot be inferred
// from the seed: in "new" mode `emit()` publishes the auto-synced session
// name, so seeding from it froze the branch after any form rebuild
// (#67 review). `worktreeBranchEdited` is carried explicitly instead.
let branchEdited = Boolean(prefill.worktreeBranchEdited);
const syncBranchToName = () => {
if (!branchEdited) branchInput.value = nameInput.value;
if (!branchEdited && branchMode === "new") branchInput.value = nameInput.value;
};
nameInput.value = prefill.name ?? generateName();
// Handles come from `GET /api/names` in batches (#53): the first one seeds
// the field, rerolls consume the rest locally so they stay instant, and a
// new batch is fetched when (and only when) the queue runs dry. One refill
// at a time keeps rapid rerolls from storming the daemon.
const NAME_BATCH_SIZE = 20;
let nameQueue: string[] = [];
let nameFetch: Promise<void> | null = null;
// Guards against a late batch clobbering something the operator already
// typed or rerolled.
let nameTouched = Boolean(prefill.name);
const refillNames = (): Promise<void> => {
if (!nameFetch) {
nameFetch = fetchNames(NAME_BATCH_SIZE)
.then((batch) => {
if (!destroyed) nameQueue.push(...batch.names);
})
.catch(() => {
/* reroll just stays on the current name */
})
.finally(() => {
nameFetch = null;
});
}
return nameFetch;
};
nameInput.value = prefill.name ?? "";
if (!nameTouched) {
void refillNames().then(() => {
if (destroyed || nameTouched || nameInput.value) return;
const next = nameQueue.shift();
if (next !== undefined) {
nameInput.value = next;
syncBranchToName();
emit();
}
});
}
nameInput.addEventListener("input", () => {
nameTouched = true;
syncBranchToName();
emit();
});
@@ -202,9 +301,21 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
emit();
});
(form.querySelector(".reroll") as HTMLButtonElement).addEventListener("click", () => {
nameInput.value = generateName();
syncBranchToName();
emit();
nameTouched = true;
const apply = () => {
if (destroyed) return;
const next = nameQueue.shift();
if (next === undefined) return;
nameInput.value = next;
syncBranchToName();
emit();
};
// Only hit the daemon when the local batch is exhausted.
if (nameQueue.length === 0) {
void refillNames().then(apply);
} else {
apply();
}
});
syncBranchToName(); // seed the branch from the initial name
@@ -291,21 +402,25 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
cwdRawWrap.hidden = false;
if (prefillValue != null) cwd = prefillValue;
cwdRaw.value = cwd;
markActiveRecent(null);
markActiveProject(null);
if (focus) {
cwdRaw.focus();
cwdRaw.select();
}
// Both the visibility and the directory are draft state: without this a
// render before the operator types snaps back to the project picker.
emit();
}
cwdRaw.addEventListener("input", () => {
cwd = cwdRaw.value.trim();
branchCache = null;
emit();
if (wtEnabled.checked) probeGitRoot();
});
function markActiveRecent(activeCwd: string | null): void {
for (const row of recentsEl.querySelectorAll(".recent-row")) {
function markActiveProject(activeCwd: string | null): void {
for (const row of recentsEl.querySelectorAll(".project-row")) {
row.classList.toggle(
"is-active",
(row as HTMLElement).dataset.cwd === activeCwd,
@@ -313,78 +428,101 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
}
}
function selectRecent(dir: RecentDir): void {
cwd = dir.cwd;
function selectProject(project: Project): void {
cwd = project.path;
cwdRawWrap.hidden = true;
markActiveRecent(dir.cwd);
markActiveProject(project.path);
// The scan already knows this directory is inside a repo (#61): the
// toggle defaults back on and no /api/gitroot round trip is needed.
wtEnabled.checked = true;
applyProjectRepo(project.base_repo ?? project.path);
emit();
// Picking a recent dir can pre-select the command it was last used with.
const match = presets.find((p) => p.command === dir.last_command && p.command !== "");
// Picking a project pre-selects the command it was last used with.
const match = presets.find((p) => p.command === project.last_command && p.command !== "");
if (match) selectPreset(match.id);
if (wtEnabled.checked) probeGitRoot();
}
async function loadRecents(): Promise<void> {
recentsEl.replaceChildren(loadingRow());
let dirs: RecentDir[] = [];
try {
dirs = (await recentDirs()).dirs ?? [];
} catch {
dirs = [];
}
let discovered: Project[] = []; // daemon snapshot, daemon-sorted
function renderProjects(): void {
recentsEl.replaceChildren();
for (const dir of dirs) {
recentsEl.appendChild(recentRow(dir));
const sorted = sortProjects(filterProjects(discovered, filterInput.value), new Date());
for (const group of groupProjects(sorted)) {
recentsEl.appendChild(projectRow(group.main));
for (const wt of group.worktrees) recentsEl.appendChild(projectRow(wt));
}
if (discovered.length > 0 && sorted.length === 0) {
const empty = document.createElement("p");
empty.className = "picker-empty";
empty.textContent = "No projects match.";
recentsEl.appendChild(empty);
}
// Persistent escape hatch + empty-history fallback.
recentsEl.appendChild(otherDirRow());
markActiveProject(cwdRawWrap.hidden ? cwd : null);
}
filterInput.addEventListener("input", renderProjects);
async function loadProjects(): Promise<void> {
recentsEl.replaceChildren(loadingRow());
try {
discovered = await fetchProjects();
} catch {
discovered = [];
}
if (destroyed) return;
renderProjects();
if (seedCwd) {
// A seed sets the directory verbatim, regardless of recorded history.
// A seed sets the directory verbatim, regardless of what the scan found.
// `cwdRawVisible === false` marks a draft restore whose cwd came from a
// picked recent row: keep the picker and don't steal focus. Otherwise
// land focused in the raw input (clone / empty-history flows).
// picked row: keep the picker and don't steal focus. Otherwise land
// focused in the raw input (clone / no-match flows).
if (prefill.cwdRawVisible === false) {
cwd = seedCwd;
cwdRaw.value = seedCwd;
markActiveRecent(cwd);
markActiveProject(cwd);
} else {
showRawCwd(seedCwd, false);
}
seedCwd = null;
} else if (dirs.length === 0) {
// No history: default to raw input prefilled with the daemon's home dir.
} else if (discovered.length === 0) {
// Nothing discovered yet: default to raw input prefilled with home.
showRawCwd(homeDir, false);
} else {
cwdRawWrap.hidden = true;
}
// The seeded cwd is in place — a restored worktree can probe now.
if (wtEnabled.checked) void probeGitRoot();
// Publish it: construction ran `emit()` with an empty cwd, so without this
// the leaf's draft keeps `cwd: ""` and the next render reseeds it blank.
// Everything seeded: the form may publish from here on.
ready = true;
resolveBaseRepo();
emit();
}
function recentRow(dir: RecentDir): HTMLElement {
function projectRow(project: Project): HTMLElement {
const row = document.createElement("button");
row.type = "button";
row.className = "recent-row";
row.dataset.cwd = dir.cwd;
row.className = `project-row${project.is_worktree ? " is-worktree" : ""}${
isStale(project, new Date()) ? " is-stale" : ""
}`;
row.dataset.cwd = project.path;
// Abbreviate exactly like the sidebar: home → `~`, then collapse leading
// segments to initials (`~/g/n/maestro`). The full path stays in the tooltip
// and `dataset.cwd` (what's actually submitted).
const display = abbreviateFolderName(abbreviateCwd(dir.cwd, homeDir));
const display = abbreviateFolderName(abbreviateCwd(project.path, homeDir));
row.innerHTML = `
<span class="recent-dir" title="${escapeHtml(dir.cwd)}">${display}</span>
<span class="recent-cmd">${escapeHtml(dir.last_command)}</span>`;
row.addEventListener("click", () => selectRecent(dir));
<span class="project-info">
<span class="project-name">${escapeHtml(project.name)}</span>
<span class="recent-dir" title="${escapeHtml(project.path)}">${display}</span>
</span>
<span class="recent-cmd">${escapeHtml(project.last_command ?? "")}</span>`;
row.addEventListener("click", () => selectProject(project));
return row;
}
function otherDirRow(): HTMLElement {
const row = document.createElement("button");
row.type = "button";
row.className = "recent-row recent-other";
row.innerHTML = `<span class="recent-dir">Other directory…</span>`;
row.className = "project-row project-other";
row.innerHTML = `<span class="project-info"><span class="recent-dir">Other directory…</span></span>`;
row.addEventListener("click", () => showRawCwd(cwd || homeDir));
return row;
}
@@ -392,11 +530,51 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
function loadingRow(): HTMLElement {
const p = document.createElement("p");
p.className = "picker-loading";
p.textContent = "Loading recent directories…";
p.textContent = "Loading projects…";
return p;
}
// ---- worktree (branch-only; base_repo inferred via /gitroot) ----------
// ---- worktree (branch-only; base_repo inferred via scan or /gitroot) --
// The project scan already reports repo membership, so a picked project
// resolves its base repo locally (#61); only the free-text escape hatch
// pays for a /api/gitroot probe.
function applyProjectRepo(repo: string): void {
inferredBaseRepo = repo;
branchCache = null;
wtHint.hidden = true;
wtBody.hidden = false;
wtRepo.textContent = `repo: ${repo}`;
if (branchMode === "existing") void loadBranches();
}
/** Decide how the worktree section describes the current target. Called
* once the initial load has settled `cwd`. */
function resolveBaseRepo(): void {
// Nothing to resolve when the operator turned the worktree off — the
// section would otherwise rebuild expanded under an unchecked box, and
// fire a `/api/gitroot` probe for a worktree nobody asked for
// (#67 review).
if (!wtEnabled.checked) {
wtBody.hidden = true;
wtHint.hidden = true;
inferredBaseRepo = null;
return;
}
if (!cwd) {
inferredBaseRepo = null;
wtBody.hidden = true;
wtHint.hidden = false;
wtHint.textContent = "Choose a working directory first.";
return;
}
const project = discovered.find((p) => p.path === cwd);
if (project) {
applyProjectRepo(project.base_repo ?? project.path);
return;
}
void probeGitRoot();
}
async function probeGitRoot(): Promise<void> {
const token = ++probeToken;
if (!cwd) {
@@ -417,45 +595,104 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
}
if (token !== probeToken) return; // a newer probe superseded us
if (root) {
inferredBaseRepo = root;
wtHint.hidden = true;
wtBody.hidden = false;
wtRepo.textContent = `repo: ${root} (inferred)`;
applyProjectRepo(root);
} else {
inferredBaseRepo = null;
// Publish the untick too, or the next render seeds the box checked with
// no inferred repo and submitting fails.
wtEnabled.checked = false;
emit();
wtBody.hidden = true;
wtHint.hidden = false;
wtHint.textContent = "Not inside a git repository — pick a dir in a repo to use a worktree.";
}
}
// ---- existing-branch picker (#61) -------------------------------------
function setBranchMode(mode: WorktreeBranchMode): void {
branchMode = mode;
for (const radio of modeRadios) radio.checked = radio.value === mode;
newWrap.hidden = mode !== "new";
existWrap.hidden = mode !== "existing";
if (mode === "existing") void loadBranches();
emit();
}
/** Fetch the target repo's branches once per cwd. Like every async load in
* this form: token-guarded, and nothing touches the DOM (or emits) after
* destroy() — a late response into a rebuilt form would corrupt that
* form's draft (#57 review). */
async function loadBranches(): Promise<void> {
if (!cwd || !inferredBaseRepo) return;
const token = ++branchToken;
if (branchCache?.cwd !== cwd) {
const target = cwd;
let branches: BranchInfo[];
try {
branches = await fetchBranches(target);
} catch {
return; // picker just stays empty; "new" mode still works
}
if (destroyed || token !== branchToken || cwd !== target) return;
branchCache = { cwd: target, branches };
}
renderBranchOptions();
}
function renderBranchOptions(): void {
if (destroyed) return;
const branches = branchCache?.cwd === cwd ? branchCache.branches : [];
branchSelect.replaceChildren();
const opt = (label: string, value: string) => {
const o = document.createElement("option");
o.textContent = label;
o.value = value;
return o;
};
if (branches.length === 0) {
branchSelect.appendChild(opt("No local branches found.", ""));
return;
}
for (const b of branches) {
// A branch that is already checked out anywhere stays visible but
// unpickable — git refuses a second checkout. That includes the main
// checkout's own HEAD, which is commonly `main` and would otherwise be
// the default pick, making every existing-branch spawn fail (#54 review).
const held = b.worktree ?? (b.is_head ? "the main checkout" : null);
const o = opt(held ? `${b.name} — held by ${held}` : b.name, b.name);
o.disabled = held !== null;
o.title = held ?? "";
branchSelect.appendChild(o);
}
}
for (const radio of modeRadios) {
radio.addEventListener("change", () => {
if (radio.checked) setBranchMode(radio.value as WorktreeBranchMode);
});
}
branchSelect.addEventListener("change", () => emit());
wtEnabled.addEventListener("change", () => {
emit();
if (wtEnabled.checked) {
probeGitRoot();
resolveBaseRepo();
} else {
inferredBaseRepo = null;
wtBody.hidden = true;
wtHint.hidden = true;
}
});
cleanupInput.addEventListener("change", emit);
// A seeded worktree restores its checkbox/body up front; its gitroot probe
// waits for loadRecents to apply the seeded cwd.
if (prefill.worktreeEnabled || prefill.worktreeBranch || prefill.worktreeCleanup) {
wtEnabled.checked = Boolean(prefill.worktreeEnabled);
if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch;
cleanupInput.checked = Boolean(prefill.worktreeCleanup);
wtBody.hidden = !wtEnabled.checked;
wtHint.hidden = true;
emit();
}
// The toggle defaults ON (#61): the operator almost always wants a
// worktree, and every non-repo target unticks it via the gitroot probe.
wtEnabled.checked = prefill.worktreeEnabled ?? true;
if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch;
setBranchMode(branchMode);
// Seed ordering: the raw-cwd application races the async info load (which
// provides homeDir for the recents' abbreviations), so recents wait for it.
void infoLoad.then(() => loadRecents());
// provides homeDir for the projects' abbreviations), so projects wait for it.
void infoLoad.then(() => loadProjects());
// ---- live dir autocomplete on the raw cwd input (NAP-329) ------------
const autocomplete = attachDirAutocomplete(cwdRaw);
@@ -470,8 +707,9 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
cwd,
name: nameInput.value,
worktreeEnabled: wtEnabled.checked,
worktreeBranch: branchInput.value,
worktreeCleanup: cleanupInput.checked,
worktreeBranch: branchValue(),
worktreeBranchEdited: branchEdited,
worktreeBranchMode: branchMode,
},
inferredBaseRepo,
);
@@ -494,6 +732,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
return {
el: form,
destroy() {
destroyed = true;
autocomplete.destroy();
form.remove();
},
+129
View File
@@ -0,0 +1,129 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
const BATCH_A = ["allegro-cello", "presto-fugue", "adagio-lyre"];
const BATCH_B = ["forte-oboe", "largo-tuba", "vivace-harp"];
/** Stub the three fetches the form's initial load makes. Records every
* /api/names URL so tests can assert how many batches were fetched. */
function stubFetch(batches: string[][]): string[] {
let batch = 0;
const nameUrls: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/api/names")) {
nameUrls.push(url);
const names = batches[Math.min(batch, batches.length - 1)];
batch += 1;
return new Response(JSON.stringify({ names }), { status: 200 });
}
const body = url.includes("/api/info")
? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }
: [];
return new Response(JSON.stringify(body), { status: 200 });
}),
);
return nameUrls;
}
describe("spawnForm name batch (#53)", () => {
let handle: SpawnFormHandle | null = null;
let nameUrls: string[];
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
handle?.destroy();
handle = null;
vi.unstubAllGlobals();
vi.useRealTimers();
});
async function mount(seed?: Parameters<typeof createSpawnForm>[0]["seed"]) {
nameUrls = stubFetch([BATCH_A, BATCH_B]);
handle = createSpawnForm({ seed, onSuccess() {} });
document.body.appendChild(handle.el);
for (let i = 0; i < 40; i++) await Promise.resolve();
return handle.el;
}
const nameOf = (form: HTMLFormElement) =>
form.querySelector<HTMLInputElement>("input[name=name]")!.value;
function reroll(form: HTMLFormElement): void {
form.querySelector<HTMLButtonElement>(".reroll")!.click();
}
async function drain(): Promise<void> {
for (let i = 0; i < 40; i++) await Promise.resolve();
}
it("seeds its name from a fetched batch when no seed provides one", async () => {
const form = await mount();
expect(BATCH_A).toContain(nameOf(form));
expect(nameUrls).toHaveLength(1);
expect(nameUrls[0]).toContain("/api/names");
expect(nameUrls[0]).toContain("count=");
});
it("keeps a seeded name and defers fetching until the first reroll", async () => {
const form = await mount({ name: "my-session" });
expect(nameOf(form)).toBe("my-session");
expect(nameUrls ?? []).toHaveLength(0);
reroll(form);
await drain();
expect(nameOf(form)).not.toBe("my-session");
expect(nameUrls).toHaveLength(1);
});
it("rerolls locally from the batch with no extra round trips", async () => {
const form = await mount();
expect(nameUrls).toHaveLength(1);
const seen = new Set([nameOf(form)]);
for (let i = 0; i < BATCH_A.length; i++) {
reroll(form);
seen.add(nameOf(form));
}
await drain();
// Every value came from the one fetched batch.
expect(seen.size).toBeGreaterThanOrEqual(2);
for (const value of seen) {
expect([...BATCH_A, ...BATCH_B]).toContain(value);
}
expect(nameUrls).toHaveLength(2); // refetch fired when the batch ran out
expect(nameUrls[1]).toContain("/api/names");
});
it("refetches only once even if rerolls outpace the network", async () => {
const form = await mount();
// Drain the batch without awaiting between rerolls.
for (let i = 0; i < BATCH_A.length + 1; i++) reroll(form);
await drain();
// One refill in flight covers the whole run — no fetch storm.
expect(nameUrls.length).toBeLessThanOrEqual(2);
});
it("does not clobber an operator-typed name when the batch arrives late", async () => {
nameUrls = stubFetch([BATCH_A]);
handle = createSpawnForm({ onSuccess() {} });
const form = handle.el;
document.body.appendChild(form);
// The operator types before the fetch resolves.
const input = form.querySelector<HTMLInputElement>("input[name=name]")!;
input.value = "hand-typed";
input.dispatchEvent(new Event("input"));
for (let i = 0; i < 40; i++) await Promise.resolve();
expect(nameOf(form)).toBe("hand-typed");
});
});
+172
View File
@@ -0,0 +1,172 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js";
const PROJECTS = [
{ path: "/home/user/maestro", name: "maestro", last_activity: "2026-08-20T10:00:00Z" },
];
const BRANCHES = [
{ name: "main", is_head: true },
{ name: "spawn-v2", is_head: false, worktree: "/home/user/wt-spawn" },
{ name: "free-branch", is_head: false },
];
/** Stub the fetches the form makes. `gateBranches` (when given) holds the
* /api/branches response until released, so tests can destroy the form while
* its branch list is still in flight. */
function stubFetch(opts?: {
gateBranches?: (release: () => void) => void;
}): string[] {
const urls: string[] = [];
const gated = new Promise<void>((resolve) => {
if (opts?.gateBranches) opts.gateBranches(resolve);
else resolve();
});
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
urls.push(url);
if (url.includes("/api/names")) {
return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 });
}
if (url.includes("/api/branches")) {
await gated;
return new Response(JSON.stringify(BRANCHES), { status: 200 });
}
if (url.includes("/api/projects")) {
return new Response(JSON.stringify(PROJECTS), { status: 200 });
}
return new Response(
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }),
{ status: 200 },
);
}),
);
return urls;
}
describe("spawnForm worktree defaults and branch picker (#61)", () => {
let handle: SpawnFormHandle | null = null;
let urls: string[];
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
handle?.destroy();
handle = null;
vi.unstubAllGlobals();
vi.useRealTimers();
});
async function mount(
seed?: Parameters<typeof createSpawnForm>[0]["seed"],
onChange?: (values: SpawnFormValues) => void,
) {
urls = stubFetch();
handle = createSpawnForm({ seed, onSuccess() {}, onChange });
document.body.appendChild(handle.el);
for (let i = 0; i < 40; i++) await Promise.resolve();
return handle.el;
}
const drain = async () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
};
function pickProject(form: HTMLFormElement): void {
form.querySelector<HTMLButtonElement>(".project-row.project-other")!.click();
form
.querySelectorAll<HTMLButtonElement>(".project-row")
.forEach((row) => row.dataset.cwd === "/home/user/maestro" && row.click());
}
it("defaults the worktree toggle on for a picked project with no gitroot probe", async () => {
const form = await mount();
expect(form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked).toBe(
true,
);
pickProject(form);
await drain();
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain(
"/home/user/maestro",
);
});
it("still probes the gitroot for a free-text directory", async () => {
const form = await mount();
// Escape hatch: switch to a free-text dir; only now does /api/gitroot fire.
form.querySelector<HTMLElement>(".project-row.project-other")!.click();
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
raw.value = "/opt/somewhere";
raw.dispatchEvent(new Event("input"));
await drain();
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(true);
});
it("populates existing branches, disabling ones held by another worktree", async () => {
const form = await mount();
pickProject(form);
const radio = form.querySelector<HTMLInputElement>(
'input[name=worktree_branch_mode][value="existing"]',
)!;
radio.click();
await drain();
const select = form.querySelector<HTMLSelectElement>(".branch-select")!;
expect(urls.some((u) => u.includes("/api/branches?path="))).toBe(true);
const options = [...select.options];
expect(options.map((o) => o.value)).toEqual(["main", "spawn-v2", "free-branch"]);
const held = options.find((o) => o.value === "spawn-v2")!;
expect(held.disabled).toBe(true);
expect(held.textContent).toContain("/home/user/wt-spawn");
expect(options.find((o) => o.value === "free-branch")!.disabled).toBe(false);
});
it("emits the branch mode as draft state when it changes", async () => {
const changes: SpawnFormValues[] = [];
const form = await mount(undefined, (v) => changes.push(v));
await drain();
const radio = form.querySelector<HTMLInputElement>(
'input[name=worktree_branch_mode][value="existing"]',
)!;
radio.click();
await drain();
const modes = changes.map((c) => (c as { worktreeBranchMode?: string }).worktreeBranchMode);
expect(modes).toContain("new");
expect(modes.at(-1)).toBe("existing");
});
it("does not repopulate a destroyed form when the branch list lands late", async () => {
const gate: { release?: () => void } = {};
urls = stubFetch({ gateBranches: (r) => (gate.release = r) });
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
for (let i = 0; i < 40; i++) await Promise.resolve();
pickProject(handle.el);
handle.el
.querySelector<HTMLInputElement>('input[name=worktree_branch_mode][value="existing"]')!
.click();
handle.destroy();
gate.release?.();
await drain();
expect(
handle.el.querySelector<HTMLSelectElement>(".branch-select")!.options.length,
).toBe(0);
});
});
+61 -124
View File
@@ -1205,72 +1205,9 @@ textarea {
border-color: var(--accent-strong);
}
/* ---------- modal ---------- */
/* ---------- forms ---------- */
.modal-backdrop {
position: fixed;
inset: 0;
background: oklch(0.12 0.01 256 / 0.6);
backdrop-filter: blur(3px);
display: flex;
align-items: flex-end;
justify-content: center;
padding: 0;
z-index: var(--z-modal);
animation: fade var(--dur) var(--ease-out);
}
@media (min-width: 600px) {
.modal-backdrop {
align-items: center;
padding: var(--sp-6);
}
}
.modal {
background: var(--surface);
border-radius: var(--r-3) var(--r-3) 0 0;
border: 1px solid var(--border);
width: 100%;
max-width: 560px;
max-height: 92vh;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-2);
animation: sheet-up var(--dur-slow) var(--ease-out);
}
@media (min-width: 600px) {
.modal {
border-radius: var(--r-3);
animation: pop var(--dur) var(--ease-out);
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-3);
border-bottom: 1px solid var(--border);
}
.modal-header h2 {
margin: 0;
font-size: var(--fs-15);
font-weight: 600;
display: inline-flex;
align-items: center;
gap: var(--sp-2);
}
.modal-header h2::before {
content: "+";
color: var(--accent);
font-weight: 700;
}
.modal-body {
.spawn-form {
padding: var(--sp-3);
overflow: auto;
display: flex;
@@ -1280,7 +1217,6 @@ textarea {
scrollbar-color: var(--border-strong) transparent;
}
.modal-body label,
.spawn-form label {
display: flex;
flex-direction: column;
@@ -1290,7 +1226,6 @@ textarea {
letter-spacing: 0.01em;
}
.modal-body label.row-inline,
.spawn-form label.row-inline {
flex-direction: row;
align-items: center;
@@ -1300,10 +1235,9 @@ textarea {
cursor: pointer;
}
.modal-body input[type="text"],
.modal-body input[type="search"],
.modal-body select,
.spawn-form input[type="text"] {
.spawn-form input[type="text"],
.spawn-form input[type="search"],
.spawn-form select {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-2);
@@ -1318,23 +1252,20 @@ textarea {
background var(--dur-fast) var(--ease-out);
}
.modal-body input[type="text"]:hover,
.modal-body select:hover,
.spawn-form input[type="text"]:hover {
.spawn-form input[type="text"]:hover,
.spawn-form select:hover {
border-color: var(--border-strong);
}
.modal-body input[type="text"]:focus,
.modal-body input[type="search"]:focus,
.modal-body select:focus,
.spawn-form input[type="text"]:focus {
.spawn-form input[type="text"]:focus,
.spawn-form input[type="search"]:focus,
.spawn-form select:focus {
outline: none;
border-color: var(--accent);
background: var(--surface);
box-shadow: 0 0 0 3px var(--accent-tint);
}
.modal-body input[type="checkbox"],
.spawn-form input[type="checkbox"] {
width: 18px;
height: 18px;
@@ -1359,13 +1290,6 @@ textarea {
font-size: var(--fs-13);
}
.modal-actions {
display: flex;
gap: var(--sp-2);
justify-content: flex-end;
margin-top: var(--sp-2);
}
.form-error {
color: var(--danger);
margin: 0;
@@ -1381,7 +1305,6 @@ textarea {
/* ---------- spawn form: tiles + recents picker (#27) ---------- */
.spawn-modal .field,
.spawn-form .field {
display: flex;
flex-direction: column;
@@ -1448,7 +1371,8 @@ textarea {
color: var(--muted);
}
/* Recent-dirs picker rows. */
/* Project picker rows (#59). Worktree rows are indented under their base
repository; rows stale beyond 7 days are dimmed but still selectable. */
.picker {
display: flex;
flex-direction: column;
@@ -1459,7 +1383,11 @@ textarea {
scrollbar-color: var(--border-strong) transparent;
}
.recent-row {
.project-filter {
width: 100%;
}
.project-row {
display: flex;
align-items: baseline;
justify-content: space-between;
@@ -1477,18 +1405,40 @@ textarea {
background var(--dur-fast) var(--ease-out);
}
.recent-row:hover {
.project-row:hover {
border-color: var(--border-strong);
}
.recent-row.is-active {
.project-row.is-active {
border-color: var(--accent);
background: var(--accent-tint);
}
.project-row.is-worktree {
margin-left: var(--sp-4);
}
.project-row.is-stale {
opacity: 0.55;
}
.project-info {
display: flex;
align-items: baseline;
gap: var(--sp-2);
min-width: 0;
}
.project-name {
font-size: var(--fs-13);
font-weight: 600;
white-space: nowrap;
}
.recent-dir {
font-family: var(--font-mono);
font-size: var(--fs-14);
font-size: var(--fs-12);
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -1501,12 +1451,18 @@ textarea {
color: var(--muted);
}
.recent-other .recent-dir {
color: var(--muted);
.project-other .recent-dir {
font-family: inherit;
font-style: italic;
}
.picker-empty {
margin: 0;
padding: 6px var(--sp-3);
color: var(--faint);
font-size: var(--fs-13);
}
.picker-loading {
margin: 0;
padding: 6px var(--sp-3);
@@ -1542,6 +1498,16 @@ textarea {
color: var(--muted);
}
/* New-vs-existing branch source (#61). */
.wt-mode {
display: flex;
gap: var(--sp-4);
}
.branch-select {
width: 100%;
}
/* Generated-name field with a reroll button. */
.name-row {
display: flex;
@@ -1568,7 +1534,7 @@ textarea {
.dir-ac {
position: fixed;
z-index: var(--z-toast); /* above the modal it completes within */
z-index: var(--z-toast); /* above the draft pane it completes within */
margin: 0;
padding: var(--sp-1);
list-style: none;
@@ -1716,26 +1682,6 @@ textarea {
}
}
@keyframes fade {
from {
opacity: 0;
}
}
@keyframes sheet-up {
from {
transform: translateY(16px);
opacity: 0;
}
}
@keyframes pop {
from {
transform: scale(0.98);
opacity: 0;
}
}
/* ---------- responsive ---------- */
@media (max-width: 520px) {
@@ -2076,15 +2022,6 @@ textarea {
flex: 1;
min-width: 0;
min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
/* The modal form got its stacking and padding from `.modal-body`; in a pane
the form is the host, so it carries them itself. */
display: flex;
flex-direction: column;
gap: var(--sp-2);
padding: var(--sp-3);
}
/* pane tools cluster (disconnect / split / close), top-right corner. The attach
+44
View File
@@ -83,12 +83,56 @@ export interface GitRoot {
root?: string;
}
/** `GET /api/names?count=N` — a batch of fresh session handles (#53). Mirrors
* `maestro_protocol::NameBatch`. */
export interface NameBatch {
names: string[];
}
/** One discovered git repository (or linked worktree) for `GET /api/projects`
* (#54). Mirrors `maestro_protocol::Project`. */
export interface Project {
/** Absolute path of the repository/worktree toplevel. */
path: string;
/** Basename of `path`. */
name: string;
is_worktree?: boolean;
/** The main checkout this worktree belongs to; absent on plain repos. */
base_repo?: string;
/** Later of the repo's git HEAD mtime and the newest spawn-history use of
* any directory inside it; absent when neither is known. */
last_activity?: IsoTimestamp;
/** Command last spawned in this project, from spawn history. */
last_command?: string;
}
/** `GET /api/projects` — every discovered project, sorted by the daemon
* newest-activity-first (#54). */
export type Projects = Project[];
export interface WorktreeSpec {
base_repo: string;
branch: string;
cleanup_on_kill?: boolean;
/** When false, `branch` already exists and is checked out as-is (#61).
* Defaults to true (create a new branch) on older daemons. */
create_branch?: boolean;
}
/** One local branch of the enclosing repository, from `GET /api/branches`
* (#55). Mirrors `maestro_protocol::BranchInfo`. */
export interface BranchInfo {
/** Short branch name (`main`, `feature/foo`). */
name: string;
/** Whether this branch is the main checkout's current HEAD. */
is_head: boolean;
/** Toplevel path of the linked worktree holding this branch, if any. */
worktree?: string | null;
}
/** `GET /api/branches?path=` — the local branches of one repository (#55). */
export type Branches = BranchInfo[];
export interface CreateSessionRequest {
cwd: string;
command: string;