Compare commits

..
Author SHA1 Message Date
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 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
30 changed files with 2535 additions and 260 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" }
+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"] }
+18 -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;
@@ -49,6 +49,7 @@ pub fn router(state: LocalApiState) -> Router {
.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))
@@ -122,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))
}
+66 -3
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),
}
}
@@ -108,6 +126,12 @@ impl WorktreeManager {
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(),
@@ -124,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() {
+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
+76 -1
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());
}
@@ -288,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
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
View File
@@ -294,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`)
// ---------------------------------------------------------------------------
+7
View File
@@ -23,12 +23,18 @@ pub const PROJECTS: &str = "/api/projects";
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`], [`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}")
@@ -184,6 +190,7 @@ mod tests {
PROJECTS,
GITROOT,
BRANCHES,
NAMES,
EVENTS,
] {
assert!(path.starts_with(API_PREFIX), "{path} escapes {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"] }
+296 -36
View File
@@ -14,9 +14,10 @@ use std::collections::{HashMap, HashSet};
use std::io::Stdout;
use std::time::Duration;
use chrono::Utc;
use crossterm::event::{Event as CtEvent, EventStream};
use futures_util::StreamExt;
use maestro_protocol::{CreateSessionRequest, DaemonClient};
use maestro_protocol::{routes, CreateSessionRequest, DaemonClient, NameBatch};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use uuid::Uuid;
@@ -53,6 +54,9 @@ pub struct App {
help: HelpOverlay,
chord: ChordState,
daemon: DaemonClient,
/// Direct HTTP for the one endpoint [`DaemonClient`] doesn't cover yet:
/// `GET /api/names` (#64).
http: reqwest::Client,
region: RegionMap,
last_sent_size: HashMap<Uuid, (u16, u16)>,
clicks: ClickTracker,
@@ -76,7 +80,14 @@ pub enum Effect {
Attach(Uuid),
Spawn(CreateSessionRequest),
Kill(Uuid),
Forward { id: Uuid, bytes: Vec<u8> },
Forward {
id: Uuid,
bytes: Vec<u8>,
},
/// Fetch presets / projects / names for a freshly opened draft (#64).
LoadDraft(usize),
/// A reroll drained the draft's local name batch (#64).
RefillNames(usize),
}
impl App {
@@ -85,6 +96,11 @@ impl App {
let help = HelpOverlay {
lines: render_help_lines(&cfg),
};
let http = reqwest::Client::builder()
.connect_timeout(maestro_protocol::client::DEFAULT_CONNECT_TIMEOUT)
.timeout(maestro_protocol::client::DEFAULT_REQUEST_TIMEOUT)
.build()
.expect("build reqwest client");
Self {
cfg,
catalog,
@@ -93,6 +109,7 @@ impl App {
help,
chord: ChordState::new(chord_timeout),
daemon,
http,
region: RegionMap::default(),
last_sent_size: HashMap::new(),
clicks: ClickTracker::default(),
@@ -153,15 +170,13 @@ impl App {
{
// Insert mode over a focused pane: a draft eats the key
// into its form, a session gets the raw bytes.
let effect = if matches!(self.state.active_tab().pane, Some(PaneRef::Draft(_)))
let effects = if matches!(self.state.active_tab().pane, Some(PaneRef::Draft(_)))
{
self.handle_draft_input(&key)
} else {
self.forward_to_session(&key)
self.forward_to_session(&key).into_iter().collect()
};
if let Some(effect) = effect {
self.perform(vec![effect]).await;
}
self.perform(effects).await;
}
Ok(false)
}
@@ -176,6 +191,8 @@ impl App {
Effect::Attach(id) => self.attach(id),
Effect::Spawn(req) => self.submit_spawn(req).await,
Effect::Kill(id) => self.submit_kill(id).await,
Effect::LoadDraft(tab_idx) => self.load_draft_data(tab_idx).await,
Effect::RefillNames(tab_idx) => self.refill_names(tab_idx).await,
Effect::Forward { id, bytes } => {
if let Some(handle) = self.emulators.get(id) {
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
@@ -252,7 +269,10 @@ impl App {
};
self.state.show_overlay(OverlayKind::Help);
}
Action::NewDraftPane => self.state.open_draft_pane(),
Action::NewDraftPane => {
self.state.open_draft_pane();
effects.push(Effect::LoadDraft(self.state.active_tab_idx));
}
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
if let Some(sid) = self.pick_sidebar_target() {
effects.push(Effect::Attach(sid));
@@ -309,21 +329,97 @@ impl App {
}
/// Feed one keystroke into the active tab's draft form. Returns the spawn
/// effect when the user accepted the form.
fn handle_draft_input(&mut self, key: &InputKey) -> Option<Effect> {
let PaneRef::Draft(draft) = self.state.active_tab_mut().pane.as_mut()? else {
return None;
/// effect when the user accepted a valid form; an invalid one names the
/// missing field inside the form (#64) instead of posting.
fn handle_draft_input(&mut self, key: &InputKey) -> Vec<Effect> {
let tab_idx = self.state.active_tab_idx;
let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() else {
return Vec::new();
};
if draft.on_input(key) != DraftInputResult::Submit {
return None;
let mut effects = Vec::new();
if draft.on_input(key) == DraftInputResult::Submit {
match draft.build_request() {
Ok(req) => {
draft.error = None;
effects.push(Effect::Spawn(req));
}
Err(e) => draft.error = Some(e.to_string()),
}
}
if draft.take_name_refill() {
effects.push(Effect::RefillNames(tab_idx));
}
effects
}
/// One `/api/names` batch. [`DaemonClient`] has no typed call for this
/// route yet, so it goes through plain reqwest against the same base URL
/// and query contract (`routes::NAMES` + `routes::COUNT_QUERY`).
async fn fetch_names(&self, count: usize) -> Vec<String> {
let url = format!(
"{}{}",
maestro_protocol::routes::normalize_base(self.daemon.base()),
routes::NAMES
);
match self
.http
.get(url)
.query(&[(routes::COUNT_QUERY, count)])
.send()
.await
{
Ok(resp) => resp
.json::<NameBatch>()
.await
.map(|batch| batch.names)
.unwrap_or_default(),
Err(_) => Vec::new(),
}
}
/// Populate a freshly opened draft: presets from `/api/info` (falling back
/// to the built-ins already in place), projects from `/api/projects`, and
/// a first name batch. Applies only if that tab still shows the draft —
/// a slow response must never clobber what replaced it.
async fn load_draft_data(&mut self, tab_idx: usize) {
let (info, projects) = tokio::join!(self.daemon.daemon_info(), self.daemon.list_projects());
let names = self.fetch_names(crate::draft::NAME_BATCH_SIZE).await;
let Some(PaneRef::Draft(draft)) = self
.state
.tabs
.get_mut(tab_idx)
.and_then(|t| t.pane.as_mut())
else {
return;
};
if let Ok(info) = info {
draft.home_dir.clone_from(&info.home_dir);
if !info.presets.is_empty() {
draft.set_presets(info.presets);
}
}
if let Ok(projects) = projects {
draft.set_projects(projects, Utc::now());
}
if !names.is_empty() {
draft.seed_names(names);
}
}
/// Fetch a fresh name batch after a reroll drained the local queue.
async fn refill_names(&mut self, tab_idx: usize) {
let names = self.fetch_names(crate::draft::NAME_BATCH_SIZE).await;
let Some(PaneRef::Draft(draft)) = self
.state
.tabs
.get_mut(tab_idx)
.and_then(|t| t.pane.as_mut())
else {
return;
};
if !names.is_empty() {
draft.extend_names(names);
}
Some(Effect::Spawn(CreateSessionRequest {
cwd: std::path::PathBuf::from(draft.cwd.value().to_string()),
command: draft.command.value().to_string(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
}))
}
async fn submit_spawn(&mut self, req: CreateSessionRequest) {
@@ -335,7 +431,19 @@ impl App {
self.state.complete_spawn(sid);
self.attach(sid);
}
Err(e) => self.state.toast(format!("spawn failed: {e}")),
Err(e) => {
// A failed POST belongs in the form it came from (#64), not a
// toast that dismisses on the next keystroke.
let message = e
.daemon_message()
.map(String::from)
.unwrap_or_else(|| e.to_string());
if let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() {
draft.error = Some(format!("spawn failed: {message}"));
} else {
self.state.toast(format!("spawn failed: {e}"));
}
}
}
}
@@ -509,12 +617,12 @@ mod tests {
use maestro_config::TuiSection;
fn make_app() -> App {
make_app_at("http://127.0.0.1:7070")
}
fn make_app_at(base: &str) -> App {
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
App::new(
cfg,
CatalogStore::default(),
DaemonClient::new("http://127.0.0.1:7070"),
)
App::new(cfg, CatalogStore::default(), DaemonClient::new(base))
}
#[test]
@@ -638,34 +746,186 @@ mod tests {
assert!(app.state.overlay().is_none());
}
#[test]
fn new_draft_pane_schedules_a_data_load() {
let mut app = make_app();
assert_eq!(
app.dispatch(Action::NewDraftPane),
vec![Effect::LoadDraft(0)]
);
}
/// A valid form flows through: pick a project (set directly — the picker
/// itself is covered in `draft.rs`), select the custom tile, type a
/// command, then submit from the Spawn row.
#[test]
fn typing_into_the_draft_then_enter_carries_the_typed_fields() {
let mut app = make_app();
app.dispatch(Action::NewDraftPane);
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
panic!("expected a draft pane");
};
for c in "/srv/work".chars() {
app.handle_draft_input(&InputKey::char(c));
}
app.handle_draft_input(&InputKey::Tab); // cwd → command
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work"));
draft.selected_preset = draft.presets.len() - 1; // custom
app.handle_draft_input(&InputKey::Tab); // preset → custom command
for c in "claude".chars() {
app.handle_draft_input(&InputKey::char(c));
}
app.handle_draft_input(&InputKey::Tab); // command → submit
app.handle_draft_input(&InputKey::Tab); // → dir picker
app.handle_draft_input(&InputKey::Tab); // → name
for c in "crimson-otter".chars() {
app.handle_draft_input(&InputKey::char(c));
}
app.handle_draft_input(&InputKey::Tab); // → submit
match app.handle_draft_input(&InputKey::Enter) {
Some(Effect::Spawn(req)) => {
match app.handle_draft_input(&InputKey::Enter).as_slice() {
[Effect::Spawn(req)] => {
assert_eq!(req.cwd, std::path::PathBuf::from("/srv/work"));
assert_eq!(req.command, "claude");
assert_eq!(
req.metadata.get("name").and_then(|v| v.as_str()),
Some("crimson-otter"),
"the generated name rides along as metadata.name"
);
}
other => panic!("expected a Spawn effect, got {other:?}"),
other => panic!("expected a single Spawn effect, got {other:?}"),
}
}
/// Submitting with no directory names the missing field inside the form
/// and posts nothing (#64).
#[test]
fn submitting_an_empty_form_reports_the_missing_field_instead_of_posting() {
let mut app = make_app();
app.dispatch(Action::NewDraftPane);
// Walk to Submit without picking anything.
for _ in 0..3 {
app.handle_draft_input(&InputKey::Tab);
}
assert!(app.handle_draft_input(&InputKey::Enter).is_empty());
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
panic!("expected a draft pane");
};
assert_eq!(
draft.error.as_deref(),
Some("Pick a working directory."),
"the refusal shows in the form"
);
// With a directory but an empty custom command, it names the command.
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
panic!();
};
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work"));
draft.selected_preset = draft.presets.len() - 1; // custom, empty text
assert!(app.handle_draft_input(&InputKey::Enter).is_empty());
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
panic!();
};
assert_eq!(
draft.error.as_deref(),
Some("Pick a command (or fill the custom command).")
);
}
/// A failed POST lands back in the form as an error line (#64), not a
/// toast that the next keystroke dismisses.
#[tokio::test]
async fn a_failed_spawn_shows_the_daemons_error_in_the_form() {
use std::time::Duration;
let mut app = App::new(
TuiConfig::load(&TuiSection::default()).unwrap(),
CatalogStore::default(),
DaemonClient::with_timeouts(
"http://127.0.0.1:9",
Duration::from_millis(200),
Duration::from_millis(500),
),
);
app.dispatch(Action::NewDraftPane);
let req = CreateSessionRequest {
cwd: std::path::PathBuf::from("/srv/work"),
command: "bash".into(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
};
app.submit_spawn(req).await;
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
panic!("expected a draft pane");
};
let error = draft
.error
.as_deref()
.expect("spawn failure must reach the form");
assert!(error.starts_with("spawn failed"), "{error}");
assert!(
matches!(app.state.overlay(), None | Some(OverlayKind::Toast(_))),
"no kill-confirm style overlay took over"
);
}
/// The full data load against a live daemon stub: presets replace the
/// built-ins, projects land sorted, and the first generated name seeds
/// the field.
#[tokio::test]
async fn load_draft_data_fills_presets_projects_and_names() {
use axum::routing::get;
use axum::Json;
use serde_json::json;
async fn info() -> Json<serde_json::Value> {
Json(json!({
"hostname": "box",
"version": "0.1.0",
"home_dir": "/home/u",
"presets": [
{"id": "aider", "label": "Aider", "command": "aider"},
{"id": "custom", "label": "Custom…", "command": ""}
]
}))
}
async fn projects() -> Json<serde_json::Value> {
Json(json!([
{"path": "/repos/maestro", "name": "maestro",
"last_activity": chrono::Utc::now().to_rfc3339(),
"last_command": "claude"}
]))
}
async fn names() -> Json<serde_json::Value> {
Json(json!({"names": ["crimson-otter", "amber-falcon"]}))
}
let app_router = axum::Router::new()
.route(maestro_protocol::routes::INFO, get(info))
.route(maestro_protocol::routes::PROJECTS, get(projects))
.route(maestro_protocol::routes::NAMES, get(names));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() });
let mut app = make_app_at(&base);
let effects = app.dispatch(Action::NewDraftPane);
assert!(matches!(effects.as_slice(), [Effect::LoadDraft(0)]));
app.perform(effects).await;
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
panic!("expected a draft pane");
};
assert_eq!(draft.command(), "aider");
assert_eq!(draft.rows.len(), 1);
assert_eq!(draft.rows[0].project.name, "maestro");
assert_eq!(draft.home_dir, "/home/u");
assert_eq!(draft.name.value(), "crimson-otter");
}
/// Keys fed while no draft exists must be ignored, not panic.
#[test]
fn draft_input_is_a_no_op_without_a_draft() {
let mut app = make_app();
assert!(app.handle_draft_input(&InputKey::char('x')).is_none());
assert!(app.handle_draft_input(&InputKey::char('x')).is_empty());
}
/// Regression: `Toast` returned `Pass` and nothing ever cleared it, so a
File diff suppressed because it is too large Load Diff
+1
View File
@@ -13,6 +13,7 @@ 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;
+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());
}
}
+8 -1
View File
@@ -315,7 +315,14 @@ mod tests {
}
out.push('\n');
}
for s in ["draft", "cwd:", "command:", "[ Spawn ]"] {
for s in [
"draft",
"cmd:",
"dir:",
"name:",
"[ Spawn ]",
"Other directory…",
] {
assert!(out.contains(s), "missing {s:?}:\n{out}");
}
}
+3
View File
@@ -20,6 +20,9 @@ pub enum Focus {
}
/// 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),
+16
View File
@@ -1,10 +1,12 @@
import * as routes from "./routes.js";
import type {
ApiError,
Branches,
CreateSessionRequest,
DaemonInfo,
DirListing,
GitRoot,
NameBatch,
Projects,
RecentDirs,
Session,
@@ -81,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";
+6 -5
View File
@@ -154,13 +154,14 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
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) {
// 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;
t.focusedLeafId = leaf.id;
host.focusedLeafId = leaf.id;
}
render();
opts.onOpenSessionsChange?.();
-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)}`;
}
+5 -2
View File
@@ -15,7 +15,8 @@ function project(overrides: Partial<Project>): Project {
};
}
/** Two fetches gate the form's initial load: /api/info then /api/projects. */
/** 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",
@@ -25,7 +26,9 @@ function stubFetch(info: unknown, projects: Project[]): void {
? info
: url.includes("/api/projects")
? projects
: undefined;
: url.includes("/api/names")
? { names: ["allegro-cello", "presto-fugue", "adagio-lyre"] }
: undefined;
return new Response(JSON.stringify(body), { status: 200 });
}),
);
+5
View File
@@ -11,11 +11,16 @@ 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)}`;
}
+41 -1
View File
@@ -8,6 +8,7 @@ const base: SpawnFormValues = {
name: "amber-fox",
worktreeEnabled: false,
worktreeBranch: "",
worktreeBranchMode: "new",
};
describe("buildSpawnRequest", () => {
@@ -33,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");
});
+217 -32
View File
@@ -1,11 +1,21 @@
import { escapeHtml } from "./html.js";
import { createSession, getDaemonInfo, projects as fetchProjects, gitRoot } from "./api.js";
import {
createSession,
fetchBranches,
fetchNames,
getDaemonInfo,
projects as fetchProjects,
gitRoot,
} from "./api.js";
import { attachDirAutocomplete } from "./dirAutocomplete.js";
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
import { generateName } from "./names.js";
import type { CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
import { abbreviateCwd, abbreviateFolderName } from "./types.js";
/** 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 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. */
@@ -15,6 +25,7 @@ export interface SpawnFormSeed {
name?: string;
worktreeEnabled?: boolean;
worktreeBranch?: string;
worktreeBranchMode?: WorktreeBranchMode;
cwdRawVisible?: boolean;
}
@@ -26,6 +37,8 @@ export interface SpawnFormValues {
name: string;
worktreeEnabled: boolean;
worktreeBranch: string;
/** "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;
}
@@ -48,15 +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,
// 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",
};
}
@@ -129,10 +148,24 @@ 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="wt-exist-wrap" hidden>
Branch
<select class="branch-select"></select>
</label>
</div>
</fieldset>
<p class="form-error" role="alert" aria-live="polite"></p>
@@ -154,7 +187,13 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
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 branchSelect = form.querySelector(".branch-select") as HTMLSelectElement;
const nameInput = form.querySelector("input[name=name]") as HTMLInputElement;
// ---- form state ------------------------------------------------------
@@ -163,11 +202,15 @@ 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) 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
@@ -177,6 +220,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
// 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?.({
@@ -184,7 +228,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
cwd,
name: nameInput.value,
worktreeEnabled: wtEnabled.checked,
worktreeBranch: branchInput.value,
worktreeBranch: branchValue(),
worktreeBranchMode: branchMode,
cwdRawVisible: !cwdRawWrap.hidden,
});
};
@@ -192,13 +237,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();
});
@@ -207,9 +290,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
@@ -308,6 +403,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
cwdRaw.addEventListener("input", () => {
cwd = cwdRaw.value.trim();
branchCache = null;
emit();
if (wtEnabled.checked) probeGitRoot();
});
@@ -325,11 +421,14 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
cwd = project.path;
cwdRawWrap.hidden = true;
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 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();
}
let discovered: Project[] = []; // daemon snapshot, daemon-sorted
@@ -383,7 +482,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
}
// Everything seeded: the form may publish from here on.
ready = true;
if (wtEnabled.checked) void probeGitRoot();
resolveBaseRepo();
emit();
}
@@ -424,7 +523,37 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
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) {
@@ -445,10 +574,7 @@ 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
@@ -461,10 +587,72 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
}
}
// ---- 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;
@@ -472,15 +660,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
}
});
// 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) {
wtEnabled.checked = Boolean(prefill.worktreeEnabled);
if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch;
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 projects' abbreviations), so projects wait for it.
@@ -499,8 +683,9 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
cwd,
name: nameInput.value,
worktreeEnabled: wtEnabled.checked,
worktreeBranch: branchInput.value,
},
worktreeBranch: branchValue(),
worktreeBranchMode: branchMode,
},
inferredBaseRepo,
);
if ("error" in built) {
+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);
});
});
+10
View File
@@ -1575,6 +1575,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;
+23
View File
@@ -83,6 +83,12 @@ 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 {
@@ -108,8 +114,25 @@ 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;