Compare commits

..
Author SHA1 Message Date
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 4a814d5b17 feat(maestro): project discovery scanner and GET /api/projects (#54) 2026-08-23 20:48:59 +01:00
Miguel Palhas 1809f5d581 feat(maestro): uncapped spawn-history recents for project enrichment (#54) 2026-08-23 20:44:15 +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 33fb50be6e feat(maestro): Project wire type and PROJECTS route (#54) 2026-08-23 20:32:29 +01:00
Miguel Palhas 2ce1dc43fe merge: tui inline draft pane + paste forwarding fix (#63) 2026-08-23 20:31:18 +01:00
Miguel Palhas b6cf87244f feat(maestro): [spawn].project_roots and project_scan_depth config (#54) 2026-08-23 20:30:59 +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 6d21061799 merge: worktree placement, existing branches, guarded cleanup (#56) 2026-08-23 20:21:08 +01:00
Miguel Palhas 431f9def6b Merge branch 'blitz/spawn-v2' of https://git.naps.pt/naps62/maestro into spawn-v2/56-worktree-place 2026-08-23 20:17:21 +01:00
Miguel Palhas d8acc3adde feat(maestro): always clean up worktrees on kill behind a dirty-work guard 2026-08-23 20:17:08 +01:00
Miguel Palhas 95f0e42df5 feat(maestro): add draft-pane widget with its own field focus 2026-08-23 20:16:43 +01:00
Miguel Palhas 6f8de8de7c fix(maestro): draft-pane regressions from the integration review
Six findings from reviewing the accumulated blitz/spawn-v2 diff:

- loadRecents restored the seeded cwd but never emitted it, so the leaf's
  draft kept cwd:"" and the next render reseeded the form blank.
- stripDrafts did not recurse, so every half-filled draft in a split tab
  was written to localStorage; the old test passed only because hydrate
  discards draft on the way back in.
- showRawCwd and selectPreset grabbed focus from async init paths, so a
  draft pane stole the caret seconds after mounting.
- .mv-draft inherited no layout once the form left .modal-body.
- onSuccess mounted the new session onto a leaf that may have been closed
  while the POST was in flight, stranding it in no pane.
2026-08-23 20:12:28 +01:00
Miguel Palhas 018f44727c feat(maestro): place worktrees at <repo>/worktrees/<branch> 2026-08-23 20:09:38 +01:00
Miguel Palhas 85e7749e5a feat(maestro): worktree specs can check out an existing branch 2026-08-23 20:05:26 +01:00
Miguel Palhas 35bc5bfb40 merge: tui text-input widget (#58) 2026-08-23 20:00:47 +01:00
Miguel Palhas 059ae86fb8 Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/58-tui-textinput 2026-08-23 19:58:03 +01:00
Miguel Palhas e3996dec76 feat(maestro): wire spawn overlay fields to the text-input widget 2026-08-23 19:57:24 +01:00
Miguel Palhas 503f01ae26 merge: web inline draft pane (#57) 2026-08-23 19:54:50 +01:00
Miguel Palhas dfc4ee91e1 feat(maestro): add shared text-input widget with cursor motion and word ops 2026-08-23 19:50:37 +01:00
Miguel Palhas d5485b8f5d feat(maestro): add home/end/delete and bracketed-paste InputKey variants 2026-08-23 19:44:14 +01:00
60 changed files with 4716 additions and 603 deletions
Generated
+8
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"
+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" }
+54 -1
View File
@@ -67,8 +67,15 @@ pub struct SpawnArgs {
/// Branch name for the worktree (required with --worktree-from).
#[arg(long)]
pub branch: Option<String>,
/// Remove the worktree when the session is killed.
/// 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.
#[arg(long, default_value_t = true)]
pub cleanup_on_kill: bool,
/// After spawning, attach immediately.
#[arg(long, short = 'a')]
@@ -147,6 +154,52 @@ mod tests {
}
}
#[test]
fn cleanup_on_kill_defaults_true_for_the_wire() {
let cli = Cli::try_parse_from([
"maestro",
"spawn",
"--cwd",
"/tmp",
"--command",
"bash",
"--worktree-from",
"/repos/foo",
"--branch",
"feat/x",
])
.unwrap();
match cli.command {
Command::Spawn(a) => assert!(a.cleanup_on_kill),
_ => panic!("wrong subcommand"),
}
}
/// 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
View File
@@ -44,6 +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: !args.existing_branch,
}),
_ => None,
};
+42 -2
View File
@@ -87,8 +87,9 @@ pub struct DaemonSection {
/// Root for everything daemon-local: sidecars, FIFOs, etc.
#[serde(default)]
pub state_dir: Option<PathBuf>,
/// Root for ephemeral worktrees the daemon creates on `CreateSession`
/// with a `worktree` spec.
/// Override root for ephemeral worktrees the daemon creates on
/// `CreateSession` with a `worktree` spec. Unset by default: worktrees
/// then live inside their base repo at `<repo>/worktrees/<branch>` (#56).
#[serde(default)]
pub worktree_root: Option<PathBuf>,
/// Per-session PTY replay buffer cap in bytes (NAP-229). Each new attach
@@ -161,6 +162,15 @@ pub struct SpawnSection {
/// it resolves to just the `custom` tile.
#[serde(default)]
pub presets: Option<Vec<SpawnPreset>>,
/// Root directories the daemon scans for git repositories (#54), `~`
/// expanded by the daemon against its own home. When absent the daemon
/// scans `["~"]`.
#[serde(default)]
pub project_roots: Option<Vec<String>>,
/// How many directory levels below each [`Self::project_roots`] entry the
/// scanner descends (#54). When absent the daemon uses `3`.
#[serde(default)]
pub project_scan_depth: Option<u32>,
}
impl SpawnSection {
@@ -386,6 +396,7 @@ mod tests {
label: "fish".into(),
command: "fish".into(),
}]),
..Default::default()
};
let ids: Vec<String> = section
.resolve()
@@ -402,6 +413,7 @@ mod tests {
// only the implicit custom tile.
let section = SpawnSection {
presets: Some(vec![]),
..Default::default()
};
let ids: Vec<String> = section
.resolve()
@@ -420,6 +432,7 @@ mod tests {
label: "x".into(),
command: "x".into(),
}]),
..Default::default()
};
assert_eq!(empty_id.resolve().unwrap_err(), SpawnConfigError::EmptyId);
@@ -429,6 +442,7 @@ mod tests {
label: "x".into(),
command: "".into(),
}]),
..Default::default()
};
assert_eq!(
empty_cmd.resolve().unwrap_err(),
@@ -451,6 +465,7 @@ mod tests {
command: "a2".into(),
},
]),
..Default::default()
};
assert_eq!(
dup.resolve().unwrap_err(),
@@ -463,6 +478,7 @@ mod tests {
label: "Custom".into(),
command: "whatever".into(),
}]),
..Default::default()
};
assert_eq!(
reserved.resolve().unwrap_err(),
@@ -496,6 +512,30 @@ presets = [
assert_eq!(ids, ["claude", "zsh", "custom"]);
}
#[test]
fn spawn_project_scan_fields_default_to_none() {
// Absent [spawn].project_* → None; the daemon applies its own defaults.
assert_eq!(SpawnSection::default().project_roots, None);
assert_eq!(SpawnSection::default().project_scan_depth, None);
}
#[test]
fn spawn_project_scan_fields_parse_from_toml() {
let cfg = toml::from_str::<MaestroConfigFile>(
r#"
[spawn]
project_roots = ["~/code", "/srv/work"]
project_scan_depth = 5
"#,
)
.expect("parse");
assert_eq!(
cfg.spawn.project_roots,
Some(vec!["~/code".to_string(), "/srv/work".to_string()])
);
assert_eq!(cfg.spawn.project_scan_depth, Some(5));
}
#[test]
fn partial_file_loads_with_section_defaults() {
// Only [daemon] present (typical single-host install).
+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());
}
}
+10 -1
View File
@@ -47,7 +47,8 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
let _ = default_state_dir();
std::fs::create_dir_all(config.sidecar_dir())?;
std::fs::create_dir_all(config.fifo_dir())?;
std::fs::create_dir_all(&config.worktree_root)?;
// No worktree dir to pre-create: without an override the worktrees live
// under their base repos, created on demand (#56).
let sidecar = SidecarStore::new(config.sidecar_dir());
let worktree_mgr = WorktreeManager::new(config.worktree_root.clone());
@@ -71,11 +72,19 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
tracing::info!(recovered, "adopted existing tmux sessions");
manager.start_liveness_watcher(DEFAULT_LIVENESS_INTERVAL);
let projects = std::sync::Arc::new(crate::projects::ProjectScanner::new(
config.project_roots.clone(),
config.project_scan_depth,
PathBuf::from(&config.home_dir),
));
projects.start_background_refresh();
let router = local_api::router(local_api::state_for(
manager,
config.hostname.clone(),
config.home_dir.clone(),
config.spawn_presets.clone(),
projects,
));
let listener = TcpListener::bind(config.bind_addr).await?;
+104 -6
View File
@@ -23,6 +23,14 @@ pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:7070";
/// `[daemon].spawn_history_days` is unset. `0` in config disables history.
pub const DEFAULT_SPAWN_HISTORY_DAYS: u32 = 30;
/// Default scan roots for project discovery (#54) when `[spawn].project_roots`
/// is unset: the whole home directory, with the scanner's own ignore list
/// keeping it cheap.
pub const DEFAULT_PROJECT_ROOTS: [&str; 1] = ["~"];
/// Default directory depth for project discovery (#54) when
/// `[spawn].project_scan_depth` is unset.
pub const DEFAULT_PROJECT_SCAN_DEPTH: u32 = 3;
/// Env var that overrides `[daemon].bind_addr`.
pub const ENV_DAEMON_BIND_ADDR: &str = "MAESTRO_DAEMON_BIND_ADDR";
/// Env var that overrides `[daemon].tmux_tmpdir`.
@@ -53,7 +61,10 @@ pub struct DaemonConfig {
pub home_dir: String,
pub bind_addr: SocketAddr,
pub state_dir: PathBuf,
pub worktree_root: PathBuf,
/// `[daemon].worktree_root` override (#56). `None` — the default — places
/// worktrees inside their base repo at `<repo>/worktrees/<branch>`;
/// operators who want them elsewhere set this.
pub worktree_root: Option<PathBuf>,
/// Per-session PTY replay buffer cap in bytes (NAP-229). Each new attach
/// receives this much of the prior PTY output before the live stream.
/// Default `pty::DEFAULT_REPLAY_BYTES`; `0` disables.
@@ -71,6 +82,12 @@ pub struct DaemonConfig {
/// built-ins) with the implicit `custom` tile appended. Advertised to
/// clients in `GET /api/info`.
pub spawn_presets: Vec<maestro_config::SpawnPreset>,
/// Project-discovery scan roots (#54): `[spawn].project_roots` (default
/// [`DEFAULT_PROJECT_ROOTS`]) with `~` expanded against the daemon's home.
pub project_roots: Vec<PathBuf>,
/// Project-discovery depth (#54): `[spawn].project_scan_depth`, default
/// [`DEFAULT_PROJECT_SCAN_DEPTH`].
pub project_scan_depth: u32,
}
impl DaemonConfig {
@@ -131,9 +148,6 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
Some(p) => p,
None => default_state_dir()?,
};
let worktree_root = daemon
.worktree_root
.unwrap_or_else(|| state_dir.join("worktrees"));
let pty_replay_bytes = daemon
.pty_replay_bytes
@@ -147,19 +161,53 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
.spawn_history_days
.unwrap_or(DEFAULT_SPAWN_HISTORY_DAYS);
// `~` in scan roots means the daemon's own home; an unresolvable home
// leaves the tilde as-is and the scanner treats the root as missing.
let project_roots: Vec<PathBuf> = file
.spawn
.project_roots
.unwrap_or_else(|| {
DEFAULT_PROJECT_ROOTS
.iter()
.map(|s| s.to_string())
.collect()
})
.iter()
.map(|root| expand_tilde(root, &home_dir))
.collect();
let project_scan_depth = file
.spawn
.project_scan_depth
.unwrap_or(DEFAULT_PROJECT_SCAN_DEPTH);
Ok(DaemonConfig {
hostname,
home_dir,
bind_addr,
state_dir,
worktree_root,
worktree_root: daemon.worktree_root,
pty_replay_bytes,
tmux_tmpdir,
spawn_history_days,
spawn_presets,
project_roots,
project_scan_depth,
})
}
/// Expand a leading `~` (or `~/…`) against `home`. Other paths pass through.
fn expand_tilde(path: &str, home: &str) -> PathBuf {
match path.strip_prefix("~") {
Some(_) if home.is_empty() => PathBuf::from(path),
Some(rest) => {
let mut p = PathBuf::from(home);
p.push(rest.trim_start_matches('/'));
p
}
None => PathBuf::from(path),
}
}
pub fn default_state_dir() -> Result<PathBuf, ConfigError> {
Ok(maestro_config::default_state_dir("maestro")?)
}
@@ -188,7 +236,7 @@ mod tests {
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.bind_addr, default_bind_addr());
assert!(cfg.state_dir.ends_with("maestro"));
assert!(cfg.worktree_root.ends_with("worktrees"));
assert!(cfg.worktree_root.is_none());
assert!(!cfg.hostname.is_empty());
}
@@ -280,6 +328,56 @@ spawn_history_days = 0
assert_eq!(cfg.spawn_history_days, 0);
}
#[test]
fn project_scan_defaults_to_home_root_at_depth_3() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "").unwrap();
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.project_scan_depth, DEFAULT_PROJECT_SCAN_DEPTH);
assert_eq!(
cfg.project_roots,
vec![PathBuf::from(cfg.home_dir.clone())],
"bare ~ must expand to the daemon's home"
);
}
#[test]
fn project_scan_roots_expand_tilde_and_pass_through_absolutes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
[spawn]
project_roots = ["~/code", "/srv/work"]
project_scan_depth = 5
"#,
)
.unwrap();
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.project_scan_depth, 5);
assert_eq!(
cfg.project_roots,
vec![
PathBuf::from(cfg.home_dir.clone()).join("code"),
PathBuf::from("/srv/work"),
]
);
}
#[test]
fn tilde_expansion_handles_bare_and_nested_forms() {
assert_eq!(expand_tilde("~", "/home/u"), PathBuf::from("/home/u"));
assert_eq!(
expand_tilde("~/a/b", "/home/u"),
PathBuf::from("/home/u/a/b")
);
assert_eq!(expand_tilde("/abs", "/home/u"), PathBuf::from("/abs"));
// Unresolvable home leaves the tilde as-is rather than mangling it.
assert_eq!(expand_tilde("~", ""), PathBuf::from("~"));
}
#[test]
fn system_hostname_is_nonempty() {
let h = system_hostname();
+2
View File
@@ -9,6 +9,7 @@
pub mod activity;
pub mod attach;
pub mod branches;
pub mod cli;
pub mod config;
pub mod dirlist;
@@ -16,6 +17,7 @@ pub mod gitroot;
pub mod local_api;
pub mod panes;
pub mod proc;
pub mod projects;
pub mod pty;
pub mod session;
pub mod sidecar;
+37 -2
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;
@@ -36,6 +36,8 @@ pub struct LocalApiState {
pub home_dir: Arc<String>,
/// Resolved spawn presets (#22/#26) advertised in `GET /api/info`.
pub presets: Arc<Vec<SpawnPreset>>,
/// Project discovery (#54) backing `GET /api/projects`.
pub projects: Arc<crate::projects::ProjectScanner>,
}
pub fn router(state: LocalApiState) -> Router {
@@ -44,7 +46,10 @@ pub fn router(state: LocalApiState) -> Router {
.route(routes::patterns::SESSION, delete(kill_session))
.route(routes::DIRS, get(list_dir))
.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))
@@ -98,6 +103,15 @@ async fn recent_dirs(State(s): State<LocalApiState>) -> Response {
Json(crate::spawn_history::to_wire(s.manager.recent_dirs())).into_response()
}
/// Discovered git repositories and worktrees, newest activity first (#54).
/// `last_activity` is the later of the repo's git HEAD mtime and the newest
/// spawn-history use of any directory inside it.
async fn projects(State(s): State<LocalApiState>) -> Response {
let repos = s.projects.repos().await;
let history = s.manager.all_recent_dirs();
Json(crate::projects::to_projects(&repos, &history)).into_response()
}
/// Probe the enclosing git repo of `path`, to infer the worktree `base_repo`
/// for the spawn form (#26). `path` is resolved against the daemon's home dir
/// (empty/`~`/relative), like [`list_dir`].
@@ -109,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(),
@@ -157,18 +187,23 @@ fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
}
/// Convenience constructor — pulls the bits `LocalApiState` needs from
/// `DaemonConfig` so callers don't have to assemble it by hand.
/// `DaemonConfig` so callers don't have to assemble it by hand. The project
/// scanner starts with its cache cold; wire
/// [`ProjectScanner::start_background_refresh`](crate::projects::ProjectScanner::start_background_refresh)
/// on the returned handle's scanner to keep it eventually fresh.
pub fn state_for(
manager: SessionManager,
hostname: impl Into<String>,
home_dir: impl Into<String>,
presets: Vec<SpawnPreset>,
projects: Arc<crate::projects::ProjectScanner>,
) -> LocalApiState {
LocalApiState {
manager,
hostname: Arc::new(hostname.into()),
home_dir: Arc::new(home_dir.into()),
presets: Arc::new(presets),
projects,
}
}
+530
View File
@@ -0,0 +1,530 @@
//! Project discovery for `GET /api/projects` (#54).
//!
//! Scans `[spawn].project_roots` (default the whole home) for `.git` entries —
//! directory or file, so linked worktrees count too — up to
//! `[spawn].project_scan_depth` levels. Descent stops at the first repo found:
//! nested independent repos are not discovered, but worktrees of every hit
//! still are, via `git worktree list`, which also catches linked worktrees
//! living outside the roots.
//!
//! Hidden directories are skipped everywhere; heavy well-known directories
//! (`node_modules`, `Downloads`, …) are skipped when they sit directly in the
//! home directory, so scanning all of home stays cheap.
//!
//! Results are cached behind a short TTL and refreshed on a slow background
//! timer ([`ProjectScanner::start_background_refresh`]) — repositories appear
//! rarely, and `/api/projects` may be polled.
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use crate::spawn_history::RecentDir;
/// Directory names never descended when they sit directly in the home dir, so
/// scanning all of home stays cheap (#54). Hidden dirs are skipped everywhere
/// regardless.
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);
/// 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);
/// One repository or linked worktree found by [`discover`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiscoveredRepo {
pub path: PathBuf,
/// Basename of `path`.
pub name: String,
pub is_worktree: bool,
/// The main checkout this worktree belongs to; set only when
/// [`Self::is_worktree`].
pub base_repo: Option<PathBuf>,
/// Mtime of the checkout's git HEAD — "someone committed / checked out
/// here". The later-of merge with spawn history happens in
/// [`to_projects`], which also sorts.
pub head_mtime: Option<DateTime<Utc>>,
}
/// Repo toplevels under each root of `roots`, at most `max_depth` levels deep.
/// A dir holding `.git` (dir *or* file) is a hit and is not descended into;
/// hidden directories are never entered, and `DEFAULT_HOME_IGNORE` names are
/// skipped directly under `home`.
pub fn scan_roots(roots: &[PathBuf], max_depth: u32, home: &Path) -> Vec<PathBuf> {
let mut found = Vec::new();
for root in roots {
walk(root, 0, max_depth, home, &mut found);
}
found.sort();
found.dedup();
found
}
fn walk(dir: &Path, depth: u32, max_depth: u32, home: &Path, found: &mut Vec<PathBuf>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
let mut subdirs = Vec::new();
let mut has_git = false;
for entry in entries.flatten() {
match entry.file_name().to_string_lossy().into_owned() {
name if name == ".git" => has_git = true,
name if name.starts_with('.') => {}
name if dir == home && DEFAULT_HOME_IGNORE.contains(&name.as_str()) => {}
_ => {
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
subdirs.push(entry.path());
}
}
}
}
if has_git {
// Match `git worktree list` output, which prints real paths.
found.push(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
return;
}
if depth >= max_depth {
return;
}
for sub in subdirs {
walk(&sub, depth + 1, max_depth, home, found);
}
}
/// Every discovered project: the scanned repos plus all worktrees their
/// `git worktree list` reports (linked worktrees can live outside the roots).
/// A hit whose git invocation fails still lists, un-worktreed.
pub fn discover(roots: &[PathBuf], max_depth: u32, home: &Path) -> Vec<DiscoveredRepo> {
let scanned = scan_roots(roots, max_depth, home);
let mut map: Vec<(PathBuf, PathBuf)> = Vec::new(); // (checkout, main)
for repo in scanned {
if map.iter().any(|(c, _)| c == &repo) {
continue;
}
let worktrees = list_worktrees(&repo).unwrap_or_else(|| vec![repo.clone()]);
let main = worktrees[0].clone();
for wt in worktrees {
if !map.iter().any(|(c, _)| c == &wt) {
map.push((wt, main.clone()));
}
}
}
map.into_iter()
.map(|(path, main)| {
let is_worktree = path != main;
DiscoveredRepo {
name: path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
is_worktree,
base_repo: is_worktree.then_some(main),
head_mtime: head_mtime(&path),
path,
}
})
.collect()
}
/// `git worktree list --porcelain` from `repo`: absolute paths, main first.
/// `None` when git fails (broken/bare checkout).
fn list_worktrees(repo: &Path) -> Option<Vec<PathBuf>> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args(["worktree", "list", "--porcelain"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let mut paths = Vec::new();
for line in String::from_utf8_lossy(&out.stdout).lines() {
if let Some(p) = line.strip_prefix("worktree ") {
paths.push(PathBuf::from(p));
}
}
(!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.
fn head_mtime(repo: &Path) -> Option<DateTime<Utc>> {
let dot_git = repo.join(".git");
let head = if dot_git.is_dir() {
dot_git.join("HEAD")
} else {
let ptr = std::fs::read_to_string(&dot_git).ok()?;
let gitdir = ptr.trim().strip_prefix("gitdir:")?.trim();
// Relative pointers are relative to the worktree.
let p = Path::new(gitdir);
let p = if p.is_absolute() {
p.to_path_buf()
} else {
repo.join(p)
};
p.join("HEAD")
};
let mtime = std::fs::metadata(head).ok()?.modified().ok()?;
Some(DateTime::<Utc>::from(mtime))
}
/// Fold spawn history into the discovery snapshot and produce the wire list,
/// sorted newest-activity-first. `history` must be newest-first (what
/// [`SpawnHistory::all_recent_dirs`](crate::spawn_history::SpawnHistory)
/// returns): the first entry whose cwd sits inside a project is its most
/// recent use.
pub fn to_projects(
repos: &[DiscoveredRepo],
history: &[RecentDir],
) -> Vec<maestro_protocol::Project> {
let mut projects: Vec<maestro_protocol::Project> = repos
.iter()
.map(|repo| {
let last_use = history.iter().find(|h| contains(&repo.path, &h.cwd));
maestro_protocol::Project {
path: repo.path.clone(),
name: repo.name.clone(),
is_worktree: repo.is_worktree,
base_repo: repo.base_repo.clone(),
last_activity: max_opt(repo.head_mtime, last_use.map(|u| u.last_used)),
last_command: last_use.map(|u| u.last_command.clone()),
}
})
.collect();
projects.sort_by_key(|p| std::cmp::Reverse(p.last_activity));
projects
}
/// Whether `dir` is `project` itself or anywhere inside it.
fn contains(project: &Path, dir: &Path) -> bool {
dir == project || dir.starts_with(project)
}
fn max_opt(a: Option<DateTime<Utc>>, b: Option<DateTime<Utc>>) -> Option<DateTime<Utc>> {
match (a, b) {
(Some(a), Some(b)) => Some(a.max(b)),
(a, b) => a.or(b),
}
}
/// Cached discovery behind a short TTL plus an optional slow background
/// refresh. Clone-free shared handle via `Arc` in [`crate::local_api`].
#[derive(Debug)]
pub struct ProjectScanner {
roots: Vec<PathBuf>,
max_depth: u32,
home: PathBuf,
ttl: Duration,
cache: tokio::sync::Mutex<Option<(Instant, std::sync::Arc<Vec<DiscoveredRepo>>)>>,
}
impl ProjectScanner {
/// Scanner over the resolved config roots. An empty `roots` list scans
/// nothing and always serves an empty list.
pub fn new(roots: Vec<PathBuf>, max_depth: u32, home: PathBuf) -> Self {
Self {
roots,
max_depth,
home,
ttl: CACHE_TTL,
cache: tokio::sync::Mutex::new(None),
}
}
/// Scanner that never discovers anything — tests and embedders that don't
/// want the endpoint populated.
pub fn disabled() -> Self {
Self::new(Vec::new(), 0, PathBuf::new())
}
/// Cached snapshot, rescanning (off-thread) when the TTL has lapsed.
pub async fn repos(&self) -> std::sync::Arc<Vec<DiscoveredRepo>> {
let mut cache = self.cache.lock().await;
if let Some((at, repos)) = cache.as_ref() {
if at.elapsed() < self.ttl {
return repos.clone();
}
}
let roots = self.roots.clone();
let max_depth = self.max_depth;
let home = self.home.clone();
// Blocking fs walk + git subprocesses must not stall the reactor.
let repos = std::sync::Arc::new(
tokio::task::spawn_blocking(move || discover(&roots, max_depth, &home))
.await
.unwrap_or_default(),
);
*cache = Some((Instant::now(), repos.clone()));
repos
}
/// Rescan now, ignoring the TTL. Called by the background timer.
async fn refresh(&self) {
self.repos().await;
}
/// Spawn the slow background refresh task. Drop the returned handle to
/// detach it; it stops when the runtime shuts down.
pub fn start_background_refresh(self: &std::sync::Arc<Self>) -> tokio::task::JoinHandle<()> {
let scanner = std::sync::Arc::clone(self);
tokio::spawn(async move {
let mut ticker = tokio::time::interval(REFRESH_INTERVAL);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
ticker.tick().await; // the immediate first tick — wait a full interval
loop {
scanner.refresh().await;
ticker.tick().await;
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::spawn_history::RecentDir;
/// Real git repo at `<root>/<name>`, one initial commit so HEAD exists.
fn make_repo(root: &Path, name: &str) -> PathBuf {
let path = root.join(name);
std::fs::create_dir_all(&path).unwrap();
run_git(&path, &["init", "-q"]);
std::fs::write(path.join("f.txt"), "x\n").unwrap();
run_git(&path, &["add", "-A"]);
run_git(
&path,
&[
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-qm",
"init",
],
);
path
}
fn run_git(dir: &Path, args: &[&str]) {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?} failed: {} | {}",
String::from_utf8_lossy(&out.stderr),
String::from_utf8_lossy(&out.stdout)
);
}
fn names(repos: &[DiscoveredRepo]) -> Vec<String> {
let mut v: Vec<String> = repos.iter().map(|r| r.name.clone()).collect();
v.sort();
v
}
#[test]
fn scan_finds_repos_and_stops_at_the_first_hit() {
let tmp = tempfile::tempdir().unwrap();
let outer = make_repo(tmp.path(), "outer");
// A nested independent repo inside `outer` is NOT discovered
// separately: descent stops once a repo is found.
make_repo(&outer, "vendored");
std::fs::create_dir_all(tmp.path().join("plain/deep/deeper")).unwrap();
let found = scan_roots(
&[tmp.path().to_path_buf()],
3,
Path::new("/nonexistent-home"),
);
assert_eq!(found, vec![outer]);
let discovered = discover(
&[tmp.path().to_path_buf()],
3,
Path::new("/nonexistent-home"),
);
assert_eq!(names(&discovered), ["outer"]);
assert!(!discovered[0].is_worktree);
assert_eq!(discovered[0].base_repo, None);
assert!(discovered[0].head_mtime.is_some());
}
#[test]
fn scan_respects_depth_hidden_dirs_and_home_ignore() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
// Hidden dirs are never entered, at any depth.
std::fs::create_dir_all(home.join(".secret/.git")).unwrap();
// node_modules directly under home is not descended…
let _ignored = make_repo(&home.join("node_modules"), "dep");
// …but the same name deeper down is fair game.
let kept = make_repo(&home.join("code/node_modules"), "dep2");
// Depth cap: a repo three levels below the root is the last tier —
// found at depth 3, but a fourth-level repo isn't.
let at_three = home.join("a/b/c");
std::fs::create_dir_all(at_three.join(".git")).unwrap();
std::fs::create_dir_all(home.join("a/b/c/d/.git")).unwrap();
let found = scan_roots(std::slice::from_ref(&home), 3, &home);
assert!(found.iter().all(|p| !p.starts_with(home.join(".secret"))));
assert!(found
.iter()
.all(|p| !p.starts_with(home.join("node_modules"))));
assert!(found.contains(&kept.canonicalize().unwrap()));
assert!(found.contains(&at_three.canonicalize().unwrap()));
assert!(!found.iter().any(|p| p.ends_with("d")));
}
#[test]
fn worktrees_outside_the_roots_are_discovered_via_git_worktree_list() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("scan-root");
std::fs::create_dir_all(&root).unwrap();
let main = make_repo(&root, "main-repo");
// Linked worktree deliberately outside the scan root.
let wt = tmp.path().join("elsewhere/wt-of-main");
std::fs::create_dir_all(wt.parent().unwrap()).unwrap();
run_git(
&main,
&[
"worktree",
"add",
"-q",
"-b",
"feature",
&wt.to_string_lossy(),
],
);
let discovered = discover(&[root], 3, Path::new("/nonexistent-home"));
assert_eq!(names(&discovered), ["main-repo", "wt-of-main"]);
let wt_entry = discovered.iter().find(|d| d.name == "wt-of-main").unwrap();
assert!(wt_entry.is_worktree);
assert_eq!(
wt_entry.base_repo.as_deref(),
Some(main.canonicalize().unwrap().as_path())
);
let main_entry = discovered.iter().find(|d| d.name == "main-repo").unwrap();
assert!(!main_entry.is_worktree);
}
#[test]
fn head_mtime_resolves_a_worktrees_git_file_pointer() {
let tmp = tempfile::tempdir().unwrap();
let main = make_repo(tmp.path(), "m");
let wt = tmp.path().join("wt");
run_git(
&main,
&["worktree", "add", "-q", "-b", "b2", &wt.to_string_lossy()],
);
assert!(head_mtime(&wt).is_some());
}
fn recent(cwd: &str, cmd: &str, at: &str) -> RecentDir {
RecentDir {
cwd: PathBuf::from(cwd),
last_command: cmd.into(),
last_used: DateTime::parse_from_rfc3339(at)
.unwrap()
.with_timezone(&Utc),
}
}
#[test]
fn to_projects_merges_history_and_sorts_newest_first() {
let repo_old = DiscoveredRepo {
path: PathBuf::from("/w/old"),
name: "old".into(),
is_worktree: false,
base_repo: None,
// Committed recently but never spawned in.
head_mtime: Some(t("2026-08-23T10:00:00Z")),
};
let repo_hist = DiscoveredRepo {
path: PathBuf::from("/w/hist"),
name: "hist".into(),
is_worktree: false,
base_repo: None,
// Old commit…
head_mtime: Some(t("2026-08-01T10:00:00Z")),
};
let repo_stale = DiscoveredRepo {
path: PathBuf::from("/w/stale"),
name: "stale".into(),
is_worktree: false,
base_repo: None,
head_mtime: None,
};
let history = vec![
// Newest overall: a subdir of /w/hist (first match wins for it).
recent("/w/hist/crates", "opencode", "2026-08-22T09:00:00Z"),
recent("/w/hist", "claude", "2026-08-20T09:00:00Z"),
];
let projects = to_projects(&[repo_old, repo_hist, repo_stale], &history);
let paths: Vec<&str> = projects.iter().map(|p| p.path.to_str().unwrap()).collect();
assert_eq!(paths, ["/w/old", "/w/hist", "/w/stale"]);
// old: git activity wins (never spawned).
assert_eq!(projects[0].last_activity, Some(t("2026-08-23T10:00:00Z")));
assert_eq!(projects[0].last_command, None);
// hist: spawn history beats the old commit; command from the newest
// matching entry (the crates/ one, not the repo root one).
assert_eq!(projects[1].last_activity, Some(t("2026-08-22T09:00:00Z")));
assert_eq!(projects[1].last_command.as_deref(), Some("opencode"));
// stale: neither source knows anything.
assert_eq!(projects[2].last_activity, None);
assert_eq!(projects[2].last_command, None);
}
fn t(s: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
}
#[tokio::test]
async fn scanner_caches_within_ttl_and_rescans_after() {
let tmp = tempfile::tempdir().unwrap();
let mut scanner = ProjectScanner::new(
vec![tmp.path().to_path_buf()],
3,
PathBuf::from("/nonexistent"),
);
let empty = scanner.repos().await;
assert!(empty.is_empty());
make_repo(tmp.path(), "late");
// Within the TTL the cached (empty) snapshot is still served.
let still_empty = scanner.repos().await;
assert!(still_empty.is_empty());
// After the TTL lapses the rescan sees the new repo.
scanner.ttl = Duration::ZERO;
let fresh = scanner.repos().await;
assert_eq!(fresh.len(), 1);
assert_eq!(fresh[0].name, "late");
}
#[test]
fn disabled_scanner_always_serves_empty() {
let scanner = ProjectScanner::disabled();
assert_eq!(scanner.roots, Vec::<PathBuf>::new());
}
}
+37 -24
View File
@@ -38,13 +38,12 @@ pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
pub struct SessionRecord {
pub session: Session,
pub fifo_path: PathBuf,
/// Worktree we created at spawn time and that should be cleaned up on
/// kill (if `cleanup_worktree_on_kill`).
/// Worktree we created at spawn time; removed on kill unless the
/// dirty-work guard refuses (#56).
pub worktree_path: Option<PathBuf>,
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
/// worktree remove` is invoked from the base repo.
pub worktree_base_repo: Option<PathBuf>,
pub cleanup_worktree_on_kill: bool,
pub bridge: PtyBridge,
}
@@ -170,6 +169,16 @@ impl SessionManager {
.unwrap_or_default()
}
/// Every deduped recent dir, newest-first, without the picker's result
/// cap (#54): project enrichment folds the whole history.
pub fn all_recent_dirs(&self) -> Vec<RecentDir> {
self.inner
.spawn_history
.as_ref()
.map(|h| h.all_recent_dirs())
.unwrap_or_default()
}
/// Subscribe to session deltas. Each subscriber gets its own lag domain —
/// a slow client makes only itself resync.
pub fn events(&self) -> broadcast::Receiver<CatalogEvent> {
@@ -213,24 +222,20 @@ impl SessionManager {
/// Spawn a new session. On success returns the freshly-created `Session`.
pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
let (effective_cwd, worktree_path, worktree_base_repo) = match &req.worktree {
Some(WorktreeSpec {
base_repo, branch, ..
}) => {
let path = self
.inner
.worktree_mgr
.add(base_repo, branch)
.map_err(SessionError::Worktree)?;
Some(spec) => {
let WorktreeSpec {
base_repo, branch, ..
} = spec;
let path = if spec.create_branch {
self.inner.worktree_mgr.add_new(base_repo, branch)
} else {
self.inner.worktree_mgr.add_existing(base_repo, branch)
}
.map_err(SessionError::Worktree)?;
(path.clone(), Some(path), Some(base_repo.clone()))
}
None => (req.cwd.clone(), None, None),
};
let cleanup_worktree_on_kill = req
.worktree
.as_ref()
.map(|w| w.cleanup_on_kill)
.unwrap_or(false);
let id = Uuid::new_v4();
// Build the FIFO + reader task *before* asking tmux to spawn the
// command — otherwise the child's first bytes race the separate
@@ -315,7 +320,6 @@ impl SessionManager {
fifo_path,
worktree_path,
worktree_base_repo,
cleanup_worktree_on_kill,
bridge,
},
);
@@ -357,8 +361,9 @@ impl SessionManager {
}
}
/// Kill a session and remove its on-disk artifacts. The worktree is only
/// pruned if the create request set `cleanup_on_kill: true`.
/// Kill a session and remove its on-disk artifacts. The worktree is
/// always pruned -- unless it holds uncommitted changes or commits not on
/// any other ref, in which case the guard leaves it on disk (#56).
///
/// This is also how an already-dead session is dismissed. tmux is only
/// asked to kill something it still has — calling `kill-session` on a
@@ -454,7 +459,6 @@ impl SessionManager {
fifo_path,
worktree_path: None,
worktree_base_repo: None,
cleanup_worktree_on_kill: false,
bridge,
},
);
@@ -703,9 +707,18 @@ async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kil
if let Err(e) = inner.sidecar.delete(id) {
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
}
if record.cleanup_worktree_on_kill {
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
let _ = inner.worktree_mgr.remove(base, path);
// Cleanup is unconditional now (#56): `cleanup_on_kill` is no longer a
// user-facing choice. The manager's guard is what protects unsaved work --
// when it refuses, leave the tree on disk and say where it is so the
// operator can find it.
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
if let Err(e) = inner.worktree_mgr.remove(base, path) {
tracing::warn!(
%id,
path = %path.display(),
error = %e,
"left worktree on disk"
);
}
}
let _ = inner.events.send(CatalogEvent::SessionRemoved { id });
+38 -2
View File
@@ -143,11 +143,23 @@ impl SpawnHistory {
/// Recent working dirs, newest use first, deduped by `cwd`, within the
/// recency cap, capped at `max_results`. Empty when disabled or unreadable.
pub fn recent_dirs(&self) -> Vec<RecentDir> {
self.recent_dirs_as_of(Utc::now())
self.recent_dirs_capped(Utc::now(), self.max_results)
}
/// Every deduped recent dir, newest-first, without the picker's result
/// cap (#54): project enrichment folds the whole history, not its first
/// page, so a project spawned in long ago still reads as fresh.
pub fn all_recent_dirs(&self) -> Vec<RecentDir> {
self.recent_dirs_capped(Utc::now(), usize::MAX)
}
/// [`recent_dirs`](Self::recent_dirs) with an explicit clock for testing.
#[cfg(test)]
fn recent_dirs_as_of(&self, now: DateTime<Utc>) -> Vec<RecentDir> {
self.recent_dirs_capped(now, self.max_results)
}
fn recent_dirs_capped(&self, now: DateTime<Utc>, max_results: usize) -> Vec<RecentDir> {
if !self.is_enabled() {
return Vec::new();
}
@@ -168,7 +180,7 @@ impl SpawnHistory {
last_command: rec.command,
last_used: rec.at,
});
if out.len() >= self.max_results {
if out.len() >= max_results {
break;
}
}
@@ -362,4 +374,28 @@ mod tests {
assert_eq!(recents[0].cwd, PathBuf::from("/d4"));
assert_eq!(recents[1].cwd, PathBuf::from("/d3"));
}
/// Project enrichment (#54) folds the whole log, not just the picker's
/// first page.
#[test]
fn all_recent_dirs_skips_the_result_cap() {
let tmp = tempfile::tempdir().unwrap();
let mut h = SpawnHistory::new(tmp.path().join("h.jsonl"), 30);
h.max_results = 2;
// Recent enough to survive the 30-day cap against the real clock.
let base = Utc::now() - Duration::hours(1);
for i in 0..5 {
h.append(&rec(
&format!("/d{i}"),
"claude",
base + Duration::minutes(i),
))
.unwrap();
}
assert_eq!(h.recent_dirs().len(), 2);
let all = h.all_recent_dirs();
assert_eq!(all.len(), 5);
assert_eq!(all[0].cwd, PathBuf::from("/d4"));
assert_eq!(all[4].cwd, PathBuf::from("/d0"));
}
}
+134 -29
View File
@@ -4,64 +4,124 @@
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
pub enum WorktreeError {
#[error("failed to spawn git: {0}")]
Spawn(#[from] std::io::Error),
#[error("git exited with code {code}: {stderr}")]
NonZero { code: i32, stderr: String },
/// A worktree already occupies the target path. The caller decides what
/// to do; the daemon never relocates to a different directory (#56).
#[error("worktree path already exists: {}", .path.display())]
PathExists { path: PathBuf },
/// The worktree holds unsaved work; removal was refused (#56).
#[error("left {} on disk: {}", .path.display(), .reason)]
Guarded { path: PathBuf, reason: String },
}
/// Generates a unique path under `root` and runs `git worktree add` rooted at
/// `base_repo`. Each call appends a UUID-suffixed directory so concurrent
/// `add()`s never collide.
/// Runs `git worktree add` rooted at `base_repo`. By default worktrees land
/// inside the repository itself at `<repo>/worktrees/<branch>` (#56) so they
/// are findable from the repo; an operator-set root overrides that.
#[derive(Debug, Clone)]
pub struct WorktreeManager {
root: PathBuf,
/// `[daemon].worktree_root` override. `None` uses the default
/// `<repo>/worktrees/<branch>` placement.
root_override: Option<PathBuf>,
}
impl WorktreeManager {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
pub fn new(root_override: Option<PathBuf>) -> Self {
Self { root_override }
}
pub fn root(&self) -> &Path {
&self.root
pub fn root(&self) -> Option<&Path> {
self.root_override.as_deref()
}
/// `git -C <base_repo> worktree add -b <branch> <generated-path>`.
/// Returns the path of the new worktree on success.
pub fn add(&self, base_repo: &Path, branch: &str) -> Result<PathBuf, WorktreeError> {
std::fs::create_dir_all(&self.root)?;
let path = self.generate_path(branch);
let out = Command::new("git")
.arg("-C")
.arg(base_repo)
.args(["worktree", "add", "-b", branch])
.arg(&path)
.output()?;
/// Where a worktree for `branch` will be created: the override root, or
/// `<base_repo>/worktrees/<sanitized branch>` by default. The path is
/// predictable (no UUID suffix), so an existing directory at the target
/// is an error rather than a silently different location.
fn target_path(&self, base_repo: &Path, branch: &str) -> PathBuf {
let slug = sanitize_branch_for_path(branch);
match &self.root_override {
Some(root) => root.join(slug),
None => base_repo.join("worktrees").join(slug),
}
}
/// `git -C <base_repo> worktree add -b <branch> <path>` — creates the
/// branch. Returns the path of the new worktree on success.
pub fn add_new(&self, base_repo: &Path, branch: &str) -> Result<PathBuf, WorktreeError> {
self.run_add(base_repo, branch, true)
}
/// `git -C <base_repo> worktree add <path> <branch>` — checks out a branch
/// that must already exist (#56).
pub fn add_existing(&self, base_repo: &Path, branch: &str) -> Result<PathBuf, WorktreeError> {
self.run_add(base_repo, branch, false)
}
fn run_add(
&self,
base_repo: &Path,
branch: &str,
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 });
}
// The parent (`<repo>/worktrees` or the override root) may not exist
// yet; the target itself must not.
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut cmd = Command::new("git");
cmd.arg("-C").arg(base_repo).args(["worktree", "add"]);
if create_branch {
cmd.arg("-b").arg(branch).arg(&path);
} else {
// Existing branch: `worktree add <path> <branch>` with no `-b`.
cmd.arg(&path).arg(branch);
}
let out = cmd.output()?;
check(out)?;
Ok(path)
}
/// `git -C <base_repo> worktree remove --force <path>`.
/// `git -C <base_repo> worktree remove <path>` — but only when the
/// worktree holds nothing unique (#56). A worktree with uncommitted
/// changes, or with commits reachable from no other ref, is refused and
/// left on disk so the operator can rescue it; cleanup is unconditional,
/// which makes this guard the only thing standing between a stray kill
/// and destroyed work.
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(reason) = unsafe_to_remove(path)? {
return Err(WorktreeError::Guarded {
path: path.to_path_buf(),
reason,
});
}
let out = Command::new("git")
.arg("-C")
.arg(base_repo)
.args(["worktree", "remove", "--force"])
.args(["worktree", "remove"])
.arg(path)
.output()?;
check(out)
}
fn generate_path(&self, branch: &str) -> PathBuf {
let slug = sanitize_branch_for_path(branch);
let unique = Uuid::new_v4().simple();
self.root.join(format!("{slug}-{unique}"))
}
}
fn sanitize_branch_for_path(branch: &str) -> String {
@@ -79,6 +139,51 @@ fn sanitize_branch_for_path(branch: &str) -> String {
out
}
/// `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> {
// `--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()));
}
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>,
) -> Result<Output, WorktreeError> {
let mut cmd = Command::new("git");
cmd.arg("-C").arg(dir).args(args);
let out = cmd.output()?;
if !out.status.success() {
return Err(WorktreeError::NonZero {
code: out.status.code().unwrap_or(-1),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
});
}
Ok(out)
}
fn check(out: Output) -> Result<(), WorktreeError> {
if out.status.success() {
Ok(())
+1
View File
@@ -37,6 +37,7 @@ async fn spawn_daemon_ws() -> Harness {
"test-host",
"/home/test",
Vec::new(),
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
+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());
}
+2 -4
View File
@@ -115,7 +115,6 @@ pub struct DaemonFixture {
pub tmux: TmuxFixture,
pub manager: SessionManager,
pub state_dir: TempDir,
pub worktree_root: TempDir,
}
impl DaemonFixture {
@@ -132,9 +131,9 @@ impl DaemonFixture {
fn build(history_days: Option<u32>) -> Self {
let tmux = TmuxFixture::new();
let state_dir = TempDir::new().unwrap();
let worktree_root = TempDir::new().unwrap();
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
let worktree_mgr = WorktreeManager::new(worktree_root.path().to_path_buf());
// Default placement: worktrees land under their base repo (#56).
let worktree_mgr = WorktreeManager::new(None);
let mut manager = SessionManager::new(
Arc::new(tmux.tmux.clone()),
sidecar,
@@ -149,7 +148,6 @@ impl DaemonFixture {
tmux,
manager,
state_dir,
worktree_root,
}
}
+130
View File
@@ -26,6 +26,17 @@ struct Harness {
}
async fn spawn(hostname: &str) -> Harness {
spawn_with(
hostname,
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
)
.await
}
async fn spawn_with(
hostname: &str,
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
) -> Harness {
let fx = DaemonFixture::new();
let manager = fx.manager.clone();
// Built-in presets (claude/opencode/zsh + custom) so `/api/info` advertises
@@ -36,6 +47,7 @@ async fn spawn(hostname: &str) -> Harness {
hostname,
"/home/test",
presets,
projects,
));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
@@ -153,6 +165,124 @@ async fn recent_dirs_returns_empty_without_history() {
assert_eq!(resp["truncated"], false);
}
/// #54: `GET /api/projects` lists every git repo under the scan roots, with
/// real git checkouts — not mocks.
#[tokio::test]
async fn projects_lists_repos_under_the_scan_roots() {
let tmp = tempfile::tempdir().unwrap();
for name in ["alpha", "beta"] {
let repo = tmp.path().join(name);
std::fs::create_dir_all(&repo).unwrap();
for args in [
vec!["init", "-q"],
vec![
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-qm",
"init",
"--allow-empty",
],
] {
let out = std::process::Command::new("git")
.arg("-C")
.arg(&repo)
.args(&args)
.output()
.unwrap();
assert!(out.status.success());
}
}
let scanner = std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::new(
vec![tmp.path().to_path_buf()],
3,
tmp.path().to_path_buf(),
));
let h = spawn_with("naps-laptop", scanner).await;
let projects: Vec<maestro_protocol::Project> = reqwest::get(url(h.addr, "/api/projects"))
.await
.unwrap()
.json()
.await
.unwrap();
let mut names: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect();
names.sort();
assert_eq!(names, ["alpha", "beta"]);
for p in &projects {
assert!(!p.is_worktree);
assert_eq!(p.base_repo, None);
assert!(p.last_activity.is_some(), "fresh commit ⇒ HEAD mtime known");
}
}
#[tokio::test]
async fn projects_returns_empty_for_a_disabled_scanner() {
let h = spawn("naps-laptop").await;
let projects: Vec<maestro_protocol::Project> = reqwest::get(url(h.addr, "/api/projects"))
.await
.unwrap()
.json()
.await
.unwrap();
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
@@ -38,7 +38,7 @@ impl Fx {
let manager = SessionManager::new(
fake.clone(),
SidecarStore::new(pick(tmp.path())),
WorktreeManager::new(tmp.path().join("worktrees")),
WorktreeManager::new(Some(tmp.path().join("worktrees"))),
fifo_dir,
);
Self { manager, fake, tmp }
@@ -387,7 +387,7 @@ fn attaching_spawn_history_after_cloning_panics() {
let manager = SessionManager::new(
fake,
SidecarStore::new(tmp.path().join("sidecars")),
WorktreeManager::new(tmp.path().join("worktrees")),
WorktreeManager::new(Some(tmp.path().join("worktrees"))),
tmp.path().join("fifos"),
);
let _clone = manager.clone();
+70 -6
View File
@@ -7,9 +7,9 @@ use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use common::{wait_for_async, DaemonFixture};
use common::{init_repo, wait_for_async, DaemonFixture};
use maestro_protocol::CreateSessionRequest;
use maestro_protocol::{CatalogEvent, SessionStatus};
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
use tempfile::TempDir;
#[tokio::test]
@@ -123,8 +123,7 @@ async fn recover_adopts_existing_tmux_sessions() {
// build a parallel manager pointing at the same dirs + same tmux server
let sidecar = maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir());
let worktree_mgr =
maestro_daemon::worktree::WorktreeManager::new(fx.worktree_root.path().to_path_buf());
let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(None);
let revived = maestro_daemon::SessionManager::new(
Arc::new(fx.tmux.tmux.clone()),
sidecar,
@@ -214,7 +213,6 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
let sidecar_dir = fx.sidecar_dir();
let fifo_dir = fx.fifo_dir();
let tmux = fx.tmux.tmux.clone();
let worktree_root = fx.worktree_root.path().to_path_buf();
// Drop the original manager. Its PtyBridge closes the FIFO's only
// reader, so tmux's `cat >> fifo` writer hits SIGPIPE and exits — but
@@ -223,7 +221,7 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
drop(fx.manager);
let sidecar = maestro_daemon::sidecar::SidecarStore::new(sidecar_dir);
let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(worktree_root);
let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(None);
let revived =
maestro_daemon::SessionManager::new(Arc::new(tmux), sidecar, worktree_mgr, fifo_dir);
assert_eq!(revived.recover().await.expect("recover"), 1);
@@ -365,6 +363,72 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
assert!(!fx.fifo_dir().join(format!("{}.fifo", s.id)).exists());
}
/// Cleanup is unconditional (#56): even a spec that says "don't clean up"
/// loses its worktree on kill — the guard, not the flag, protects work.
#[tokio::test]
async fn kill_removes_worktree_even_when_spec_says_keep() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let wt_path = repo.path().join("worktrees").join("feat-keep");
let s = fx
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/keep".into(),
cleanup_on_kill: false,
create_branch: true,
}),
})
.await
.expect("create");
assert!(wt_path.exists());
fx.manager.kill(s.id).await.expect("kill");
assert!(!wt_path.exists(), "worktree must be removed on kill");
}
/// The guard (#56): a worktree holding uncommitted changes survives the kill,
/// left on disk where the operator can find it.
#[tokio::test]
async fn kill_leaves_worktree_with_unsaved_work_on_disk() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let wt_path = repo.path().join("worktrees").join("feat-guarded");
let s = fx
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/guarded".into(),
cleanup_on_kill: true,
create_branch: true,
}),
})
.await
.expect("create");
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
fx.manager.kill(s.id).await.expect("kill");
assert!(
wt_path.exists(),
"dirty worktree must be left on disk, not destroyed"
);
assert!(fx.manager.snapshot().await.is_empty());
}
#[tokio::test]
async fn liveness_watcher_leaves_running_sessions_alone() {
let fx = DaemonFixture::new();
+196 -9
View File
@@ -31,9 +31,9 @@ fn add_creates_worktree_on_new_branch() {
init_repo(&repo);
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(wt_root.path());
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let path = mgr.add(&repo, "feat/example").expect("add worktree");
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
assert!(path.exists());
assert!(path.join("README").exists());
assert!(path.starts_with(wt_root.path()));
@@ -42,6 +42,63 @@ fn add_creates_worktree_on_new_branch() {
assert!(branches.iter().any(|b| b == "feat/example"), "{branches:?}");
}
#[test]
fn add_places_worktree_inside_the_base_repo_by_default() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
// No override: the worktree must land where the repository keeps its
// own worktrees (#56), not under some daemon state dir.
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
assert_eq!(path, repo.join("worktrees").join("feat-example"));
assert!(path.exists());
assert!(path.join("README").exists());
let branches = worktree_branches(&repo);
assert!(branches.iter().any(|b| b == "feat/example"), "{branches:?}");
}
#[test]
fn worktree_root_override_redirects_placement() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
assert_eq!(path, wt_root.path().join("feat-example"));
assert!(path.exists());
}
#[test]
fn existing_directory_at_target_path_is_an_error_carrying_the_path() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
// A predictable path means collisions are possible; they must be
// reported, never silently relocated.
std::fs::create_dir_all(repo.join("worktrees").join("feat-collide")).unwrap();
let mgr = WorktreeManager::new(None);
let err = mgr.add_new(&repo, "feat/collide").unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains(
repo.join("worktrees")
.join("feat-collide")
.to_str()
.unwrap()
),
"{msg}"
);
}
#[test]
fn add_twice_generates_distinct_paths() {
let repo_dir = TempDir::new().unwrap();
@@ -49,10 +106,10 @@ fn add_twice_generates_distinct_paths() {
init_repo(&repo);
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(wt_root.path());
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let p1 = mgr.add(&repo, "feat/a").expect("add a");
let p2 = mgr.add(&repo, "feat/b").expect("add b");
let p1 = mgr.add_new(&repo, "feat/a").expect("add a");
let p2 = mgr.add_new(&repo, "feat/b").expect("add b");
assert_ne!(p1, p2);
assert!(p1.exists());
assert!(p2.exists());
@@ -65,9 +122,9 @@ fn remove_prunes_worktree() {
init_repo(&repo);
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(wt_root.path());
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let path = mgr.add(&repo, "feat/gone").expect("add");
let path = mgr.add_new(&repo, "feat/gone").expect("add");
assert!(path.exists());
mgr.remove(&repo, &path).expect("remove");
@@ -79,6 +136,136 @@ fn remove_prunes_worktree() {
assert_eq!(branches, vec!["main".to_string()]);
}
#[test]
fn add_existing_checks_out_branch_without_creating_it() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
// Pre-create the branch with its own commit so we can tell a checkout
// from an accidental `-b`.
let created = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["checkout", "-q", "-b", "feat/dup"])
.status()
.unwrap();
assert!(created.success());
std::fs::write(repo.join("FEATURE"), b"dup\n").unwrap();
let staged = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["add", "."])
.status()
.unwrap();
assert!(staged.success());
let committed = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["commit", "-q", "-am", "wip"])
.status()
.unwrap();
assert!(committed.success());
let back = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["checkout", "-q", "main"])
.status()
.unwrap();
assert!(back.success());
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let path = mgr.add_existing(&repo, "feat/dup").expect("add existing");
assert!(path.exists());
assert!(path.join("README").exists());
// The branch set is unchanged: `feat/dup` was checked out, not recreated.
let branches = worktree_branches(&repo);
let mut sorted = branches.clone();
sorted.sort();
assert_eq!(
sorted,
vec!["feat/dup".to_string(), "main".to_string()],
"{branches:?}"
);
}
#[test]
fn remove_refuses_worktree_with_uncommitted_changes() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/dirty").expect("add");
std::fs::write(path.join("README"), b"unsaved edits\n").unwrap();
mgr.remove(&repo, &path)
.expect_err("dirty worktree must be refused");
assert!(path.exists(), "guarded worktree must stay on disk");
// 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", "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("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]) {
let out = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("git spawn");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn add_fails_when_branch_already_exists() {
let repo_dir = TempDir::new().unwrap();
@@ -95,9 +282,9 @@ fn add_fails_when_branch_already_exists() {
assert!(status.success());
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(wt_root.path());
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let err = mgr.add(&repo, "feat/dup").unwrap_err();
let err = mgr.add_new(&repo, "feat/dup").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("git exited"), "{msg}");
}
+87 -7
View File
@@ -114,7 +114,7 @@ async fn multi_attach_sees_same_output() {
}
#[tokio::test(flavor = "multi_thread")]
async fn cleanup_on_kill_removes_worktree() {
async fn kill_removes_clean_worktree() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
@@ -129,18 +129,19 @@ async fn cleanup_on_kill_removes_worktree() {
base_repo: base.path().to_path_buf(),
branch: "feature/e2e".into(),
cleanup_on_kill: true,
create_branch: true,
}),
};
let s = client.spawn_session(&req).await.expect("spawn worktree");
// The daemon's WorktreeManager puts new worktrees under
// `<state_dir>/worktrees/<branch>`. We can't peek the daemon's state dir
// directly from here, but we can verify by asking git: a worktree must
// exist that lives outside the original repo.
// Worktrees live inside the base repo at `<repo>/worktrees/<branch>`
// (#56), so `git worktree list` must show the predictable in-repo path.
let worktrees = git_worktree_list(base.path());
assert!(
worktrees.iter().any(|w| w.contains("feature/e2e")),
"expected a worktree for feature/e2e branch, got: {worktrees:?}"
worktrees
.iter()
.any(|w| w.contains("worktrees/feature-e2e")),
"expected a worktree for feature/e2e under <repo>/worktrees, got: {worktrees:?}"
);
client.kill_session(s.id).await.expect("kill");
@@ -160,6 +161,85 @@ async fn cleanup_on_kill_removes_worktree() {
);
}
/// The guard (#56): killing a session whose worktree holds uncommitted
/// changes must leave the tree on disk — cleanup is unconditional, so this
/// guard is what keeps a stray kill from destroying work.
#[tokio::test(flavor = "multi_thread")]
async fn kill_spares_worktree_with_uncommitted_changes() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let wt_path = base.path().join("worktrees").join("feature-unsaved");
let req = CreateSessionRequest {
cwd: base.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: base.path().to_path_buf(),
branch: "feature/unsaved".into(),
cleanup_on_kill: true,
create_branch: true,
}),
};
let s = client.spawn_session(&req).await.expect("spawn worktree");
assert!(wt_path.exists());
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
client.kill_session(s.id).await.expect("kill");
// The session is gone but the dirty worktree stays findable on disk.
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
let sessions = client.list_sessions().await.unwrap_or_default();
if sessions.is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
assert!(
wt_path.exists(),
"guarded worktree must be left on disk after kill"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn spawn_on_existing_branch_checks_it_out() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
run_git(&["branch", "feature/existing"], base.path());
let req = CreateSessionRequest {
cwd: base.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: base.path().to_path_buf(),
branch: "feature/existing".into(),
cleanup_on_kill: true,
create_branch: false,
}),
};
let s = client
.spawn_session(&req)
.await
.expect("spawn on existing branch");
let worktrees = git_worktree_list(base.path());
assert!(
worktrees.iter().any(|w| w.contains("feature/existing")),
"expected a worktree checked out at feature/existing, got: {worktrees:?}"
);
client.kill_session(s.id).await.expect("kill");
}
#[tokio::test(flavor = "multi_thread")]
async fn daemon_restart_recovers_session() {
let (mut daemon, client) = bring_up().await;
+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);
}
}
+6 -1
View File
@@ -15,7 +15,8 @@ use uuid::Uuid;
use crate::routes::{self, UrlError};
use crate::{
ApiError, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, RecentDirs, Session,
ApiError, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, Project,
RecentDirs, Session,
};
/// Time budget for establishing a TCP connection to the daemon.
@@ -138,6 +139,10 @@ impl DaemonClient {
.await
}
pub async fn list_projects(&self) -> Result<Vec<Project>, ClientError> {
self.get(routes::PROJECTS, &[]).await
}
// -- plumbing -----------------------------------------------------------
async fn get<T: DeserializeOwned>(
+113 -1
View File
@@ -111,8 +111,20 @@ pub enum ActivityState {
pub struct WorktreeSpec {
pub base_repo: PathBuf,
pub branch: String,
#[serde(default)]
/// Kept on the wire for CLI compatibility (#56) but no longer a
/// user-facing choice: the daemon always cleans up on kill, guarded
/// against unsaved work. Defaults to `true`.
#[serde(default = "default_true")]
pub cleanup_on_kill: bool,
/// When false, `branch` must already exist and is checked out as-is
/// (#56); when true (the default, and what every payload meant before
/// the field existed) the daemon creates it with `worktree add -b`.
#[serde(default = "default_true")]
pub create_branch: bool,
}
fn default_true() -> bool {
true
}
/// HTTP `POST /api/sessions` body.
@@ -176,6 +188,45 @@ pub struct GitRoot {
pub root: Option<PathBuf>,
}
/// One discovered git repository (or linked worktree) for `GET /api/projects`
/// (#54). The daemon reports the timestamp; clients decide what "stale" means
/// (the web UI dims entries older than 7 days).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Project {
/// Absolute path of the repository/worktree toplevel.
pub path: PathBuf,
/// Basename of `path`.
pub name: String,
#[serde(default)]
pub is_worktree: bool,
/// The main checkout this worktree belongs to. Set only when
/// [`Self::is_worktree`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_repo: Option<PathBuf>,
/// Later of the repo's git HEAD mtime and the newest spawn-history use of
/// any directory inside it. `None` when neither is known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_activity: Option<DateTime<Utc>>,
/// Command last spawned in this project, from spawn history.
#[serde(default, skip_serializing_if = "Option::is_none")]
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 {
@@ -243,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`)
// ---------------------------------------------------------------------------
@@ -317,6 +375,34 @@ mod tests {
serde_json::from_str(&json).expect("deserialize")
}
#[test]
fn project_round_trips_and_defaults_on_legacy_payload() {
let full = Project {
path: PathBuf::from("/home/u/tea/maestro"),
name: "maestro".into(),
is_worktree: true,
base_repo: Some(PathBuf::from("/home/u/tea/maestro-main")),
last_activity: Some(
DateTime::parse_from_rfc3339("2026-08-23T12:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
last_command: Some("claude".into()),
};
assert_eq!(full, round_trip(&full));
// A payload from a daemon predating the optional fields still decodes.
let legacy = serde_json::json!({
"path": "/home/u/proj",
"name": "proj",
});
let p: Project = serde_json::from_value(legacy).unwrap();
assert!(!p.is_worktree);
assert_eq!(p.base_repo, None);
assert_eq!(p.last_activity, None);
assert_eq!(p.last_command, None);
}
fn sample_session() -> Session {
Session {
id: Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef),
@@ -393,6 +479,7 @@ mod tests {
base_repo: PathBuf::from("/home/naps62/projects/maestro"),
branch: "feat/foo".into(),
cleanup_on_kill: true,
create_branch: true,
}),
};
assert_eq!(req, round_trip(&req));
@@ -410,6 +497,31 @@ mod tests {
assert!(req.metadata.is_empty());
}
/// A client that predates the new/existing discriminator sends no
/// `create_branch`; the daemon must read that as "create a new branch" —
/// exactly what every payload meant before the field existed (#56).
#[test]
fn worktree_spec_defaults_on_legacy_payload() {
let json = serde_json::json!({
"base_repo": "/home/naps62/projects/maestro",
"branch": "feat/foo",
});
let spec: WorktreeSpec = serde_json::from_value(json).unwrap();
assert!(spec.create_branch);
assert!(spec.cleanup_on_kill);
}
#[test]
fn worktree_spec_round_trips_existing_branch_mode() {
let spec = WorktreeSpec {
base_repo: PathBuf::from("/work"),
branch: "feat/foo".into(),
cleanup_on_kill: true,
create_branch: false,
};
assert_eq!(spec, round_trip(&spec));
}
#[test]
fn catalog_event_round_trips_and_tags() {
let session = sample_session();
+22 -2
View File
@@ -17,14 +17,24 @@ pub const INFO: &str = "/api/info";
pub const DIRS: &str = "/api/dirs";
/// `GET` spawn history.
pub const RECENT_DIRS: &str = "/api/recent-dirs";
/// `GET` discovered git repositories and worktrees, newest activity first.
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}")
@@ -172,7 +182,17 @@ mod tests {
#[test]
fn every_route_sits_under_the_api_prefix() {
for path in [SESSIONS, INFO, DIRS, RECENT_DIRS, 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));
+13 -2
View File
@@ -38,7 +38,7 @@ pub enum Action {
// Catalog actions on the highlighted sidebar row
AttachHighlighted,
ToggleAttachHighlighted,
OpenSpawnModal,
NewDraftPane,
KillHighlighted,
// Tabs
@@ -78,7 +78,7 @@ 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),
"KillHighlighted" => return Ok(Action::KillHighlighted),
"NewTab" => return Ok(Action::NewTab),
"CloseTab" => return Ok(Action::CloseTab),
@@ -202,6 +202,7 @@ mod tests {
for a in [
Action::EnterInsert,
Action::Quit,
Action::NewDraftPane,
Action::FocusPane(Direction::Right),
Action::GotoTab(7),
] {
@@ -209,4 +210,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())
);
}
}
+181 -81
View File
@@ -6,9 +6,9 @@
//! changes, and a 30Hz heartbeat.
//!
//! Actions mutate `AppState` directly. Side effects that talk to the daemon
//! (`AttachHighlighted` spawning a `SessionTask`, `OpenSpawnModal` submit
//! firing a POST, `KillHighlighted` confirm firing a DELETE) live inline in
//! `dispatch` / `handle_overlay_input`.
//! (`AttachHighlighted` spawning a `SessionTask`, a draft-pane submit firing
//! a POST, `KillHighlighted` confirm firing a DELETE) live inline in
//! `dispatch` / the Insert-mode pane routing.
use std::collections::{HashMap, HashSet};
use std::io::Stdout;
@@ -24,15 +24,16 @@ use uuid::Uuid;
use crate::action::Action;
use crate::catalog::CatalogStore;
use crate::config::TuiConfig;
use crate::draft::DraftInputResult;
use crate::emulators::SessionEmulators;
use crate::input::{canonicalise, ChordResult, ChordState, ClickTracker, InputKey};
use crate::keymap::Mode;
use crate::overlays::{HelpOverlay, SpawnModal};
use crate::overlays::HelpOverlay;
use crate::pane::{Pane, PaneStatus};
use crate::render::{draw, RegionMap, RenderInputs};
use crate::session_task::{SessionInput, SessionTask};
use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind};
use crate::state::{AppState, Focus, OverlayKind, PaneRef};
use crate::tabs::TabBar;
use crate::TuiError;
@@ -49,7 +50,6 @@ pub struct App {
catalog: CatalogStore,
emulators: SessionEmulators,
state: AppState,
spawn: SpawnModal,
help: HelpOverlay,
chord: ChordState,
daemon: DaemonClient,
@@ -90,7 +90,6 @@ impl App {
catalog,
emulators: SessionEmulators::default(),
state: AppState::new(),
spawn: SpawnModal::default(),
help,
chord: ChordState::new(chord_timeout),
daemon,
@@ -152,7 +151,15 @@ impl App {
self.perform(effects).await;
} else if self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane)
{
if let Some(effect) = self.forward_to_session(&key) {
// 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(_)))
{
self.handle_draft_input(&key)
} else {
self.forward_to_session(&key)
};
if let Some(effect) = effect {
self.perform(vec![effect]).await;
}
}
@@ -179,27 +186,7 @@ impl App {
}
fn handle_overlay_input(&mut self, key: &InputKey) -> (OverlayDecision, Vec<Effect>) {
use crate::overlays::SpawnInputResult;
match self.state.overlay().cloned() {
Some(OverlayKind::Spawn) => {
let mut effects = Vec::new();
match self.spawn.on_input(key) {
SpawnInputResult::Consumed => {}
SpawnInputResult::Cancel => {
self.state.dismiss_overlay();
}
SpawnInputResult::Submit => {
effects.push(Effect::Spawn(CreateSessionRequest {
cwd: std::path::PathBuf::from(self.spawn.cwd.clone()),
command: self.spawn.command.clone(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
}));
}
}
(OverlayDecision::Consumed, effects)
}
Some(OverlayKind::KillConfirm(id)) => match key {
InputKey::Char { c: 'y', .. } => {
self.state.dismiss_overlay();
@@ -265,14 +252,7 @@ impl App {
};
self.state.show_overlay(OverlayKind::Help);
}
Action::OpenSpawnModal => {
self.spawn = SpawnModal {
cwd: String::new(),
command: String::new(),
focused_field: Default::default(),
};
self.state.show_overlay(OverlayKind::Spawn);
}
Action::NewDraftPane => self.state.open_draft_pane(),
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
if let Some(sid) = self.pick_sidebar_target() {
effects.push(Effect::Attach(sid));
@@ -322,41 +302,38 @@ impl App {
/// Encode a keystroke for the focused pane. `None` when there is nothing
/// attached to send it to.
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
let sid = self.state.active_tab().pane?;
let sid = self.state.active_session()?;
self.emulators.get(sid)?;
let bytes = match key {
InputKey::Char { c, ctrl, alt, .. } => {
let mut out = Vec::with_capacity(8);
if *alt {
out.push(0x1b);
}
if *ctrl && c.is_ascii_alphabetic() {
out.push((c.to_ascii_uppercase() as u8) - b'@');
} else {
let mut b = [0u8; 4];
let s = c.encode_utf8(&mut b);
out.extend_from_slice(s.as_bytes());
}
out
}
InputKey::Enter => b"\r".to_vec(),
InputKey::Tab => b"\t".to_vec(),
InputKey::BackTab => b"\x1b[Z".to_vec(),
InputKey::Backspace => vec![0x7f],
InputKey::Esc => vec![0x1b],
InputKey::Up => b"\x1b[A".to_vec(),
InputKey::Down => b"\x1b[B".to_vec(),
InputKey::Right => b"\x1b[C".to_vec(),
InputKey::Left => b"\x1b[D".to_vec(),
InputKey::Mouse { .. } => return None,
};
let bytes = encode_key(key)?;
Some(Effect::Forward { id: sid, bytes })
}
/// 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;
};
if draft.on_input(key) != DraftInputResult::Submit {
return None;
}
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) {
match self.daemon.create_session(&req).await {
Ok(_) => {
self.state.dismiss_overlay();
Ok(session) => {
// Fill the pane in place with the new session (web parity,
// #57) and attach to it right away.
let sid = session.id;
self.state.complete_spawn(sid);
self.attach(sid);
}
Err(e) => self.state.toast(format!("spawn failed: {e}")),
}
@@ -374,7 +351,12 @@ impl App {
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
) -> Result<(), TuiError> {
let snap = self.catalog.snapshot();
let attached: HashSet<Uuid> = self.state.tabs.iter().filter_map(|t| t.pane).collect();
let attached: HashSet<Uuid> = self
.state
.tabs
.iter()
.filter_map(crate::state::Tab::session)
.collect();
let sidebar = Sidebar {
sessions: snap.sessions.clone(),
highlight: self.state.sidebar_highlight,
@@ -384,7 +366,7 @@ impl App {
tab_names: self.state.tabs.iter().map(|t| t.name.clone()).collect(),
active_idx: self.state.active_tab_idx,
};
let active_sid = self.state.active_tab().pane;
let active_sid = self.state.active_session();
let pane = active_sid.and_then(|sid| {
self.emulators.get(sid).map(|h| Pane {
screen: h.screen.clone(),
@@ -392,13 +374,17 @@ impl App {
focused: matches!(self.state.focus, Focus::Pane),
})
});
let draft = match self.state.active_tab().pane.as_ref() {
Some(PaneRef::Draft(d)) => Some(d),
_ => None,
};
let inputs = RenderInputs {
sidebar: &sidebar,
tab_bar: &tab_bar,
pane: pane.as_ref(),
state: &self.state,
spawn: &self.spawn,
draft,
help: &self.help,
};
@@ -446,6 +432,52 @@ fn visible_sidebar_order(snap: &crate::catalog::CatalogSnapshot) -> Vec<Uuid> {
snap.sessions.iter().map(|s| s.id).collect()
}
/// Wire encoding for a keystroke headed to a session's PTY. `None` for keys
/// with no byte representation (mouse events). A bracketed paste is forwarded
/// verbatim — the remote readline reassembles it, since the pane's PTY also
/// has bracketed paste enabled.
fn encode_key(key: &InputKey) -> Option<Vec<u8>> {
match key {
InputKey::Char { c, ctrl, alt, .. } => {
let mut out = Vec::with_capacity(8);
if *alt {
out.push(0x1b);
}
if *ctrl && c.is_ascii_alphabetic() {
out.push((c.to_ascii_uppercase() as u8) - b'@');
} else {
let mut b = [0u8; 4];
let s = c.encode_utf8(&mut b);
out.extend_from_slice(s.as_bytes());
}
Some(out)
}
InputKey::Enter => Some(b"\r".to_vec()),
InputKey::Tab => Some(b"\t".to_vec()),
InputKey::BackTab => Some(b"\x1b[Z".to_vec()),
InputKey::Backspace => Some(vec![0x7f]),
InputKey::Esc => Some(vec![0x1b]),
InputKey::Up => Some(b"\x1b[A".to_vec()),
InputKey::Down => Some(b"\x1b[B".to_vec()),
InputKey::Right => Some(b"\x1b[C".to_vec()),
InputKey::Left => Some(b"\x1b[D".to_vec()),
InputKey::Home => Some(b"\x1b[H".to_vec()),
InputKey::End => Some(b"\x1b[F".to_vec()),
InputKey::Delete => Some(b"\x1b[3~".to_vec()),
InputKey::Paste(text) => {
// Wrap in the DECSET 2004 markers. Raw bytes look like typed keys
// to an app that enabled bracketed paste, so a multi-line paste
// would execute line by line instead of arriving as one block.
let mut out = Vec::with_capacity(text.len() + 12);
out.extend_from_slice(b"\x1b[200~");
out.extend_from_slice(text.as_bytes());
out.extend_from_slice(b"\x1b[201~");
Some(out)
}
InputKey::Mouse { .. } => None,
}
}
/// Build the lines shown by the help overlay from the live keymap, so the
/// user always sees the bindings that are actually active (defaults + their
/// own `[tui.keymap.*]` overrides).
@@ -513,10 +545,33 @@ mod tests {
}
#[test]
fn open_spawn_modal_sets_overlay() {
fn new_draft_pane_opens_a_focused_draft_in_the_empty_tab() {
let mut app = make_app();
app.dispatch(Action::OpenSpawnModal);
assert!(matches!(app.state.overlay(), Some(OverlayKind::Spawn)));
app.dispatch(Action::NewDraftPane);
assert!(matches!(
app.state.active_tab().pane,
Some(PaneRef::Draft(_))
));
assert_eq!(app.state.focus, Focus::Pane);
assert_eq!(app.state.mode, Mode::Insert);
assert!(app.state.overlay().is_none(), "no overlay involved");
}
/// A tab already showing a session gets a new tab for the draft.
#[test]
fn new_draft_pane_over_a_session_opens_a_new_tab() {
let sid = Uuid::from_u128(10);
let mut app = app_with_highlighted_session(sid);
app.dispatch(Action::AttachHighlighted);
app.dispatch(Action::NewDraftPane);
assert_eq!(app.state.tabs.len(), 2);
assert_eq!(app.state.active_tab_idx, 1);
assert!(matches!(
app.state.active_tab().pane,
Some(PaneRef::Draft(_))
));
assert_eq!(app.state.tabs[0].session(), Some(sid));
}
/// Seed the catalog with one session and highlight it.
@@ -551,7 +606,7 @@ mod tests {
app.dispatch(Action::AttachHighlighted),
vec![Effect::Attach(sid)]
);
assert_eq!(app.state.active_tab().pane, Some(sid));
assert_eq!(app.state.active_tab().session(), Some(sid));
}
#[test]
@@ -584,23 +639,35 @@ mod tests {
}
#[test]
fn submitting_the_spawn_modal_carries_the_typed_fields() {
fn typing_into_the_draft_then_enter_carries_the_typed_fields() {
let mut app = make_app();
app.dispatch(Action::OpenSpawnModal);
app.spawn.cwd = "/srv/work".into();
app.spawn.command = "claude".into();
app.spawn.focused_field = crate::overlays::SpawnField::Submit;
app.dispatch(Action::NewDraftPane);
let (_, effects) = app.handle_overlay_input(&InputKey::Enter);
match effects.as_slice() {
[Effect::Spawn(req)] => {
for c in "/srv/work".chars() {
app.handle_draft_input(&InputKey::char(c));
}
app.handle_draft_input(&InputKey::Tab); // cwd → command
for c in "claude".chars() {
app.handle_draft_input(&InputKey::char(c));
}
app.handle_draft_input(&InputKey::Tab); // command → submit
match app.handle_draft_input(&InputKey::Enter) {
Some(Effect::Spawn(req)) => {
assert_eq!(req.cwd, std::path::PathBuf::from("/srv/work"));
assert_eq!(req.command, "claude");
}
other => panic!("expected one Spawn effect, got {other:?}"),
other => panic!("expected a Spawn effect, got {other:?}"),
}
}
/// 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());
}
/// Regression: `Toast` returned `Pass` and nothing ever cleared it, so a
/// failure message stayed on screen until another overlay replaced it.
#[test]
@@ -629,6 +696,39 @@ mod tests {
assert!(app.forward_to_session(&InputKey::Enter).is_none());
}
/// A draft pane is not a session: forwarding must not try to reach it.
#[test]
fn forwarding_is_a_no_op_with_a_draft_pane() {
let mut app = make_app();
app.dispatch(Action::NewDraftPane);
assert!(app.forward_to_session(&InputKey::Enter).is_none());
}
/// Regression review fix: `Paste(_)` used to be silently dropped, so a
/// paste into an attached session vanished once bracketed paste was
/// enabled. It also has to keep its DECSET 2004 markers — without them a
/// remote shell reads the payload as typed keys and runs a multi-line
/// paste line by line.
#[test]
fn a_bracketed_paste_reaches_the_session_with_its_markers() {
assert_eq!(
encode_key(&InputKey::Paste("cd /tmp/foo\nls".into())),
Some(b"\x1b[200~cd /tmp/foo\nls\x1b[201~".to_vec())
);
}
#[test]
fn mouse_events_have_no_wire_encoding() {
use crate::input::{MouseKind, Region};
assert!(encode_key(&InputKey::Mouse {
kind: MouseKind::Click,
region: Region::Pane,
col: 3,
row: 4,
})
.is_none());
}
#[test]
fn kill_highlighted_opens_confirm() {
use chrono::Utc;
+5 -1
View File
@@ -45,7 +45,7 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
("up", "SidebarPrev"),
// Catalog actions
("enter", "AttachHighlighted"),
("s", "OpenSpawnModal"),
("s", "NewDraftPane"),
("x", "KillHighlighted"),
// Tabs
("ctrl-t", "NewTab"),
@@ -155,6 +155,10 @@ mod tests {
cfg.keymap.resolve(Mode::Normal, "g t"),
Some(Action::NextTab),
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "s"),
Some(Action::NewDraftPane),
);
}
#[test]
+272
View File
@@ -0,0 +1,272 @@
//! Draft pane — the spawn form living in an ordinary pane (issue #63).
//!
//! An empty pane renders a [`DraftPane`] instead of nothing; submitting hands
//! the caller a built request and the pane is replaced with the new session,
//! matching the web client's inline draft panes (#57). Field editing goes
//! through [`crate::text_input::TextInput`]; Tab / BackTab walk the fields.
//!
//! Scope note: presets, the project list, generated names, and the worktree
//! section land with issues #64/#65 — this is cwd + command only.
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
use crate::input::InputKey;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum DraftField {
#[default]
Cwd,
Command,
Submit,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct DraftPane {
pub cwd: crate::text_input::TextInput,
pub command: crate::text_input::TextInput,
pub focused_field: DraftField,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DraftInputResult {
Consumed,
Submit,
}
impl DraftPane {
pub fn next_field(&mut self) {
self.focused_field = match self.focused_field {
DraftField::Cwd => DraftField::Command,
DraftField::Command => DraftField::Submit,
DraftField::Submit => DraftField::Cwd,
};
}
pub fn prev_field(&mut self) {
self.focused_field = match self.focused_field {
DraftField::Cwd => DraftField::Submit,
DraftField::Command => DraftField::Cwd,
DraftField::Submit => DraftField::Command,
};
}
/// Handle one keystroke while this draft owns input (Insert mode, pane
/// focused). Returns [`DraftInputResult::Submit`] when the user accepts;
/// the caller fires the POST and replaces the draft with the session.
pub fn on_input(&mut self, key: &InputKey) -> DraftInputResult {
match key {
InputKey::Tab => {
self.next_field();
DraftInputResult::Consumed
}
InputKey::BackTab => {
self.prev_field();
DraftInputResult::Consumed
}
InputKey::Enter => match self.focused_field {
DraftField::Submit => DraftInputResult::Submit,
_ => {
self.next_field();
DraftInputResult::Consumed
}
},
// Everything else — characters, motion, editing, paste — goes to
// the focused field's text input.
other => {
match self.focused_field {
DraftField::Cwd => self.cwd.handle_key(other),
DraftField::Command => self.command.handle_key(other),
DraftField::Submit => {}
}
DraftInputResult::Consumed
}
}
}
/// Draw into the pane area. `pane_focused` gates the field highlight and
/// cursor placement the same way a session pane hides its cursor when
/// unfocused.
pub fn render(&self, f: &mut Frame, area: Rect, pane_focused: bool) {
let block = Block::default().title(" draft ").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 style_for = |field: DraftField| {
if pane_focused && 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(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, style: Style| -> u16 {
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], style_for(DraftField::Cwd));
let cmd_col = draw_field(f, &self.command, rows[1], style_for(DraftField::Command));
f.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
"[ Spawn ]",
style_for(DraftField::Submit),
)])),
rows[2],
);
if pane_focused {
match self.focused_field {
DraftField::Cwd => f.set_cursor_position((cwd_col, rows[0].y)),
DraftField::Command => f.set_cursor_position((cmd_col, rows[1].y)),
DraftField::Submit => {
// The submit button is indicated by the REVERSED style; no
// cursor placement.
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn render_to_string(w: u16, h: u16, draw: impl FnOnce(&mut Frame)) -> String {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(draw).unwrap();
let buf = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
#[test]
fn draft_pane_renders_field_labels_and_values() {
let draft = DraftPane {
cwd: crate::text_input::TextInput::with_value("/x"),
command: crate::text_input::TextInput::with_value("bash"),
focused_field: DraftField::Cwd,
};
let rendered = render_to_string(80, 20, |f| draft.render(f, f.area(), true));
for s in ["cwd", "command", "draft", "Spawn", "/x", "bash"] {
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
}
}
#[test]
fn field_rotation_wraps_in_both_directions() {
let mut draft = DraftPane::default();
assert_eq!(draft.focused_field, DraftField::Cwd);
draft.next_field();
assert_eq!(draft.focused_field, DraftField::Command);
draft.next_field();
assert_eq!(draft.focused_field, DraftField::Submit);
draft.next_field();
assert_eq!(draft.focused_field, DraftField::Cwd);
draft.prev_field();
assert_eq!(draft.focused_field, DraftField::Submit);
draft.on_input(&InputKey::BackTab);
assert_eq!(draft.focused_field, DraftField::Command);
draft.on_input(&InputKey::Tab);
draft.on_input(&InputKey::Tab);
assert_eq!(draft.focused_field, DraftField::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 draft = DraftPane::default();
assert_eq!(draft.focused_field, DraftField::Cwd);
for k in [
InputKey::char('/'),
InputKey::char('t'),
InputKey::char('m'),
InputKey::char('p'),
] {
draft.on_input(&k);
}
draft.on_input(&InputKey::Left);
draft.on_input(&InputKey::Backspace);
assert_eq!(draft.cwd.value(), "/tp");
// Tab to command, then a paste and ctrl-w land there, not in cwd.
draft.on_input(&InputKey::Tab);
assert_eq!(draft.focused_field, DraftField::Command);
draft.on_input(&InputKey::Paste("/tmp/foo".into()));
assert_eq!(draft.command.value(), "/tmp/foo");
draft.on_input(&InputKey::ctrl_char('w'));
assert_eq!(draft.command.value(), "");
assert_eq!(draft.cwd.value(), "/tp", "cwd untouched while unfocused");
}
/// Enter walks forward until the submit field, where it submits.
#[test]
fn enter_walks_fields_then_submits_from_submit() {
let mut draft = DraftPane::default();
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Consumed);
assert_eq!(draft.focused_field, DraftField::Command);
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Consumed);
assert_eq!(draft.focused_field, DraftField::Submit);
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
}
/// The terminal cursor must sit right after the typed text in the focused
/// field (label is 8 chars + 1 space, block border takes column 0).
#[test]
fn render_places_the_cursor_after_the_typed_text_when_focused() {
let draft = DraftPane {
cwd: crate::text_input::TextInput::with_value("abc"),
..DraftPane::default()
};
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| draft.render(f, f.area(), true)).unwrap();
let pos = terminal.get_cursor_position().unwrap();
assert_eq!(pos, ratatui::layout::Position::new(1 + 9 + 3, 1));
// Unfocused pane: no field highlight either.
let rendered_unfocused = render_to_string(80, 20, |f| draft.render(f, f.area(), false));
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| draft.render(f, f.area(), false)).unwrap();
let buf = terminal.backend().buffer();
assert!(
!buf.content()
.iter()
.any(|c| c.modifier.contains(ratatui::style::Modifier::REVERSED)),
"unfocused draft must not highlight fields:\n{rendered_unfocused}"
);
}
}
+32
View File
@@ -34,6 +34,12 @@ pub enum InputKey {
Down,
Left,
Right,
Home,
End,
Delete,
/// Bracketed paste: crossterm delivers the whole payload as one event, so
/// text fields can insert it as a single operation.
Paste(String),
Mouse {
kind: MouseKind,
region: Region,
@@ -126,6 +132,12 @@ impl InputKey {
InputKey::Down => "down".into(),
InputKey::Left => "left".into(),
InputKey::Right => "right".into(),
InputKey::Home => "home".into(),
InputKey::End => "end".into(),
InputKey::Delete => "delete".into(),
// Pastes are not bindable; the canonical form exists only to
// satisfy exhaustiveness and never round-trips through `parse`.
InputKey::Paste(_) => "paste".into(),
InputKey::Mouse { kind, region, .. } => {
let k = match kind {
MouseKind::Click => "click",
@@ -191,6 +203,9 @@ impl InputKey {
"down" => return Ok(InputKey::Down),
"left" => return Ok(InputKey::Left),
"right" => return Ok(InputKey::Right),
"home" => return Ok(InputKey::Home),
"end" => return Ok(InputKey::End),
"delete" => return Ok(InputKey::Delete),
_ => {}
}
// Modifier chain: ctrl-, alt-, shift- prefixes, then a single char.
@@ -262,9 +277,13 @@ where
KeyCode::Down => InputKey::Down,
KeyCode::Left => InputKey::Left,
KeyCode::Right => InputKey::Right,
KeyCode::Home => InputKey::Home,
KeyCode::End => InputKey::End,
KeyCode::Delete => InputKey::Delete,
_ => return None,
})
}
CtEvent::Paste(text) => Some(InputKey::Paste(text.clone())),
CtEvent::Mouse(MouseEvent {
kind, column, row, ..
}) => {
@@ -578,12 +597,25 @@ mod tests {
"down",
"left",
"right",
"home",
"end",
"delete",
] {
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s, "round-trip {s:?}");
}
}
/// A bracketed paste must arrive as one key carrying the whole payload,
/// not a burst of per-character keys.
#[test]
fn canonicalise_paste_event_carries_the_whole_payload() {
use crossterm::event::Event as CtEvent;
let ev = CtEvent::Paste("/srv/work/maestro".into());
let k = canonicalise(&ev, |_, _| Region::Unknown).unwrap();
assert_eq!(k, InputKey::Paste("/srv/work/maestro".into()));
}
#[test]
fn canonical_round_trips_mouse() {
let s = "mouse:click(pane)";
+2
View File
@@ -7,6 +7,7 @@ pub mod app;
pub mod catalog;
pub mod cli;
pub mod config;
pub mod draft;
pub mod emulators;
pub mod input;
pub mod keymap;
@@ -18,6 +19,7 @@ pub mod sidebar;
pub mod state;
pub mod tabs;
pub mod terminal;
pub mod text_input;
use std::path::PathBuf;
use std::time::Duration;
+7 -204
View File
@@ -1,163 +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: String,
pub command: String,
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
}
},
InputKey::Char { c, .. } => {
match self.focused_field {
SpawnField::Cwd => self.cwd.push(*c),
SpawnField::Command => self.command.push(*c),
SpawnField::Submit => {}
}
SpawnInputResult::Consumed
}
InputKey::Backspace => {
match self.focused_field {
SpawnField::Cwd => {
self.cwd.pop();
}
SpawnField::Command => {
self.command.pop();
}
_ => {}
}
SpawnInputResult::Consumed
}
_ => 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()
}
};
let field_row = |label: &str, value: &str, style: Style| {
Paragraph::new(Line::from(vec![
ratatui::text::Span::raw(format!("{label:8} ")),
ratatui::text::Span::styled(value.to_string(), style),
]))
};
f.render_widget(
field_row("cwd:", &self.cwd, highlight(SpawnField::Cwd)),
rows[0],
);
f.render_widget(
field_row("command:", &self.command, highlight(SpawnField::Command)),
rows[1],
);
f.render_widget(
Paragraph::new(Line::from(vec![ratatui::text::Span::styled(
"[ Spawn ]",
highlight(SpawnField::Submit),
)])),
rows[2],
);
// Place the cursor at the end of the currently-focused text field so
// the user can see where their typing lands. Label is fixed-width
// 8 chars + 1 space = column 9 inside the row.
let cursor_col_for = |value: &str| -> u16 {
inner
.x
.saturating_add(9)
.saturating_add(value.chars().count() as u16)
};
match self.focused_field {
SpawnField::Cwd => f.set_cursor_position((cursor_col_for(&self.cwd), rows[0].y)),
SpawnField::Command => {
f.set_cursor_position((cursor_col_for(&self.command), 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,
@@ -264,33 +117,6 @@ mod tests {
out
}
#[test]
fn spawn_modal_renders_field_labels() {
let modal = SpawnModal {
cwd: "/x".into(),
command: "bash".into(),
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 {
@@ -308,34 +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);
}
assert!(rendered.contains("NewDraftPane"));
}
}
+60 -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,57 @@ 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", "cwd:", "command:", "[ Spawn ]"] {
assert!(out.contains(s), "missing {s:?}:\n{out}");
}
}
}
+109 -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,31 @@ pub enum Focus {
Pane,
}
/// What a tab's pane slot holds: a live session, or an inline draft form.
#[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 +126,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 +136,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 +167,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 +334,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 +360,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 +370,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 +380,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 +393,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);
}
}
+15 -3
View File
@@ -5,7 +5,9 @@
use std::io;
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::event::{
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
@@ -26,7 +28,12 @@ impl TerminalGuard {
pub fn enter() -> Result<Self, TerminalError> {
enable_raw_mode()?;
let mut out = io::stdout();
execute!(out, EnterAlternateScreen, EnableMouseCapture)?;
execute!(
out,
EnterAlternateScreen,
EnableMouseCapture,
EnableBracketedPaste
)?;
Ok(TerminalGuard)
}
@@ -34,7 +41,12 @@ impl TerminalGuard {
/// call it without producing weird errors.
fn restore() {
let mut out = io::stdout();
let _ = execute!(out, DisableMouseCapture, LeaveAlternateScreen);
let _ = execute!(
out,
DisableBracketedPaste,
DisableMouseCapture,
LeaveAlternateScreen
);
let _ = disable_raw_mode();
}
}
+462
View File
@@ -0,0 +1,462 @@
//! Single-line text-input primitive shared by every TUI form field.
//!
//! Owns a `String`, a character-indexed cursor, and horizontal scroll state.
//! All editing goes through [`TextInput::handle_key`] so callers feed the same
//! canonical [`InputKey`] stream the rest of the app uses; bracketed paste
//! arrives as one [`InputKey::Paste`] and is inserted as a single operation.
//!
//! This is the one piece of the TUI form layer that unit-tests without a
//! terminal: drive it with `InputKey` sequences and inspect value/cursor.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use crate::input::InputKey;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct TextInput {
value: String,
/// Cursor position as a char offset into `value`.
cursor: usize,
/// Char offset of the leftmost visible cell. A `Cell` so `render` can
/// re-clamp it without `&mut self` — widgets are drawn through shared
/// references in this codebase.
scroll: std::cell::Cell<usize>,
}
impl TextInput {
pub fn with_value(value: impl Into<String>) -> Self {
let mut input = Self {
value: value.into(),
..Self::default()
};
input.cursor = input.value.chars().count();
input
}
pub fn value(&self) -> &str {
&self.value
}
pub fn cursor(&self) -> usize {
self.cursor
}
/// Char offset of the leftmost visible cell; only meaningful after
/// [`TextInput::render`] has clamped it to the field width.
pub fn scroll_offset(&self) -> usize {
self.scroll.get()
}
/// Insert `text` at the cursor as one operation. Used both for typed
/// characters and for whole bracketed-paste payloads.
pub fn insert(&mut self, text: &str) {
let at = self.byte_offset(self.cursor);
self.value.insert_str(at, text);
self.cursor += text.chars().count();
}
pub fn handle_key(&mut self, key: &InputKey) {
match key {
InputKey::Char {
c,
ctrl: false,
alt: false,
..
} => self.insert(&c.to_string()),
InputKey::Char {
c: 'w', ctrl: true, ..
} => self.delete_word_backwards(),
InputKey::Char {
c: 'k', ctrl: true, ..
} => self.kill_to_end(),
// Emacs-style word motion; ctrl-arrow reaches us as a plain
// Left/Right today, so these are the dependable binding.
InputKey::Char {
c: 'b', ctrl: true, ..
} => self.cursor = self.word_left(),
InputKey::Char {
c: 'f', ctrl: true, ..
} => self.cursor = self.word_right(),
InputKey::Left => {
self.cursor = self.cursor.saturating_sub(1);
}
InputKey::Right => {
if self.cursor < self.value.chars().count() {
self.cursor += 1;
}
}
InputKey::Home => self.cursor = 0,
InputKey::End => self.cursor = self.value.chars().count(),
InputKey::Backspace => {
if self.cursor > 0 {
let at = self.byte_offset(self.cursor - 1);
let end = self.byte_offset(self.cursor);
self.value.replace_range(at..end, "");
self.cursor -= 1;
}
}
InputKey::Delete => {
if self.cursor < self.value.chars().count() {
let at = self.byte_offset(self.cursor);
let end = self.byte_offset(self.cursor + 1);
self.value.replace_range(at..end, "");
}
}
InputKey::Paste(text) => self.insert(text),
_ => {}
}
}
/// Draw into `area` (expected one row tall), scrolling horizontally so the
/// cursor stays visible, and return the screen column where the caller
/// should place the terminal cursor.
pub fn render(&self, area: Rect, buf: &mut Buffer, style: Style) -> u16 {
let width = area.width as usize;
let len = self.value.chars().count();
let scroll = self.scroll.get().min(len);
let scroll = if self.cursor < scroll {
self.cursor
} else if width > 0 && self.cursor.saturating_sub(scroll) >= width {
self.cursor + 1 - width
} else {
scroll
};
self.scroll.set(scroll);
for (col, c) in self.value.chars().skip(scroll).take(width).enumerate() {
buf[(area.x + col as u16, area.y)]
.set_symbol(&c.to_string())
.set_style(style);
}
area.x + self.cursor.saturating_sub(scroll) as u16
}
fn byte_offset(&self, char_index: usize) -> usize {
self.value
.char_indices()
.nth(char_index)
.map(|(b, _)| b)
.unwrap_or(self.value.len())
}
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
/// Start of the word before the cursor, skipping any separators first —
/// emacs `backward-word`.
fn word_left(&self) -> usize {
let chars: Vec<char> = self.value.chars().collect();
let mut i = self.cursor;
while i > 0 && !Self::is_word_char(chars[i - 1]) {
i -= 1;
}
while i > 0 && Self::is_word_char(chars[i - 1]) {
i -= 1;
}
i
}
/// Start of the word after the cursor, skipping the rest of the current
/// word first — emacs `forward-word`.
fn word_right(&self) -> usize {
let chars: Vec<char> = self.value.chars().collect();
let len = chars.len();
let mut i = self.cursor;
while i < len && !Self::is_word_char(chars[i]) {
i += 1;
}
while i < len && Self::is_word_char(chars[i]) {
i += 1;
}
i
}
fn kill_to_end(&mut self) {
let at = self.byte_offset(self.cursor);
self.value.truncate(at);
}
/// Shell-style ctrl-w: kill back to the previous whitespace, taking any
/// trailing whitespace run with it — so `cd /tmp/foo` loses the whole
/// path segment in one keystroke.
fn delete_word_backwards(&mut self) {
let chars: Vec<char> = self.value.chars().collect();
let mut i = self.cursor;
while i > 0 && chars[i - 1].is_whitespace() {
i -= 1;
}
while i > 0 && !chars[i - 1].is_whitespace() {
i -= 1;
}
let at = self.byte_offset(i);
let end = self.byte_offset(self.cursor);
self.value.replace_range(at..end, "");
self.cursor = i;
}
}
#[cfg(test)]
mod tests {
use super::*;
use InputKey::{Backspace, Delete, End, Home, Left, Right};
fn typed(s: &str) -> Vec<InputKey> {
s.chars().map(InputKey::char).collect()
}
fn fed(keys: &[InputKey]) -> TextInput {
let mut input = TextInput::default();
for k in keys {
input.handle_key(k);
}
input
}
fn ctrl(c: char) -> InputKey {
InputKey::ctrl_char(c)
}
#[test]
fn typing_lands_in_order() {
let input = fed(&typed("hello"));
assert_eq!(input.value(), "hello");
assert_eq!(input.cursor(), 5);
}
#[test]
fn backspace_removes_the_last_typed_char() {
let mut input = fed(&typed("hello"));
input.handle_key(&Backspace);
assert_eq!(input.value(), "hell");
assert_eq!(input.cursor(), 4);
}
#[test]
fn left_then_insert_fixes_a_middle_typo() {
let mut input = fed(&typed("helo"));
input.handle_key(&Left);
input.handle_key(&Left);
input.handle_key(&InputKey::char('l'));
assert_eq!(input.value(), "hello");
assert_eq!(input.cursor(), 3);
}
#[test]
fn motion_keys_stop_at_the_edges() {
let mut input = fed(&typed("abc"));
for _ in 0..5 {
input.handle_key(&Left);
}
assert_eq!(input.cursor(), 0);
input.handle_key(&Left);
assert_eq!(input.cursor(), 0);
for _ in 0..5 {
input.handle_key(&Right);
}
assert_eq!(input.cursor(), 3);
}
#[test]
fn home_and_end_jump_to_the_ends() {
let mut input = fed(&typed("abcdef"));
input.handle_key(&Home);
assert_eq!(input.cursor(), 0);
input.handle_key(&InputKey::char('x'));
assert_eq!(input.value(), "xabcdef");
input.handle_key(&End);
input.handle_key(&InputKey::char('!'));
assert_eq!(input.value(), "xabcdef!");
}
/// Cursor starts after the final `/`; word-left lands on word starts,
/// never inside a separator run.
#[test]
fn ctrl_b_walks_left_one_word_at_a_time() {
let mut input = TextInput::with_value("/tmp/foo bar-baz/");
assert_eq!(input.cursor(), 17);
for expected in [13, 9, 5, 1, 0, 0] {
input.handle_key(&ctrl('b'));
assert_eq!(input.cursor(), expected);
}
}
#[test]
fn ctrl_f_walks_right_one_word_at_a_time() {
let mut input = TextInput::with_value("/tmp/foo bar");
input.handle_key(&Home);
for expected in [4, 8, 12, 12] {
input.handle_key(&ctrl('f'));
assert_eq!(input.cursor(), expected);
}
}
#[test]
fn backspace_deletes_before_the_cursor() {
let mut input = fed(&typed("abcdef"));
input.handle_key(&Left);
input.handle_key(&Left);
input.handle_key(&Left);
input.handle_key(&Backspace);
assert_eq!(input.value(), "abdef");
assert_eq!(input.cursor(), 2);
}
#[test]
fn backspace_on_empty_input_is_a_noop() {
let mut input = TextInput::default();
input.handle_key(&Backspace);
assert_eq!(input.value(), "");
assert_eq!(input.cursor(), 0);
}
#[test]
fn delete_removes_the_char_under_the_cursor() {
let mut input = fed(&typed("abcdef"));
input.handle_key(&Home);
input.handle_key(&Delete);
assert_eq!(input.value(), "bcdef");
assert_eq!(input.cursor(), 0);
}
#[test]
fn delete_past_the_end_is_a_noop() {
let mut input = TextInput::with_value("abc");
input.handle_key(&End);
input.handle_key(&Delete);
assert_eq!(input.value(), "abc");
}
/// `cd /tmp/foo` with ctrl-w leaves `cd ` — the whole path segment is one
/// word to a shell (readline kills back to whitespace, spaces included).
#[test]
fn ctrl_w_deletes_the_word_behind_the_cursor() {
let mut input = fed(&[typed("cd ").as_slice(), typed("/tmp/foo").as_slice()].concat());
input.handle_key(&ctrl('w'));
assert_eq!(input.value(), "cd ");
assert_eq!(input.cursor(), 3);
}
#[test]
fn ctrl_w_stops_before_the_separator_run() {
let mut input = TextInput::with_value("a b");
input.cursor = 5;
input.handle_key(&ctrl('w'));
assert_eq!(input.value(), "a ");
assert_eq!(input.cursor(), 4);
}
#[test]
fn ctrl_k_kills_everything_after_the_cursor() {
let mut input = fed(&typed("spawn --debug extra"));
for _ in 0..14 {
input.handle_key(&Left);
}
input.handle_key(&ctrl('k'));
assert_eq!(input.value(), "spawn");
assert_eq!(input.cursor(), 5);
}
/// A paste arrives as one event carrying the whole payload and must land
/// at the cursor as a single insertion, not per-character.
#[test]
fn paste_inserts_whole_payload_at_the_cursor() {
let mut input = fed(&typed("cd "));
input.handle_key(&InputKey::Paste("/srv/work".into()));
assert_eq!(input.value(), "cd /srv/work");
assert_eq!(input.cursor(), 12);
// What follows still behaves like ordinary typed text.
input.handle_key(&InputKey::char('/'));
assert_eq!(input.value(), "cd /srv/work/");
}
#[test]
fn paste_between_existing_text_keeps_both_halves() {
let mut input = TextInput::with_value("ac");
input.handle_key(&Left);
input.handle_key(&InputKey::Paste("b×".into()));
assert_eq!(input.value(), "ab×c");
assert_eq!(input.cursor(), 3, "cursor counts chars, not bytes");
}
#[test]
fn rendering_an_empty_field_points_at_its_first_column() {
let input = TextInput::default();
let area = Rect::new(10, 5, 20, 1);
let mut buf = Buffer::empty(area);
let col = input.render(area, &mut buf, Style::default());
assert_eq!(col, 10);
}
#[test]
fn rendering_reports_the_visible_cursor_column() {
let mut input = TextInput::with_value("hello");
input.handle_key(&Left);
input.handle_key(&Left);
let area = Rect::new(10, 5, 20, 1);
let mut buf = Buffer::empty(area);
let col = input.render(area, &mut buf, Style::default());
assert_eq!(col, 13);
for (i, expected) in ["h", "e", "l", "l", "o"].iter().enumerate() {
assert_eq!(buf[(10 + i as u16, 5)].symbol(), *expected);
}
}
#[test]
fn rendering_a_value_wider_than_the_field_shows_its_tail() {
let value = "0123456789abcdefghij";
let input = TextInput::with_value(value);
let area = Rect::new(0, 0, 10, 1);
let mut buf = Buffer::empty(area);
let col = input.render(area, &mut buf, Style::default());
assert_eq!(col, 9, "cursor sits on the last visible column");
for (i, expected) in value.chars().skip(input.scroll_offset()).enumerate() {
assert_eq!(
buf[(i as u16, 0)].symbol(),
expected.to_string(),
"cell {i}"
);
}
}
/// Moving left into the hidden region must scroll the window back so the
/// cursor never disappears off the left edge.
#[test]
fn moving_left_into_the_hidden_region_scrolls_back() {
let value = "0123456789abcdefghij";
let mut input = TextInput::with_value(value);
let area = Rect::new(0, 0, 10, 1);
let mut buf = Buffer::empty(area);
input.render(area, &mut buf, Style::default());
assert_eq!(input.cursor(), 20);
input.handle_key(&Home);
for _ in 0..5 {
input.handle_key(&Right);
}
let mut buf = Buffer::empty(area);
let col = input.render(area, &mut buf, Style::default());
assert_eq!(input.cursor(), 5);
assert_eq!(
input.value().chars().nth(input.scroll_offset()),
Some('5'),
"window scrolled back to the cursor"
);
assert_eq!(col, 0);
assert_eq!(buf[(0, 0)].symbol(), "5");
}
#[test]
fn shrinking_the_value_clamps_scroll_and_cursor() {
let mut input = TextInput::with_value("0123456789abcdefghij");
input.handle_key(&Home);
input.handle_key(&ctrl('k'));
let area = Rect::new(0, 0, 10, 1);
let mut buf = Buffer::empty(area);
let col = input.render(area, &mut buf, Style::default());
assert_eq!(input.value(), "");
assert_eq!(col, 0);
}
}
+3 -2
View File
@@ -280,8 +280,9 @@ maestro daemon # foreground; or wire your own service manager
```
Every setting has a default, so `~/.config/maestro/config.toml` is optional —
create it only to change the bind address, worktree root, spawn presets, or TUI
keymap.
create it only to change the bind address, spawn presets, or TUI
keymap. (Worktrees live inside their base repos at `<repo>/worktrees/<branch>`;
set `[daemon].worktree_root` only to override that.)
## Non-Linux hosts
+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";
+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);
+35 -12
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")) {
@@ -113,7 +136,7 @@ export function seedMock(store: CatalogStore): void {
});
// `?mock&spawn` auto-opens the spawn modal for visual review, with the spawn
// backend endpoints stubbed so presets/recents/gitroot all render.
// backend endpoints stubbed so presets/projects/gitroot all render.
if (location.search.includes("spawn")) {
stubSpawnApi();
void import("./spawn.js").then((m) => m.openSpawnModal());
+9 -3
View File
@@ -152,10 +152,16 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
leaf.draft = values;
},
onSuccess: (session) => {
leaf.sessionId = session.id;
leaf.draft = null;
// 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);
if (leaves(t.root).includes(leaf)) t.focusedLeafId = leaf.id;
const attached = leaves(t.root).includes(leaf);
if (attached) {
leaf.sessionId = session.id;
leaf.draft = null;
t.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)}`;
}
+26
View File
@@ -99,4 +99,30 @@ describe("serializeLayout", () => {
}
expect(leaves(restored!.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1", null]);
});
it("keeps the draft out of the stored blob itself, not just out of hydrate", () => {
// A draft pane only exists once a tab has split, so the strip has to walk
// into split branches; asserting on the restored layout alone passes even
// when the draft was written to localStorage, because hydrate drops it.
const layout = hydrate({
tabs: [
{
id: "t",
root: {
kind: "split",
dir: "row",
a: { kind: "leaf", id: "l1", sessionId: "s1" },
b: { kind: "leaf", id: "l2", sessionId: null },
},
},
],
activeTabId: "t",
}) as Layout;
const draftLeaf = leaves(layout.tabs[0].root).find((l) => !l.sessionId)!;
draftLeaf.draft = { command: "claude", cwd: "/home/user/secret-project", name: "amber-fox" };
const raw = serializeLayout(layout);
expect(raw).not.toContain("secret-project");
expect(raw).not.toContain("amber-fox");
});
});
+6 -1
View File
@@ -34,7 +34,12 @@ export function serializeLayout(layout: Layout): string {
}
function stripDrafts(node: Node | null): Node | null {
if (!node || node.kind !== "leaf") return node;
if (!node) return node;
// Recurse: a draft pane only exists once a tab has split, so a shallow strip
// would persist every half-filled form it was meant to drop.
if (node.kind === "split") {
return { ...node, a: stripDrafts(node.a)!, b: stripDrafts(node.b)! };
}
return { ...node, draft: null };
}
+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)}`;
}
+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");
});
+313 -93
View File
@@ -1,10 +1,21 @@
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";
/** 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. */
@@ -14,7 +25,7 @@ export interface SpawnFormSeed {
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",
};
}
@@ -87,7 +107,7 @@ export interface SpawnFormOptions {
onSuccess: (session: Session) => void;
}
/** The spawn form itself — preset tiles, recent-dirs picker, name, worktree.
/** The spawn form itself — preset tiles, project picker, name, worktree.
* Shared by the modal (`spawn.ts`) and inline draft panes (issue #57). */
export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
const prefill = opts.seed ?? {};
@@ -103,7 +123,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 +148,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>
@@ -147,17 +179,21 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
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 +202,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 +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();
});
@@ -202,21 +290,36 @@ 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
// ---- command tiles ---------------------------------------------------
function selectPreset(id: string): void {
// `focus` is false for the two initial applications (construction, and again
// when `/api/info` resolves): a draft pane must not grab the caret out from
// under the operator seconds after it mounts.
function selectPreset(id: string, focus = true): void {
selectedPresetId = id;
const preset = presets.find((p) => p.id === id);
const isCustom = !preset || preset.command === "";
cmdRaw.hidden = !isCustom;
if (isCustom) {
command = cmdRaw.value.trim();
cmdRaw.focus();
if (focus) cmdRaw.focus();
} else {
command = preset!.command;
}
@@ -270,17 +373,17 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
if (wanted) {
const match = presets.find((p) => p.command === wanted && p.command !== "");
if (match) {
selectPreset(match.id);
selectPreset(match.id, false);
} else {
const custom = presets.find((p) => p.command === "");
cmdRaw.value = wanted;
selectPreset(custom ? custom.id : "custom");
selectPreset(custom ? custom.id : "custom", false);
command = wanted;
}
return;
}
const first = presets.find((p) => p.command !== "");
if (first) selectPreset(first.id);
if (first) selectPreset(first.id, false);
}
// ---- working-directory picker ---------------------------------------
@@ -288,21 +391,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,
@@ -310,75 +417,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);
showRawCwd(seedCwd, false);
}
seedCwd = null;
} else if (dirs.length === 0) {
// No history: default to raw input prefilled with the daemon's home dir.
showRawCwd(homeDir);
} 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();
// 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;
}
@@ -386,11 +519,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) {
@@ -411,45 +574,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);
@@ -464,8 +683,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,
);
@@ -488,6 +707,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);
});
});
+56 -7
View File
@@ -1448,7 +1448,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 +1460,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 +1482,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 +1528,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 +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;
@@ -2079,6 +2122,12 @@ textarea {
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;