Compare commits

..
Author SHA1 Message Date
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
63 changed files with 6619 additions and 871 deletions
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([
+1 -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,
};
+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());
}
}
+1
View File
@@ -9,6 +9,7 @@
pub mod activity;
pub mod attach;
pub mod branches;
pub mod cli;
pub mod config;
pub mod dirlist;
+19 -1
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))
@@ -121,6 +123,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(),
+20 -6
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))
}
+99 -45
View File
@@ -39,14 +39,32 @@ impl WorktreeManager {
}
/// Where a worktree for `branch` will be created: the override root, or
/// `<base_repo>/worktrees/<sanitized branch>` by default. The path is
/// `<main checkout>/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.
///
/// `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 and the second
/// fails with `PathExists`.
fn target_path(&self, base_repo: &Path, branch: &str) -> PathBuf {
let slug = sanitize_branch_for_path(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) => {
let repo = main
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "repo".into());
root.join(repo).join(slug)
}
None => main.join("worktrees").join(slug),
}
}
@@ -69,6 +87,9 @@ impl WorktreeManager {
create_branch: bool,
) -> Result<PathBuf, WorktreeError> {
let path = self.target_path(base_repo, branch);
// Clear registrations whose directory is gone before testing the
// path, so a previously-leaked entry doesn't block this branch.
prune(base_repo)?;
if path.symlink_metadata().is_ok() {
return Err(WorktreeError::PathExists { path });
}
@@ -97,6 +118,20 @@ impl WorktreeManager {
/// which makes this guard the only thing standing between a stray kill
/// and destroyed work.
pub fn remove(&self, base_repo: &Path, path: &Path) -> Result<(), WorktreeError> {
// 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: format!("holds a nested worktree at {}", inner.display()),
});
}
if let Some(reason) = unsafe_to_remove(path)? {
return Err(WorktreeError::Guarded {
path: path.to_path_buf(),
@@ -113,6 +148,45 @@ impl WorktreeManager {
}
}
/// 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()?;
let text = String::from_utf8_lossy(&out.stdout);
text.lines()
.filter_map(|l| l.strip_prefix("worktree "))
.map(PathBuf::from)
.find(|p| p != path && p.starts_with(path))
}
fn sanitize_branch_for_path(branch: &str) -> String {
let mut out = String::with_capacity(branch.len());
for c in branch.chars() {
@@ -128,55 +202,35 @@ fn sanitize_branch_for_path(branch: &str) -> String {
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).
/// `Some(reason)` when deleting `path` would destroy work.
///
/// Only an unclean working tree qualifies. `git worktree remove` deletes the
/// checkout but keeps `refs/heads/<branch>`, so committed work — pushed or
/// not — stays reachable and is never at risk. Guarding on "commits on no
/// other ref" instead would refuse every ordinary unpushed feature branch,
/// and because the path is deterministic that branch could then never be
/// spawned again (#56 review).
fn unsafe_to_remove(path: &Path) -> Result<Option<String>, WorktreeError> {
let status = run_git(path, ["status", "--porcelain"])?;
// `--ignored=no` keeps build output out of it; untracked sources count,
// since removal would delete them.
let status = run_git(path, ["status", "--porcelain", "--ignored=no"])?;
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)
.trim()
.parse()
.unwrap_or(0);
if count > 0 {
return Ok(Some(format!(
"has {count} commit(s) not present on any other ref"
)));
}
Ok(None)
}
/// `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: impl IntoIterator<Item = &'static str>,
+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());
}
+51
View File
@@ -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
+117 -10
View File
@@ -70,8 +70,11 @@ 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"));
assert_eq!(path, wt_root.path().join(repo_name).join("feat-example"));
assert!(path.exists());
}
@@ -206,18 +209,50 @@ 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…
// Committing it makes removal safe: `worktree remove` keeps
// `refs/heads/feat/dirty`, so the work is still reachable. Refusing here
// instead would make every unpushed branch a one-shot, since the path is
// deterministic and the leftover tree blocks the next spawn (#56 review).
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 +291,75 @@ 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_eq!(
inner.parent().and_then(|p| p.parent()),
Some(repo.canonicalize().unwrap().as_path()),
"placed against the main checkout"
);
}
/// 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");
}
+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;
+308
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;
@@ -204,6 +220,19 @@ async fn kill_spares_worktree_with_uncommitted_changes() {
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")]
@@ -240,6 +269,276 @@ async fn spawn_on_existing_branch_checks_it_out() {
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-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;
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.
let holders = git_worktree_list(base.path())
.iter()
.filter(|w| w.contains("feature/dup"))
.count();
assert_eq!(holders, 1, "duplicate spawn must not register another tree");
assert!(
base.path().join("worktrees").join("feature-dup").is_dir(),
"original worktree must be untouched"
);
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-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"
);
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 +727,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")
+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>(
+22
View File
@@ -212,6 +212,21 @@ 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 {
@@ -279,6 +294,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`)
// ---------------------------------------------------------------------------
+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));
+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
+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}");
}
}
}
+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`.
+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);
+93
View File
@@ -0,0 +1,93 @@
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("seeds worktree off when not even a project resolved", () => {
const seed = fastForwardSeed(resolveFastForward([], presets));
expect(seed.worktreeEnabled).toBe(false);
expect(seed.cwd).toBe("");
});
});
+62
View File
@@ -0,0 +1,62 @@
// 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 ?? "",
worktreeEnabled: Boolean(res.project),
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);
+89 -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,79 @@ 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 {
await createSession(req);
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");
});
});
+83 -28
View File
@@ -13,6 +13,7 @@ import {
closeTab,
findLeaf,
type Dir,
type Draft,
type Layout,
leaves,
locateSession,
@@ -52,6 +53,10 @@ export interface MultiView {
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 +70,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 +152,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 +378,49 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
render();
}
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 +429,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 +445,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 +693,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
return {
el,
openSession,
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";
}
}
+43 -4
View File
@@ -8,7 +8,7 @@ const base: SpawnFormValues = {
name: "amber-fox",
worktreeEnabled: false,
worktreeBranch: "",
worktreeCleanup: false,
worktreeBranchMode: "new",
};
describe("buildSpawnRequest", () => {
@@ -24,9 +24,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 +34,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");
});
+311 -96
View File
@@ -1,20 +1,31 @@
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;
worktreeBranchMode?: WorktreeBranchMode;
cwdRawVisible?: boolean;
}
@@ -26,7 +37,8 @@ export interface SpawnFormValues {
name: string;
worktreeEnabled: boolean;
worktreeBranch: string;
worktreeCleanup: 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 +61,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 +83,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 +100,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 +108,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 +124,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 +149,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 +175,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 +203,34 @@ 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(),
worktreeBranchMode: branchMode,
cwdRawVisible: !cwdRawWrap.hidden,
});
};
@@ -187,13 +238,51 @@ 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.
// own branch. Only in "new" mode: an existing-branch pick is not a name.
let branchEdited = Boolean(prefill.worktreeBranch);
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 +291,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 +392,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 +418,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 +520,41 @@ 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 {
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 +575,101 @@ 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 another worktree holds stays visible but unpickable — git
// would refuse to check it out twice; the path says who holds it.
const o = opt(b.worktree ? `${b.name} — held by ${b.worktree}` : b.name, b.name);
o.disabled = Boolean(b.worktree);
o.title = b.worktree ?? "";
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 +684,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
cwd,
name: nameInput.value,
worktreeEnabled: wtEnabled.checked,
worktreeBranch: branchInput.value,
worktreeCleanup: cleanupInput.checked,
worktreeBranch: branchValue(),
worktreeBranchMode: branchMode,
},
inferredBaseRepo,
);
@@ -494,6 +708,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;