diff --git a/CLAUDE.md b/CLAUDE.md index cfa0ac5..50ec101 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,10 @@ nix/ # Home Manager module (`services.maestro`) See `docs/INSTALL.md` for end-user install instructions. +`docs/WORKTREES.md` is the contract for session worktrees — placement, +collisions, what blocks a kill, what `--force` retains, and what reclaim +touches. Read it before changing anything that creates or removes one. + `nix run .#dev` (or `just dev`) brings up the whole local stack under process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only diff --git a/Cargo.lock b/Cargo.lock index 6b2f1e2..646ccfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1102,6 +1102,7 @@ dependencies = [ "futures-util", "libc", "maestro-config", + "maestro-names", "maestro-protocol", "reqwest", "serde", @@ -1146,6 +1147,13 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "maestro-names" +version = "0.1.0" +dependencies = [ + "rand 0.8.6", +] + [[package]] name = "maestro-protocol" version = "0.1.0" @@ -1185,6 +1193,7 @@ dependencies = [ "maestro-protocol", "maestro-testing", "ratatui", + "reqwest", "serde_json", "thiserror 1.0.69", "tokio", diff --git a/Cargo.toml b/Cargo.toml index a5cc5ef..7fb7deb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/DESIGN.md b/DESIGN.md index 282b754..7cb8220 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -79,16 +79,15 @@ no bounce. Tokens `--dur-fast`/`--dur`/`--dur-slow`. `connecting` state pill pulses. - Interaction: row hover/focus, button press (1px), input focus ring, chevron rotation, theme-toggle sun/moon swap. -- Modal: bottom-sheet slide-up on mobile, scale-pop on ≥600px. - Every animation has a `prefers-reduced-motion: reduce` path (pulses off, transitions near-instant). ## Layout & structure Mobile-first, structural responsiveness (not fluid type). Single `≤520px` -breakpoint stacks the header (full-width search, wrapped actions). `≥600px` -switches the spawn modal from bottom-sheet to centered dialog. A faint slate grid texture sits on `--bg` as -terminal atmosphere (very low contrast, not decoration). +breakpoint stacks the header (full-width search, wrapped actions). A faint +slate grid texture sits on `--bg` as terminal atmosphere (very low contrast, +not decoration). ## Components @@ -101,8 +100,10 @@ Three surfaces, all DOM built in TS, styled by class: no-sessions, no-search-match. - **Attach** — header (back, `session `, live/closed/error state pill) over a framed dark xterm with a green cursor. -- **Spawn modal** — bottom-sheet/dialog form; mono inputs with green focus - ring; grouped worktree fieldset. +- **Draft pane** — the spawn form mounted inline in an empty split pane, + focusable like any session pane; mono inputs with green focus ring; grouped + worktree fieldset. Spawn opens one (reusing an empty focused pane, else + splitting), and "Spawn similar" seeds one from a session's dir + command. Interactive elements ship default / hover / focus-visible / active / disabled states; the accent green is used only for action, selection, focus, and live @@ -111,5 +112,5 @@ status — never decoration. ## Dev review aids (DEV-only, stripped from production) `web/src/mock.ts` + query params seed a representative catalog without a running -daemon: `?mock` (seed sessions), `?mock&spawn` (open the spawn modal), +daemon: `?mock` (seed sessions), `?mock&spawn` (open a draft pane), `?theme=light|dark` (pin a theme). Guarded by `import.meta.env.DEV`. diff --git a/crates/maestro-cli/src/cli.rs b/crates/maestro-cli/src/cli.rs index 660f494..b1c7ac2 100644 --- a/crates/maestro-cli/src/cli.rs +++ b/crates/maestro-cli/src/cli.rs @@ -41,6 +41,36 @@ pub enum Command { Attach(AttachArgs), /// Kill a session by ID. Kill(KillArgs), + /// List retained worktrees, or reclaim one by ID (#86). + Worktrees(WorktreesArgs), +} + +/// `maestro worktrees ` +#[derive(Debug, clap::Args)] +pub struct WorktreesArgs { + #[command(subcommand)] + pub command: WorktreesCommand, +} + +#[derive(Debug, Subcommand)] +pub enum WorktreesCommand { + /// List retained worktrees and what is in the way of reclaiming each. + List(ListWorktreesArgs), + /// Reclaim one retained worktree: removes the directory, never the branch. + Reclaim(ReclaimArgs), +} + +#[derive(Debug, clap::Args)] +pub struct ListWorktreesArgs { + /// Output as JSON instead of a table. + #[arg(long)] + pub json: bool, +} + +#[derive(Debug, clap::Args)] +pub struct ReclaimArgs { + /// Retained worktree ID to reclaim. Required — there is no reclaim-all. + pub id: Uuid, } #[derive(Debug, clap::Args)] @@ -67,9 +97,11 @@ pub struct SpawnArgs { /// Branch name for the worktree (required with --worktree-from). #[arg(long)] pub branch: Option, - /// 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 cleanup_on_kill: bool, + pub existing_branch: bool, /// After spawning, attach immediately. #[arg(long, short = 'a')] pub attach: bool, @@ -88,6 +120,11 @@ pub struct AttachArgs { pub struct KillArgs { /// Session ID to kill. pub session_id: Uuid, + /// End the session even when its worktree holds unsaved work (#81). The + /// worktree is retained — never removed — and enters the retained store; + /// the output says where the work went. + #[arg(long)] + pub force: bool, } fn parse_kv(s: &str) -> Result<(String, String), String> { @@ -126,7 +163,6 @@ mod tests { "/repos/foo", "--branch", "feat/x", - "--cleanup-on-kill", "-e", "FOO=bar", "-e", @@ -137,7 +173,6 @@ mod tests { Command::Spawn(a) => { assert_eq!(a.command, "bash"); assert_eq!(a.branch.as_deref(), Some("feat/x")); - assert!(a.cleanup_on_kill); assert_eq!( a.env, vec![("FOO".into(), "bar".into()), ("BAZ".into(), "qux".into()),] @@ -147,6 +182,31 @@ mod tests { } } + /// Review fix: without a flag the CLI always sent `create_branch: true`, + /// so the daemon's existing-branch path was unreachable and spawning onto + /// a branch that already existed just failed. + #[test] + fn parses_spawn_onto_an_existing_branch() { + let cli = Cli::try_parse_from([ + "maestro", + "spawn", + "--cwd", + "/tmp", + "--command", + "bash", + "--worktree-from", + "/repo", + "--branch", + "feat/existing", + "--existing-branch", + ]) + .unwrap(); + let Command::Spawn(a) = cli.command else { + panic!("expected spawn") + }; + assert!(a.existing_branch); + } + #[test] fn spawn_worktree_requires_branch() { let err = Cli::try_parse_from([ @@ -173,6 +233,32 @@ mod tests { } } + #[test] + fn parses_kill_with_force() { + let id = "01234567-89ab-cdef-0123-456789abcdef"; + let cli = Cli::try_parse_from(["maestro", "kill", "--force", id]).unwrap(); + match cli.command { + Command::Kill(a) => { + assert!(a.force); + assert_eq!(a.session_id.to_string(), id); + } + _ => panic!("wrong subcommand"), + } + } + + #[test] + fn kill_force_defaults_false() { + let id = "01234567-89ab-cdef-0123-456789abcdef"; + let cli = Cli::try_parse_from(["maestro", "kill", id]).unwrap(); + match cli.command { + Command::Kill(a) => { + assert!(!a.force); + assert_eq!(a.session_id.to_string(), id); + } + _ => panic!("wrong subcommand"), + } + } + #[test] fn rejects_env_without_equals() { let err = Cli::try_parse_from([ @@ -188,4 +274,56 @@ mod tests { .unwrap_err(); assert!(err.to_string().contains("KEY=VALUE")); } + + #[test] + fn parses_worktrees_list() { + let cli = Cli::try_parse_from(["maestro", "worktrees", "list", "--json"]).unwrap(); + match cli.command { + Command::Worktrees(w) => match w.command { + WorktreesCommand::List(a) => assert!(a.json), + _ => panic!("wrong worktrees subcommand"), + }, + _ => panic!("wrong subcommand"), + } + } + + #[test] + fn parses_worktrees_reclaim() { + let id = "01234567-89ab-cdef-0123-456789abcdef"; + let cli = Cli::try_parse_from(["maestro", "worktrees", "reclaim", id]).unwrap(); + match cli.command { + Command::Worktrees(w) => match w.command { + WorktreesCommand::Reclaim(a) => assert_eq!(a.id.to_string(), id), + _ => panic!("wrong worktrees subcommand"), + }, + _ => panic!("wrong subcommand"), + } + } + + /// Contract rule: reclaim refuses to run without an explicit target. + /// There is no reclaim-everything convenience. + #[test] + fn reclaim_requires_an_explicit_target() { + let err = Cli::try_parse_from(["maestro", "worktrees", "reclaim"]).unwrap_err(); + assert!( + matches!( + err.kind(), + clap::error::ErrorKind::InvalidSubcommand + | clap::error::ErrorKind::MissingRequiredArgument + ), + "{err}" + ); + } + + #[test] + fn worktrees_without_subcommand_shows_help() { + let err = Cli::try_parse_from(["maestro", "worktrees"]).unwrap_err(); + assert!( + matches!( + err.kind(), + clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand + ), + "{err}" + ); + } } diff --git a/crates/maestro-cli/src/dispatch.rs b/crates/maestro-cli/src/dispatch.rs index b1a6fed..3e2b36a 100644 --- a/crates/maestro-cli/src/dispatch.rs +++ b/crates/maestro-cli/src/dispatch.rs @@ -9,8 +9,9 @@ use std::path::{Path, PathBuf}; use maestro_protocol::DaemonClient; use crate::attach::{parse_detach_key, run_attach_url}; -use crate::cli::Command; +use crate::cli::{Command, WorktreesCommand}; use crate::sessions::{run_kill, run_list, run_spawn}; +use crate::worktrees::{run_list as run_worktree_list, run_reclaim as run_worktree_reclaim}; use maestro_config::{default_config_path, MaestroConfigFile}; #[derive(Debug, thiserror::Error)] @@ -61,9 +62,41 @@ pub async fn dispatch( run_attach_url(&daemon.attach_url(args.session_id)?, detach).await?; } Command::Kill(args) => { - run_kill(&daemon, &args).await?; + // #83: warn BEFORE the attempt, so the refusal is not a surprise. + // Best-effort: an unreachable list must not block the kill. + if !args.force { + warn_if_blocked(&daemon, args.session_id).await; + } + let outcome = run_kill(&daemon, &args).await?; println!("killed {}", args.session_id); + if let Some(w) = outcome.retained { + println!( + "worktree retained at {} (branch {}, force-killed)", + w.path.display(), + w.branch + ); + } } + Command::Worktrees(args) => match args.command { + WorktreesCommand::List(list_args) => { + let mut stdout = std::io::stdout().lock(); + run_worktree_list(&daemon, &list_args, &mut stdout).await?; + } + WorktreesCommand::Reclaim(reclaim_args) => { + // The daemon removes the directory and the store entry; the + // branch is never deleted — worth saying out loud (#86). + let entry = run_worktree_reclaim(&daemon, &reclaim_args).await?; + match entry { + Some(wt) => println!( + "reclaimed {} ({})\nbranch '{}' kept", + wt.id, + wt.path.display(), + wt.branch + ), + None => println!("reclaimed {}\nbranch kept", reclaim_args.id), + } + } + }, } Ok(()) } @@ -71,6 +104,25 @@ pub async fn dispatch( /// Default detach byte, matching `maestro attach --detach-key`'s `^q`. const CTRL_Q: u8 = 0x11; +/// #83: say "this will be refused" before the operator commits to it, in the +/// same words [`maestro_protocol::InTheWay`] uses everywhere else. +async fn warn_if_blocked(daemon: &DaemonClient, id: uuid::Uuid) { + let Ok(sessions) = daemon.list_sessions().await else { + return; + }; + if let Some(b) = sessions + .iter() + .find(|s| s.id == id) + .and_then(|s| s.blocked_work) + { + if !b.is_clear() { + eprintln!( + "warning: this kill will be refused — the session's worktree holds unsaved work ({b}); use --force to retain it" + ); + } + } +} + /// HTTP client pointed at the daemon's API. That surface needs no auth — /// reaching `bind_addr` is the authorization. #[allow(clippy::result_large_err)] diff --git a/crates/maestro-cli/src/lib.rs b/crates/maestro-cli/src/lib.rs index a6a9cc9..ce3599d 100644 --- a/crates/maestro-cli/src/lib.rs +++ b/crates/maestro-cli/src/lib.rs @@ -6,5 +6,6 @@ pub mod attach; pub mod cli; pub mod dispatch; pub mod sessions; +pub mod worktrees; pub use dispatch::{dispatch, CliError}; diff --git a/crates/maestro-cli/src/sessions.rs b/crates/maestro-cli/src/sessions.rs index 2e3729a..ad43be6 100644 --- a/crates/maestro-cli/src/sessions.rs +++ b/crates/maestro-cli/src/sessions.rs @@ -43,7 +43,7 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result Some(WorktreeSpec { base_repo: repo.clone(), branch: branch.clone(), - cleanup_on_kill: args.cleanup_on_kill, + create_branch: !args.existing_branch, }), _ => None, }; @@ -58,9 +58,14 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result Result<(), ClientError> { - client.kill_session(args.session_id).await +/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead +/// of removing it; the outcome names what was kept so dispatch can say where +/// the work went. +pub async fn run_kill( + client: &DaemonClient, + args: &KillArgs, +) -> Result { + client.kill_session(args.session_id, args.force).await } #[allow(clippy::write_literal)] @@ -88,8 +93,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon } writeln!( out, - " {:<36} {:<8} {:<9} {:<28} {}", - "ID", "STATUS", "ACTIVITY", "DIR", "COMMAND" + " {:<36} {:<8} {:<9} {:<28} {:<40} {}", + "ID", "STATUS", "ACTIVITY", "DIR", "COMMAND", "BLOCKED" ) .ok(); for s in sorted { @@ -110,18 +115,31 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon Some(fg) if fg != &s.command => format!("{} [{}]", s.command, fg), _ => s.command.clone(), }; + // #83: what an unforced kill would be refused for, in InTheWay's own + // words; blank while nothing blocks (or hasn't been probed yet). + let blocked = blocked_label(s); writeln!( out, - " {:<36} {:<8} {:<9} {:<28} {command}", - s.id, status, activity, dir + " {:<36} {:<8} {:<9} {:<28} {:<40} {blocked}", + s.id, status, activity, dir, command ) .ok(); } } +/// The blocked-work indicator (#83), phrased by [`InTheWay`]'s `Display`. +/// Empty unless something actually blocks — "no unsaved work" would just be +/// noise on every row. +pub(crate) fn blocked_label(s: &Session) -> String { + s.blocked_work + .filter(|b| !b.is_clear()) + .map(|b| b.to_string()) + .unwrap_or_default() +} + /// Render a path with the daemon's home dir collapsed to `~`. Mirrors the web /// UI's abbreviation so CLI and browser show the same directory shorthand. -fn abbreviate_dir(path: &std::path::Path, home: &str) -> String { +pub(crate) fn abbreviate_dir(path: &std::path::Path, home: &str) -> String { let p = path.to_string_lossy(); if !home.is_empty() { if p == home { @@ -152,12 +170,14 @@ mod tests { command: command.into(), env: HashMap::new(), metadata: HashMap::new(), + worktree: None, status: SessionStatus::Running, created_at: Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap(), foreground: None, activity: Default::default(), last_activity_at: chrono::Utc::now(), current_cwd: None, + blocked_work: None, } } @@ -218,4 +238,37 @@ mod tests { super::print_session_table(&mut buf, &[], &info("")); assert_eq!(String::from_utf8(buf).unwrap(), "no sessions\n"); } + + /// #83: a blocked session shows what an unforced kill would be refused + /// for, in `InTheWay`'s own words; a clear or unprobed one shows nothing. + #[test] + fn shows_the_blocked_work_summary_only_when_something_blocks() { + use maestro_protocol::InTheWay; + + let mut blocked = session("/tmp", "claude", 1); + blocked.blocked_work = Some(InTheWay { + uncommitted_files: 3, + unpushed_commits: 2, + ..InTheWay::default() + }); + let mut clear = session("/tmp", "bash", 2); + clear.blocked_work = Some(InTheWay::default()); + let unprobed = session("/tmp", "zsh", 3); + + let mut buf = Vec::new(); + super::print_session_table(&mut buf, &[blocked, clear, unprobed], &info("")); + let out = String::from_utf8(buf).unwrap(); + assert!( + out.contains("3 uncommitted files, 2 unpushed commits"), + "{out}" + ); + // Clear and unprobed rows stay blank in the BLOCKED column. + let lines: Vec<&str> = out.lines().collect(); + let header = lines.iter().position(|l| l.contains("BLOCKED")).unwrap(); + for line in &lines[header + 1..] { + if !line.contains("uncommitted files") { + assert!(!line.contains("unsaved work"), "{line}"); + } + } + } } diff --git a/crates/maestro-cli/src/worktrees.rs b/crates/maestro-cli/src/worktrees.rs new file mode 100644 index 0000000..c00237c --- /dev/null +++ b/crates/maestro-cli/src/worktrees.rs @@ -0,0 +1,204 @@ +//! `maestro worktrees list / reclaim` — the retained-store surface (#86). +//! +//! `reclaim` removes one directory; it never deletes the branch, which is +//! said out loud in the output because "reclaim" otherwise sounds like it +//! takes the commits too. + +use std::io::Write; + +use maestro_protocol::{ClientError, DaemonClient, DaemonInfo, InTheWay, RetainedWorktree}; + +use crate::cli::{ListWorktreesArgs, ReclaimArgs}; +use crate::sessions::abbreviate_dir; + +/// `maestro worktrees list` +pub async fn run_list( + client: &DaemonClient, + args: &ListWorktreesArgs, + out: &mut impl Write, +) -> Result<(), ClientError> { + let worktrees = client.list_worktrees().await?; + let info = client.daemon_info().await.unwrap_or_default(); + + if args.json { + let body = serde_json::json!({ "worktrees": worktrees }); + writeln!(out, "{}", serde_json::to_string_pretty(&body)?).ok(); + return Ok(()); + } + + print_worktree_table(out, &worktrees, &info); + Ok(()) +} + +/// `maestro worktrees reclaim ` +/// +/// Returns the entry as listed just before the reclaim, when it was found in +/// the listing, so the confirmation can name what was removed. +pub async fn run_reclaim( + client: &DaemonClient, + args: &ReclaimArgs, +) -> Result, ClientError> { + let listed = client + .list_worktrees() + .await? + .into_iter() + .find(|w| w.id == args.id); + client.reclaim_worktree(args.id).await?; + Ok(listed) +} + +fn print_worktree_table(out: &mut impl Write, worktrees: &[RetainedWorktree], info: &DaemonInfo) { + if worktrees.is_empty() { + writeln!(out, "no retained worktrees").ok(); + return; + } + + let mut sorted: Vec<&RetainedWorktree> = worktrees.iter().collect(); + sorted.sort_by_key(|w| w.retained_at); + + if !info.hostname.is_empty() { + let warn = if maestro_protocol::is_compatible(&info.version) { + "" + } else { + " (protocol mismatch)" + }; + let version = if info.version.is_empty() { + String::new() + } else { + format!(" maestro {}{warn}", info.version) + }; + writeln!(out, "\n{}{version}", info.hostname).ok(); + } + writeln!( + out, + " {:<36} {:<24} {:<28} {:<24} IN THE WAY", + "ID", "BRANCH", "PATH", "REASON" + ) + .ok(); + for w in sorted { + writeln!( + out, + " {:<36} {:<24} {:<28} {:<24} {}", + w.id, + w.branch, + abbreviate_dir(&w.path, &info.home_dir), + reason_label(w.reason), + blocker_label(&w.in_the_way), + ) + .ok(); + } + // The branch survives every reclaim — see docs/WORKTREES.md. + writeln!( + out, + "\nreclaim removes the directory only; branches are never deleted" + ) + .ok(); +} + +fn reason_label(reason: maestro_protocol::RetainedReason) -> &'static str { + match reason { + maestro_protocol::RetainedReason::ForceKilled => "force-killed", + maestro_protocol::RetainedReason::ExitedWithUnsavedWork => "exited-with-unsaved-work", + } +} + +/// The same breakdown that blocks an unforced kill, as words: what is still +/// in the way, or `clear` when the work has since been committed and pushed. +fn blocker_label(in_the_way: &InTheWay) -> String { + if in_the_way.is_clear() { + return "clear".into(); + } + let mut parts: Vec = Vec::new(); + if in_the_way.uncommitted_files > 0 { + parts.push(format!("{} uncommitted", in_the_way.uncommitted_files)); + } + if in_the_way.untracked_files > 0 { + parts.push(format!("{} untracked", in_the_way.untracked_files)); + } + if in_the_way.unpushed_commits > 0 { + parts.push(format!("{} unpushed", in_the_way.unpushed_commits)); + } + parts.join(", ") +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use chrono::{TimeZone, Utc}; + use maestro_protocol::{DaemonInfo, InTheWay, RetainedReason, RetainedWorktree}; + use uuid::Uuid; + + fn worktree(branch: &str, path: &str, id: u128) -> RetainedWorktree { + RetainedWorktree { + id: Uuid::from_u128(id), + path: PathBuf::from(path), + base_repo: PathBuf::from("/home/naps/maestro"), + branch: branch.into(), + reason: RetainedReason::ForceKilled, + retained_at: Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(), + in_the_way: InTheWay::default(), + } + } + + fn info(home: &str) -> DaemonInfo { + DaemonInfo { + hostname: "naps-laptop".into(), + home_dir: home.into(), + ..Default::default() + } + } + + #[test] + fn formats_table() { + let mut w = worktree("feat/login", "/home/naps/maestro/worktrees/feat/login", 1); + w.in_the_way = InTheWay { + uncommitted_files: 2, + untracked_files: 1, + unpushed_commits: 3, + }; + let mut buf = Vec::new(); + super::print_worktree_table(&mut buf, &[w], &info("/home/naps")); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("naps-laptop"), "{s}"); + assert!(s.contains("feat/login"), "{s}"); + // Home dir collapses to ~ in the PATH column, like sessions' DIR. + assert!(s.contains("~/maestro/worktrees/feat/login"), "{s}"); + assert!(s.contains("force-killed"), "{s}"); + assert!(s.contains("2 uncommitted, 1 untracked, 3 unpushed"), "{s}"); + assert!(s.contains("IN THE WAY"), "{s}"); + } + + /// Contract rule, visible to the operator: reclaiming a tree must not + /// throw away commits on its branch. + #[test] + fn says_the_branch_survives() { + let mut buf = Vec::new(); + super::print_worktree_table( + &mut buf, + &[worktree("feat/x", "/repo/worktrees/feat/x", 1)], + &info(""), + ); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("never deleted"), "{s}"); + } + + #[test] + fn clear_tree_says_clear() { + let mut buf = Vec::new(); + super::print_worktree_table( + &mut buf, + &[worktree("main-ish", "/repo/worktrees/main-ish", 1)], + &info(""), + ); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("clear"), "{s}"); + } + + #[test] + fn empty_store() { + let mut buf = Vec::new(); + super::print_worktree_table(&mut buf, &[], &info("")); + assert_eq!(String::from_utf8(buf).unwrap(), "no retained worktrees\n"); + } +} diff --git a/crates/maestro-cli/tests/against_mock_daemon.rs b/crates/maestro-cli/tests/against_mock_daemon.rs index 955cc8b..064daaf 100644 --- a/crates/maestro-cli/tests/against_mock_daemon.rs +++ b/crates/maestro-cli/tests/against_mock_daemon.rs @@ -76,7 +76,7 @@ async fn killing_an_unknown_session_reports_the_daemon_reason() { let err = mock .client() - .kill_session(Uuid::new_v4()) + .kill_session(Uuid::new_v4(), false) .await .expect_err("should fail"); @@ -102,6 +102,9 @@ async fn spawn_round_trips_through_the_daemon() { assert_eq!(created.command, "bash"); assert_eq!(mock.session_count(), 1); - mock.client().kill_session(created.id).await.expect("kill"); + mock.client() + .kill_session(created.id, false) + .await + .expect("kill"); assert_eq!(mock.session_count(), 0); } diff --git a/crates/maestro-config/src/lib.rs b/crates/maestro-config/src/lib.rs index 57b2a6d..e7d6520 100644 --- a/crates/maestro-config/src/lib.rs +++ b/crates/maestro-config/src/lib.rs @@ -87,8 +87,9 @@ pub struct DaemonSection { /// Root for everything daemon-local: sidecars, FIFOs, etc. #[serde(default)] pub state_dir: Option, - /// 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 `/worktrees/` (#56). #[serde(default)] pub worktree_root: Option, /// 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>, + /// 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>, + /// 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, } impl SpawnSection { @@ -386,6 +396,7 @@ mod tests { label: "fish".into(), command: "fish".into(), }]), + ..Default::default() }; let ids: Vec = section .resolve() @@ -402,6 +413,7 @@ mod tests { // only the implicit custom tile. let section = SpawnSection { presets: Some(vec![]), + ..Default::default() }; let ids: Vec = 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::( + 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). diff --git a/crates/maestro-daemon/Cargo.toml b/crates/maestro-daemon/Cargo.toml index 112f736..24c5c52 100644 --- a/crates/maestro-daemon/Cargo.toml +++ b/crates/maestro-daemon/Cargo.toml @@ -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"] } diff --git a/crates/maestro-daemon/src/branches.rs b/crates/maestro-daemon/src/branches.rs new file mode 100644 index 0000000..6f08f9d --- /dev/null +++ b/crates/maestro-daemon/src/branches.rs @@ -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, Query(q): Query) -> 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 { + 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 { + 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 { + 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, 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, PathBuf)> { + let mut result: Vec<(Option, PathBuf)> = Vec::new(); + let mut pending: Option = None; + let mut branch: Option = 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()); + } +} diff --git a/crates/maestro-daemon/src/cli.rs b/crates/maestro-daemon/src/cli.rs index 542440b..cef210d 100644 --- a/crates/maestro-daemon/src/cli.rs +++ b/crates/maestro-daemon/src/cli.rs @@ -47,7 +47,8 @@ async fn run(config_override: Option) -> Result<(), Box> { 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()); @@ -55,6 +56,9 @@ async fn run(config_override: Option) -> Result<(), Box> { Some(p) => Tmux::new().with_tmpdir(p), None => Tmux::new(), }; + let retained = std::sync::Arc::new(crate::retained_store::RetainedStore::new( + config.retained_worktree_dir(), + )); let manager = SessionManager::new_with_replay( std::sync::Arc::new(tmux), sidecar, @@ -65,17 +69,29 @@ async fn run(config_override: Option) -> Result<(), Box> { .with_spawn_history(crate::spawn_history::SpawnHistory::new( config.spawn_history_path(), config.spawn_history_days, - )); + )) + // One store instance shared with the API handlers: forced kills (#81) + // record here, `GET /api/worktrees` (#80) reads back from here. + .with_retained_store((*retained).clone()); let recovered = manager.recover().await?; 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, + retained, )); let listener = TcpListener::bind(config.bind_addr).await?; diff --git a/crates/maestro-daemon/src/config.rs b/crates/maestro-daemon/src/config.rs index 88ded4b..31bf2c5 100644 --- a/crates/maestro-daemon/src/config.rs +++ b/crates/maestro-daemon/src/config.rs @@ -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 `/worktrees/`; + /// operators who want them elsewhere set this. + pub worktree_root: Option, /// 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, + /// Project-discovery scan roots (#54): `[spawn].project_roots` (default + /// [`DEFAULT_PROJECT_ROOTS`]) with `~` expanded against the daemon's home. + pub project_roots: Vec, + /// Project-discovery depth (#54): `[spawn].project_scan_depth`, default + /// [`DEFAULT_PROJECT_SCAN_DEPTH`]. + pub project_scan_depth: u32, } impl DaemonConfig { @@ -86,6 +103,11 @@ impl DaemonConfig { pub fn spawn_history_path(&self) -> PathBuf { self.state_dir.join("spawn-history.jsonl") } + + /// Retained-worktree store directory (#78): `/retained-worktrees/`. + pub fn retained_worktree_dir(&self) -> PathBuf { + self.state_dir.join("retained-worktrees") + } } /// Env-var overrides applied by [`load_config`]. Tests pass explicit @@ -131,9 +153,6 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result 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 +166,53 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result = 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 { Ok(maestro_config::default_state_dir("maestro")?) } @@ -188,7 +241,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 +333,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(); diff --git a/crates/maestro-daemon/src/dirlist.rs b/crates/maestro-daemon/src/dirlist.rs index cae1c96..19a14ec 100644 --- a/crates/maestro-daemon/src/dirlist.rs +++ b/crates/maestro-daemon/src/dirlist.rs @@ -111,6 +111,7 @@ fn io_error(dir: &Path, e: &std::io::Error) -> ErrorInfo { ErrorInfo { code: code.into(), message: format!("{message}: {}", dir.display()), + unsaved_work: None, } } diff --git a/crates/maestro-daemon/src/lib.rs b/crates/maestro-daemon/src/lib.rs index f40ca02..8f85980 100644 --- a/crates/maestro-daemon/src/lib.rs +++ b/crates/maestro-daemon/src/lib.rs @@ -9,6 +9,7 @@ pub mod activity; pub mod attach; +pub mod branches; pub mod cli; pub mod config; pub mod dirlist; @@ -16,12 +17,15 @@ pub mod gitroot; pub mod local_api; pub mod panes; pub mod proc; +pub mod projects; pub mod pty; +pub mod retained_store; pub mod session; pub mod sidecar; pub mod spawn_history; pub mod tmux; pub mod worktree; +pub mod worktree_probe; pub use config::{default_config_path, default_state_dir, load_config, DaemonConfig, EnvOverrides}; pub use session::{SessionError, SessionManager}; diff --git a/crates/maestro-daemon/src/local_api.rs b/crates/maestro-daemon/src/local_api.rs index d7bb438..6ae1c3a 100644 --- a/crates/maestro-daemon/src/local_api.rs +++ b/crates/maestro-daemon/src/local_api.rs @@ -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,11 @@ pub struct LocalApiState { pub home_dir: Arc, /// Resolved spawn presets (#22/#26) advertised in `GET /api/info`. pub presets: Arc>, + /// Project discovery (#54) backing `GET /api/projects`. + pub projects: Arc, + /// Retained worktrees (#78) backing `GET /api/worktrees` and their + /// reclaim endpoint (#80). + pub retained: Arc, } pub fn router(state: LocalApiState) -> Router { @@ -44,7 +49,12 @@ 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::WORKTREES, get(list_worktrees)) + .route(routes::patterns::WORKTREE, delete(reclaim_worktree)) .route(routes::INFO, get(daemon_info)) .route(routes::EVENTS, any(events_ws)) .route(routes::patterns::ATTACH, any(attach_ws)) @@ -89,6 +99,10 @@ fn session_error_status(code: &str) -> StatusCode { match code { "not_found" => StatusCode::NOT_FOUND, "invalid_env" => StatusCode::BAD_REQUEST, + // A guard refusal is the daemon doing its job, not a failure (#79). + "kill_guarded" => StatusCode::CONFLICT, + // The retention record failed before anything was torn down (#81). + "retain_failed" => StatusCode::INTERNAL_SERVER_ERROR, _ => StatusCode::BAD_GATEWAY, } } @@ -98,6 +112,15 @@ async fn recent_dirs(State(s): State) -> 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) -> 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 +132,22 @@ async fn git_root(State(s): State, Query(q): Query) -> .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, +} + +/// 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) -> Json { + 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) -> Json { Json(DaemonInfo { hostname: (*s.hostname).clone(), @@ -138,17 +177,144 @@ async fn create_session( /// `kill` already reports a missing session as `SessionError::NotFound`, which /// maps to 404. Checking first would only add a window in which the liveness /// watcher reaps the session between the two awaits, turning a 404 into a 502. -async fn kill_session(State(s): State, Path(id): Path) -> Response { - match s.manager.kill(id).await { - Ok(()) => StatusCode::NO_CONTENT.into_response(), +/// +/// The full error info is passed through rather than rebuilt from +/// code + message: a `kill_guarded` refusal (#79) carries the unsaved-work +/// breakdown clients render as "3 uncommitted files, 2 unpushed commits". +/// +/// With `force=true` (#81) the refusal is overridden and the worktree +/// retained; the answer is `200` with a [`KillOutcome`] naming what was kept. +/// Without it the historical `204` stands. +#[derive(Debug, Deserialize)] +struct KillQuery { + #[serde(default)] + force: bool, +} + +async fn kill_session( + State(s): State, + Path(id): Path, + Query(q): Query, +) -> Response { + match s.manager.kill(id, q.force).await { + Ok(outcome) => { + if q.force { + (StatusCode::OK, Json(outcome)).into_response() + } else { + StatusCode::NO_CONTENT.into_response() + } + } Err(e) => { let info = e.to_error_info(); - error_response( - session_error_status(&info.code), - info.code.as_str(), - info.message.as_str(), + let status = session_error_status(&info.code); + (status, Json(ApiError { error: info })).into_response() + } + } +} + +/// `GET /api/worktrees` (#80): every retained worktree, with the in-the-way +/// breakdown recomputed at read time — never stored, so a tree whose work has +/// since been committed or pushed says so. Entries whose directory somebody +/// removed by hand are pruned on the way through. +async fn list_worktrees(State(s): State) -> Response { + let retained = s.retained.clone(); + let listed = tokio::task::spawn_blocking(move || { + retained.prune_missing()?; + retained + .load_all()? + .iter() + .map(|entry| { + // A tree git cannot read (permissions, corrupt checkout) + // still gets listed; it just reports nothing in the way. + let in_the_way = crate::worktree_probe::in_the_way(&entry.path).unwrap_or_default(); + Ok(maestro_protocol::RetainedWorktree { + id: entry.id, + path: entry.path.clone(), + base_repo: entry.base_repo.clone(), + branch: entry.branch.clone(), + reason: entry.reason, + retained_at: entry.retained_at, + in_the_way, + }) + }) + .collect::, crate::retained_store::RetainedStoreError>>() + }) + .await; + match listed { + Ok(Ok(worktrees)) => Json(worktrees).into_response(), + Ok(Err(e)) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "store_failed", + &e.to_string(), + ), + Err(e) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "join_failed", + &e.to_string(), + ), + } +} + +/// `DELETE /api/worktrees/:id` (#80): reclaim one retained worktree. Removes +/// the directory and drops the store entry, without consulting the guard — +/// reclaim is the deliberate destructive act, chosen while looking at what is +/// in the way. The branch itself is never deleted; commits on it survive. +async fn reclaim_worktree(State(s): State, Path(id): Path) -> Response { + let found = { + let retained = s.retained.clone(); + tokio::task::spawn_blocking(move || -> Result< + Option, + crate::retained_store::RetainedStoreError, + > { + Ok(retained.load_all()?.into_iter().find(|e| e.id == id)) + }) + .await + }; + let entry = match found { + Ok(Ok(Some(entry))) => entry, + Ok(Ok(None)) => { + return error_response( + StatusCode::NOT_FOUND, + "not_found", + "no such retained worktree", ) } + Ok(Err(e)) => { + return error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "store_failed", + &e.to_string(), + ) + } + Err(e) => { + return error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "join_failed", + &e.to_string(), + ) + } + }; + + let outcome = { + let retained = s.retained.clone(); + tokio::task::spawn_blocking(move || -> Result<(), String> { + crate::worktree_probe::reclaim(&entry.base_repo, &entry.path) + .map_err(|e| e.to_string())?; + retained.remove(id).map_err(|e| e.to_string())?; + Ok(()) + }) + .await + }; + match outcome { + Ok(Ok(())) => StatusCode::NO_CONTENT.into_response(), + // The directory could not be removed: leave the entry so the + // operator still sees the tree and can retry. + Ok(Err(e)) => error_response(StatusCode::BAD_GATEWAY, "reclaim_failed", &e.to_string()), + Err(e) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "join_failed", + &e.to_string(), + ), } } @@ -157,18 +323,25 @@ 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, home_dir: impl Into, presets: Vec, + projects: Arc, + retained: Arc, ) -> LocalApiState { LocalApiState { manager, hostname: Arc::new(hostname.into()), home_dir: Arc::new(home_dir.into()), presets: Arc::new(presets), + projects, + retained, } } diff --git a/crates/maestro-daemon/src/projects.rs b/crates/maestro-daemon/src/projects.rs new file mode 100644 index 0000000..aab747e --- /dev/null +++ b/crates/maestro-daemon/src/projects.rs @@ -0,0 +1,552 @@ +//! 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. +/// +/// A scan walks the roots and runs one `git worktree list` per hit, holding +/// the scanner lock throughout, and clients re-request whenever a draft pane +/// re-mounts — which `multiview.render()` does on unrelated events. Keep this +/// comfortably longer than [`REFRESH_INTERVAL`] so the background timer is +/// what refreshes, and a request only rescans when that timer has failed to. +pub const CACHE_TTL: Duration = Duration::from_secs(180); +/// Background refresh cadence. Repositories appear rarely; this keeps the +/// served list eventually fresh without anyone asking. +pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60); + +/// 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, + /// 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>, +} + +/// 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 { + 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) { + 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 { + 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> { + 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 reflog, falling back to `HEAD` itself. +/// +/// `HEAD` is only rewritten on checkout — committing updates +/// `refs/heads/` and appends to `logs/HEAD`, leaving `HEAD` alone. A +/// repo worked on daily from one long-lived branch would otherwise report its +/// last *branch switch* as its activity and get dimmed as stale (#54 review). +/// +/// For a linked worktree `.git` is a file holding a `gitdir:` pointer; +/// resolve it so worktrees report their own reflog rather than nothing. +fn head_mtime(repo: &Path) -> Option> { + 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") + }; + // Prefer the reflog next to whichever HEAD we resolved. + let reflog = head.with_file_name("logs").join("HEAD"); + let head = if reflog.exists() { reflog } else { head }; + let mtime = std::fs::metadata(head).ok()?.modified().ok()?; + Some(DateTime::::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 { + let mut projects: Vec = 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>, b: Option>) -> Option> { + 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, + max_depth: u32, + home: PathBuf, + ttl: Duration, + cache: tokio::sync::Mutex>)>>, +} + +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, 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> { + self.repos_inner(false).await + } + + async fn repos_inner(&self, force: bool) -> std::sync::Arc> { + let mut cache = self.cache.lock().await; + if !force { + 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 — going + /// through `repos()` would hit the cache and make most ticks no-ops, + /// leaving requests to pay for the rescan instead (#54 review). + async fn refresh(&self) { + self.repos_inner(true).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) -> 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 `/`, 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 { + let mut v: Vec = 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 { + 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::::new()); + } +} diff --git a/crates/maestro-daemon/src/retained_store.rs b/crates/maestro-daemon/src/retained_store.rs new file mode 100644 index 0000000..869e1b9 --- /dev/null +++ b/crates/maestro-daemon/src/retained_store.rs @@ -0,0 +1,310 @@ +//! Store of retained worktrees (#78): worktrees the daemon created whose +//! session is gone. +//! +//! One JSON file per entry under `/retained-worktrees/`, written +//! the way `sidecar.rs` writes session records — atomic tmp+rename replace, +//! missing or malformed files tolerated on read. The store survives a daemon +//! restart; it is the recovery path for everything else in the worktree +//! lifecycle. +//! +//! It is not an ownership record: it must never be consulted to decide whether +//! a path may be adopted. Its only job is to remember what the daemon is +//! holding so it can be listed and reclaimed. + +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; +/// Why a worktree outlived its session — [`maestro_protocol::RetainedReason`], +/// so the store and the wire payload cannot drift apart. +pub use maestro_protocol::RetainedReason; +use uuid::Uuid; + +#[derive(Debug, thiserror::Error)] +pub enum RetainedStoreError { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("json: {0}")] + Json(#[from] serde_json::Error), +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct RetainedWorktreeRecord { + pub id: Uuid, + pub path: PathBuf, + pub base_repo: PathBuf, + pub branch: String, + pub reason: RetainedReason, + pub retained_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct RetainedStore { + dir: PathBuf, +} + +impl RetainedStore { + pub fn new(dir: impl Into) -> Self { + Self { dir: dir.into() } + } + + pub fn dir(&self) -> &Path { + &self.dir + } + + fn path_for(&self, id: Uuid) -> PathBuf { + self.dir.join(format!("{id}.json")) + } + + /// Record a retained worktree and return the entry that was written. + pub fn retain( + &self, + path: &Path, + base_repo: &Path, + branch: &str, + reason: RetainedReason, + ) -> Result { + let entry = RetainedWorktreeRecord { + id: Uuid::new_v4(), + path: path.to_path_buf(), + base_repo: base_repo.to_path_buf(), + branch: branch.to_string(), + reason, + retained_at: Utc::now(), + }; + self.overwrite(&entry)?; + Ok(entry) + } + + /// Atomically write `entry`. Creates the directory if it does not exist. + pub fn overwrite(&self, entry: &RetainedWorktreeRecord) -> Result<(), RetainedStoreError> { + std::fs::create_dir_all(&self.dir)?; + let final_path = self.path_for(entry.id); + let tmp = self.dir.join(format!("{}.json.tmp", entry.id)); + let json = serde_json::to_vec_pretty(entry)?; + std::fs::write(&tmp, json)?; + std::fs::rename(&tmp, &final_path)?; + Ok(()) + } + + /// Drop one entry. No-op when the id is unknown — reclaiming an entry the + /// store already lost (e.g. pruned) must not fail. + pub fn remove(&self, id: Uuid) -> Result<(), RetainedStoreError> { + match std::fs::remove_file(self.path_for(id)) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.into()), + } + } + + /// Read every entry. Files that are missing or malformed are skipped + /// silently: a corrupt record must not hide the others, and an empty or + /// absent directory simply means nothing is retained. + pub fn load_all(&self) -> Result, RetainedStoreError> { + let mut out = Vec::new(); + let entries = match std::fs::read_dir(&self.dir) { + Ok(e) => e, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out), + Err(e) => return Err(e.into()), + }; + for entry in entries { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("json") { + continue; + } + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + if let Ok(worktree) = serde_json::from_slice::(&bytes) { + out.push(worktree); + } + } + out.sort_by_key(|w| w.retained_at); + Ok(out) + } + + /// Drop entries whose worktree directory no longer exists — somebody + /// removed it by hand. Returns the ids that were pruned. + pub fn prune_missing(&self) -> Result, RetainedStoreError> { + let mut pruned = Vec::new(); + for entry in self.load_all()? { + if !entry.path.exists() { + self.remove(entry.id)?; + pruned.push(entry.id); + } + } + Ok(pruned) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Duration; + use std::path::PathBuf; + + fn tmp_dir() -> tempfile::TempDir { + tempfile::tempdir().expect("tempdir") + } + + fn sample_path(root: &std::path::Path, name: &str) -> PathBuf { + let p = root.join(name); + std::fs::create_dir_all(&p).expect("mkdir"); + p + } + + #[test] + fn retain_then_load_round_trips_every_field() { + let dir = tmp_dir(); + let store = RetainedStore::new(dir.path()); + let wt = sample_path(dir.path(), "wt"); + let repo = sample_path(dir.path(), "repo"); + + let entry = store + .retain(&wt, &repo, "feature/login", RetainedReason::ForceKilled) + .expect("retain"); + + let all = store.load_all().expect("load_all"); + assert_eq!(all, vec![entry.clone()]); + assert_eq!(entry.path, wt); + assert_eq!(entry.base_repo, repo); + assert_eq!(entry.branch, "feature/login"); + assert_eq!(entry.reason, RetainedReason::ForceKilled); + let age = Utc::now() - entry.retained_at; + assert!(age >= Duration::zero() && age < Duration::minutes(1)); + } + + #[test] + fn reason_serializes_kebab_case() { + let json = + serde_json::to_string(&RetainedReason::ExitedWithUnsavedWork).expect("serialize"); + assert_eq!(json, r#""exited-with-unsaved-work""#); + } + + #[test] + fn entries_survive_a_restart() { + let dir = tmp_dir(); + let wt = sample_path(dir.path(), "wt"); + + let first = RetainedStore::new(dir.path()); + let entry = first + .retain(&wt, dir.path(), "main", RetainedReason::ForceKilled) + .expect("retain"); + + // A fresh store over the same state_dir — what a daemon restart sees. + let second = RetainedStore::new(dir.path()); + assert_eq!(second.load_all().expect("load_all"), vec![entry]); + } + + #[test] + fn missing_store_reads_as_empty() { + let dir = tmp_dir(); + let store = RetainedStore::new(dir.path().join("does-not-exist")); + assert_eq!(store.load_all().expect("load_all"), Vec::new()); + } + + #[test] + fn malformed_entries_are_skipped_silently() { + let dir = tmp_dir(); + let store = RetainedStore::new(dir.path()); + let wt = sample_path(dir.path(), "wt"); + store + .retain(&wt, dir.path(), "kept", RetainedReason::ForceKilled) + .expect("retain"); + std::fs::write(dir.path().join("not-a-uuid.json"), b"{oops").expect("write junk"); + + let all = store.load_all().expect("load_all"); + assert_eq!(all.len(), 1); + assert_eq!(all[0].branch, "kept"); + } + + #[test] + fn ids_are_unique() { + let dir = tmp_dir(); + let store = RetainedStore::new(dir.path()); + let a = store + .retain(dir.path(), dir.path(), "a", RetainedReason::ForceKilled) + .expect("retain"); + let b = store + .retain(dir.path(), dir.path(), "b", RetainedReason::ForceKilled) + .expect("retain"); + assert_ne!(a.id, b.id); + } + + #[test] + fn remove_drops_the_entry() { + let dir = tmp_dir(); + let store = RetainedStore::new(dir.path()); + let wt = sample_path(dir.path(), "wt"); + let entry = store + .retain(&wt, dir.path(), "main", RetainedReason::ForceKilled) + .expect("retain"); + + store.remove(entry.id).expect("remove"); + assert_eq!(store.load_all().expect("load_all"), Vec::new()); + + // Removing an unknown id is a no-op, not an error. + store.remove(entry.id).expect("remove again"); + } + + #[test] + fn load_all_is_sorted_by_time() { + let dir = tmp_dir(); + let store = RetainedStore::new(dir.path()); + let mut first = store + .retain(dir.path(), dir.path(), "first", RetainedReason::ForceKilled) + .expect("retain"); + first.retained_at -= Duration::hours(1); + store.overwrite(&first).expect("rewrite"); + + let second = store + .retain( + dir.path(), + dir.path(), + "second", + RetainedReason::ForceKilled, + ) + .expect("retain"); + + let branches: Vec<_> = store + .load_all() + .expect("load_all") + .into_iter() + .map(|e| e.branch) + .collect(); + assert_eq!(branches, vec!["first", "second"]); + let _ = second; + } + + #[test] + fn prune_missing_drops_gone_directories_only() { + let dir = tmp_dir(); + let store = RetainedStore::new(dir.path()); + let alive = sample_path(dir.path(), "alive"); + let gone = sample_path(dir.path(), "gone"); + let keep = store + .retain(&alive, dir.path(), "keep", RetainedReason::ForceKilled) + .expect("retain"); + let drop = store + .retain( + &gone, + dir.path(), + "drop", + RetainedReason::ExitedWithUnsavedWork, + ) + .expect("retain"); + std::fs::remove_dir_all(&gone).expect("rm"); + + let pruned = store.prune_missing().expect("prune_missing"); + assert_eq!(pruned, vec![drop.id]); + + let branches: Vec<_> = store + .load_all() + .expect("load_all") + .into_iter() + .map(|e| e.branch) + .collect(); + assert_eq!(branches, vec!["keep"]); + let _ = keep; + } +} diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index 2372aef..fd27f50 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -12,8 +12,8 @@ use std::time::Duration; use chrono::Utc; use maestro_protocol::{ - ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, Session, SessionStatus, - WorktreeSpec, + ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, KillOutcome, RetainedReason, + RetainedWorktree, Session, SessionStatus, WorktreeSpec, }; use tokio::sync::{broadcast, Mutex}; use uuid::Uuid; @@ -33,18 +33,29 @@ pub const EVENTS_CAPACITY: usize = maestro_config::EVENTS_CAPACITY; /// How often the liveness watcher polls tmux for sessions that disappeared. pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2); +/// How often the liveness watcher re-probes worktrees for the blocked-work +/// indicator (#83). Each probe costs three git subprocesses per session, so +/// the pass rides the same tick as everything else but runs on a slower +/// cadence: an indicator a few seconds stale still warns before the kill +/// attempt, which is all #83 needs. +pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15); + /// Per-session daemon-local bookkeeping that complements the public `Session`. #[derive(Debug)] 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, but only after the + /// kill guard (#79) found nothing worth keeping in it. pub worktree_path: Option, /// `base_repo` from the original `WorktreeSpec`. Needed because `git /// worktree remove` is invoked from the base repo. pub worktree_base_repo: Option, - pub cleanup_worktree_on_kill: bool, + /// Branch the worktree was created for, recorded at spawn time so a + /// forced kill (#81) can name it in the retained store without asking + /// git. Sessions adopted from a sidecar have no branch on record and + /// fall back to a probe. + pub worktree_branch: Option, pub bridge: PtyBridge, } @@ -62,6 +73,16 @@ pub enum SessionError { NotFound(Uuid), #[error("invalid environment variable name: {0:?}")] InvalidEnv(String), + /// The kill was refused (#79): the session's worktree holds unsaved work. + /// Nothing happened — the session keeps running and the tree is untouched, + /// so it stays the operator's handle on their own unfinished work. + #[error("kill refused: the session's worktree holds unsaved work ({0})")] + KillGuarded(maestro_protocol::InTheWay), + /// A forced kill (#81) records the retained worktree *before* tearing + /// anything down; if that write fails nothing was destroyed, and the + /// error says so rather than silently dropping unrecorded work. + #[error("could not record the retained worktree: {0}")] + RetainFailed(String), } impl SessionError { @@ -73,10 +94,17 @@ impl SessionError { SessionError::Io(_) => "io_failed", SessionError::NotFound(_) => "not_found", SessionError::InvalidEnv(_) => "invalid_env", + SessionError::KillGuarded(_) => "kill_guarded", + SessionError::RetainFailed(_) => "retain_failed", + }; + let unsaved_work = match self { + SessionError::KillGuarded(b) => Some(*b), + _ => None, }; ErrorInfo { code: code.into(), message: self.to_string(), + unsaved_work, } } } @@ -100,6 +128,9 @@ struct Inner { /// `None` when not wired (tests, or before `with_spawn_history`); a present /// but disabled (`0`-day) history no-ops internally. spawn_history: Option, + /// Retained-worktree store (#78). `None` when not wired (tests); a forced + /// kill (#81) without one fails rather than destroying unrecorded work. + retained: Option, sessions: Mutex>, events: broadcast::Sender, } @@ -138,6 +169,7 @@ impl SessionManager { fifo_dir, replay_bytes, spawn_history: None, + retained: None, sessions: Mutex::new(HashMap::new()), events, }), @@ -160,6 +192,16 @@ impl SessionManager { self } + /// Attach the retained-worktree store (#78). A forced kill (#81) needs it + /// to record what it retained; without one, forcing a guarded kill fails + /// instead of destroying unrecorded work. + pub fn with_retained_store(mut self, store: crate::retained_store::RetainedStore) -> Self { + let inner = Arc::get_mut(&mut self.inner) + .expect("with_retained_store must be called before the SessionManager is cloned"); + inner.retained = Some(store); + self + } + /// Recent working dirs for the spawn form's picker (#24), newest-first and /// deduped by dir. Empty when no history is wired or it's disabled. pub fn recent_dirs(&self) -> Vec { @@ -170,6 +212,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 { + 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 { @@ -213,24 +265,23 @@ impl SessionManager { /// Spawn a new session. On success returns the freshly-created `Session`. pub async fn create(&self, req: CreateSessionRequest) -> Result { 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; + // A path collision is sidestepped inside `run_add` (#77), and + // a branch already checked out elsewhere is git's refusal, + // not ours. + let path = if spec.create_branch { + self.inner.worktree_mgr.add_new(base_repo, branch) + } else { + 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 @@ -285,6 +336,12 @@ impl SessionManager { command: req.command, env: req.env, metadata: req.metadata, + worktree: worktree_path.as_ref().zip(worktree_base_repo.as_ref()).map( + |(path, base_repo)| maestro_protocol::SessionWorktree { + path: path.clone(), + base_repo: base_repo.clone(), + }, + ), status: SessionStatus::Running, created_at: Utc::now(), // A freshly-spawned session is producing its initial output; the @@ -294,6 +351,9 @@ impl SessionManager { last_activity_at: Utc::now(), current_cwd: None, + // The watcher fills this in on its first blocked-work pass; a + // fresh tree has nothing in the way anyway. + blocked_work: None, }; if let Err(e) = self.inner.sidecar.write(&session) { @@ -315,7 +375,7 @@ impl SessionManager { fifo_path, worktree_path, worktree_base_repo, - cleanup_worktree_on_kill, + worktree_branch: req.worktree.as_ref().map(|w| w.branch.clone()), bridge, }, ); @@ -357,21 +417,78 @@ 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. + /// + /// Refused (#79) when the session's worktree holds unsaved work — + /// uncommitted tracked changes, untracked non-ignored files, or commits + /// reachable from no remote-tracking ref. Nothing is killed and nothing + /// is removed; the error names what blocked it and how much of it there + /// is. The session stays as the operator's handle on their own unfinished + /// work, so ending a session that still matters becomes a deliberate + /// `--force`, which retains rather than destroys (WORKTREES.md). + /// + /// With `force` (#81) the refusal — and nothing else — is overridden: + /// the session ends and the guarded worktree is retained into the store + /// with reason `force-killed`, never removed. The outcome names what was + /// kept so a client can say where the work went. A tree whose guard is + /// clear never needed the override; force removes it as usual. /// /// This is also how an already-dead session is dismissed. tmux is only /// asked to kill something it still has — calling `kill-session` on a /// session tmux already lost fails, and that failure is not interesting. - pub async fn kill(&self, id: Uuid) -> Result<(), SessionError> { + pub async fn kill(&self, id: Uuid, force: bool) -> Result { + let (worktree, alive, known_branch) = { + let sessions = self.inner.sessions.lock().await; + match sessions.get(&id) { + None => return Err(SessionError::NotFound(id)), + Some(record) => ( + record + .worktree_base_repo + .clone() + .zip(record.worktree_path.clone()), + record.session.status == SessionStatus::Running, + record.worktree_branch.clone(), + ), + } + }; + let probe = match &worktree { + Some(wt) => Some(probe_worktree(wt).await?), + None => None, + }; + let guarded = probe.as_ref().is_some_and(|p| !p.is_clear()); + if guarded && !force { + return Err(SessionError::KillGuarded(probe.expect("guarded ⇒ probed"))); + } + + let mut retained = None; + if guarded { + let entry = record_retention( + &self.inner, + worktree.as_ref().expect("guarded ⇒ worktree"), + known_branch, + RetainedReason::ForceKilled, + ) + .await?; + retained = Some((entry, probe.expect("guarded ⇒ probed"))); + } + let removed = { let mut sessions = self.inner.sessions.lock().await; sessions.remove(&id) }; let record = removed.ok_or(SessionError::NotFound(id))?; - let alive = record.session.status == SessionStatus::Running; - cleanup_record(&self.inner, id, record, alive).await; - Ok(()) + cleanup_record(&self.inner, id, record, alive, retained.is_some()).await; + Ok(KillOutcome { + retained: retained.map(|(entry, in_the_way)| RetainedWorktree { + id: entry.id, + path: entry.path, + base_repo: entry.base_repo, + branch: entry.branch, + reason: entry.reason, + retained_at: entry.retained_at, + in_the_way, + }), + }) } /// Spawn a background task that polls tmux every `interval` and reaps any @@ -382,6 +499,17 @@ impl SessionManager { /// `SessionManager` clone is dropped — call this once from the daemon /// startup path after [`Self::recover`] has populated the map. pub fn start_liveness_watcher(&self, interval: Duration) { + self.start_liveness_watcher_with_blocked_work(interval, DEFAULT_BLOCKED_WORK_INTERVAL); + } + + /// [`Self::start_liveness_watcher`] with the blocked-work cadence (#83) + /// made explicit. Tests pass a small `blocked_work_interval` to watch the + /// probe actually happen. + pub fn start_liveness_watcher_with_blocked_work( + &self, + interval: Duration, + blocked_work_interval: Duration, + ) { let weak = Arc::downgrade(&self.inner); tokio::spawn(async move { let mut ticker = tokio::time::interval(interval); @@ -389,6 +517,9 @@ impl SessionManager { // first tick fires immediately — skip it so we don't reap before // the caller has a chance to create anything. ticker.tick().await; + // Start "due" so the first live tick probes: a recovered daemon + // should not wait a full interval to show what blocks. + let mut since_probe = blocked_work_interval; loop { ticker.tick().await; let Some(inner) = weak.upgrade() else { @@ -398,6 +529,11 @@ impl SessionManager { // Same tick, no new timer (NAP-340): refresh activity for the // survivors and emit SessionUpdated on any transition. run_activity_pass(&inner).await; + since_probe += interval; + if since_probe >= blocked_work_interval { + since_probe = Duration::ZERO; + run_blocked_work_pass(&inner).await; + } } }); } @@ -425,6 +561,7 @@ impl SessionManager { command: String::new(), env: Default::default(), metadata: Default::default(), + worktree: None, status: SessionStatus::Running, created_at: Utc::now(), // Activity is unknown on adoption; the watcher fills it in. @@ -433,6 +570,7 @@ impl SessionManager { last_activity_at: Utc::now(), current_cwd: None, + blocked_work: None, }, }; let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo")); @@ -452,9 +590,14 @@ impl SessionManager { SessionRecord { session: session.clone(), fifo_path, - worktree_path: None, - worktree_base_repo: None, - cleanup_worktree_on_kill: false, + // Restored from the sidecar: without it the recovered + // session cleans up nothing on kill, and the leftover + // directory blocks that branch forever (#54 review). + worktree_path: session.worktree.as_ref().map(|w| w.path.clone()), + worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()), + // The sidecar carries no branch; a forced kill of a + // recovered session probes git for it at retain time. + worktree_branch: None, bridge, }, ); @@ -531,6 +674,49 @@ where .expect("tmux blocking task panicked") } +/// What would be lost if this session's worktree were removed right now. +/// +/// The single [`crate::worktree_probe::in_the_way`] call site behind both +/// endings of a session — the kill guard (#79) and the natural-exit +/// classifier (#82) — so neither can grow its own answer. Runs on the +/// blocking pool; callers invoke it before anything is torn down, because +/// refusing (#79), recording a retention (#81) and classifying an exit (#82) +/// must all see the tree as it stands. +async fn probe_worktree( + (_, path): &(PathBuf, PathBuf), +) -> Result { + let path = path.clone(); + tmux_blocking(move || crate::worktree_probe::in_the_way(&path)) + .await + .map_err(|e| SessionError::Worktree(crate::worktree::WorktreeError::from(e))) +} + +/// Record a guarded worktree into the retained store with `reason`, BEFORE +/// the caller tears anything down: a failed write then leaves every artifact +/// untouched instead of destroying work nothing records anymore. Shared by +/// the forced kill (`ForceKilled`, #81) and the natural exit +/// (`ExitedWithUnsavedWork`, #82) so the two paths cannot drift apart. +async fn record_retention( + inner: &Arc, + (base_repo, path): &(PathBuf, PathBuf), + known_branch: Option, + reason: RetainedReason, +) -> Result { + let store = inner + .retained + .clone() + .ok_or_else(|| SessionError::RetainFailed("no retained store is wired".into()))?; + // Sessions adopted from a sidecar carry no branch; ask the tree itself. + let branch = known_branch + .or_else(|| crate::worktree_probe::current_branch(path)) + .unwrap_or_default(); + let base_repo = base_repo.clone(); + let path = path.clone(); + tmux_blocking(move || store.retain(&path, &base_repo, &branch, reason)) + .await + .map_err(|e| SessionError::RetainFailed(e.to_string())) +} + /// One pass of the liveness watcher: diff the in-memory session set against /// the tmux-reported one, and reap anything we still track but tmux has lost. async fn run_liveness_pass(inner: &Arc) { @@ -653,18 +839,79 @@ async fn run_activity_pass(inner: &Arc) { } } +/// Refresh each running session's blocked-work indicator (#83): what its +/// worktree holds right now that would block an unforced kill. Rides the +/// liveness tick on its own (slower) cadence — see +/// [`DEFAULT_BLOCKED_WORK_INTERVAL`] — because every probe is three git +/// subprocesses per session. +/// +/// Sessions without a worktree are filtered out BEFORE any probe: they can +/// never block a kill, so their indicator must cost nothing at all. A failed +/// probe keeps the previous value rather than clearing it — a transient git +/// error must not read as "nothing blocks". Like the activity pass, this only +/// emits `SessionUpdated` when the value actually changed. +async fn run_blocked_work_pass(inner: &Arc) { + let targets: Vec<(Uuid, (PathBuf, PathBuf))> = { + let sessions = inner.sessions.lock().await; + sessions + .iter() + .filter(|(_, r)| r.session.status == SessionStatus::Running) + .filter_map(|(id, r)| { + let wt = r.worktree_base_repo.clone().zip(r.worktree_path.clone())?; + Some((*id, wt)) + }) + .collect() + }; + if targets.is_empty() { + return; + } + + for (id, wt) in targets { + // The kill guard's own probe (#79), so the indicator can never + // disagree with what a kill attempt would find. + let breakdown = match probe_worktree(&wt).await { + Ok(breakdown) => breakdown, + Err(e) => { + tracing::debug!(%id, error = ?e, "blocked-work: probe failed; keeping previous value"); + continue; + } + }; + let updated = { + let mut sessions = inner.sessions.lock().await; + let Some(rec) = sessions.get_mut(&id) else { + continue; + }; + if rec.session.blocked_work == Some(breakdown) { + None + } else { + rec.session.blocked_work = Some(breakdown); + Some(rec.session.clone()) + } + }; + if let Some(session) = updated { + let _ = inner.events.send(CatalogEvent::SessionUpdated { session }); + } + } +} + /// Mark a session whose tmux session has disappeared as [`SessionStatus::Dead`] -/// and announce it, keeping everything else intact. +/// and settle its worktree per the natural-exit rule (#82). /// /// The record, its [`PtyBridge`] and its sidecar all survive, so a client can /// still attach and read the replay buffer — the last thing a crashed agent -/// printed. Artifacts are only released when the operator dismisses the session -/// via [`SessionManager::kill`]. +/// printed. The worktree, though, is settled right away: a pristine tree is +/// removed, anything else is retained into the store with reason +/// `exited-with-unsaved-work` (#82) — nobody is waiting for an answer here, +/// so there is nothing to refuse. Once settled, the session drops its +/// worktree reference so a later dismissal neither re-probes nor retains +/// twice. When settling is impossible (probe or store failure) nothing is +/// destroyed: the tree stays attached to the dead record for a later forced +/// dismissal (#81). /// /// Dead sessions do not survive a daemon restart: [`SessionManager::recover`] /// adopts only what tmux still holds. async fn mark_session_dead(inner: &Arc, id: Uuid) { - let updated = { + let flipped = { let mut sessions = inner.sessions.lock().await; match sessions.get_mut(&id) { Some(record) if record.session.status == SessionStatus::Running => { @@ -672,19 +919,114 @@ async fn mark_session_dead(inner: &Arc, id: Uuid) { // Release anyone attached; the scrollback survives for a later // attach. record.bridge.seal(); - Some(record.session.clone()) + let session = record.session.clone(); + let worktree = record + .worktree_base_repo + .clone() + .zip(record.worktree_path.clone()); + let branch = record.worktree_branch.clone(); + Some((session, worktree, branch)) } _ => None, } }; - let Some(session) = updated else { + let Some((mut session, worktree, branch)) = flipped else { return; }; tracing::info!(%id, "session vanished from tmux — marking dead"); + + if let Some(wt) = &worktree { + if settle_worktree_on_exit(inner, id, wt, branch).await { + // The tree is settled (removed or retained); a stale "in the + // way" would describe something the session no longer holds. + session.blocked_work = None; + let mut sessions = inner.sessions.lock().await; + if let Some(record) = sessions.get_mut(&id) { + record.session.worktree = None; + record.session.blocked_work = None; + record.worktree_path = None; + record.worktree_base_repo = None; + record.worktree_branch = None; + } + } + } + let _ = inner.sidecar.write(&session); let _ = inner.events.send(CatalogEvent::SessionUpdated { session }); } +/// Apply the exit rule (#82) to a vanished session's worktree: probe what +/// would be lost through the kill's own predicate, then remove a pristine +/// tree or retain anything else under `exited-with-unsaved-work` — recorded +/// before anything is torn down, exactly as the forced kill does (#81). A +/// removal that fails falls back to retaining, since whatever blocked it is +/// by definition something worth keeping. +/// +/// Returns whether the worktree was fully accounted for. When it wasn't, +/// nothing was destroyed and the caller keeps the reference on the record. +async fn settle_worktree_on_exit( + inner: &Arc, + id: Uuid, + (base_repo, path): &(PathBuf, PathBuf), + known_branch: Option, +) -> bool { + let probe = match probe_worktree(&(base_repo.clone(), path.clone())).await { + Ok(probe) => probe, + Err(e) => { + tracing::error!( + %id, + path = %path.display(), + error = ?e, + "natural exit: could not probe the worktree; leaving it untouched" + ); + return false; + } + }; + if probe.is_clear() { + match inner.worktree_mgr.remove(base_repo, path) { + Ok(()) => { + tracing::info!(%id, path = %path.display(), "natural exit: pristine worktree removed"); + return true; + } + Err(e) => { + tracing::warn!( + %id, + path = %path.display(), + error = ?e, + "natural exit: removal failed; falling back to retention" + ); + } + } + } + match record_retention( + inner, + &(base_repo.clone(), path.clone()), + known_branch, + RetainedReason::ExitedWithUnsavedWork, + ) + .await + { + Ok(entry) => { + tracing::info!( + %id, + path = %path.display(), + entry = %entry.id, + "natural exit: worktree retained with unsaved work" + ); + true + } + Err(e) => { + tracing::error!( + %id, + path = %path.display(), + error = ?e, + "natural exit: could not record the retained worktree; leaving it untouched" + ); + false + } + } +} + /// Tear down a session's artifacts after it has already been removed from the /// live map. Shared by [`SessionManager::kill`] and [`reap_dead_session`], /// which previously hand-rolled the same five-step choreography and only @@ -694,7 +1036,17 @@ async fn mark_session_dead(inner: &Arc, id: Uuid) { /// gone from the authoritative in-memory map, so a stray FIFO or sidecar can't /// resurrect it. `kill_tmux` is `true` for an explicit kill and `false` for the /// liveness reaper (tmux already lost the session). -async fn cleanup_record(inner: &Arc, id: Uuid, record: SessionRecord, kill_tmux: bool) { +/// +/// `retain_worktree` is `true` for a forced kill (#81) that retained the +/// worktree instead of removing it: the store entry is already written, so +/// only the removal is skipped. +async fn cleanup_record( + inner: &Arc, + id: Uuid, + record: SessionRecord, + kill_tmux: bool, + retain_worktree: bool, +) { if kill_tmux { let tmux = inner.tmux.clone(); let _ = tmux_blocking(move || tmux.kill_session(id)).await; @@ -703,9 +1055,24 @@ async fn cleanup_record(inner: &Arc, 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); + if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) { + if retain_worktree { + tracing::info!( + %id, + path = %path.display(), + "forced kill: worktree retained in the store" + ); + } else if let Err(e) = inner.worktree_mgr.remove(base, path) { + // The kill already passed the unsaved-work guard (#79), so this + // removal is unconditional; the manager's own nested-worktree + // refusal is all that can still leave a tree on disk, and it + // says where. + tracing::warn!( + %id, + path = %path.display(), + error = %e, + "left worktree on disk" + ); } } let _ = inner.events.send(CatalogEvent::SessionRemoved { id }); diff --git a/crates/maestro-daemon/src/spawn_history.rs b/crates/maestro-daemon/src/spawn_history.rs index 668bca4..b0aec59 100644 --- a/crates/maestro-daemon/src/spawn_history.rs +++ b/crates/maestro-daemon/src/spawn_history.rs @@ -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 { - 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 { + 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) -> Vec { + self.recent_dirs_capped(now, self.max_results) + } + + fn recent_dirs_capped(&self, now: DateTime, max_results: usize) -> Vec { 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")); + } } diff --git a/crates/maestro-daemon/src/worktree.rs b/crates/maestro-daemon/src/worktree.rs index b5ffa63..a5f0d1b 100644 --- a/crates/maestro-daemon/src/worktree.rs +++ b/crates/maestro-daemon/src/worktree.rs @@ -4,79 +4,270 @@ 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 registered worktree lives under the tree about to be removed; git + /// hides its contents from `git status`, so removal would delete its + /// uncommitted work unseen (#54 review). + #[error("left {} on disk: {}", .path.display(), .reason)] + Guarded { path: PathBuf, reason: String }, } -/// 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. +impl From for WorktreeError { + fn from(e: crate::worktree_probe::ProbeError) -> Self { + match e { + crate::worktree_probe::ProbeError::Spawn(io) => WorktreeError::Spawn(io), + crate::worktree_probe::ProbeError::NonZero { code, stderr } => { + WorktreeError::NonZero { code, stderr } + } + } + } +} + +/// Runs `git worktree add` rooted at `base_repo`. By default worktrees land +/// inside the repository itself at `/worktrees/` (#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 + /// `/worktrees/` placement. + root_override: Option, } impl WorktreeManager { - pub fn new(root: impl Into) -> Self { - Self { root: root.into() } + pub fn new(root_override: Option) -> Self { + Self { root_override } } - pub fn root(&self) -> &Path { - &self.root + pub fn root(&self) -> Option<&Path> { + self.root_override.as_deref() } - /// `git -C worktree add -b `. - /// Returns the path of the new worktree on success. - pub fn add(&self, base_repo: &Path, branch: &str) -> Result { - 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 + /// `
/worktrees/` by default. If the path + /// is occupied, [`Self::add_new`] / [`Self::add_existing`] sidestep to + /// `-2`, `-3`, ... instead. + /// + /// `base_repo` is whatever the client picked, which the project list makes + /// easy to point at a *linked worktree* — placing the new tree inside it + /// would nest one worktree in another, and git hides a registered + /// worktree's contents from the outer `git status`, so the outer tree + /// would later look clean and its removal would delete the inner one's + /// uncommitted work (#54 review). Always place against the main checkout. + /// + /// The override root gets the repo's basename too: without it two repos + /// spawning the same branch name target one directory. + fn target_path(&self, base_repo: &Path, branch: &str) -> PathBuf { + // An unusable branch name falls back to a digest rather than escaping + // the root; `run_add` surfaces git's own error for it anyway. + let slug = branch_relpath(branch).unwrap_or_else(|| PathBuf::from(short_hash(branch))); + let main = main_checkout(base_repo); + match &self.root_override { + Some(root) => { + // Basename alone collides for two checkouts of the same name + // (`~/a/maestro` and `~/b/maestro`), so disambiguate with a + // digest of the full path (#54 review). + let base = main + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "repo".into()); + let repo = format!("{base}-{}", short_hash(&main.to_string_lossy())); + root.join(repo).join(slug) + } + None => main.join("worktrees").join(slug), + } + } + + /// [`Self::target_path`] with `-n` appended to the final component, the + /// collision suffix (#77). + fn target_path_suffixed(&self, base_repo: &Path, branch: &str, n: u32) -> PathBuf { + let mut name = self.target_path(base_repo, branch).into_os_string(); + name.push(format!("-{n}")); + PathBuf::from(name) + } + + /// `git -C worktree add -b ` — creates the + /// branch. Returns the path of the new worktree on success. + pub fn add_new(&self, base_repo: &Path, branch: &str) -> Result { + self.run_add(base_repo, branch, true) + } + + /// `git -C worktree add ` — checks out a branch + /// that must already exist (#56). + pub fn add_existing(&self, base_repo: &Path, branch: &str) -> Result { + self.run_add(base_repo, branch, false) + } + + fn run_add( + &self, + base_repo: &Path, + branch: &str, + create_branch: bool, + ) -> Result { + // Clear registrations whose directory is gone first, so a + // previously-leaked entry doesn't push this branch to a suffix. + prune(base_repo)?; + // Anything occupying the target — a tree we left behind, one the + // operator made, an unrelated directory — is sidestepped with a + // numeric suffix on the directory; the branch stays whatever was + // requested (#77). The daemon never adopts what it finds. + let mut path = self.target_path(base_repo, branch); + let mut n = 1; + while path.symlink_metadata().is_ok() { + n += 1; + path = self.target_path_suffixed(base_repo, branch, n); + } + // The parent (`/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 ` with no `-b`. + cmd.arg(&path).arg(branch); + } + let out = cmd.output()?; check(out)?; Ok(path) } - /// `git -C worktree remove --force `. + /// `git -C worktree remove `. + /// + /// Unconditional (#79): the guard moved off the worktree and onto the + /// kill, so by the time removal runs the caller has already chosen to + /// destroy the tree — the kill only gets here after + /// [`crate::worktree_probe::in_the_way`] came back clear. Still refused for a tree holding a + /// nested registered worktree, which `git status` cannot see. pub fn remove(&self, base_repo: &Path, path: &Path) -> Result<(), WorktreeError> { + // Gone from disk already (operator `rm -rf`, or a create that failed + // half-way). Probing it with git would fail and be misread as unsaved + // work, stranding the registration — and since paths are + // deterministic, `worktree add` would then refuse that branch forever + // with "missing but already registered" (#56 review). + if !path.exists() { + return prune(base_repo); + } + if let Some(inner) = nested_worktree(path) { + return Err(WorktreeError::Guarded { + path: path.to_path_buf(), + reason: format!("holds a nested worktree at {}", inner.display()), + }); + } 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 { - let mut out = String::with_capacity(branch.len()); - for c in branch.chars() { - if c.is_ascii_alphanumeric() || c == '-' || c == '_' { - out.push(c); - } else if c == '/' || c == '.' { - out.push('-'); - } +/// The repository's main checkout. `--git-common-dir` points at the shared +/// `.git` for every linked worktree, so its parent is the main tree. Falls +/// back to the input when git can't answer. +fn main_checkout(path: &Path) -> PathBuf { + let out = Command::new("git") + .arg("-C") + .arg(path) + .args(["rev-parse", "--path-format=absolute", "--git-common-dir"]) + .output(); + let Ok(out) = out else { + return path.to_path_buf(); + }; + if !out.status.success() { + return path.to_path_buf(); } - if out.is_empty() { - out.push_str("wt"); + let common = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim().to_string()); + common + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| path.to_path_buf()) +} + +/// `Some(inner)` when a registered worktree lives under `path`. Git omits +/// those directories from `git status`, so the dirty-work guard cannot see +/// them and removal would delete them (#54 review). +fn nested_worktree(path: &Path) -> Option { + let out = Command::new("git") + .arg("-C") + .arg(path) + .args(["worktree", "list", "--porcelain"]) + .output() + .ok()?; + // `worktree list` prints fully-resolved paths while ours comes from + // `--git-common-dir` and may still hold symlinks. Comparing the two forms + // directly makes the guard silently pass on any symlinked repo path — and + // this guard is what stands between a kill and deleting an inner + // worktree's uncommitted work (#54 review). + let needle = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + let text = String::from_utf8_lossy(&out.stdout); + text.lines() + .filter_map(|l| l.strip_prefix("worktree ")) + .map(PathBuf::from) + .find(|p| { + let real = p.canonicalize().unwrap_or_else(|_| p.clone()); + real != needle && real.starts_with(&needle) + }) +} + +/// Short digest used to disambiguate override roots. +/// +/// Hand-rolled FNV-1a rather than `DefaultHasher`: this value becomes a +/// directory name, and `DefaultHasher`'s algorithm is explicitly not +/// guaranteed stable across Rust releases — a toolchain bump would remap every +/// repo to a new directory and orphan the worktrees under the old one +/// (#67 review). +fn short_hash(s: &str) -> String { + let mut h: u32 = 0x811c_9dc5; + for b in s.as_bytes() { + h ^= u32::from(*b); + h = h.wrapping_mul(0x0100_0193); } - out + format!("{h:08x}") +} + +/// The relative directory a branch's worktree lives in. +/// +/// A git branch name is already a valid relative path — git stores it as +/// `.git/refs/heads/feat/example` — so it is used verbatim and `feat/example` +/// nests under `worktrees/feat/`. That keeps the path readable *and* keeps the +/// mapping injective: flattening `/` and `.` to `-` made `feat/x`, `feat.x` +/// and `feat-x` share one directory (#54 review). +/// +/// Git already forbids holding both `feat` and `feat/x`, so the directory +/// collision that nesting could cause cannot arise from valid refs. Only +/// traversal and absolute components are rejected. +fn branch_relpath(branch: &str) -> Option { + use std::path::Component; + let candidate = Path::new(branch); + if branch.is_empty() + || !candidate + .components() + .all(|c| matches!(c, Component::Normal(_))) + { + return None; + } + Some(candidate.to_path_buf()) +} + +/// `git -C 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 check(out: Output) -> Result<(), WorktreeError> { diff --git a/crates/maestro-daemon/src/worktree_probe.rs b/crates/maestro-daemon/src/worktree_probe.rs new file mode 100644 index 0000000..f1720e7 --- /dev/null +++ b/crates/maestro-daemon/src/worktree_probe.rs @@ -0,0 +1,325 @@ +//! The one git probe for what a worktree holds that would be lost, plus +//! guard-free reclaim (#79, #80). +//! +//! [`in_the_way`] is the single predicate behind all three paths: the kill +//! refusal (#79), the natural-exit classifier (#82), and the retained list +//! (#80). It lives here rather than in `worktree.rs` so that none of them can +//! answer the question differently — #79 and #80 were built in parallel and +//! each grew its own copy, which is exactly the drift this collapses. +//! +//! Reclaim never consults it: reclaim is the deliberate destructive act, and +//! the operator chose it while looking at what was in the way. + +use std::path::Path; + +use maestro_protocol::InTheWay; + +#[derive(Debug, thiserror::Error)] +pub enum ProbeError { + #[error("failed to spawn git: {0}")] + Spawn(#[from] std::io::Error), + #[error("git exited with code {code}: {stderr}")] + NonZero { code: i32, stderr: String }, +} + +/// What a kill refusal would report about the worktree at `path`, right now. +/// +/// Ignored files never count (`--ignored=no`) — every Rust worktree has a +/// `target/`, and counting those would put every tree permanently in the way. +/// A directory that no longer exists reports nothing in the way: there is +/// nothing left to lose, and the store's own pruning drops the entry. +pub fn in_the_way(path: &Path) -> Result { + let mut breakdown = InTheWay::default(); + // `symlink_metadata` rather than `exists`: a broken symlink is gone for + // our purposes, and following it would probe the wrong tree. + if std::fs::symlink_metadata(path).is_err() { + return Ok(breakdown); + } + let status = run_git( + path, + [ + "status", + "--porcelain", + "--ignored=no", + "--untracked-files=normal", + ], + )?; + for line in String::from_utf8_lossy(&status.stdout).lines() { + if line.starts_with("??") { + breakdown.untracked_files += 1; + } else if !line.is_empty() { + breakdown.uncommitted_files += 1; + } + } + // Resolved inside the worktree so a detached HEAD still has a starting + // point; asking the base repo for `refs/heads/` fails there. + let head = run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"])?; + let branch = String::from_utf8_lossy(&head.stdout).trim().to_string(); + let tip: &str = if branch == "HEAD" { "HEAD" } else { &branch }; + // Commits reachable from the tip but from no remote-tracking ref. A branch + // pushed anywhere counts as pushed; there is no "which remote" question to + // get wrong. + let unpushed = run_git(path, ["rev-list", "--count", tip, "--not", "--remotes"])?; + breakdown.unpushed_commits = String::from_utf8_lossy(&unpushed.stdout) + .trim() + .parse() + .unwrap_or(0); + Ok(breakdown) +} + +/// Remove a retained worktree's directory without consulting any guard, per +/// the reclaim contract: the operator chose this while looking at what was in +/// the way. `git worktree remove` keeps `refs/heads/`, so commits on +/// the branch survive even when they were never pushed. +/// +/// A directory that is already gone only leaves a stale registration behind; +/// `worktree prune` clears it so the branch can be spawned again. +pub fn reclaim(base_repo: &Path, path: &Path) -> Result<(), ProbeError> { + if !path.exists() { + return run_git(base_repo, ["worktree", "prune"]).map(|_| ()); + } + run_git( + base_repo, + [ + "worktree", + "remove", + "--force", + path.to_str().expect("worktree path is valid utf-8"), + ], + ) + .map(|_| ()) +} + +/// Branch checked out at `path`. Sessions adopted from a sidecar carry no +/// branch on record, so a forced kill (#81) asks the tree itself when it +/// needs one for the retained store. `None` when git cannot answer. +pub fn current_branch(path: &Path) -> Option { + let out = run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"]).ok()?; + Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +fn run_git(dir: &Path, args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let mut cmd = std::process::Command::new("git"); + cmd.arg("-C").arg(dir).args(args); + let out = cmd.output()?; + if !out.status.success() { + return Err(ProbeError::NonZero { + code: out.status.code().unwrap_or(-1), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + }); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use std::process::Command; + + struct Repo { + root: tempfile::TempDir, + repo: PathBuf, + } + + fn git(dir: &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) + ); + } + + /// A repo with a remote, one commit on `main`, and a linked worktree for + /// branch `feature` at `/worktrees/feature`. + fn repo_with_worktree() -> Repo { + let root = tempfile::tempdir().expect("tempdir"); + let repo = root.path().join("repo"); + let remote = root.path().join("remote.git"); + std::fs::create_dir_all(&repo).unwrap(); + std::fs::create_dir_all(&remote).unwrap(); + git(&repo, &["init", "-q", "-b", "main"]); + git(&repo, &["config", "user.email", "t@t"]); + git(&repo, &["config", "user.name", "T"]); + std::fs::write(repo.join("README"), b"hello\n").unwrap(); + git(&repo, &["add", "."]); + git(&repo, &["commit", "-q", "-m", "init"]); + git(&remote, &["init", "-q", "--bare"]); + git(&repo, &["remote", "add", "origin", &path_str(&remote)]); + git(&repo, &["push", "-q", "-u", "origin", "main"]); + git( + &repo, + &[ + "worktree", + "add", + "-q", + "-b", + "feature", + "worktrees/feature", + ], + ); + Repo { root, repo } + } + + fn path_str(path: &Path) -> String { + path.to_string_lossy().into_owned() + } + + impl Repo { + fn wt(&self) -> PathBuf { + self.repo.join("worktrees").join("feature") + } + + fn commit_in_wt(&self, msg: &str) { + std::fs::write(self.wt().join("README"), msg.as_bytes()).unwrap(); + git(&self.wt(), &["add", "-A"]); + git(&self.wt(), &["commit", "-qm", msg]); + } + } + + #[test] + fn a_clean_pushed_worktree_has_nothing_in_the_way() { + let r = repo_with_worktree(); + git(&r.wt(), &["push", "-q", "-u", "origin", "feature"]); + let breakdown = in_the_way(&r.wt()).expect("probe"); + assert_eq!(breakdown, InTheWay::default()); + assert!(breakdown.is_clear()); + } + + #[test] + fn uncommitted_untracked_and_unpushed_are_counted_separately() { + let r = repo_with_worktree(); + std::fs::write(r.wt().join("README"), b"edited\n").unwrap(); + std::fs::write(r.wt().join("scratch.txt"), b"untracked\n").unwrap(); + + let dirty = in_the_way(&r.wt()).expect("probe"); + assert_eq!(dirty.uncommitted_files, 1); + assert_eq!(dirty.untracked_files, 1); + assert_eq!(dirty.unpushed_commits, 0); + assert!(!dirty.is_clear()); + + // Committing clears exactly one dimension and creates another. + git(&r.wt(), &["add", "README"]); + git(&r.wt(), &["commit", "-qm", "wip"]); + + let breakdown = in_the_way(&r.wt()).expect("probe"); + assert_eq!(breakdown.uncommitted_files, 0); + assert_eq!(breakdown.untracked_files, 1); + assert_eq!(breakdown.unpushed_commits, 1); + } + + #[test] + fn tracked_edits_count_as_uncommitted() { + let r = repo_with_worktree(); + std::fs::write(r.wt().join("README"), b"edited\n").unwrap(); + let breakdown = in_the_way(&r.wt()).expect("probe"); + assert_eq!(breakdown.uncommitted_files, 1); + assert_eq!(breakdown.untracked_files, 0); + } + + #[test] + fn pushing_clears_unpushed_but_keeps_other_entries() { + let r = repo_with_worktree(); + std::fs::write(r.wt().join("new.txt"), b"x\n").unwrap(); + r.commit_in_wt("wip"); + + let before = in_the_way(&r.wt()).expect("probe"); + assert_eq!(before.unpushed_commits, 1); + + git(&r.wt(), &["push", "-q", "-u", "origin", "feature"]); + // Same stored entry, different tree: the breakdown must move with it. + let after = in_the_way(&r.wt()).expect("probe"); + assert_eq!(after.unpushed_commits, 0); + assert_eq!(after.untracked_files, 0); + + std::fs::write(r.wt().join("loose.txt"), b"x\n").unwrap(); + let dirty = in_the_way(&r.wt()).expect("probe"); + assert_eq!(dirty.untracked_files, 1); + } + + #[test] + fn ignored_files_never_count() { + let r = repo_with_worktree(); + std::fs::write(r.wt().join(".gitignore"), b"target/\n").unwrap(); + std::fs::create_dir_all(r.wt().join("target")).unwrap(); + std::fs::write(r.wt().join("target/artifact"), b"x").unwrap(); + + let breakdown = in_the_way(&r.wt()).expect("probe"); + assert_eq!( + breakdown, + InTheWay { + untracked_files: 1, + ..InTheWay::default() + }, + "the .gitignore itself is untracked; target/ is not" + ); + } + + #[test] + fn a_missing_directory_reports_nothing_in_the_way() { + let r = repo_with_worktree(); + let gone = r.root.path().join("nowhere"); + let breakdown = in_the_way(&gone).expect("probe"); + assert!(breakdown.is_clear()); + } + + #[test] + fn reclaim_removes_the_directory_and_prunes_a_gone_one() { + let r = repo_with_worktree(); + r.commit_in_wt("precious"); + reclaim(&r.repo, &r.wt()).expect("reclaim"); + assert!(!r.wt().exists()); + + // A second reclaim over the now-missing path just prunes. + reclaim(&r.repo, &r.wt()).expect("reclaim again"); + } + + #[test] + fn reclaim_keeps_the_branch_and_its_commits() { + let r = repo_with_worktree(); + r.commit_in_wt("precious"); + let sha = { + let out = Command::new("git") + .arg("-C") + .arg(r.wt()) + .args(["rev-parse", "HEAD"]) + .output() + .unwrap(); + String::from_utf8_lossy(&out.stdout).trim().to_string() + }; + + reclaim(&r.repo, &r.wt()).expect("reclaim"); + + let out = Command::new("git") + .arg("-C") + .arg(&r.repo) + .args(["rev-parse", "--verify", "refs/heads/feature"]) + .output() + .unwrap(); + assert!( + out.status.success(), + "branch must survive reclaim: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), sha); + } + + #[test] + fn reclaim_is_guard_free_on_a_dirty_tree() { + let r = repo_with_worktree(); + std::fs::write(r.wt().join("README"), b"unsaved\n").unwrap(); + std::fs::write(r.wt().join("notes.txt"), b"unsaved\n").unwrap(); + reclaim(&r.repo, &r.wt()).expect("reclaim ignores the guard"); + assert!(!r.wt().exists()); + } +} diff --git a/crates/maestro-daemon/tests/attach.rs b/crates/maestro-daemon/tests/attach.rs index d8c6b1f..6aa0e31 100644 --- a/crates/maestro-daemon/tests/attach.rs +++ b/crates/maestro-daemon/tests/attach.rs @@ -37,6 +37,8 @@ async fn spawn_daemon_ws() -> Harness { "test-host", "/home/test", Vec::new(), + std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), + std::sync::Arc::new(fx.retained_store()), )); let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) .await @@ -120,7 +122,7 @@ async fn pty_round_trip_through_real_tmux() { "marker not seen in {} bytes", got.len() ); - let _ = h.manager.kill(id).await; + let _ = h.manager.kill(id, false).await; } #[tokio::test] @@ -168,7 +170,7 @@ async fn multi_attach_broadcasts_to_every_client() { "client B missed marker" ); - let _ = h.manager.kill(id).await; + let _ = h.manager.kill(id, false).await; } #[tokio::test] @@ -217,7 +219,7 @@ async fn sends_close_frame_when_session_is_killed() { // give the broadcast subscriber a moment to register before killing. tokio::time::sleep(Duration::from_millis(100)).await; - h.manager.kill(id).await.unwrap(); + h.manager.kill(id, false).await.unwrap(); let mut saw_close = false; let deadline = std::time::Instant::now() + Duration::from_secs(2); @@ -265,7 +267,7 @@ async fn ping_text_frame_gets_pong() { } } assert!(saw_pong, "no Pong frame received"); - let _ = h.manager.kill(id).await; + let _ = h.manager.kill(id, false).await; } #[tokio::test] diff --git a/crates/maestro-daemon/tests/blocked_work_cost.rs b/crates/maestro-daemon/tests/blocked_work_cost.rs new file mode 100644 index 0000000..a04a923 --- /dev/null +++ b/crates/maestro-daemon/tests/blocked_work_cost.rs @@ -0,0 +1,116 @@ +//! The performance contract of #83: the blocked-work pass must cost NO git +//! probe at all for a session without a worktree, while a session with one is +//! still probed. +//! +//! `git` itself is shimmed through PATH with a wrapper that logs every +//! invocation before exec'ing the real binary, so the assertion counts actual +//! probes rather than inferring them from state. This lives in its own test +//! binary because it mutates the process environment. + +mod common; + +use std::collections::HashMap; +use std::process::Command; +use std::time::Duration; + +use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture}; +use maestro_protocol::{CreateSessionRequest, InTheWay, WorktreeSpec}; +use tempfile::TempDir; + +fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> CreateSessionRequest { + CreateSessionRequest { + cwd: cwd.to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(WorktreeSpec { + base_repo: repo.to_path_buf(), + branch: branch.into(), + create_branch: true, + }), + } +} + +#[tokio::test] +async fn a_session_without_a_worktree_costs_no_probe() { + let fx = DaemonFixture::new(); + let log = TempDir::new().unwrap(); + let log_path = log.path().join("git-calls.log"); + + // Session A owns a real (pristine, pushed) worktree; session B has none. + let repo_a = TempDir::new().unwrap(); + init_repo(repo_a.path()); + init_bare_repo(&repo_a.path().join("origin.git")); + push_all_to_origin(repo_a.path(), &repo_a.path().join("origin.git")); + fx.manager + .create(request(repo_a.path(), repo_a.path(), "feat/probed")) + .await + .expect("create A"); + + let cwd_b = TempDir::new().unwrap(); + let mut req_b = request(cwd_b.path(), cwd_b.path(), "feat/unused"); + req_b.worktree = None; + fx.manager.create(req_b).await.expect("create B"); + + // Shim `git` AFTER setup so only watcher-driven probes get logged. + let real_git = String::from_utf8( + Command::new("sh") + .arg("-c") + .arg("command -v git") + .output() + .expect("locate git") + .stdout, + ) + .unwrap() + .trim() + .to_string(); + let shim_dir = TempDir::new().unwrap(); + let script = format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> {}\nexec {} \"$@\"\n", + log_path.display(), + real_git, + ); + std::fs::write(shim_dir.path().join("git"), script).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + shim_dir.path().join("git"), + std::fs::Permissions::from_mode(0o755), + ) + .unwrap(); + } + let old_path = std::env::var("PATH").unwrap_or_default(); + std::env::set_var( + "PATH", + format!("{}:{}", shim_dir.path().display(), old_path), + ); + + fx.manager.start_liveness_watcher_with_blocked_work( + Duration::from_millis(50), + Duration::from_millis(100), + ); + + // The pass demonstrably runs and probed A's tree... + wait_for_async(Duration::from_secs(5), || async { + fx.manager + .snapshot() + .await + .iter() + .any(|s| s.worktree.is_some() && s.blocked_work == Some(InTheWay::default())) + }) + .await; + + std::env::set_var("PATH", &old_path); + + let calls = std::fs::read_to_string(&log_path).unwrap_or_default(); + assert!( + !calls.is_empty(), + "the pass never probed anything — the shim saw no git call at all" + ); + // ...while B — no worktree, so nothing to probe — was never touched. + assert!( + !calls.contains(cwd_b.path().to_str().unwrap()), + "a worktree-less session was probed:\n{calls}" + ); +} diff --git a/crates/maestro-daemon/tests/branches.rs b/crates/maestro-daemon/tests/branches.rs new file mode 100644 index 0000000..cdb2191 --- /dev/null +++ b/crates/maestro-daemon/tests/branches.rs @@ -0,0 +1,142 @@ +//! 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()), + std::sync::Arc::new(fx.retained_store()), + )); + 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 `/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 { + 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::>(), + ["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()); +} diff --git a/crates/maestro-daemon/tests/common/mod.rs b/crates/maestro-daemon/tests/common/mod.rs index 2e1e96a..a079660 100644 --- a/crates/maestro-daemon/tests/common/mod.rs +++ b/crates/maestro-daemon/tests/common/mod.rs @@ -93,6 +93,38 @@ fn run(cmd: &mut Command) { ); } +/// An empty bare repository at `dir`, usable as a push target (#79). +pub fn init_bare_repo(dir: &Path) { + run(Command::new("git") + .arg("init") + .arg("-q") + .arg("--bare") + .arg(dir.to_str().expect("non-utf8 bare repo path"))); +} + +/// Push every branch of `repo` to `origin`, creating remote-tracking refs. +/// +/// The unsaved-work probe (#79) counts commits reachable from no +/// remote-tracking ref as unpushed, so tests that want a killable worktree +/// must give its branch one. Safe to call again: re-adding an existing +/// remote is ignored. +pub fn push_all_to_origin(repo: &Path, origin: &Path) { + let _ = Command::new("git") + .arg("-C") + .arg(repo) + .args([ + "remote", + "add", + "origin", + origin.to_str().expect("non-utf8 origin path"), + ]) + .status(); + run(Command::new("git") + .arg("-C") + .arg(repo) + .args(["push", "-q", "--all", "origin"])); +} + /// Async polling sibling of [`wait_for`] for tokio tests. pub async fn wait_for_async(timeout: Duration, mut cond: F) where @@ -115,7 +147,6 @@ pub struct DaemonFixture { pub tmux: TmuxFixture, pub manager: SessionManager, pub state_dir: TempDir, - pub worktree_root: TempDir, } impl DaemonFixture { @@ -132,9 +163,9 @@ impl DaemonFixture { fn build(history_days: Option) -> 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, @@ -145,11 +176,15 @@ impl DaemonFixture { manager = manager.with_spawn_history(SpawnHistory::new(Self::history_path(&state_dir), days)); } + // Same wiring as production: the manager records retained worktrees + // into the same dir the API reads back from (#78/#81). + let retained = + maestro_daemon::retained_store::RetainedStore::new(Self::retained_path(&state_dir)); + manager = manager.with_retained_store(retained.clone()); Self { tmux, manager, state_dir, - worktree_root, } } @@ -157,6 +192,10 @@ impl DaemonFixture { state_dir.path().join("spawn-history.jsonl") } + fn retained_path(state_dir: &TempDir) -> PathBuf { + state_dir.path().join("retained-worktrees") + } + pub fn spawn_history_path(&self) -> PathBuf { Self::history_path(&self.state_dir) } @@ -165,6 +204,15 @@ impl DaemonFixture { self.state_dir.path().join("sessions") } + /// Retained-worktree store rooted at the fixture's own state dir (#78). + pub fn retained_store(&self) -> maestro_daemon::retained_store::RetainedStore { + maestro_daemon::retained_store::RetainedStore::new(self.retained_dir()) + } + + pub fn retained_dir(&self) -> PathBuf { + self.state_dir.path().join("retained-worktrees") + } + pub fn fifo_dir(&self) -> PathBuf { self.state_dir.path().join("fifos") } diff --git a/crates/maestro-daemon/tests/local_api.rs b/crates/maestro-daemon/tests/local_api.rs index c1be910..c5d1ab7 100644 --- a/crates/maestro-daemon/tests/local_api.rs +++ b/crates/maestro-daemon/tests/local_api.rs @@ -9,7 +9,7 @@ use std::time::Duration; use common::DaemonFixture; use futures_util::{SinkExt, StreamExt}; use maestro_daemon::local_api; -use maestro_protocol::{CreateSessionRequest, Session}; +use maestro_protocol::{ApiError, CreateSessionRequest, Session, WorktreeSpec}; use tokio::net::TcpListener; use tokio::sync::oneshot; use tokio_tungstenite::tungstenite::Message as WsMessage; @@ -25,7 +25,25 @@ struct Harness { _fx: DaemonFixture, } +impl Harness { + /// Store over the same dir the manager records into. + fn fx_retained(&self) -> maestro_daemon::retained_store::RetainedStore { + self._fx.retained_store() + } +} + 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, +) -> Harness { let fx = DaemonFixture::new(); let manager = fx.manager.clone(); // Built-in presets (claude/opencode/zsh + custom) so `/api/info` advertises @@ -36,6 +54,8 @@ async fn spawn(hostname: &str) -> Harness { hostname, "/home/test", presets, + projects, + std::sync::Arc::new(fx.retained_store()), )); let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) .await @@ -153,6 +173,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 = 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 = 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 @@ -208,6 +346,107 @@ async fn delete_owned_session_kills_it() { assert!(!h.manager.has(session.id).await); } +/// The kill guard on the wire (#79): `DELETE /api/sessions/:id` answers 409 +/// with the structured breakdown, and the session keeps running. +#[tokio::test] +async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the_breakdown() { + let h = spawn("naps-laptop").await; + let repo = tempfile::TempDir::new().unwrap(); + common::init_repo(repo.path()); + let session = h + .manager + .create(CreateSessionRequest { + cwd: repo.path().to_path_buf(), + command: "sleep 60".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(WorktreeSpec { + base_repo: repo.path().to_path_buf(), + branch: "feat/guarded".into(), + create_branch: true, + }), + }) + .await + .unwrap(); + std::fs::write( + repo.path() + .join("worktrees") + .join("feat") + .join("guarded") + .join("README"), + b"unsaved edits\n", + ) + .unwrap(); + + let resp = reqwest::Client::new() + .delete(url(h.addr, &format!("/api/sessions/{}", session.id))) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::CONFLICT); + let body: ApiError = resp.json().await.unwrap(); + assert_eq!(body.error.code, "kill_guarded"); + let breakdown = body.error.unsaved_work.expect("breakdown on the wire"); + assert_eq!(breakdown.uncommitted_files, 1); + + assert!(h.manager.has(session.id).await, "the session keeps running"); +} + +/// `?force=true` on the wire (#81): the refusal is overridden, the session +/// ends, and the answer is 200 with the retained worktree named — path, +/// branch, reason — while the directory survives on disk. +#[tokio::test] +async fn delete_with_force_ends_the_session_and_answers_what_was_retained() { + let h = spawn("naps-laptop").await; + let repo = tempfile::TempDir::new().unwrap(); + common::init_repo(repo.path()); + let wt_path = repo + .path() + .join("worktrees") + .join("feat") + .join("guarded") + .to_path_buf(); + let session = h + .manager + .create(CreateSessionRequest { + cwd: repo.path().to_path_buf(), + command: "sleep 60".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(WorktreeSpec { + base_repo: repo.path().to_path_buf(), + branch: "feat/guarded".into(), + create_branch: true, + }), + }) + .await + .unwrap(); + std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap(); + + let resp = reqwest::Client::new() + .delete(url(h.addr, &format!("/api/sessions/{}", session.id))) + .query(&[("force", "true")]) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::OK); + let body: maestro_protocol::KillOutcome = resp.json().await.unwrap(); + let retained = body.retained.expect("force names what was kept"); + assert_eq!(retained.path, wt_path); + assert_eq!(retained.branch, "feat/guarded"); + assert_eq!( + retained.reason, + maestro_protocol::RetainedReason::ForceKilled + ); + + // The tree would have blocked; force must not have removed it. + assert!(wt_path.exists(), "a forced kill never removes the worktree"); + // And the store entry exists, so reclaim can find it (#80). + let all = h.fx_retained().load_all().unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].id, retained.id); +} + #[tokio::test] async fn attach_ws_streams_pty_bytes() { let h = spawn("naps-laptop").await; @@ -298,7 +537,7 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() { other => panic!("expected SessionAdded, got {other:?}"), } - h.manager.kill(created.id).await.expect("kill"); + h.manager.kill(created.id, false).await.expect("kill"); // Activity ticks may interleave; the removal is what we are waiting for. let deadline = tokio::time::Instant::now() + Duration::from_secs(5); @@ -362,3 +601,234 @@ async fn next_event(ws: &mut WsClient) -> maestro_protocol::CatalogEvent { } } } + +// --------------------------------------------------------------------------- +// GET /api/worktrees + DELETE /api/worktrees/:id (#80) +// --------------------------------------------------------------------------- + +/// `git -C dir args`, asserting success — the worktree endpoints are tested +/// against real git, like everything else in this crate. +fn git(dir: &std::path::Path, args: &[&str]) { + let out = std::process::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) + ); +} + +struct RetainedFixture { + repo: tempfile::TempDir, + wt: std::path::PathBuf, +} + +/// A real repo with a linked worktree for branch `feat/login` whose tree holds +/// an uncommitted tracked edit and an unpushed commit. +fn retained_fixture() -> RetainedFixture { + let repo = tempfile::tempdir().unwrap(); + common::init_repo(repo.path()); + // A pushed main, so "unpushed" means commits made on the feature branch, + // not the shared history. + let remote = repo.path().join("remote.git"); + std::fs::create_dir_all(&remote).unwrap(); + let remote_s = remote.to_string_lossy().into_owned(); + git(&remote, &["init", "-q", "--bare"]); + git(repo.path(), &["remote", "add", "origin", &remote_s]); + git(repo.path(), &["push", "-q", "-u", "origin", "main"]); + git( + repo.path(), + &[ + "worktree", + "add", + "-q", + "-b", + "feat/login", + "worktrees/feat/login", + ], + ); + let wt = repo.path().join("worktrees").join("feat").join("login"); + RetainedFixture { repo, wt } +} + +impl RetainedFixture { + fn commit_in_wt(&self, msg: &str) { + std::fs::write(self.wt.join("README"), msg.as_bytes()).unwrap(); + git(&self.wt, &["add", "-A"]); + git(&self.wt, &["commit", "-qm", msg]); + } + + fn branch_sha(&self, branch: &str) -> String { + let out = std::process::Command::new("git") + .arg("-C") + .arg(self.repo.path()) + .args(["rev-parse", "--verify", &format!("refs/heads/{branch}")]) + .output() + .unwrap(); + assert!(out.status.success(), "branch {branch} must exist"); + String::from_utf8_lossy(&out.stdout).trim().to_string() + } +} + +#[tokio::test] +async fn worktrees_lists_retained_entries_with_recomputed_breakdown() { + let h = spawn("naps-laptop").await; + let fx = retained_fixture(); + // Uncommitted tracked edit + an untracked file; the branch has no commits + // beyond main yet. + std::fs::write(fx.wt.join("README"), b"unsaved\n").unwrap(); + std::fs::write(fx.wt.join("scratch.txt"), b"loose\n").unwrap(); + + let store = h._fx.retained_store(); + let entry = store + .retain( + &fx.wt, + fx.repo.path(), + "feat/login", + maestro_daemon::retained_store::RetainedReason::ForceKilled, + ) + .unwrap(); + + let listed: Vec = + reqwest::get(url(h.addr, "/api/worktrees")) + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(listed.len(), 1); + let w = &listed[0]; + assert_eq!(w.id, entry.id); + assert_eq!(w.path, fx.wt); + assert_eq!(w.base_repo, fx.repo.path()); + assert_eq!(w.branch, "feat/login"); + assert_eq!(w.reason, maestro_protocol::RetainedReason::ForceKilled); + assert_eq!(w.in_the_way.uncommitted_files, 1); + assert_eq!(w.in_the_way.untracked_files, 1); + assert_eq!(w.in_the_way.unpushed_commits, 0); + + // The operator saves the work themselves. The stored entry is untouched — + // the next read must report the tree as it is *now*, not as it was when + // it was retained. + fx.commit_in_wt("saved"); + let listed: Vec = + reqwest::get(url(h.addr, "/api/worktrees")) + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(listed.len(), 1, "same entry, still retained"); + assert_eq!(listed[0].id, entry.id); + assert_eq!(listed[0].in_the_way.uncommitted_files, 0); + assert_eq!(listed[0].in_the_way.unpushed_commits, 1); +} + +#[tokio::test] +async fn reclaim_removes_directory_and_entry_but_never_the_branch() { + let h = spawn("naps-laptop").await; + let fx = retained_fixture(); + fx.commit_in_wt("precious"); + let sha = fx.branch_sha("feat/login"); + std::fs::write(fx.wt.join("README"), b"unsaved\n").unwrap(); // guard would refuse + + let store = h._fx.retained_store(); + let entry = store + .retain( + &fx.wt, + fx.repo.path(), + "feat/login", + maestro_daemon::retained_store::RetainedReason::ExitedWithUnsavedWork, + ) + .unwrap(); + + let resp = reqwest::Client::new() + .delete(url(h.addr, &format!("/api/worktrees/{}", entry.id))) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT); + + assert!(!fx.wt.exists(), "reclaim removes the directory"); + let listed: Vec = + reqwest::get(url(h.addr, "/api/worktrees")) + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(listed.is_empty(), "reclaim drops the store entry"); + assert_eq!( + fx.branch_sha("feat/login"), + sha, + "the branch and its commits survive" + ); +} + +#[tokio::test] +async fn reclaim_of_an_already_gone_directory_still_drops_the_entry() { + let h = spawn("naps-laptop").await; + let fx = retained_fixture(); + let store = h._fx.retained_store(); + let entry = store + .retain( + &fx.wt, + fx.repo.path(), + "feat/login", + maestro_daemon::retained_store::RetainedReason::ForceKilled, + ) + .unwrap(); + std::fs::remove_dir_all(&fx.wt).unwrap(); // operator rm -rf + + let resp = reqwest::Client::new() + .delete(url(h.addr, &format!("/api/worktrees/{}", entry.id))) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT); + assert!(store.load_all().unwrap().is_empty()); +} + +#[tokio::test] +async fn reclaim_unknown_id_returns_404() { + let h = spawn("naps-laptop").await; + let resp = reqwest::Client::new() + .delete(url(h.addr, &format!("/api/worktrees/{}", Uuid::new_v4()))) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn worktrees_prunes_entries_whose_directory_vanished_by_hand() { + let h = spawn("naps-laptop").await; + let fx = retained_fixture(); + let store = h._fx.retained_store(); + store + .retain( + &fx.wt, + fx.repo.path(), + "feat/login", + maestro_daemon::retained_store::RetainedReason::ForceKilled, + ) + .unwrap(); + std::fs::remove_dir_all(&fx.wt).unwrap(); + + let listed: Vec = + reqwest::get(url(h.addr, "/api/worktrees")) + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(listed.is_empty(), "a hand-removed directory is not listed"); + assert!( + store.load_all().unwrap().is_empty(), + "and its entry is gone" + ); +} diff --git a/crates/maestro-daemon/tests/session_decisions.rs b/crates/maestro-daemon/tests/session_decisions.rs index 967f6fd..aae5475 100644 --- a/crates/maestro-daemon/tests/session_decisions.rs +++ b/crates/maestro-daemon/tests/session_decisions.rs @@ -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 } @@ -211,7 +211,7 @@ async fn killing_a_dead_session_dismisses_it_without_touching_tmux() { fx.manager.start_liveness_watcher(Duration::from_millis(25)); wait_for_status(&fx.manager, s.id, SessionStatus::Dead).await; - fx.manager.kill(s.id).await.expect("dismiss"); + fx.manager.kill(s.id, false).await.expect("dismiss"); assert!(fx.manager.snapshot().await.is_empty()); assert!( @@ -371,7 +371,7 @@ async fn killing_an_unknown_session_is_reported_as_not_found() { let fx = Fx::new(); let err = fx .manager - .kill(uuid::Uuid::new_v4()) + .kill(uuid::Uuid::new_v4(), false) .await .expect_err("should fail"); assert_eq!(err.to_error_info().code, "not_found"); @@ -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(); diff --git a/crates/maestro-daemon/tests/session_manager.rs b/crates/maestro-daemon/tests/session_manager.rs index 6b1df55..6b4f016 100644 --- a/crates/maestro-daemon/tests/session_manager.rs +++ b/crates/maestro-daemon/tests/session_manager.rs @@ -4,12 +4,16 @@ mod common; use std::collections::HashMap; use std::path::PathBuf; +use std::process::Command; use std::sync::Arc; use std::time::Duration; -use common::{wait_for_async, DaemonFixture}; +use common::{ + init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture, TmuxFixture, +}; +use maestro_daemon::SessionError; use maestro_protocol::CreateSessionRequest; -use maestro_protocol::{CatalogEvent, SessionStatus}; +use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec}; use tempfile::TempDir; #[tokio::test] @@ -42,7 +46,7 @@ async fn create_then_kill_round_trip() { // FIFO should also exist assert!(fx.fifo_dir().join(format!("{}.fifo", session.id)).exists()); - fx.manager.kill(session.id).await.expect("kill"); + fx.manager.kill(session.id, false).await.expect("kill"); assert!(!fx.manager.has(session.id).await); assert!(!fx .sidecar_dir() @@ -77,7 +81,7 @@ async fn create_appends_to_spawn_history() { assert_eq!(recents[0].cwd, cwd.path()); assert_eq!(recents[0].last_command, "sleep 30"); - fx.manager.kill(session.id).await.expect("kill"); + fx.manager.kill(session.id, false).await.expect("kill"); // History is durable — it survives the kill that deletes the sidecar. assert_eq!(fx.manager.recent_dirs().len(), 1); } @@ -123,8 +127,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, @@ -184,7 +187,7 @@ async fn create_captures_initial_output_in_replay_buffer() { "first-line marker never reached the replay buffer — pipe-pane raced past the child's initial output", ); - fx.manager.kill(s.id).await.unwrap(); + fx.manager.kill(s.id, false).await.unwrap(); } /// Regression for NAP-228: after `recover`, PTY output must reach new @@ -214,7 +217,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 +225,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); @@ -251,7 +253,7 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() { String::from_utf8_lossy(&buf), ); - revived.kill(s.id).await.unwrap(); + revived.kill(s.id, false).await.unwrap(); } #[tokio::test] @@ -300,7 +302,7 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() { assert!(buf_a.windows(want.len()).any(|w| w == want), "A missed"); assert!(buf_b.windows(want.len()).any(|w| w == want), "B missed"); - fx.manager.kill(s.id).await.unwrap(); + fx.manager.kill(s.id, false).await.unwrap(); let _ = (cwd, PathBuf::from("/")); } @@ -359,12 +361,567 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() { ); // Dismissing it is what finally releases the artifacts. - fx.manager.kill(s.id).await.expect("dismiss"); + fx.manager.kill(s.id, false).await.expect("dismiss"); assert!(!fx.manager.has(s.id).await); assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists()); assert!(!fx.fifo_dir().join(format!("{}.fifo", s.id)).exists()); } +/// #83: the watcher refreshes `Session.blocked_work` on its own cadence — +/// first "nothing blocks" for a pristine tree, then the breakdown once the +/// tree picks up unsaved work, so clients can warn before the kill attempt. +#[tokio::test] +async fn watcher_refreshes_the_blocked_work_indicator() { + let fx = DaemonFixture::new(); + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + let wt_path = repo.path().join("worktrees").join("feat").join("indicator"); + + 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/indicator".into(), + create_branch: true, + }), + }) + .await + .expect("create"); + + // Pushed and clean: nothing in the way, and the indicator must say so. + let origin = TempDir::new().unwrap(); + init_bare_repo(origin.path()); + push_all_to_origin(repo.path(), origin.path()); + + fx.manager.start_liveness_watcher_with_blocked_work( + Duration::from_millis(50), + Duration::from_millis(100), + ); + + wait_for_async(Duration::from_secs(5), || async { + fx.manager.snapshot().await[0].blocked_work == Some(maestro_protocol::InTheWay::default()) + }) + .await; + + // Unsaved work appears; the next pass must surface it. + std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap(); + std::fs::write(wt_path.join("notes.txt"), b"untracked\n").unwrap(); + wait_for_async(Duration::from_secs(5), || async { + matches!( + &fx.manager.snapshot().await[0].blocked_work, + Some(b) if b.uncommitted_files >= 1 && b.untracked_files >= 1 + ) + }) + .await; + + // A steady state must not re-emit every tick (#83 change-detection). + let mut events = fx.manager.events(); + while events.try_recv().is_ok() {} + tokio::time::sleep(Duration::from_millis(400)).await; + assert!( + !matches!(events.try_recv(), Ok(CatalogEvent::SessionUpdated { .. })), + "an unchanged worktree must not re-emit SessionUpdated" + ); +} + +/// Cleanup on kill is unconditional once the guard passes (#79): even a spec +/// that says "don't clean up" loses its worktree — the branch was pushed, so +/// nothing in the tree is unique and removal goes ahead. +#[tokio::test] +async fn kill_removes_worktree_even_when_spec_says_keep() { + let fx = DaemonFixture::new(); + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + let wt_path = repo.path().join("worktrees").join("feat").join("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(), + create_branch: true, + }), + }) + .await + .expect("create"); + assert!(wt_path.exists()); + + // Push the branch so its commits are reachable from a remote-tracking ref. + let origin = TempDir::new().unwrap(); + init_bare_repo(origin.path()); + push_all_to_origin(repo.path(), origin.path()); + + fx.manager.kill(s.id, false).await.expect("kill"); + assert!(!wt_path.exists(), "worktree must be removed on kill"); +} + +/// The kill guard (#79): a worktree holding uncommitted changes refuses the +/// kill outright. Nothing happens — the session keeps running as the +/// operator's handle on their unfinished work, and the tree is untouched. +/// The refusal names what blocked it and how much of it there is. +#[tokio::test] +async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() { + let fx = DaemonFixture::new(); + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + let wt_path = repo.path().join("worktrees").join("feat").join("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(), + create_branch: true, + }), + }) + .await + .expect("create"); + std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap(); + std::fs::write(wt_path.join("notes.txt"), b"untracked\n").unwrap(); + + let err = fx + .manager + .kill(s.id, false) + .await + .expect_err("kill must refuse"); + let SessionError::KillGuarded(breakdown) = err else { + panic!("expected KillGuarded, got {err:?}"); + }; + assert_eq!(breakdown.uncommitted_files, 1); + assert_eq!(breakdown.untracked_files, 1); + assert!( + breakdown.unpushed_commits >= 1, + "a fresh branch has no remote-tracking ref" + ); + let message = breakdown.to_string(); + assert!(message.contains("1 uncommitted file"), "{message}"); + assert!(message.contains("1 untracked file"), "{message}"); + + // Nothing happened: the session survives and so does the tree. + assert!(fx.manager.has(s.id).await, "the session must keep running"); + assert!(wt_path.exists(), "the worktree must be untouched"); + + // A second kill after the work is saved goes through. + run_git_in(&wt_path, &["add", "."]); + run_git_in(&wt_path, &["commit", "-q", "-m", "saved"]); + let origin = TempDir::new().unwrap(); + init_bare_repo(origin.path()); + push_all_to_origin(repo.path(), origin.path()); + fx.manager + .kill(s.id, false) + .await + .expect("kill after saving"); + assert!(!fx.manager.has(s.id).await); +} + +/// Unpushed commits alone block the kill (#79): a feature branch almost +/// always has them, which is why `--force` (which retains) is the ordinary +/// way to end such a session. +#[tokio::test] +async fn killing_refuses_until_the_branch_is_pushed() { + let fx = DaemonFixture::new(); + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + let wt_path = repo.path().join("worktrees").join("feat").join("pushed"); + + let s = fx + .manager + .create(CreateSessionRequest { + cwd: repo.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(WorktreeSpec { + base_repo: repo.path().to_path_buf(), + branch: "feat/pushed".into(), + create_branch: true, + }), + }) + .await + .expect("create"); + + let err = fx + .manager + .kill(s.id, false) + .await + .expect_err("unpushed blocks"); + let SessionError::KillGuarded(breakdown) = err else { + panic!("expected KillGuarded, got {err:?}"); + }; + assert_eq!(breakdown.uncommitted_files, 0); + assert_eq!(breakdown.untracked_files, 0); + assert!(breakdown.unpushed_commits >= 1); + + let origin = TempDir::new().unwrap(); + init_bare_repo(origin.path()); + push_all_to_origin(repo.path(), origin.path()); + + fx.manager + .kill(s.id, false) + .await + .expect("pushed branch kills"); + assert!(!wt_path.exists()); + assert!(!fx.manager.has(s.id).await); +} + +/// `--force` overrides the refusal and nothing else (#81): the session ends, +/// but the guarded worktree is RETAINED — never removed — and enters the +/// retained store with reason `force-killed`. The outcome names what was +/// kept, so a client can say where the work went. +#[tokio::test] +async fn force_killing_a_guarded_worktree_retains_it_in_the_store() { + let fx = DaemonFixture::new(); + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + let wt_path = repo.path().join("worktrees").join("feat").join("forced"); + + 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/forced".into(), + create_branch: true, + }), + }) + .await + .expect("create"); + std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap(); + + // Without force the same kill is refused (#79). + assert!(fx.manager.kill(s.id, false).await.is_err()); + + let outcome = fx + .manager + .kill(s.id, true) + .await + .expect("force overrides the refusal"); + + // The tree would have blocked, so force must NOT have removed it... + assert!( + wt_path.exists(), + "a forced kill never removes a tree that would have blocked" + ); + // ...but the session itself still ends. + assert!(!fx.manager.has(s.id).await); + assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists()); + + let retained = outcome.retained.expect("the outcome names what was kept"); + assert_eq!(retained.path, wt_path); + assert_eq!(retained.branch, "feat/forced"); + assert_eq!( + retained.reason, + maestro_protocol::RetainedReason::ForceKilled + ); + assert!( + retained.in_the_way.uncommitted_files >= 1, + "the outcome carries what blocked the unforced kill" + ); + + // The store holds the same entry, so reclaim can find it later (#80). + let all = fx.retained_store().load_all().expect("load_all"); + assert_eq!(all.len(), 1); + assert_eq!(all[0].id, retained.id); + assert_eq!(all[0].path, wt_path); + assert_eq!(all[0].branch, "feat/forced"); +} + +/// Force overrides the *refusal*, not ordinary cleanup (#81): a tree whose +/// guard is clear never needed the override, so a forced kill removes it as +/// usual and retains nothing. +#[tokio::test] +async fn force_on_a_clear_worktree_removes_it_as_usual() { + let fx = DaemonFixture::new(); + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + let wt_path = repo.path().join("worktrees").join("feat").join("clear"); + + 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/clear".into(), + create_branch: true, + }), + }) + .await + .expect("create"); + + let origin = TempDir::new().unwrap(); + init_bare_repo(origin.path()); + push_all_to_origin(repo.path(), origin.path()); + + let outcome = fx.manager.kill(s.id, true).await.expect("kill"); + assert!(!wt_path.exists(), "a clear tree needs no override"); + assert_eq!(outcome.retained, None); + assert!(fx.retained_store().load_all().unwrap().is_empty()); +} + +/// Ignored files NEVER block (#79): build output like `target/` exists in +/// every Rust checkout, and an ignored-only worktree stays killable. +#[tokio::test] +async fn killing_an_ignored_only_worktree_succeeds() { + let fx = DaemonFixture::new(); + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + // .gitignore must be committed before the worktree branches off, or it + // would itself read as an untracked file. + std::fs::write(repo.path().join(".gitignore"), b"/target\n").unwrap(); + run_git_in(repo.path(), &["add", ".gitignore"]); + run_git_in(repo.path(), &["commit", "-q", "-m", "ignore target"]); + let origin = TempDir::new().unwrap(); + init_bare_repo(origin.path()); + + let wt_path = repo.path().join("worktrees").join("feat").join("rusty"); + let s = fx + .manager + .create(CreateSessionRequest { + cwd: repo.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(WorktreeSpec { + base_repo: repo.path().to_path_buf(), + branch: "feat/rusty".into(), + create_branch: true, + }), + }) + .await + .expect("create"); + std::fs::create_dir_all(wt_path.join("target")).unwrap(); + std::fs::write(wt_path.join("target").join("app.o"), b"binary\n").unwrap(); + + // Branch pushed; the only dirt is ignored output. + push_all_to_origin(repo.path(), origin.path()); + + fx.manager + .kill(s.id, false) + .await + .expect("ignored files never block"); + assert!(!wt_path.exists()); + assert!(!fx.manager.has(s.id).await); +} + +/// Natural exit (#82): a session that ends on its own never reaches the kill +/// path, so the watcher applies the kill's rule itself. A worktree holding +/// unsaved work is RETAINED into the store with reason +/// `exited-with-unsaved-work`; the session stays in the catalog marked dead +/// until dismissed — and dismissing it afterwards neither refuses nor +/// retains a second time. +#[tokio::test] +async fn natural_exit_of_a_guarded_worktree_retains_it_in_the_store() { + let fx = DaemonFixture::new(); + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + let wt_path = repo.path().join("worktrees").join("feat").join("exited"); + + 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/exited".into(), + create_branch: true, + }), + }) + .await + .expect("create"); + std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap(); + + // The command finishes / the operator types exit: tmux loses the session. + fx.tmux.tmux.kill_session(s.id).expect("kill tmux directly"); + fx.manager.start_liveness_watcher(Duration::from_millis(50)); + wait_for_async(Duration::from_secs(3), || async { + fx.manager.status(s.id).await == Some(SessionStatus::Dead) + }) + .await; + + // The tree would have blocked a kill, so the exit kept it... + assert!( + wt_path.exists(), + "a dirty worktree must be retained on natural exit" + ); + // ...in the store, under the exit reason. + let all = fx.retained_store().load_all().expect("load_all"); + assert_eq!(all.len(), 1); + assert_eq!(all[0].path, wt_path); + assert_eq!(all[0].branch, "feat/exited"); + assert_eq!( + all[0].reason, + maestro_protocol::RetainedReason::ExitedWithUnsavedWork + ); + + // The session itself stays until dismissed; its worktree reference is + // settled, so dismissal is neither refused nor retained twice. + // (`Dead` is observable a moment before the settle step finishes.) + wait_for_async(Duration::from_secs(3), || async { + let s = &fx.manager.snapshot().await[0]; + s.worktree.is_none() && s.blocked_work.is_none() + }) + .await; + let snap = fx.manager.snapshot().await; + assert_eq!(snap.len(), 1); + assert_eq!(snap[0].worktree, None); + + fx.manager.kill(s.id, false).await.expect("dismiss"); + assert!(!fx.manager.has(s.id).await); + assert!( + wt_path.exists(), + "dismissal must not remove what was retained" + ); + assert_eq!( + fx.retained_store().load_all().unwrap().len(), + 1, + "dismissal must not record a second entry for the same tree" + ); +} + +/// Natural exit (#82), pristine side of the same rule: nothing in the tree, +/// nothing to keep — the worktree is removed and no store entry appears. +#[tokio::test] +async fn natural_exit_of_a_pristine_worktree_removes_it() { + let fx = DaemonFixture::new(); + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + let wt_path = repo.path().join("worktrees").join("feat").join("clean"); + + 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/clean".into(), + create_branch: true, + }), + }) + .await + .expect("create"); + + // Push everything so the branch has no unpushed commits: fully pristine. + let origin = TempDir::new().unwrap(); + init_bare_repo(origin.path()); + push_all_to_origin(repo.path(), origin.path()); + + fx.tmux.tmux.kill_session(s.id).expect("kill tmux directly"); + fx.manager.start_liveness_watcher(Duration::from_millis(50)); + wait_for_async(Duration::from_secs(3), || async { + fx.manager.status(s.id).await == Some(SessionStatus::Dead) + }) + .await; + + assert!( + !wt_path.exists(), + "a pristine worktree leaves nothing behind" + ); + assert!( + fx.retained_store().load_all().unwrap().is_empty(), + "nothing was retained" + ); + assert!( + fx.manager.has(s.id).await, + "the session stays until dismissed" + ); +} + +/// When nothing can be recorded — here: no retained store wired — the exit +/// destroys nothing. The tree stays on disk AND stays attached to the dead +/// record, where dismissing it later still goes through #81's forced-kill +/// retention. Losing the store must not lose the work. +#[tokio::test] +async fn natural_exit_without_a_retained_store_destroys_nothing() { + let tmux = TmuxFixture::new(); + let state_dir = TempDir::new().unwrap(); + let manager = maestro_daemon::SessionManager::new( + Arc::new(tmux.tmux.clone()), + maestro_daemon::sidecar::SidecarStore::new(state_dir.path().join("sessions")), + maestro_daemon::worktree::WorktreeManager::new(None), + state_dir.path().join("fifos"), + ); + // Deliberately NO with_retained_store. + + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + let wt_path = repo.path().join("worktrees").join("feat").join("nostore"); + + let s = 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/nostore".into(), + create_branch: true, + }), + }) + .await + .expect("create"); + std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap(); + + tmux.tmux.kill_session(s.id).expect("kill tmux directly"); + manager.start_liveness_watcher(Duration::from_millis(50)); + wait_for_async(Duration::from_secs(3), || async { + manager.status(s.id).await == Some(SessionStatus::Dead) + }) + .await; + + assert!( + wt_path.exists(), + "nothing may be destroyed when it cannot be recorded" + ); + let snap = manager.snapshot().await; + assert!( + snap[0].worktree.is_some(), + "the tree must stay attached to the record so a later forced kill can retain it" + ); +} + +fn run_git_in(dir: &std::path::Path, args: &[&str]) { + let out = Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .expect("git spawn"); + assert!( + out.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + #[tokio::test] async fn liveness_watcher_leaves_running_sessions_alone() { let fx = DaemonFixture::new(); @@ -388,5 +945,5 @@ async fn liveness_watcher_leaves_running_sessions_alone() { tokio::time::sleep(Duration::from_millis(400)).await; assert!(fx.manager.has(s.id).await, "live session got reaped"); - fx.manager.kill(s.id).await.unwrap(); + fx.manager.kill(s.id, false).await.unwrap(); } diff --git a/crates/maestro-daemon/tests/sidecar.rs b/crates/maestro-daemon/tests/sidecar.rs index f270d1e..bf864ed 100644 --- a/crates/maestro-daemon/tests/sidecar.rs +++ b/crates/maestro-daemon/tests/sidecar.rs @@ -19,6 +19,7 @@ fn sample(id: Uuid) -> Session { command: "bash".into(), env: HashMap::from([("FOO".into(), "bar".into())]), metadata: HashMap::from([("name".into(), Value::String("maestro-impl".into()))]), + worktree: None, status: SessionStatus::Running, created_at: DateTime::parse_from_rfc3339("2026-05-24T12:00:00Z") .unwrap() @@ -27,6 +28,7 @@ fn sample(id: Uuid) -> Session { activity: Default::default(), last_activity_at: chrono::Utc::now(), current_cwd: Some(PathBuf::from("/tmp/work/crates")), + blocked_work: None, } } diff --git a/crates/maestro-daemon/tests/worktree.rs b/crates/maestro-daemon/tests/worktree.rs index 1087ac6..c7c8dbf 100644 --- a/crates/maestro-daemon/tests/worktree.rs +++ b/crates/maestro-daemon/tests/worktree.rs @@ -6,8 +6,9 @@ mod common; use std::path::PathBuf; use std::process::Command; -use common::init_repo; +use common::{init_bare_repo, init_repo, push_all_to_origin}; use maestro_daemon::worktree::WorktreeManager; +use maestro_daemon::worktree_probe::in_the_way; use tempfile::TempDir; fn worktree_branches(repo: &PathBuf) -> Vec { @@ -31,9 +32,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 +43,82 @@ 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").join("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())); + + // The repo's basename sits between the root and the branch slug, so two + // repos spawning the same branch name don't target one directory. + let repo_name = repo.file_name().unwrap(); + let path = mgr.add_new(&repo, "feat/example").expect("add worktree"); + // The override root disambiguates same-named checkouts with a digest of + // the full repo path, and the branch keeps its own shape. + // /-/feat/example + let prefix = format!("{}-", repo_name.to_string_lossy()); + let repo_dir_name = path + .strip_prefix(wt_root.path()) + .unwrap() + .components() + .next() + .unwrap(); + assert!( + repo_dir_name + .as_os_str() + .to_string_lossy() + .starts_with(&prefix), + "{path:?}" + ); + assert!(path.ends_with("feat/example"), "{path:?}"); + assert!(path.exists()); +} + +/// Anything at all occupying the target path — ours, the operator's, or an +/// unrelated directory — is sidestepped, not adopted and not reported (#77). +#[test] +fn an_occupied_target_path_gets_a_numeric_suffix() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + // A non-git directory sits exactly where the worktree would go. + let squatter = repo.join("worktrees").join("feat").join("collide"); + std::fs::create_dir_all(&squatter).unwrap(); + std::fs::write(squatter.join("operator-notes.txt"), b"mine\n").unwrap(); + + let mgr = WorktreeManager::new(None); + let path = mgr.add_new(&repo, "feat/collide").expect("sidesteps"); + assert_eq!(path, repo.join("worktrees").join("feat").join("collide-2")); + assert!(path.exists()); + assert!( + squatter.join("operator-notes.txt").exists(), + "the occupant is untouched" + ); +} + #[test] fn add_twice_generates_distinct_paths() { let repo_dir = TempDir::new().unwrap(); @@ -49,15 +126,132 @@ 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()); } +/// The unsaved-work probe (#79) counts uncommitted tracked changes. +#[test] +fn the_probe_counts_uncommitted_tracked_changes() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + let mgr = WorktreeManager::new(None); + let path = mgr.add_new(&repo, "feat/dirty").expect("add"); + std::fs::write(path.join("README"), b"unsaved edits\n").unwrap(); + + let w = in_the_way(&path).expect("probe"); + assert_eq!(w.uncommitted_files, 1); + assert_eq!(w.untracked_files, 0); +} + +/// Untracked sources count; a staged new file is also unsaved tracked work. +#[test] +fn the_probe_counts_untracked_files() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + let mgr = WorktreeManager::new(None); + let path = mgr.add_new(&repo, "feat/untracked").expect("add"); + std::fs::write(path.join("notes.txt"), b"wip\n").unwrap(); + std::fs::write(path.join("more.txt"), b"also wip\n").unwrap(); + + let w = in_the_way(&path).expect("probe"); + assert_eq!(w.untracked_files, 2); + assert_eq!(w.uncommitted_files, 0); + + run_git(&path, &["add", "notes.txt"]); + let w = in_the_way(&path).expect("probe"); + assert_eq!(w.uncommitted_files, 1, "staged counts as uncommitted"); + assert_eq!(w.untracked_files, 1); +} + +/// Ignored files NEVER block: every Rust checkout has a `target/`, and +/// counting ignored output would leave no session killable (#79). +#[test] +fn the_probe_ignores_ignored_files() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + // .gitignore must be committed, or it would itself read as untracked. + std::fs::write(repo.join(".gitignore"), b"/target\n").unwrap(); + run_git(&repo, &["add", ".gitignore"]); + run_git(&repo, &["commit", "-q", "-m", "ignore target"]); + + let mgr = WorktreeManager::new(None); + let path = mgr.add_new(&repo, "feat/builds").expect("add"); + std::fs::create_dir_all(path.join("target")).unwrap(); + std::fs::write(path.join("target").join("app.o"), b"binary\n").unwrap(); + + let w = in_the_way(&path).expect("probe"); + assert_eq!(w.uncommitted_files, 0); + assert_eq!(w.untracked_files, 0); + + // With the branch pushed too, an ignored-only tree reads fully clean. + let origin_dir = TempDir::new().unwrap(); + init_bare_repo(origin_dir.path()); + push_all_to_origin(&repo, origin_dir.path()); + let w = in_the_way(&path).expect("probe after push"); + assert!(w.is_clear(), "ignored-only worktree must read clean: {w:?}"); +} + +/// Commits reachable from no remote-tracking ref block; pushing the branch +/// clears them. With no remote at all every commit is unreachable from one, +/// which is exactly why feature branches almost always refuse (#79). +#[test] +fn the_probe_counts_unpushed_commits_until_the_branch_is_pushed() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + let mgr = WorktreeManager::new(None); + let path = mgr.add_new(&repo, "feat/branch").expect("add"); + + let w = in_the_way(&path).expect("probe"); + assert!( + w.unpushed_commits >= 1, + "a fresh branch has no remote-tracking ref: {w:?}" + ); + + let origin_dir = TempDir::new().unwrap(); + init_bare_repo(origin_dir.path()); + push_all_to_origin(&repo, origin_dir.path()); + + let w = in_the_way(&path).expect("probe after push"); + assert_eq!(w.unpushed_commits, 0, "{w:?}"); + assert!(w.is_clear(), "pushed and clean must be empty: {w:?}"); +} + +/// The full breakdown names each blocker with its count. +#[test] +fn the_probe_reports_every_blocker_at_once() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + let mgr = WorktreeManager::new(None); + let path = mgr.add_new(&repo, "feat/mixed").expect("add"); + std::fs::write(path.join("README"), b"edited\n").unwrap(); + std::fs::write(path.join("scratch.txt"), b"untracked\n").unwrap(); + + let w = in_the_way(&path).expect("probe"); + assert_eq!(w.uncommitted_files, 1); + assert_eq!(w.untracked_files, 1); + assert!(w.unpushed_commits >= 1); + let summary = w.to_string(); + assert!(summary.contains("1 uncommitted file"), "{summary}"); + assert!(summary.contains("1 untracked file"), "{summary}"); + assert!(summary.contains("unpushed commit"), "{summary}"); +} + #[test] fn remove_prunes_worktree() { let repo_dir = TempDir::new().unwrap(); @@ -65,9 +259,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 +273,138 @@ 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:?}" + ); +} + +/// Removal is guarded upstream now (#79): the kill refuses unsaved work +/// before anything is torn down, so by the time `remove` runs the tree is +/// clean. Git still refuses a dirty tree on its own — this asserts that last +/// line of defense — and committed-but-unpushed work does not block removal, +/// because `worktree remove` keeps `refs/heads/` and the commits stay +/// reachable. +#[test] +fn remove_still_relies_on_git_refusing_a_dirty_tree() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + 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"); + + 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 +421,152 @@ 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}"); } + +/// The project picker lists linked worktrees as pickable rows, so `base_repo` +/// is easily a worktree rather than the main checkout. Placing inside it would +/// nest one worktree in another — and git hides a registered worktree's +/// contents from the outer `git status`, so the outer tree reads clean and +/// removing it deletes the inner one's uncommitted work (#54 review). +#[test] +fn placement_resolves_a_linked_worktree_to_the_main_checkout() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + let mgr = WorktreeManager::new(None); + let outer = mgr.add_new(&repo, "feat/outer").expect("add outer"); + + // Spawn again with the *worktree* as the base, the way the picker would. + let inner = mgr.add_new(&outer, "feat/inner").expect("add inner"); + assert!( + !inner.starts_with(&outer), + "must not nest: {inner:?} inside {outer:?}" + ); + assert!( + inner.starts_with(repo.canonicalize().unwrap().join("worktrees")), + "placed against the main checkout: {inner:?}" + ); +} + +/// Belt and braces for a nesting that predates this daemon: refuse to remove +/// a worktree that has another registered underneath it. +#[test] +fn remove_refuses_a_worktree_holding_a_nested_one() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + let mgr = WorktreeManager::new(None); + let outer = mgr.add_new(&repo, "feat/outer").expect("add outer"); + // Register one by hand inside `outer`, bypassing our placement. + let nested = outer.join("worktrees").join("hand-rolled"); + run_git( + &repo, + &[ + "worktree", + "add", + "-b", + "feat/hand", + nested.to_str().unwrap(), + ], + ); + std::fs::write(nested.join("notes.txt"), b"unsaved\n").unwrap(); + + mgr.remove(&repo, &outer) + .expect_err("a worktree holding a nested one must be refused"); + assert!(nested.join("notes.txt").exists(), "inner work survives"); +} + +/// Two repos spawning the same branch name under one override root must not +/// collide (#54 review). +#[test] +fn override_root_keeps_repos_apart() { + let root_dir = TempDir::new().unwrap(); + let a_dir = TempDir::new().unwrap(); + let b_dir = TempDir::new().unwrap(); + init_repo(a_dir.path()); + init_repo(b_dir.path()); + + let mgr = WorktreeManager::new(Some(root_dir.path().to_path_buf())); + let a = mgr.add_new(a_dir.path(), "feat/shared").expect("repo a"); + let b = mgr.add_new(b_dir.path(), "feat/shared").expect("repo b"); + assert_ne!(a, b, "same branch name in two repos must not collide"); +} + +/// Flattening `/` and `.` to `-` made these three share one directory, so the +/// losers could never get a worktree (#54 review). +#[test] +fn similar_branch_names_get_distinct_directories() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + let mgr = WorktreeManager::new(None); + let a = mgr.add_new(&repo, "feat/x").expect("feat/x"); + let b = mgr.add_new(&repo, "feat.x").expect("feat.x"); + let c = mgr.add_new(&repo, "feat-x").expect("feat-x"); + assert_ne!(a, b); + assert_ne!(b, c); + assert_ne!(a, c); + assert!(a.ends_with("feat/x"), "{a:?}"); +} + +/// A guarded leftover stays on disk untouched, and is never adopted (#77). +/// Re-spawning its branch is refused by git itself — the branch is still +/// checked out inside the leftover — which the caller surfaces. +#[test] +fn a_guarded_leftover_is_never_adopted() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + let mgr = WorktreeManager::new(None); + let first = mgr.add_new(&repo, "feat/resume").expect("add"); + std::fs::write(first.join("notes.txt"), b"unsaved\n").unwrap(); + + // Removal is refused — git itself refuses a dirty tree — leaving it on + // disk; the daemon-side guard lives on the kill (#79). + mgr.remove(&repo, &first) + .expect_err("dirty tree is guarded"); + + let err = mgr + .add_existing(&repo, "feat/resume") + .expect_err("a branch already checked out cannot be spawned twice"); + assert!(format!("{err}").contains("git exited"), "{err}"); + assert!( + first.join("notes.txt").exists(), + "the leftover's unsaved work is untouched" + ); +} + +/// The `worktree_root` override gets the same collision suffix (#77). +#[test] +fn the_override_root_suffixes_collisions_too() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + let wt_root = TempDir::new().unwrap(); + let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf())); + + // First add settles `/-/` so the squatter + // can be created at the exact target. + mgr.add_new(&repo, "feat/first").expect("add first"); + let repo_dir_in_root = std::fs::read_dir(wt_root.path()) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + let squatter = repo_dir_in_root.join("feat").join("collide"); + std::fs::create_dir_all(&squatter).unwrap(); + + let path = mgr.add_new(&repo, "feat/collide").expect("sidesteps"); + assert_eq!(path, squatter.with_file_name("collide-2"), "{path:?}"); +} diff --git a/crates/maestro-e2e/src/client.rs b/crates/maestro-e2e/src/client.rs index e247222..6ba4410 100644 --- a/crates/maestro-e2e/src/client.rs +++ b/crates/maestro-e2e/src/client.rs @@ -11,7 +11,10 @@ use std::sync::Arc; use std::time::Duration; use futures_util::{SinkExt, StreamExt}; -use maestro_protocol::{CatalogEvent, CreateSessionRequest, DaemonClient, Session}; +use maestro_protocol::{ + BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, KillOutcome, Project, + RetainedWorktree, Session, +}; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::Message as WsMessage; use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; @@ -59,7 +62,29 @@ impl TestClient { } pub async fn kill_session(&self, id: Uuid) -> Result<(), ClientError> { - Ok(self.daemon.kill_session(id).await?) + Ok(self.daemon.kill_session(id, false).await.map(|_| ())?) + } + + /// Kill with `force=true`: overrides a kill refusal and retains the + /// guarded worktree (#81). Answers with what was kept. + pub async fn kill_session_forced(&self, id: Uuid) -> Result { + Ok(self.daemon.kill_session(id, true).await?) + } + + pub async fn list_worktrees(&self) -> Result, ClientError> { + Ok(self.daemon.list_worktrees().await?) + } + + pub async fn reclaim_worktree(&self, id: Uuid) -> Result<(), ClientError> { + Ok(self.daemon.reclaim_worktree(id).await?) + } + + pub async fn list_projects(&self) -> Result, ClientError> { + Ok(self.daemon.list_projects().await?) + } + + pub async fn list_branches(&self, path: &str) -> Result, ClientError> { + Ok(self.daemon.list_branches(path).await?) } /// Connect to the daemon's per-session WS and return the stream. The diff --git a/crates/maestro-e2e/src/daemon.rs b/crates/maestro-e2e/src/daemon.rs index 9c215a8..ca0e1b9 100644 --- a/crates/maestro-e2e/src/daemon.rs +++ b/crates/maestro-e2e/src/daemon.rs @@ -29,11 +29,32 @@ impl Daemon { Self::spawn_at(SocketAddr::from((Ipv4Addr::LOCALHOST, free_port()))).await } + /// Spawn with a config file pre-seeded at the daemon's expected path + /// (`$XDG_CONFIG_HOME/maestro/config.toml`). + pub async fn spawn_with_config(toml: &str) -> Self { + Self::spawn_config_at( + SocketAddr::from((Ipv4Addr::LOCALHOST, free_port())), + Some(toml), + ) + .await + } + /// Spawn on a caller-supplied bind address. pub async fn spawn_at(bind: SocketAddr) -> Self { + Self::spawn_config_at(bind, None).await + } + + /// Spawn on a caller-supplied bind address, with an optional config file. + async fn spawn_config_at(bind: SocketAddr, config: impl Into>) -> Self { let home = TempDir::new().expect("home tmpdir"); let tmux = TempDir::new().expect("tmux tmpdir"); + if let Some(toml) = config.into() { + let dir = home.path().join(".config").join("maestro"); + std::fs::create_dir_all(&dir).expect("config dir"); + std::fs::write(dir.join("config.toml"), toml).expect("write config.toml"); + } + let child = launch(bind, home.path(), tmux.path()); let me = Self { bind_addr: bind, diff --git a/crates/maestro-e2e/src/lib.rs b/crates/maestro-e2e/src/lib.rs index 9096913..630dc43 100644 --- a/crates/maestro-e2e/src/lib.rs +++ b/crates/maestro-e2e/src/lib.rs @@ -14,5 +14,5 @@ pub mod daemon; pub mod util; pub use build::workspace_target_bin; -pub use client::TestClient; +pub use client::{ClientError, TestClient}; pub use daemon::Daemon; diff --git a/crates/maestro-e2e/tests/e2e.rs b/crates/maestro-e2e/tests/e2e.rs index 5cb166a..be5e092 100644 --- a/crates/maestro-e2e/tests/e2e.rs +++ b/crates/maestro-e2e/tests/e2e.rs @@ -35,6 +35,28 @@ fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest { } } +/// A worktree spec built through its wire shape. The daemon and clients ship +/// together (#87 dropped `cleanup_on_kill` from the struct), so the JSON +/// object carries only the fields the lifecycle actually honours. +fn worktree_spec(base_repo: &std::path::Path, branch: &str, create_branch: bool) -> WorktreeSpec { + WorktreeSpec { + base_repo: base_repo.to_path_buf(), + branch: branch.into(), + create_branch, + } +} + +/// Spawn request carrying a worktree spec against `base`. +fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest { + CreateSessionRequest { + cwd: base.to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(worktree_spec(base, branch, create_branch)), + } +} + #[tokio::test(flavor = "multi_thread")] async fn lists_spawned_sessions() { let (_daemon, client) = bring_up().await; @@ -114,7 +136,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(); @@ -125,24 +147,21 @@ async fn cleanup_on_kill_removes_worktree() { command: "sleep 30".into(), env: HashMap::new(), metadata: HashMap::new(), - worktree: Some(WorktreeSpec { - base_repo: base.path().to_path_buf(), - branch: "feature/e2e".into(), - cleanup_on_kill: true, - }), + worktree: Some(worktree_spec(base.path(), "feature/e2e", true)), }; let s = client.spawn_session(&req).await.expect("spawn worktree"); - // The daemon's WorktreeManager puts new worktrees under - // `/worktrees/`. 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 `/worktrees/` + // (#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 /worktrees, got: {worktrees:?}" ); + let _origin = throwaway_origin(base.path()); client.kill_session(s.id).await.expect("kill"); // Give the daemon a moment to remove the worktree, then verify. @@ -160,6 +179,853 @@ async fn cleanup_on_kill_removes_worktree() { ); } +/// The kill guard (#79): killing a session whose worktree holds uncommitted +/// changes is refused outright. Nothing happens — the session keeps running +/// as the operator's handle on their unfinished work, the tree stays on disk, +/// and the refusal names what blocked it and how much. +#[tokio::test(flavor = "multi_thread")] +async fn kill_refuses_worktree_with_uncommitted_changes() { + let (_daemon, client) = bring_up().await; + + let base = TempDir::new().unwrap(); + init_repo(base.path()); + let wt_path = base + .path() + .join("worktrees") + .join("feature") + .join("unsaved"); + + let req = CreateSessionRequest { + cwd: base.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(worktree_spec(base.path(), "feature/unsaved", 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(); + + let err = client + .kill_session(s.id) + .await + .expect_err("kill must refuse"); + match err { + maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. }) => { + assert_eq!(info.code, "kill_guarded", "{info:?}"); + let breakdown = info.unsaved_work.expect("breakdown on the wire"); + assert_eq!(breakdown.uncommitted_files, 1); + } + other => panic!("expected a kill_guarded refusal, got {other:?}"), + } + + // The session survives and the dirty worktree stays findable on disk. + let sessions = client.list_sessions().await.unwrap_or_default(); + assert!( + sessions.iter().any(|x| x.id == s.id), + "a refused kill must keep the session alive" + ); + assert!( + wt_path.exists(), + "guarded worktree must be left on disk after kill" + ); + // Left findable, not silently half-deleted: git still registers it and + // the uncommitted edit survives byte for byte (#66). + assert!( + git_worktree_list(base.path()) + .iter() + .any(|w| w.contains("feature/unsaved")), + "guarded worktree must stay registered in git worktree list" + ); + assert_eq!( + std::fs::read(wt_path.join("README")).unwrap(), + b"unsaved edits\n", + "uncommitted changes must survive the guarded kill" + ); +} + +#[tokio::test(flavor = "multi_thread")] +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(worktree_spec(base.path(), "feature/existing", 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:?}" + ); + + let _origin = throwaway_origin(base.path()); + client.kill_session(s.id).await.expect("kill"); +} + +/// #66: a fresh branch spawns a worktree at `/worktrees/` and +/// the session actually runs inside it — `current_cwd` comes from tmux's live +/// pane state, not from the spawn request. +#[tokio::test(flavor = "multi_thread")] +async fn spawn_new_branch_creates_worktree_and_runs_in_it() { + let (_daemon, client) = bring_up().await; + + let base = TempDir::new().unwrap(); + init_repo(base.path()); + let wt_path = base.path().join("worktrees").join("feature").join("fresh"); + + let s = client + .spawn_session(&worktree_req(base.path(), "feature/fresh", true)) + .await + .expect("spawn new-branch worktree"); + + assert_eq!( + s.cwd, wt_path, + "session must report the worktree as its cwd" + ); + assert!(wt_path.is_dir(), "worktree directory must exist on disk"); + + // The activity watcher fills `current_cwd` from tmux's pane_current_path, + // so this is the daemon confirming where the process really runs. + wait_for_async( + "session's live cwd is the worktree", + Duration::from_secs(20), + || { + let client = client.clone(); + let id = s.id; + let want = wt_path.clone(); + async move { + client + .list_sessions() + .await + .ok() + .and_then(|ss| ss.iter().find(|s| s.id == id).cloned()) + .map(|s| s.current_cwd == Some(want.clone())) + .unwrap_or(false) + } + }, + ) + .await; + + let _origin = throwaway_origin(base.path()); + client.kill_session(s.id).await.expect("kill"); +} + +/// #66: a second spawn onto a branch that already has a worktree is rejected +/// with a structured worktree error; the first worktree stays intact. +#[tokio::test(flavor = "multi_thread")] +async fn second_spawn_on_same_branch_is_rejected() { + let (_daemon, client) = bring_up().await; + + let base = TempDir::new().unwrap(); + init_repo(base.path()); + + let first = client + .spawn_session(&worktree_req(base.path(), "feature/dup", true)) + .await + .expect("first spawn"); + let dup = client + .spawn_session(&worktree_req(base.path(), "feature/dup", true)) + .await; + match dup { + Err(maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. })) => { + assert_eq!( + info.code, "worktree_failed", + "expected worktree error: {info:?}" + ); + } + other => panic!("expected rejection for duplicate branch, got {other:?}"), + } + + // Exactly one registration, one directory, still usable. + // Count branch records only: the worktree path now contains the branch + // name too (`worktrees/feature/dup`), so a substring match over every line + // would count the same tree twice. + let holders = git_worktree_list(base.path()) + .iter() + .filter(|w| w.as_str() == "branch refs/heads/feature/dup") + .count(); + assert_eq!(holders, 1, "duplicate spawn must not register another tree"); + assert!( + base.path() + .join("worktrees") + .join("feature") + .join("dup") + .is_dir(), + "original worktree must be untouched" + ); + + let _origin = throwaway_origin(base.path()); + client.kill_session(first.id).await.expect("kill"); +} + +/// #66/#56: when the client picks a *linked worktree* as base_repo, placement +/// resolves through `--git-common-dir` to the main checkout, so the new tree +/// lands at `
/worktrees/` instead of nesting inside another +/// worktree. +#[tokio::test(flavor = "multi_thread")] +async fn spawn_from_linked_worktree_places_in_main_checkout() { + let (_daemon, client) = bring_up().await; + + let base = TempDir::new().unwrap(); + init_repo(base.path()); + let linked = base.path().join("linked"); + run_git( + &[ + "worktree", + "add", + "-q", + "-b", + "other", + &linked.to_string_lossy(), + ], + base.path(), + ); + + let s = client + .spawn_session(&worktree_req(&linked, "feature/nested", true)) + .await + .expect("spawn from linked worktree"); + + let want = base.path().join("worktrees").join("feature").join("nested"); + assert_eq!( + s.cwd, want, + "worktree must be placed under the main checkout" + ); + assert!(want.is_dir()); + assert!( + !s.cwd.starts_with(&linked), + "worktrees must never nest inside another worktree" + ); + + let _origin = throwaway_origin(base.path()); + client.kill_session(s.id).await.expect("kill"); +} + +// --------------------------------------------------------------------------- +// Worktree lifecycle: collisions, retention, reclaim (#88) +// --------------------------------------------------------------------------- + +/// #77/#88: the target path `/worktrees/` is occupied by a real +/// registered worktree the operator made by hand. The daemon sidesteps to the +/// suffixed directory and never touches the occupant — and both trees keep +/// working: two registered worktrees, two sessions, each running in its own. +#[tokio::test(flavor = "multi_thread")] +async fn occupied_path_gets_suffixed_directory_and_both_worktrees_work() { + let (_daemon, client) = bring_up().await; + + let base = TempDir::new().unwrap(); + init_repo(base.path()); + + // An operator-made worktree sits exactly where `feature/collide` would go. + // Its branch is a different one, so the requested branch stays creatable. + let squatter = base + .path() + .join("worktrees") + .join("feature") + .join("collide"); + run_git( + &[ + "worktree", + "add", + "-q", + "-b", + "operator/manual", + &squatter.to_string_lossy(), + ], + base.path(), + ); + + let s = client + .spawn_session(&worktree_req(base.path(), "feature/collide", true)) + .await + .expect("spawn onto occupied path"); + + let suffixed = base + .path() + .join("worktrees") + .join("feature") + .join("collide-2"); + assert_eq!( + s.cwd, suffixed, + "the daemon must take the suffixed directory, not the occupied one" + ); + assert!(suffixed.is_dir(), "suffixed worktree must exist on disk"); + + // Both trees are registered, distinct, and the squatter is byte-intact. + let registrations = git_worktree_list(base.path()); + assert!( + registrations + .iter() + .any(|l| l == &format!("worktree {}", squatter.canonicalize().unwrap().display())), + "operator's worktree must stay registered: {registrations:?}" + ); + assert!( + registrations.iter().any(|l| l.contains("collide-2")), + "suffixed worktree must be registered too: {registrations:?}" + ); + + // Both sessions run inside their own tree at the same time. + let sessions = client.list_sessions().await.expect("list"); + assert!( + sessions.iter().any(|x| x.id == s.id && x.cwd == suffixed), + "session must keep running in its suffixed tree" + ); + + let _origin = throwaway_origin(base.path()); + client.kill_session(s.id).await.expect("kill"); + + // Only the daemon-made trees go; the operator's manual one survives. + let after = git_worktree_list(base.path()); + assert!( + after + .iter() + .any(|l| l == &format!("worktree {}", squatter.canonicalize().unwrap().display())), + "operator's worktree must outlive the sessions: {after:?}" + ); +} + +/// #77/#88: the target path holds a plain directory the daemon never made and +/// git has never heard of. The daemon neither adopts it nor errors — it builds +/// beside it and leaves every byte of it alone. +#[tokio::test(flavor = "multi_thread")] +async fn foreign_directory_is_sidestepped_never_adopted() { + let (_daemon, client) = bring_up().await; + + let base = TempDir::new().unwrap(); + init_repo(base.path()); + + let squatter = base + .path() + .join("worktrees") + .join("feature") + .join("foreign"); + std::fs::create_dir_all(&squatter).unwrap(); + std::fs::write(squatter.join("operator-notes.txt"), b"mine\n").unwrap(); + + let s = client + .spawn_session(&worktree_req(base.path(), "feature/foreign", true)) + .await + .expect("spawn must sidestep, not adopt or fail"); + + let suffixed = base + .path() + .join("worktrees") + .join("feature") + .join("foreign-2"); + assert_eq!(s.cwd, suffixed, "session must run in the suffixed tree"); + assert_eq!( + std::fs::read(squatter.join("operator-notes.txt")).unwrap(), + b"mine\n", + "the foreign directory must be untouched" + ); + let registrations = git_worktree_list(base.path()); + let registered_paths: Vec<&str> = registrations + .iter() + .filter_map(|l| l.strip_prefix("worktree ")) + .collect(); + assert!( + !registered_paths + .iter() + .any(|p| *p == squatter.canonicalize().unwrap().display().to_string()), + "a plain directory must never be adopted as a worktree: {registered_paths:?}" + ); + + let _origin = throwaway_origin(base.path()); + client.kill_session(s.id).await.expect("kill"); + assert!(squatter.join("operator-notes.txt").exists()); +} + +/// #79/#88: killing with uncommitted changes is refused; committing and +/// pushing that work clears the guard and the very same kill then succeeds +/// and removes the tree. The session is the handle on the work: while it is +/// unsaved the session stays, once it is saved the tree goes. +#[tokio::test(flavor = "multi_thread")] +async fn guarded_kill_then_commit_and_push_makes_the_same_kill_succeed() { + let (_daemon, client) = bring_up().await; + + let base = TempDir::new().unwrap(); + init_repo(base.path()); + let _origin = throwaway_origin(base.path()); + + let wt = base.path().join("worktrees").join("feature").join("guard"); + let s = client + .spawn_session(&worktree_req(base.path(), "feature/guard", true)) + .await + .expect("spawn"); + std::fs::write(wt.join("README"), b"work in progress\n").unwrap(); + + // Refused: the edit is uncommitted. + let err = client + .kill_session(s.id) + .await + .expect_err("kill must refuse"); + match err { + maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. }) => { + assert_eq!(info.code, "kill_guarded", "{info:?}"); + let breakdown = info.unsaved_work.expect("breakdown on the wire"); + assert_eq!(breakdown.uncommitted_files, 1); + assert_eq!(breakdown.unpushed_commits, 0); + } + other => panic!("expected a kill_guarded refusal, got {other:?}"), + } + assert!( + client + .list_sessions() + .await + .unwrap() + .iter() + .any(|x| x.id == s.id), + "a refused kill keeps the session running" + ); + assert_eq!( + std::fs::read(wt.join("README")).unwrap(), + b"work in progress\n", + "the worktree is untouched by the refusal" + ); + + // The operator does exactly what the refusal asked for: save and push. + commit_all(&wt, "wip"); + push_all(base.path()); + + client.kill_session(s.id).await.expect("kill after saving"); + + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if !wt.exists() { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert!( + !wt.exists(), + "a cleared worktree must be removed after kill" + ); + let registrations = git_worktree_list(base.path()); + assert!( + !registrations.iter().any(|l| l.contains("feature/guard")), + "removal must deregister the tree: {registrations:?}" + ); +} + +/// #81/#88: commits that no remote has refuse the kill; `--force` ends the +/// session anyway but RETAINS the worktree — force never destroys blocked +/// work, it parks it in the retained store with reason `force-killed`. +#[tokio::test(flavor = "multi_thread")] +async fn unpushed_commits_block_kill_and_force_retains_the_tree() { + let (_daemon, client) = bring_up().await; + + let base = TempDir::new().unwrap(); + init_repo(base.path()); + let _origin = throwaway_origin(base.path()); + + let wt = base.path().join("worktrees").join("feature").join("local"); + let s = client + .spawn_session(&worktree_req(base.path(), "feature/local", true)) + .await + .expect("spawn"); + std::fs::write(wt.join("README"), b"local only\n").unwrap(); + commit_all(&wt, "not pushed yet"); + + // Refused: the commit is on no remote-tracking ref. + let err = client + .kill_session(s.id) + .await + .expect_err("kill must refuse"); + match err { + maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. }) => { + assert_eq!(info.code, "kill_guarded", "{info:?}"); + let breakdown = info.unsaved_work.expect("breakdown on the wire"); + assert_eq!(breakdown.unpushed_commits, 1); + assert_eq!(breakdown.uncommitted_files, 0); + } + other => panic!("expected a kill_guarded refusal, got {other:?}"), + } + + // Force overrides the refusal and nothing else: session gone, tree kept. + let outcome = client.kill_session_forced(s.id).await.expect("forced kill"); + let retained = outcome + .retained + .expect("force must retain the guarded tree"); + assert_eq!( + retained.reason, + maestro_protocol::RetainedReason::ForceKilled + ); + assert_eq!(retained.path, wt); + assert_eq!(retained.branch, "feature/local"); + assert_eq!(retained.in_the_way.unpushed_commits, 1); + + assert!( + !client + .list_sessions() + .await + .unwrap() + .iter() + .any(|x| x.id == s.id), + "the forced kill ends the session" + ); + assert!(wt.is_dir(), "a force-retained worktree must stay on disk"); + assert!( + git_worktree_list(base.path()) + .iter() + .any(|l| l.contains("feature/local")), + "the retained tree stays registered in git" + ); +} + +/// #82/#88: a session that exits on its own never reaches the kill path, but +/// its worktree follows the same rule. Dirty → retained with reason +/// `exited-with-unsaved-work`; pristine → removed, leaving nothing behind. +#[tokio::test(flavor = "multi_thread")] +async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() { + let (_daemon, client) = bring_up().await; + + // Two repos so the two cases cannot see each other's state. + let dirty_repo = TempDir::new().unwrap(); + init_repo(dirty_repo.path()); + let _origin = throwaway_origin(dirty_repo.path()); + let pristine_repo = TempDir::new().unwrap(); + init_repo(pristine_repo.path()); + let _origin_pristine = throwaway_origin(pristine_repo.path()); + + let dirty_wt = dirty_repo + .path() + .join("worktrees") + .join("feature") + .join("messy"); + let pristine_wt = pristine_repo + .path() + .join("worktrees") + .join("feature") + .join("tidy"); + + let _dirty = client + .spawn_session(&CreateSessionRequest { + cwd: dirty_repo.path().to_path_buf(), + command: "sh -c 'echo unsaved > scratch.txt'".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(worktree_spec(dirty_repo.path(), "feature/messy", true)), + }) + .await + .expect("spawn dirty"); + let _pristine = client + .spawn_session(&create_req_cmd( + pristine_repo.path(), + "true", + "feature/tidy", + )) + .await + .expect("spawn pristine"); + + assert!(dirty_wt.is_dir() && pristine_wt.is_dir()); + + // The watcher reaps dead tmux sessions every couple of ticks and settles + // each worktree accordingly. Poll for both outcomes. + wait_for_async( + "dirty exit retains its worktree", + Duration::from_secs(20), + || { + let client = client.clone(); + let path = dirty_wt.clone(); + async move { + client + .list_worktrees() + .await + .ok() + .map(|wts| { + wts.iter().any(|w| { + w.path == path + && w.reason + == maestro_protocol::RetainedReason::ExitedWithUnsavedWork + }) + }) + .unwrap_or(false) + } + }, + ) + .await; + wait_for_async( + "pristine exit removes its worktree entirely", + Duration::from_secs(20), + || { + let client = client.clone(); + let repo = pristine_repo.path().to_path_buf(); + let path = pristine_wt.clone(); + async move { + if path.exists() { + return false; + } + let listed = client.list_worktrees().await.ok(); + let listed_clear = listed + .map(|wts| !wts.iter().any(|w| w.path == path)) + .unwrap_or(false); + let registered = git_worktree_list(&repo); + !registered.iter().any(|l| l.contains("feature/tidy")) && listed_clear + } + }, + ) + .await; + + // The dirty tree is still fully there, file included. + assert!(dirty_wt.is_dir()); + assert_eq!( + std::fs::read(dirty_wt.join("scratch.txt")).unwrap(), + b"unsaved\n" + ); +} + +fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateSessionRequest { + CreateSessionRequest { + cwd: cwd.to_path_buf(), + command: command.into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(worktree_spec(cwd, branch, true)), + } +} + +/// #80/#88: `GET /api/worktrees` lists what the daemon is holding, with the +/// reason and a live in-the-way breakdown; reclaim removes the directory and +/// the store entry but NEVER the branch — its commits outlive the reclaim. +#[tokio::test(flavor = "multi_thread")] +async fn worktrees_endpoint_lists_reasons_and_reclaim_keeps_the_branch() { + let (_daemon, client) = bring_up().await; + + let base = TempDir::new().unwrap(); + init_repo(base.path()); + let _origin = throwaway_origin(base.path()); + + let wt = base.path().join("worktrees").join("feature").join("parked"); + let s = client + .spawn_session(&worktree_req(base.path(), "feature/parked", true)) + .await + .expect("spawn"); + std::fs::write(wt.join("README"), b"worth keeping\n").unwrap(); + commit_all(&wt, "worth keeping"); + let sha = branch_sha(&wt, "feature/parked"); + + let outcome = client.kill_session_forced(s.id).await.expect("force kill"); + assert!(outcome.retained.is_some()); + + let listed = client.list_worktrees().await.expect("list worktrees"); + assert_eq!(listed.len(), 1, "exactly one retained entry: {listed:?}"); + let entry = &listed[0]; + assert_eq!(entry.path, wt); + assert_eq!(entry.branch, "feature/parked"); + assert_eq!(entry.reason, maestro_protocol::RetainedReason::ForceKilled); + assert_eq!(entry.in_the_way.unpushed_commits, 1); + + client.reclaim_worktree(entry.id).await.expect("reclaim"); + assert!(!wt.exists(), "reclaim removes the directory"); + assert!( + client.list_worktrees().await.unwrap().is_empty(), + "reclaim drops the store entry" + ); + + // The whole point of reclaim: the branch and its commits survive. + assert_eq!( + branch_sha(base.path(), "feature/parked"), + sha, + "the branch must keep pointing at the same commit after reclaim" + ); +} + +/// The retained store is state-dir backed (#80): a daemon restart must not +/// orphan the trees it is holding — they are still on disk with entries +/// pointing at them, and still reclaimable afterwards. +#[tokio::test(flavor = "multi_thread")] +async fn retained_store_survives_a_daemon_restart() { + let (mut daemon, client) = bring_up().await; + + let base = TempDir::new().unwrap(); + init_repo(base.path()); + let _origin = throwaway_origin(base.path()); + + let wt = base + .path() + .join("worktrees") + .join("feature") + .join("durable"); + let s = client + .spawn_session(&worktree_req(base.path(), "feature/durable", true)) + .await + .expect("spawn"); + std::fs::write(wt.join("README"), b"survives restarts\n").unwrap(); + commit_all(&wt, "survives restarts"); + client.kill_session_forced(s.id).await.expect("force kill"); + + daemon.restart().await; + + let listed = client.list_worktrees().await.expect("list after restart"); + assert_eq!(listed.len(), 1, "store must survive restart: {listed:?}"); + assert_eq!(listed[0].path, wt); + assert_eq!( + listed[0].reason, + maestro_protocol::RetainedReason::ForceKilled + ); + assert!(wt.is_dir(), "the tree itself must survive the restart too"); + + client + .reclaim_worktree(listed[0].id) + .await + .expect("reclaim"); + assert!(!wt.exists()); +} + +// --------------------------------------------------------------------------- +// GET /api/projects + /api/branches (#54/#55/#66) +// --------------------------------------------------------------------------- + +/// #66: two repos under a configured `[spawn].project_roots` root are both +/// discovered by `GET /api/projects`; git activity gives every repo a +/// `last_activity`, and spawning inside one merges its command into the +/// project entry via spawn history. +#[tokio::test(flavor = "multi_thread")] +async fn projects_endpoint_discovers_roots_and_merges_activity() { + let root = TempDir::new().unwrap(); + let alpha = make_repo(root.path(), "alpha"); + make_repo(root.path(), "beta"); + + let daemon = Daemon::spawn_with_config(&format!( + "[spawn]\nproject_roots = [\"{}\"]\nproject_scan_depth = 2\n", + root.path().display() + )) + .await; + let client = TestClient::new(&daemon.http_base()); + + let projects = client.list_projects().await.expect("list projects"); + let names: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect(); + assert!( + names.contains(&"alpha") && names.contains(&"beta"), + "both repos must be discovered, got: {names:?}" + ); + + let find = |name: &str| { + projects + .iter() + .find(|p| p.name == name) + .cloned() + .expect("project present") + }; + // Both have commits, so git activity alone populates last_activity… + assert!( + find("alpha").last_activity.is_some(), + "git activity must surface" + ); + assert!( + find("beta").last_activity.is_some(), + "git activity must surface" + ); + // …and neither has been spawned into yet. + assert_eq!(find("alpha").last_command, None); + assert_eq!(find("beta").last_command, None); + + // Spawning inside alpha writes durable history; /api/projects folds it in. + let s = client + .spawn_session(&create_req(&alpha, "sleep 30")) + .await + .expect("spawn in alpha"); + + wait_for_async( + "alpha's project entry reflects spawn history", + Duration::from_secs(5), + || { + let client = client.clone(); + async move { + client + .list_projects() + .await + .ok() + .map(|ps| { + ps.iter() + .find(|p| p.name == "alpha") + .map(|p| p.last_command.as_deref() == Some("sleep 30")) + .unwrap_or(false) + }) + .unwrap_or(false) + } + }, + ) + .await; + + // beta was never spawned into: no command recorded there. + let beta_after = client + .list_projects() + .await + .expect("list after spawn") + .into_iter() + .find(|p| p.name == "beta") + .unwrap(); + assert_eq!(beta_after.last_command, None); + + client.kill_session(s.id).await.expect("kill"); +} + +/// #66: `GET /api/branches` lists every local branch, flags the main +/// checkout's HEAD with `is_head`, and reports which linked worktree holds an +/// occupied branch. +#[tokio::test(flavor = "multi_thread")] +async fn branches_endpoint_lists_branches_and_flags_occupied() { + let (_daemon, client) = bring_up().await; + + let base = TempDir::new().unwrap(); + init_repo(base.path()); + run_git(&["branch", "feature/taken"], base.path()); + run_git(&["branch", "feature/free"], base.path()); + let holder = base.path().join("holder-wt"); + run_git( + &[ + "worktree", + "add", + "-q", + &holder.to_string_lossy(), + "feature/taken", + ], + base.path(), + ); + + let branches = client + .list_branches(&base.path().to_string_lossy()) + .await + .expect("list branches"); + let by_name = |name: &str| { + branches + .iter() + .find(|b| b.name == name) + .cloned() + .unwrap_or_else(|| panic!("branch {name} missing from {branches:?}")) + }; + + let main = by_name("main"); + assert!(main.is_head, "main is the main checkout's HEAD"); + assert_eq!(main.worktree, None); + + let taken = by_name("feature/taken"); + assert!(!taken.is_head); + assert_eq!( + taken.worktree.as_deref(), + Some(holder.canonicalize().unwrap().as_path()), + "occupied branch must report its holding worktree" + ); + + let free = by_name("feature/free"); + assert!(!free.is_head); + assert_eq!(free.worktree, None, "unoccupied branch must be spawnable"); +} + #[tokio::test(flavor = "multi_thread")] async fn daemon_restart_recovers_session() { let (mut daemon, client) = bring_up().await; @@ -348,6 +1214,15 @@ fn init_repo(dir: &std::path::Path) { run_git(&["commit", "-q", "-m", "initial"], dir); } +/// Real git repo at `/`, canonicalized (the scanner canonicalizes +/// discovered paths too, so comparisons must agree on the real path). +fn make_repo(root: &std::path::Path, name: &str) -> std::path::PathBuf { + let dir = root.join(name); + std::fs::create_dir_all(&dir).unwrap(); + init_repo(&dir); + dir.canonicalize().unwrap() +} + fn run_git(args: &[&str], cwd: &std::path::Path) { let out = Command::new("git") .arg("-C") @@ -375,6 +1250,51 @@ fn git_worktree_list(dir: &std::path::Path) -> Vec { .collect() } +/// Bare remote registered as `origin` on `repo`, seeded with every current +/// branch, so existing commits are reachable from remote-tracking refs. +/// Without one the kill guard (#79) reads every commit as unpushed, and any +/// worktree session refuses to die. The caller must keep the `TempDir` alive +/// while the remote is still needed; later pushes go through [`push_all`]. +fn throwaway_origin(repo: &std::path::Path) -> TempDir { + let origin = tempfile::TempDir::new().unwrap(); + run_git( + &["init", "-q", "--bare", origin.path().to_str().unwrap()], + repo, + ); + let _ = Command::new("git") + .arg("-C") + .arg(repo) + .args(["remote", "add", "origin", origin.path().to_str().unwrap()]) + .output() + .expect("git remote add"); + push_all(repo); + origin +} + +fn push_all(repo: &std::path::Path) { + run_git(&["push", "-q", "--all", "origin"], repo); +} + +fn commit_all(dir: &std::path::Path, message: &str) { + run_git(&["add", "-A"], dir); + run_git(&["commit", "-q", "-m", message], dir); +} + +fn branch_sha(repo: &std::path::Path, branch: &str) -> String { + let out = Command::new("git") + .arg("-C") + .arg(repo) + .args(["rev-parse", "--verify", &format!("refs/heads/{branch}")]) + .output() + .expect("git rev-parse"); + assert!( + out.status.success(), + "branch {branch} missing: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim().to_string() +} + #[tokio::test(flavor = "multi_thread")] async fn daemon_events_ws_streams_snapshot_then_session_added() { // NAP-248: subscribe to the daemon's WS /api/events, expect an initial diff --git a/crates/maestro-names/Cargo.toml b/crates/maestro-names/Cargo.toml new file mode 100644 index 0000000..ca64850 --- /dev/null +++ b/crates/maestro-names/Cargo.toml @@ -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 diff --git a/crates/maestro-names/src/lib.rs b/crates/maestro-names/src/lib.rs new file mode 100644 index 0000000..9f88c72 --- /dev/null +++ b/crates/maestro-names/src/lib.rs @@ -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 { + (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); + } +} diff --git a/crates/maestro-protocol/src/client.rs b/crates/maestro-protocol/src/client.rs index 71ae127..61bcffd 100644 --- a/crates/maestro-protocol/src/client.rs +++ b/crates/maestro-protocol/src/client.rs @@ -15,7 +15,8 @@ use uuid::Uuid; use crate::routes::{self, UrlError}; use crate::{ - ApiError, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, RecentDirs, Session, + ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, + KillOutcome, Project, RecentDirs, RetainedWorktree, Session, }; /// Time budget for establishing a TCP connection to the daemon. @@ -113,12 +114,24 @@ impl DaemonClient { self.post(routes::SESSIONS, req).await } - pub async fn kill_session(&self, id: Uuid) -> Result<(), ClientError> { + /// Kill a session. With `force`, the daemon overrides the kill refusal + /// (#79) and retains the worktree instead of removing it (#81); the + /// outcome then names what was kept and where. + pub async fn kill_session(&self, id: Uuid, force: bool) -> Result { + let query: &[(&str, &str)] = if force { + &[(routes::FORCE_QUERY, "true")] + } else { + &[] + }; let resp = self - .send(Method::DELETE, &routes::session(id), &[], None::<&()>) + .send(Method::DELETE, &routes::session(id), query, None::<&()>) .await?; - check(resp).await?; - Ok(()) + let resp = check(resp).await?; + // An unforced kill keeps its historical `204 No Content`. + if resp.status() == reqwest::StatusCode::NO_CONTENT { + return Ok(KillOutcome { retained: None }); + } + json(resp).await } pub async fn daemon_info(&self) -> Result { @@ -138,6 +151,32 @@ impl DaemonClient { .await } + pub async fn list_projects(&self) -> Result, ClientError> { + self.get(routes::PROJECTS, &[]).await + } + + pub async fn list_branches(&self, path: &str) -> Result, ClientError> { + self.get(routes::BRANCHES, &[(routes::PATH_QUERY, path)]) + .await + } + + /// `GET /api/worktrees` (#80): every retained worktree with its in-the-way + /// breakdown as of this read. + pub async fn list_worktrees(&self) -> Result, ClientError> { + self.get(routes::WORKTREES, &[]).await + } + + /// `DELETE /api/worktrees/:id` (#86): reclaim one retained worktree. The + /// daemon removes the directory and drops the store entry; the branch is + /// never deleted. + pub async fn reclaim_worktree(&self, id: Uuid) -> Result<(), ClientError> { + let resp = self + .send(Method::DELETE, &routes::worktree(id), &[], None::<&()>) + .await?; + check(resp).await?; + Ok(()) + } + // -- plumbing ----------------------------------------------------------- async fn get( diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index fe001b8..66fb569 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -53,6 +53,14 @@ pub struct Session { pub env: HashMap, #[serde(default)] pub metadata: HashMap, + /// The worktree this session owns, when it was spawned with one. + /// + /// Persisted rather than kept in memory only: after a daemon restart the + /// recovered session must still know what to clean up, and with + /// deterministic worktree paths a forgotten association leaves a directory + /// that blocks that branch from ever being spawned again (#54 review). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree: Option, pub status: SessionStatus, pub created_at: DateTime, /// Current foreground command in the session's pane (e.g. `claude`, @@ -79,6 +87,13 @@ pub struct Session { /// from daemons that predate the field. #[serde(default)] pub current_cwd: Option, + /// Whether the session's worktree currently holds anything that would + /// block an unforced kill (#83), summarised by [`InTheWay`]'s `Display`. + /// Refreshed by the daemon's watcher; `None` for sessions without a + /// worktree and until the first probe. Defaults on the wire so payloads + /// from daemons that predate the field stay forward-compatible. + #[serde(default)] + pub blocked_work: Option, } /// Default for [`Session::last_activity_at`] when an older payload omits it. @@ -111,8 +126,117 @@ pub enum ActivityState { pub struct WorktreeSpec { pub base_repo: PathBuf, pub branch: String, + /// 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 +} + +/// The worktree a running session owns, carried on [`Session`] so it survives +/// a daemon restart. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionWorktree { + pub path: PathBuf, + pub base_repo: PathBuf, +} + +/// Why a retained worktree outlived its session (#80). Same values the +/// daemon's retained store persists; new variants are additive but clients +/// should render unknown reasons generically. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum RetainedReason { + /// The session was killed with `--force` while its worktree held unsaved + /// work. + ForceKilled, + /// The session ended by itself and its worktree held unsaved work. + ExitedWithUnsavedWork, +} + +/// What a kill refusal would report about a worktree right now (#80) — the +/// same breakdown that blocks an unforced kill, recomputed on every read of +/// [`RetainedWorktree`] so it reflects the tree now rather than when it was +/// retained. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct InTheWay { + /// Tracked files with uncommitted modifications. #[serde(default)] - pub cleanup_on_kill: bool, + pub uncommitted_files: usize, + /// Untracked files that are not ignored (ignored files never block). + #[serde(default)] + pub untracked_files: usize, + /// Commits on the branch reachable from no remote-tracking ref. + #[serde(default)] + pub unpushed_commits: usize, +} + +impl InTheWay { + /// Nothing blocks any more — the work has since been committed and pushed. + pub fn is_clear(&self) -> bool { + *self == Self::default() + } +} + +/// One entry of `GET /api/worktrees` (#80): a retained worktree with its +/// in-the-way state as of this read. Reclaiming it via +/// `DELETE /api/worktrees/:id` removes the directory but **never** the branch. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RetainedWorktree { + pub id: Uuid, + pub path: PathBuf, + pub base_repo: PathBuf, + pub branch: String, + pub reason: RetainedReason, + pub retained_at: DateTime, + #[serde(default)] + pub in_the_way: InTheWay, +} + +/// Body of a successful forced `DELETE /api/sessions/:id?force=true` (#81). +/// A forced kill ends the session and retains its worktree instead of +/// removing it; this names what was kept, where, and why, so a client can say +/// where the work went rather than leaving the operator to wonder. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct KillOutcome { + /// Set when the worktree was retained with reason `force-killed`; `None` + /// when there was nothing to retain — no worktree at all, or one whose + /// guard was clear, which a forced kill removes as usual. + #[serde(default)] + pub retained: Option, +} + +impl std::fmt::Display for InTheWay { + /// "3 uncommitted files, 2 unpushed commits" — zero parts are omitted; + /// all-zero renders "no unsaved work". This is the phrasing every client + /// uses for both the kill refusal and the retained list, so the two + /// cannot describe the same tree differently. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let parts = [ + (self.uncommitted_files, "uncommitted file"), + (self.untracked_files, "untracked file"), + (self.unpushed_commits, "unpushed commit"), + ]; + let said: Vec = parts + .into_iter() + .filter(|(n, _)| *n > 0) + .map(|(n, unit)| { + if n == 1 { + format!("1 {unit}") + } else { + format!("{n} {unit}s") + } + }) + .collect(); + if said.is_empty() { + return write!(f, "no unsaved work"); + } + write!(f, "{}", said.join(", ")) + } } /// HTTP `POST /api/sessions` body. @@ -176,11 +300,54 @@ pub struct GitRoot { pub root: Option, } +/// 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, + /// 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>, + /// Command last spawned in this project, from spawn history. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_command: Option, +} + +/// 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, +} + /// Structured failure used everywhere we don't want to leak a string-only error. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ErrorInfo { pub code: String, pub message: String, + /// Set only by the kill guard (#79): the unsaved work that blocked + /// `DELETE /api/sessions/:id`, so a client can render counts directly. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unsaved_work: Option, } /// The envelope every `/api/*` failure is wrapped in: `{"error": {…}}`. @@ -201,6 +368,7 @@ impl ApiError { error: ErrorInfo { code: code.into(), message: message.into(), + unsaved_work: None, }, } } @@ -243,6 +411,13 @@ pub struct DaemonInfo { pub presets: Vec, } +/// 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, +} + // --------------------------------------------------------------------------- // Catalog events (`WS /api/events`) // --------------------------------------------------------------------------- @@ -317,6 +492,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), @@ -324,6 +527,7 @@ mod tests { command: "claude".into(), env: HashMap::from([("FOO".into(), "bar".into())]), metadata: HashMap::from([("name".into(), Value::String("maestro-impl".into()))]), + worktree: None, status: SessionStatus::Running, created_at: DateTime::parse_from_rfc3339("2026-05-24T12:00:00Z") .unwrap() @@ -334,6 +538,7 @@ mod tests { .unwrap() .with_timezone(&Utc), current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")), + blocked_work: None, } } @@ -353,6 +558,34 @@ mod tests { assert_eq!(s.activity, ActivityState::Idle); assert_eq!(s.last_activity_at, epoch()); assert!(s.current_cwd.is_none()); + assert!(s.blocked_work.is_none()); + } + + #[test] + fn blocked_work_defaults_to_none_on_legacy_payload() { + // A pre-#83 daemon omits the indicator; it must deserialize to `None` + // rather than failing, and a present value round-trips. + let json = serde_json::json!({ + "id": Uuid::from_u128(1), + "cwd": "/tmp", + "command": "bash", + "status": "Running", + "created_at": "2026-05-24T12:00:00Z" + }); + let s: Session = serde_json::from_value(json).unwrap(); + assert_eq!(s.blocked_work, None); + + let mut with_blocker = sample_session(); + with_blocker.blocked_work = Some(InTheWay { + uncommitted_files: 3, + ..InTheWay::default() + }); + assert_eq!( + with_blocker.clone().blocked_work, + serde_json::from_value::(serde_json::to_value(&with_blocker).unwrap()) + .unwrap() + .blocked_work + ); } #[test] @@ -392,7 +625,7 @@ mod tests { worktree: Some(WorktreeSpec { 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 +643,99 @@ 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); + } + + #[test] + fn worktree_spec_round_trips_existing_branch_mode() { + let spec = WorktreeSpec { + base_repo: PathBuf::from("/work"), + branch: "feat/foo".into(), + create_branch: false, + }; + assert_eq!(spec, round_trip(&spec)); + } + + fn sample_retained_worktree() -> RetainedWorktree { + RetainedWorktree { + id: Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef), + path: PathBuf::from("/work/maestro/worktrees/feat/login"), + base_repo: PathBuf::from("/work/maestro"), + branch: "feat/login".into(), + reason: RetainedReason::ForceKilled, + retained_at: DateTime::parse_from_rfc3339("2026-08-24T10:00:00Z") + .unwrap() + .with_timezone(&Utc), + in_the_way: InTheWay { + uncommitted_files: 2, + untracked_files: 1, + unpushed_commits: 3, + }, + } + } + + #[test] + fn retained_worktree_round_trips() { + let wt = sample_retained_worktree(); + assert_eq!(wt, round_trip(&wt)); + } + + /// A payload from a daemon predating `in_the_way` still decodes; the + /// breakdown defaults to "nothing in the way". + #[test] + fn retained_worktree_defaults_in_the_way_on_legacy_payload() { + let legacy = serde_json::json!({ + "id": Uuid::from_u128(1), + "path": "/w/t", + "base_repo": "/w", + "branch": "feat/login", + "reason": "force-killed", + "retained_at": "2026-08-24T10:00:00Z", + }); + let wt: RetainedWorktree = serde_json::from_value(legacy).unwrap(); + assert_eq!(wt.reason, RetainedReason::ForceKilled); + assert_eq!(wt.in_the_way, InTheWay::default()); + assert!(wt.in_the_way.is_clear()); + } + + #[test] + fn retained_reason_serializes_kebab_case() { + assert_eq!( + serde_json::to_value(RetainedReason::ForceKilled).unwrap(), + serde_json::json!("force-killed") + ); + assert_eq!( + serde_json::to_value(RetainedReason::ExitedWithUnsavedWork).unwrap(), + serde_json::json!("exited-with-unsaved-work") + ); + } + + #[test] + fn kill_outcome_round_trips_a_retained_worktree() { + let outcome = KillOutcome { + retained: Some(sample_retained_worktree()), + }; + assert_eq!(outcome, round_trip(&outcome)); + } + + /// A kill with nothing to retain — no worktree, or a clean one removed as + /// usual — decodes from an absent field. + #[test] + fn kill_outcome_defaults_to_nothing_retained() { + let outcome: KillOutcome = serde_json::from_value(serde_json::json!({})).unwrap(); + assert_eq!(outcome.retained, None); + } + #[test] fn catalog_event_round_trips_and_tags() { let session = sample_session(); @@ -449,6 +775,7 @@ mod tests { ClientControl::Error(ErrorInfo { code: "bad_frame".into(), message: "decode failed".into(), + unsaved_work: None, }), resize, ] { diff --git a/crates/maestro-protocol/src/routes.rs b/crates/maestro-protocol/src/routes.rs index 10c8131..7b3e024 100644 --- a/crates/maestro-protocol/src/routes.rs +++ b/crates/maestro-protocol/src/routes.rs @@ -17,19 +17,41 @@ 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"; +/// `GET` retained worktrees (#80): what the store holds, with the in-the-way +/// breakdown recomputed on read. +pub const WORKTREES: &str = "/api/worktrees"; /// `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"; + +/// Name of the boolean query parameter `DELETE /api/sessions/:id` takes (#81). +/// `force=true` overrides the kill refusal and retains the worktree. +pub const FORCE_QUERY: &str = "force"; + /// `DELETE /api/sessions/:id`. pub fn session(id: Uuid) -> String { format!("{SESSIONS}/{id}") } +/// `DELETE /api/worktrees/:id` — reclaim one retained worktree (#80). +pub fn worktree(id: Uuid) -> String { + format!("{WORKTREES}/{id}") +} + /// `WS /api/attach/:id` — raw PTY bytes. pub fn attach(id: Uuid) -> String { format!("{API_PREFIX}/attach/{id}") @@ -40,6 +62,7 @@ pub fn attach(id: Uuid) -> String { pub mod patterns { pub const SESSION: &str = "/api/sessions/:id"; pub const ATTACH: &str = "/api/attach/:id"; + pub const WORKTREE: &str = "/api/worktrees/:id"; } /// A daemon base URL that could not be turned into a WebSocket base. @@ -150,6 +173,10 @@ mod tests { attach(id()), "/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50" ); + assert_eq!( + worktree(id()), + "/api/worktrees/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50" + ); assert_eq!( attach_url("https://laptop.lan:7070", id()).unwrap(), "wss://laptop.lan:7070/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50" @@ -168,11 +195,26 @@ mod tests { patterns::ATTACH.trim_end_matches(":id"), attach(id()).trim_end_matches(&id().to_string()) ); + assert_eq!( + patterns::WORKTREE.trim_end_matches(":id"), + worktree(id()).trim_end_matches(&id().to_string()) + ); } #[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, + WORKTREES, + EVENTS, + ] { assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}"); } assert!(attach(id()).starts_with(API_PREFIX)); diff --git a/crates/maestro-testing/src/lib.rs b/crates/maestro-testing/src/lib.rs index cce52c5..881fd32 100644 --- a/crates/maestro-testing/src/lib.rs +++ b/crates/maestro-testing/src/lib.rs @@ -36,12 +36,14 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session { command: command.into(), env: HashMap::new(), metadata: HashMap::new(), + worktree: None, status: SessionStatus::Running, created_at: Utc::now(), foreground: None, activity: Default::default(), last_activity_at: Utc::now(), current_cwd: None, + blocked_work: None, } } diff --git a/crates/maestro-tui/Cargo.toml b/crates/maestro-tui/Cargo.toml index dd99eff..469f91f 100644 --- a/crates/maestro-tui/Cargo.toml +++ b/crates/maestro-tui/Cargo.toml @@ -8,12 +8,14 @@ publish.workspace = true description = "Terminal UI client over the local maestro daemon's `/api/*` surface." [dependencies] +chrono.workspace = true clap.workspace = true crossterm.workspace = true futures-util.workspace = true maestro-config.workspace = true maestro-protocol = { workspace = true, features = ["client"] } ratatui.workspace = true +reqwest.workspace = true serde_json.workspace = true thiserror.workspace = true tokio = { workspace = true, features = ["sync", "time", "macros", "rt"] } diff --git a/crates/maestro-tui/src/action.rs b/crates/maestro-tui/src/action.rs index 4ebf0e6..0efba87 100644 --- a/crates/maestro-tui/src/action.rs +++ b/crates/maestro-tui/src/action.rs @@ -38,8 +38,13 @@ pub enum Action { // Catalog actions on the highlighted sidebar row AttachHighlighted, ToggleAttachHighlighted, - OpenSpawnModal, + NewDraftPane, + /// Fast-forward spawn (#67): spawn immediately with every default + /// resolved; unresolvable defaults open a pre-filled draft instead. + FastForwardSpawn, KillHighlighted, + /// Open the retained-worktrees view (#85). + ShowWorktrees, // Tabs NewTab, @@ -78,8 +83,10 @@ impl Action { "SidebarPrev" => return Ok(Action::SidebarPrev), "AttachHighlighted" => return Ok(Action::AttachHighlighted), "ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted), - "OpenSpawnModal" => return Ok(Action::OpenSpawnModal), + "NewDraftPane" => return Ok(Action::NewDraftPane), + "FastForwardSpawn" => return Ok(Action::FastForwardSpawn), "KillHighlighted" => return Ok(Action::KillHighlighted), + "ShowWorktrees" => return Ok(Action::ShowWorktrees), "NewTab" => return Ok(Action::NewTab), "CloseTab" => return Ok(Action::CloseTab), "NextTab" => return Ok(Action::NextTab), @@ -197,11 +204,22 @@ mod tests { assert_eq!(err, ActionParseError::Malformed("GotoTab(3".into())); } + /// The fast-forward pair sits next to the draft-pane key (#67). + #[test] + fn fast_forward_spawn_round_trips() { + assert_eq!( + Action::parse("FastForwardSpawn").unwrap(), + Action::FastForwardSpawn + ); + assert_eq!(Action::FastForwardSpawn.to_string(), "FastForwardSpawn"); + } + #[test] fn display_round_trips() { for a in [ Action::EnterInsert, Action::Quit, + Action::NewDraftPane, Action::FocusPane(Direction::Right), Action::GotoTab(7), ] { @@ -209,4 +227,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()) + ); + } } diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 25b438e..cecf43e 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -6,17 +6,21 @@ //! 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; +use std::path::PathBuf; use std::time::Duration; +use chrono::Utc; use crossterm::event::{Event as CtEvent, EventStream}; use futures_util::StreamExt; -use maestro_protocol::{CreateSessionRequest, DaemonClient}; +use maestro_protocol::{ + routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch, RetainedWorktree, +}; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; use uuid::Uuid; @@ -24,16 +28,18 @@ 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::worktrees::WorktreesView; use crate::TuiError; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -49,10 +55,24 @@ pub struct App { catalog: CatalogStore, emulators: SessionEmulators, state: AppState, - spawn: SpawnModal, help: HelpOverlay, chord: ChordState, daemon: DaemonClient, + /// Results of draft data loads (and the worktrees view fetch, #85), + /// delivered back to the event loop. + /// + /// These are network calls — `/api/projects` on a cold cache walks all of + /// `$HOME` — and awaiting them inside `perform` froze the whole TUI: no + /// keys, no frames, attached panes stalled, for up to the request timeout + /// (#67 review). They run as detached tasks and post here instead. + draft_tx: tokio::sync::mpsc::UnboundedSender, + draft_rx: tokio::sync::mpsc::UnboundedReceiver, + /// Direct HTTP for the one endpoint [`DaemonClient`] doesn't cover yet: + /// `GET /api/names` (#64). + http: reqwest::Client, + /// Entries of the retained-worktrees view (#85). Daemon data, kept here + /// rather than in `AppState` so the overlay kind stays payload-free. + worktrees: WorktreesView, region: RegionMap, last_sent_size: HashMap, clicks: ClickTracker, @@ -76,24 +96,57 @@ pub enum Effect { Attach(Uuid), Spawn(CreateSessionRequest), Kill(Uuid), - Forward { id: Uuid, bytes: Vec }, + Forward { + id: Uuid, + bytes: Vec, + }, + /// Fetch presets / projects / names for a freshly opened draft (#64). + LoadDraft(usize), + /// Fast-forward spawn (#67): resolve every default from the daemon and + /// spawn with no form; unresolvable defaults open a pre-filled draft. + FastForward, + /// A reroll drained the draft's local name batch (#64). + RefillNames(usize), + /// The draft's target changed: probe `/api/gitroot` so the worktree + /// section knows its base repo (#65). `seq` guards stale answers. + ProbeGitRoot { + tab: usize, + seq: u32, + path: PathBuf, + }, + /// Fetch the draft target's branches from `/api/branches` (#65). + LoadBranches(usize), + /// Fetch `/api/worktrees` for the retained-worktrees view (#85). + LoadWorktrees, + /// Reclaim one retained worktree (#85) — removes its directory; the + /// branch is never deleted. + ReclaimWorktree(Uuid), } impl App { pub fn new(cfg: TuiConfig, catalog: CatalogStore, daemon: DaemonClient) -> Self { + let (draft_tx, draft_rx) = tokio::sync::mpsc::unbounded_channel(); let chord_timeout = cfg.chord_timeout; let help = HelpOverlay { lines: render_help_lines(&cfg), }; + let http = reqwest::Client::builder() + .connect_timeout(maestro_protocol::client::DEFAULT_CONNECT_TIMEOUT) + .timeout(maestro_protocol::client::DEFAULT_REQUEST_TIMEOUT) + .build() + .expect("build reqwest client"); Self { cfg, catalog, emulators: SessionEmulators::default(), state: AppState::new(), - spawn: SpawnModal::default(), help, chord: ChordState::new(chord_timeout), daemon, + http, + worktrees: WorktreesView::default(), + draft_tx, + draft_rx, region: RegionMap::default(), last_sent_size: HashMap::new(), clicks: ClickTracker::default(), @@ -115,6 +168,9 @@ impl App { return Ok(()); } } + Some(update) = self.draft_rx.recv() => { + self.apply_draft_update(update).await; + } _ = self.catalog.changed() => { // Render on next loop iteration. } @@ -152,9 +208,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) { - self.perform(vec![effect]).await; - } + // Insert mode over a focused pane: a draft eats the key + // into its form, a session gets the raw bytes. + let effects = if matches!(self.state.active_tab().pane, Some(PaneRef::Draft(_))) + { + self.handle_draft_input(&key) + } else { + self.forward_to_session(&key).into_iter().collect() + }; + self.perform(effects).await; } Ok(false) } @@ -169,6 +231,15 @@ impl App { Effect::Attach(id) => self.attach(id), Effect::Spawn(req) => self.submit_spawn(req).await, Effect::Kill(id) => self.submit_kill(id).await, + Effect::LoadDraft(tab_idx) => self.spawn_load_draft(tab_idx), + Effect::FastForward => self.spawn_fast_forward(), + Effect::RefillNames(tab_idx) => self.spawn_refill_names(tab_idx), + Effect::ProbeGitRoot { tab, seq, path } => { + self.spawn_probe_git_root(tab, seq, path) + } + Effect::LoadBranches(tab) => self.spawn_load_branches(tab), + Effect::LoadWorktrees => self.spawn_load_worktrees(), + Effect::ReclaimWorktree(id) => self.submit_reclaim(id).await, Effect::Forward { id, bytes } => { if let Some(handle) = self.emulators.get(id) { let _ = handle.tx.send(SessionInput::Bytes(bytes)).await; @@ -179,27 +250,7 @@ impl App { } fn handle_overlay_input(&mut self, key: &InputKey) -> (OverlayDecision, Vec) { - 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(); @@ -211,6 +262,44 @@ impl App { } _ => (OverlayDecision::Consumed, Vec::new()), }, + Some(OverlayKind::Worktrees) => match key { + InputKey::Char { c: 'j', .. } | InputKey::Down => { + self.worktrees.move_highlight(1); + (OverlayDecision::Consumed, Vec::new()) + } + InputKey::Char { c: 'k', .. } | InputKey::Up => { + self.worktrees.move_highlight(-1); + (OverlayDecision::Consumed, Vec::new()) + } + InputKey::Char { c: 'r', .. } => { + if let Some(wt) = self.worktrees.selected() { + self.state.show_overlay(OverlayKind::ReclaimConfirm { + id: wt.id, + branch: wt.branch.clone(), + // Shared phrasing: InTheWay's Display impl is what + // the kill refusal and the web client use too. + blockers: wt.in_the_way.to_string(), + }); + } + (OverlayDecision::Consumed, Vec::new()) + } + InputKey::Char { c: 'q', .. } | InputKey::Esc => { + self.state.dismiss_overlay(); + (OverlayDecision::Consumed, Vec::new()) + } + _ => (OverlayDecision::Consumed, Vec::new()), + }, + Some(OverlayKind::ReclaimConfirm { id, .. }) => match key { + InputKey::Char { c: 'y', .. } => { + self.state.dismiss_overlay(); + (OverlayDecision::Consumed, vec![Effect::ReclaimWorktree(id)]) + } + InputKey::Char { c: 'n', .. } | InputKey::Esc => { + self.state.show_overlay(OverlayKind::Worktrees); + (OverlayDecision::Consumed, Vec::new()) + } + _ => (OverlayDecision::Consumed, Vec::new()), + }, Some(OverlayKind::Help) => { self.state.dismiss_overlay(); (OverlayDecision::Consumed, Vec::new()) @@ -265,14 +354,11 @@ 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(); + effects.push(Effect::LoadDraft(self.state.active_tab_idx)); } + Action::FastForwardSpawn => effects.push(Effect::FastForward), Action::AttachHighlighted | Action::ToggleAttachHighlighted => { if let Some(sid) = self.pick_sidebar_target() { effects.push(Effect::Attach(sid)); @@ -284,6 +370,10 @@ impl App { self.state.show_overlay(OverlayKind::KillConfirm(sid)); } } + Action::ShowWorktrees => { + self.state.show_overlay(OverlayKind::Worktrees); + effects.push(Effect::LoadWorktrees); + } } effects } @@ -322,59 +412,382 @@ 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 { - 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 a valid form; an invalid one names the + /// missing field inside the form (#64) instead of posting. + fn handle_draft_input(&mut self, key: &InputKey) -> Vec { + let tab_idx = self.state.active_tab_idx; + let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() else { + return Vec::new(); + }; + let mut effects = Vec::new(); + if draft.on_input(key) == DraftInputResult::Submit { + match draft.build_request() { + Ok(req) => { + draft.error = None; + effects.push(Effect::Spawn(req)); + } + Err(e) => draft.error = Some(e.to_string()), + } + } + if draft.take_name_refill() { + effects.push(Effect::RefillNames(tab_idx)); + } + if let Some((seq, path)) = draft.take_gitroot_probe() { + effects.push(Effect::ProbeGitRoot { + tab: tab_idx, + seq, + path, + }); + } + if draft.take_branch_load().is_some() { + effects.push(Effect::LoadBranches(tab_idx)); + } + effects + } + + /// Populate a freshly opened draft: presets from `/api/info` (falling back + /// to the built-ins already in place), projects from `/api/projects`, and + /// a first name batch. Applies only if that tab still shows the draft — + /// a slow response must never clobber what replaced it. + /// Kick off the draft's initial data load without blocking the loop. + fn spawn_load_draft(&self, tab: usize) { + let Some(draft) = self.draft_id(tab) else { + return; + }; + let (daemon, http, base, tx) = self.loader_handles(); + tokio::spawn(async move { + let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects()); + let names = fetch_names(&http, &base, crate::draft::NAME_BATCH_SIZE).await; + let _ = tx.send(DraftUpdate::Data { + draft, + info: info.ok(), + projects: projects.ok(), + names, + }); + }); + } + + fn spawn_refill_names(&self, tab: usize) { + let Some(draft) = self.draft_id(tab) else { + return; + }; + let (_, http, base, tx) = self.loader_handles(); + tokio::spawn(async move { + let names = fetch_names(&http, &base, crate::draft::NAME_BATCH_SIZE).await; + let _ = tx.send(DraftUpdate::Names { draft, names }); + }); + } + + fn spawn_probe_git_root(&self, tab: usize, seq: u32, path: PathBuf) { + let Some(draft) = self.draft_id(tab) else { + return; + }; + let (daemon, _, _, tx) = self.loader_handles(); + tokio::spawn(async move { + let root = daemon + .git_root(&path.to_string_lossy()) + .await + .ok() + .and_then(|g| g.root); + let _ = tx.send(DraftUpdate::GitRoot { draft, seq, root }); + }); + } + + fn spawn_load_branches(&self, tab: usize) { + let Some(draft) = self.draft_id(tab) else { + return; + }; + let Some(cwd) = self + .state + .tabs + .get(tab) + .and_then(|t| t.pane.as_ref()) + .and_then(|p| match p { + PaneRef::Draft(d) => d.cwd(), + _ => None, + }) + else { + return; + }; + let (_, http, base, tx) = self.loader_handles(); + tokio::spawn(async move { + let branches = fetch_branches(&http, &base, &cwd).await; + let _ = tx.send(DraftUpdate::Branches { + draft, + cwd, + branches, + }); + }); + } + + /// The draft pane living in `tab`, by identity. + fn draft_id(&self, tab: usize) -> Option { + match self.state.tabs.get(tab).and_then(|t| t.pane.as_ref()) { + Some(PaneRef::Draft(d)) => Some(d.id), + _ => None, + } + } + + fn loader_handles( + &self, + ) -> ( + DaemonClient, + reqwest::Client, + String, + tokio::sync::mpsc::UnboundedSender, + ) { + ( + self.daemon.clone(), + self.http.clone(), + self.daemon.base().to_string(), + self.draft_tx.clone(), + ) + } + + /// Fold a finished background load into the draft that asked for it. + async fn apply_draft_update(&mut self, update: DraftUpdate) { + if let DraftUpdate::FastForward { + info, + projects, + names, + } = update + { + self.fast_forward_spawn(info, projects, names).await; + return; + } + if let DraftUpdate::WorktreesLoaded { + worktrees, + home_dir, + } = update + { + self.worktrees.apply(worktrees); + self.worktrees.home_dir = home_dir; + return; + } + let want = match &update { + DraftUpdate::Data { draft, .. } + | DraftUpdate::Names { draft, .. } + | DraftUpdate::GitRoot { draft, .. } + | DraftUpdate::Branches { draft, .. } => *draft, + // Handled above; it targets no particular draft. + DraftUpdate::FastForward { .. } | DraftUpdate::WorktreesLoaded { .. } => unreachable!(), + }; + // Find it by identity: closing a tab shifts every index to its right, + // so an in-flight load addressed by index would be dropped or land on + // whatever pane inherited the slot (#67 review). + let Some(tab_idx) = self + .state + .tabs + .iter() + .position(|t| matches!(&t.pane, Some(PaneRef::Draft(d)) if d.id == want)) + else { + // The draft went away while the request was in flight. + return; + }; + let Some(PaneRef::Draft(draft)) = self + .state + .tabs + .get_mut(tab_idx) + .and_then(|t| t.pane.as_mut()) + else { + return; + }; + let mut follow_up_branches = false; + match update { + DraftUpdate::Data { + info, + projects, + names, + .. + } => { + if let Some(info) = info { + draft.home_dir.clone_from(&info.home_dir); + if !info.presets.is_empty() { + draft.set_presets(info.presets); + } + } + if let Some(projects) = projects { + draft.set_projects(projects, Utc::now()); + } + if !names.is_empty() { + draft.seed_names(names); + } + } + DraftUpdate::Names { names, .. } => { + if !names.is_empty() { + draft.extend_names(names); + } + } + DraftUpdate::GitRoot { seq, root, .. } => { + draft.resolve_gitroot(seq, root); + follow_up_branches = draft.take_branch_load().is_some(); + } + DraftUpdate::Branches { cwd, branches, .. } => { + draft.set_branches(&cwd, branches); + } + DraftUpdate::FastForward { .. } | DraftUpdate::WorktreesLoaded { .. } => unreachable!(), + } + if follow_up_branches { + self.spawn_load_branches(tab_idx); + } + } + + /// Fast-forward spawn (#67): one keystroke, every default resolved, no + /// form. The most recent project, its last command (or the first + /// non-custom preset), a freshly generated name, worktree on with a new + /// branch named after that name. Anything unresolvable — no projects + /// known yet, no command, no name — downgrades to a draft pane seeded + /// with whatever did resolve. + /// Load what the fast path needs without blocking the loop — the same + /// reason the draft loaders were moved off it (#67 review): a cold + /// `/api/projects` walks all of `$HOME`, and pressing the key must not + /// freeze keys, frames and attached panes until it returns. + fn spawn_fast_forward(&self) { + let (daemon, http, base, tx) = self.loader_handles(); + tokio::spawn(async move { + let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects()); + let names = fetch_names(&http, &base, crate::draft::NAME_BATCH_SIZE).await; + let _ = tx.send(DraftUpdate::FastForward { + info: info.ok(), + projects: projects.ok(), + names, + }); + }); + } + + async fn fast_forward_spawn( + &mut self, + info: Option, + projects: Option>, + names: Vec, + ) { + // Keep the home dir: without it the pre-filled draft renders + // unabbreviated paths and "Other directory…" seeds `/` instead of + // home, so the two draft entry points disagreed (#67 review). + let home_dir = info + .as_ref() + .map(|i| i.home_dir.clone()) + .unwrap_or_default(); + let presets = match info { + Some(info) if !info.presets.is_empty() => info.presets, + _ => Vec::new(), + }; + let discovered = projects.unwrap_or_default(); + let resolved = + crate::fast_forward::resolve(&discovered, &presets, names.first().cloned(), Utc::now()); + if let Some(req) = resolved.build_request() { + // Never eat a pane already showing something: the spawn takes a + // fresh tab, same as opening a draft over a session would. + if self.state.active_tab().pane.is_some() { + self.state.new_tab(); + } + self.submit_spawn(req).await; + return; + } + // Unresolved defaults: a draft pre-filled with what did resolve. + self.state.open_draft_pane(); + let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() else { + return; + }; + draft.home_dir = home_dir; + if !presets.is_empty() { + draft.set_presets(presets); + } + draft.set_projects(discovered, Utc::now()); + resolved.apply_to_draft(draft); + // A missing generated name still arrives here; seeding is a no-op + // once apply_to_draft has set a touched name. + draft.seed_names(names); + } + 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) => { + // A failed POST belongs in the form it came from (#64), not a + // toast that dismisses on the next keystroke. + let message = e + .daemon_message() + .map(String::from) + .unwrap_or_else(|| e.to_string()); + if let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() { + draft.error = Some(format!("spawn failed: {message}")); + } else { + self.state.toast(format!("spawn failed: {e}")); + } } - Err(e) => self.state.toast(format!("spawn failed: {e}")), } } async fn submit_kill(&mut self, sid: Uuid) { - match self.daemon.kill_session(sid).await { - Ok(()) => self.release_session(sid), + match self.daemon.kill_session(sid, false).await { + Ok(outcome) => { + if let Some(w) = outcome.retained { + self.state + .toast(format!("worktree retained: {}", w.path.display())); + } + self.release_session(sid) + } Err(e) => self.state.toast(format!("kill failed: {e}")), } } + /// Fetch the retained-worktree store without blocking the loop (#85) — + /// same off-loop pattern as the draft loaders. + fn spawn_load_worktrees(&self) { + let (daemon, _, _, tx) = self.loader_handles(); + tokio::spawn(async move { + let (worktrees, info) = tokio::join!(daemon.list_worktrees(), daemon.daemon_info()); + let _ = tx.send(DraftUpdate::WorktreesLoaded { + worktrees: worktrees.unwrap_or_default(), + home_dir: info.map(|i| i.home_dir).unwrap_or_default(), + }); + }); + } + + /// Reclaim one retained worktree (#85): the directory goes, the branch + /// never does. The open view drops the entry so the list tells the truth + /// without waiting for a re-fetch. + async fn submit_reclaim(&mut self, id: Uuid) { + match self.daemon.reclaim_worktree(id).await { + Ok(()) => { + self.worktrees.remove(id); + self.state.toast("worktree reclaimed; branch kept"); + } + Err(e) => { + let message = e + .daemon_message() + .map(String::from) + .unwrap_or_else(|| e.to_string()); + self.state.toast(format!("reclaim failed: {message}")); + } + } + } + fn redraw( &mut self, terminal: &mut Terminal>, ) -> Result<(), TuiError> { let snap = self.catalog.snapshot(); - let attached: HashSet = self.state.tabs.iter().filter_map(|t| t.pane).collect(); + let attached: HashSet = 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 +797,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,14 +805,19 @@ 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, + worktrees: Some(&self.worktrees), }; let mut new_region = RegionMap::default(); @@ -446,6 +864,52 @@ fn visible_sidebar_order(snap: &crate::catalog::CatalogSnapshot) -> Vec { 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> { + 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). @@ -469,20 +933,102 @@ fn render_help_lines(cfg: &TuiConfig) -> Vec { lines } +/// A completed background load, applied on the event loop. +#[derive(Debug)] +enum DraftUpdate { + Data { + draft: u64, + info: Option, + projects: Option>, + names: Vec, + }, + Names { + draft: u64, + names: Vec, + }, + GitRoot { + draft: u64, + seq: u32, + root: Option, + }, + Branches { + draft: u64, + cwd: PathBuf, + branches: Vec, + }, + /// A fast-forward's data arrived; resolution and spawn happen on the loop. + FastForward { + info: Option, + projects: Option>, + names: Vec, + }, + /// The retained-worktree store arrived for the worktrees view (#85). + WorktreesLoaded { + worktrees: Vec, + home_dir: String, + }, +} + +/// `GET /api/names?count=N`. Free function: the spawned loaders have no `self`. +async fn fetch_names(http: &reqwest::Client, base: &str, count: usize) -> Vec { + let url = format!( + "{}{}", + maestro_protocol::routes::normalize_base(base), + routes::NAMES + ); + match http + .get(url) + .query(&[(routes::COUNT_QUERY, count)]) + .send() + .await + { + Ok(resp) => resp + .json::() + .await + .map(|batch| batch.names) + .unwrap_or_default(), + Err(_) => Vec::new(), + } +} + +/// `GET /api/branches?path=`. On failure the picker stays empty and "new +/// branch" mode still works. +async fn fetch_branches( + http: &reqwest::Client, + base: &str, + path: &std::path::Path, +) -> Vec { + let url = format!( + "{}{}", + maestro_protocol::routes::normalize_base(base), + routes::BRANCHES + ); + match http + .get(url) + .query(&[(routes::PATH_QUERY, path.to_string_lossy().as_ref())]) + .send() + .await + { + Ok(resp) => resp.json::>().await.unwrap_or_default(), + Err(_) => Vec::new(), + } +} + #[cfg(test)] mod tests { use super::*; use crate::action::Action; + use crate::draft::DraftField; use crate::keymap::Mode; use maestro_config::TuiSection; fn make_app() -> App { + make_app_at("http://127.0.0.1:7070") + } + + fn make_app_at(base: &str) -> App { let cfg = TuiConfig::load(&TuiSection::default()).unwrap(); - App::new( - cfg, - CatalogStore::default(), - DaemonClient::new("http://127.0.0.1:7070"), - ) + App::new(cfg, CatalogStore::default(), DaemonClient::new(base)) } #[test] @@ -513,10 +1059,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. @@ -531,12 +1100,14 @@ mod tests { cwd: std::path::PathBuf::from("/"), env: Default::default(), metadata: Default::default(), + worktree: None, status: SessionStatus::Running, created_at: Utc::now(), foreground: None, activity: Default::default(), last_activity_at: Utc::now(), current_cwd: None, + blocked_work: None, }], }); app.dispatch(Action::SidebarNext); @@ -551,7 +1122,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 +1155,384 @@ mod tests { } #[test] - fn submitting_the_spawn_modal_carries_the_typed_fields() { + fn new_draft_pane_schedules_a_data_load() { 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; + assert_eq!( + app.dispatch(Action::NewDraftPane), + vec![Effect::LoadDraft(0)] + ); + } - let (_, effects) = app.handle_overlay_input(&InputKey::Enter); - match effects.as_slice() { + /// The fast-forward keystroke itself is side-effect-free until its + /// daemon fetches come back. + #[test] + fn fast_forward_spawn_dispatches_the_fast_forward_effect() { + let mut app = make_app(); + assert_eq!( + app.dispatch(Action::FastForwardSpawn), + vec![Effect::FastForward] + ); + } + + /// Every default resolves: the POST goes straight out — most recent + /// project, its last command, generated name, worktree branch named + /// after it (#67). + #[tokio::test] + async fn fast_forward_spawns_immediately_when_every_default_resolves() { + use axum::routing::{get, post}; + use axum::Json; + use serde_json::json; + use std::sync::{Arc, Mutex}; + + async fn info() -> Json { + Json(json!({ + "hostname": "box", "version": "0.1.0", "home_dir": "/home/u", + "presets": [{"id": "claude", "label": "Claude", "command": "claude"}] + })) + } + async fn projects() -> Json { + Json(json!([ + {"path": "/repos/old", "name": "old", + "last_activity": chrono::Utc::now().to_rfc3339(), "last_command": "zsh"}, + {"path": "/repos/fresh", "name": "fresh", "base_repo": "/repos/fresh", + "last_activity": chrono::Utc::now().to_rfc3339(), "last_command": "aider"} + ])) + } + async fn names() -> Json { + Json(json!({"names": ["crimson-otter"]})) + } + let posted: Arc>> = Arc::default(); + let seen = posted.clone(); + async fn spawn_session( + axum::extract::State(seen): axum::extract::State>>>, + Json(body): Json, + ) -> Json { + seen.lock().unwrap().push(body); + Json(json!({ + "id": uuid::Uuid::from_u128(77).to_string(), + "cwd": "/repos/fresh", "command": "aider", + "status": "Running", "created_at": chrono::Utc::now().to_rfc3339() + })) + } + + let app_router = axum::Router::new() + .route(maestro_protocol::routes::INFO, get(info)) + .route(maestro_protocol::routes::PROJECTS, get(projects)) + .route(maestro_protocol::routes::NAMES, get(names)) + .route( + maestro_protocol::routes::SESSIONS, + post(spawn_session).with_state(seen), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() }); + + let mut app = make_app_at(&base); + let effects = app.dispatch(Action::FastForwardSpawn); + // The fast path loads off the event loop now; the run loop drains the + // channel, so do that by hand. + app.perform(effects).await; + let update = tokio::time::timeout(Duration::from_secs(5), app.draft_rx.recv()) + .await + .expect("load completed") + .expect("channel open"); + app.apply_draft_update(update).await; + + let posts = posted.lock().unwrap(); + assert_eq!(posts.len(), 1, "one immediate POST, no form shown"); + let req = &posts[0]; + assert_eq!(req["cwd"], json!("/repos/fresh")); + assert_eq!(req["command"], json!("aider")); + assert_eq!(req["metadata"]["name"], json!("crimson-otter")); + assert_eq!(req["worktree"]["base_repo"], json!("/repos/fresh")); + assert_eq!(req["worktree"]["branch"], json!("crimson-otter")); + assert_eq!(req["worktree"]["create_branch"], json!(true)); + } + + /// No projects known yet: nothing can resolve, so the keystroke opens a + /// draft pane (focused, Insert mode) instead of guessing (#67). + #[tokio::test] + async fn unresolvable_defaults_open_a_prefilled_draft_instead() { + use axum::routing::get; + use axum::Json; + use serde_json::json; + + async fn info() -> Json { + Json(json!({ + "hostname": "box", "version": "0.1.0", "home_dir": "/home/u", + "presets": [{"id": "claude", "label": "Claude", "command": "claude"}] + })) + } + async fn projects() -> Json { + Json(json!([])) + } + async fn names() -> Json { + Json(json!({"names": ["amber-falcon"]})) + } + + let app_router = axum::Router::new() + .route(maestro_protocol::routes::INFO, get(info)) + .route(maestro_protocol::routes::PROJECTS, get(projects)) + .route(maestro_protocol::routes::NAMES, get(names)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() }); + + let mut app = make_app_at(&base); + let effects = app.dispatch(Action::FastForwardSpawn); + // The fast path loads off the event loop now; the run loop drains the + // channel, so do that by hand. + app.perform(effects).await; + let update = tokio::time::timeout(Duration::from_secs(5), app.draft_rx.recv()) + .await + .expect("load completed") + .expect("channel open"); + app.apply_draft_update(update).await; + + assert!( + matches!(app.state.active_tab().pane, Some(PaneRef::Draft(_))), + "the fallback is a draft pane" + ); + assert_eq!(app.state.focus, Focus::Pane); + assert_eq!(app.state.mode, Mode::Insert); + let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else { + panic!("expected a draft pane"); + }; + // Whatever did resolve (nothing project-side) is seeded; the + // generated name still lands so the form is ready to go. + assert_eq!(draft.name.value(), "amber-falcon"); + assert_eq!(draft.command(), "claude"); + } + + /// A valid form flows through: pick a project (set directly — the picker + /// itself is covered in `draft.rs`), select the custom tile, type a + /// command, then submit from the Spawn row. + #[test] + fn typing_into_the_draft_then_enter_carries_the_typed_fields() { + let mut app = make_app(); + app.dispatch(Action::NewDraftPane); + let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else { + panic!("expected a draft pane"); + }; + + draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work")); + draft.base_repo = Some(std::path::PathBuf::from("/srv/work")); + draft.probed_cwd = Some(std::path::PathBuf::from("/srv/work")); + draft.selected_preset = draft.presets.len() - 1; // custom + app.handle_draft_input(&InputKey::Tab); // preset → custom command + for c in "claude".chars() { + app.handle_draft_input(&InputKey::char(c)); + } + app.handle_draft_input(&InputKey::Tab); // → dir picker + app.handle_draft_input(&InputKey::Tab); // → name + for c in "crimson-otter".chars() { + app.handle_draft_input(&InputKey::char(c)); + } + app.handle_draft_input(&InputKey::Tab); // → worktree toggle + app.handle_draft_input(&InputKey::Tab); // → branch mode + app.handle_draft_input(&InputKey::Tab); // → new-branch name + app.handle_draft_input(&InputKey::Tab); // → submit + + match app.handle_draft_input(&InputKey::Enter).as_slice() { [Effect::Spawn(req)] => { assert_eq!(req.cwd, std::path::PathBuf::from("/srv/work")); assert_eq!(req.command, "claude"); + assert_eq!( + req.metadata.get("name").and_then(|v| v.as_str()), + Some("crimson-otter"), + "the generated name rides along as metadata.name" + ); } - other => panic!("expected one Spawn effect, got {other:?}"), + other => panic!("expected a single Spawn effect, got {other:?}"), } } + /// Editing the raw path with the worktree toggle on schedules a + /// `/api/gitroot` probe so the section learns its base repo (#65). + #[test] + fn typing_a_raw_path_schedules_a_gitroot_probe() { + let mut app = make_app(); + app.dispatch(Action::NewDraftPane); + let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else { + panic!("expected a draft pane"); + }; + draft.cwd_raw_visible = true; + draft.focused_field = DraftField::CwdRaw; + + let effects = app.handle_draft_input(&InputKey::char('/')); + assert!( + matches!( + effects.as_slice(), + [Effect::ProbeGitRoot { tab: 0, seq: 2, .. }] + ), + "{effects:?}" + ); + } + + /// Switching the worktree branch source to Existing schedules one + /// `/api/branches` load for the current target (#65). + #[test] + fn switching_to_existing_branch_mode_schedules_a_branch_load() { + let mut app = make_app(); + app.dispatch(Action::NewDraftPane); + let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else { + panic!("expected a draft pane"); + }; + draft.picked_cwd = Some(std::path::PathBuf::from("/srv/repo")); + draft.base_repo = Some(std::path::PathBuf::from("/srv/repo")); + draft.probed_cwd = Some(std::path::PathBuf::from("/srv/repo")); + draft.focused_field = DraftField::BranchMode; + + let effects = app.handle_draft_input(&InputKey::Right); + assert!( + matches!(effects.as_slice(), [Effect::LoadBranches(0)]), + "{effects:?}" + ); + } + + /// Submitting with no directory names the missing field inside the form + /// and posts nothing (#64). + #[test] + fn submitting_an_empty_form_reports_the_missing_field_instead_of_posting() { + let mut app = make_app(); + app.dispatch(Action::NewDraftPane); + // Walk to Submit without picking anything. + for _ in 0..4 { + app.handle_draft_input(&InputKey::Tab); + } + assert!(app.handle_draft_input(&InputKey::Enter).is_empty()); + let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else { + panic!("expected a draft pane"); + }; + assert_eq!( + draft.error.as_deref(), + Some("Pick a working directory."), + "the refusal shows in the form" + ); + + // With a directory but an empty custom command, it names the command. + let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else { + panic!(); + }; + draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work")); + draft.selected_preset = draft.presets.len() - 1; // custom, empty text + assert!(app.handle_draft_input(&InputKey::Enter).is_empty()); + let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else { + panic!(); + }; + assert_eq!( + draft.error.as_deref(), + Some("Pick a command (or fill the custom command).") + ); + } + + /// A failed POST lands back in the form as an error line (#64), not a + /// toast that the next keystroke dismisses. + #[tokio::test] + async fn a_failed_spawn_shows_the_daemons_error_in_the_form() { + use std::time::Duration; + let mut app = App::new( + TuiConfig::load(&TuiSection::default()).unwrap(), + CatalogStore::default(), + DaemonClient::with_timeouts( + "http://127.0.0.1:9", + Duration::from_millis(200), + Duration::from_millis(500), + ), + ); + app.dispatch(Action::NewDraftPane); + let req = CreateSessionRequest { + cwd: std::path::PathBuf::from("/srv/work"), + command: "bash".into(), + env: Default::default(), + metadata: Default::default(), + worktree: None, + }; + app.submit_spawn(req).await; + + let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else { + panic!("expected a draft pane"); + }; + let error = draft + .error + .as_deref() + .expect("spawn failure must reach the form"); + assert!(error.starts_with("spawn failed"), "{error}"); + assert!( + matches!(app.state.overlay(), None | Some(OverlayKind::Toast(_))), + "no kill-confirm style overlay took over" + ); + } + + /// The full data load against a live daemon stub: presets replace the + /// built-ins, projects land sorted, and the first generated name seeds + /// the field. + #[tokio::test] + async fn load_draft_data_fills_presets_projects_and_names() { + use axum::routing::get; + use axum::Json; + use serde_json::json; + + async fn info() -> Json { + Json(json!({ + "hostname": "box", + "version": "0.1.0", + "home_dir": "/home/u", + "presets": [ + {"id": "aider", "label": "Aider", "command": "aider"}, + {"id": "custom", "label": "Custom…", "command": ""} + ] + })) + } + async fn projects() -> Json { + Json(json!([ + {"path": "/repos/maestro", "name": "maestro", + "last_activity": chrono::Utc::now().to_rfc3339(), + "last_command": "claude"} + ])) + } + async fn names() -> Json { + Json(json!({"names": ["crimson-otter", "amber-falcon"]})) + } + + let app_router = axum::Router::new() + .route(maestro_protocol::routes::INFO, get(info)) + .route(maestro_protocol::routes::PROJECTS, get(projects)) + .route(maestro_protocol::routes::NAMES, get(names)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() }); + + let mut app = make_app_at(&base); + let effects = app.dispatch(Action::NewDraftPane); + assert!(matches!(effects.as_slice(), [Effect::LoadDraft(0)])); + // `perform` only spawns the load — it must not block the event loop on + // the network (#67 review). The result arrives on the draft channel, + // which the run loop drains; do that by hand here. + app.perform(effects).await; + let update = tokio::time::timeout(Duration::from_secs(5), app.draft_rx.recv()) + .await + .expect("load completed") + .expect("channel open"); + app.apply_draft_update(update).await; + + let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else { + panic!("expected a draft pane"); + }; + assert_eq!(draft.command(), "aider"); + assert_eq!(draft.rows.len(), 1); + assert_eq!(draft.rows[0].project.name, "maestro"); + assert_eq!(draft.home_dir, "/home/u"); + assert_eq!(draft.name.value(), "crimson-otter"); + } + + /// Keys fed while no draft exists must be ignored, not panic. + #[test] + fn draft_input_is_a_no_op_without_a_draft() { + let mut app = make_app(); + assert!(app.handle_draft_input(&InputKey::char('x')).is_empty()); + } + /// Regression: `Toast` returned `Pass` and nothing ever cleared it, so a /// failure message stayed on screen until another overlay replaced it. #[test] @@ -629,6 +1561,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; @@ -642,6 +1607,7 @@ mod tests { cwd: std::path::PathBuf::from("/"), env: Default::default(), metadata: Default::default(), + worktree: None, status: SessionStatus::Running, created_at: Utc::now(), foreground: None, @@ -649,9 +1615,168 @@ mod tests { last_activity_at: chrono::Utc::now(), current_cwd: None, + blocked_work: None, }], }); app.dispatch(Action::KillHighlighted); assert!(matches!(app.state.overlay(), Some(OverlayKind::KillConfirm(s)) if *s == sid)); } + + // -- retained worktrees (#85) ------------------------------------------- + + fn sample_worktree(id: u128) -> maestro_protocol::RetainedWorktree { + use chrono::TimeZone; + maestro_protocol::RetainedWorktree { + id: Uuid::from_u128(id), + path: "/home/naps/tea/maestro/worktrees/feat/login".into(), + base_repo: "/home/naps/tea/maestro".into(), + branch: "feat/login".into(), + reason: maestro_protocol::RetainedReason::ForceKilled, + retained_at: chrono::Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(), + in_the_way: maestro_protocol::InTheWay { + uncommitted_files: 2, + untracked_files: 1, + unpushed_commits: 3, + }, + } + } + + fn app_with_one_retained_worktree() -> App { + let mut app = make_app(); + app.worktrees.home_dir = "/home/naps".into(); + app.worktrees.apply(vec![sample_worktree(42)]); + app.state.show_overlay(OverlayKind::Worktrees); + app + } + + #[test] + fn show_worktrees_opens_the_view_and_fetches() { + let mut app = make_app(); + assert_eq!( + app.dispatch(Action::ShowWorktrees), + vec![Effect::LoadWorktrees] + ); + assert_eq!(app.state.overlay(), Some(&OverlayKind::Worktrees)); + } + + #[test] + fn r_over_the_highlighted_worktree_asks_for_a_confirmation() { + let mut app = app_with_one_retained_worktree(); + + let (decision, effects) = app.handle_overlay_input(&InputKey::char('r')); + assert_eq!(decision, OverlayDecision::Consumed); + assert!(effects.is_empty()); + match app.state.overlay() { + Some(OverlayKind::ReclaimConfirm { id, branch, .. }) => { + assert_eq!(*id, Uuid::from_u128(42)); + assert_eq!(branch, "feat/login"); + } + other => panic!("expected a reclaim confirm, got {other:?}"), + } + } + + /// The confirmation names what is in the way using the shared phrasing, + /// so the confirm step cannot drift from the list or the CLI. + #[test] + fn the_reclaim_confirmation_carries_the_shared_in_the_way_phrasing() { + let mut app = app_with_one_retained_worktree(); + app.handle_overlay_input(&InputKey::char('r')); + match app.state.overlay() { + Some(OverlayKind::ReclaimConfirm { blockers, .. }) => { + assert_eq!( + blockers, + "2 uncommitted files, 1 untracked file, 3 unpushed commits" + ); + } + other => panic!("expected a reclaim confirm, got {other:?}"), + } + } + + #[test] + fn confirming_a_reclaim_asks_for_the_reclaim() { + let mut app = app_with_one_retained_worktree(); + app.handle_overlay_input(&InputKey::char('r')); + + let (decision, effects) = app.handle_overlay_input(&InputKey::char('y')); + assert_eq!(decision, OverlayDecision::Consumed); + assert_eq!(effects, vec![Effect::ReclaimWorktree(Uuid::from_u128(42))]); + assert!(app.state.overlay().is_none()); + } + + #[test] + fn declining_a_reclaim_returns_to_the_list() { + let mut app = app_with_one_retained_worktree(); + app.handle_overlay_input(&InputKey::char('r')); + + let (decision, effects) = app.handle_overlay_input(&InputKey::char('n')); + assert_eq!(decision, OverlayDecision::Consumed); + assert!(effects.is_empty()); + assert_eq!(app.state.overlay(), Some(&OverlayKind::Worktrees)); + } + + #[test] + fn j_and_k_move_the_highlight_inside_the_view() { + let mut app = make_app(); + app.worktrees + .apply(vec![sample_worktree(1), sample_worktree(2)]); + // Distinct ids so movement is observable. + app.worktrees.items[1].branch = "other".into(); + app.state.show_overlay(OverlayKind::Worktrees); + + app.handle_overlay_input(&InputKey::char('j')); + assert_eq!(app.worktrees.selected().unwrap().branch, "other"); + app.handle_overlay_input(&InputKey::char('k')); + assert_eq!(app.worktrees.selected().unwrap().branch, "feat/login"); + } + + #[tokio::test] + async fn loading_worktrees_fills_the_view_from_the_daemon() { + use axum::routing::{delete, get}; + use axum::Json; + use serde_json::json; + + async fn info() -> Json { + Json(json!({"hostname": "box", "version": "0.1.0", "home_dir": "/home/naps"})) + } + async fn worktrees() -> Json { + Json(json!([{ + "id": Uuid::from_u128(42).to_string(), + "path": "/home/naps/tea/maestro/worktrees/feat/login", + "base_repo": "/home/naps/tea/maestro", + "branch": "feat/login", + "reason": "force-killed", + "retained_at": "2026-08-24T10:00:00Z", + "in_the_way": {"uncommitted_files": 2, "untracked_files": 1, "unpushed_commits": 3}, + }])) + } + async fn reclaim() -> Json { + Json(json!(null)) + } + + let app_router = axum::Router::new() + .route(maestro_protocol::routes::INFO, get(info)) + .route(maestro_protocol::routes::WORKTREES, get(worktrees)) + .route("/api/worktrees/:id", delete(reclaim)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() }); + + let mut app = make_app_at(&base); + let effects = app.dispatch(Action::ShowWorktrees); + app.perform(effects).await; + let update = tokio::time::timeout(Duration::from_secs(5), app.draft_rx.recv()) + .await + .expect("load completed") + .expect("channel open"); + app.apply_draft_update(update).await; + + assert_eq!(app.state.overlay(), Some(&OverlayKind::Worktrees)); + assert_eq!(app.worktrees.items.len(), 1); + assert_eq!(app.worktrees.items[0].branch, "feat/login"); + assert_eq!(app.worktrees.home_dir, "/home/naps"); + + // Reclaiming drops the entry from the open view. + app.submit_reclaim(Uuid::from_u128(42)).await; + assert!(app.worktrees.items.is_empty()); + } } diff --git a/crates/maestro-tui/src/catalog.rs b/crates/maestro-tui/src/catalog.rs index 2190ee4..d2f539b 100644 --- a/crates/maestro-tui/src/catalog.rs +++ b/crates/maestro-tui/src/catalog.rs @@ -188,12 +188,14 @@ mod tests { cwd: std::path::PathBuf::from("/tmp"), env: Default::default(), metadata: Default::default(), + worktree: None, status: SessionStatus::Running, created_at: Utc::now(), foreground: None, activity: Default::default(), last_activity_at: chrono::Utc::now(), current_cwd: None, + blocked_work: None, } } diff --git a/crates/maestro-tui/src/config.rs b/crates/maestro-tui/src/config.rs index e4e7c9f..9f4eb2b 100644 --- a/crates/maestro-tui/src/config.rs +++ b/crates/maestro-tui/src/config.rs @@ -45,8 +45,12 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[ ("up", "SidebarPrev"), // Catalog actions ("enter", "AttachHighlighted"), - ("s", "OpenSpawnModal"), + ("s", "NewDraftPane"), + // Fast-forward spawn (#67): the draft-pane key's neighbour does the same + // thing with every default resolved and no form. + ("S", "FastForwardSpawn"), ("x", "KillHighlighted"), + ("w", "ShowWorktrees"), // Tabs ("ctrl-t", "NewTab"), ("g t", "NextTab"), @@ -155,6 +159,18 @@ mod tests { cfg.keymap.resolve(Mode::Normal, "g t"), Some(Action::NextTab), ); + assert_eq!( + cfg.keymap.resolve(Mode::Normal, "s"), + Some(Action::NewDraftPane), + ); + assert_eq!( + cfg.keymap.resolve(Mode::Normal, "S"), + Some(Action::FastForwardSpawn), + ); + assert_eq!( + cfg.keymap.resolve(Mode::Normal, "w"), + Some(Action::ShowWorktrees), + ); } #[test] diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs new file mode 100644 index 0000000..3e26386 --- /dev/null +++ b/crates/maestro-tui/src/draft.rs @@ -0,0 +1,2000 @@ +//! Draft pane — the spawn form living in an ordinary pane (issues #63/#64). +//! +//! 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. +//! +//! Field groups (#64), mirroring the web form (`web/src/spawnForm.ts`): +//! command presets from `/api/info` (with an implicit `custom` free-text +//! entry), a project picker fed by [`crate::projects`] over `/api/projects` +//! plus an `Other directory…` escape hatch, a generated session name from +//! `/api/names` with a reroll key, and validation that reports which field is +//! missing instead of posting an empty request. A worktree section (#65) +//! mirrors `web/src/spawnForm.ts`: on by default over a git repository, +//! branch tracking the generated name until touched, with a new-or-existing +//! branch switch fed by `/api/branches`. + +use std::collections::VecDeque; +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; +use maestro_config::{builtin_presets, custom_preset}; +use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec}; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::Frame; + +use crate::input::InputKey; +use crate::projects; + +/// How many generated names one `/api/names` refill fetches. Rerolls consume +/// the local batch so they stay instant; the daemon is hit only when it runs +/// dry. +pub const NAME_BATCH_SIZE: usize = 20; + +/// Width of the fixed label gutter every form line starts with (`cmd: `, +/// `dir: `, …). +const GUTTER: usize = 5; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DraftField { + /// The horizontal preset-tile row (Left / Right move the selection). + Preset, + /// Free-text command, reachable only while a custom preset is selected. + CustomCommand, + /// Project picker: typing filters, Up / Down highlight, Enter picks. + DirPicker, + /// Free-text path, revealed by picking `Other directory…`. + CwdRaw, + Name, + /// Worktree on/off for this session (#65). Enter / Space toggles. + WorktreeToggle, + /// New-vs-existing branch source; Left / Right switches. + BranchMode, + /// Free-text new-branch name, shown in `New` mode. + BranchNew, + /// Existing-branch picker, shown in `Existing` mode. + BranchExisting, + Submit, +} + +/// Where a worktree's branch comes from (#65), matching the web form's +/// `WorktreeBranchMode`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorktreeBranchMode { + /// Create the branch (named after the generated session name by default). + New, + /// Check out a branch picked from `GET /api/branches` as-is. + Existing, +} + +/// One flattened project-picker entry: a discovered repo/worktree plus what +/// rendering needs to know about it. `indent` marks worktrees nested under +/// their main checkout. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DraftRow { + pub project: Project, + pub stale: bool, + pub indent: bool, +} + +/// Why a submit was refused. The message names the missing field so the form +/// can show it instead of posting an empty request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DraftValidationError { + MissingDirectory, + MissingCommand, + MissingWorktreeRepo, + MissingNewBranch, + MissingExistingBranch, +} + +impl std::fmt::Display for DraftValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingDirectory => write!(f, "Pick a working directory."), + Self::MissingCommand => write!(f, "Pick a command (or fill the custom command)."), + Self::MissingWorktreeRepo => { + write!(f, "Worktree needs a directory inside a git repository.") + } + Self::MissingNewBranch => write!(f, "Worktree requires a branch name."), + Self::MissingExistingBranch => write!(f, "Pick an existing branch."), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DraftInputResult { + Consumed, + Submit, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct DraftPane { + /// Identity for routing async load results back to *this* draft. + /// + /// Tab indices shift when a tab closes, so an in-flight load addressed by + /// index could be dropped or land on the wrong pane (#67 review). + pub id: u64, + /// Includes the implicit custom tile (empty `command`) last. + pub presets: Vec, + pub selected_preset: usize, + pub custom_command: crate::text_input::TextInput, + /// Discovered projects, sorted fresh-first with worktrees nested. + pub rows: Vec, + pub filter: crate::text_input::TextInput, + /// Highlighted entry within the visible list; the implicit + /// `Other directory…` entry sits at index `visible_rows().len()`. + pub highlighted: usize, + pub cwd_raw_visible: bool, + pub cwd_raw: crate::text_input::TextInput, + pub picked_cwd: Option, + pub name: crate::text_input::TextInput, + pub name_queue: VecDeque, + /// Set once the operator types or rerolls a name; guards refills against + /// clobbering it, exactly like the web form's `nameTouched`. + pub name_touched: bool, + needs_name_refill: bool, + /// Worktree on for this spawn (#65). Defaults on; a failed gitroot probe + /// unticks it, like the web form. + pub worktree_enabled: bool, + /// Enclosing repository of the chosen target — from the project scan for + /// picked rows, `/api/gitroot` for raw paths. `None` until known. + pub base_repo: Option, + /// The cwd the current `base_repo` answer belongs to. + pub(crate) probed_cwd: Option, + /// Bumped on every target change; answers carrying an older seq are stale. + probe_seq: u32, + needs_probe: bool, + /// Set when a probe found no repository (drives the rendered hint). + repo_missing: bool, + /// New-branch name. Tracks [`Self::name`] until edited, like the web form. + pub branch: crate::text_input::TextInput, + branch_edited: bool, + pub branch_mode: WorktreeBranchMode, + /// Branches of the target repo (`GET /api/branches`), with occupancy. + pub branches: Vec, + /// Highlighted entry in `Existing` mode; always an available branch + /// unless moved by hand. + pub selected_branch: usize, + branches_for: Option, + needs_branches: bool, + /// Last refusal (validation or a failed POST), rendered inside the form + /// instead of dismissing it. + pub error: Option, + pub focused_field: DraftField, + pub home_dir: String, +} + +/// Source of [`DraftPane::id`]. +static NEXT_DRAFT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +impl Default for DraftPane { + fn default() -> Self { + // Built-ins until `/api/info` answers — the form must be usable even + // while its data loads, and stay usable when the daemon reports none. + let mut presets = builtin_presets(); + presets.push(custom_preset()); + Self { + id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + presets, + selected_preset: 0, + custom_command: crate::text_input::TextInput::default(), + rows: Vec::new(), + filter: crate::text_input::TextInput::default(), + highlighted: 0, + cwd_raw_visible: false, + cwd_raw: crate::text_input::TextInput::default(), + picked_cwd: None, + name: crate::text_input::TextInput::default(), + name_queue: VecDeque::new(), + name_touched: false, + needs_name_refill: false, + worktree_enabled: true, + base_repo: None, + probed_cwd: None, + probe_seq: 0, + needs_probe: false, + repo_missing: false, + branch: crate::text_input::TextInput::default(), + branch_edited: false, + branch_mode: WorktreeBranchMode::New, + branches: Vec::new(), + selected_branch: 0, + branches_for: None, + needs_branches: false, + error: None, + focused_field: DraftField::Preset, + home_dir: String::new(), + } + } +} + +impl DraftPane { + /// Replace the preset list from `GET /api/info`. An empty-command tile is + /// treated as the custom escape hatch and appended if missing, so a daemon + /// list without one still leaves the operator a way out. + pub fn set_presets(&mut self, mut presets: Vec) { + if !presets.iter().any(|p| p.command.is_empty()) { + presets.push(custom_preset()); + } + self.presets = presets; + self.selected_preset = self + .presets + .iter() + .position(|p| !p.command.is_empty()) + .unwrap_or(0); + } + + /// Replace the project snapshot: sort fresh-first (#54), nest worktrees + /// under their main checkout, and precompute staleness for rendering. + pub fn set_projects(&mut self, discovered: Vec, now: DateTime) { + let sorted = projects::sort_projects(discovered, now); + let groups = projects::group_projects(&sorted); + self.rows = groups + .iter() + .flat_map(|g| { + std::iter::once(DraftRow { + stale: projects::is_stale(g.main, now), + indent: false, + project: g.main.clone(), + }) + .chain(g.worktrees.iter().map(|wt| DraftRow { + stale: projects::is_stale(wt, now), + indent: true, + project: (*wt).clone(), + })) + }) + .collect(); + self.highlighted = self.highlighted.min(self.entry_count().saturating_sub(1)); + } + + /// Fill the local name batch from `GET /api/names`, seeding the field + /// itself unless the operator already typed one. + pub fn seed_names(&mut self, names: Vec) { + self.name_queue.extend(names); + if !self.name_touched && self.name.value().trim().is_empty() { + if let Some(first) = self.name_queue.pop_front() { + self.set_name(first); + } + } + } + + /// Append a refill batch; the first name replaces the field (this only + /// happens after an explicit reroll on an empty batch). + /// + /// The shown name is *consumed*, not left at the head — queueing it too + /// made the next reroll produce the same name and the key look dead + /// (#67 review). + pub fn extend_names(&mut self, mut names: Vec) { + if names.is_empty() { + return; + } + let first = names.remove(0); + self.set_name(first); + self.name_queue.extend(names); + } + + pub fn set_name(&mut self, name: impl Into) { + self.name = crate::text_input::TextInput::with_value(name.into()); + self.sync_branch_to_name(); + } + + /// The new-branch field mirrors the session name until the operator types + /// their own branch — and only in `New` mode, like the web form. + fn sync_branch_to_name(&mut self) { + if !self.branch_edited && self.branch_mode == WorktreeBranchMode::New { + self.branch = crate::text_input::TextInput::with_value(self.name.value().to_string()); + } + } + + /// Reroll the generated name. Pulls from the local batch; when it has run + /// dry, flags the caller to fetch a new one via [`Self::take_name_refill`]. + pub fn reroll(&mut self) { + self.name_touched = true; + match self.name_queue.pop_front() { + Some(next) => self.set_name(next), + None => self.needs_name_refill = true, + } + } + + /// Consume the reroll-needs-a-new-batch flag raised by [`Self::reroll`]. + pub fn take_name_refill(&mut self) -> bool { + std::mem::take(&mut self.needs_name_refill) + } + + // -- worktree (#65) ----------------------------------------------------- + + /// Whether the branch controls are shown: the toggle alone appears until + /// the target is known to be inside a repository. + fn worktree_body_visible(&self) -> bool { + self.worktree_enabled && self.base_repo.is_some() + } + + /// A target the draft already knows the repo for (picked project row). + fn note_picked_repo(&mut self, base_repo: PathBuf) { + self.base_repo = Some(base_repo); + self.repo_missing = false; + self.probe_seq += 1; + self.needs_probe = false; + self.probed_cwd = self.cwd(); + self.branches_for = None; + self.schedule_branch_load(); + } + + /// A target with an unknown repo (raw path edit or the Other… hatch). + /// Cancels any in-flight probe and requests a fresh one. + fn note_unknown_repo(&mut self) { + self.base_repo = None; + self.repo_missing = false; + self.probe_seq += 1; + self.needs_probe = self.worktree_enabled && self.cwd().is_some(); + self.branches_for = None; + self.schedule_branch_load(); + } + + fn schedule_branch_load(&mut self) { + if self.worktree_body_visible() && self.branch_mode == WorktreeBranchMode::Existing { + self.needs_branches = true; + } + } + + /// Consume the gitroot-probe request: the caller fetches + /// `GET /api/gitroot?path=…` and reports back via [`Self::resolve_gitroot`] + /// with the same seq. + pub fn take_gitroot_probe(&mut self) -> Option<(u32, PathBuf)> { + if !self.needs_probe { + return None; + } + self.needs_probe = false; + let cwd = self.cwd()?; + self.probe_seq += 1; + Some((self.probe_seq, cwd)) + } + + /// Apply one `/api/gitroot` answer. Stale answers — anything not matching + /// the latest seq — are dropped, so rapid retyping can't resurrect an old + /// repo. A miss unticks the toggle (web parity) and records the hint. + pub fn resolve_gitroot(&mut self, seq: u32, root: Option) { + if seq != self.probe_seq { + return; + } + self.needs_probe = false; + self.probed_cwd = self.cwd(); + match root { + Some(repo) => { + self.base_repo = Some(repo); + self.repo_missing = false; + } + None => { + self.base_repo = None; + self.repo_missing = true; + self.worktree_enabled = false; + } + } + self.schedule_branch_load(); + } + + /// Consume the branch-list request: the caller fetches + /// `GET /api/branches?path=…` for the returned cwd. + pub fn take_branch_load(&mut self) -> Option { + if !self.needs_branches { + return None; + } + self.needs_branches = false; + self.cwd() + } + + /// Store a fetched branch list; answers for anything but the current cwd + /// are stale and dropped. + pub fn set_branches(&mut self, cwd: &Path, branches: Vec) { + if self.cwd().as_deref() != Some(cwd) { + return; + } + self.branches_for = Some(cwd.to_path_buf()); + self.branches = branches; + self.selected_branch = (0..self.branches.len()) + .find(|&i| Self::branch_is_available(&self.branches[i])) + .unwrap_or(0); + } + + pub fn set_branch_mode(&mut self, mode: WorktreeBranchMode) { + if self.branch_mode == mode { + return; + } + self.branch_mode = mode; + self.sync_branch_to_name(); + self.schedule_branch_load(); + } + + pub fn cycle_branch_mode(&mut self, delta: i32) { + let next = match (self.branch_mode, delta >= 0) { + (WorktreeBranchMode::New, true) | (WorktreeBranchMode::Existing, false) => { + WorktreeBranchMode::Existing + } + _ => WorktreeBranchMode::New, + }; + self.set_branch_mode(next); + } + + /// Enter / Space on the toggle. Re-enabling reuses a still-valid repo + /// answer instead of re-probing. + pub fn toggle_worktree(&mut self) { + self.worktree_enabled = !self.worktree_enabled; + if !self.worktree_enabled { + return; + } + let cwd = self.cwd(); + if cwd.as_deref() != self.probed_cwd.as_deref() { + self.base_repo = None; + self.repo_missing = false; + } + if self.base_repo.is_none() { + self.probe_seq += 1; + self.needs_probe = cwd.is_some(); + } + self.schedule_branch_load(); + } + + /// A branch is pickable only when nothing has it checked out. That means + /// no linked worktree *and* not the main checkout's HEAD — git refuses a + /// second checkout either way, and HEAD is commonly `main`, so treating it + /// as available made it the default pick and every spawn failed with + /// "already used by worktree" (#54 review). + fn branch_is_available(b: &BranchInfo) -> bool { + b.worktree.is_none() && !b.is_head + } + + fn available_branch(&self, i: usize) -> bool { + self.branches.get(i).is_some_and(Self::branch_is_available) + } + + /// Move the existing-branch highlight, skipping branches another worktree + /// holds (git refuses to check them out twice). + pub fn move_branch_selection(&mut self, delta: i32) { + let available: Vec = (0..self.branches.len()) + .filter(|&i| self.available_branch(i)) + .collect(); + if available.is_empty() { + return; + } + let cur = available + .iter() + .position(|&i| i == self.selected_branch) + .unwrap_or(0); + let len = available.len() as i32; + self.selected_branch = available[((cur as i32) + delta).rem_euclid(len) as usize]; + } + + /// The highlighted branch, only when nothing has it checked out. + pub fn picked_branch(&self) -> Option<&BranchInfo> { + self.branches + .get(self.selected_branch) + .filter(|b| Self::branch_is_available(b)) + } + + pub fn is_custom(&self) -> bool { + self.presets + .get(self.selected_preset) + .is_none_or(|p| p.command.is_empty()) + } + + /// The resolved command: the selected preset's, or the custom free text. + pub fn command(&self) -> &str { + match self.presets.get(self.selected_preset) { + Some(p) if !p.command.is_empty() => &p.command, + _ => self.custom_command.value().trim(), + } + } + + /// The chosen working directory: a picked project row, else the raw path + /// input when it holds something. + pub fn cwd(&self) -> Option { + if let Some(cwd) = &self.picked_cwd { + return Some(cwd.clone()); + } + let raw = self.cwd_raw.value().trim(); + (!raw.is_empty()).then(|| PathBuf::from(raw)) + } + + /// Build the spawn request, naming the missing field instead of letting + /// an empty request reach the daemon. A non-empty name rides along as + /// `metadata.name`; an empty one sends no name at all. + pub fn build_request(&self) -> Result { + let Some(cwd) = self.cwd() else { + return Err(DraftValidationError::MissingDirectory); + }; + let command = self.command(); + if command.is_empty() { + return Err(DraftValidationError::MissingCommand); + } + let mut metadata = std::collections::HashMap::new(); + let name = self.name.value().trim(); + if !name.is_empty() { + metadata.insert( + "name".to_string(), + serde_json::Value::String(name.to_string()), + ); + } + let worktree = self.build_worktree()?; + Ok(CreateSessionRequest { + cwd, + command: command.to_string(), + env: Default::default(), + metadata, + worktree, + }) + } + + /// The worktree half of the request (#65): `None` while toggled off; + /// otherwise the inferred base repo plus a branch that is either created + /// (`New`) or checked out as-is (`Existing`). Cleanup is unconditional + /// daemon-side (#56), so no option for it here. + fn build_worktree(&self) -> Result, DraftValidationError> { + if !self.worktree_enabled { + return Ok(None); + } + let Some(base_repo) = self.base_repo.clone() else { + return Err(DraftValidationError::MissingWorktreeRepo); + }; + let spec = match self.branch_mode { + WorktreeBranchMode::New => { + let branch = self.branch.value().trim(); + if branch.is_empty() { + return Err(DraftValidationError::MissingNewBranch); + } + WorktreeSpec { + base_repo, + branch: branch.to_string(), + create_branch: true, + } + } + WorktreeBranchMode::Existing => { + let name = self + .picked_branch() + .ok_or(DraftValidationError::MissingExistingBranch)? + .name + .clone(); + WorktreeSpec { + base_repo, + branch: name, + create_branch: false, + } + } + }; + Ok(Some(spec)) + } + + fn field_order(&self) -> Vec { + let mut order = vec![DraftField::Preset]; + if self.is_custom() { + order.push(DraftField::CustomCommand); + } + order.push(DraftField::DirPicker); + if self.cwd_raw_visible { + order.push(DraftField::CwdRaw); + } + order.push(DraftField::Name); + order.push(DraftField::WorktreeToggle); + if self.worktree_body_visible() { + order.push(DraftField::BranchMode); + order.push(match self.branch_mode { + WorktreeBranchMode::New => DraftField::BranchNew, + WorktreeBranchMode::Existing => DraftField::BranchExisting, + }); + } + order.extend([DraftField::Submit]); + order + } + + fn step_field(&mut self, delta: i32) { + let order = self.field_order(); + let len = order.len() as i32; + let cur = order + .iter() + .position(|f| *f == self.focused_field) + .unwrap_or(0) as i32; + self.focused_field = order[(cur + delta).rem_euclid(len) as usize]; + } + + pub fn next_field(&mut self) { + self.step_field(1); + } + + pub fn prev_field(&mut self) { + self.step_field(-1); + } + + pub fn cycle_preset(&mut self, delta: i32) { + let len = self.presets.len(); + if len == 0 { + return; + } + self.selected_preset = + ((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize; + } + + /// Rows surviving the type-to-filter, in display order. + pub fn visible_rows(&self) -> Vec<&DraftRow> { + let query = self.filter.value(); + self.rows + .iter() + .filter(|r| projects::matches_filter(&r.project, query)) + .collect() + } + + /// Visible entries including the trailing implicit `Other directory…`. + pub fn entry_count(&self) -> usize { + self.visible_rows().len() + 1 + } + + /// Highlight clamped to the current entry count. + pub fn selection(&self) -> usize { + self.highlighted.min(self.entry_count().saturating_sub(1)) + } + + pub fn move_selection(&mut self, delta: i32) { + let count = self.entry_count() as i32; + if count == 0 { + return; + } + self.highlighted = ((self.selection() as i32) + delta).rem_euclid(count) as usize; + } + + /// Act on the highlighted picker entry: a project becomes the working + /// directory (and pre-selects the command it was last used with), while + /// the trailing entry reveals the free-text path input. + pub fn select_entry(&mut self) { + let visible = self.visible_rows(); + let sel = self.selection(); + if sel < visible.len() { + let project = visible[sel].project.clone(); + self.picked_cwd = Some(project.path.clone()); + self.cwd_raw_visible = false; + // The scan already knows this directory's repo (#61): no probe + // round trip needed, exactly like the web form. + let repo = project + .base_repo + .clone() + .unwrap_or_else(|| project.path.clone()); + if let Some(cmd) = project.last_command.as_deref() { + if !cmd.is_empty() { + if let Some(idx) = self.presets.iter().position(|p| p.command == cmd) { + self.selected_preset = idx; + } + } + } + self.note_picked_repo(repo); + return; + } + let seed = self.cwd().unwrap_or_else(|| { + if self.home_dir.is_empty() { + PathBuf::from("/") + } else { + PathBuf::from(&self.home_dir) + } + }); + self.picked_cwd = None; + self.cwd_raw_visible = true; + self.cwd_raw = crate::text_input::TextInput::with_value(seed.to_string_lossy()); + self.focused_field = DraftField::CwdRaw; + self.note_unknown_repo(); + } + + /// Handle one keystroke while this draft owns input (Insert mode, pane + /// focused). Returns [`DraftInputResult::Submit`] when the user accepts; + /// the caller validates, fires the POST and replaces the draft with the + /// session — or shows the reason it couldn't. + 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, + DraftField::DirPicker => { + self.select_entry(); + DraftInputResult::Consumed + } + DraftField::WorktreeToggle => { + self.toggle_worktree(); + DraftInputResult::Consumed + } + // The existing-branch selection is live; Enter just confirms. + DraftField::BranchExisting => DraftInputResult::Consumed, + _ => { + self.next_field(); + DraftInputResult::Consumed + } + }, + InputKey::Char { + c: ' ', + ctrl: false, + .. + } if self.focused_field == DraftField::WorktreeToggle => { + self.toggle_worktree(); + DraftInputResult::Consumed + } + InputKey::Up if self.focused_field == DraftField::DirPicker => { + self.move_selection(-1); + DraftInputResult::Consumed + } + InputKey::Down if self.focused_field == DraftField::DirPicker => { + self.move_selection(1); + DraftInputResult::Consumed + } + InputKey::Left if self.focused_field == DraftField::Preset => { + self.cycle_preset(-1); + DraftInputResult::Consumed + } + InputKey::Right if self.focused_field == DraftField::Preset => { + self.cycle_preset(1); + DraftInputResult::Consumed + } + InputKey::Left if self.focused_field == DraftField::BranchMode => { + self.cycle_branch_mode(-1); + DraftInputResult::Consumed + } + InputKey::Right if self.focused_field == DraftField::BranchMode => { + self.cycle_branch_mode(1); + DraftInputResult::Consumed + } + InputKey::Up if self.focused_field == DraftField::BranchExisting => { + self.move_branch_selection(-1); + DraftInputResult::Consumed + } + InputKey::Down if self.focused_field == DraftField::BranchExisting => { + self.move_branch_selection(1); + DraftInputResult::Consumed + } + InputKey::Char { + c: 'r', ctrl: true, .. + } if self.focused_field == DraftField::Name => { + self.reroll(); + DraftInputResult::Consumed + } + other => { + match self.focused_field { + DraftField::Preset | DraftField::Submit => {} + DraftField::CustomCommand => self.custom_command.handle_key(other), + // Typing filters the list; the highlight resets to the top + // so a narrowed search always starts at its best match. + DraftField::DirPicker => { + self.filter.handle_key(other); + self.highlighted = 0; + } + DraftField::CwdRaw => { + self.cwd_raw.handle_key(other); + // The target moved: the old repo answer no longer + // applies and a fresh probe is needed. + self.note_unknown_repo(); + } + DraftField::Name => { + self.name.handle_key(other); + self.name_touched = true; + self.sync_branch_to_name(); + } + DraftField::BranchNew => { + self.branch.handle_key(other); + self.branch_edited = true; + } + _ => {} + } + 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 show_custom = self.is_custom(); + let show_raw = self.cwd_raw_visible; + let show_wt_body = self.worktree_body_visible(); + // Up to three branch rows stay visible around the selection; held + // branches need their reason on screen, so they aren't collapsed. + let branch_rows = self.branches.len().clamp(1, 3) as u16; + let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* worktree */ + 1 /* spawn */ + + u16::from(show_custom) + + u16::from(show_raw) + + u16::from(show_wt_body) * (1 + if self.branch_mode == WorktreeBranchMode::New { 1 } else { branch_rows }) + + u16::from(self.error.is_some()); + let list_capacity = (inner.height.saturating_sub(fixed)).max(1) as usize; + + let mut constraints = vec![Constraint::Length(1)]; + if show_custom { + constraints.push(Constraint::Length(1)); + } + constraints.push(Constraint::Length(1)); + constraints.push(Constraint::Length(list_capacity as u16)); + if show_raw { + constraints.push(Constraint::Length(1)); + } + constraints.push(Constraint::Length(1)); // name + constraints.push(Constraint::Length(1)); // worktree toggle + if show_wt_body { + constraints.push(Constraint::Length(1)); // branch mode + match self.branch_mode { + WorktreeBranchMode::New => constraints.push(Constraint::Length(1)), + WorktreeBranchMode::Existing => constraints.push(Constraint::Length(branch_rows)), + } + } + constraints.push(Constraint::Length(1)); + if self.error.is_some() { + constraints.push(Constraint::Length(1)); + } + let rows = Layout::vertical(constraints).split(inner); + + let mut r = 0usize; + let mut next_row = || { + r += 1; + rows[r - 1] + }; + + let field_style = |field: DraftField| { + if pane_focused && self.focused_field == field { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + } + }; + + // Tracks where the terminal cursor belongs for the focused text field. + let mut cursor: Option<(u16, u16)> = None; + + // -- command presets --------------------------------------------------- + let tiles_row = next_row(); + let mut tiles = vec![Span::raw(format!("{: 0 { + tiles.push(Span::raw(" ")); + } + let mut style = Style::default(); + if i == self.selected_preset { + style = style.add_modifier(Modifier::REVERSED); + } + tiles.push(Span::styled(format!("[{}]", preset.label), style)); + } + f.render_widget(Paragraph::new(Line::from(tiles)), tiles_row); + + // -- custom command (only while a custom tile is selected) ------------- + if show_custom { + let row = next_row(); + draw_gutter(f, row, ""); + let col = draw_text_field( + f, + &self.custom_command, + row, + field_style(DraftField::CustomCommand), + ); + if self.focused_field == DraftField::CustomCommand { + cursor = Some((col, row.y)); + } + } + + // -- directory picker ---------------------------------------------------- + let filter_row = next_row(); + draw_gutter(f, filter_row, "dir:"); + let col = draw_text_field( + f, + &self.filter, + filter_row, + field_style(DraftField::DirPicker), + ); + if self.focused_field == DraftField::DirPicker { + cursor = Some((col, filter_row.y)); + } + + let visible = self.visible_rows(); + let sel = self.selection(); + let count = visible.len() + 1; + let capacity = list_capacity.min(count); + let start = if sel >= capacity { + sel + 1 - capacity + } else { + 0 + }; + let name_width = visible + .iter() + .take(start + capacity) + .skip(start) + .map(|row| row.project.name.chars().count() + usize::from(row.indent) * 2) + .max() + .unwrap_or(0) + .min(18); + + let list_row = next_row(); + for i in start..start + capacity { + let y = list_row.y + (i - start) as u16; + if y >= list_row.y + list_row.height { + break; + } + let active = pane_focused && self.focused_field == DraftField::DirPicker && i == sel; + let line = if i < visible.len() { + let row = visible[i]; + let mut base = Style::default(); + if row.stale { + base = base.add_modifier(Modifier::DIM); + } + if active { + base = base.add_modifier(Modifier::REVERSED); + } + let name = if row.indent { + format!(" {}", row.project.name) + } else { + row.project.name.clone() + }; + Line::from(vec![ + Span::styled(if active { ">" } else { " " }, base), + Span::raw(" "), + Span::styled(format!("{name: { + let row = next_row(); + draw_gutter(f, row, "br:"); + let col = + draw_text_field(f, &self.branch, row, field_style(DraftField::BranchNew)); + if self.focused_field == DraftField::BranchNew { + cursor = Some((col, row.y)); + } + } + WorktreeBranchMode::Existing => { + let len = self.branches.len(); + let capacity = branch_rows as usize; + let sel = self.selected_branch.min(len.saturating_sub(1)); + let start = if sel >= capacity { + sel + 1 - capacity + } else { + 0 + }; + let name_width = self + .branches + .iter() + .map(|b| b.name.chars().count()) + .max() + .unwrap_or(0) + .min(20); + let list_row = next_row(); + for i in start..(start + capacity).min(len.max(1)) { + let y = list_row.y + (i - start) as u16; + if y >= list_row.y + list_row.height { + break; + } + let line = match self.branches.get(i) { + Some(b) => { + let held = b.worktree.is_some(); + let mut base = Style::default(); + if held { + base = base.add_modifier(Modifier::DIM); + } + if pane_focused + && self.focused_field == DraftField::BranchExisting + && i == sel + { + base = base.add_modifier(Modifier::REVERSED); + } + let reason = b + .worktree + .as_ref() + .map(|w| { + format!(" — held by {}", abbreviate_path(w, &self.home_dir)) + }) + .unwrap_or_default(); + Line::from(vec![ + Span::styled(if i == sel { ">" } else { " " }, base), + Span::raw(" "), + Span::styled(format!("{: Line::from(Span::styled( + "no local branches found", + Style::default().add_modifier(Modifier::DIM), + )), + }; + f.render_widget( + Paragraph::new(line), + Rect { + x: list_row.x + GUTTER as u16, + y, + width: list_row.width.saturating_sub(GUTTER as u16), + height: 1, + }, + ); + } + } + } + } + + // -- submit ------------------------------------------------------------------ + let spawn_row = next_row(); + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "[ Spawn ]", + field_style(DraftField::Submit), + ))), + spawn_row, + ); + + // -- error --------------------------------------------------------------------- + if let Some(error) = &self.error { + let row = next_row(); + f.render_widget( + Paragraph::new(Line::from(Span::styled( + format!("error: {error}"), + Style::default().fg(Color::Red), + ))), + row, + ); + } + + if let Some((x, y)) = cursor.filter(|_| pane_focused) { + f.set_cursor_position((x, y)); + } + } +} + +fn draw_gutter(f: &mut Frame, row: Rect, label: &str) { + f.render_widget( + Paragraph::new(Line::from(Span::raw(format!("{label: u16 { + let area = Rect { + x: row.x + GUTTER as u16, + y: row.y, + width: row.width.saturating_sub(GUTTER as u16), + height: 1, + }; + input.render(area, f.buffer_mut(), style) +} + +/// `~` abbreviation plus leading-segment collapsing, matching the web UI's +/// `abbreviateCwd` + `abbreviateFolderName`: `/home/u/tea/Maestro` renders as +/// `/h/u/t/Maestro` given home `/home/u`. +pub(crate) fn abbreviate_path(path: &Path, home: &str) -> String { + let raw = path.to_string_lossy(); + let abbreviated = if !home.is_empty() { + if raw == home { + "~".to_string() + } else if let Some(rest) = raw.strip_prefix(home) { + if rest.starts_with('/') { + format!("~{rest}") + } else { + raw.into_owned() + } + } else { + raw.into_owned() + } + } else { + raw.into_owned() + }; + let parts: Vec<&str> = abbreviated.split('/').collect(); + if parts.len() <= 2 { + return abbreviated; + } + let last = parts[parts.len() - 1]; + let stem = parts[..parts.len() - 1] + .iter() + .map(|p| { + if *p == "~" { + "~".to_string() + } else { + p.chars().next().map(String::from).unwrap_or_default() + } + }) + .collect::>() + .join("/"); + format!("{stem}/{last}") +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeDelta; + 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 + } + + fn project(name: &str, path: &str, days_ago: i64, cmd: Option<&str>) -> Project { + Project { + path: PathBuf::from(path), + name: name.into(), + is_worktree: false, + base_repo: None, + last_activity: Some(Utc::now() - TimeDelta::days(days_ago)), + last_command: cmd.map(String::from), + } + } + + fn repo_project( + name: &str, + path: &str, + base: &str, + days_ago: i64, + cmd: Option<&str>, + ) -> Project { + Project { + base_repo: Some(PathBuf::from(base)), + ..project(name, path, days_ago, cmd) + } + } + + fn typed(draft: &mut DraftPane, s: &str) { + for c in s.chars() { + draft.on_input(&InputKey::char(c)); + } + } + + #[test] + fn defaults_fall_back_to_builtin_presets_with_custom_last() { + let draft = DraftPane::default(); + assert_eq!( + draft.presets.last().unwrap().id, + maestro_config::CUSTOM_PRESET_ID + ); + assert_eq!(draft.selected_preset, 0, "first non-custom tile starts"); + assert_eq!(draft.command(), "claude"); + assert!(!draft.is_custom()); + } + + #[test] + fn daemon_presets_replace_builtins_and_keep_the_custom_hatch() { + let mut draft = DraftPane::default(); + draft.set_presets(vec![SpawnPreset { + id: "aider".into(), + label: "Aider".into(), + command: "aider".into(), + }]); + assert_eq!( + draft.command(), + "aider", + "selection resets to first real preset" + ); + assert_eq!(draft.presets.last().unwrap().id, "custom"); + } + + #[test] + fn left_right_cycles_presets_wrapping_into_custom() { + let mut draft = DraftPane::default(); + draft.on_input(&InputKey::Right); + assert_eq!(draft.command(), "opencode"); + draft.on_input(&InputKey::Left); + assert_eq!(draft.command(), "claude"); + // Wrapping backwards off the first tile lands on the custom one. + draft.on_input(&InputKey::Left); + assert!(draft.is_custom(), "wraps backwards onto the custom tile"); + draft.on_input(&InputKey::Right); + assert_eq!(draft.command(), "claude"); + assert_eq!( + draft.focused_field, + DraftField::Preset, + "selection changed, focus stays" + ); + } + + #[test] + fn selecting_custom_reveals_a_free_text_command_field() { + let mut draft = DraftPane::default(); + draft.selected_preset = draft.presets.len() - 1; + assert!(draft.is_custom()); + assert_eq!( + draft.field_order()[..2], + [DraftField::Preset, DraftField::CustomCommand], + "custom command sits between presets and the dir picker" + ); + draft.next_field(); + assert_eq!(draft.focused_field, DraftField::CustomCommand); + typed(&mut draft, "bash -l"); + assert_eq!(draft.command(), "bash -l"); + // Tab walks past the hidden fields in order. + draft.next_field(); + assert_eq!(draft.focused_field, DraftField::DirPicker); + } + + #[test] + fn custom_command_flows_into_the_request() { + let mut draft = DraftPane::default(); + draft.picked_cwd = Some(PathBuf::from("/srv/work")); + draft.base_repo = Some(PathBuf::from("/srv/work")); + draft.probed_cwd = Some(PathBuf::from("/srv/work")); + draft.worktree_enabled = false; // under test: command + cwd only + draft.selected_preset = draft.presets.len() - 1; + draft.custom_command = crate::text_input::TextInput::with_value("bash -l"); + let req = draft.build_request().unwrap(); + assert_eq!(req.command, "bash -l"); + assert_eq!(req.cwd, PathBuf::from("/srv/work")); + } + + #[test] + fn projects_render_sorted_with_stale_dimmed_and_other_directory_last() { + let mut draft = DraftPane { + home_dir: "/home/u".into(), + ..DraftPane::default() + }; + draft.set_projects( + vec![ + project("stale-repo", "/home/u/old/repo", 30, Some("zsh")), + project("fresh-repo", "/home/u/new/repo", 1, Some("claude")), + ], + Utc::now(), + ); + let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true)); + assert!(rendered.contains("Other directory…"), "{rendered}"); + let fresh_pos = rendered.find("fresh-repo").unwrap(); + let stale_pos = rendered.find("stale-repo").unwrap(); + assert!(fresh_pos < stale_pos, "fresh before stale:\n{rendered}"); + assert!( + rendered.contains("~/n/repo"), + "collapsed path stem:\n{rendered}" + ); + assert!( + rendered.contains("claude"), + "last command shown:\n{rendered}" + ); + + // Staleness dims the row's cells. + let backend = TestBackend::new(100, 24); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| draft.render(f, f.area(), true)).unwrap(); + let buf = terminal.backend().buffer(); + let stale_cell = buf + .content() + .iter() + .find(|c| c.symbol() == "s" && c.modifier.contains(Modifier::DIM)); + assert!(stale_cell.is_some(), "stale row renders dimmed"); + } + + #[test] + fn typing_filters_and_enter_picks_a_project_as_the_working_directory() { + let mut draft = DraftPane::default(); + draft.set_projects( + vec![ + project("maestro", "/repos/maestro", 1, Some("claude")), + project("other", "/repos/other", 2, Some("zsh")), + ], + Utc::now(), + ); + draft.next_field(); // Preset → DirPicker + assert_eq!(draft.focused_field, DraftField::DirPicker); + typed(&mut draft, "maes"); + assert_eq!( + draft.visible_rows().len(), + 1, + "type-to-filter narrows the list" + ); + draft.on_input(&InputKey::Enter); + assert_eq!( + draft.picked_cwd.as_deref(), + Some(Path::new("/repos/maestro")) + ); + assert_eq!( + draft.command(), + "claude", + "pick pre-selects the last command" + ); + } + + #[test] + fn up_down_moves_the_highlight_within_the_list() { + let mut draft = DraftPane::default(); + draft.set_projects( + vec![project("a", "/a", 1, None), project("b", "/b", 2, None)], + Utc::now(), + ); + draft.focused_field = DraftField::DirPicker; + assert_eq!(draft.selection(), 0); + draft.on_input(&InputKey::Down); + draft.on_input(&InputKey::Down); + assert_eq!( + draft.selection(), + 2, + "the Other directory… entry is reachable" + ); + draft.on_input(&InputKey::Down); + assert_eq!(draft.selection(), 0, "wraps"); + draft.on_input(&InputKey::Up); + assert_eq!(draft.selection(), 2, "wraps backwards"); + } + + #[test] + fn other_directory_reveals_a_free_text_path_seeded_from_home() { + let mut draft = DraftPane { + home_dir: "/home/u".into(), + ..DraftPane::default() + }; + draft.set_projects(vec![project("a", "/a", 1, None)], Utc::now()); + draft.focused_field = DraftField::DirPicker; + draft.move_selection(1); // onto Other directory… + draft.on_input(&InputKey::Enter); + assert!(draft.cwd_raw_visible); + assert_eq!(draft.focused_field, DraftField::CwdRaw); + assert_eq!(draft.cwd_raw.value(), "/home/u"); + typed(&mut draft, "/opt/tooling"); + assert_eq!(draft.cwd(), Some(PathBuf::from("/home/u/opt/tooling"))); + // The raw target probed clean, so the worktree spec carries its repo. + let (seq, _) = draft.take_gitroot_probe().unwrap(); + draft.resolve_gitroot(seq, Some(PathBuf::from("/home/u/opt/tooling"))); + draft.seed_names(vec!["amber-falcon".into()]); + let req = draft.build_request().unwrap(); + assert_eq!(req.cwd, PathBuf::from("/home/u/opt/tooling")); + assert_eq!( + req.worktree.unwrap().base_repo, + PathBuf::from("/home/u/opt/tooling") + ); + } + + #[test] + fn picking_other_directory_after_a_pick_seeds_the_raw_field_with_the_pick() { + let mut draft = DraftPane { + home_dir: "/home/u".into(), + picked_cwd: Some(PathBuf::from("/repos/picked")), + ..DraftPane::default() + }; + draft.select_entry(); // highlight defaults past the (empty) list → Other… + assert!(draft.cwd_raw_visible); + assert_eq!(draft.cwd_raw.value(), "/repos/picked"); + } + + #[test] + fn generated_names_seed_the_field_and_metadata() { + let mut draft = DraftPane::default(); + assert!( + draft.name.value().is_empty(), + "no name until /api/names lands" + ); + draft.seed_names(vec!["crimson-otter".into(), "amber-falcon".into()]); + assert_eq!(draft.name.value(), "crimson-otter"); + + draft.picked_cwd = Some(PathBuf::from("/w")); + draft.base_repo = Some(PathBuf::from("/w")); + draft.probed_cwd = Some(PathBuf::from("/w")); + let req = draft.build_request().unwrap(); + assert_eq!( + req.metadata.get("name").and_then(|v| v.as_str()), + Some("crimson-otter"), + "name rides along as metadata.name" + ); + + draft.reroll(); + assert_eq!( + draft.name.value(), + "amber-falcon", + "reroll consumes the batch" + ); + draft.reroll(); + assert!( + draft.take_name_refill(), + "dry batch flags the caller to refetch" + ); + assert!(!draft.take_name_refill(), "flag consumes"); + } + + #[test] + fn refills_do_not_clobber_a_name_the_operator_typed() { + let mut draft = DraftPane::default(); + draft.seed_names(vec!["seeded".into()]); + typed_name(&mut draft, "-mine"); + assert_eq!(draft.name.value(), "seeded-mine"); + draft.seed_names(vec!["late-batch".into()]); + assert_eq!( + draft.name.value(), + "seeded-mine", + "late initial batches never overwrite an edited name" + ); + } + + fn typed_name(draft: &mut DraftPane, s: &str) { + draft.focused_field = DraftField::Name; + for c in s.chars() { + draft.on_input(&InputKey::char(c)); + } + } + + #[test] + fn an_empty_name_sends_no_metadata_at_all() { + let mut draft = DraftPane { + picked_cwd: Some(PathBuf::from("/w")), + ..DraftPane::default() + }; + draft.base_repo = Some(PathBuf::from("/w")); + draft.probed_cwd = Some(PathBuf::from("/w")); + // No names seeded: the empty new-branch field would refuse, so spawn + // in the checkout to isolate the metadata assertion. + draft.worktree_enabled = false; + let req = draft.build_request().unwrap(); + assert!(req.metadata.is_empty()); + } + + #[test] + fn validation_names_the_missing_field_instead_of_posting() { + let mut draft = DraftPane::default(); + assert_eq!( + draft.build_request(), + Err(DraftValidationError::MissingDirectory) + ); + assert_eq!( + DraftValidationError::MissingDirectory.to_string(), + "Pick a working directory." + ); + + draft.cwd_raw = crate::text_input::TextInput::with_value("/srv/work"); + draft.picked_cwd = Some(PathBuf::from("/srv/work")); + draft.selected_preset = draft.presets.len() - 1; // custom, empty text + assert_eq!( + draft.build_request(), + Err(DraftValidationError::MissingCommand) + ); + assert_eq!( + DraftValidationError::MissingCommand.to_string(), + "Pick a command (or fill the custom command)." + ); + } + + #[test] + fn render_shows_every_field_group_and_the_reroll_hint() { + let mut draft = DraftPane { + home_dir: "/home/u".into(), + ..DraftPane::default() + }; + draft.set_projects( + vec![project("maestro", "/home/u/tea/maestro", 1, Some("claude"))], + Utc::now(), + ); + draft.seed_names(vec!["crimson-otter".into()]); + let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true)); + for s in [ + "[Claude]", + "[opencode]", + "[Shell]", + "[Custom…]", + "dir:", + "name:", + "^R reroll", + "maestro", + "[ Spawn ]", + ] { + assert!(rendered.contains(s), "missing {s:?}:\n{rendered}"); + } + } + + #[test] + fn render_shows_the_error_line_when_set() { + let draft = DraftPane { + error: Some(DraftValidationError::MissingDirectory.to_string()), + ..DraftPane::default() + }; + let rendered = render_to_string(80, 24, |f| draft.render(f, f.area(), true)); + assert!( + rendered.contains("error: Pick a working directory."), + "{rendered}" + ); + } + + /// A short pane still shows the essential fields; the list window scrolls + /// with the selection instead of overflowing the border. + #[test] + fn render_survives_a_small_pane() { + let mut draft = DraftPane::default(); + draft.set_projects( + (0..10) + .map(|i| project(&format!("p{i}"), &format!("/p{i}"), i, None)) + .collect(), + Utc::now(), + ); + draft.focused_field = DraftField::DirPicker; + for _ in 0..8 { + draft.on_input(&InputKey::Down); + } + let rendered = render_to_string(80, 12, |f| draft.render(f, f.area(), true)); + assert!( + rendered.contains("[ Spawn ]"), + "submit still visible:\n{rendered}" + ); + assert!(rendered.contains('p'), "some projects visible:\n{rendered}"); + } + + /// The terminal cursor must sit right after the typed text in the focused + /// field (gutter is 5 chars, block border takes column 0). + #[test] + fn render_places_the_cursor_in_the_focused_text_field() { + let mut draft = DraftPane { + focused_field: DraftField::DirPicker, + ..DraftPane::default() + }; + typed(&mut draft, "ab"); + 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 + GUTTER as u16 + 2, 2) + ); + } + + #[test] + fn worktree_is_on_by_default_and_builds_a_create_branch_spec() { + let mut draft = DraftPane::default(); + assert!(draft.worktree_enabled, "on before any directory is chosen"); + draft.set_projects( + vec![repo_project( + "maestro", + "/repos/maestro", + "/repos/maestro", + 1, + None, + )], + Utc::now(), + ); + draft.focused_field = DraftField::DirPicker; + draft.on_input(&InputKey::Enter); // picks the repo row + assert_eq!( + draft.base_repo.as_deref(), + Some(Path::new("/repos/maestro")), + "a picked project resolves its base repo without a probe" + ); + assert!(draft.take_gitroot_probe().is_none()); + draft.seed_names(vec!["crimson-otter".into()]); + + let wt = draft.build_request().unwrap().worktree.unwrap(); + assert_eq!(wt.base_repo, PathBuf::from("/repos/maestro")); + assert_eq!(wt.branch, "crimson-otter", "branch defaults to the name"); + assert!(wt.create_branch); + } + + #[test] + fn raw_path_targets_probe_gitroot_and_resolution_feeds_the_spec() { + let mut draft = DraftPane { + focused_field: DraftField::DirPicker, + ..DraftPane::default() + }; + draft.on_input(&InputKey::Enter); // no projects → Other directory… + typed(&mut draft, "/opt/tool"); + + let (seq, path) = draft.take_gitroot_probe().expect("probe requested"); + assert_eq!(path, PathBuf::from("/opt/tool")); + assert!(draft.take_gitroot_probe().is_none(), "one probe at a time"); + + // Further typing supersedes the in-flight probe… + typed(&mut draft, "-x"); + let (seq2, _) = draft.take_gitroot_probe().unwrap(); + assert_ne!(seq2, seq); + // …so a late answer to the old one must be ignored. + draft.resolve_gitroot(seq, Some(PathBuf::from("/somewhere/else"))); + assert!(draft.base_repo.is_none()); + + draft.resolve_gitroot(seq2, Some(PathBuf::from("/opt/tool"))); + assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/opt/tool"))); + draft.seed_names(vec!["amber-falcon".into()]); + let req = draft.build_request().unwrap(); + assert_eq!(req.worktree.unwrap().base_repo, PathBuf::from("/opt/tool")); + } + + #[test] + fn a_failed_gitroot_probe_unticks_the_toggle_like_the_web_form() { + let mut draft = DraftPane { + focused_field: DraftField::DirPicker, + ..DraftPane::default() + }; + draft.on_input(&InputKey::Enter); + typed(&mut draft, "/not/a/repo"); + let (seq, _) = draft.take_gitroot_probe().unwrap(); + + draft.resolve_gitroot(seq, None); + assert!(!draft.worktree_enabled); + assert!( + !draft.build_request().unwrap().worktree.is_some(), + "spawns in the checkout itself" + ); + + // A stale success must not re-enable it. + draft.resolve_gitroot(seq, Some(PathBuf::from("/not/a/repo"))); + assert!(!draft.worktree_enabled); + } + + #[test] + fn the_new_branch_tracks_the_session_name_until_touched() { + let mut draft = DraftPane::default(); + draft.seed_names(vec!["crimson-otter".into(), "amber-falcon".into()]); + assert_eq!(draft.branch.value(), "crimson-otter"); + draft.reroll(); + assert_eq!(draft.branch.value(), "amber-falcon", "rerolls carry over"); + + draft.focused_field = DraftField::BranchNew; + typed(&mut draft, "-mine"); + assert_eq!(draft.branch.value(), "amber-falcon-mine"); + draft.seed_names(vec!["late-batch".into()]); + assert_eq!( + draft.branch.value(), + "amber-falcon-mine", + "an edited branch stops tracking the name" + ); + } + + #[test] + fn existing_mode_checks_out_a_picked_branch_without_creating_it() { + let mut draft = DraftPane::default(); + draft.set_projects( + vec![repo_project( + "maestro", + "/repos/maestro", + "/repos/maestro", + 1, + None, + )], + Utc::now(), + ); + draft.focused_field = DraftField::DirPicker; + draft.on_input(&InputKey::Enter); + draft.focused_field = DraftField::BranchMode; + draft.on_input(&InputKey::Right); + assert_eq!(draft.branch_mode, WorktreeBranchMode::Existing); + assert!( + draft.take_branch_load().is_some(), + "switching modes requests the branch list" + ); + + draft.set_branches( + Path::new("/repos/maestro"), + vec![ + BranchInfo { + name: "main".into(), + is_head: true, + worktree: None, + }, + BranchInfo { + name: "held".into(), + is_head: false, + worktree: Some(PathBuf::from("/repos/maestro-held")), + }, + BranchInfo { + name: "feature".into(), + is_head: false, + worktree: None, + }, + ], + ); + assert_eq!( + draft.branches[draft.selected_branch].name, "feature", + "selection skips `main`: the main checkout has it, so git would \ + refuse a second checkout" + ); + + draft.focused_field = DraftField::BranchExisting; + draft.on_input(&InputKey::Down); + assert_eq!( + draft.branches[draft.selected_branch].name, "feature", + "the only pickable branch stays selected" + ); + + let wt = draft.build_request().unwrap().worktree.unwrap(); + assert_eq!(wt.branch, "feature"); + assert!(!wt.create_branch, "existing picks are checked out as-is"); + } + + #[test] + fn held_branches_render_unavailable_with_their_holder_as_the_reason() { + let mut draft = DraftPane::default(); + draft.set_projects( + vec![repo_project( + "maestro", + "/repos/maestro", + "/repos/maestro", + 1, + None, + )], + Utc::now(), + ); + draft.picked_cwd = Some(PathBuf::from("/repos/maestro")); + draft.base_repo = Some(PathBuf::from("/repos/maestro")); + draft.probed_cwd = Some(PathBuf::from("/repos/maestro")); + draft.focused_field = DraftField::BranchMode; + draft.on_input(&InputKey::Right); + let _ = draft.take_branch_load(); + draft.set_branches( + Path::new("/repos/maestro"), + vec![ + BranchInfo { + name: "held".into(), + is_head: false, + worktree: Some(PathBuf::from("/home/u/wt")), + }, + BranchInfo { + name: "free".into(), + is_head: false, + worktree: None, + }, + ], + ); + + let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true)); + assert!( + rendered.contains("held by"), + "the holder path is the reason:\n{rendered}" + ); + assert!(rendered.contains("free"), "{rendered}"); + + // A held branch is never submittable, even highlighted directly. + draft.selected_branch = 0; + assert_eq!( + draft.build_request(), + Err(DraftValidationError::MissingExistingBranch) + ); + } + + #[test] + fn toggling_worktree_off_spawns_in_the_checkout_itself() { + let mut draft = DraftPane::default(); + draft.set_projects( + vec![repo_project( + "maestro", + "/repos/maestro", + "/repos/maestro", + 1, + None, + )], + Utc::now(), + ); + draft.picked_cwd = Some(PathBuf::from("/repos/maestro")); + draft.base_repo = Some(PathBuf::from("/repos/maestro")); + draft.probed_cwd = Some(PathBuf::from("/repos/maestro")); + + draft.focused_field = DraftField::WorktreeToggle; + draft.on_input(&InputKey::Enter); + assert!(!draft.worktree_enabled); + assert!(draft.build_request().unwrap().worktree.is_none()); + + draft.on_input(&InputKey::Enter); + assert!(draft.worktree_enabled); + assert!( + draft.take_gitroot_probe().is_none(), + "re-enabling reuses the known repo for an unchanged target" + ); + } + + #[test] + fn worktree_validation_names_the_problem() { + let mut draft = DraftPane { + cwd_raw: crate::text_input::TextInput::with_value("/opt/tool"), + picked_cwd: Some(PathBuf::from("/opt/tool")), + ..DraftPane::default() + }; + assert_eq!( + draft.build_request(), + Err(DraftValidationError::MissingWorktreeRepo) + ); + assert_eq!( + DraftValidationError::MissingWorktreeRepo.to_string(), + "Worktree needs a directory inside a git repository." + ); + + draft.base_repo = Some(PathBuf::from("/opt/tool")); + assert_eq!( + draft.build_request(), + Err(DraftValidationError::MissingNewBranch), + "no names seeded yet, so the new-branch field is empty" + ); + assert_eq!( + DraftValidationError::MissingNewBranch.to_string(), + "Worktree requires a branch name." + ); + + draft.branch_mode = WorktreeBranchMode::Existing; + assert_eq!( + draft.build_request(), + Err(DraftValidationError::MissingExistingBranch) + ); + assert_eq!( + DraftValidationError::MissingExistingBranch.to_string(), + "Pick an existing branch." + ); + } + + #[test] + fn render_shows_the_worktree_section() { + let mut draft = DraftPane { + home_dir: "/home/u".into(), + ..DraftPane::default() + }; + draft.set_projects( + vec![project("maestro", "/home/u/tea/maestro", 1, Some("claude"))], + Utc::now(), + ); + draft.seed_names(vec!["crimson-otter".into()]); + let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true)); + for s in ["wt:", "[x] worktree"] { + assert!(rendered.contains(s), "missing {s:?}:\n{rendered}"); + } + assert!( + rendered.contains("choose a directory first"), + "hint while no target:\n{rendered}" + ); + assert!( + !rendered.contains("[new] [existing]"), + "body hidden until the repo is known:\n{rendered}" + ); + + draft.picked_cwd = Some(PathBuf::from("/home/u/tea/maestro")); + draft.base_repo = Some(PathBuf::from("/home/u/tea/maestro")); + draft.probed_cwd = Some(PathBuf::from("/home/u/tea/maestro")); + let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true)); + for s in ["[new] [existing]", "br:", "crimson-otter"] { + assert!(rendered.contains(s), "missing {s:?}:\n{rendered}"); + } + + draft.focused_field = DraftField::WorktreeToggle; + draft.on_input(&InputKey::Enter); + let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true)); + assert!(rendered.contains("[ ] worktree"), "{rendered}"); + assert!( + !rendered.contains("[new] [existing]"), + "body hidden while off:\n{rendered}" + ); + } + + #[test] + fn abbreviate_path_matches_the_web_helpers() { + assert_eq!( + abbreviate_path(Path::new("/home/u/tea/Maestro"), "/home/u"), + "~/t/Maestro" + ); + assert_eq!(abbreviate_path(Path::new("/home/u"), "/home/u"), "~"); + assert_eq!( + abbreviate_path(Path::new("/opt/x"), "/home/u"), + "/o/x", + "three-or-more segments collapse even outside home (web parity)" + ); + assert_eq!( + abbreviate_path(Path::new("/home/u"), ""), + "/h/u", + "segments collapse even when home is unknown (web parity)" + ); + } +} diff --git a/crates/maestro-tui/src/fast_forward.rs b/crates/maestro-tui/src/fast_forward.rs new file mode 100644 index 0000000..daa879a --- /dev/null +++ b/crates/maestro-tui/src/fast_forward.rs @@ -0,0 +1,282 @@ +//! Fast-forward spawn (#67): one keystroke, every default resolved, no form. +//! +//! The defaults mirror what a draft pane would settle on after the operator +//! picked the most recent project and kept their hands off everything else: +//! its last command (or the first non-custom preset), a freshly generated +//! name, and a worktree with a branch named after that name. Any default +//! that cannot be resolved — no projects yet, no command, no generated +//! name — downgrades the keystroke to a draft pane pre-filled with whatever +//! did resolve ([`FastForwardDefaults::apply_to_draft`]). + +use std::path::PathBuf; + +use chrono::{DateTime, Utc}; +use maestro_protocol::{CreateSessionRequest, Project, SpawnPreset, WorktreeSpec}; + +use crate::draft::DraftPane; + +/// What one fast-forward keystroke resolved. Every field optional: `None` +/// anywhere means "open a draft instead of spawning". +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FastForwardDefaults { + pub project: Option, + pub command: Option, + pub name: Option, +} + +/// Resolve the fast-forward defaults from the daemon's answers. `name` is +/// `None` when `/api/names` yielded nothing; `presets` may be empty (the +/// caller falls back to the built-ins it already has). +pub fn resolve( + projects: &[Project], + presets: &[SpawnPreset], + name: Option, + now: DateTime, +) -> FastForwardDefaults { + let project = crate::projects::sort_projects(projects.to_vec(), now) + .into_iter() + .next(); + let command = project + .as_ref() + .and_then(|p| p.last_command.clone()) + .filter(|c| !c.trim().is_empty()) + .or_else(|| { + presets + .iter() + .find(|p| !p.command.is_empty()) + .map(|p| p.command.clone()) + }); + FastForwardDefaults { + project, + command, + name, + } +} + +impl FastForwardDefaults { + /// The spawn request when *every* default resolved: worktree on, new + /// branch named after the generated session name, base repo from the + /// scan (`None` means the project is itself a main checkout). + pub fn build_request(&self) -> Option { + let project = self.project.as_ref()?; + let command = self.command.as_deref()?.trim(); + let name = self.name.as_deref()?; + if command.is_empty() || name.is_empty() { + return None; + } + let mut metadata = std::collections::HashMap::new(); + metadata.insert( + "name".to_string(), + serde_json::Value::String(name.to_string()), + ); + Some(CreateSessionRequest { + cwd: project.path.clone(), + command: command.to_string(), + env: Default::default(), + metadata, + worktree: Some(WorktreeSpec { + base_repo: project + .base_repo + .clone() + .unwrap_or_else(|| project.path.clone()), + branch: name.to_string(), + create_branch: true, + }), + }) + } + + /// Seed a fresh draft pane with whatever did resolve — the fallback when + /// [`Self::build_request`] is `None`. The operator fills in the rest. + pub fn apply_to_draft(&self, draft: &mut DraftPane) { + if let Some(project) = &self.project { + draft.picked_cwd = Some(PathBuf::from(&project.path)); + // The scan already knows this directory's repo (#61): no probe + // round trip needed, exactly like picking the row by hand. + draft.base_repo = Some( + project + .base_repo + .clone() + .unwrap_or_else(|| project.path.clone()), + ); + draft.probed_cwd = Some(PathBuf::from(&project.path)); + } + if let Some(command) = &self.command { + if let Some(idx) = draft.presets.iter().position(|p| &p.command == command) { + draft.selected_preset = idx; + } else if !command.trim().is_empty() { + // A last_command no preset advertises lands in custom free text. + draft.selected_preset = draft.presets.len().saturating_sub(1); + draft.custom_command = crate::text_input::TextInput::with_value(command); + } + } + if let Some(name) = &self.name { + draft.set_name(name.clone()); + // Seeded, not generated: a later refill must not clobber it. + draft.name_touched = true; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeDelta; + + fn project(path: &str, days_ago: i64, cmd: Option<&str>) -> Project { + Project { + path: PathBuf::from(path), + name: path.rsplit('/').next().unwrap_or(path).into(), + is_worktree: false, + base_repo: None, + last_activity: Some(Utc::now() - TimeDelta::days(days_ago)), + last_command: cmd.map(String::from), + } + } + + fn repo_project(path: &str, base: &str, days_ago: i64, cmd: Option<&str>) -> Project { + Project { + base_repo: Some(PathBuf::from(base)), + ..project(path, days_ago, cmd) + } + } + + fn preset(id: &str, command: &str) -> SpawnPreset { + SpawnPreset { + id: id.into(), + label: id.into(), + command: command.into(), + } + } + + #[test] + fn resolves_every_default_from_the_most_recent_project() { + let projects = vec![ + project("/repos/old", 30, Some("zsh")), + repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")), + ]; + let presets = vec![preset("aider", "aider"), preset("custom", "")]; + let r = resolve(&projects, &presets, Some("amber-falcon".into()), Utc::now()); + assert_eq!( + r.project.as_ref().unwrap().path, + PathBuf::from("/repos/fresh") + ); + assert_eq!(r.command.as_deref(), Some("claude")); + assert_eq!(r.name.as_deref(), Some("amber-falcon")); + + let req = r.build_request().expect("all defaults resolved"); + assert_eq!(req.cwd, PathBuf::from("/repos/fresh")); + assert_eq!(req.command, "claude"); + assert_eq!( + req.metadata.get("name").and_then(|v| v.as_str()), + Some("amber-falcon") + ); + let wt = req.worktree.expect("worktree on"); + assert_eq!(wt.base_repo, PathBuf::from("/repos/fresh")); + assert_eq!(wt.branch, "amber-falcon"); + assert!(wt.create_branch, "fresh branch named after the session"); + } + + #[test] + fn worktree_of_the_most_recent_project_targets_its_base_repo() { + let projects = vec![repo_project("/repos/main/wt-x", "/repos/main", 0, None)]; + let r = resolve( + &projects, + &[preset("zsh", "zsh")], + Some("s".into()), + Utc::now(), + ); + let wt = r.build_request().unwrap().worktree.unwrap(); + assert_eq!(wt.base_repo, PathBuf::from("/repos/main")); + } + + #[test] + fn missing_last_command_falls_back_to_the_first_non_custom_preset() { + let projects = vec![project("/repos/p", 0, None)]; + let presets = vec![preset("custom", ""), preset("claude", "claude")]; + let r = resolve(&projects, &presets, Some("n".into()), Utc::now()); + assert_eq!(r.command.as_deref(), Some("claude")); + } + + #[test] + fn an_empty_last_command_is_no_command_at_all() { + let projects = vec![project("/repos/p", 0, Some(""))]; + let presets = vec![preset("custom", "")]; + let r = resolve(&projects, &presets, Some("n".into()), Utc::now()); + assert_eq!(r.command, None); + assert!(r.build_request().is_none()); + } + + #[test] + fn nothing_resolves_when_no_projects_are_known_yet() { + let r = resolve( + &[], + &[preset("claude", "claude")], + Some("n".into()), + Utc::now(), + ); + assert_eq!(r.project, None); + // No project → no directory → no request, even with command + name. + assert!(r.build_request().is_none()); + } + + #[test] + fn a_missing_generated_name_downgrades_to_a_draft() { + let projects = vec![project("/repos/p", 0, Some("claude"))]; + let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now()); + assert!(r.build_request().is_none(), "no name → no immediate spawn"); + } + + #[test] + fn unresolvable_defaults_still_seed_the_draft_with_what_resolved() { + let projects = vec![repo_project("/repos/p", "/repos/p", 0, Some("aider"))]; + let r = resolve(&projects, &[preset("aider", "aider")], None, Utc::now()); + + let mut draft = DraftPane::default(); + r.apply_to_draft(&mut draft); + assert_eq!(draft.cwd(), Some(PathBuf::from("/repos/p"))); + assert_eq!(draft.base_repo, Some(PathBuf::from("/repos/p"))); + assert_eq!(draft.command(), "aider"); + assert!( + draft.name.value().is_empty(), + "nothing to seed the name with" + ); + assert!(draft.worktree_enabled); + } + + #[test] + fn a_resolved_name_survives_late_refills_and_tracks_the_branch() { + let projects = vec![project("/repos/p", 0, Some("claude"))]; + let named = resolve( + &projects, + &[preset("claude", "claude")], + Some("amber-falcon".into()), + Utc::now(), + ); + let mut draft = DraftPane::default(); + named.apply_to_draft(&mut draft); + // A later batch must not overwrite the name the spawn was seeded with. + draft.seed_names(vec!["late-name".into()]); + assert_eq!(draft.name.value(), "amber-falcon"); + assert_eq!(draft.branch.value(), "amber-falcon"); + } + + #[test] + fn an_unresolved_name_still_gets_generated_by_a_later_batch() { + let projects = vec![project("/repos/p", 0, Some("claude"))]; + let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now()); + let mut draft = DraftPane::default(); + r.apply_to_draft(&mut draft); + draft.seed_names(vec!["late-name".into()]); + assert_eq!(draft.name.value(), "late-name"); + } + + #[test] + fn a_last_command_no_preset_advertises_lands_in_custom_text() { + let projects = vec![project("/repos/p", 0, Some("nvim -d"))]; + let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now()); + let mut draft = DraftPane::default(); + r.apply_to_draft(&mut draft); + assert_eq!(draft.command(), "nvim -d"); + assert!(draft.is_custom()); + } +} diff --git a/crates/maestro-tui/src/input.rs b/crates/maestro-tui/src/input.rs index 4a2736d..34c4d4a 100644 --- a/crates/maestro-tui/src/input.rs +++ b/crates/maestro-tui/src/input.rs @@ -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)"; diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index bc9ed0c..c7cfc56 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -7,17 +7,22 @@ pub mod app; pub mod catalog; pub mod cli; pub mod config; +pub mod draft; pub mod emulators; +pub mod fast_forward; pub mod input; pub mod keymap; pub mod overlays; pub mod pane; +pub mod projects; pub mod render; pub mod session_task; pub mod sidebar; pub mod state; pub mod tabs; pub mod terminal; +pub mod text_input; +pub mod worktrees; use std::path::PathBuf; use std::time::Duration; diff --git a/crates/maestro-tui/src/overlays.rs b/crates/maestro-tui/src/overlays.rs index 5cc5722..8ac67ca 100644 --- a/crates/maestro-tui/src/overlays.rs +++ b/crates/maestro-tui/src/overlays.rs @@ -1,183 +1,41 @@ -//! 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, + /// #83: what the daemon's watcher last saw in the worktree, phrased by + /// [`maestro_protocol::InTheWay`]'s Display — so "this will be refused" + /// is visible before the attempt. `None` while nothing blocks. + pub blockers: Option, } impl KillConfirm { pub fn render(&self, f: &mut Frame, parent: Rect) { - let area = centered(parent, 50, 5); + let area = centered(parent, 60, if self.blockers.is_some() { 6 } else { 5 }); f.render_widget(Clear, area); let block = Block::default().title(" kill ").borders(Borders::ALL); let inner = block.inner(area); f.render_widget(block, area); - f.render_widget( - Paragraph::new(vec![ - Line::from(format!("Kill session {}?", self.label)), - Line::from(""), - Line::from("[y] yes [n] no"), - ]), - inner, - ); + let mut lines = vec![Line::from(format!("Kill session {}?", self.label))]; + if let Some(blockers) = &self.blockers { + // Same phrasing the reclaim confirmation uses for its own guard. + lines.push(Line::from(format!("Currently in the way: {blockers}"))); + lines.push(Line::from("The kill will be refused unless forced.")); + } + lines.push(Line::from("")); + lines.push(Line::from("[y] yes [n] no")); + f.render_widget(Paragraph::new(lines), inner); } } @@ -229,7 +87,7 @@ impl Toast { } } -fn centered(parent: Rect, w: u16, h: u16) -> Rect { +pub(crate) fn centered(parent: Rect, w: u16, h: u16) -> Rect { let w = w.min(parent.width); let h = h.min(parent.height); Rect { @@ -264,37 +122,11 @@ 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 { label: "session-7".into(), + blockers: None, }; let rendered = render_to_string(60, 10, |f| modal.render(f, f.area())); assert!(rendered.contains("Kill")); @@ -303,39 +135,32 @@ mod tests { assert!(rendered.contains("no")); } + /// #83: when the worktree blocks, the confirm names it before the + /// operator commits — in `InTheWay`'s own words. + #[test] + fn kill_confirm_names_what_blocks() { + let modal = KillConfirm { + label: "session-7".into(), + blockers: Some("2 uncommitted files".into()), + }; + let rendered = render_to_string(70, 10, |f| modal.render(f, f.area())); + assert!( + rendered.contains("Currently in the way: 2 uncommitted files"), + "{rendered}" + ); + assert!(rendered.contains("refused"), "{rendered}"); + } + #[test] fn help_overlay_lists_lines() { 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")); } } diff --git a/crates/maestro-tui/src/projects.rs b/crates/maestro-tui/src/projects.rs new file mode 100644 index 0000000..79d5e1a --- /dev/null +++ b/crates/maestro-tui/src/projects.rs @@ -0,0 +1,216 @@ +//! Pure helpers behind the draft pane's project picker (#64): staleness, +//! recency ordering, worktree grouping, and free-text filtering. Mirrors +//! `web/src/projectList.ts` so both clients order and dim identically — the +//! daemon reports the timestamps; what "stale" means is a client decision +//! (#54). + +use chrono::{DateTime, TimeDelta, Utc}; +use maestro_protocol::Project; + +/// Projects whose last activity is older than this are dimmed and sort after +/// fresh ones — still reachable, visibly stale. +pub const STALE_AFTER_DAYS: i64 = 7; + +/// True when a project has no usable activity timestamp, one in the future, +/// or its last activity is older than [`STALE_AFTER_DAYS`] days. +pub fn is_stale(project: &Project, now: DateTime) -> bool { + match project.last_activity { + None => true, + Some(t) => { + let age = now.signed_duration_since(t); + age < TimeDelta::zero() || age > TimeDelta::days(STALE_AFTER_DAYS) + } + } +} + +fn time_of(project: &Project) -> DateTime { + project.last_activity.unwrap_or(DateTime::UNIX_EPOCH) +} + +/// Fresh projects first, each block newest-activity-first; unknown or future +/// timestamps sort as stalest. Ties fall back to name, then path, so the +/// order is stable. +pub fn sort_projects(mut projects: Vec, now: DateTime) -> Vec { + projects.sort_by(|a, b| { + let stale = is_stale(a, now).cmp(&is_stale(b, now)); + stale + .then_with(|| time_of(b).cmp(&time_of(a))) + .then_with(|| a.name.cmp(&b.name)) + .then_with(|| a.path.cmp(&b.path)) + }); + projects +} + +/// Substring filter over project name and full path, case-insensitive. +pub fn matches_filter(project: &Project, query: &str) -> bool { + let q = query.trim().to_lowercase(); + if q.is_empty() { + return true; + } + project.name.to_lowercase().contains(&q) + || project.path.to_string_lossy().to_lowercase().contains(&q) +} + +/// One repository group: the main checkout followed by its linked worktrees. +/// A group's position follows its freshest member, since a worktree worked on +/// today should pull its (dormant) base repo up with it. +#[derive(Debug)] +pub struct ProjectGroup<'a> { + pub main: &'a Project, + pub worktrees: Vec<&'a Project>, +} + +/// Group `sorted` projects by their base repository. A worktree nests under +/// its main checkout even when the main sorts later; a main checkout missing +/// from the list doesn't drop its worktrees. +pub fn group_projects(sorted: &[Project]) -> Vec> { + let mut members: Vec<(String, Vec<&Project>)> = Vec::new(); + for p in sorted { + let key = p + .base_repo + .as_ref() + .unwrap_or(&p.path) + .to_string_lossy() + .into_owned(); + match members.iter_mut().find(|(k, _)| *k == key) { + Some((_, list)) => list.push(p), + None => members.push((key, vec![p])), + } + } + members + .into_iter() + .map(|(key, mut list)| { + let main_pos = list + .iter() + .position(|p| p.path.to_string_lossy() == key && !p.is_worktree) + .unwrap_or(0); + let main = list.remove(main_pos); + // Keep the daemon's newest-first order within the group. + list.sort_by_key(|p| std::cmp::Reverse(time_of(p))); + ProjectGroup { + main, + worktrees: list, + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn days_ago(days: i64) -> DateTime { + Utc::now() - TimeDelta::days(days) + } + + fn project(name: &str, path: &str, last_activity: Option>) -> Project { + Project { + path: PathBuf::from(path), + name: name.into(), + is_worktree: false, + base_repo: None, + last_activity, + last_command: None, + } + } + + fn worktree( + name: &str, + path: &str, + base: &str, + last_activity: Option>, + ) -> Project { + Project { + is_worktree: true, + base_repo: Some(PathBuf::from(base)), + ..project(name, path, last_activity) + } + } + + #[test] + fn unknown_and_future_activity_are_stale() { + let now = Utc::now(); + assert!(is_stale(&project("x", "/x", None), now)); + assert!(is_stale( + &project("x", "/x", Some(now + TimeDelta::hours(1))), + now + )); + } + + #[test] + fn activity_within_a_week_is_fresh_beyond_is_stale() { + let now = Utc::now(); + assert!(!is_stale(&project("x", "/x", Some(days_ago(6))), now)); + assert!(is_stale(&project("x", "/x", Some(days_ago(8))), now)); + } + + #[test] + fn fresh_sorts_before_stale_then_newest_first() { + let now = Utc::now(); + let sorted = sort_projects( + vec![ + project("old-fresh", "/a", Some(days_ago(5))), + project("stale", "/b", Some(days_ago(30))), + project("newest", "/c", Some(days_ago(1))), + project("unknown", "/d", None), + ], + now, + ); + let names: Vec<&str> = sorted.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["newest", "old-fresh", "stale", "unknown"]); + } + + #[test] + fn ties_break_by_name_then_path() { + let now = Utc::now(); + let t = Some(days_ago(2)); + let sorted = sort_projects(vec![project("b", "/b", t), project("a", "/a", t)], now); + let names: Vec<&str> = sorted.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["a", "b"]); + } + + #[test] + fn filter_matches_name_or_path_case_insensitively() { + let p = project("Maestro", "/home/u/tea/Maestro", None); + assert!(matches_filter(&p, "maes")); + assert!(matches_filter(&p, " tea ")); + assert!(!matches_filter(&p, "zzz")); + assert!(matches_filter(&p, ""), "empty query matches everything"); + } + + #[test] + fn worktrees_nest_under_their_main_checkout() { + let now = Utc::now(); + let main = project("maestro", "/repos/maestro", Some(days_ago(10))); + let wt = worktree("64-tui", "/repos/wt64", "/repos/maestro", Some(days_ago(1))); + let other = project("other", "/repos/other", Some(days_ago(2))); + let sorted = sort_projects(vec![main, wt, other], now); + let groups = group_projects(&sorted); + assert_eq!(groups.len(), 2); + assert_eq!( + groups[0].main.name, "maestro", + "fresh worktree pulls its repo up" + ); + assert_eq!(groups[0].worktrees.len(), 1); + assert_eq!(groups[0].worktrees[0].name, "64-tui"); + assert_eq!(groups[1].main.name, "other"); + } + + /// A main checkout absent from the scan must not drop its worktrees. + #[test] + fn worktrees_survive_a_missing_main_checkout() { + let now = Utc::now(); + let wt = worktree( + "solo-wt", + "/repos/solo-wt", + "/repos/solo", + Some(days_ago(1)), + ); + let sorted = sort_projects(vec![wt], now); + let groups = group_projects(&sorted); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].main.name, "solo-wt"); + assert!(groups[0].worktrees.is_empty()); + } +} diff --git a/crates/maestro-tui/src/render.rs b/crates/maestro-tui/src/render.rs index 0bb2c75..12673cb 100644 --- a/crates/maestro-tui/src/render.rs +++ b/crates/maestro-tui/src/render.rs @@ -10,11 +10,12 @@ 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}; use crate::tabs::TabBar; +use crate::worktrees::{ReclaimConfirm, WorktreesView}; /// Tagged regions for mouse hit-testing. The input router resolves a mouse /// coordinate to one of these via [`RegionMap::region_at`]. @@ -63,9 +64,12 @@ 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, + /// Retained-worktree entries for the worktrees view (#85). + pub worktrees: Option<&'a WorktreesView>, } /// Draw one frame. Returns the `RegionMap` the input router will consult for @@ -131,6 +135,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,13 +145,20 @@ 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)) => { + // #83: surface what the watcher last saw in the worktree, in + // InTheWay's own words, before the operator commits to the kill. + let blockers = inputs + .sidebar + .sessions + .iter() + .find(|s| &s.id == id) + .and_then(|s| s.blocked_work) + .filter(|b| !b.is_clear()) + .map(|b| b.to_string()); let kc = KillConfirm { label: id.to_string(), + blockers, }; kc.render(f, area); map.modal = Some(area); @@ -153,6 +167,22 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { inputs.help.render(f, area); map.modal = Some(area); } + Some(OverlayKind::Worktrees) => { + if let Some(view) = inputs.worktrees { + view.render(f, area); + map.modal = Some(area); + } + } + Some(OverlayKind::ReclaimConfirm { + branch, blockers, .. + }) => { + let rc = ReclaimConfirm { + branch: branch.clone(), + blockers: blockers.clone(), + }; + rc.render(f, area); + map.modal = Some(area); + } Some(OverlayKind::Toast(text)) => { let t = Toast { text: text.clone() }; t.render(f, area); @@ -186,7 +216,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 +296,65 @@ 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, + worktrees: None, + }; + + let backend = ratatui::backend::TestBackend::new(80, 24); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + draw(f, inputs); + }) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut out = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + for s in [ + "draft", + "cmd:", + "dir:", + "name:", + "[ Spawn ]", + "Other directory…", + ] { + assert!(out.contains(s), "missing {s:?}:\n{out}"); + } + } } diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index d17c12e..724eff0 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -52,6 +52,13 @@ impl Sidebar { (SessionStatus::Running, false) => "○", }; let label = name_for(s); + // #83: a session whose worktree blocks a kill gets a warning + // glyph; the kill confirm names the specifics. + let blocked = s + .blocked_work + .filter(|b| !b.is_clear()) + .map(|_| " ⚠") + .unwrap_or_default(); let style = if self.highlight == Some(s.id) { Style::default().add_modifier(Modifier::REVERSED) } else { @@ -59,7 +66,7 @@ impl Sidebar { }; lines.push(Line::from(vec![ Span::raw(" "), - Span::styled(format!("{glyph} {label}"), style), + Span::styled(format!("{glyph} {label}{blocked}"), style), ])); } f.render_widget(Paragraph::new(lines), inner); @@ -92,12 +99,14 @@ mod tests { cwd: std::path::PathBuf::from("/tmp"), env: Default::default(), metadata, + worktree: None, status: SessionStatus::Running, created_at: Utc::now(), foreground: None, activity: Default::default(), last_activity_at: chrono::Utc::now(), current_cwd: None, + blocked_work: None, } } @@ -171,6 +180,40 @@ mod tests { assert!(line.contains('✗'), "expected dead glyph in: {line:?}"); } + /// #83: a session whose worktree blocks a kill carries the warning glyph; + /// a clear or unprobed one does not. + #[test] + fn blocked_session_gets_a_warning_glyph() { + use maestro_protocol::InTheWay; + + let mut blocked = session_named(1, "claude"); + blocked.blocked_work = Some(InTheWay { + unpushed_commits: 4, + ..InTheWay::default() + }); + let mut clear = session_named(2, "bash"); + clear.blocked_work = Some(InTheWay::default()); + let unprobed = session_named(3, "vim"); + let sb = Sidebar { + sessions: vec![blocked, clear, unprobed], + highlight: None, + attached: Default::default(), + }; + let rendered = render_to_string(&sb, 40, 8); + let line = rendered + .lines() + .find(|l| l.contains("claude")) + .expect("blocked row"); + assert!(line.contains('⚠'), "expected warning glyph in: {line:?}"); + for name in ["bash", "vim"] { + let line = rendered + .lines() + .find(|l| l.contains(name)) + .expect("clear row"); + assert!(!line.contains('⚠'), "{name} must not warn: {line:?}"); + } + } + #[test] fn falls_back_to_command_when_name_metadata_is_absent() { let s = Session { @@ -179,6 +222,7 @@ mod tests { cwd: std::path::PathBuf::from("/tmp"), env: Default::default(), metadata: HashMap::new(), + worktree: None, status: SessionStatus::Running, created_at: Utc::now(), foreground: None, @@ -186,6 +230,7 @@ mod tests { last_activity_at: chrono::Utc::now(), current_cwd: None, + blocked_work: None, }; let sb = Sidebar { sessions: vec![s], diff --git a/crates/maestro-tui/src/state.rs b/crates/maestro-tui/src/state.rs index 4e9e32f..4e487c8 100644 --- a/crates/maestro-tui/src/state.rs +++ b/crates/maestro-tui/src/state.rs @@ -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` 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,18 +19,48 @@ pub enum Focus { Pane, } +/// What a tab's pane slot holds: a live session, or an inline draft form. +// The draft variant dwarfs `Session(Uuid)`; tabs are moved rarely enough +// that boxing every access costs more than the copy does. +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Clone, PartialEq)] +pub enum PaneRef { + Session(Uuid), + Draft(DraftPane), +} + #[derive(Debug, Clone)] pub struct Tab { pub name: String, - pub pane: Option, + pub pane: Option, +} + +impl Tab { + /// The session this tab shows, if it shows one. Draft panes have none. + pub fn session(&self) -> Option { + match &self.pane { + Some(PaneRef::Session(sid)) => Some(*sid), + _ => None, + } + } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum OverlayKind { - Spawn, KillConfirm(Uuid), Help, Toast(String), + /// The retained-worktrees view (#85); its entries live on `App` because + /// they are daemon data, not UI state. + Worktrees, + /// Reclaim confirmation for the worktree with this id (#85), guarding + /// the destructive key like [`OverlayKind::KillConfirm`] does. + ReclaimConfirm { + id: Uuid, + branch: String, + /// [`maestro_protocol::InTheWay`] as of when the confirm was opened. + blockers: String, + }, } #[derive(Debug)] @@ -108,7 +140,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 { if self.tabs.len() <= 1 { return None; @@ -117,9 +150,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 +181,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 { + 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 +348,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 +374,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 +384,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 +394,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 +407,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); + } } diff --git a/crates/maestro-tui/src/terminal.rs b/crates/maestro-tui/src/terminal.rs index eeb5fb5..e21df3f 100644 --- a/crates/maestro-tui/src/terminal.rs +++ b/crates/maestro-tui/src/terminal.rs @@ -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 { 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(); } } diff --git a/crates/maestro-tui/src/text_input.rs b/crates/maestro-tui/src/text_input.rs new file mode 100644 index 0000000..c3e1b44 --- /dev/null +++ b/crates/maestro-tui/src/text_input.rs @@ -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, +} + +impl TextInput { + pub fn with_value(value: impl Into) -> 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 = 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 = 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 = 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 { + 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); + } +} diff --git a/crates/maestro-tui/src/worktrees.rs b/crates/maestro-tui/src/worktrees.rs new file mode 100644 index 0000000..fbb640d --- /dev/null +++ b/crates/maestro-tui/src/worktrees.rs @@ -0,0 +1,259 @@ +//! Retained-worktrees view (#85): an overlay listing every retained worktree +//! — branch, abbreviated path, reason, what is in the way — with j/k to move, +//! `r` to reclaim behind a confirmation, esc to close. +//! +//! The in-the-way summary comes straight from [`InTheWay`]'s `Display` impl, +//! the same source the kill refusal and the web client use, so no two clients +//! can describe the same tree differently. + +use ratatui::layout::Rect; +use ratatui::text::Line; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; +use ratatui::Frame; + +use maestro_protocol::{RetainedReason, RetainedWorktree}; + +use crate::draft::abbreviate_path; + +/// The open worktrees overlay: its entries and which one is highlighted. +#[derive(Debug, Clone, Default)] +pub struct WorktreesView { + pub items: Vec, + pub highlight: usize, + /// Daemon-reported home dir, for `~` abbreviation of paths. + pub home_dir: String, +} + +impl WorktreesView { + /// Replace the listing and keep the highlight pointing at something real. + pub fn apply(&mut self, items: Vec) { + self.items = items; + if !self.items.is_empty() { + self.highlight = self.highlight.min(self.items.len() - 1); + } else { + self.highlight = 0; + } + } + + pub fn remove(&mut self, id: uuid::Uuid) { + let idx = self.items.iter().position(|w| w.id == id); + self.items.retain(|w| w.id != id); + if let Some(i) = idx { + self.highlight = self.highlight.min(self.items.len().saturating_sub(1)); + let _ = i; + } + } + + /// Move the highlight by `delta` (positive = down). Wraps. No-op on empty. + pub fn move_highlight(&mut self, delta: i32) { + if self.items.is_empty() { + return; + } + let len = self.items.len() as i32; + let next = (self.highlight as i32 + delta).rem_euclid(len); + self.highlight = next as usize; + } + + pub fn selected(&self) -> Option<&RetainedWorktree> { + self.items.get(self.highlight) + } + + pub fn render(&self, f: &mut Frame, parent: Rect) { + let h = (self.items.len() as u16 + 6).clamp(4, parent.height); + let area = super::overlays::centered(parent, parent.width.saturating_sub(4), h); + f.render_widget(Clear, area); + let block = Block::default() + .title(" retained worktrees ") + .borders(Borders::ALL); + let inner = block.inner(area); + f.render_widget(block, area); + + let mut lines: Vec = Vec::new(); + if self.items.is_empty() { + // A drained store is the normal state — say so plainly, not like + // something went wrong. + lines.push(Line::from("no retained worktrees")); + } else { + lines.push(Line::from(format!( + " {:<26} {:<34} {:<24} IN THE WAY", + "BRANCH", "PATH", "REASON" + ))); + for (i, w) in self.items.iter().enumerate() { + let marker = if i == self.highlight { ">" } else { " " }; + lines.push(Line::from(format!( + "{marker} {:<25} {:<34} {:<24} {}", + w.branch, + abbreviate_path(&w.path, &self.home_dir), + reason_label(w.reason), + w.in_the_way, + ))); + } + } + lines.push(Line::from("")); + lines.push(Line::from("j/k: select r: reclaim esc: close")); + f.render_widget(Paragraph::new(lines), inner); + } +} + +/// The confirmation guarding reclaim, mirroring [`super::overlays::KillConfirm`]. +/// It names the branch and says out loud what reclaim does not take with it — +/// docs/WORKTREES.md: "reclaim" otherwise sounds like it takes the commits too. +#[derive(Debug, Clone)] +pub struct ReclaimConfirm { + pub branch: String, + /// [`maestro_protocol::InTheWay`] rendered via its `Display` impl. + pub blockers: String, +} + +impl ReclaimConfirm { + pub fn render(&self, f: &mut Frame, parent: Rect) { + let area = super::overlays::centered(parent, 70, 7); + f.render_widget(Clear, area); + let block = Block::default().title(" reclaim ").borders(Borders::ALL); + let inner = block.inner(area); + f.render_widget(block, area); + f.render_widget( + ratatui::widgets::Paragraph::new(vec![ + Line::from(format!("Reclaim worktree {}?", self.branch)), + Line::from(format!("Currently in the way: {}", self.blockers)), + Line::from("The directory is removed; the branch is never deleted."), + Line::from(""), + Line::from("[y] yes [n] no"), + ]), + inner, + ); + } +} + +fn reason_label(reason: RetainedReason) -> &'static str { + match reason { + RetainedReason::ForceKilled => "force-killed", + RetainedReason::ExitedWithUnsavedWork => "exited-with-unsaved-work", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Utc}; + use maestro_protocol::{InTheWay, RetainedReason}; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + fn render_to_string(w: u16, h: u16, draw: F) -> String + where + F: FnOnce(&mut Frame), + { + let backend = TestBackend::new(w, h); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(draw).unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut out = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + out + } + + fn worktree(branch: &str, path: &str, id: u128) -> RetainedWorktree { + RetainedWorktree { + id: uuid::Uuid::from_u128(id), + path: path.into(), + base_repo: "/home/naps/tea/maestro".into(), + branch: branch.into(), + reason: RetainedReason::ForceKilled, + retained_at: Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(), + in_the_way: InTheWay::default(), + } + } + + #[test] + fn view_lists_branch_path_reason_and_what_is_in_the_way() { + let mut view = WorktreesView { + home_dir: "/home/naps".into(), + ..Default::default() + }; + let mut wt = worktree( + "feat/login", + "/home/naps/tea/maestro/worktrees/feat/login", + 1, + ); + wt.in_the_way = InTheWay { + uncommitted_files: 2, + untracked_files: 1, + unpushed_commits: 3, + }; + view.apply(vec![wt]); + + let rendered = render_to_string(160, 20, |f| view.render(f, f.area())); + assert!(rendered.contains("feat/login"), "{rendered}"); + // Path abbreviated against the daemon's home dir, same helper as the + // draft pane's project list. + assert!(rendered.contains("~/t/m/w/f/login"), "{rendered}"); + assert!(rendered.contains("force-killed"), "{rendered}"); + // Phrasing comes from InTheWay's Display impl — shared with the kill + // refusal and the web client. + assert!( + rendered.contains("2 uncommitted files, 1 untracked file, 3 unpushed commits"), + "{rendered}" + ); + } + + #[test] + fn empty_store_looks_like_a_normal_state_not_an_error() { + let view = WorktreesView::default(); + let rendered = render_to_string(80, 12, |f| view.render(f, f.area())); + assert!(rendered.contains("no retained worktrees"), "{rendered}"); + assert!(!rendered.contains("error"), "{rendered}"); + assert!(!rendered.contains("failed"), "{rendered}"); + } + + #[test] + fn highlight_moves_and_wraps() { + let mut view = WorktreesView::default(); + view.apply(vec![ + worktree("a", "/r/a", 1), + worktree("b", "/r/b", 2), + worktree("c", "/r/c", 3), + ]); + assert_eq!(view.selected().unwrap().branch, "a"); + view.move_highlight(1); + assert_eq!(view.selected().unwrap().branch, "b"); + view.move_highlight(-1); + assert_eq!(view.selected().unwrap().branch, "a"); + view.move_highlight(-1); + // Wraps backward to the last entry. + assert_eq!(view.selected().unwrap().branch, "c"); + view.move_highlight(1); + assert_eq!(view.selected().unwrap().branch, "a"); + } + + #[test] + fn apply_clamps_a_stale_highlight() { + let mut view = WorktreesView::default(); + view.apply(vec![worktree("a", "/r/a", 1), worktree("b", "/r/b", 2)]); + view.move_highlight(1); + view.apply(vec![worktree("c", "/r/c", 3)]); + assert_eq!(view.selected().unwrap().branch, "c"); + } + + #[test] + fn reclaim_confirm_names_the_branch_and_says_it_survives() { + let confirm = ReclaimConfirm { + branch: "feat/login".into(), + blockers: "2 uncommitted files".into(), + }; + let rendered = render_to_string(80, 14, |f| confirm.render(f, f.area())); + assert!( + rendered.contains("Reclaim worktree feat/login?"), + "{rendered}" + ); + assert!(rendered.contains("2 uncommitted files"), "{rendered}"); + assert!(rendered.contains("never deleted"), "{rendered}"); + assert!(rendered.contains("yes"), "{rendered}"); + assert!(rendered.contains("no"), "{rendered}"); + } +} diff --git a/docs/INSTALL.md b/docs/INSTALL.md index ad02b5f..a6b2a63 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -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 `/worktrees/`; +set `[daemon].worktree_root` only to override that.) ## Non-Linux hosts diff --git a/docs/WORKTREES.md b/docs/WORKTREES.md new file mode 100644 index 0000000..cdbf0d3 --- /dev/null +++ b/docs/WORKTREES.md @@ -0,0 +1,143 @@ +# Worktrees + +Maestro can create a git worktree for a session and is responsible for it from +that moment until the worktree is removed. This document is the contract for +that lifetime: where a worktree goes, what ends a session, what survives, and +what the operator has to do by hand. + +It exists because the rules here are not guessable from the code. Every rule +below was decided once, re-litigated in review, and decided the same way again. +The reasoning is written down alongside each rule so the next reader does not +have to reconstruct it. + +## Nothing is ever adopted + +The daemon only ever removes a worktree it created in this session's lifetime or +recorded in its retained store. It never takes ownership of a directory it finds +on disk, whatever that directory looks like. + +There is no reliable way to ask "is this worktree mine?". A git worktree carries +no marker distinguishing one a daemon made from one a human made ten minutes +earlier, and any heuristic that answers the question will eventually answer it +wrong — in the direction of deleting somebody's work. + +So the daemon does not ask. If the target path is occupied by anything at all, it +picks a different path. + +## Placement + +A worktree for branch `` of repository `` goes to +`/worktrees/`, where `` is the main checkout — not whichever +worktree the request happened to come from. + +The branch name is used verbatim as a relative path, so `feature/login` becomes +`worktrees/feature/login`. A branch name that cannot be a relative path falls +back to a short hash of the branch. + +If that path exists, the daemon appends `-2`, then `-3`, and so on until it finds +a free one. The suffix names the **directory only**. The branch is whatever was +requested; a second worktree of the same branch is not what happens here, because +git refuses to check the same branch out twice and the branch picker already +marks branches that are checked out elsewhere. + +### The `worktree_root` override + +`[daemon].worktree_root` moves every worktree out of the repository and under one +root. The per-repository directory is the repository's basename plus a short +digest of its full path — the digest is what keeps two repositories with the same +basename apart. Inside it the layout and the collision suffix are the same. + +## What ends a session + +`DELETE /api/sessions/:id` is **refused** when the session's worktree holds +unsaved work, meaning any of: + +- uncommitted changes to tracked files +- untracked files that are not ignored +- commits on the branch reachable from no remote-tracking ref + +Ignored files never block. Every Rust worktree has a `target/`; if ignored files +counted, no session would ever be killable. + +The refusal is a structured error naming what blocked it and how much of it there +is, so a client can say "3 uncommitted files, 2 unpushed commits". The session +keeps running and the worktree is untouched. + +The point of refusing rather than cleaning up is that the session stays as the +operator's handle on their own unfinished work. A session that is killed while +its worktree still matters leaves a directory nobody is looking at. + +A consequence, accepted deliberately: a feature branch almost always has unpushed +commits, so `--force` is the ordinary way to end a session, not an exception. +That is only safe because force retains. + +### `--force` + +`--force` overrides the refusal and nothing else. It ends the session and +**retains** the worktree, which enters the retained store with reason +`force-killed`. Force never removes a tree that would have blocked. A tree +whose guard is clear never needed the override, so a forced kill removes it as +usual — force changes what happens to blocked work, not ordinary cleanup. + +Ending a session and destroying its work are two separate destructive acts. +Each is chosen on its own, and the second one — reclaim — is chosen while +looking at exactly what would be lost. + +A forced kill answers with what was retained and why, so a client can say where +the work went rather than leaving the operator to wonder. + +### Exiting on its own + +A session that ends by itself — the command finished, the operator typed `exit` — +never reaches the kill path. When the watcher notices the tmux session is gone it +applies the same rule: a pristine worktree is removed, anything else is retained +with reason `exited-with-unsaved-work`. + +Nobody is waiting for an answer here, so there is nothing to refuse; the choice is +only between removing and retaining. It uses the same predicate as the kill, from +the same place, so the two paths cannot drift apart. + +The session itself stays in the catalog marked dead until it is dismissed — the +replay buffer of a crashed agent outlives it — but its worktree is settled at +once. Once settled, the session drops its worktree reference, so dismissing it +afterwards neither refuses (there is no guard left to trip) nor records a second +store entry. + +If settling is impossible — the probe fails, or the store cannot take the entry — +nothing is destroyed. The tree stays on disk *and* stays attached to the dead +record, where dismissing it later still goes through `--force`'s +retain-before-teardown path. Losing the store must never mean losing the work. + +## The retained store + +A `state_dir`-backed record of worktrees the daemon created that no longer have a +session. For each one: the path, the base repository, the branch, why it was +retained, and when. + +It survives a daemon restart. It is the recovery path for everything above — a +store that is lost on restart is worse than no store, because the worktrees are +still on disk with nothing pointing at them. + +Entries leave when the worktree is reclaimed, and when the daemon notices the +directory is gone because somebody removed it by hand. + +**It is not an ownership record.** It must never be consulted to decide whether a +path may be adopted — see the first rule. Its only job is to remember what the +daemon is holding so that it can be listed and reclaimed. + +## Reclaim + +`GET /api/worktrees` lists the store: path, base repository, branch, reason, when, +and what is currently in the way. The in-the-way breakdown is recomputed on read, +not stored, so a tree whose work has since been pushed says so. + +`DELETE /api/worktrees/` reclaims one. It removes the directory and drops the +store entry. It does **not** consult the guard — reclaim is the deliberate +destructive act, and the operator chose it while looking at what was in the way. + +It never deletes the branch. `git worktree remove` leaves the ref, and reclaiming +a tree must not throw away commits on it. This is worth saying out loud in every +client, because "reclaim" otherwise sounds like it takes the commits too. + +Nothing is reclaimed automatically. There is no age-based expiry and no bulk +reclaim. diff --git a/web/README.md b/web/README.md index 2dec9ca..5451a56 100644 --- a/web/README.md +++ b/web/README.md @@ -20,9 +20,10 @@ src/ main.ts # entry point, hash-route dispatch api.ts # daemon HTTP API client events.ts # `/api/events` WebSocket subscriber - catalog.ts # session tree view (groups by working directory) - attach.ts # xterm.js + attach WS view - spawn.ts # spawn-session modal + catalog.ts # session tree view (groups by working directory) + attach.ts # xterm.js + attach WS view + multiview.ts # tabs + split panes; draft panes host the spawn form + spawnForm.ts # shared spawn form (preset tiles, project picker, worktree) theme.ts # dark/light theme toggle + persistence types.ts # mirror of `maestro-protocol` JSON shapes styles.css # design-system tokens + component styles @@ -40,7 +41,7 @@ follows the OS by default and can be pinned via the header toggle (persisted in `mock.ts` is a DEV-only fixture (stripped from production builds): visit the dev server with `?mock` to seed a representative catalog without a running daemon, -`?mock&spawn` to open the spawn modal, and `?theme=light|dark` to pin a theme. +`?mock&spawn` to open a draft pane, and `?theme=light|dark` to pin a theme. Routing is hash-based: - `#/` → catalog diff --git a/web/package.json b/web/package.json index 26e4793..528365c 100644 --- a/web/package.json +++ b/web/package.json @@ -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" diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index cc85eb1..c4de6d0 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -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: {} diff --git a/web/src/api.ts b/web/src/api.ts index 3a7c055..b1cd03d 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,11 +1,15 @@ import * as routes from "./routes.js"; import type { ApiError, + Branches, CreateSessionRequest, DaemonInfo, DirListing, GitRoot, + NameBatch, + Projects, RecentDirs, + RetainedWorktree, Session, Uuid, } from "./types.js"; @@ -61,12 +65,17 @@ export async function getDaemonInfo(): Promise { 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 { 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 { + 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 +84,36 @@ export async function gitRoot(path: string): Promise { 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 { + 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 { + const url = routes.withPathQuery(routes.BRANCHES, path); + return jsonOrError(await fetch(url, { credentials: "same-origin" })); +} + +/** The daemon's retained worktrees (#80): trees kept after a session ended + * with unsaved work, with what is currently in the way. Drives the + * retained-worktrees view (#84). */ +export async function listWorktrees(): Promise { + return jsonOrError(await fetch(routes.WORKTREES, { credentials: "same-origin" })); +} + +/** Reclaim a retained worktree: removes its directory and drops the store + * entry. Never deletes the branch (docs/WORKTREES.md). */ +export async function reclaimWorktree(id: Uuid): Promise { + await jsonOrError( + await fetch(routes.worktree(id), { + method: "DELETE", + credentials: "same-origin", + }), + ); +} + export { eventsWsUrl } from "./routes.js"; diff --git a/web/src/catalog.test.ts b/web/src/catalog.test.ts new file mode 100644 index 0000000..b502fd7 --- /dev/null +++ b/web/src/catalog.test.ts @@ -0,0 +1,106 @@ +// @vitest-environment happy-dom +import { beforeAll, describe, expect, it } from "vitest"; + +import { renderSidebar } from "./catalog.js"; +import type { CatalogListener, CatalogStore } from "./events.js"; +import type { InTheWay, Session } from "./types.js"; + +// happy-dom runs without a backing store; renderSidebar reads the persisted +// sidebar width on mount. +beforeAll(() => { + Object.defineProperty(window, "localStorage", { + value: { getItem: () => null, setItem: () => {} }, + }); + // No daemon here; renderSidebar treats a failed info fetch as "absolute + // paths are a fine fallback". + window.fetch = () => Promise.reject(new Error("offline")); +}); + +function session(overrides: Partial): Session { + return { + id: "11111111-2222-3333-4444-555555555555", + cwd: "/home/naps/tea/maestro", + command: "claude", + env: {}, + metadata: {}, + status: "Running", + created_at: "2026-08-24T10:00:00Z", + ...overrides, + }; +} + +function storeWith(sessions: Session[]): CatalogStore { + const state = { sessions: new Map(sessions.map((s) => [s.id, s])) }; + return { + snapshot: () => state, + // Like the real store, deliver an immediate snapshot so the sidebar + // renders its rows during construction. + subscribe: (fn: CatalogListener) => { + fn(state); + return () => {}; + }, + } as unknown as CatalogStore; +} + +function rowFor(el: HTMLElement, id: string): HTMLElement | null { + return el.querySelector(`[data-session-id="${id}"]`); +} + +describe("blocked-work indicator (#83)", () => { + it("marks the row of a blocked session with the shared phrasing", () => { + const blocked = session({ + id: "aaaaaaaa-0000-0000-0000-000000000001", + blocked_work: { uncommitted_files: 3, unpushed_commits: 2 }, + }); + const el = renderSidebar(storeWith([blocked])).el; + const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked"); + expect(badge?.textContent).toContain("⚠"); + expect(badge?.textContent).toContain("3 uncommitted files, 2 unpushed commits"); + }); + + it("does not mark clear or unprobed sessions", () => { + const clear = session({ + id: "aaaaaaaa-0000-0000-0000-000000000002", + blocked_work: {} as InTheWay, + }); + const unprobed = session({ + id: "aaaaaaaa-0000-0000-0000-000000000003", + }); + const el = renderSidebar(storeWith([clear, unprobed])).el; + expect(rowFor(el, clear.id)?.querySelector(".leaf-blocked")).toBeNull(); + expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull(); + }); + + it("restates the warning at the kill confirm", async () => { + const confirmations: string[] = []; + window.confirm = (msg?: string) => { + confirmations.push(msg ?? ""); + return false; + }; + const blocked = session({ + id: "aaaaaaaa-0000-0000-0000-000000000004", + command: "bash", + blocked_work: { untracked_files: 1 }, + }); + const el = renderSidebar(storeWith([blocked])).el; + const kill = rowFor(el, blocked.id)?.querySelector(".leaf-kill"); + kill?.click(); + await Promise.resolve(); + expect(confirmations).toHaveLength(1); + expect(confirmations[0]).toContain("1 untracked file"); + expect(confirmations[0]).toContain("refused unless forced"); + }); + + it("confirms plainly when nothing blocks", async () => { + const confirmations: string[] = []; + window.confirm = (msg?: string) => { + confirmations.push(msg ?? ""); + return false; + }; + const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" }); + const el = renderSidebar(storeWith([plain])).el; + rowFor(el, plain.id)?.querySelector(".leaf-kill")?.click(); + await Promise.resolve(); + expect(confirmations).toEqual(['Kill session "claude"?']); + }); +}); diff --git a/web/src/catalog.ts b/web/src/catalog.ts index 9475ca3..b9d4bf0 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -3,12 +3,18 @@ import { getDaemonInfo, killSession } from "./api.js"; import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js"; import { CatalogStore } from "./events.js"; import type { Session } from "./types.js"; -import { abbreviateFolderName, sessionName } from "./types.js"; +import { abbreviateFolderName, formatInTheWay, sessionBlockers, sessionName } from "./types.js"; import type { TreeNode } from "./tree.js"; import { buildTree } from "./tree.js"; -import { openSpawnModal } from "./spawn.js"; import { createThemeToggle } from "./theme.js"; +/** Ask the shell to open a new draft pane (#62). Handled in main.ts, where the + * multi-view controller lives; dispatched from every Spawn affordance. */ +export const OPEN_SPAWN_EVENT = "catalog-open-spawn"; +export function requestOpenSpawn(): void { + document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT)); +} + const collapsed = new Set(); function renderNode(node: TreeNode, depth: number): HTMLElement { @@ -58,10 +64,14 @@ function renderNode(node: TreeNode, depth: number): HTMLElement { // The directory is the enclosing folder, so only surface the command — and // only when it isn't already what the name shows. const showCmd = s.command !== name; + // #83: warn on the row itself, in `formatInTheWay`'s shared phrasing, so + // "this will be refused" is visible before the kill is attempted. + const blockers = sessionBlockers(s); row.innerHTML = ` ${escapeHtml(name)} ${showCmd ? `${escapeHtml(s.command)}` : ""} ${running ? "" : `exited`} + ${blockers ? `⚠ ${escapeHtml(formatInTheWay(blockers))}` : ""} @@ -77,7 +87,12 @@ function renderNode(node: TreeNode, depth: number): HTMLElement { killBtn.addEventListener("click", async (ev) => { ev.preventDefault(); ev.stopPropagation(); - if (!confirm(`Kill session "${sessionName(s)}"?`)) return; + // #83: the confirm restates the warning at the moment of decision. + const blockers = sessionBlockers(s); + const question = blockers + ? `Kill session "${name}"? Its worktree still has ${formatInTheWay(blockers)} — the kill will be refused unless forced.` + : `Kill session "${name}"?`; + if (!confirm(question)) return; try { await killSession(s.id); } catch (e) { @@ -174,6 +189,15 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void): renderStatus(); const actions = header.querySelector(".app-actions") as HTMLElement; + // Retained worktrees (#84): a section reachable from the header, not a + // sidebar fixture — PRODUCT.md's scan is the product, so this must not + // compete with the session catalog for attention. + const worktreesLink = document.createElement("a"); + worktreesLink.className = "btn btn-ghost"; + worktreesLink.href = "#/worktrees"; + worktreesLink.textContent = "worktrees"; + worktreesLink.title = "Retained worktrees"; + actions.appendChild(worktreesLink); // DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by // clicking inside a proxied preview (e.g. crit live) that has no address bar. if (import.meta.env.DEV) { @@ -190,7 +214,9 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void): spawnBtn.type = "button"; spawnBtn.className = "btn btn-primary spawn-btn"; spawnBtn.textContent = "+ Spawn"; - spawnBtn.addEventListener("click", () => openSpawnModal()); + // The keyboard pair sits right here in the tooltip (#67). + spawnBtn.title = "Draft pane: n — fast-forward spawn: N"; + spawnBtn.addEventListener("click", () => requestOpenSpawn()); actions.appendChild(spawnBtn); return header; } @@ -342,7 +368,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S treeHost.replaceChildren(); if (sessions.length === 0) { treeHost.appendChild( - renderEmpty(filter.trim().length > 0, () => openSpawnModal()), + renderEmpty(filter.trim().length > 0, () => requestOpenSpawn()), ); return; } @@ -364,14 +390,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S const rerenderHandler = () => refresh(); document.addEventListener("catalog-rerender", rerenderHandler); - // Spawn similar: reopen the spawn modal pre-seeded from a session's dir and - // command (matched back to a preset by the form) — but with a freshly - // generated name rather than the original's, since it's a new session. - const spawnSimilarHandler = (ev: Event) => { - const s = (ev as CustomEvent).detail; - openSpawnModal({ cwd: s.cwd, command: s.command }); - }; - document.addEventListener("catalog-spawn-similar", spawnSimilarHandler); + // "Spawn similar" (#28) is dispatched here and handled in main.ts, where the + // multi-view controller can seed a draft pane with the session's dir/command. const unsubscribe = store.subscribe(refresh); @@ -391,7 +411,6 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S }, dispose() { document.removeEventListener("catalog-rerender", rerenderHandler); - document.removeEventListener("catalog-spawn-similar", spawnSimilarHandler); unsubscribe(); }, }; diff --git a/web/src/dirAutocomplete.test.ts b/web/src/dirAutocomplete.test.ts new file mode 100644 index 0000000..3c9e811 --- /dev/null +++ b/web/src/dirAutocomplete.test.ts @@ -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; +let ac: DirAutocompleteHandle | undefined; + +function popup(): HTMLUListElement { + return document.querySelector(".dir-ac") as HTMLUListElement; +} + +function items(): HTMLElement[] { + return [...popup().querySelectorAll(".dir-ac-item")]; +} + +async function type(value: string): Promise { + 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(); + }); +}); diff --git a/web/src/dirAutocomplete.ts b/web/src/dirAutocomplete.ts index 3bf9647..3ec9114 100644 --- a/web/src/dirAutocomplete.ts +++ b/web/src/dirAutocomplete.ts @@ -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 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 . - 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); diff --git a/web/src/fastForward.test.ts b/web/src/fastForward.test.ts new file mode 100644 index 0000000..9d1d6e5 --- /dev/null +++ b/web/src/fastForward.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { + buildFastForwardRequest, + fastForwardSeed, + resolveFastForward, +} from "./fastForward.js"; +import type { Project, SpawnPreset } from "./types.js"; + +const presets: SpawnPreset[] = [ + { id: "custom", label: "Custom…", command: "" }, + { id: "claude", label: "Claude", command: "claude" }, +]; + +function project(overrides: Partial): Project { + return { + path: "/repos/p", + name: "p", + last_activity: new Date().toISOString(), + ...overrides, + }; +} + +describe("resolveFastForward (#67)", () => { + it("resolves every default from the most recent project", () => { + const projects = [ + project({ path: "/repos/old", name: "old", last_activity: "2020-01-01T00:00:00Z" }), + project({ + path: "/repos/fresh", + name: "fresh", + base_repo: "/repos/fresh", + last_command: "aider", + }), + ]; + const res = resolveFastForward(projects, presets, "amber-falcon"); + expect(res.project?.path).toBe("/repos/fresh"); + expect(res.command).toBe("aider"); + expect(res.name).toBe("amber-falcon"); + + const req = buildFastForwardRequest(res)!; + expect(req.cwd).toBe("/repos/fresh"); + expect(req.command).toBe("aider"); + expect(req.metadata).toEqual({ name: "amber-falcon" }); + expect(req.worktree).toEqual({ + base_repo: "/repos/fresh", + branch: "amber-falcon", + create_branch: true, + }); + }); + + it("falls back to the first non-custom preset when there is no last command", () => { + const res = resolveFastForward([project({ last_command: undefined })], presets, "n"); + expect(res.command).toBe("claude"); + }); + + it("treats an empty last command as no command", () => { + const res = resolveFastForward([project({ last_command: "" })], [presets[0]], "n"); + expect(res.command).toBeUndefined(); + expect(buildFastForwardRequest(res)).toBeNull(); + }); + + it("resolves nothing when no projects are known yet", () => { + const res = resolveFastForward([], presets, "n"); + expect(res.project).toBeUndefined(); + expect(buildFastForwardRequest(res)).toBeNull(); + }); + + it("downgrades to a draft when the generated name is missing", () => { + const res = resolveFastForward([project({ last_command: "claude" })], presets); + expect(res.name).toBeUndefined(); + expect(buildFastForwardRequest(res)).toBeNull(); + }); + + it("seeds the fallback draft with whatever did resolve", () => { + const res = resolveFastForward( + [project({ path: "/repos/p", base_repo: "/repos/p", last_command: "aider" })], + presets, + ); + expect(fastForwardSeed(res)).toEqual({ + cwd: "/repos/p", + command: "aider", + name: "", + worktreeEnabled: true, + worktreeBranch: undefined, + }); + }); + + it("leaves the worktree default alone when not even a project resolved", () => { + // Not `false`: the form defaults the toggle on and a false would stick, + // so the fallback draft would disagree with a normally-opened draft and + // with the TUI's fast path. + const seed = fastForwardSeed(resolveFastForward([], presets)); + expect(seed.worktreeEnabled).toBeUndefined(); + expect(seed.cwd).toBe(""); + }); +}); diff --git a/web/src/fastForward.ts b/web/src/fastForward.ts new file mode 100644 index 0000000..39d620c --- /dev/null +++ b/web/src/fastForward.ts @@ -0,0 +1,65 @@ +// Fast-forward spawn (#67): one keystroke, every default resolved, no form. +// Pure resolution only — the caller (main.ts) owns the fetches and the POST, +// and falls back to a seeded draft pane when anything is missing. + +import type { CreateSessionRequest, Project, SpawnPreset } from "./types.js"; +import { sortProjects } from "./projectList.js"; + +/** What one fast-forward keystroke resolved. Anything absent means "open a + * draft pre-filled with the rest instead of spawning". */ +export interface FastForwardResolution { + project?: Project; + command?: string; + name?: string; +} + +/** The defaults: most recent project (fresh-first, same ordering the picker + * shows), its last command falling back to the first non-custom preset, and + * a freshly generated name when the daemon supplied one. */ +export function resolveFastForward( + projects: Project[], + presets: SpawnPreset[], + name?: string, +): FastForwardResolution { + const project = sortProjects(projects, new Date())[0]; + const lastCommand = project?.last_command?.trim(); + const command = + lastCommand || presets.find((p) => p.command !== "")?.command || undefined; + return { project, command, name: name || undefined }; +} + +/** The immediate spawn request — worktree on, new branch named after the + * generated session name — or `null` when any default is missing. */ +export function buildFastForwardRequest( + res: FastForwardResolution, +): CreateSessionRequest | null { + const { project, command, name } = res; + if (!project || !command?.trim() || !name) return null; + return { + cwd: project.path, + command, + metadata: { name }, + worktree: { + base_repo: project.base_repo ?? project.path, + branch: name, + create_branch: true, + }, + }; +} + +/** Seed for the fallback draft pane: whatever did resolve goes into the + * form; the rest stays with its own defaults. */ +export function fastForwardSeed(res: FastForwardResolution) { + return { + cwd: res.project?.path ?? "", + command: res.command ?? "", + name: res.name ?? "", + // Left undefined rather than false when nothing resolved: the form + // defaults the toggle on, and `false` would stick, so the fallback draft + // would disagree with both a normally-opened draft and the TUI's fast path + // (#67 review). + worktreeEnabled: res.project ? true : undefined, + worktreeBranch: + res.project && res.name ? res.name : undefined, + }; +} diff --git a/web/src/html.ts b/web/src/html.ts index 93f5646..01db561 100644 --- a/web/src/html.ts +++ b/web/src/html.ts @@ -1,5 +1,5 @@ -// One HTML escaper. Previously written four times, in catalog.ts, spawn.ts, -// types.ts and attach.ts. +// One HTML escaper. Previously written four times, in catalog.ts, types.ts +// and attach.ts. export function escapeHtml(s: string): string { return s diff --git a/web/src/kit.css b/web/src/kit.css index 062c9a3..29116c9 100644 --- a/web/src/kit.css +++ b/web/src/kit.css @@ -102,7 +102,7 @@ background: color-mix(in oklab, var(--surface) 40%, var(--bg)); } -.kit-slot .modal-body { +.kit-slot .spawn-form { padding: 0; min-width: 200px; } diff --git a/web/src/kit.ts b/web/src/kit.ts index bc42371..f33759c 100644 --- a/web/src/kit.ts +++ b/web/src/kit.ts @@ -135,7 +135,7 @@ export function renderKit(): HTMLElement { { const { el, body } = section( "Buttons — current", - "The shipping button styles — now the bracket terminal style, as used across the header, modal, and empty states.", + "The shipping button styles — now the bracket terminal style, as used across the header, panes, and empty states.", ); body.append( specimen("btn-primary", btn("btn btn-primary", "+ Spawn")), @@ -170,12 +170,12 @@ export function renderKit(): HTMLElement { search.type = "search"; search.className = "search"; search.placeholder = "Search sessions…"; - const text = h("div", "modal-body"); + const text = h("div", "spawn-form"); const ti = document.createElement("input"); ti.type = "text"; ti.placeholder = "branch-name"; text.appendChild(ti); - const sel = h("div", "modal-body"); + const sel = h("div", "spawn-form"); const s = document.createElement("select"); s.innerHTML = ``; sel.appendChild(s); diff --git a/web/src/layout.ts b/web/src/layout.ts index 51a2ec5..bf98155 100644 --- a/web/src/layout.ts +++ b/web/src/layout.ts @@ -11,8 +11,23 @@ import type { Uuid } from "./types.js"; export type Dir = "row" | "col"; +/** Half-filled spawn-form state for a draft pane (issue #57): an empty leaf + * renders the spawn form instead of a placeholder, and each pane's values + * hang off its own leaf so two drafts stay independent. Never persisted — + * a reload gives back an empty form (persistence strips it). */ +export interface Draft { + command: string; + cwd: string; + name: string; + worktreeEnabled?: boolean; + worktreeBranch?: string; + worktreeCleanup?: boolean; + /** Whether the cwd lives in the raw text input (vs a picked recent row). */ + cwdRawVisible?: boolean; +} + export type Node = - | { kind: "leaf"; id: string; sessionId: Uuid | null } + | { kind: "leaf"; id: string; sessionId: Uuid | null; draft?: Draft | null } | { kind: "split"; dir: Dir; a: Node; b: Node; ratio: number }; export type Leaf = Extract; @@ -89,7 +104,7 @@ export function splitLeaf( dir: Dir, after: boolean, ): string { - const fresh: Leaf = { kind: "leaf", id: freshId("l"), sessionId }; + const fresh: Leaf = { kind: "leaf", id: freshId("l"), sessionId, draft: null }; const rec = (n: Node): Node => { if (n.kind === "leaf") { if (n.id !== targetId) return n; diff --git a/web/src/main.ts b/web/src/main.ts index aa5fd48..6b2a26a 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -3,13 +3,28 @@ import { ConnectionPool } from "./connections.js"; import { installHudKeybind } from "./debug.js"; import { CatalogStore } from "./events.js"; import { shouldRetryClose } from "./reconnect.js"; +import { + createSession, + fetchNames, + getDaemonInfo, + listWorktrees, + projects as fetchProjects, + reclaimWorktree, +} from "./api.js"; +import { + buildFastForwardRequest, + fastForwardSeed, + resolveFastForward, +} from "./fastForward.js"; +import { FALLBACK_PRESETS } from "./spawnForm.js"; import { mountHud, type Hud } from "./hud.js"; import { emptyLayout, type Layout, openSessions } from "./layout.js"; import { createMultiView, type MultiView } from "./multiview.js"; import { loadLayout, saveLayout } from "./persistence.js"; -import { openSpawnModal } from "./spawn.js"; +import { OPEN_SPAWN_EVENT } from "./catalog.js"; import { initTheme } from "./theme.js"; -import { sessionName } from "./types.js"; +import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js"; +import { createWorktreesView, type WorktreesViewHandle } from "./worktreesView.js"; import { installViewportSizing } from "./viewport.js"; import "./styles.css"; @@ -212,7 +227,8 @@ function renderWelcome(): HTMLElement { spawn.type = "button"; spawn.className = "btn btn-primary"; spawn.textContent = "+ Spawn session"; - spawn.addEventListener("click", () => openSpawnModal()); + spawn.title = "Draft pane: n — fast-forward spawn: N"; + spawn.addEventListener("click", () => requestOpenSpawn()); (el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn); return el; } @@ -236,6 +252,9 @@ mv = createMultiView({ infoFor, onDisconnect: closePane, renderWelcome, + // A draft-pane submit (#57) fills its leaf in place; the shell route may flip + // from catalog (no sessions) to attach. + onOpenSessionsChange: updateShellRoute, onLayoutChange: scheduleSave, }); let contentShowsMv = false; @@ -248,19 +267,128 @@ function showMultiView(): void { updateShellRoute(); } +// Spawn affordances (#62): the header button, the sidebar's empty state, and +// `?mock&spawn` all open a draft pane; "Spawn similar" seeds one with the +// cloned session's dir + command. +function requestOpenSpawn(): void { + showMultiView(); + mv.openDraft(); +} +document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn()); +document.addEventListener("catalog-spawn-similar", (ev) => { + const s = (ev as CustomEvent).detail; + showMultiView(); + mv.openDraft({ cwd: s.cwd, command: s.command }); +}); + +// Fast-forward spawn (#67): the draft pane's keystroke neighbour spawns +// immediately with every default resolved — most recent project, its last +// command (or the first non-custom preset), a fresh name, worktree on with +// a new branch named after that name. Anything unresolvable (no projects +// known yet, no command, no generated name) opens a draft pre-filled with +// what did resolve instead. +async function requestFastForward(): Promise { + showMultiView(); + const [discovered, name] = await Promise.all([ + fetchProjects().catch(() => [] as Project[]), + fetchNames(1) + .then((batch) => batch.names[0]) + .catch(() => undefined), + ]); + let presets: SpawnPreset[] = FALLBACK_PRESETS; + try { + const info = await getDaemonInfo(); + if (info.presets?.length) presets = info.presets; + } catch { + /* keep fallbacks */ + } + const resolved = resolveFastForward(discovered, presets, name); + const req = buildFastForwardRequest(resolved); + if (req) { + try { + const session = await createSession(req); + // Mount it, the way the TUI's fast path attaches. Without this the + // keystroke creates a session and leaves the UI untouched, so it reads + // as a dead key (#67 review). + mv.openSessionBeside(session.id); + updateShellRoute(); + return; + } catch { + // A failed POST degrades to the same pre-filled draft as an + // unresolvable default — never a silent dead keystroke. + } + } + mv.openDraft(fastForwardSeed(resolved)); +} + +// Keyboard pair (#67): `n` opens a draft pane, `N` fast-forwards a spawn. +// Ignored while typing anywhere (form fields, the terminal's hidden +// textarea, search) so an attached session's keys are never hijacked. +document.addEventListener("keydown", (e) => { + if (e.ctrlKey || e.altKey || e.metaKey) return; + const target = e.target as HTMLElement | null; + if ( + target && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.tagName === "SELECT" || + target.isContentEditable) + ) { + return; + } + if (e.key === "n") { + e.preventDefault(); + requestOpenSpawn(); + } else if (e.key === "N") { + e.preventDefault(); + void requestFastForward(); + } +}); + type Route = | { kind: "catalog" } | { kind: "attach"; sessionId: string } + | { kind: "worktrees" } | { kind: "not-found"; raw: string }; function parseRoute(): Route { const hash = location.hash.replace(/^#/, ""); if (hash === "" || hash === "/") return { kind: "catalog" }; + if (hash === "/worktrees") return { kind: "worktrees" }; const m = hash.match(/^\/attach\/([^/?#]+)$/); if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) }; return { kind: "not-found", raw: hash }; } +// Retained worktrees (#84): built lazily on first visit, then kept so a +// revisit re-enters instantly. The daemon's home dir abbreviates paths once +// `/api/info` answers. +let worktreesView: WorktreesViewHandle | undefined; +let homeDir: string | undefined; +void getDaemonInfo() + .then((info) => { + homeDir = info.home_dir; + }) + .catch(() => { + /* absolute paths are a fine fallback */ + }); + +function showWorktrees(): void { + pool.setVisible([]); + pool.setFocus(null); + contentShowsMv = false; + if (!worktreesView) { + worktreesView = createWorktreesView({ + load: listWorktrees, + reclaim: reclaimWorktree, + homeDir: () => homeDir, + }); + } + content.replaceChildren(worktreesView.el); + shellEl.dataset.route = "worktrees"; + void worktreesView.refresh(); +} + function dispatch(): void { const route = parseRoute(); switch (route.kind) { @@ -282,6 +410,9 @@ function dispatch(): void { // tears panes down. showMultiView(); return; + case "worktrees": + showWorktrees(); + return; } } diff --git a/web/src/mock.ts b/web/src/mock.ts index e3847da..dc9fc6f 100644 --- a/web/src/mock.ts +++ b/web/src/mock.ts @@ -33,8 +33,8 @@ function sess( }; } -/** Stub the spawn-form backend endpoints (#26) so `?mock&spawn` renders the full - * tile-first flow — presets, recents, gitroot — with no daemon running. Dev-only; +/** Stub the spawn-form backend endpoints (#26/#59) so `?mock&spawn` renders the + * full flow — presets, projects, gitroot — with no daemon running. Dev-only; * delegates every other request to the real `fetch`. */ function stubSpawnApi(): void { const real = globalThis.fetch.bind(globalThis); @@ -61,16 +61,39 @@ function stubSpawnApi(): void { }), ); } - if (path.includes("/recent-dirs")) { + if (path.includes("/projects")) { + const now = Date.now(); + const iso = (daysAgo: number) => new Date(now - daysAgo * 86_400_000).toISOString(); return Promise.resolve( - json({ - dirs: [ - { cwd: "/home/dev/git/maestro", last_command: "claude", last_used: "2026-06-20T10:00:00Z" }, - { cwd: "/home/dev/git/dotfiles", last_command: "zsh", last_used: "2026-06-19T18:00:00Z" }, - { cwd: "/home/dev/work/acme/api", last_command: "opencode", last_used: "2026-06-18T09:00:00Z" }, - ], - truncated: false, - }), + json([ + { + path: "/home/dev/git/maestro", + name: "maestro", + is_worktree: false, + last_activity: iso(0), + last_command: "claude", + }, + { + path: "/home/dev/worktrees/maestro-59", + name: "maestro-59", + is_worktree: true, + base_repo: "/home/dev/git/maestro", + last_activity: iso(1), + last_command: "opencode", + }, + { + path: "/home/dev/git/dotfiles", + name: "dotfiles", + is_worktree: false, + last_activity: iso(30), + last_command: "zsh", + }, + { + path: "/home/dev/work/acme/api", + name: "api", + is_worktree: false, + }, + ]), ); } if (path.includes("/gitroot")) { @@ -112,10 +135,10 @@ export function seedMock(store: CatalogStore): void { sessions, }); - // `?mock&spawn` auto-opens the spawn modal for visual review, with the spawn - // backend endpoints stubbed so presets/recents/gitroot all render. + // `?mock&spawn` auto-opens a draft pane for visual review, with the spawn + // backend endpoints stubbed so presets/projects/gitroot all render. if (location.search.includes("spawn")) { stubSpawnApi(); - void import("./spawn.js").then((m) => m.openSpawnModal()); + document.dispatchEvent(new CustomEvent("catalog-open-spawn")); } } diff --git a/web/src/multiview.test.ts b/web/src/multiview.test.ts new file mode 100644 index 0000000..8067d5e --- /dev/null +++ b/web/src/multiview.test.ts @@ -0,0 +1,162 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { emptyLayout, leaves, type Layout } from "./layout.js"; +import { splitLeaf } from "./layout.js"; +import { createMultiView, type MultiView } from "./multiview.js"; +import type { ConnectionPool } from "./connections.js"; +import type { SidebarHandle } from "./catalog.js"; + +function stubFetch(): void { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const body = url.includes("/api/info") + ? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] } + : url.includes("/api/projects") + ? [] + : url.includes("/api/names") + ? { names: ["allegro-cello"] } + : undefined; + return new Response(JSON.stringify(body), { status: 200 }); + }), + ); +} + +function fakePool(): ConnectionPool { + return { + setVisible() {}, + setFocus() {}, + } as unknown as ConnectionPool; +} + +function fakeSidebar(): SidebarHandle { + return { + el: document.createElement("aside"), + setActive() {}, + setHighlights() {}, + dispose() {}, + }; +} + +function makeMv(layout: Layout): MultiView { + return createMultiView({ + pool: fakePool(), + layout, + sidebar: fakeSidebar(), + infoFor: () => ({ name: "sess", command: "bash" }), + onDisconnect: () => {}, + renderWelcome: () => document.createElement("div"), + }); +} + +/** Drain the microtask queue so the draft form's async seed application lands. */ +async function flush(): Promise { + for (let i = 0; i < 20; i++) await Promise.resolve(); +} + +describe("multiview openDraft (#62)", () => { + beforeEach(() => { + stubFetch(); + document.body.replaceChildren(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("turns an empty tab into a draft pane", async () => { + const layout = emptyLayout(); + const mv = makeMv(layout); + document.body.appendChild(mv.el); + + mv.openDraft(); + await flush(); + + const ls = leaves(layout.tabs[0].root); + expect(ls).toHaveLength(1); + expect(ls[0].sessionId).toBeNull(); + expect(ls[0].draft).not.toBeNull(); + expect(mv.el.querySelector(".mv-draft")).toBeTruthy(); + }); + + it("splits the focused session pane into a fresh draft", async () => { + const layout = emptyLayout(); + const tab = layout.tabs[0]; + const first = splitLeaf(tab, "", "s1", "row", true); + tab.focusedLeafId = first; + const mv = makeMv(layout); + document.body.appendChild(mv.el); + mv.render(); + + mv.openDraft(); + await flush(); + + const ls = leaves(tab.root); + expect(ls).toHaveLength(2); + expect(ls[0].sessionId).toBe("s1"); + const draftLeaf = ls.find((l) => l.sessionId === null)!; + expect(draftLeaf.draft).not.toBeNull(); + expect(tab.focusedLeafId).toBe(draftLeaf.id); + expect(mv.el.querySelectorAll(".mv-pane")).toHaveLength(2); + }); + + it("reuses the focused pane when it is already empty", async () => { + const layout = emptyLayout(); + const tab = layout.tabs[0]; + const first = splitLeaf(tab, "", "s1", "row", true); + const empty = splitLeaf(tab, first, null, "row", true); + tab.focusedLeafId = empty; + const mv = makeMv(layout); + document.body.appendChild(mv.el); + mv.render(); + + mv.openDraft(); + await flush(); + + const ls = leaves(tab.root); + expect(ls).toHaveLength(2); + const target = ls.find((l) => l.id === empty)!; + expect(target.draft).not.toBeNull(); + expect(tab.focusedLeafId).toBe(empty); + }); + + it("keeps the draft form instance when an unrelated session closes (#68)", async () => { + const layout = emptyLayout(); + const tab = layout.tabs[0]; + const s1 = splitLeaf(tab, "", "s1", "row", true); + const draftLeaf = splitLeaf(tab, s1, null, "row", true); + tab.focusedLeafId = draftLeaf; + const mv = makeMv(layout); + document.body.appendChild(mv.el); + mv.render(); + await flush(); + + const nameInput = mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement; + nameInput.value = "wip"; + nameInput.dispatchEvent(new Event("input")); + await flush(); + expect(leaves(tab.root).find((l) => l.id === draftLeaf)?.draft?.name).toBe("wip"); + + const formBefore = mv.el.querySelector(".mv-draft"); + mv.closeSession("s1"); + await flush(); + + expect(mv.el.querySelector(".mv-draft")).toBe(formBefore); + expect((mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement).value).toBe("wip"); + }); + + it("seeds a clone's cwd and command into the draft", async () => { + const layout = emptyLayout(); + const mv = makeMv(layout); + document.body.appendChild(mv.el); + + mv.openDraft({ cwd: "/home/user/proj", command: "claude" }); + await flush(); + + const leaf = leaves(layout.tabs[0].root)[0]; + expect(leaf.draft?.cwd).toBe("/home/user/proj"); + expect(leaf.draft?.command).toBe("claude"); + }); +}); diff --git a/web/src/multiview.ts b/web/src/multiview.ts index 777865b..b837c6e 100644 --- a/web/src/multiview.ts +++ b/web/src/multiview.ts @@ -1,8 +1,9 @@ // The multi-view renderer/controller (issues #30 splits, #31 tabs, #33 // highlights). Owns the tab bar + canvas inside the content pane, builds the // active tab's split tree into DOM, and mounts a live AttachConnection into each -// session leaf via the pool. Empty leaves (freshly split, not yet filled) show a -// placeholder. Drag-to-dock is a later issue (#32); mobile projection is #35. +// session leaf via the pool. Empty leaves are draft panes (#57): they render +// the spawn form inline, and submitting replaces the draft with that session's +// terminal in place. Drag-to-dock is issue #32; mobile projection is #35. import type { SidebarHandle } from "./catalog.js"; import type { ConnectionPool } from "./connections.js"; @@ -12,6 +13,7 @@ import { closeTab, findLeaf, type Dir, + type Draft, type Layout, leaves, locateSession, @@ -22,6 +24,7 @@ import { } from "./layout.js"; import { sessionColor } from "./sessioncolor.js"; import type { AttachInfo } from "./attach.js"; +import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js"; export interface MultiViewOptions { pool: ConnectionPool; @@ -33,6 +36,9 @@ export interface MultiViewOptions { onDisconnect: (sessionId: string) => void; /** Welcome content for an empty tab. */ renderWelcome: () => HTMLElement; + /** Called when a draft-pane submit turns an empty pane into a session pane, + * so the shell can re-evaluate its route (sidebar vs attach layout). */ + onOpenSessionsChange?: () => void; /** Called after any layout change (structure, focus, or divider resize) so the * shell can persist it (issue #34). */ onLayoutChange?: () => void; @@ -43,10 +49,15 @@ export interface MultiView { /** Bring a session into the layout: focus it if already open, else fill the * focused pane (or open the first pane in an empty tab). */ openSession: (sessionId: string) => void; + openSessionBeside: (sessionId: string) => void; /** Drop the pane showing `sessionId` (the session was disconnected/killed). */ closeSession: (sessionId: string) => void; /** Every session currently shown in some pane, across all tabs. */ openSessions: () => Set; + /** Open a draft pane (issue #62): reuse the focused pane if it is already + * empty, otherwise split the focused pane. `seed` pre-fills the form + * ("Spawn similar" clones a session's cwd + command). */ + openDraft: (seed?: Partial) => void; /** Start dragging a sidebar session into the canvas (issue #32). The * controller owns drag-vs-tap: a tap lets the row's normal click open it, a * drag shows drop zones to place it. */ @@ -60,6 +71,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView { const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts; const persist = () => opts.onLayoutChange?.(); + // Draft-pane forms (#57) carry global listeners (dir autocomplete), so forms + // whose leaves are gone are destroyed on each render. Forms for draft leaves + // that survive the render are reused in place (#68): rebuilding one mid-edit + // would drop focus, caret and selection, and re-fetch info/projects/names. + const draftForms = new Map(); + const el = document.createElement("div"); el.className = "mv"; const tabBar = document.createElement("div"); @@ -133,7 +150,38 @@ export function createMultiView(opts: MultiViewOptions): MultiView { const host = document.createElement("div"); host.className = "mv-pane-host"; if (!leaf.sessionId) { - host.innerHTML = `
Empty pane — pick a session from the sidebar.
`; + // Draft pane (#57): an empty pane is a spawn form. Values hang off the + // leaf (never persisted) so each draft keeps its own half-filled state; + // submitting fills this leaf with the new session in place. + const form = draftForms.get(leaf.id); + if (form) { + host.appendChild(form.el); + } else { + const created = createSpawnForm({ + seed: leaf.draft ?? undefined, + class: "mv-draft", + onChange: (values) => { + leaf.draft = values; + }, + onSuccess: (session) => { + // The pane can be closed while the POST is in flight. Mounting the + // session onto a leaf no longer in the tree would strand it in no + // pane at all, so only claim the leaf while it is still there — + // searching every tab, since the operator may have switched tabs + // while the request was in flight. + const host = layout.tabs.find((t) => leaves(t.root).includes(leaf)); + if (host) { + leaf.sessionId = session.id; + leaf.draft = null; + host.focusedLeafId = leaf.id; + } + render(); + opts.onOpenSessionsChange?.(); + }, + }); + draftForms.set(leaf.id, created); + host.appendChild(created.el); + } } const tools = document.createElement("div"); @@ -326,12 +374,86 @@ export function createMultiView(opts: MultiViewOptions): MultiView { // Fill the focused pane (VS Code style: open replaces the active group). const target = (tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)) || leaves(tab.root)[0]; target.sessionId = sessionId; + target.draft = null; tab.focusedLeafId = target.id; render(); } + /// Mount a session without evicting one. + /// + /// `openSession` deliberately replaces the focused pane (VS Code style) for + /// a sidebar click, but the fast path must never do that: pressing the key + /// while attached would drop the running session out of the layout + /// entirely. Follows `openDraft`'s rule instead — reuse an empty pane, else + /// split beside the focused one (#67 review). + function openSessionBeside(sessionId: string): void { + const loc = locateSession(layout, sessionId); + if (loc) { + layout.activeTabId = loc.tab.id; + loc.tab.focusedLeafId = loc.leaf.id; + render(); + return; + } + const tab = activeTab(layout); + if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) { + tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true); + render(); + opts.onOpenSessionsChange?.(); + return; + } + const target = findLeaf(tab.root, tab.focusedLeafId)!; + if (!target.sessionId) { + target.sessionId = sessionId; + target.draft = null; + } else { + tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true); + } + render(); + opts.onOpenSessionsChange?.(); + } + + function openDraft(seed?: Partial): void { + const tab = activeTab(layout); + const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed }); + if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) { + // Empty tab (or stale focus): the draft becomes the first pane. + tab.focusedLeafId = splitLeaf(tab, "", null, "row", true); + findLeaf(tab.root!, tab.focusedLeafId)!.draft = draft(); + render(); + return; + } + const target = findLeaf(tab.root, tab.focusedLeafId)!; + if (!target.sessionId && !target.draft) { + // An already-empty pane becomes the draft in place. + target.draft = draft(); + } else { + // Split a fresh draft beside the focused pane. + tab.focusedLeafId = splitLeaf(tab, target.id, null, "row", true); + findLeaf(tab.root, tab.focusedLeafId)!.draft = draft(); + } + render(); + } + // ---- top-level render ---- + /** Destroy forms whose leaf is no longer a draft (leaf removed or filled by + * a session). Surviving draft leaves keep their form instance (#68). */ + function pruneForms(tab: { root: Node | null }): void { + const alive = new Set( + tab.root + ? leaves(tab.root) + .filter((l) => !l.sessionId) + .map((l) => l.id) + : [], + ); + for (const [id, f] of draftForms) { + if (!alive.has(id)) { + f.destroy(); + draftForms.delete(id); + } + } + } + function render(): void { renderTabs(); canvas.replaceChildren(); @@ -341,6 +463,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView { el.classList.toggle("mv-mobile-solo", isMobile && layout.tabs.length === 1); if (!tab.root) { + pruneForms(tab); pool.setVisible([]); pool.setFocus(null); canvas.appendChild(renderWelcome()); @@ -356,12 +479,14 @@ export function createMultiView(opts: MultiViewOptions): MultiView { if (isMobile) { renderMobile(tab); + pruneForms(tab); persist(); return; } const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = []; canvas.appendChild(buildTree(tab.root, hosts)); + pruneForms(tab); pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info }))); syncFocus(); persist(); @@ -569,6 +694,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView { if (source.kind === "pane" && self) { const dragged = findLeaf(tab.root, self)!; [dragged.sessionId, target.sessionId] = [target.sessionId, dragged.sessionId]; + [dragged.draft, target.draft] = [target.draft, dragged.draft]; tab.focusedLeafId = target.id; } else if (sessionId) { const loc = locateSession(layout, sessionId); @@ -576,6 +702,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView { const t2 = findLeaf(tab.root, targetLeafId); if (t2) { t2.sessionId = sessionId; + t2.draft = null; tab.focusedLeafId = t2.id; } } @@ -600,6 +727,8 @@ export function createMultiView(opts: MultiViewOptions): MultiView { return { el, openSession, + openSessionBeside, + openDraft, closeSession, openSessions: () => openSessions(layout), beginSessionDrag, diff --git a/web/src/names.ts b/web/src/names.ts deleted file mode 100644 index 9de5d70..0000000 --- a/web/src/names.ts +++ /dev/null @@ -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(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)}`; -} diff --git a/web/src/persistence.test.ts b/web/src/persistence.test.ts index ffc00e5..a626f58 100644 --- a/web/src/persistence.test.ts +++ b/web/src/persistence.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { hydrate } from "./persistence.js"; -import { leaves } from "./layout.js"; +import { hydrate, serializeLayout } from "./persistence.js"; +import { leaves, type Layout } from "./layout.js"; describe("hydrate", () => { it("rejects anything that is not a tab-bearing object", () => { @@ -61,4 +61,68 @@ describe("hydrate", () => { }); expect(leaves(layout!.tabs[0].root).map((l) => l.sessionId)).toEqual(["s"]); }); + + it("never restores draft state, even if a stale blob carries it", () => { + const layout = hydrate({ + tabs: [ + { id: "t", root: { kind: "leaf", id: "l1", sessionId: null, draft: { command: "zsh", cwd: "/x" } } }, + ], + activeTabId: "t", + }); + expect(leaves(layout!.tabs[0].root)[0]).not.toHaveProperty("draft"); + }); +}); + +describe("serializeLayout", () => { + it("strips draft state so a reload gives back an empty form", () => { + 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/project", name: "amber-fox" }; + + const restored = hydrate(JSON.parse(serializeLayout(layout)).layout); + expect(restored).not.toBeNull(); + for (const leaf of leaves(restored!.tabs[0].root)) { + expect(leaf.draft ?? null).toBeNull(); + } + 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"); + }); }); diff --git a/web/src/persistence.ts b/web/src/persistence.ts index 4ac03b9..4a60195 100644 --- a/web/src/persistence.ts +++ b/web/src/persistence.ts @@ -14,12 +14,35 @@ const VERSION = 1; export function saveLayout(layout: Layout): void { try { - localStorage.setItem(KEY, JSON.stringify({ v: VERSION, layout })); + localStorage.setItem(KEY, serializeLayout(layout)); } catch { // Storage full or unavailable (private mode) — persistence is best-effort. } } +/** Serialise the layout for storage with draft state stripped (issue #57): a + * reload gives back an empty form, never a stale half-filled one. */ +export function serializeLayout(layout: Layout): string { + const clean: Layout = { + ...layout, + tabs: layout.tabs.map((t) => ({ + ...t, + root: stripDrafts(t.root), + })), + }; + return JSON.stringify({ v: VERSION, layout: clean }); +} + +function stripDrafts(node: Node | null): Node | null { + 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 }; +} + export function loadLayout(): Layout | null { let raw: string | null; try { diff --git a/web/src/projectList.test.ts b/web/src/projectList.test.ts new file mode 100644 index 0000000..cbf5ac9 --- /dev/null +++ b/web/src/projectList.test.ts @@ -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 { + 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]); + }); +}); diff --git a/web/src/projectList.ts b/web/src/projectList.ts new file mode 100644 index 0000000..4a44ca6 --- /dev/null +++ b/web/src/projectList.ts @@ -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(); + 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), + ); +} diff --git a/web/src/projectPicker.test.ts b/web/src/projectPicker.test.ts new file mode 100644 index 0000000..ba6b821 --- /dev/null +++ b/web/src/projectPicker.test.ts @@ -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 { + 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(".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[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(".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(".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(".project-row")!; + row.click(); + + const values = form.querySelector("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(".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(".cwd-raw")!; + expect(raw.value).toBe("/home/user"); + }); +}); diff --git a/web/src/routes.ts b/web/src/routes.ts index 6fb38e2..299812c 100644 --- a/web/src/routes.ts +++ b/web/src/routes.ts @@ -9,12 +9,19 @@ 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 WORKTREES = "/api/worktrees"; 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)}`; } @@ -23,6 +30,11 @@ export function attach(id: Uuid): string { return `${API_PREFIX}/attach/${encodeURIComponent(id)}`; } +/** `DELETE /api/worktrees/:id` — reclaim one retained worktree (#80). */ +export function worktree(id: Uuid): string { + return `${WORKTREES}/${encodeURIComponent(id)}`; +} + /** Append the `path` query parameter `DIRS` and `GITROOT` accept. */ export function withPathQuery(route: string, value: string): string { return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`; diff --git a/web/src/spawn.ts b/web/src/spawn.ts deleted file mode 100644 index a3c7040..0000000 --- a/web/src/spawn.ts +++ /dev/null @@ -1,428 +0,0 @@ -import { escapeHtml } from "./html.js"; -import { createSession, getDaemonInfo, recentDirs, gitRoot } from "./api.js"; -import { attachDirAutocomplete, type DirAutocompleteHandle } from "./dirAutocomplete.js"; -import { generateName } from "./names.js"; -import type { CreateSessionRequest, RecentDir, SpawnPreset } from "./types.js"; -import { abbreviateCwd, abbreviateFolderName } from "./types.js"; - -/** Seed values for the spawn form — used by "clone" to reopen the dialog with - * the same cwd, command, and name as an existing session. */ -export interface SpawnPrefill { - cwd?: string; - command?: string; - name?: string; -} - -/** Built-ins used when the daemon's `/api/info` presets can't be fetched - * (older daemon, transient error). Mirrors `maestro_config::builtin_presets`. */ -const FALLBACK_PRESETS: SpawnPreset[] = [ - { id: "claude", label: "Claude", command: "claude" }, - { id: "opencode", label: "opencode", command: "opencode" }, - { id: "zsh", label: "Shell", command: "zsh" }, - { id: "custom", label: "Custom…", command: "" }, -]; - -export function openSpawnModal(prefill?: SpawnPrefill): void { - const existing = document.getElementById("modal-host"); - if (existing) existing.remove(); - - const host = document.createElement("div"); - host.id = "modal-host"; - host.className = "modal-backdrop"; - host.innerHTML = ` - - `; - document.body.appendChild(host); - - const form = host.querySelector("#spawn-form") as HTMLFormElement; - const errEl = form.querySelector(".form-error") as HTMLElement; - const cmdTiles = host.querySelector("#cmd-tiles") as HTMLElement; - const cmdRaw = host.querySelector("#cmd-raw") as HTMLInputElement; - const recentsEl = host.querySelector("#recents") as HTMLElement; - const cwdRawWrap = host.querySelector("#cwd-raw-wrap") as HTMLElement; - const cwdRaw = host.querySelector("#cwd-raw") as HTMLInputElement; - const wtEnabled = host.querySelector("#wt-enabled") as HTMLInputElement; - const wtHint = host.querySelector("#wt-hint") as HTMLElement; - const wtBody = host.querySelector("#wt-body") as HTMLElement; - const wtRepo = host.querySelector("#wt-repo") as HTMLElement; - const branchInput = host.querySelector("input[name=worktree_branch]") as HTMLInputElement; - const nameInput = host.querySelector("input[name=name]") as HTMLInputElement; - - // ---- form state ------------------------------------------------------ - let homeDir = ""; - let presets: SpawnPreset[] = FALLBACK_PRESETS; - 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 probeToken = 0; // guards async gitroot probes against races - // A clone's cwd, applied once the (async) recents load for the initial host - // settles — so the fetch can't race past and clobber the seeded directory. - let cloneCwd: string | null = prefill?.cwd ?? null; - - // ---- name (also seeds the worktree branch) --------------------------- - // The worktree branch defaults to the session name — including a clone's - // prefilled name — and tracks it on reroll/edit until the user types their - // own branch. - let branchEdited = false; - const syncBranchToName = () => { - if (!branchEdited) branchInput.value = nameInput.value; - }; - nameInput.value = prefill?.name ?? generateName(); - nameInput.addEventListener("input", syncBranchToName); - branchInput.addEventListener("input", () => { - branchEdited = true; - }); - (host.querySelector("#reroll") as HTMLButtonElement).addEventListener("click", () => { - nameInput.value = generateName(); - syncBranchToName(); - }); - syncBranchToName(); // seed the branch from the initial name - - // ---- command tiles --------------------------------------------------- - function selectPreset(id: string): 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(); - } else { - command = preset!.command; - } - for (const tile of cmdTiles.querySelectorAll(".tile")) { - tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id); - } - } - - function renderCommandTiles(): void { - cmdTiles.replaceChildren(); - for (const p of presets) { - const tile = document.createElement("button"); - tile.type = "button"; - tile.className = "tile"; - tile.dataset.id = p.id; - tile.innerHTML = `${escapeHtml(p.label)}`; - tile.addEventListener("click", () => selectPreset(p.id)); - cmdTiles.appendChild(tile); - } - } - - cmdRaw.addEventListener("input", () => { - if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") { - command = cmdRaw.value.trim(); - } - }); - - // Presets ride `/api/info`; render fallbacks immediately, then refine. - renderCommandTiles(); - applyInitialCommand(); - void getDaemonInfo() - .then((info) => { - homeDir = info.home_dir ?? ""; - if (info.presets?.length) { - presets = info.presets; - renderCommandTiles(); - applyInitialCommand(); - } - }) - .catch(() => { - /* keep fallbacks */ - }); - - // Pick a sensible starting command: a clone's command (matched to a preset or - // dropped into custom), else the first non-custom preset. - function applyInitialCommand(): void { - const wanted = prefill?.command; - if (wanted) { - const match = presets.find((p) => p.command === wanted && p.command !== ""); - if (match) { - selectPreset(match.id); - } else { - const custom = presets.find((p) => p.command === ""); - cmdRaw.value = wanted; - selectPreset(custom ? custom.id : "custom"); - command = wanted; - } - return; - } - const first = presets.find((p) => p.command !== ""); - if (first) selectPreset(first.id); - } - - // ---- working-directory picker --------------------------------------- - function showRawCwd(prefillValue?: string): void { - cwdRawWrap.hidden = false; - if (prefillValue != null) cwd = prefillValue; - cwdRaw.value = cwd; - markActiveRecent(null); - cwdRaw.focus(); - cwdRaw.select(); - } - - cwdRaw.addEventListener("input", () => { - cwd = cwdRaw.value.trim(); - if (wtEnabled.checked) probeGitRoot(); - }); - - function markActiveRecent(activeCwd: string | null): void { - for (const row of recentsEl.querySelectorAll(".recent-row")) { - row.classList.toggle( - "is-active", - (row as HTMLElement).dataset.cwd === activeCwd, - ); - } - } - - function selectRecent(dir: RecentDir): void { - cwd = dir.cwd; - cwdRawWrap.hidden = true; - markActiveRecent(dir.cwd); - // 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 !== ""); - if (match) selectPreset(match.id); - if (wtEnabled.checked) probeGitRoot(); - } - - async function loadRecents(): Promise { - recentsEl.replaceChildren(loadingRow()); - let dirs: RecentDir[] = []; - try { - dirs = (await recentDirs()).dirs ?? []; - } catch { - dirs = []; - } - recentsEl.replaceChildren(); - for (const dir of dirs) { - recentsEl.appendChild(recentRow(dir)); - } - // Persistent escape hatch + empty-history fallback. - recentsEl.appendChild(otherDirRow()); - if (cloneCwd) { - // A clone seeds the directory verbatim into the raw input, regardless of - // the recorded history. - showRawCwd(cloneCwd); - cloneCwd = null; - } else if (dirs.length === 0) { - // No history: default to raw input prefilled with the daemon's home dir. - showRawCwd(homeDir); - } else { - cwdRawWrap.hidden = true; - } - } - - function recentRow(dir: RecentDir): HTMLElement { - const row = document.createElement("button"); - row.type = "button"; - row.className = "recent-row"; - row.dataset.cwd = dir.cwd; - // 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)); - row.innerHTML = ` - ${display} - ${escapeHtml(dir.last_command)}`; - row.addEventListener("click", () => selectRecent(dir)); - return row; - } - - function otherDirRow(): HTMLElement { - const row = document.createElement("button"); - row.type = "button"; - row.className = "recent-row recent-other"; - row.innerHTML = `Other directory…`; - row.addEventListener("click", () => showRawCwd(cwd || homeDir)); - return row; - } - - function loadingRow(): HTMLElement { - const p = document.createElement("p"); - p.className = "picker-loading"; - p.textContent = "Loading recent directories…"; - return p; - } - - // ---- worktree (branch-only; base_repo inferred via /gitroot) ---------- - async function probeGitRoot(): Promise { - const token = ++probeToken; - if (!cwd) { - inferredBaseRepo = null; - wtBody.hidden = true; - wtHint.hidden = false; - wtHint.textContent = "Choose a working directory first."; - return; - } - wtHint.hidden = false; - wtHint.textContent = "Checking for a git repository…"; - wtBody.hidden = true; - let root: string | undefined; - try { - root = (await gitRoot(cwd)).root; - } catch { - root = undefined; - } - if (token !== probeToken) return; // a newer probe superseded us - if (root) { - inferredBaseRepo = root; - wtHint.hidden = true; - wtBody.hidden = false; - wtRepo.textContent = `repo: ${root} (inferred)`; - } else { - inferredBaseRepo = null; - wtEnabled.checked = false; - wtBody.hidden = true; - wtHint.hidden = false; - wtHint.textContent = "Not inside a git repository — pick a dir in a repo to use a worktree."; - } - } - - wtEnabled.addEventListener("change", () => { - if (wtEnabled.checked) { - probeGitRoot(); - } else { - inferredBaseRepo = null; - wtBody.hidden = true; - wtHint.hidden = true; - } - }); - - void loadRecents(); - - // ---- clone seed ------------------------------------------------------ - if (prefill) { - (host.querySelector("#spawn-title") as HTMLElement).textContent = "Clone session"; - // The cwd itself is applied by loadRecents once the initial host's recents - // settle (see `cloneCwd`), so a slow fetch can't clobber it. - } - - // ---- live dir autocomplete on the raw cwd input (NAP-329) ------------ - const autocompletes: DirAutocompleteHandle[] = [attachDirAutocomplete(cwdRaw)]; - - // ---- lifecycle ------------------------------------------------------- - const close = () => { - autocompletes.forEach((a) => a.destroy()); - host.remove(); - }; - host.querySelector(".close")?.addEventListener("click", close); - host.querySelector(".cancel")?.addEventListener("click", close); - host.addEventListener("click", (e) => { - if (e.target === host) close(); - }); - window.addEventListener( - "keydown", - (e) => { - if (e.key === "Escape") close(); - }, - { once: true }, - ); - - // ---- submit ---------------------------------------------------------- - form.addEventListener("submit", async (e) => { - e.preventDefault(); - errEl.textContent = ""; - if (!cwd) { - errEl.textContent = "Pick a working directory."; - return; - } - if (!command) { - errEl.textContent = "Pick a command (or fill the custom command)."; - return; - } - - const metadata: Record = {}; - const name = nameInput.value.trim(); - if (name) metadata.name = name; - - const req: CreateSessionRequest = { - cwd, - command, - metadata, - }; - - if (wtEnabled.checked) { - const branch = branchInput.value.trim(); - if (!inferredBaseRepo) { - errEl.textContent = "Worktree needs a directory inside a git repository."; - return; - } - if (!branch) { - errEl.textContent = "Worktree requires a branch name."; - return; - } - req.worktree = { - base_repo: inferredBaseRepo, - branch, - cleanup_on_kill: Boolean( - (host.querySelector("input[name=worktree_cleanup_on_kill]") as HTMLInputElement).checked, - ), - }; - } - - const submitBtn = form.querySelector("button[type=submit]") as HTMLButtonElement; - submitBtn.disabled = true; - try { - const session = await createSession(req); - close(); - location.hash = `#/attach/${encodeURIComponent(session.id)}`; - } catch (err) { - errEl.textContent = (err as Error).message; - submitBtn.disabled = false; - } - }); -} - diff --git a/web/src/spawnForm.test.ts b/web/src/spawnForm.test.ts new file mode 100644 index 0000000..34834d2 --- /dev/null +++ b/web/src/spawnForm.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; + +import { buildSpawnRequest, type SpawnFormValues } from "./spawnForm.js"; + +const base: SpawnFormValues = { + command: "claude", + cwd: "/home/user/project", + name: "amber-fox", + worktreeEnabled: false, + worktreeBranch: "", + worktreeBranchMode: "new", + worktreeBranchEdited: false, +}; + +describe("buildSpawnRequest", () => { + it("builds a plain request with the picked cwd and command", () => { + const res = buildSpawnRequest(base, null); + expect(res).toEqual({ + req: { cwd: "/home/user/project", command: "claude", metadata: { name: "amber-fox" } }, + }); + }); + + it("omits metadata.name when the name is blank", () => { + const res = buildSpawnRequest({ ...base, name: " " }, null); + expect(res).toEqual({ req: { cwd: base.cwd, command: base.command, metadata: {} } }); + }); + + it("attaches a trimmed branch when worktree is enabled, and always cleans up", () => { + const res = buildSpawnRequest( + { ...base, worktreeEnabled: true, worktreeBranch: " feat/foo " }, + "/home/user/project", + ); + expect(res).toEqual({ + req: { + cwd: base.cwd, + command: base.command, + metadata: { name: "amber-fox" }, + worktree: { + base_repo: "/home/user/project", + branch: "feat/foo", + 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", + 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"); + }); + + it("rejects a missing command", () => { + expect(buildSpawnRequest({ ...base, command: "" }, null)).toHaveProperty("error"); + }); + + it("rejects a worktree without an inferred base repo", () => { + expect(buildSpawnRequest({ ...base, worktreeEnabled: true, worktreeBranch: "b" }, null)) + .toHaveProperty("error"); + }); + + it("rejects a worktree without a branch name", () => { + expect(buildSpawnRequest({ ...base, worktreeEnabled: true, worktreeBranch: " " }, "/repo")) + .toHaveProperty("error"); + }); +}); diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts new file mode 100644 index 0000000..194d71c --- /dev/null +++ b/web/src/spawnForm.ts @@ -0,0 +1,737 @@ +import { escapeHtml } from "./html.js"; +import { + createSession, + fetchBranches, + fetchNames, + getDaemonInfo, + projects as fetchProjects, + gitRoot, +} from "./api.js"; +import { attachDirAutocomplete } from "./dirAutocomplete.js"; +import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js"; +import 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 open a draft with the + * same cwd, command, and name as an existing session, and by draft panes to + * restore their own half-filled state after a re-render. */ +export interface SpawnFormSeed { + command?: string; + cwd?: string; + name?: string; + worktreeEnabled?: boolean; + worktreeBranch?: string; + /** Whether the operator typed the branch themselves, rather than it having + * been auto-synced from the session name. Carried separately because the + * value alone cannot distinguish the two. */ + worktreeBranchEdited?: boolean; + worktreeBranchMode?: WorktreeBranchMode; + cwdRawVisible?: boolean; +} + +/** The form's current values, reported on every change so a caller (the draft + * pane) can hang them off its layout leaf. */ +export interface SpawnFormValues { + command: string; + cwd: string; + name: string; + worktreeEnabled: boolean; + worktreeBranch: string; + worktreeBranchEdited: boolean; + /** "new" creates the branch; "existing" checks out a picked one (#61). */ + worktreeBranchMode: WorktreeBranchMode; + /** Whether the cwd lives in the raw text input (vs a picked recent row). */ + cwdRawVisible?: boolean; +} + +export function buildSpawnRequest( + values: SpawnFormValues, + baseRepo: string | null, +): { req: CreateSessionRequest } | { error: string } { + if (!values.cwd) return { error: "Pick a working directory." }; + if (!values.command) return { error: "Pick a command (or fill the custom command)." }; + + const metadata: Record = {}; + const name = values.name.trim(); + if (name) metadata.name = name; + + const req: CreateSessionRequest = { + cwd: values.cwd, + command: values.command, + metadata, + }; + + 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: mode === "existing" ? "Pick an existing branch." : "Worktree requires a branch name." }; + } + req.worktree = { + base_repo: baseRepo, + branch, + // Existing-mode picks must check out as-is; the daemon creates + // anything else with `worktree add -b` (#56/#61). + create_branch: mode !== "existing", + }; + } + + return { req }; +} + +/** Built-ins used when the daemon's `/api/info` presets can't be fetched + * (older daemon, transient error). Mirrors `maestro_config::builtin_presets`. + * Exported for the fast-forward spawn's preset fallback (#67). */ +export const FALLBACK_PRESETS: SpawnPreset[] = [ + { id: "claude", label: "Claude", command: "claude" }, + { id: "opencode", label: "opencode", command: "opencode" }, + { id: "zsh", label: "Shell", command: "zsh" }, + { id: "custom", label: "Custom…", command: "" }, +]; + +export interface SpawnFormHandle { + el: HTMLFormElement; + /** Remove global listeners (dir autocomplete) — call when unmounting. */ + destroy(): void; +} + +export interface SpawnFormOptions { + seed?: SpawnFormSeed; + /** Extra class(es) for the
root (e.g. `mv-draft` in a draft pane). */ + class?: string; + /** Fired whenever any value changes (input, tile pick, checkbox). */ + onChange?: (values: SpawnFormValues) => void; + /** Called with the created session after a successful submit. */ + onSuccess: (session: Session) => void; +} + +/** The spawn form itself — preset tiles, project picker, name, worktree. + * Mounted inline by draft panes (issue #57). */ +export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { + const prefill = opts.seed ?? {}; + + const form = document.createElement("form"); + form.className = `spawn-form${opts.class ? ` ${opts.class}` : ""}`; + form.innerHTML = ` +
+ Command +
+ +
+
+ Working directory + +
+ +
+
+ Name +
+ + +
+
+
+ + + + + +
+ +
+ +
+ `; + // Field queries are scoped to this form instance — several forms can be + // mounted at once (one per draft pane), so ids would collide. + + const errEl = form.querySelector(".form-error") as HTMLElement; + const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement; + const cmdRaw = form.querySelector(".cmd-raw") as HTMLInputElement; + const recentsEl = form.querySelector(".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("input[name=worktree_branch_mode]"), + ]; + const newWrap = form.querySelector(".wt-new-wrap") as HTMLElement; + const existWrap = form.querySelector(".wt-exist-wrap") as HTMLElement; + const branchInput = form.querySelector("input[name=worktree_branch]") as HTMLInputElement; + const branchSelect = form.querySelector(".branch-select") as HTMLSelectElement; + const nameInput = form.querySelector("input[name=name]") as HTMLInputElement; + + // ---- form state ------------------------------------------------------ + let homeDir = ""; + let presets: SpawnPreset[] = FALLBACK_PRESETS; + 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 or the project scan + let probeToken = 0; // guards async gitroot probes against races + // A seed's cwd, applied once the (async) projects load for the initial host + // settles — so the fetch can't race past and clobber the seeded directory. + let seedCwd: string | null = prefill.cwd ?? null; + let branchMode: WorktreeBranchMode = prefill.worktreeBranchMode ?? "new"; + let branchToken = 0; // guards async branch-list loads against races + // Branch list for the current cwd — refetched whenever the target changes. + let branchCache: { cwd: string; branches: BranchInfo[] } | null = null; + + // `emit` stays silent until the initial load settles and goes silent again + // once the form is destroyed. Construction runs with `cwd === ""` and the + // 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: branchValue(), + worktreeBranchEdited: branchEdited, + worktreeBranchMode: branchMode, + cwdRawVisible: !cwdRawWrap.hidden, + }); + }; + + // ---- 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. Only in "new" mode: an existing-branch pick is not a name. + // True only once the operator types their own branch. It cannot be inferred + // from the seed: in "new" mode `emit()` publishes the auto-synced session + // name, so seeding from it froze the branch after any form rebuild + // (#67 review). `worktreeBranchEdited` is carried explicitly instead. + let branchEdited = Boolean(prefill.worktreeBranchEdited); + const syncBranchToName = () => { + if (!branchEdited && branchMode === "new") branchInput.value = nameInput.value; + }; + // 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 | null = null; + // Guards against a late batch clobbering something the operator already + // typed or rerolled. + let nameTouched = Boolean(prefill.name); + const refillNames = (): Promise => { + 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(); + }); + branchInput.addEventListener("input", () => { + branchEdited = true; + emit(); + }); + (form.querySelector(".reroll") as HTMLButtonElement).addEventListener("click", () => { + 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 --------------------------------------------------- + // `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(); + if (focus) cmdRaw.focus(); + } else { + command = preset!.command; + } + for (const tile of cmdTiles.querySelectorAll(".tile")) { + tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id); + } + emit(); + } + + function renderCommandTiles(): void { + cmdTiles.replaceChildren(); + for (const p of presets) { + const tile = document.createElement("button"); + tile.type = "button"; + tile.className = "tile"; + tile.dataset.id = p.id; + tile.innerHTML = `${escapeHtml(p.label)}`; + tile.addEventListener("click", () => selectPreset(p.id)); + cmdTiles.appendChild(tile); + } + } + + cmdRaw.addEventListener("input", () => { + if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") { + command = cmdRaw.value.trim(); + emit(); + } + }); + + // Presets ride `/api/info`; render fallbacks immediately, then refine. + renderCommandTiles(); + applyInitialCommand(); + + const infoLoad = getDaemonInfo() + .then((info) => { + homeDir = info.home_dir ?? ""; + if (info.presets?.length) { + presets = info.presets; + renderCommandTiles(); + applyInitialCommand(); + } + }) + .catch(() => { + /* keep fallbacks */ + }); + + // Pick a sensible starting command: a seed's command (matched to a preset or + // dropped into custom), else the first non-custom preset. + function applyInitialCommand(): void { + const wanted = prefill.command; + if (wanted) { + const match = presets.find((p) => p.command === wanted && p.command !== ""); + if (match) { + selectPreset(match.id, false); + } else { + const custom = presets.find((p) => p.command === ""); + cmdRaw.value = wanted; + selectPreset(custom ? custom.id : "custom", false); + command = wanted; + } + return; + } + const first = presets.find((p) => p.command !== ""); + if (first) selectPreset(first.id, false); + } + + // ---- working-directory picker --------------------------------------- + function showRawCwd(prefillValue?: string, focus = true): void { + cwdRawWrap.hidden = false; + if (prefillValue != null) cwd = prefillValue; + cwdRaw.value = cwd; + 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 markActiveProject(activeCwd: string | null): void { + for (const row of recentsEl.querySelectorAll(".project-row")) { + row.classList.toggle( + "is-active", + (row as HTMLElement).dataset.cwd === activeCwd, + ); + } + } + + function selectProject(project: Project): void { + cwd = project.path; + cwdRawWrap.hidden = true; + markActiveProject(project.path); + // The scan already knows this directory is inside a repo (#61): the + // toggle defaults back on and no /api/gitroot round trip is needed. + wtEnabled.checked = true; + applyProjectRepo(project.base_repo ?? project.path); + emit(); + // Picking a project pre-selects the command it was last used with. + const match = presets.find((p) => p.command === project.last_command && p.command !== ""); + if (match) selectPreset(match.id); + } + + let discovered: Project[] = []; // daemon snapshot, daemon-sorted + + function renderProjects(): void { + recentsEl.replaceChildren(); + 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); + } + recentsEl.appendChild(otherDirRow()); + markActiveProject(cwdRawWrap.hidden ? cwd : null); + } + + filterInput.addEventListener("input", renderProjects); + + async function loadProjects(): Promise { + recentsEl.replaceChildren(loadingRow()); + try { + discovered = await fetchProjects(); + } catch { + discovered = []; + } + if (destroyed) return; + renderProjects(); + if (seedCwd) { + // A seed sets the directory verbatim, regardless of what the scan found. + // `cwdRawVisible === false` marks a draft restore whose cwd came from a + // 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; + markActiveProject(cwd); + } else { + showRawCwd(seedCwd, false); + } + seedCwd = null; + } else if (discovered.length === 0) { + // Nothing discovered yet: default to raw input prefilled with home. + showRawCwd(homeDir, false); + } else { + cwdRawWrap.hidden = true; + } + // Everything seeded: the form may publish from here on. + ready = true; + resolveBaseRepo(); + emit(); + } + + function projectRow(project: Project): HTMLElement { + const row = document.createElement("button"); + row.type = "button"; + 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(project.path, homeDir)); + row.innerHTML = ` + + ${escapeHtml(project.name)} + ${display} + + ${escapeHtml(project.last_command ?? "")}`; + row.addEventListener("click", () => selectProject(project)); + return row; + } + + function otherDirRow(): HTMLElement { + const row = document.createElement("button"); + row.type = "button"; + row.className = "project-row project-other"; + row.innerHTML = `Other directory…`; + row.addEventListener("click", () => showRawCwd(cwd || homeDir)); + return row; + } + + function loadingRow(): HTMLElement { + const p = document.createElement("p"); + p.className = "picker-loading"; + p.textContent = "Loading projects…"; + return p; + } + + // ---- worktree (branch-only; base_repo inferred via scan or /gitroot) -- + // The project scan already reports repo membership, so a picked project + // resolves its base repo locally (#61); only the free-text escape hatch + // pays for a /api/gitroot probe. + function applyProjectRepo(repo: string): void { + inferredBaseRepo = repo; + branchCache = null; + wtHint.hidden = true; + wtBody.hidden = false; + wtRepo.textContent = `repo: ${repo}`; + if (branchMode === "existing") void loadBranches(); + } + + /** Decide how the worktree section describes the current target. Called + * once the initial load has settled `cwd`. */ + function resolveBaseRepo(): void { + // Nothing to resolve when the operator turned the worktree off — the + // section would otherwise rebuild expanded under an unchecked box, and + // fire a `/api/gitroot` probe for a worktree nobody asked for + // (#67 review). + if (!wtEnabled.checked) { + wtBody.hidden = true; + wtHint.hidden = true; + inferredBaseRepo = null; + return; + } + if (!cwd) { + inferredBaseRepo = null; + wtBody.hidden = true; + wtHint.hidden = false; + wtHint.textContent = "Choose a working directory first."; + return; + } + const project = discovered.find((p) => p.path === cwd); + if (project) { + applyProjectRepo(project.base_repo ?? project.path); + return; + } + void probeGitRoot(); + } + + async function probeGitRoot(): Promise { + const token = ++probeToken; + if (!cwd) { + inferredBaseRepo = null; + wtBody.hidden = true; + wtHint.hidden = false; + wtHint.textContent = "Choose a working directory first."; + return; + } + wtHint.hidden = false; + wtHint.textContent = "Checking for a git repository…"; + wtBody.hidden = true; + let root: string | undefined; + try { + root = (await gitRoot(cwd)).root; + } catch { + root = undefined; + } + if (token !== probeToken) return; // a newer probe superseded us + if (root) { + 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 { + if (!cwd || !inferredBaseRepo) return; + const token = ++branchToken; + if (branchCache?.cwd !== cwd) { + const target = cwd; + let branches: BranchInfo[]; + try { + branches = await fetchBranches(target); + } catch { + return; // picker just stays empty; "new" mode still works + } + if (destroyed || token !== branchToken || cwd !== target) return; + branchCache = { cwd: target, branches }; + } + renderBranchOptions(); + } + + function renderBranchOptions(): void { + if (destroyed) return; + const branches = branchCache?.cwd === cwd ? branchCache.branches : []; + branchSelect.replaceChildren(); + const opt = (label: string, value: string) => { + const o = document.createElement("option"); + o.textContent = label; + o.value = value; + return o; + }; + if (branches.length === 0) { + branchSelect.appendChild(opt("No local branches found.", "")); + return; + } + for (const b of branches) { + // A branch that is already checked out anywhere stays visible but + // unpickable — git refuses a second checkout. That includes the main + // checkout's own HEAD, which is commonly `main` and would otherwise be + // the default pick, making every existing-branch spawn fail (#54 review). + const held = b.worktree ?? (b.is_head ? "the main checkout" : null); + const o = opt(held ? `${b.name} — held by ${held}` : b.name, b.name); + o.disabled = held !== null; + o.title = held ?? ""; + branchSelect.appendChild(o); + } + } + + for (const radio of modeRadios) { + radio.addEventListener("change", () => { + if (radio.checked) setBranchMode(radio.value as WorktreeBranchMode); + }); + } + branchSelect.addEventListener("change", () => emit()); + + wtEnabled.addEventListener("change", () => { + emit(); + if (wtEnabled.checked) { + resolveBaseRepo(); + } else { + inferredBaseRepo = null; + wtBody.hidden = true; + wtHint.hidden = true; + } + }); + + // The toggle defaults ON (#61): the operator almost always wants a + // worktree, and every non-repo target unticks it via the gitroot probe. + wtEnabled.checked = prefill.worktreeEnabled ?? true; + if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch; + setBranchMode(branchMode); + + // Seed ordering: the raw-cwd application races the async info load (which + // provides homeDir for the projects' abbreviations), so projects wait for it. + void infoLoad.then(() => loadProjects()); + + // ---- live dir autocomplete on the raw cwd input (NAP-329) ------------ + const autocomplete = attachDirAutocomplete(cwdRaw); + + // ---- submit ---------------------------------------------------------- + form.addEventListener("submit", async (e) => { + e.preventDefault(); + errEl.textContent = ""; + const built = buildSpawnRequest( + { + command, + cwd, + name: nameInput.value, + worktreeEnabled: wtEnabled.checked, + worktreeBranch: branchValue(), + worktreeBranchEdited: branchEdited, + worktreeBranchMode: branchMode, + }, + inferredBaseRepo, + ); + if ("error" in built) { + errEl.textContent = built.error; + return; + } + + const submitBtn = form.querySelector("button[type=submit]") as HTMLButtonElement; + submitBtn.disabled = true; + try { + const session = await createSession(built.req); + opts.onSuccess(session); + } catch (err) { + errEl.textContent = (err as Error).message; + submitBtn.disabled = false; + } + }); + + return { + el: form, + destroy() { + destroyed = true; + autocomplete.destroy(); + form.remove(); + }, + }; +} diff --git a/web/src/spawnFormNames.test.ts b/web/src/spawnFormNames.test.ts new file mode 100644 index 0000000..50aab84 --- /dev/null +++ b/web/src/spawnFormNames.test.ts @@ -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[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("input[name=name]")!.value; + + function reroll(form: HTMLFormElement): void { + form.querySelector(".reroll")!.click(); + } + + async function drain(): Promise { + 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("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"); + }); +}); diff --git a/web/src/spawnFormWorktree.test.ts b/web/src/spawnFormWorktree.test.ts new file mode 100644 index 0000000..76fd12c --- /dev/null +++ b/web/src/spawnFormWorktree.test.ts @@ -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((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[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(".project-row.project-other")!.click(); + form + .querySelectorAll(".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("input[name=worktree_enabled]")!.checked).toBe( + true, + ); + + pickProject(form); + await drain(); + + expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false); + expect(form.querySelector(".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(".project-row.project-other")!.click(); + const raw = form.querySelector("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( + 'input[name=worktree_branch_mode][value="existing"]', + )!; + radio.click(); + await drain(); + + const select = form.querySelector(".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( + '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('input[name=worktree_branch_mode][value="existing"]')! + .click(); + + handle.destroy(); + gate.release?.(); + await drain(); + + expect( + handle.el.querySelector(".branch-select")!.options.length, + ).toBe(0); + }); +}); diff --git a/web/src/styles.css b/web/src/styles.css index 5061384..065d7e1 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -831,6 +831,13 @@ textarea { flex: 0 0 auto; } +/* #83: the session row's blocked-work warning — an unforced kill will be + refused, and the operator should see that before pressing the button. */ +.leaf-badge.leaf-blocked { + color: var(--warn, #b45309); + border-color: var(--warn, #b45309); +} + .leaf-actions { position: absolute; right: var(--sp-1); @@ -1205,72 +1212,9 @@ textarea { border-color: var(--accent-strong); } -/* ---------- modal ---------- */ +/* ---------- forms ---------- */ -.modal-backdrop { - position: fixed; - inset: 0; - background: oklch(0.12 0.01 256 / 0.6); - backdrop-filter: blur(3px); - display: flex; - align-items: flex-end; - justify-content: center; - padding: 0; - z-index: var(--z-modal); - animation: fade var(--dur) var(--ease-out); -} - -@media (min-width: 600px) { - .modal-backdrop { - align-items: center; - padding: var(--sp-6); - } -} - -.modal { - background: var(--surface); - border-radius: var(--r-3) var(--r-3) 0 0; - border: 1px solid var(--border); - width: 100%; - max-width: 560px; - max-height: 92vh; - display: flex; - flex-direction: column; - box-shadow: var(--shadow-2); - animation: sheet-up var(--dur-slow) var(--ease-out); -} - -@media (min-width: 600px) { - .modal { - border-radius: var(--r-3); - animation: pop var(--dur) var(--ease-out); - } -} - -.modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--sp-3); - border-bottom: 1px solid var(--border); -} - -.modal-header h2 { - margin: 0; - font-size: var(--fs-15); - font-weight: 600; - display: inline-flex; - align-items: center; - gap: var(--sp-2); -} - -.modal-header h2::before { - content: "+"; - color: var(--accent); - font-weight: 700; -} - -.modal-body { +.spawn-form { padding: var(--sp-3); overflow: auto; display: flex; @@ -1280,7 +1224,7 @@ textarea { scrollbar-color: var(--border-strong) transparent; } -.modal-body label { +.spawn-form label { display: flex; flex-direction: column; gap: 5px; @@ -1289,7 +1233,7 @@ textarea { letter-spacing: 0.01em; } -.modal-body label.row-inline { +.spawn-form label.row-inline { flex-direction: row; align-items: center; gap: var(--sp-2); @@ -1298,9 +1242,9 @@ textarea { cursor: pointer; } -.modal-body input[type="text"], -.modal-body input[type="search"], -.modal-body select { +.spawn-form input[type="text"], +.spawn-form input[type="search"], +.spawn-form select { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-2); @@ -1315,21 +1259,21 @@ textarea { background var(--dur-fast) var(--ease-out); } -.modal-body input[type="text"]:hover, -.modal-body select:hover { +.spawn-form input[type="text"]:hover, +.spawn-form select:hover { border-color: var(--border-strong); } -.modal-body input[type="text"]:focus, -.modal-body input[type="search"]:focus, -.modal-body select:focus { +.spawn-form input[type="text"]:focus, +.spawn-form input[type="search"]:focus, +.spawn-form select:focus { outline: none; border-color: var(--accent); background: var(--surface); box-shadow: 0 0 0 3px var(--accent-tint); } -.modal-body input[type="checkbox"] { +.spawn-form input[type="checkbox"] { width: 18px; height: 18px; accent-color: var(--accent); @@ -1353,13 +1297,6 @@ textarea { font-size: var(--fs-13); } -.modal-actions { - display: flex; - gap: var(--sp-2); - justify-content: flex-end; - margin-top: var(--sp-2); -} - .form-error { color: var(--danger); margin: 0; @@ -1375,7 +1312,7 @@ textarea { /* ---------- spawn form: tiles + recents picker (#27) ---------- */ -.spawn-modal .field { +.spawn-form .field { display: flex; flex-direction: column; gap: var(--sp-2); @@ -1441,7 +1378,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; @@ -1452,7 +1390,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; @@ -1470,18 +1412,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; @@ -1494,12 +1458,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); @@ -1535,6 +1505,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; @@ -1546,6 +1526,13 @@ textarea { flex: 1 1 auto; } +.form-actions { + display: flex; + gap: var(--sp-2); + justify-content: flex-end; + margin-top: var(--sp-2); +} + .reroll { flex: 0 0 auto; } @@ -1554,7 +1541,7 @@ textarea { .dir-ac { position: fixed; - z-index: var(--z-toast); /* above the modal it completes within */ + z-index: var(--z-toast); /* above the draft pane it completes within */ margin: 0; padding: var(--sp-1); list-style: none; @@ -1702,26 +1689,6 @@ textarea { } } -@keyframes fade { - from { - opacity: 0; - } -} - -@keyframes sheet-up { - from { - transform: translateY(16px); - opacity: 0; - } -} - -@keyframes pop { - from { - transform: scale(0.98); - opacity: 0; - } -} - /* ---------- responsive ---------- */ @media (max-width: 520px) { @@ -2056,14 +2023,12 @@ textarea { display: none; } -.mv-pane-empty { +/* Draft pane (#57): an empty pane renders the shared spawn form inline. + The host is already a flex column; the form fills and scrolls within it. */ +.mv-pane-host > .mv-draft { flex: 1; - display: grid; - place-items: center; - padding: var(--sp-4); - color: var(--faint); - font: var(--fs-12) / 1.5 var(--font-mono); - text-align: center; + min-width: 0; + min-height: 0; } /* pane tools cluster (disconnect / split / close), top-right corner. The attach @@ -2267,3 +2232,148 @@ textarea { background: oklch(0 0 0 / 0.5); } } + +/* ---------- retained worktrees (#84) ---------- */ + +.worktrees { + padding: var(--sp-4); + max-width: 960px; + margin: 0 auto; +} + +.wt-head { + margin-bottom: var(--sp-4); +} + +.wt-title { + font-family: var(--font-mono); + font-size: var(--fs-15); + font-weight: 600; + color: var(--ink); + margin: 0 0 var(--sp-1); +} + +.wt-sub { + color: var(--muted); + font-size: var(--fs-13); + margin: 0; +} + +.wt-row { + display: flex; + align-items: baseline; + gap: var(--sp-3); + padding: var(--sp-2) var(--sp-2); + border-bottom: 1px solid var(--surface-2); + font-family: var(--font-mono); + font-size: var(--fs-13); + flex-wrap: wrap; +} + +.wt-branch { + color: var(--ink); + font-weight: 600; +} + +.wt-path { + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wt-reason { + color: var(--faint); +} + +/* Status never rides on color alone: the label itself says what blocks. */ +.wt-way[data-clear] { + color: var(--faint); +} + +.wt-way:not([data-clear]) { + color: var(--warn); +} + +.wt-row .wt-reclaim-btn { + margin-left: auto; +} + +.wt-row .wt-reclaim-btn::before { + content: "["; + color: var(--danger); +} + +.wt-row .wt-reclaim-btn::after { + content: "]"; + color: var(--danger); +} + +.wt-row .wt-reclaim-btn:hover { + color: var(--danger); + background: var(--danger-tint); +} + +.btn-danger { + color: var(--danger); + font-weight: 600; +} + +.btn-danger:hover { + background: var(--danger-tint); +} + +/* The destructive confirmation, centered over the section. */ +.wt-confirm { + position: fixed; + inset: 0; + z-index: var(--z-backdrop); + display: flex; + align-items: center; + justify-content: center; + background: oklch(0 0 0 / 0.5); + padding: var(--sp-4); +} + +.wt-confirm[hidden] { + display: none; +} + +.wt-confirm-panel { + pointer-events: auto; + background: var(--bg); + border: 1px solid var(--surface-2); + padding: var(--sp-4); + max-width: 480px; + font-family: var(--font-sans); + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.wt-confirm-q { + margin: 0; + font-family: var(--font-mono); + font-weight: 600; + color: var(--ink); +} + +.wt-confirm-way { + margin: 0; + font-family: var(--font-mono); + font-size: var(--fs-13); + color: var(--warn); +} + +.wt-confirm-note { + margin: 0; + font-size: var(--fs-13); + color: var(--muted); +} + +.wt-confirm-actions { + display: flex; + gap: var(--sp-2); + justify-content: flex-end; + margin-top: var(--sp-2); +} diff --git a/web/src/types.ts b/web/src/types.ts index 2018dd4..b036b5b 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -31,6 +31,11 @@ export interface Session { * observed it (`#{pane_current_path}`) — distinct from `cwd` (the spawn dir). * Drives the directory-based session tree. Absent until first observed. */ current_cwd?: string | null; + /** What currently blocks an unforced kill of this session (#83), as the + * daemon's watcher last probed it. Absent until probed and for sessions + * without a worktree; all counts default to 0 on the wire for older + * daemons. */ + blocked_work?: InTheWay | null; } /** One directory's immediate subdirectories (NAP-329). */ @@ -83,10 +88,114 @@ 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[]; + +/** Why a retained worktree outlived its session (#80). New values are + * additive; unknown reasons render generically. */ +export type RetainedReason = "force-killed" | "exited-with-unsaved-work"; + +/** What currently blocks an unforced kill of a worktree's session (#80), + * recomputed by the daemon on every read. All counts default to 0 on the + * wire for older daemons. */ +export interface InTheWay { + uncommitted_files?: number; + untracked_files?: number; + unpushed_commits?: number; +} + +/** One entry of `GET /api/worktrees` (#80): a retained worktree with its + * in-the-way state as of this read. Reclaiming it removes the directory but + * never the branch. Mirrors `maestro_protocol::RetainedWorktree`. */ +export interface RetainedWorktree { + id: Uuid; + path: string; + base_repo: string; + branch: string; + reason: RetainedReason; + retained_at: IsoTimestamp; + in_the_way?: InTheWay; +} + +/** The phrasing `maestro_protocol::InTheWay`'s Display impl uses — shared by + * the kill refusal, the TUI and this client, so no two clients can describe + * the same tree differently. Zero parts are omitted; all-zero renders + * "no unsaved work". */ +export function formatInTheWay(w: InTheWay): string { + const parts: string[] = []; + const push = (n: number | undefined, unit: string): void => { + if (!n || n <= 0) return; + parts.push(n === 1 ? `1 ${unit}` : `${n} ${unit}s`); + }; + push(w.uncommitted_files, "uncommitted file"); + push(w.untracked_files, "untracked file"); + push(w.unpushed_commits, "unpushed commit"); + return parts.length > 0 ? parts.join(", ") : "no unsaved work"; +} + +/** Same reason labels the CLI table and the TUI overlay use; unknown future + * reasons render their raw wire value. */ +export function reasonLabel(reason: RetainedReason): string { + return reason; +} + +/** #83: this session's blocked-work breakdown when something actually blocks + * an unforced kill; `null` while clear or not yet probed. Render it with + * {@link formatInTheWay} so every client phrases it identically. */ +export function sessionBlockers(s: Session): InTheWay | null { + const w = s.blocked_work; + if (!w) return null; + const blocked = + (w.uncommitted_files ?? 0) > 0 || + (w.untracked_files ?? 0) > 0 || + (w.unpushed_commits ?? 0) > 0; + return blocked ? w : null; } export interface CreateSessionRequest { diff --git a/web/src/worktrees.test.ts b/web/src/worktrees.test.ts new file mode 100644 index 0000000..f240877 --- /dev/null +++ b/web/src/worktrees.test.ts @@ -0,0 +1,114 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from "vitest"; + +import { createWorktreesView } from "./worktreesView.js"; +import type { RetainedWorktree } from "./types.js"; + +function worktree(overrides: Partial): RetainedWorktree { + return { + id: "11111111-2222-3333-4444-555555555555", + path: "/home/naps/tea/maestro/worktrees/feat/login", + base_repo: "/home/naps/tea/maestro", + branch: "feat/login", + reason: "force-killed", + retained_at: "2026-08-24T10:00:00Z", + in_the_way: { + uncommitted_files: 2, + untracked_files: 1, + unpushed_commits: 3, + }, + ...overrides, + }; +} + +/** Mount a view over a scripted store and return (el, reclaimed-ids). */ +function mount(items: RetainedWorktree[], homeDir = "/home/naps") { + const reclaimed: string[] = []; + const view = createWorktreesView({ + load: async () => items, + reclaim: async (id) => { + reclaimed.push(id); + items.splice( + items.findIndex((w) => w.id === id), + 1, + ); + }, + homeDir: () => homeDir, + }); + document.body.appendChild(view.el); + return { view, reclaimed }; +} + +describe("retained-worktrees view (#84)", () => { + it("lists branch, abbreviated path, reason and what is in the way", async () => { + const { view } = mount([ + worktree({ + path: "/home/naps/tea/maestro/worktrees/feat/login", + }), + ]); + await view.refresh(); + const text = view.el.textContent ?? ""; + expect(text).toContain("feat/login"); + // Sidebar-style abbreviation of the path against the daemon's home dir, + // full path kept in the tooltip. + expect(text).toContain("t/m/w/f/login"); + const pathEl = view.el.querySelector(".wt-path")!; + expect(pathEl.title).toBe("/home/naps/tea/maestro/worktrees/feat/login"); + // Same reason labels as `maestro worktrees list` and the TUI overlay. + expect(text).toContain("force-killed"); + // Same phrasing as InTheWay's Display impl — shared with the kill refusal. + expect(text).toContain("2 uncommitted files, 1 untracked file, 3 unpushed commits"); + }); + + it("says a tree whose work has since been pushed is clear", async () => { + const { view } = mount([worktree({ in_the_way: {} })]); + await view.refresh(); + expect(view.el.textContent).toContain("no unsaved work"); + }); + + it("renders an empty store as a normal state, not an error", async () => { + const { view } = mount([]); + await view.refresh(); + const text = view.el.textContent ?? ""; + expect(text).toContain("no retained worktrees"); + expect(text.toLowerCase()).not.toContain("error"); + expect(text.toLowerCase()).not.toContain("failed"); + }); + + it("guards reclaim with a confirmation that names what is lost and that the branch survives", async () => { + const wt = worktree({ branch: "feat/login" }); + const { view, reclaimed } = mount([wt]); + await view.refresh(); + + view.el.querySelector(".wt-reclaim-btn")!.click(); + const confirmText = view.el.querySelector(".wt-confirm")!.textContent ?? ""; + expect(confirmText).toContain("Reclaim worktree feat/login?"); + // Names what will be lost before it happens… + expect(confirmText).toContain( + "2 uncommitted files, 1 untracked file, 3 unpushed commits", + ); + // …and says reclaim does not take the commits too. + expect(confirmText).toContain("never deleted"); + + view.el.querySelector(".wt-confirm-cancel")!.click(); + expect(reclaimed).toEqual([]); + + view.el.querySelector(".wt-reclaim-btn")!.click(); + view.el.querySelector(".wt-confirm-go")!.click(); + await Promise.resolve(); + await Promise.resolve(); + expect(reclaimed).toEqual(["11111111-2222-3333-4444-555555555555"]); + // The row is gone once the refresh lands. + expect(view.el.textContent).toContain("no retained worktrees"); + }); + + it("sorts oldest-retained first, like every other client", async () => { + const { view } = mount([ + worktree({ branch: "newer", retained_at: "2026-08-24T12:00:00Z" }), + worktree({ branch: "older", retained_at: "2026-08-24T08:00:00Z" }), + ]); + await view.refresh(); + const branches = [...view.el.querySelectorAll(".wt-branch")].map((n) => n.textContent); + expect(branches).toEqual(["older", "newer"]); + }); +}); diff --git a/web/src/worktreesView.ts b/web/src/worktreesView.ts new file mode 100644 index 0000000..2ec67ed --- /dev/null +++ b/web/src/worktreesView.ts @@ -0,0 +1,166 @@ +// Retained-worktrees view (#84): every worktree the daemon is holding that no +// session uses, listed from `GET /api/worktrees` with a reclaim action. +// +// Wording is shared with the CLI (`maestro worktrees list`) and the TUI +// overlay: same reason labels, same in-the-way phrasing via +// `formatInTheWay` (the mirror of `InTheWay`'s Display impl). Reclaim reads +// as what it is — destructive — naming what will be lost before it happens +// and saying out loud that the branch survives (docs/WORKTREES.md). + +import { escapeHtml } from "./html.js"; +import { + abbreviateCwd, + abbreviateFolderName, + formatInTheWay, + reasonLabel, + type RetainedWorktree, + type Uuid, +} from "./types.js"; + +export interface WorktreesDeps { + /** `GET /api/worktrees`. */ + load(): Promise; + /** `DELETE /api/worktrees/:id`. */ + reclaim(id: Uuid): Promise; + /** The daemon's home dir, for `~` abbreviation of paths. */ + homeDir(): string | undefined; +} + +export interface WorktreesViewHandle { + el: HTMLElement; + /** Re-fetch the store and re-render. Safe to call repeatedly. */ + refresh(): Promise; + dispose(): void; +} + +/** Oldest-retained first, matching `maestro worktrees list`'s ordering. */ +function sorted(items: RetainedWorktree[]): RetainedWorktree[] { + return [...items].sort( + (a, b) => Date.parse(a.retained_at) - Date.parse(b.retained_at), + ); +} + +export function createWorktreesView(deps: WorktreesDeps): WorktreesViewHandle { + const el = document.createElement("section"); + el.className = "worktrees"; + el.innerHTML = ` +
+

retained worktrees

+

+ Kept when a session ended with unsaved work. Reclaiming removes the + directory only; branches are never deleted. +

+
+
+ + `; + const listHost = el.querySelector(".wt-list")!; + const confirmEl = el.querySelector(".wt-confirm")!; + + let pendingId: string | null = null; + + const render = (items: RetainedWorktree[]) => { + listHost.replaceChildren(); + if (items.length === 0) { + // A drained store is the normal state — say so plainly, not like + // something went wrong. + const empty = document.createElement("div"); + empty.className = "empty"; + empty.innerHTML = ` + +
no retained worktrees
+

Nothing is being held. Worktrees kept after a force-kill or an unsaved exit show up here.

`; + listHost.appendChild(empty); + return; + } + const home = deps.homeDir(); + for (const w of sorted(items)) { + listHost.appendChild(renderRow(w, home)); + } + }; + + const renderRow = (w: RetainedWorktree, home: string | undefined): HTMLElement => { + const row = document.createElement("div"); + row.className = "wt-row"; + const clear = formatInTheWay(w.in_the_way ?? {}) === "no unsaved work"; + row.innerHTML = ` + ${escapeHtml(w.branch)} + ${abbreviateFolderName(abbreviateCwd(w.path, home))} + ${escapeHtml(reasonLabel(w.reason))} + ${escapeHtml(formatInTheWay(w.in_the_way ?? {}))} + + `; + row.querySelector(".wt-reclaim-btn")!.addEventListener("click", () => { + showConfirm(w); + }); + return row; + }; + + const showConfirm = (w: RetainedWorktree) => { + pendingId = w.id; + // Mirrors the TUI's ReclaimConfirm line for line: what goes away, what is + // in the way right now, and that the branch survives reclaim. + confirmEl.innerHTML = ` +
+

Reclaim worktree ${escapeHtml(w.branch)}?

+

Currently in the way: ${escapeHtml(formatInTheWay(w.in_the_way ?? {}))}

+

The directory is removed; the branch is never deleted.

+
+ + +
+
`; + confirmEl.hidden = false; + confirmEl.querySelector(".wt-confirm-go")!.focus(); + }; + + const hideConfirm = () => { + pendingId = null; + confirmEl.hidden = true; + confirmEl.replaceChildren(); + }; + + confirmEl.addEventListener("click", async (e) => { + const target = e.target as HTMLElement; + if (target.closest(".wt-confirm-cancel")) { + hideConfirm(); + return; + } + if (!target.closest(".wt-confirm-go") || !pendingId) return; + const id = pendingId; + hideConfirm(); + try { + await deps.reclaim(id); + } catch (err) { + alert(`Failed to reclaim: ${(err as Error).message}`); + } + await refresh(); + }); + + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && !confirmEl.hidden) hideConfirm(); + }; + document.addEventListener("keydown", onKey); + + const refresh = async () => { + try { + render(await deps.load()); + } catch (err) { + listHost.replaceChildren(); + const fail = document.createElement("div"); + fail.className = "empty"; + fail.innerHTML = `
Couldn't load retained worktrees

`; + fail.querySelector(".empty-hint")!.textContent = (err as Error).message; + listHost.appendChild(fail); + } + }; + + return { + el, + refresh, + dispose() { + document.removeEventListener("keydown", onKey); + el.remove(); + }, + }; +}