Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74967e45b4 | ||
|
|
7a2cc30bf9 | ||
|
|
8ed44e785d | ||
|
|
63da845614 | ||
|
|
d336bd802d | ||
|
|
67d17f48d0 | ||
|
|
ec45312400 | ||
|
|
525dc9a04a | ||
|
|
264332266c | ||
|
|
37338def71 | ||
|
|
51f91b3f5d | ||
|
|
5c3f9cab9f | ||
|
|
117f371c41 | ||
|
|
ad690ab2fa | ||
|
|
7dcb87cd6e | ||
|
|
26b0fa5e8a | ||
|
|
8b3c5d73b6 | ||
|
|
ca4f7cb1d5 | ||
|
|
cc1b3d45b8 | ||
|
|
f7a8dcae76 | ||
|
|
818e0cc2db | ||
|
|
23f01233b2 | ||
|
|
2a84e07f7d | ||
|
|
a970ba89c2 | ||
|
|
17796c1ffa | ||
|
|
01105f9424 | ||
|
|
b9829bc56f | ||
|
|
9f582263b8 | ||
|
|
b5cbf94fa8 | ||
|
|
8376122dfe | ||
|
|
4a814d5b17 | ||
|
|
1809f5d581 | ||
|
|
ec51b0221c | ||
|
|
ca585447ff | ||
|
|
71301861c0 | ||
|
|
33fb50be6e | ||
|
|
2ce1dc43fe | ||
|
|
b6cf87244f | ||
|
|
e99e21260b | ||
|
|
cea06f9204 | ||
|
|
1199596528 | ||
|
|
6d21061799 | ||
|
|
431f9def6b | ||
|
|
d8acc3adde | ||
|
|
95f0e42df5 | ||
|
|
6f8de8de7c | ||
|
|
018f44727c | ||
|
|
85e7749e5a | ||
|
|
35bc5bfb40 | ||
|
|
059ae86fb8 | ||
|
|
e3996dec76 | ||
|
|
503f01ae26 | ||
|
|
dfc4ee91e1 | ||
|
|
d5485b8f5d |
Generated
+9
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -79,16 +79,15 @@ no bounce. Tokens `--dur-fast`/`--dur`/`--dur-slow`.
|
||||
`connecting` state pill pulses.
|
||||
- Interaction: row hover/focus, button press (1px), input focus ring, chevron
|
||||
rotation, theme-toggle sun/moon swap.
|
||||
- Modal: bottom-sheet slide-up on mobile, scale-pop on ≥600px.
|
||||
- Every animation has a `prefers-reduced-motion: reduce` path (pulses off,
|
||||
transitions near-instant).
|
||||
|
||||
## Layout & structure
|
||||
|
||||
Mobile-first, structural responsiveness (not fluid type). Single `≤520px`
|
||||
breakpoint stacks the header (full-width search, wrapped actions). `≥600px`
|
||||
switches the spawn modal from bottom-sheet to centered dialog. A faint slate grid texture sits on `--bg` as
|
||||
terminal atmosphere (very low contrast, not decoration).
|
||||
breakpoint stacks the header (full-width search, wrapped actions). A faint
|
||||
slate grid texture sits on `--bg` as terminal atmosphere (very low contrast,
|
||||
not decoration).
|
||||
|
||||
## Components
|
||||
|
||||
@@ -101,8 +100,10 @@ Three surfaces, all DOM built in TS, styled by class:
|
||||
no-sessions, no-search-match.
|
||||
- **Attach** — header (back, `session <id>`, live/closed/error state pill) over
|
||||
a framed dark xterm with a green cursor.
|
||||
- **Spawn modal** — bottom-sheet/dialog form; mono inputs with green focus
|
||||
ring; grouped worktree fieldset.
|
||||
- **Draft pane** — the spawn form mounted inline in an empty split pane,
|
||||
focusable like any session pane; mono inputs with green focus ring; grouped
|
||||
worktree fieldset. Spawn opens one (reusing an empty focused pane, else
|
||||
splitting), and "Spawn similar" seeds one from a session's dir + command.
|
||||
|
||||
Interactive elements ship default / hover / focus-visible / active / disabled
|
||||
states; the accent green is used only for action, selection, focus, and live
|
||||
@@ -111,5 +112,5 @@ status — never decoration.
|
||||
## Dev review aids (DEV-only, stripped from production)
|
||||
|
||||
`web/src/mock.ts` + query params seed a representative catalog without a running
|
||||
daemon: `?mock` (seed sessions), `?mock&spawn` (open the spawn modal),
|
||||
daemon: `?mock` (seed sessions), `?mock&spawn` (open a draft pane),
|
||||
`?theme=light|dark` (pin a theme). Guarded by `import.meta.env.DEV`.
|
||||
|
||||
@@ -67,8 +67,15 @@ pub struct SpawnArgs {
|
||||
/// Branch name for the worktree (required with --worktree-from).
|
||||
#[arg(long)]
|
||||
pub branch: Option<String>,
|
||||
/// Remove the worktree when the session is killed.
|
||||
/// Check out an existing branch instead of creating one. Without it the
|
||||
/// daemon runs `worktree add -b`, which fails if the branch is already
|
||||
/// there.
|
||||
#[arg(long)]
|
||||
pub existing_branch: bool,
|
||||
/// Remove the worktree when the session is killed. Kept for wire
|
||||
/// compatibility (#56): the daemon now always cleans up on kill, guarded
|
||||
/// against unsaved work.
|
||||
#[arg(long, default_value_t = true)]
|
||||
pub cleanup_on_kill: bool,
|
||||
/// After spawning, attach immediately.
|
||||
#[arg(long, short = 'a')]
|
||||
@@ -147,6 +154,52 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_on_kill_defaults_true_for_the_wire() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"maestro",
|
||||
"spawn",
|
||||
"--cwd",
|
||||
"/tmp",
|
||||
"--command",
|
||||
"bash",
|
||||
"--worktree-from",
|
||||
"/repos/foo",
|
||||
"--branch",
|
||||
"feat/x",
|
||||
])
|
||||
.unwrap();
|
||||
match cli.command {
|
||||
Command::Spawn(a) => assert!(a.cleanup_on_kill),
|
||||
_ => panic!("wrong subcommand"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Review fix: without a flag the CLI always sent `create_branch: true`,
|
||||
/// so the daemon's existing-branch path was unreachable and spawning onto
|
||||
/// a branch that already existed just failed.
|
||||
#[test]
|
||||
fn parses_spawn_onto_an_existing_branch() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"maestro",
|
||||
"spawn",
|
||||
"--cwd",
|
||||
"/tmp",
|
||||
"--command",
|
||||
"bash",
|
||||
"--worktree-from",
|
||||
"/repo",
|
||||
"--branch",
|
||||
"feat/existing",
|
||||
"--existing-branch",
|
||||
])
|
||||
.unwrap();
|
||||
let Command::Spawn(a) = cli.command else {
|
||||
panic!("expected spawn")
|
||||
};
|
||||
assert!(a.existing_branch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_worktree_requires_branch() {
|
||||
let err = Cli::try_parse_from([
|
||||
|
||||
@@ -44,6 +44,7 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
|
||||
base_repo: repo.clone(),
|
||||
branch: branch.clone(),
|
||||
cleanup_on_kill: args.cleanup_on_kill,
|
||||
create_branch: !args.existing_branch,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
@@ -87,8 +87,9 @@ pub struct DaemonSection {
|
||||
/// Root for everything daemon-local: sidecars, FIFOs, etc.
|
||||
#[serde(default)]
|
||||
pub state_dir: Option<PathBuf>,
|
||||
/// Root for ephemeral worktrees the daemon creates on `CreateSession`
|
||||
/// with a `worktree` spec.
|
||||
/// Override root for ephemeral worktrees the daemon creates on
|
||||
/// `CreateSession` with a `worktree` spec. Unset by default: worktrees
|
||||
/// then live inside their base repo at `<repo>/worktrees/<branch>` (#56).
|
||||
#[serde(default)]
|
||||
pub worktree_root: Option<PathBuf>,
|
||||
/// Per-session PTY replay buffer cap in bytes (NAP-229). Each new attach
|
||||
@@ -161,6 +162,15 @@ pub struct SpawnSection {
|
||||
/// it resolves to just the `custom` tile.
|
||||
#[serde(default)]
|
||||
pub presets: Option<Vec<SpawnPreset>>,
|
||||
/// Root directories the daemon scans for git repositories (#54), `~`
|
||||
/// expanded by the daemon against its own home. When absent the daemon
|
||||
/// scans `["~"]`.
|
||||
#[serde(default)]
|
||||
pub project_roots: Option<Vec<String>>,
|
||||
/// How many directory levels below each [`Self::project_roots`] entry the
|
||||
/// scanner descends (#54). When absent the daemon uses `3`.
|
||||
#[serde(default)]
|
||||
pub project_scan_depth: Option<u32>,
|
||||
}
|
||||
|
||||
impl SpawnSection {
|
||||
@@ -386,6 +396,7 @@ mod tests {
|
||||
label: "fish".into(),
|
||||
command: "fish".into(),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
let ids: Vec<String> = section
|
||||
.resolve()
|
||||
@@ -402,6 +413,7 @@ mod tests {
|
||||
// only the implicit custom tile.
|
||||
let section = SpawnSection {
|
||||
presets: Some(vec![]),
|
||||
..Default::default()
|
||||
};
|
||||
let ids: Vec<String> = section
|
||||
.resolve()
|
||||
@@ -420,6 +432,7 @@ mod tests {
|
||||
label: "x".into(),
|
||||
command: "x".into(),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(empty_id.resolve().unwrap_err(), SpawnConfigError::EmptyId);
|
||||
|
||||
@@ -429,6 +442,7 @@ mod tests {
|
||||
label: "x".into(),
|
||||
command: "".into(),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
empty_cmd.resolve().unwrap_err(),
|
||||
@@ -451,6 +465,7 @@ mod tests {
|
||||
command: "a2".into(),
|
||||
},
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
dup.resolve().unwrap_err(),
|
||||
@@ -463,6 +478,7 @@ mod tests {
|
||||
label: "Custom".into(),
|
||||
command: "whatever".into(),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
reserved.resolve().unwrap_err(),
|
||||
@@ -496,6 +512,30 @@ presets = [
|
||||
assert_eq!(ids, ["claude", "zsh", "custom"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_project_scan_fields_default_to_none() {
|
||||
// Absent [spawn].project_* → None; the daemon applies its own defaults.
|
||||
assert_eq!(SpawnSection::default().project_roots, None);
|
||||
assert_eq!(SpawnSection::default().project_scan_depth, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_project_scan_fields_parse_from_toml() {
|
||||
let cfg = toml::from_str::<MaestroConfigFile>(
|
||||
r#"
|
||||
[spawn]
|
||||
project_roots = ["~/code", "/srv/work"]
|
||||
project_scan_depth = 5
|
||||
"#,
|
||||
)
|
||||
.expect("parse");
|
||||
assert_eq!(
|
||||
cfg.spawn.project_roots,
|
||||
Some(vec!["~/code".to_string(), "/srv/work".to_string()])
|
||||
);
|
||||
assert_eq!(cfg.spawn.project_scan_depth, Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_file_loads_with_section_defaults() {
|
||||
// Only [daemon] present (typical single-host install).
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
//! `GET /api/branches` — local branches of one repository (#55).
|
||||
//!
|
||||
//! Spawning into a worktree on an *existing* branch needs to know which
|
||||
//! branches the repository has and which are taken: git refuses to check the
|
||||
//! same branch out in two worktrees at once. Each [`BranchInfo`] answers for
|
||||
//! one local branch — its name, whether it is the main checkout's HEAD, and
|
||||
//! which linked worktree (if any) holds it.
|
||||
//!
|
||||
//! Read-only, like [`crate::gitroot`]: two git probes (`for-each-ref`,
|
||||
//! `worktree list --porcelain`) with no write path anywhere. A path outside
|
||||
//! any repository yields an empty list rather than an error, matching how
|
||||
//! `/api/gitroot` reports "not a repository".
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use axum::extract::{Query, State};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use maestro_protocol::BranchInfo;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::local_api::LocalApiState;
|
||||
|
||||
/// Query string for `GET /api/branches`. Same `path` convention as
|
||||
/// `/api/dirs` and `/api/gitroot`: resolved against the daemon's home dir.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BranchesQuery {
|
||||
#[serde(default)]
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
pub async fn handler(State(s): State<LocalApiState>, Query(q): Query<BranchesQuery>) -> Response {
|
||||
let dir = crate::dirlist::resolve(&s.home_dir, &q.path);
|
||||
Json(list(&dir)).into_response()
|
||||
}
|
||||
|
||||
/// List the enclosing repository's local branches. Empty when `dir` is not
|
||||
/// inside a repository.
|
||||
pub fn list(dir: &Path) -> Vec<BranchInfo> {
|
||||
let Some(root) = crate::gitroot::find_root(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let worktrees = worktree_branches(&root);
|
||||
// The first porcelain record is always the main checkout; its branch is
|
||||
// the HEAD answer. Linked worktrees are the occupancy map.
|
||||
let head_branch = worktrees.first().and_then(|(branch, _)| branch.clone());
|
||||
let occupied: HashMap<&str, &Path> = worktrees
|
||||
.iter()
|
||||
.skip(1)
|
||||
.filter_map(|(branch, path)| branch.as_deref().map(|b| (b, path.as_path())))
|
||||
.collect();
|
||||
|
||||
branch_names(&root)
|
||||
.into_iter()
|
||||
.map(|name| BranchInfo {
|
||||
is_head: head_branch.as_deref() == Some(name.as_str()),
|
||||
worktree: occupied.get(name.as_str()).copied().map(Path::to_path_buf),
|
||||
name,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run_git(root: &Path, args: &[&str]) -> Option<String> {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(root)
|
||||
.args(args)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
fn branch_names(root: &Path) -> Vec<String> {
|
||||
run_git(
|
||||
root,
|
||||
&["for-each-ref", "refs/heads", "--format=%(refname:short)"],
|
||||
)
|
||||
.map(|out| {
|
||||
out.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn worktree_branches(root: &Path) -> Vec<(Option<String>, PathBuf)> {
|
||||
let out = match run_git(root, &["worktree", "list", "--porcelain"]) {
|
||||
Some(out) => out,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
parse_worktrees(&out)
|
||||
}
|
||||
|
||||
/// Parse `git worktree list --porcelain` into `(branch, toplevel)` records,
|
||||
/// branch-less for detached/bare checkouts. Records are separated by blank
|
||||
/// lines; each `worktree` line starts a new one.
|
||||
fn parse_worktrees(out: &str) -> Vec<(Option<String>, PathBuf)> {
|
||||
let mut result: Vec<(Option<String>, PathBuf)> = Vec::new();
|
||||
let mut pending: Option<PathBuf> = None;
|
||||
let mut branch: Option<String> = None;
|
||||
|
||||
for line in out.lines() {
|
||||
if let Some(path) = line.strip_prefix("worktree ") {
|
||||
if let Some(prev) = pending.take() {
|
||||
result.push((branch.take(), prev));
|
||||
}
|
||||
pending = Some(PathBuf::from(path));
|
||||
} else if let Some(b) = line.strip_prefix("branch ") {
|
||||
branch = Some(b.trim_start_matches("refs/heads/").to_owned());
|
||||
}
|
||||
}
|
||||
if let Some(path) = pending {
|
||||
result.push((branch.take(), path));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_main_and_linked_worktrees() {
|
||||
let out = "\
|
||||
worktree /repos/maestro
|
||||
HEAD 0123abcd
|
||||
branch refs/heads/main
|
||||
|
||||
worktree /repos/wt-feature
|
||||
HEAD 4567abcd
|
||||
branch refs/heads/spawn-v2
|
||||
|
||||
";
|
||||
assert_eq!(
|
||||
parse_worktrees(out),
|
||||
vec![
|
||||
(Some("main".into()), PathBuf::from("/repos/maestro")),
|
||||
(Some("spawn-v2".into()), PathBuf::from("/repos/wt-feature")),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detached_head_yields_no_branch() {
|
||||
let out = "\
|
||||
worktree /repos/detached
|
||||
HEAD 0123abcd
|
||||
detached
|
||||
|
||||
";
|
||||
assert_eq!(
|
||||
parse_worktrees(out),
|
||||
vec![(None, PathBuf::from("/repos/detached"))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_output_yields_no_records() {
|
||||
assert!(parse_worktrees("").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,8 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
||||
let _ = default_state_dir();
|
||||
std::fs::create_dir_all(config.sidecar_dir())?;
|
||||
std::fs::create_dir_all(config.fifo_dir())?;
|
||||
std::fs::create_dir_all(&config.worktree_root)?;
|
||||
// No worktree dir to pre-create: without an override the worktrees live
|
||||
// under their base repos, created on demand (#56).
|
||||
|
||||
let sidecar = SidecarStore::new(config.sidecar_dir());
|
||||
let worktree_mgr = WorktreeManager::new(config.worktree_root.clone());
|
||||
@@ -71,11 +72,19 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
||||
tracing::info!(recovered, "adopted existing tmux sessions");
|
||||
manager.start_liveness_watcher(DEFAULT_LIVENESS_INTERVAL);
|
||||
|
||||
let projects = std::sync::Arc::new(crate::projects::ProjectScanner::new(
|
||||
config.project_roots.clone(),
|
||||
config.project_scan_depth,
|
||||
PathBuf::from(&config.home_dir),
|
||||
));
|
||||
projects.start_background_refresh();
|
||||
|
||||
let router = local_api::router(local_api::state_for(
|
||||
manager,
|
||||
config.hostname.clone(),
|
||||
config.home_dir.clone(),
|
||||
config.spawn_presets.clone(),
|
||||
projects,
|
||||
));
|
||||
|
||||
let listener = TcpListener::bind(config.bind_addr).await?;
|
||||
|
||||
@@ -23,6 +23,14 @@ pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:7070";
|
||||
/// `[daemon].spawn_history_days` is unset. `0` in config disables history.
|
||||
pub const DEFAULT_SPAWN_HISTORY_DAYS: u32 = 30;
|
||||
|
||||
/// Default scan roots for project discovery (#54) when `[spawn].project_roots`
|
||||
/// is unset: the whole home directory, with the scanner's own ignore list
|
||||
/// keeping it cheap.
|
||||
pub const DEFAULT_PROJECT_ROOTS: [&str; 1] = ["~"];
|
||||
/// Default directory depth for project discovery (#54) when
|
||||
/// `[spawn].project_scan_depth` is unset.
|
||||
pub const DEFAULT_PROJECT_SCAN_DEPTH: u32 = 3;
|
||||
|
||||
/// Env var that overrides `[daemon].bind_addr`.
|
||||
pub const ENV_DAEMON_BIND_ADDR: &str = "MAESTRO_DAEMON_BIND_ADDR";
|
||||
/// Env var that overrides `[daemon].tmux_tmpdir`.
|
||||
@@ -53,7 +61,10 @@ pub struct DaemonConfig {
|
||||
pub home_dir: String,
|
||||
pub bind_addr: SocketAddr,
|
||||
pub state_dir: PathBuf,
|
||||
pub worktree_root: PathBuf,
|
||||
/// `[daemon].worktree_root` override (#56). `None` — the default — places
|
||||
/// worktrees inside their base repo at `<repo>/worktrees/<branch>`;
|
||||
/// operators who want them elsewhere set this.
|
||||
pub worktree_root: Option<PathBuf>,
|
||||
/// Per-session PTY replay buffer cap in bytes (NAP-229). Each new attach
|
||||
/// receives this much of the prior PTY output before the live stream.
|
||||
/// Default `pty::DEFAULT_REPLAY_BYTES`; `0` disables.
|
||||
@@ -71,6 +82,12 @@ pub struct DaemonConfig {
|
||||
/// built-ins) with the implicit `custom` tile appended. Advertised to
|
||||
/// clients in `GET /api/info`.
|
||||
pub spawn_presets: Vec<maestro_config::SpawnPreset>,
|
||||
/// Project-discovery scan roots (#54): `[spawn].project_roots` (default
|
||||
/// [`DEFAULT_PROJECT_ROOTS`]) with `~` expanded against the daemon's home.
|
||||
pub project_roots: Vec<PathBuf>,
|
||||
/// Project-discovery depth (#54): `[spawn].project_scan_depth`, default
|
||||
/// [`DEFAULT_PROJECT_SCAN_DEPTH`].
|
||||
pub project_scan_depth: u32,
|
||||
}
|
||||
|
||||
impl DaemonConfig {
|
||||
@@ -131,9 +148,6 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
|
||||
Some(p) => p,
|
||||
None => default_state_dir()?,
|
||||
};
|
||||
let worktree_root = daemon
|
||||
.worktree_root
|
||||
.unwrap_or_else(|| state_dir.join("worktrees"));
|
||||
|
||||
let pty_replay_bytes = daemon
|
||||
.pty_replay_bytes
|
||||
@@ -147,19 +161,53 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
|
||||
.spawn_history_days
|
||||
.unwrap_or(DEFAULT_SPAWN_HISTORY_DAYS);
|
||||
|
||||
// `~` in scan roots means the daemon's own home; an unresolvable home
|
||||
// leaves the tilde as-is and the scanner treats the root as missing.
|
||||
let project_roots: Vec<PathBuf> = file
|
||||
.spawn
|
||||
.project_roots
|
||||
.unwrap_or_else(|| {
|
||||
DEFAULT_PROJECT_ROOTS
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
})
|
||||
.iter()
|
||||
.map(|root| expand_tilde(root, &home_dir))
|
||||
.collect();
|
||||
let project_scan_depth = file
|
||||
.spawn
|
||||
.project_scan_depth
|
||||
.unwrap_or(DEFAULT_PROJECT_SCAN_DEPTH);
|
||||
|
||||
Ok(DaemonConfig {
|
||||
hostname,
|
||||
home_dir,
|
||||
bind_addr,
|
||||
state_dir,
|
||||
worktree_root,
|
||||
worktree_root: daemon.worktree_root,
|
||||
pty_replay_bytes,
|
||||
tmux_tmpdir,
|
||||
spawn_history_days,
|
||||
spawn_presets,
|
||||
project_roots,
|
||||
project_scan_depth,
|
||||
})
|
||||
}
|
||||
|
||||
/// Expand a leading `~` (or `~/…`) against `home`. Other paths pass through.
|
||||
fn expand_tilde(path: &str, home: &str) -> PathBuf {
|
||||
match path.strip_prefix("~") {
|
||||
Some(_) if home.is_empty() => PathBuf::from(path),
|
||||
Some(rest) => {
|
||||
let mut p = PathBuf::from(home);
|
||||
p.push(rest.trim_start_matches('/'));
|
||||
p
|
||||
}
|
||||
None => PathBuf::from(path),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_state_dir() -> Result<PathBuf, ConfigError> {
|
||||
Ok(maestro_config::default_state_dir("maestro")?)
|
||||
}
|
||||
@@ -188,7 +236,7 @@ mod tests {
|
||||
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||
assert_eq!(cfg.bind_addr, default_bind_addr());
|
||||
assert!(cfg.state_dir.ends_with("maestro"));
|
||||
assert!(cfg.worktree_root.ends_with("worktrees"));
|
||||
assert!(cfg.worktree_root.is_none());
|
||||
assert!(!cfg.hostname.is_empty());
|
||||
}
|
||||
|
||||
@@ -280,6 +328,56 @@ spawn_history_days = 0
|
||||
assert_eq!(cfg.spawn_history_days, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_scan_defaults_to_home_root_at_depth_3() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(&path, "").unwrap();
|
||||
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||
assert_eq!(cfg.project_scan_depth, DEFAULT_PROJECT_SCAN_DEPTH);
|
||||
assert_eq!(
|
||||
cfg.project_roots,
|
||||
vec![PathBuf::from(cfg.home_dir.clone())],
|
||||
"bare ~ must expand to the daemon's home"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_scan_roots_expand_tilde_and_pass_through_absolutes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
[spawn]
|
||||
project_roots = ["~/code", "/srv/work"]
|
||||
project_scan_depth = 5
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||
assert_eq!(cfg.project_scan_depth, 5);
|
||||
assert_eq!(
|
||||
cfg.project_roots,
|
||||
vec![
|
||||
PathBuf::from(cfg.home_dir.clone()).join("code"),
|
||||
PathBuf::from("/srv/work"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilde_expansion_handles_bare_and_nested_forms() {
|
||||
assert_eq!(expand_tilde("~", "/home/u"), PathBuf::from("/home/u"));
|
||||
assert_eq!(
|
||||
expand_tilde("~/a/b", "/home/u"),
|
||||
PathBuf::from("/home/u/a/b")
|
||||
);
|
||||
assert_eq!(expand_tilde("/abs", "/home/u"), PathBuf::from("/abs"));
|
||||
// Unresolvable home leaves the tilde as-is rather than mangling it.
|
||||
assert_eq!(expand_tilde("~", ""), PathBuf::from("~"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_hostname_is_nonempty() {
|
||||
let h = system_hostname();
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
pub mod activity;
|
||||
pub mod attach;
|
||||
pub mod branches;
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
pub mod dirlist;
|
||||
@@ -16,6 +17,7 @@ pub mod gitroot;
|
||||
pub mod local_api;
|
||||
pub mod panes;
|
||||
pub mod proc;
|
||||
pub mod projects;
|
||||
pub mod pty;
|
||||
pub mod session;
|
||||
pub mod sidecar;
|
||||
|
||||
@@ -19,7 +19,7 @@ use axum::routing::{any, delete, get};
|
||||
use axum::{Json, Router};
|
||||
use maestro_config::SpawnPreset;
|
||||
use maestro_protocol::{
|
||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, Session,
|
||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch, Session,
|
||||
PROTOCOL_VERSION,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
@@ -36,6 +36,8 @@ pub struct LocalApiState {
|
||||
pub home_dir: Arc<String>,
|
||||
/// Resolved spawn presets (#22/#26) advertised in `GET /api/info`.
|
||||
pub presets: Arc<Vec<SpawnPreset>>,
|
||||
/// Project discovery (#54) backing `GET /api/projects`.
|
||||
pub projects: Arc<crate::projects::ProjectScanner>,
|
||||
}
|
||||
|
||||
pub fn router(state: LocalApiState) -> Router {
|
||||
@@ -44,7 +46,10 @@ pub fn router(state: LocalApiState) -> Router {
|
||||
.route(routes::patterns::SESSION, delete(kill_session))
|
||||
.route(routes::DIRS, get(list_dir))
|
||||
.route(routes::RECENT_DIRS, get(recent_dirs))
|
||||
.route(routes::PROJECTS, get(projects))
|
||||
.route(routes::GITROOT, get(git_root))
|
||||
.route(routes::BRANCHES, get(crate::branches::handler))
|
||||
.route(routes::NAMES, get(names))
|
||||
.route(routes::INFO, get(daemon_info))
|
||||
.route(routes::EVENTS, any(events_ws))
|
||||
.route(routes::patterns::ATTACH, any(attach_ws))
|
||||
@@ -98,6 +103,15 @@ async fn recent_dirs(State(s): State<LocalApiState>) -> Response {
|
||||
Json(crate::spawn_history::to_wire(s.manager.recent_dirs())).into_response()
|
||||
}
|
||||
|
||||
/// Discovered git repositories and worktrees, newest activity first (#54).
|
||||
/// `last_activity` is the later of the repo's git HEAD mtime and the newest
|
||||
/// spawn-history use of any directory inside it.
|
||||
async fn projects(State(s): State<LocalApiState>) -> Response {
|
||||
let repos = s.projects.repos().await;
|
||||
let history = s.manager.all_recent_dirs();
|
||||
Json(crate::projects::to_projects(&repos, &history)).into_response()
|
||||
}
|
||||
|
||||
/// Probe the enclosing git repo of `path`, to infer the worktree `base_repo`
|
||||
/// for the spawn form (#26). `path` is resolved against the daemon's home dir
|
||||
/// (empty/`~`/relative), like [`list_dir`].
|
||||
@@ -109,6 +123,22 @@ async fn git_root(State(s): State<LocalApiState>, Query(q): Query<DirQuery>) ->
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Query string for `GET /api/names`. `count` defaults to 1 and is capped at
|
||||
/// [`maestro_names::MAX_BATCH`] (#53).
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NamesQuery {
|
||||
count: Option<usize>,
|
||||
}
|
||||
|
||||
/// Fresh session handles for the spawn form's instant reroll (#53). The web
|
||||
/// client fetches one batch per draft pane and rerolls locally from it.
|
||||
async fn names(Query(q): Query<NamesQuery>) -> Json<NameBatch> {
|
||||
let count = q.count.unwrap_or(1).clamp(1, maestro_names::MAX_BATCH);
|
||||
Json(NameBatch {
|
||||
names: maestro_names::generate_batch(count),
|
||||
})
|
||||
}
|
||||
|
||||
async fn daemon_info(State(s): State<LocalApiState>) -> Json<DaemonInfo> {
|
||||
Json(DaemonInfo {
|
||||
hostname: (*s.hostname).clone(),
|
||||
@@ -157,18 +187,23 @@ fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
|
||||
}
|
||||
|
||||
/// Convenience constructor — pulls the bits `LocalApiState` needs from
|
||||
/// `DaemonConfig` so callers don't have to assemble it by hand.
|
||||
/// `DaemonConfig` so callers don't have to assemble it by hand. The project
|
||||
/// scanner starts with its cache cold; wire
|
||||
/// [`ProjectScanner::start_background_refresh`](crate::projects::ProjectScanner::start_background_refresh)
|
||||
/// on the returned handle's scanner to keep it eventually fresh.
|
||||
pub fn state_for(
|
||||
manager: SessionManager,
|
||||
hostname: impl Into<String>,
|
||||
home_dir: impl Into<String>,
|
||||
presets: Vec<SpawnPreset>,
|
||||
projects: Arc<crate::projects::ProjectScanner>,
|
||||
) -> LocalApiState {
|
||||
LocalApiState {
|
||||
manager,
|
||||
hostname: Arc::new(hostname.into()),
|
||||
home_dir: Arc::new(home_dir.into()),
|
||||
presets: Arc::new(presets),
|
||||
projects,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
//! 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<PathBuf>,
|
||||
/// Mtime of the checkout's git HEAD — "someone committed / checked out
|
||||
/// here". The later-of merge with spawn history happens in
|
||||
/// [`to_projects`], which also sorts.
|
||||
pub head_mtime: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Repo toplevels under each root of `roots`, at most `max_depth` levels deep.
|
||||
/// A dir holding `.git` (dir *or* file) is a hit and is not descended into;
|
||||
/// hidden directories are never entered, and `DEFAULT_HOME_IGNORE` names are
|
||||
/// skipped directly under `home`.
|
||||
pub fn scan_roots(roots: &[PathBuf], max_depth: u32, home: &Path) -> Vec<PathBuf> {
|
||||
let mut found = Vec::new();
|
||||
for root in roots {
|
||||
walk(root, 0, max_depth, home, &mut found);
|
||||
}
|
||||
found.sort();
|
||||
found.dedup();
|
||||
found
|
||||
}
|
||||
|
||||
fn walk(dir: &Path, depth: u32, max_depth: u32, home: &Path, found: &mut Vec<PathBuf>) {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
let mut subdirs = Vec::new();
|
||||
let mut has_git = false;
|
||||
for entry in entries.flatten() {
|
||||
match entry.file_name().to_string_lossy().into_owned() {
|
||||
name if name == ".git" => has_git = true,
|
||||
name if name.starts_with('.') => {}
|
||||
name if dir == home && DEFAULT_HOME_IGNORE.contains(&name.as_str()) => {}
|
||||
_ => {
|
||||
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
||||
subdirs.push(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if has_git {
|
||||
// Match `git worktree list` output, which prints real paths.
|
||||
found.push(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
|
||||
return;
|
||||
}
|
||||
if depth >= max_depth {
|
||||
return;
|
||||
}
|
||||
for sub in subdirs {
|
||||
walk(&sub, depth + 1, max_depth, home, found);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every discovered project: the scanned repos plus all worktrees their
|
||||
/// `git worktree list` reports (linked worktrees can live outside the roots).
|
||||
/// A hit whose git invocation fails still lists, un-worktreed.
|
||||
pub fn discover(roots: &[PathBuf], max_depth: u32, home: &Path) -> Vec<DiscoveredRepo> {
|
||||
let scanned = scan_roots(roots, max_depth, home);
|
||||
let mut map: Vec<(PathBuf, PathBuf)> = Vec::new(); // (checkout, main)
|
||||
for repo in scanned {
|
||||
if map.iter().any(|(c, _)| c == &repo) {
|
||||
continue;
|
||||
}
|
||||
let worktrees = list_worktrees(&repo).unwrap_or_else(|| vec![repo.clone()]);
|
||||
let main = worktrees[0].clone();
|
||||
for wt in worktrees {
|
||||
if !map.iter().any(|(c, _)| c == &wt) {
|
||||
map.push((wt, main.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
map.into_iter()
|
||||
.map(|(path, main)| {
|
||||
let is_worktree = path != main;
|
||||
DiscoveredRepo {
|
||||
name: path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default(),
|
||||
is_worktree,
|
||||
base_repo: is_worktree.then_some(main),
|
||||
head_mtime: head_mtime(&path),
|
||||
path,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `git worktree list --porcelain` from `repo`: absolute paths, main first.
|
||||
/// `None` when git fails (broken/bare checkout).
|
||||
fn list_worktrees(repo: &Path) -> Option<Vec<PathBuf>> {
|
||||
let out = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(["worktree", "list", "--porcelain"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let mut paths = Vec::new();
|
||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||
if let Some(p) = line.strip_prefix("worktree ") {
|
||||
paths.push(PathBuf::from(p));
|
||||
}
|
||||
}
|
||||
(!paths.is_empty()).then_some(paths)
|
||||
}
|
||||
|
||||
/// Mtime of the checkout's reflog, falling back to `HEAD` itself.
|
||||
///
|
||||
/// `HEAD` is only rewritten on checkout — committing updates
|
||||
/// `refs/heads/<branch>` and appends to `logs/HEAD`, leaving `HEAD` alone. A
|
||||
/// repo worked on daily from one long-lived branch would otherwise report its
|
||||
/// last *branch switch* as its activity and get dimmed as stale (#54 review).
|
||||
///
|
||||
/// For a linked worktree `.git` is a file holding a `gitdir:` pointer;
|
||||
/// resolve it so worktrees report their own reflog rather than nothing.
|
||||
fn head_mtime(repo: &Path) -> Option<DateTime<Utc>> {
|
||||
let dot_git = repo.join(".git");
|
||||
let head = if dot_git.is_dir() {
|
||||
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::<Utc>::from(mtime))
|
||||
}
|
||||
|
||||
/// Fold spawn history into the discovery snapshot and produce the wire list,
|
||||
/// sorted newest-activity-first. `history` must be newest-first (what
|
||||
/// [`SpawnHistory::all_recent_dirs`](crate::spawn_history::SpawnHistory)
|
||||
/// returns): the first entry whose cwd sits inside a project is its most
|
||||
/// recent use.
|
||||
pub fn to_projects(
|
||||
repos: &[DiscoveredRepo],
|
||||
history: &[RecentDir],
|
||||
) -> Vec<maestro_protocol::Project> {
|
||||
let mut projects: Vec<maestro_protocol::Project> = repos
|
||||
.iter()
|
||||
.map(|repo| {
|
||||
let last_use = history.iter().find(|h| contains(&repo.path, &h.cwd));
|
||||
maestro_protocol::Project {
|
||||
path: repo.path.clone(),
|
||||
name: repo.name.clone(),
|
||||
is_worktree: repo.is_worktree,
|
||||
base_repo: repo.base_repo.clone(),
|
||||
last_activity: max_opt(repo.head_mtime, last_use.map(|u| u.last_used)),
|
||||
last_command: last_use.map(|u| u.last_command.clone()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
projects.sort_by_key(|p| std::cmp::Reverse(p.last_activity));
|
||||
projects
|
||||
}
|
||||
|
||||
/// Whether `dir` is `project` itself or anywhere inside it.
|
||||
fn contains(project: &Path, dir: &Path) -> bool {
|
||||
dir == project || dir.starts_with(project)
|
||||
}
|
||||
|
||||
fn max_opt(a: Option<DateTime<Utc>>, b: Option<DateTime<Utc>>) -> Option<DateTime<Utc>> {
|
||||
match (a, b) {
|
||||
(Some(a), Some(b)) => Some(a.max(b)),
|
||||
(a, b) => a.or(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached discovery behind a short TTL plus an optional slow background
|
||||
/// refresh. Clone-free shared handle via `Arc` in [`crate::local_api`].
|
||||
#[derive(Debug)]
|
||||
pub struct ProjectScanner {
|
||||
roots: Vec<PathBuf>,
|
||||
max_depth: u32,
|
||||
home: PathBuf,
|
||||
ttl: Duration,
|
||||
cache: tokio::sync::Mutex<Option<(Instant, std::sync::Arc<Vec<DiscoveredRepo>>)>>,
|
||||
}
|
||||
|
||||
impl ProjectScanner {
|
||||
/// Scanner over the resolved config roots. An empty `roots` list scans
|
||||
/// nothing and always serves an empty list.
|
||||
pub fn new(roots: Vec<PathBuf>, max_depth: u32, home: PathBuf) -> Self {
|
||||
Self {
|
||||
roots,
|
||||
max_depth,
|
||||
home,
|
||||
ttl: CACHE_TTL,
|
||||
cache: tokio::sync::Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Scanner that never discovers anything — tests and embedders that don't
|
||||
/// want the endpoint populated.
|
||||
pub fn disabled() -> Self {
|
||||
Self::new(Vec::new(), 0, PathBuf::new())
|
||||
}
|
||||
|
||||
/// Cached snapshot, rescanning (off-thread) when the TTL has lapsed.
|
||||
pub async fn repos(&self) -> std::sync::Arc<Vec<DiscoveredRepo>> {
|
||||
let mut cache = self.cache.lock().await;
|
||||
if let Some((at, repos)) = cache.as_ref() {
|
||||
if at.elapsed() < self.ttl {
|
||||
return repos.clone();
|
||||
}
|
||||
}
|
||||
let roots = self.roots.clone();
|
||||
let max_depth = self.max_depth;
|
||||
let home = self.home.clone();
|
||||
// Blocking fs walk + git subprocesses must not stall the reactor.
|
||||
let repos = std::sync::Arc::new(
|
||||
tokio::task::spawn_blocking(move || discover(&roots, max_depth, &home))
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
*cache = Some((Instant::now(), repos.clone()));
|
||||
repos
|
||||
}
|
||||
|
||||
/// Rescan now, ignoring the TTL. Called by the background timer.
|
||||
async fn refresh(&self) {
|
||||
self.repos().await;
|
||||
}
|
||||
|
||||
/// Spawn the slow background refresh task. Drop the returned handle to
|
||||
/// detach it; it stops when the runtime shuts down.
|
||||
pub fn start_background_refresh(self: &std::sync::Arc<Self>) -> tokio::task::JoinHandle<()> {
|
||||
let scanner = std::sync::Arc::clone(self);
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(REFRESH_INTERVAL);
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
ticker.tick().await; // the immediate first tick — wait a full interval
|
||||
loop {
|
||||
scanner.refresh().await;
|
||||
ticker.tick().await;
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::spawn_history::RecentDir;
|
||||
|
||||
/// Real git repo at `<root>/<name>`, one initial commit so HEAD exists.
|
||||
fn make_repo(root: &Path, name: &str) -> PathBuf {
|
||||
let path = root.join(name);
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
run_git(&path, &["init", "-q"]);
|
||||
std::fs::write(path.join("f.txt"), "x\n").unwrap();
|
||||
run_git(&path, &["add", "-A"]);
|
||||
run_git(
|
||||
&path,
|
||||
&[
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"commit",
|
||||
"-qm",
|
||||
"init",
|
||||
],
|
||||
);
|
||||
path
|
||||
}
|
||||
|
||||
fn run_git(dir: &Path, args: &[&str]) {
|
||||
let out = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(dir)
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {args:?} failed: {} | {}",
|
||||
String::from_utf8_lossy(&out.stderr),
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
);
|
||||
}
|
||||
|
||||
fn names(repos: &[DiscoveredRepo]) -> Vec<String> {
|
||||
let mut v: Vec<String> = repos.iter().map(|r| r.name.clone()).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_finds_repos_and_stops_at_the_first_hit() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let outer = make_repo(tmp.path(), "outer");
|
||||
// A nested independent repo inside `outer` is NOT discovered
|
||||
// separately: descent stops once a repo is found.
|
||||
make_repo(&outer, "vendored");
|
||||
std::fs::create_dir_all(tmp.path().join("plain/deep/deeper")).unwrap();
|
||||
|
||||
let found = scan_roots(
|
||||
&[tmp.path().to_path_buf()],
|
||||
3,
|
||||
Path::new("/nonexistent-home"),
|
||||
);
|
||||
assert_eq!(found, vec![outer]);
|
||||
|
||||
let discovered = discover(
|
||||
&[tmp.path().to_path_buf()],
|
||||
3,
|
||||
Path::new("/nonexistent-home"),
|
||||
);
|
||||
assert_eq!(names(&discovered), ["outer"]);
|
||||
assert!(!discovered[0].is_worktree);
|
||||
assert_eq!(discovered[0].base_repo, None);
|
||||
assert!(discovered[0].head_mtime.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_respects_depth_hidden_dirs_and_home_ignore() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path().join("home");
|
||||
|
||||
// Hidden dirs are never entered, at any depth.
|
||||
std::fs::create_dir_all(home.join(".secret/.git")).unwrap();
|
||||
// node_modules directly under home is not descended…
|
||||
let _ignored = make_repo(&home.join("node_modules"), "dep");
|
||||
// …but the same name deeper down is fair game.
|
||||
let kept = make_repo(&home.join("code/node_modules"), "dep2");
|
||||
|
||||
// Depth cap: a repo three levels below the root is the last tier —
|
||||
// found at depth 3, but a fourth-level repo isn't.
|
||||
let at_three = home.join("a/b/c");
|
||||
std::fs::create_dir_all(at_three.join(".git")).unwrap();
|
||||
std::fs::create_dir_all(home.join("a/b/c/d/.git")).unwrap();
|
||||
|
||||
let found = scan_roots(std::slice::from_ref(&home), 3, &home);
|
||||
assert!(found.iter().all(|p| !p.starts_with(home.join(".secret"))));
|
||||
assert!(found
|
||||
.iter()
|
||||
.all(|p| !p.starts_with(home.join("node_modules"))));
|
||||
assert!(found.contains(&kept.canonicalize().unwrap()));
|
||||
assert!(found.contains(&at_three.canonicalize().unwrap()));
|
||||
assert!(!found.iter().any(|p| p.ends_with("d")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktrees_outside_the_roots_are_discovered_via_git_worktree_list() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("scan-root");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let main = make_repo(&root, "main-repo");
|
||||
// Linked worktree deliberately outside the scan root.
|
||||
let wt = tmp.path().join("elsewhere/wt-of-main");
|
||||
std::fs::create_dir_all(wt.parent().unwrap()).unwrap();
|
||||
run_git(
|
||||
&main,
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
"-q",
|
||||
"-b",
|
||||
"feature",
|
||||
&wt.to_string_lossy(),
|
||||
],
|
||||
);
|
||||
|
||||
let discovered = discover(&[root], 3, Path::new("/nonexistent-home"));
|
||||
assert_eq!(names(&discovered), ["main-repo", "wt-of-main"]);
|
||||
let wt_entry = discovered.iter().find(|d| d.name == "wt-of-main").unwrap();
|
||||
assert!(wt_entry.is_worktree);
|
||||
assert_eq!(
|
||||
wt_entry.base_repo.as_deref(),
|
||||
Some(main.canonicalize().unwrap().as_path())
|
||||
);
|
||||
let main_entry = discovered.iter().find(|d| d.name == "main-repo").unwrap();
|
||||
assert!(!main_entry.is_worktree);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_mtime_resolves_a_worktrees_git_file_pointer() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let main = make_repo(tmp.path(), "m");
|
||||
let wt = tmp.path().join("wt");
|
||||
run_git(
|
||||
&main,
|
||||
&["worktree", "add", "-q", "-b", "b2", &wt.to_string_lossy()],
|
||||
);
|
||||
assert!(head_mtime(&wt).is_some());
|
||||
}
|
||||
|
||||
fn recent(cwd: &str, cmd: &str, at: &str) -> RecentDir {
|
||||
RecentDir {
|
||||
cwd: PathBuf::from(cwd),
|
||||
last_command: cmd.into(),
|
||||
last_used: DateTime::parse_from_rfc3339(at)
|
||||
.unwrap()
|
||||
.with_timezone(&Utc),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_projects_merges_history_and_sorts_newest_first() {
|
||||
let repo_old = DiscoveredRepo {
|
||||
path: PathBuf::from("/w/old"),
|
||||
name: "old".into(),
|
||||
is_worktree: false,
|
||||
base_repo: None,
|
||||
// Committed recently but never spawned in.
|
||||
head_mtime: Some(t("2026-08-23T10:00:00Z")),
|
||||
};
|
||||
let repo_hist = DiscoveredRepo {
|
||||
path: PathBuf::from("/w/hist"),
|
||||
name: "hist".into(),
|
||||
is_worktree: false,
|
||||
base_repo: None,
|
||||
// Old commit…
|
||||
head_mtime: Some(t("2026-08-01T10:00:00Z")),
|
||||
};
|
||||
let repo_stale = DiscoveredRepo {
|
||||
path: PathBuf::from("/w/stale"),
|
||||
name: "stale".into(),
|
||||
is_worktree: false,
|
||||
base_repo: None,
|
||||
head_mtime: None,
|
||||
};
|
||||
|
||||
let history = vec![
|
||||
// Newest overall: a subdir of /w/hist (first match wins for it).
|
||||
recent("/w/hist/crates", "opencode", "2026-08-22T09:00:00Z"),
|
||||
recent("/w/hist", "claude", "2026-08-20T09:00:00Z"),
|
||||
];
|
||||
let projects = to_projects(&[repo_old, repo_hist, repo_stale], &history);
|
||||
|
||||
let paths: Vec<&str> = projects.iter().map(|p| p.path.to_str().unwrap()).collect();
|
||||
assert_eq!(paths, ["/w/old", "/w/hist", "/w/stale"]);
|
||||
|
||||
// old: git activity wins (never spawned).
|
||||
assert_eq!(projects[0].last_activity, Some(t("2026-08-23T10:00:00Z")));
|
||||
assert_eq!(projects[0].last_command, None);
|
||||
// hist: spawn history beats the old commit; command from the newest
|
||||
// matching entry (the crates/ one, not the repo root one).
|
||||
assert_eq!(projects[1].last_activity, Some(t("2026-08-22T09:00:00Z")));
|
||||
assert_eq!(projects[1].last_command.as_deref(), Some("opencode"));
|
||||
// stale: neither source knows anything.
|
||||
assert_eq!(projects[2].last_activity, None);
|
||||
assert_eq!(projects[2].last_command, None);
|
||||
}
|
||||
|
||||
fn t(s: &str) -> DateTime<Utc> {
|
||||
DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_caches_within_ttl_and_rescans_after() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut scanner = ProjectScanner::new(
|
||||
vec![tmp.path().to_path_buf()],
|
||||
3,
|
||||
PathBuf::from("/nonexistent"),
|
||||
);
|
||||
|
||||
let empty = scanner.repos().await;
|
||||
assert!(empty.is_empty());
|
||||
|
||||
make_repo(tmp.path(), "late");
|
||||
// Within the TTL the cached (empty) snapshot is still served.
|
||||
let still_empty = scanner.repos().await;
|
||||
assert!(still_empty.is_empty());
|
||||
|
||||
// After the TTL lapses the rescan sees the new repo.
|
||||
scanner.ttl = Duration::ZERO;
|
||||
let fresh = scanner.repos().await;
|
||||
assert_eq!(fresh.len(), 1);
|
||||
assert_eq!(fresh[0].name, "late");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_scanner_always_serves_empty() {
|
||||
let scanner = ProjectScanner::disabled();
|
||||
assert_eq!(scanner.roots, Vec::<PathBuf>::new());
|
||||
}
|
||||
}
|
||||
@@ -38,13 +38,12 @@ pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
|
||||
pub struct SessionRecord {
|
||||
pub session: Session,
|
||||
pub fifo_path: PathBuf,
|
||||
/// Worktree we created at spawn time and that should be cleaned up on
|
||||
/// kill (if `cleanup_worktree_on_kill`).
|
||||
/// Worktree we created at spawn time; removed on kill unless the
|
||||
/// dirty-work guard refuses (#56).
|
||||
pub worktree_path: Option<PathBuf>,
|
||||
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
|
||||
/// worktree remove` is invoked from the base repo.
|
||||
pub worktree_base_repo: Option<PathBuf>,
|
||||
pub cleanup_worktree_on_kill: bool,
|
||||
pub bridge: PtyBridge,
|
||||
}
|
||||
|
||||
@@ -170,6 +169,16 @@ impl SessionManager {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Every deduped recent dir, newest-first, without the picker's result
|
||||
/// cap (#54): project enrichment folds the whole history.
|
||||
pub fn all_recent_dirs(&self) -> Vec<RecentDir> {
|
||||
self.inner
|
||||
.spawn_history
|
||||
.as_ref()
|
||||
.map(|h| h.all_recent_dirs())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Subscribe to session deltas. Each subscriber gets its own lag domain —
|
||||
/// a slow client makes only itself resync.
|
||||
pub fn events(&self) -> broadcast::Receiver<CatalogEvent> {
|
||||
@@ -213,24 +222,20 @@ impl SessionManager {
|
||||
/// Spawn a new session. On success returns the freshly-created `Session`.
|
||||
pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
|
||||
let (effective_cwd, worktree_path, worktree_base_repo) = match &req.worktree {
|
||||
Some(WorktreeSpec {
|
||||
base_repo, branch, ..
|
||||
}) => {
|
||||
let path = self
|
||||
.inner
|
||||
.worktree_mgr
|
||||
.add(base_repo, branch)
|
||||
.map_err(SessionError::Worktree)?;
|
||||
Some(spec) => {
|
||||
let WorktreeSpec {
|
||||
base_repo, branch, ..
|
||||
} = spec;
|
||||
let path = if spec.create_branch {
|
||||
self.inner.worktree_mgr.add_new(base_repo, branch)
|
||||
} else {
|
||||
self.inner.worktree_mgr.add_existing(base_repo, branch)
|
||||
}
|
||||
.map_err(SessionError::Worktree)?;
|
||||
(path.clone(), Some(path), Some(base_repo.clone()))
|
||||
}
|
||||
None => (req.cwd.clone(), None, None),
|
||||
};
|
||||
let cleanup_worktree_on_kill = req
|
||||
.worktree
|
||||
.as_ref()
|
||||
.map(|w| w.cleanup_on_kill)
|
||||
.unwrap_or(false);
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
// Build the FIFO + reader task *before* asking tmux to spawn the
|
||||
// command — otherwise the child's first bytes race the separate
|
||||
@@ -315,7 +320,6 @@ impl SessionManager {
|
||||
fifo_path,
|
||||
worktree_path,
|
||||
worktree_base_repo,
|
||||
cleanup_worktree_on_kill,
|
||||
bridge,
|
||||
},
|
||||
);
|
||||
@@ -357,8 +361,9 @@ impl SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill a session and remove its on-disk artifacts. The worktree is only
|
||||
/// pruned if the create request set `cleanup_on_kill: true`.
|
||||
/// Kill a session and remove its on-disk artifacts. The worktree is
|
||||
/// always pruned -- unless it holds uncommitted changes or commits not on
|
||||
/// any other ref, in which case the guard leaves it on disk (#56).
|
||||
///
|
||||
/// This is also how an already-dead session is dismissed. tmux is only
|
||||
/// asked to kill something it still has — calling `kill-session` on a
|
||||
@@ -454,7 +459,6 @@ impl SessionManager {
|
||||
fifo_path,
|
||||
worktree_path: None,
|
||||
worktree_base_repo: None,
|
||||
cleanup_worktree_on_kill: false,
|
||||
bridge,
|
||||
},
|
||||
);
|
||||
@@ -703,9 +707,18 @@ async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kil
|
||||
if let Err(e) = inner.sidecar.delete(id) {
|
||||
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
|
||||
}
|
||||
if record.cleanup_worktree_on_kill {
|
||||
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
|
||||
let _ = inner.worktree_mgr.remove(base, path);
|
||||
// Cleanup is unconditional now (#56): `cleanup_on_kill` is no longer a
|
||||
// user-facing choice. The manager's guard is what protects unsaved work --
|
||||
// when it refuses, leave the tree on disk and say where it is so the
|
||||
// operator can find it.
|
||||
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
|
||||
if let Err(e) = inner.worktree_mgr.remove(base, path) {
|
||||
tracing::warn!(
|
||||
%id,
|
||||
path = %path.display(),
|
||||
error = %e,
|
||||
"left worktree on disk"
|
||||
);
|
||||
}
|
||||
}
|
||||
let _ = inner.events.send(CatalogEvent::SessionRemoved { id });
|
||||
|
||||
@@ -143,11 +143,23 @@ impl SpawnHistory {
|
||||
/// Recent working dirs, newest use first, deduped by `cwd`, within the
|
||||
/// recency cap, capped at `max_results`. Empty when disabled or unreadable.
|
||||
pub fn recent_dirs(&self) -> Vec<RecentDir> {
|
||||
self.recent_dirs_as_of(Utc::now())
|
||||
self.recent_dirs_capped(Utc::now(), self.max_results)
|
||||
}
|
||||
|
||||
/// Every deduped recent dir, newest-first, without the picker's result
|
||||
/// cap (#54): project enrichment folds the whole history, not its first
|
||||
/// page, so a project spawned in long ago still reads as fresh.
|
||||
pub fn all_recent_dirs(&self) -> Vec<RecentDir> {
|
||||
self.recent_dirs_capped(Utc::now(), usize::MAX)
|
||||
}
|
||||
|
||||
/// [`recent_dirs`](Self::recent_dirs) with an explicit clock for testing.
|
||||
#[cfg(test)]
|
||||
fn recent_dirs_as_of(&self, now: DateTime<Utc>) -> Vec<RecentDir> {
|
||||
self.recent_dirs_capped(now, self.max_results)
|
||||
}
|
||||
|
||||
fn recent_dirs_capped(&self, now: DateTime<Utc>, max_results: usize) -> Vec<RecentDir> {
|
||||
if !self.is_enabled() {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -168,7 +180,7 @@ impl SpawnHistory {
|
||||
last_command: rec.command,
|
||||
last_used: rec.at,
|
||||
});
|
||||
if out.len() >= self.max_results {
|
||||
if out.len() >= max_results {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -362,4 +374,28 @@ mod tests {
|
||||
assert_eq!(recents[0].cwd, PathBuf::from("/d4"));
|
||||
assert_eq!(recents[1].cwd, PathBuf::from("/d3"));
|
||||
}
|
||||
|
||||
/// Project enrichment (#54) folds the whole log, not just the picker's
|
||||
/// first page.
|
||||
#[test]
|
||||
fn all_recent_dirs_skips_the_result_cap() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut h = SpawnHistory::new(tmp.path().join("h.jsonl"), 30);
|
||||
h.max_results = 2;
|
||||
// Recent enough to survive the 30-day cap against the real clock.
|
||||
let base = Utc::now() - Duration::hours(1);
|
||||
for i in 0..5 {
|
||||
h.append(&rec(
|
||||
&format!("/d{i}"),
|
||||
"claude",
|
||||
base + Duration::minutes(i),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(h.recent_dirs().len(), 2);
|
||||
let all = h.all_recent_dirs();
|
||||
assert_eq!(all.len(), 5);
|
||||
assert_eq!(all[0].cwd, PathBuf::from("/d4"));
|
||||
assert_eq!(all[4].cwd, PathBuf::from("/d0"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,64 +4,187 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum WorktreeError {
|
||||
#[error("failed to spawn git: {0}")]
|
||||
Spawn(#[from] std::io::Error),
|
||||
#[error("git exited with code {code}: {stderr}")]
|
||||
NonZero { code: i32, stderr: String },
|
||||
/// A worktree already occupies the target path. The caller decides what
|
||||
/// to do; the daemon never relocates to a different directory (#56).
|
||||
#[error("worktree path already exists: {}", .path.display())]
|
||||
PathExists { path: PathBuf },
|
||||
/// The worktree holds unsaved work; removal was refused (#56).
|
||||
#[error("left {} on disk: {}", .path.display(), .reason)]
|
||||
Guarded { path: PathBuf, reason: String },
|
||||
}
|
||||
|
||||
/// Generates a unique path under `root` and runs `git worktree add` rooted at
|
||||
/// `base_repo`. Each call appends a UUID-suffixed directory so concurrent
|
||||
/// `add()`s never collide.
|
||||
/// Runs `git worktree add` rooted at `base_repo`. By default worktrees land
|
||||
/// inside the repository itself at `<repo>/worktrees/<branch>` (#56) so they
|
||||
/// are findable from the repo; an operator-set root overrides that.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorktreeManager {
|
||||
root: PathBuf,
|
||||
/// `[daemon].worktree_root` override. `None` uses the default
|
||||
/// `<repo>/worktrees/<branch>` placement.
|
||||
root_override: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl WorktreeManager {
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
pub fn new(root_override: Option<PathBuf>) -> Self {
|
||||
Self { root_override }
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
pub fn root(&self) -> Option<&Path> {
|
||||
self.root_override.as_deref()
|
||||
}
|
||||
|
||||
/// `git -C <base_repo> worktree add -b <branch> <generated-path>`.
|
||||
/// Returns the path of the new worktree on success.
|
||||
pub fn add(&self, base_repo: &Path, branch: &str) -> Result<PathBuf, WorktreeError> {
|
||||
std::fs::create_dir_all(&self.root)?;
|
||||
let path = self.generate_path(branch);
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(base_repo)
|
||||
.args(["worktree", "add", "-b", branch])
|
||||
.arg(&path)
|
||||
.output()?;
|
||||
/// Where a worktree for `branch` will be created: the override root, or
|
||||
/// `<main checkout>/worktrees/<sanitized branch>` by default. The path is
|
||||
/// predictable (no UUID suffix), so an existing directory at the target
|
||||
/// is an error rather than a silently different location.
|
||||
///
|
||||
/// `base_repo` is whatever the client picked, which the project list makes
|
||||
/// easy to point at a *linked worktree* — placing the new tree inside it
|
||||
/// would nest one worktree in another, and git hides a registered
|
||||
/// worktree's contents from the outer `git status`, so the outer tree
|
||||
/// would later look clean and its removal would delete the inner one's
|
||||
/// uncommitted work (#54 review). Always place against the main checkout.
|
||||
///
|
||||
/// The override root gets the repo's basename too: without it two repos
|
||||
/// spawning the same branch name target one directory and the second
|
||||
/// fails with `PathExists`.
|
||||
fn target_path(&self, base_repo: &Path, branch: &str) -> PathBuf {
|
||||
let slug = sanitize_branch_for_path(branch);
|
||||
let main = main_checkout(base_repo);
|
||||
match &self.root_override {
|
||||
Some(root) => {
|
||||
let repo = main
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "repo".into());
|
||||
root.join(repo).join(slug)
|
||||
}
|
||||
None => main.join("worktrees").join(slug),
|
||||
}
|
||||
}
|
||||
|
||||
/// `git -C <base_repo> worktree add -b <branch> <path>` — creates the
|
||||
/// branch. Returns the path of the new worktree on success.
|
||||
pub fn add_new(&self, base_repo: &Path, branch: &str) -> Result<PathBuf, WorktreeError> {
|
||||
self.run_add(base_repo, branch, true)
|
||||
}
|
||||
|
||||
/// `git -C <base_repo> worktree add <path> <branch>` — checks out a branch
|
||||
/// that must already exist (#56).
|
||||
pub fn add_existing(&self, base_repo: &Path, branch: &str) -> Result<PathBuf, WorktreeError> {
|
||||
self.run_add(base_repo, branch, false)
|
||||
}
|
||||
|
||||
fn run_add(
|
||||
&self,
|
||||
base_repo: &Path,
|
||||
branch: &str,
|
||||
create_branch: bool,
|
||||
) -> Result<PathBuf, WorktreeError> {
|
||||
let path = self.target_path(base_repo, branch);
|
||||
// Clear registrations whose directory is gone before testing the
|
||||
// path, so a previously-leaked entry doesn't block this branch.
|
||||
prune(base_repo)?;
|
||||
if path.symlink_metadata().is_ok() {
|
||||
return Err(WorktreeError::PathExists { path });
|
||||
}
|
||||
// The parent (`<repo>/worktrees` or the override root) may not exist
|
||||
// yet; the target itself must not.
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg("-C").arg(base_repo).args(["worktree", "add"]);
|
||||
if create_branch {
|
||||
cmd.arg("-b").arg(branch).arg(&path);
|
||||
} else {
|
||||
// Existing branch: `worktree add <path> <branch>` with no `-b`.
|
||||
cmd.arg(&path).arg(branch);
|
||||
}
|
||||
let out = cmd.output()?;
|
||||
check(out)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// `git -C <base_repo> worktree remove --force <path>`.
|
||||
/// `git -C <base_repo> worktree remove <path>` — but only when the
|
||||
/// worktree holds nothing unique (#56). A worktree with uncommitted
|
||||
/// changes, or with commits reachable from no other ref, is refused and
|
||||
/// left on disk so the operator can rescue it; cleanup is unconditional,
|
||||
/// which makes this guard the only thing standing between a stray kill
|
||||
/// and destroyed work.
|
||||
pub fn remove(&self, base_repo: &Path, path: &Path) -> Result<(), WorktreeError> {
|
||||
// Gone from disk already (operator `rm -rf`, or a create that failed
|
||||
// half-way). Probing it with git would fail and be misread as unsaved
|
||||
// work, stranding the registration — and since paths are
|
||||
// deterministic, `worktree add` would then refuse that branch forever
|
||||
// with "missing but already registered" (#56 review).
|
||||
if !path.exists() {
|
||||
return prune(base_repo);
|
||||
}
|
||||
if let Some(inner) = nested_worktree(path) {
|
||||
return Err(WorktreeError::Guarded {
|
||||
path: path.to_path_buf(),
|
||||
reason: format!("holds a nested worktree at {}", inner.display()),
|
||||
});
|
||||
}
|
||||
if let Some(reason) = unsafe_to_remove(path)? {
|
||||
return Err(WorktreeError::Guarded {
|
||||
path: path.to_path_buf(),
|
||||
reason,
|
||||
});
|
||||
}
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(base_repo)
|
||||
.args(["worktree", "remove", "--force"])
|
||||
.args(["worktree", "remove"])
|
||||
.arg(path)
|
||||
.output()?;
|
||||
check(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_path(&self, branch: &str) -> PathBuf {
|
||||
let slug = sanitize_branch_for_path(branch);
|
||||
let unique = Uuid::new_v4().simple();
|
||||
self.root.join(format!("{slug}-{unique}"))
|
||||
/// The repository's main checkout. `--git-common-dir` points at the shared
|
||||
/// `.git` for every linked worktree, so its parent is the main tree. Falls
|
||||
/// back to the input when git can't answer.
|
||||
fn main_checkout(path: &Path) -> PathBuf {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(path)
|
||||
.args(["rev-parse", "--path-format=absolute", "--git-common-dir"])
|
||||
.output();
|
||||
let Ok(out) = out else {
|
||||
return path.to_path_buf();
|
||||
};
|
||||
if !out.status.success() {
|
||||
return path.to_path_buf();
|
||||
}
|
||||
let common = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim().to_string());
|
||||
common
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| path.to_path_buf())
|
||||
}
|
||||
|
||||
/// `Some(inner)` when a registered worktree lives under `path`. Git omits
|
||||
/// those directories from `git status`, so the dirty-work guard cannot see
|
||||
/// them and removal would delete them (#54 review).
|
||||
fn nested_worktree(path: &Path) -> Option<PathBuf> {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(path)
|
||||
.args(["worktree", "list", "--porcelain"])
|
||||
.output()
|
||||
.ok()?;
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
text.lines()
|
||||
.filter_map(|l| l.strip_prefix("worktree "))
|
||||
.map(PathBuf::from)
|
||||
.find(|p| p != path && p.starts_with(path))
|
||||
}
|
||||
|
||||
fn sanitize_branch_for_path(branch: &str) -> String {
|
||||
@@ -79,6 +202,51 @@ fn sanitize_branch_for_path(branch: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// `Some(reason)` when deleting `path` would destroy work.
|
||||
///
|
||||
/// Only an unclean working tree qualifies. `git worktree remove` deletes the
|
||||
/// checkout but keeps `refs/heads/<branch>`, so committed work — pushed or
|
||||
/// not — stays reachable and is never at risk. Guarding on "commits on no
|
||||
/// other ref" instead would refuse every ordinary unpushed feature branch,
|
||||
/// and because the path is deterministic that branch could then never be
|
||||
/// spawned again (#56 review).
|
||||
fn unsafe_to_remove(path: &Path) -> Result<Option<String>, WorktreeError> {
|
||||
// `--ignored=no` keeps build output out of it; untracked sources count,
|
||||
// since removal would delete them.
|
||||
let status = run_git(path, ["status", "--porcelain", "--ignored=no"])?;
|
||||
if !status.stdout.is_empty() {
|
||||
return Ok(Some("has uncommitted changes".into()));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// `git -C <base_repo> worktree prune` — drops registrations whose directory
|
||||
/// no longer exists.
|
||||
fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(base_repo)
|
||||
.args(["worktree", "prune"])
|
||||
.output()?;
|
||||
check(out)
|
||||
}
|
||||
|
||||
fn run_git(
|
||||
dir: &Path,
|
||||
args: impl IntoIterator<Item = &'static str>,
|
||||
) -> Result<Output, WorktreeError> {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg("-C").arg(dir).args(args);
|
||||
let out = cmd.output()?;
|
||||
if !out.status.success() {
|
||||
return Err(WorktreeError::NonZero {
|
||||
code: out.status.code().unwrap_or(-1),
|
||||
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn check(out: Output) -> Result<(), WorktreeError> {
|
||||
if out.status.success() {
|
||||
Ok(())
|
||||
|
||||
@@ -37,6 +37,7 @@ async fn spawn_daemon_ws() -> Harness {
|
||||
"test-host",
|
||||
"/home/test",
|
||||
Vec::new(),
|
||||
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Integration tests for `GET /api/branches` (#55).
|
||||
|
||||
mod common;
|
||||
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
|
||||
use common::DaemonFixture;
|
||||
use maestro_daemon::local_api;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
struct Harness {
|
||||
addr: SocketAddr,
|
||||
_shutdown: oneshot::Sender<()>,
|
||||
_fx: DaemonFixture,
|
||||
}
|
||||
|
||||
async fn spawn() -> Harness {
|
||||
let fx = DaemonFixture::new();
|
||||
let presets = maestro_config::SpawnSection::default().resolve().unwrap();
|
||||
let router = local_api::router(local_api::state_for(
|
||||
fx.manager.clone(),
|
||||
"naps-laptop",
|
||||
"/home/test",
|
||||
presets,
|
||||
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = oneshot::channel::<()>();
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, router)
|
||||
.with_graceful_shutdown(async move {
|
||||
let _ = rx.await;
|
||||
})
|
||||
.await;
|
||||
});
|
||||
Harness {
|
||||
addr,
|
||||
_shutdown: tx,
|
||||
_fx: fx,
|
||||
}
|
||||
}
|
||||
|
||||
fn git(repo: &Path, args: &[&str]) {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
/// A real repo on `main` with a second branch `feature`, and a linked
|
||||
/// worktree at `<tmp>/wt-feature` holding `feature`.
|
||||
fn repo_with_occupied_branch() -> tempfile::TempDir {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path();
|
||||
git(repo, &["init", "-q", "-b", "main"]);
|
||||
git(
|
||||
repo,
|
||||
&[
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"commit",
|
||||
"-qm",
|
||||
"init",
|
||||
"--allow-empty",
|
||||
],
|
||||
);
|
||||
git(repo, &["branch", "feature"]);
|
||||
let wt = tmp.path().join("wt-feature");
|
||||
git(repo, &["worktree", "add", &wt.to_string_lossy(), "feature"]);
|
||||
tmp
|
||||
}
|
||||
|
||||
async fn get_branches(addr: SocketAddr, path: &Path) -> Vec<maestro_protocol::BranchInfo> {
|
||||
reqwest::get(format!(
|
||||
"http://{addr}/api/branches?path={}",
|
||||
urlencoding::encode(&path.to_string_lossy())
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// #55: every local branch is listed; the main checkout's HEAD reports
|
||||
/// `is_head`, and a branch held by a linked worktree reports that worktree's
|
||||
/// path so the client can explain why the entry is disabled.
|
||||
#[tokio::test]
|
||||
async fn branches_lists_local_branches_and_worktree_occupancy() {
|
||||
let tmp = repo_with_occupied_branch();
|
||||
let h = spawn().await;
|
||||
|
||||
let mut branches = get_branches(h.addr, tmp.path()).await;
|
||||
branches.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
assert_eq!(
|
||||
branches.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(),
|
||||
["feature", "main"]
|
||||
);
|
||||
|
||||
let main = branches.iter().find(|b| b.name == "main").unwrap();
|
||||
assert!(main.is_head, "main is the main checkout's HEAD");
|
||||
assert_eq!(main.worktree, None, "HEAD occupancy rides `is_head`");
|
||||
|
||||
let feature = branches.iter().find(|b| b.name == "feature").unwrap();
|
||||
assert!(!feature.is_head);
|
||||
assert_eq!(
|
||||
feature.worktree.as_deref(),
|
||||
Some(tmp.path().join("wt-feature").as_path()),
|
||||
"the linked worktree holding the branch must be reported"
|
||||
);
|
||||
}
|
||||
|
||||
/// #55: a path outside any repository is not an error — an empty list,
|
||||
/// matching how `/api/gitroot` reports "not a repository".
|
||||
#[tokio::test]
|
||||
async fn branches_returns_empty_for_a_path_outside_a_repository() {
|
||||
let h = spawn().await;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let plain = tmp.path().join("plain");
|
||||
std::fs::create_dir_all(&plain).unwrap();
|
||||
|
||||
let branches = get_branches(h.addr, &plain).await;
|
||||
assert!(branches.is_empty());
|
||||
}
|
||||
@@ -115,7 +115,6 @@ pub struct DaemonFixture {
|
||||
pub tmux: TmuxFixture,
|
||||
pub manager: SessionManager,
|
||||
pub state_dir: TempDir,
|
||||
pub worktree_root: TempDir,
|
||||
}
|
||||
|
||||
impl DaemonFixture {
|
||||
@@ -132,9 +131,9 @@ impl DaemonFixture {
|
||||
fn build(history_days: Option<u32>) -> Self {
|
||||
let tmux = TmuxFixture::new();
|
||||
let state_dir = TempDir::new().unwrap();
|
||||
let worktree_root = TempDir::new().unwrap();
|
||||
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
|
||||
let worktree_mgr = WorktreeManager::new(worktree_root.path().to_path_buf());
|
||||
// Default placement: worktrees land under their base repo (#56).
|
||||
let worktree_mgr = WorktreeManager::new(None);
|
||||
let mut manager = SessionManager::new(
|
||||
Arc::new(tmux.tmux.clone()),
|
||||
sidecar,
|
||||
@@ -149,7 +148,6 @@ impl DaemonFixture {
|
||||
tmux,
|
||||
manager,
|
||||
state_dir,
|
||||
worktree_root,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,17 @@ struct Harness {
|
||||
}
|
||||
|
||||
async fn spawn(hostname: &str) -> Harness {
|
||||
spawn_with(
|
||||
hostname,
|
||||
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn spawn_with(
|
||||
hostname: &str,
|
||||
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
|
||||
) -> Harness {
|
||||
let fx = DaemonFixture::new();
|
||||
let manager = fx.manager.clone();
|
||||
// Built-in presets (claude/opencode/zsh + custom) so `/api/info` advertises
|
||||
@@ -36,6 +47,7 @@ async fn spawn(hostname: &str) -> Harness {
|
||||
hostname,
|
||||
"/home/test",
|
||||
presets,
|
||||
projects,
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
@@ -153,6 +165,124 @@ async fn recent_dirs_returns_empty_without_history() {
|
||||
assert_eq!(resp["truncated"], false);
|
||||
}
|
||||
|
||||
/// #54: `GET /api/projects` lists every git repo under the scan roots, with
|
||||
/// real git checkouts — not mocks.
|
||||
#[tokio::test]
|
||||
async fn projects_lists_repos_under_the_scan_roots() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
for name in ["alpha", "beta"] {
|
||||
let repo = tmp.path().join(name);
|
||||
std::fs::create_dir_all(&repo).unwrap();
|
||||
for args in [
|
||||
vec!["init", "-q"],
|
||||
vec![
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"commit",
|
||||
"-qm",
|
||||
"init",
|
||||
"--allow-empty",
|
||||
],
|
||||
] {
|
||||
let out = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(&args)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success());
|
||||
}
|
||||
}
|
||||
|
||||
let scanner = std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::new(
|
||||
vec![tmp.path().to_path_buf()],
|
||||
3,
|
||||
tmp.path().to_path_buf(),
|
||||
));
|
||||
let h = spawn_with("naps-laptop", scanner).await;
|
||||
|
||||
let projects: Vec<maestro_protocol::Project> = reqwest::get(url(h.addr, "/api/projects"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut names: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["alpha", "beta"]);
|
||||
for p in &projects {
|
||||
assert!(!p.is_worktree);
|
||||
assert_eq!(p.base_repo, None);
|
||||
assert!(p.last_activity.is_some(), "fresh commit ⇒ HEAD mtime known");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn projects_returns_empty_for_a_disabled_scanner() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let projects: Vec<maestro_protocol::Project> = reqwest::get(url(h.addr, "/api/projects"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(projects.is_empty());
|
||||
}
|
||||
|
||||
/// #53: every returned handle splits into a known adjective and noun.
|
||||
fn assert_valid_names(batch: &maestro_protocol::NameBatch) {
|
||||
for name in &batch.names {
|
||||
let (adj, noun) = name.split_once('-').unwrap_or_else(|| {
|
||||
panic!("handle {name:?} is not adjective-noun");
|
||||
});
|
||||
assert!(
|
||||
maestro_names::ADJECTIVES.contains(&adj),
|
||||
"{adj} not in ADJECTIVES"
|
||||
);
|
||||
assert!(maestro_names::NOUNS.contains(&noun), "{noun} not in NOUNS");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn names_defaults_to_one_handle() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let batch: maestro_protocol::NameBatch = reqwest::get(url(h.addr, "/api/names"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(batch.names.len(), 1);
|
||||
assert_valid_names(&batch);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn names_honors_count_and_caps_it() {
|
||||
use maestro_names::MAX_BATCH;
|
||||
let h = spawn("naps-laptop").await;
|
||||
|
||||
let batch: maestro_protocol::NameBatch = reqwest::get(url(h.addr, "/api/names?count=5"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(batch.names.len(), 5);
|
||||
assert_valid_names(&batch);
|
||||
|
||||
let batch: maestro_protocol::NameBatch =
|
||||
reqwest::get(url(h.addr, &format!("/api/names?count={}", MAX_BATCH * 10)))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(batch.names.len(), MAX_BATCH);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gitroot_probes_path() {
|
||||
// #26: resolves the query against the daemon home dir and walks up for a
|
||||
|
||||
@@ -38,7 +38,7 @@ impl Fx {
|
||||
let manager = SessionManager::new(
|
||||
fake.clone(),
|
||||
SidecarStore::new(pick(tmp.path())),
|
||||
WorktreeManager::new(tmp.path().join("worktrees")),
|
||||
WorktreeManager::new(Some(tmp.path().join("worktrees"))),
|
||||
fifo_dir,
|
||||
);
|
||||
Self { manager, fake, tmp }
|
||||
@@ -387,7 +387,7 @@ fn attaching_spawn_history_after_cloning_panics() {
|
||||
let manager = SessionManager::new(
|
||||
fake,
|
||||
SidecarStore::new(tmp.path().join("sidecars")),
|
||||
WorktreeManager::new(tmp.path().join("worktrees")),
|
||||
WorktreeManager::new(Some(tmp.path().join("worktrees"))),
|
||||
tmp.path().join("fifos"),
|
||||
);
|
||||
let _clone = manager.clone();
|
||||
|
||||
@@ -7,9 +7,9 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{wait_for_async, DaemonFixture};
|
||||
use common::{init_repo, wait_for_async, DaemonFixture};
|
||||
use maestro_protocol::CreateSessionRequest;
|
||||
use maestro_protocol::{CatalogEvent, SessionStatus};
|
||||
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -123,8 +123,7 @@ async fn recover_adopts_existing_tmux_sessions() {
|
||||
|
||||
// build a parallel manager pointing at the same dirs + same tmux server
|
||||
let sidecar = maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir());
|
||||
let worktree_mgr =
|
||||
maestro_daemon::worktree::WorktreeManager::new(fx.worktree_root.path().to_path_buf());
|
||||
let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(None);
|
||||
let revived = maestro_daemon::SessionManager::new(
|
||||
Arc::new(fx.tmux.tmux.clone()),
|
||||
sidecar,
|
||||
@@ -214,7 +213,6 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
|
||||
let sidecar_dir = fx.sidecar_dir();
|
||||
let fifo_dir = fx.fifo_dir();
|
||||
let tmux = fx.tmux.tmux.clone();
|
||||
let worktree_root = fx.worktree_root.path().to_path_buf();
|
||||
|
||||
// Drop the original manager. Its PtyBridge closes the FIFO's only
|
||||
// reader, so tmux's `cat >> fifo` writer hits SIGPIPE and exits — but
|
||||
@@ -223,7 +221,7 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
|
||||
drop(fx.manager);
|
||||
|
||||
let sidecar = maestro_daemon::sidecar::SidecarStore::new(sidecar_dir);
|
||||
let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(worktree_root);
|
||||
let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(None);
|
||||
let revived =
|
||||
maestro_daemon::SessionManager::new(Arc::new(tmux), sidecar, worktree_mgr, fifo_dir);
|
||||
assert_eq!(revived.recover().await.expect("recover"), 1);
|
||||
@@ -365,6 +363,72 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
|
||||
assert!(!fx.fifo_dir().join(format!("{}.fifo", s.id)).exists());
|
||||
}
|
||||
|
||||
/// Cleanup is unconditional (#56): even a spec that says "don't clean up"
|
||||
/// loses its worktree on kill — the guard, not the flag, protects work.
|
||||
#[tokio::test]
|
||||
async fn kill_removes_worktree_even_when_spec_says_keep() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
let wt_path = repo.path().join("worktrees").join("feat-keep");
|
||||
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/keep".into(),
|
||||
cleanup_on_kill: false,
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
assert!(wt_path.exists());
|
||||
|
||||
fx.manager.kill(s.id).await.expect("kill");
|
||||
assert!(!wt_path.exists(), "worktree must be removed on kill");
|
||||
}
|
||||
|
||||
/// The guard (#56): a worktree holding uncommitted changes survives the kill,
|
||||
/// left on disk where the operator can find it.
|
||||
#[tokio::test]
|
||||
async fn kill_leaves_worktree_with_unsaved_work_on_disk() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
let wt_path = repo.path().join("worktrees").join("feat-guarded");
|
||||
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/guarded".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||
|
||||
fx.manager.kill(s.id).await.expect("kill");
|
||||
assert!(
|
||||
wt_path.exists(),
|
||||
"dirty worktree must be left on disk, not destroyed"
|
||||
);
|
||||
assert!(fx.manager.snapshot().await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn liveness_watcher_leaves_running_sessions_alone() {
|
||||
let fx = DaemonFixture::new();
|
||||
|
||||
@@ -31,9 +31,9 @@ fn add_creates_worktree_on_new_branch() {
|
||||
init_repo(&repo);
|
||||
|
||||
let wt_root = TempDir::new().unwrap();
|
||||
let mgr = WorktreeManager::new(wt_root.path());
|
||||
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
|
||||
|
||||
let path = mgr.add(&repo, "feat/example").expect("add worktree");
|
||||
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
|
||||
assert!(path.exists());
|
||||
assert!(path.join("README").exists());
|
||||
assert!(path.starts_with(wt_root.path()));
|
||||
@@ -42,6 +42,66 @@ fn add_creates_worktree_on_new_branch() {
|
||||
assert!(branches.iter().any(|b| b == "feat/example"), "{branches:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_places_worktree_inside_the_base_repo_by_default() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
// No override: the worktree must land where the repository keeps its
|
||||
// own worktrees (#56), not under some daemon state dir.
|
||||
let mgr = WorktreeManager::new(None);
|
||||
|
||||
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
|
||||
assert_eq!(path, repo.join("worktrees").join("feat-example"));
|
||||
assert!(path.exists());
|
||||
assert!(path.join("README").exists());
|
||||
|
||||
let branches = worktree_branches(&repo);
|
||||
assert!(branches.iter().any(|b| b == "feat/example"), "{branches:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_root_override_redirects_placement() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
let wt_root = TempDir::new().unwrap();
|
||||
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
|
||||
|
||||
// The repo's basename sits between the root and the branch slug, so two
|
||||
// repos spawning the same branch name don't target one directory.
|
||||
let repo_name = repo.file_name().unwrap();
|
||||
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
|
||||
assert_eq!(path, wt_root.path().join(repo_name).join("feat-example"));
|
||||
assert!(path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_directory_at_target_path_is_an_error_carrying_the_path() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
// A predictable path means collisions are possible; they must be
|
||||
// reported, never silently relocated.
|
||||
std::fs::create_dir_all(repo.join("worktrees").join("feat-collide")).unwrap();
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let err = mgr.add_new(&repo, "feat/collide").unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(
|
||||
msg.contains(
|
||||
repo.join("worktrees")
|
||||
.join("feat-collide")
|
||||
.to_str()
|
||||
.unwrap()
|
||||
),
|
||||
"{msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_twice_generates_distinct_paths() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
@@ -49,10 +109,10 @@ fn add_twice_generates_distinct_paths() {
|
||||
init_repo(&repo);
|
||||
|
||||
let wt_root = TempDir::new().unwrap();
|
||||
let mgr = WorktreeManager::new(wt_root.path());
|
||||
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
|
||||
|
||||
let p1 = mgr.add(&repo, "feat/a").expect("add a");
|
||||
let p2 = mgr.add(&repo, "feat/b").expect("add b");
|
||||
let p1 = mgr.add_new(&repo, "feat/a").expect("add a");
|
||||
let p2 = mgr.add_new(&repo, "feat/b").expect("add b");
|
||||
assert_ne!(p1, p2);
|
||||
assert!(p1.exists());
|
||||
assert!(p2.exists());
|
||||
@@ -65,9 +125,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 +139,136 @@ fn remove_prunes_worktree() {
|
||||
assert_eq!(branches, vec!["main".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_existing_checks_out_branch_without_creating_it() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
// Pre-create the branch with its own commit so we can tell a checkout
|
||||
// from an accidental `-b`.
|
||||
let created = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(["checkout", "-q", "-b", "feat/dup"])
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(created.success());
|
||||
std::fs::write(repo.join("FEATURE"), b"dup\n").unwrap();
|
||||
let staged = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(["add", "."])
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(staged.success());
|
||||
let committed = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(["commit", "-q", "-am", "wip"])
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(committed.success());
|
||||
let back = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(["checkout", "-q", "main"])
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(back.success());
|
||||
|
||||
let wt_root = TempDir::new().unwrap();
|
||||
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
|
||||
|
||||
let path = mgr.add_existing(&repo, "feat/dup").expect("add existing");
|
||||
assert!(path.exists());
|
||||
assert!(path.join("README").exists());
|
||||
|
||||
// The branch set is unchanged: `feat/dup` was checked out, not recreated.
|
||||
let branches = worktree_branches(&repo);
|
||||
let mut sorted = branches.clone();
|
||||
sorted.sort();
|
||||
assert_eq!(
|
||||
sorted,
|
||||
vec!["feat/dup".to_string(), "main".to_string()],
|
||||
"{branches:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_refuses_worktree_with_uncommitted_changes() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let path = mgr.add_new(&repo, "feat/dirty").expect("add");
|
||||
std::fs::write(path.join("README"), b"unsaved edits\n").unwrap();
|
||||
|
||||
mgr.remove(&repo, &path)
|
||||
.expect_err("dirty worktree must be refused");
|
||||
assert!(path.exists(), "guarded worktree must stay on disk");
|
||||
|
||||
// Committing it makes removal safe: `worktree remove` keeps
|
||||
// `refs/heads/feat/dirty`, so the work is still reachable. Refusing here
|
||||
// instead would make every unpushed branch a one-shot, since the path is
|
||||
// deterministic and the leftover tree blocks the next spawn (#56 review).
|
||||
run_git(&path, &["add", "."]);
|
||||
run_git(&path, &["commit", "-q", "-m", "saved"]);
|
||||
|
||||
mgr.remove(&repo, &path).expect("committed work can go");
|
||||
assert!(!path.exists());
|
||||
assert!(
|
||||
worktree_branches(&repo).contains(&"main".to_string()),
|
||||
"the branch survives removal"
|
||||
);
|
||||
let branches = String::from_utf8(
|
||||
Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(["for-each-ref", "--format=%(refname:short)", "refs/heads"])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(branches.contains("feat/dirty"), "{branches}");
|
||||
}
|
||||
|
||||
/// A worktree whose directory vanished must be pruned, not misread as holding
|
||||
/// unsaved work — otherwise the registration is stranded and, because paths
|
||||
/// are deterministic, that branch can never be spawned again (#56 review).
|
||||
#[test]
|
||||
fn remove_prunes_a_worktree_whose_directory_vanished() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let path = mgr.add_new(&repo, "feat/gone").expect("add");
|
||||
std::fs::remove_dir_all(&path).unwrap();
|
||||
|
||||
mgr.remove(&repo, &path)
|
||||
.expect("a vanished worktree prunes");
|
||||
// The registration is gone too, so the same branch can be spawned again.
|
||||
mgr.add_existing(&repo, "feat/gone")
|
||||
.expect("branch is spawnable again");
|
||||
}
|
||||
|
||||
fn run_git(dir: &std::path::Path, args: &[&str]) {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(dir)
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("git spawn");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_fails_when_branch_already_exists() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
@@ -95,9 +285,81 @@ 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_eq!(
|
||||
inner.parent().and_then(|p| p.parent()),
|
||||
Some(repo.canonicalize().unwrap().as_path()),
|
||||
"placed against the main checkout"
|
||||
);
|
||||
}
|
||||
|
||||
/// Belt and braces for a nesting that predates this daemon: refuse to remove
|
||||
/// a worktree that has another registered underneath it.
|
||||
#[test]
|
||||
fn remove_refuses_a_worktree_holding_a_nested_one() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let outer = mgr.add_new(&repo, "feat/outer").expect("add outer");
|
||||
// Register one by hand inside `outer`, bypassing our placement.
|
||||
let nested = outer.join("worktrees").join("hand-rolled");
|
||||
run_git(
|
||||
&repo,
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
"-b",
|
||||
"feat/hand",
|
||||
nested.to_str().unwrap(),
|
||||
],
|
||||
);
|
||||
std::fs::write(nested.join("notes.txt"), b"unsaved\n").unwrap();
|
||||
|
||||
mgr.remove(&repo, &outer)
|
||||
.expect_err("a worktree holding a nested one must be refused");
|
||||
assert!(nested.join("notes.txt").exists(), "inner work survives");
|
||||
}
|
||||
|
||||
/// Two repos spawning the same branch name under one override root must not
|
||||
/// collide (#54 review).
|
||||
#[test]
|
||||
fn override_root_keeps_repos_apart() {
|
||||
let root_dir = TempDir::new().unwrap();
|
||||
let a_dir = TempDir::new().unwrap();
|
||||
let b_dir = TempDir::new().unwrap();
|
||||
init_repo(a_dir.path());
|
||||
init_repo(b_dir.path());
|
||||
|
||||
let mgr = WorktreeManager::new(Some(root_dir.path().to_path_buf()));
|
||||
let a = mgr.add_new(a_dir.path(), "feat/shared").expect("repo a");
|
||||
let b = mgr.add_new(b_dir.path(), "feat/shared").expect("repo b");
|
||||
assert_ne!(a, b, "same branch name in two repos must not collide");
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use maestro_protocol::{CatalogEvent, CreateSessionRequest, DaemonClient, Session};
|
||||
use maestro_protocol::{
|
||||
BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, Project, Session,
|
||||
};
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
|
||||
@@ -62,6 +64,14 @@ impl TestClient {
|
||||
Ok(self.daemon.kill_session(id).await?)
|
||||
}
|
||||
|
||||
pub async fn list_projects(&self) -> Result<Vec<Project>, ClientError> {
|
||||
Ok(self.daemon.list_projects().await?)
|
||||
}
|
||||
|
||||
pub async fn list_branches(&self, path: &str) -> Result<Vec<BranchInfo>, ClientError> {
|
||||
Ok(self.daemon.list_branches(path).await?)
|
||||
}
|
||||
|
||||
/// Connect to the daemon's per-session WS and return the stream. The
|
||||
/// caller drives the conversation.
|
||||
pub async fn attach(
|
||||
|
||||
@@ -29,11 +29,32 @@ impl Daemon {
|
||||
Self::spawn_at(SocketAddr::from((Ipv4Addr::LOCALHOST, free_port()))).await
|
||||
}
|
||||
|
||||
/// Spawn with a config file pre-seeded at the daemon's expected path
|
||||
/// (`$XDG_CONFIG_HOME/maestro/config.toml`).
|
||||
pub async fn spawn_with_config(toml: &str) -> Self {
|
||||
Self::spawn_config_at(
|
||||
SocketAddr::from((Ipv4Addr::LOCALHOST, free_port())),
|
||||
Some(toml),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Spawn on a caller-supplied bind address.
|
||||
pub async fn spawn_at(bind: SocketAddr) -> Self {
|
||||
Self::spawn_config_at(bind, None).await
|
||||
}
|
||||
|
||||
/// Spawn on a caller-supplied bind address, with an optional config file.
|
||||
async fn spawn_config_at(bind: SocketAddr, config: impl Into<Option<&str>>) -> Self {
|
||||
let home = TempDir::new().expect("home tmpdir");
|
||||
let tmux = TempDir::new().expect("tmux tmpdir");
|
||||
|
||||
if let Some(toml) = config.into() {
|
||||
let dir = home.path().join(".config").join("maestro");
|
||||
std::fs::create_dir_all(&dir).expect("config dir");
|
||||
std::fs::write(dir.join("config.toml"), toml).expect("write config.toml");
|
||||
}
|
||||
|
||||
let child = launch(bind, home.path(), tmux.path());
|
||||
let me = Self {
|
||||
bind_addr: bind,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -35,6 +35,22 @@ fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn request carrying a worktree spec against `base`.
|
||||
fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest {
|
||||
CreateSessionRequest {
|
||||
cwd: base.to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: base.to_path_buf(),
|
||||
branch: branch.into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn lists_spawned_sessions() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
@@ -114,7 +130,7 @@ async fn multi_attach_sees_same_output() {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cleanup_on_kill_removes_worktree() {
|
||||
async fn kill_removes_clean_worktree() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
@@ -129,18 +145,19 @@ async fn cleanup_on_kill_removes_worktree() {
|
||||
base_repo: base.path().to_path_buf(),
|
||||
branch: "feature/e2e".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
}),
|
||||
};
|
||||
|
||||
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
||||
// The daemon's WorktreeManager puts new worktrees under
|
||||
// `<state_dir>/worktrees/<branch>`. We can't peek the daemon's state dir
|
||||
// directly from here, but we can verify by asking git: a worktree must
|
||||
// exist that lives outside the original repo.
|
||||
// Worktrees live inside the base repo at `<repo>/worktrees/<branch>`
|
||||
// (#56), so `git worktree list` must show the predictable in-repo path.
|
||||
let worktrees = git_worktree_list(base.path());
|
||||
assert!(
|
||||
worktrees.iter().any(|w| w.contains("feature/e2e")),
|
||||
"expected a worktree for feature/e2e branch, got: {worktrees:?}"
|
||||
worktrees
|
||||
.iter()
|
||||
.any(|w| w.contains("worktrees/feature-e2e")),
|
||||
"expected a worktree for feature/e2e under <repo>/worktrees, got: {worktrees:?}"
|
||||
);
|
||||
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
@@ -160,6 +177,368 @@ async fn cleanup_on_kill_removes_worktree() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The guard (#56): killing a session whose worktree holds uncommitted
|
||||
/// changes must leave the tree on disk — cleanup is unconditional, so this
|
||||
/// guard is what keeps a stray kill from destroying work.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn kill_spares_worktree_with_uncommitted_changes() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
init_repo(base.path());
|
||||
let wt_path = base.path().join("worktrees").join("feature-unsaved");
|
||||
|
||||
let req = CreateSessionRequest {
|
||||
cwd: base.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: base.path().to_path_buf(),
|
||||
branch: "feature/unsaved".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
}),
|
||||
};
|
||||
|
||||
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
||||
assert!(wt_path.exists());
|
||||
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
|
||||
// The session is gone but the dirty worktree stays findable on disk.
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline {
|
||||
let sessions = client.list_sessions().await.unwrap_or_default();
|
||||
if sessions.is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
assert!(
|
||||
wt_path.exists(),
|
||||
"guarded worktree must be left on disk after kill"
|
||||
);
|
||||
// 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(WorktreeSpec {
|
||||
base_repo: base.path().to_path_buf(),
|
||||
branch: "feature/existing".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: false,
|
||||
}),
|
||||
};
|
||||
|
||||
let s = client
|
||||
.spawn_session(&req)
|
||||
.await
|
||||
.expect("spawn on existing branch");
|
||||
let worktrees = git_worktree_list(base.path());
|
||||
assert!(
|
||||
worktrees.iter().any(|w| w.contains("feature/existing")),
|
||||
"expected a worktree checked out at feature/existing, got: {worktrees:?}"
|
||||
);
|
||||
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
}
|
||||
|
||||
/// #66: a fresh branch spawns a worktree at `<repo>/worktrees/<branch>` and
|
||||
/// the session actually runs inside it — `current_cwd` comes from tmux's live
|
||||
/// pane state, not from the spawn request.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn spawn_new_branch_creates_worktree_and_runs_in_it() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
init_repo(base.path());
|
||||
let wt_path = base.path().join("worktrees").join("feature-fresh");
|
||||
|
||||
let s = client
|
||||
.spawn_session(&worktree_req(base.path(), "feature/fresh", true))
|
||||
.await
|
||||
.expect("spawn new-branch worktree");
|
||||
|
||||
assert_eq!(
|
||||
s.cwd, wt_path,
|
||||
"session must report the worktree as its cwd"
|
||||
);
|
||||
assert!(wt_path.is_dir(), "worktree directory must exist on disk");
|
||||
|
||||
// The activity watcher fills `current_cwd` from tmux's pane_current_path,
|
||||
// so this is the daemon confirming where the process really runs.
|
||||
wait_for_async(
|
||||
"session's live cwd is the worktree",
|
||||
Duration::from_secs(20),
|
||||
|| {
|
||||
let client = client.clone();
|
||||
let id = s.id;
|
||||
let want = wt_path.clone();
|
||||
async move {
|
||||
client
|
||||
.list_sessions()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|ss| ss.iter().find(|s| s.id == id).cloned())
|
||||
.map(|s| s.current_cwd == Some(want.clone()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
}
|
||||
|
||||
/// #66: a second spawn onto a branch that already has a worktree is rejected
|
||||
/// with a structured worktree error; the first worktree stays intact.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn second_spawn_on_same_branch_is_rejected() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
init_repo(base.path());
|
||||
|
||||
let first = client
|
||||
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
|
||||
.await
|
||||
.expect("first spawn");
|
||||
let dup = client
|
||||
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
|
||||
.await;
|
||||
match dup {
|
||||
Err(maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. })) => {
|
||||
assert_eq!(
|
||||
info.code, "worktree_failed",
|
||||
"expected worktree error: {info:?}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected rejection for duplicate branch, got {other:?}"),
|
||||
}
|
||||
|
||||
// Exactly one registration, one directory, still usable.
|
||||
let holders = git_worktree_list(base.path())
|
||||
.iter()
|
||||
.filter(|w| w.contains("feature/dup"))
|
||||
.count();
|
||||
assert_eq!(holders, 1, "duplicate spawn must not register another tree");
|
||||
assert!(
|
||||
base.path().join("worktrees").join("feature-dup").is_dir(),
|
||||
"original worktree must be untouched"
|
||||
);
|
||||
|
||||
client.kill_session(first.id).await.expect("kill");
|
||||
}
|
||||
|
||||
/// #66/#56: when the client picks a *linked worktree* as base_repo, placement
|
||||
/// resolves through `--git-common-dir` to the main checkout, so the new tree
|
||||
/// lands at `<main>/worktrees/<branch>` instead of nesting inside another
|
||||
/// worktree.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn spawn_from_linked_worktree_places_in_main_checkout() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
init_repo(base.path());
|
||||
let linked = base.path().join("linked");
|
||||
run_git(
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
"-q",
|
||||
"-b",
|
||||
"other",
|
||||
&linked.to_string_lossy(),
|
||||
],
|
||||
base.path(),
|
||||
);
|
||||
|
||||
let s = client
|
||||
.spawn_session(&worktree_req(&linked, "feature/nested", true))
|
||||
.await
|
||||
.expect("spawn from linked worktree");
|
||||
|
||||
let want = base.path().join("worktrees").join("feature-nested");
|
||||
assert_eq!(
|
||||
s.cwd, want,
|
||||
"worktree must be placed under the main checkout"
|
||||
);
|
||||
assert!(want.is_dir());
|
||||
assert!(
|
||||
!s.cwd.starts_with(&linked),
|
||||
"worktrees must never nest inside another worktree"
|
||||
);
|
||||
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/projects + /api/branches (#54/#55/#66)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// #66: two repos under a configured `[spawn].project_roots` root are both
|
||||
/// discovered by `GET /api/projects`; git activity gives every repo a
|
||||
/// `last_activity`, and spawning inside one merges its command into the
|
||||
/// project entry via spawn history.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn projects_endpoint_discovers_roots_and_merges_activity() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let alpha = make_repo(root.path(), "alpha");
|
||||
make_repo(root.path(), "beta");
|
||||
|
||||
let daemon = Daemon::spawn_with_config(&format!(
|
||||
"[spawn]\nproject_roots = [\"{}\"]\nproject_scan_depth = 2\n",
|
||||
root.path().display()
|
||||
))
|
||||
.await;
|
||||
let client = TestClient::new(&daemon.http_base());
|
||||
|
||||
let projects = client.list_projects().await.expect("list projects");
|
||||
let names: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect();
|
||||
assert!(
|
||||
names.contains(&"alpha") && names.contains(&"beta"),
|
||||
"both repos must be discovered, got: {names:?}"
|
||||
);
|
||||
|
||||
let find = |name: &str| {
|
||||
projects
|
||||
.iter()
|
||||
.find(|p| p.name == name)
|
||||
.cloned()
|
||||
.expect("project present")
|
||||
};
|
||||
// Both have commits, so git activity alone populates last_activity…
|
||||
assert!(
|
||||
find("alpha").last_activity.is_some(),
|
||||
"git activity must surface"
|
||||
);
|
||||
assert!(
|
||||
find("beta").last_activity.is_some(),
|
||||
"git activity must surface"
|
||||
);
|
||||
// …and neither has been spawned into yet.
|
||||
assert_eq!(find("alpha").last_command, None);
|
||||
assert_eq!(find("beta").last_command, None);
|
||||
|
||||
// Spawning inside alpha writes durable history; /api/projects folds it in.
|
||||
let s = client
|
||||
.spawn_session(&create_req(&alpha, "sleep 30"))
|
||||
.await
|
||||
.expect("spawn in alpha");
|
||||
|
||||
wait_for_async(
|
||||
"alpha's project entry reflects spawn history",
|
||||
Duration::from_secs(5),
|
||||
|| {
|
||||
let client = client.clone();
|
||||
async move {
|
||||
client
|
||||
.list_projects()
|
||||
.await
|
||||
.ok()
|
||||
.map(|ps| {
|
||||
ps.iter()
|
||||
.find(|p| p.name == "alpha")
|
||||
.map(|p| p.last_command.as_deref() == Some("sleep 30"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
// beta was never spawned into: no command recorded there.
|
||||
let beta_after = client
|
||||
.list_projects()
|
||||
.await
|
||||
.expect("list after spawn")
|
||||
.into_iter()
|
||||
.find(|p| p.name == "beta")
|
||||
.unwrap();
|
||||
assert_eq!(beta_after.last_command, None);
|
||||
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
}
|
||||
|
||||
/// #66: `GET /api/branches` lists every local branch, flags the main
|
||||
/// checkout's HEAD with `is_head`, and reports which linked worktree holds an
|
||||
/// occupied branch.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn branches_endpoint_lists_branches_and_flags_occupied() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
init_repo(base.path());
|
||||
run_git(&["branch", "feature/taken"], base.path());
|
||||
run_git(&["branch", "feature/free"], base.path());
|
||||
let holder = base.path().join("holder-wt");
|
||||
run_git(
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
"-q",
|
||||
&holder.to_string_lossy(),
|
||||
"feature/taken",
|
||||
],
|
||||
base.path(),
|
||||
);
|
||||
|
||||
let branches = client
|
||||
.list_branches(&base.path().to_string_lossy())
|
||||
.await
|
||||
.expect("list branches");
|
||||
let by_name = |name: &str| {
|
||||
branches
|
||||
.iter()
|
||||
.find(|b| b.name == name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| panic!("branch {name} missing from {branches:?}"))
|
||||
};
|
||||
|
||||
let main = by_name("main");
|
||||
assert!(main.is_head, "main is the main checkout's HEAD");
|
||||
assert_eq!(main.worktree, None);
|
||||
|
||||
let taken = by_name("feature/taken");
|
||||
assert!(!taken.is_head);
|
||||
assert_eq!(
|
||||
taken.worktree.as_deref(),
|
||||
Some(holder.canonicalize().unwrap().as_path()),
|
||||
"occupied branch must report its holding worktree"
|
||||
);
|
||||
|
||||
let free = by_name("feature/free");
|
||||
assert!(!free.is_head);
|
||||
assert_eq!(free.worktree, None, "unoccupied branch must be spawnable");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn daemon_restart_recovers_session() {
|
||||
let (mut daemon, client) = bring_up().await;
|
||||
@@ -348,6 +727,15 @@ fn init_repo(dir: &std::path::Path) {
|
||||
run_git(&["commit", "-q", "-m", "initial"], dir);
|
||||
}
|
||||
|
||||
/// Real git repo at `<root>/<name>`, canonicalized (the scanner canonicalizes
|
||||
/// discovered paths too, so comparisons must agree on the real path).
|
||||
fn make_repo(root: &std::path::Path, name: &str) -> std::path::PathBuf {
|
||||
let dir = root.join(name);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
init_repo(&dir);
|
||||
dir.canonicalize().unwrap()
|
||||
}
|
||||
|
||||
fn run_git(args: &[&str], cwd: &std::path::Path) {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,96 @@
|
||||
//! The one home of the session-handle wordlists (#53).
|
||||
//!
|
||||
//! Previously duplicated in `web/src/names.ts`; every client now gets handles
|
||||
//! from here, served by `GET /api/names`.
|
||||
|
||||
/// Musical adjectives — tempo, dynamics, and articulation markings.
|
||||
pub const ADJECTIVES: &[&str] = &[
|
||||
"allegro",
|
||||
"andante",
|
||||
"adagio",
|
||||
"presto",
|
||||
"vivace",
|
||||
"largo",
|
||||
"grave",
|
||||
"lento",
|
||||
"moderato",
|
||||
"crescendo",
|
||||
"staccato",
|
||||
"legato",
|
||||
"forte",
|
||||
"dolce",
|
||||
"agitato",
|
||||
"cantabile",
|
||||
"sostenuto",
|
||||
"tremolo",
|
||||
"rubato",
|
||||
"marcato",
|
||||
"brillante",
|
||||
"maestoso",
|
||||
"grazioso",
|
||||
"espressivo",
|
||||
"tranquillo",
|
||||
"animato",
|
||||
"spirito",
|
||||
"sotto",
|
||||
"vivo",
|
||||
"feroce",
|
||||
];
|
||||
|
||||
/// Musical nouns — instruments and forms from the concert hall.
|
||||
pub const NOUNS: &[&str] = &[
|
||||
"cello", "violin", "viola", "oboe", "flute", "clarinet", "bassoon", "trumpet", "trombone",
|
||||
"tuba", "horn", "timpani", "harp", "piano", "organ", "lyre", "lute", "fugue", "sonata",
|
||||
"octave", "chord", "cadence", "prelude", "rondo", "nocturne", "scherzo", "refrain", "coda",
|
||||
"motif", "overture",
|
||||
];
|
||||
|
||||
/// Upper bound for a `GET /api/names?count=N` batch.
|
||||
pub const MAX_BATCH: usize = 100;
|
||||
|
||||
/// A fresh `adjective-noun` handle, e.g. `allegro-cello`.
|
||||
pub fn generate() -> String {
|
||||
use rand::seq::SliceRandom;
|
||||
let mut rng = rand::thread_rng();
|
||||
format!(
|
||||
"{}-{}",
|
||||
ADJECTIVES.choose(&mut rng).unwrap(),
|
||||
NOUNS.choose(&mut rng).unwrap()
|
||||
)
|
||||
}
|
||||
|
||||
/// `count` fresh handles (the caller clamps; this only trusts its input).
|
||||
pub fn generate_batch(count: usize) -> Vec<String> {
|
||||
(0..count).map(|_| generate()).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn generated_name_is_adjective_dash_noun() {
|
||||
let name = generate();
|
||||
let (adj, noun) = name.split_once('-').expect("name contains a dash");
|
||||
assert!(ADJECTIVES.contains(&adj), "{adj} not in ADJECTIVES");
|
||||
assert!(NOUNS.contains(&noun), "{noun} not in NOUNS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_returns_the_requested_count() {
|
||||
assert_eq!(generate_batch(1).len(), 1);
|
||||
assert_eq!(generate_batch(50).len(), 50);
|
||||
for name in generate_batch(10) {
|
||||
let (adj, noun) = name.split_once('-').unwrap();
|
||||
assert!(ADJECTIVES.contains(&adj) && NOUNS.contains(&noun));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerolls_can_differ() {
|
||||
// 30x30=900 combinations; 20 draws all matching has probability ~1e-40.
|
||||
let first = generate();
|
||||
let differs = (0..20).any(|_| generate() != first);
|
||||
assert!(differs);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
Project, RecentDirs, Session,
|
||||
};
|
||||
|
||||
/// Time budget for establishing a TCP connection to the daemon.
|
||||
@@ -138,6 +139,15 @@ impl DaemonClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_projects(&self) -> Result<Vec<Project>, ClientError> {
|
||||
self.get(routes::PROJECTS, &[]).await
|
||||
}
|
||||
|
||||
pub async fn list_branches(&self, path: &str) -> Result<Vec<BranchInfo>, ClientError> {
|
||||
self.get(routes::BRANCHES, &[(routes::PATH_QUERY, path)])
|
||||
.await
|
||||
}
|
||||
|
||||
// -- plumbing -----------------------------------------------------------
|
||||
|
||||
async fn get<T: DeserializeOwned>(
|
||||
|
||||
@@ -111,8 +111,20 @@ pub enum ActivityState {
|
||||
pub struct WorktreeSpec {
|
||||
pub base_repo: PathBuf,
|
||||
pub branch: String,
|
||||
#[serde(default)]
|
||||
/// Kept on the wire for CLI compatibility (#56) but no longer a
|
||||
/// user-facing choice: the daemon always cleans up on kill, guarded
|
||||
/// against unsaved work. Defaults to `true`.
|
||||
#[serde(default = "default_true")]
|
||||
pub cleanup_on_kill: bool,
|
||||
/// When false, `branch` must already exist and is checked out as-is
|
||||
/// (#56); when true (the default, and what every payload meant before
|
||||
/// the field existed) the daemon creates it with `worktree add -b`.
|
||||
#[serde(default = "default_true")]
|
||||
pub create_branch: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// HTTP `POST /api/sessions` body.
|
||||
@@ -176,6 +188,45 @@ pub struct GitRoot {
|
||||
pub root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// One discovered git repository (or linked worktree) for `GET /api/projects`
|
||||
/// (#54). The daemon reports the timestamp; clients decide what "stale" means
|
||||
/// (the web UI dims entries older than 7 days).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
/// Absolute path of the repository/worktree toplevel.
|
||||
pub path: PathBuf,
|
||||
/// Basename of `path`.
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub is_worktree: bool,
|
||||
/// The main checkout this worktree belongs to. Set only when
|
||||
/// [`Self::is_worktree`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub base_repo: Option<PathBuf>,
|
||||
/// Later of the repo's git HEAD mtime and the newest spawn-history use of
|
||||
/// any directory inside it. `None` when neither is known.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_activity: Option<DateTime<Utc>>,
|
||||
/// Command last spawned in this project, from spawn history.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_command: Option<String>,
|
||||
}
|
||||
|
||||
/// One local branch of a repository for `GET /api/branches` (#55). A client
|
||||
/// disables the entry when `is_head` is set or `worktree` is present — git
|
||||
/// refuses to check the same branch out in two worktrees at once.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BranchInfo {
|
||||
/// Short branch name (`main`, `feature/foo`).
|
||||
pub name: String,
|
||||
/// Whether this branch is the main checkout's current HEAD. The main
|
||||
/// checkout's occupancy reports here rather than through `worktree`.
|
||||
pub is_head: bool,
|
||||
/// Toplevel path of the linked worktree holding this branch, if any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub worktree: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Structured failure used everywhere we don't want to leak a string-only error.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ErrorInfo {
|
||||
@@ -243,6 +294,13 @@ pub struct DaemonInfo {
|
||||
pub presets: Vec<SpawnPreset>,
|
||||
}
|
||||
|
||||
/// Payload of `GET /api/names?count=N` (#53) — a batch of fresh
|
||||
/// `adjective-noun` session handles generated by the `maestro-names` crate.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NameBatch {
|
||||
pub names: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalog events (`WS /api/events`)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -317,6 +375,34 @@ mod tests {
|
||||
serde_json::from_str(&json).expect("deserialize")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_round_trips_and_defaults_on_legacy_payload() {
|
||||
let full = Project {
|
||||
path: PathBuf::from("/home/u/tea/maestro"),
|
||||
name: "maestro".into(),
|
||||
is_worktree: true,
|
||||
base_repo: Some(PathBuf::from("/home/u/tea/maestro-main")),
|
||||
last_activity: Some(
|
||||
DateTime::parse_from_rfc3339("2026-08-23T12:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc),
|
||||
),
|
||||
last_command: Some("claude".into()),
|
||||
};
|
||||
assert_eq!(full, round_trip(&full));
|
||||
|
||||
// A payload from a daemon predating the optional fields still decodes.
|
||||
let legacy = serde_json::json!({
|
||||
"path": "/home/u/proj",
|
||||
"name": "proj",
|
||||
});
|
||||
let p: Project = serde_json::from_value(legacy).unwrap();
|
||||
assert!(!p.is_worktree);
|
||||
assert_eq!(p.base_repo, None);
|
||||
assert_eq!(p.last_activity, None);
|
||||
assert_eq!(p.last_command, None);
|
||||
}
|
||||
|
||||
fn sample_session() -> Session {
|
||||
Session {
|
||||
id: Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef),
|
||||
@@ -393,6 +479,7 @@ mod tests {
|
||||
base_repo: PathBuf::from("/home/naps62/projects/maestro"),
|
||||
branch: "feat/foo".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
}),
|
||||
};
|
||||
assert_eq!(req, round_trip(&req));
|
||||
@@ -410,6 +497,31 @@ mod tests {
|
||||
assert!(req.metadata.is_empty());
|
||||
}
|
||||
|
||||
/// A client that predates the new/existing discriminator sends no
|
||||
/// `create_branch`; the daemon must read that as "create a new branch" —
|
||||
/// exactly what every payload meant before the field existed (#56).
|
||||
#[test]
|
||||
fn worktree_spec_defaults_on_legacy_payload() {
|
||||
let json = serde_json::json!({
|
||||
"base_repo": "/home/naps62/projects/maestro",
|
||||
"branch": "feat/foo",
|
||||
});
|
||||
let spec: WorktreeSpec = serde_json::from_value(json).unwrap();
|
||||
assert!(spec.create_branch);
|
||||
assert!(spec.cleanup_on_kill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_spec_round_trips_existing_branch_mode() {
|
||||
let spec = WorktreeSpec {
|
||||
base_repo: PathBuf::from("/work"),
|
||||
branch: "feat/foo".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: false,
|
||||
};
|
||||
assert_eq!(spec, round_trip(&spec));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_event_round_trips_and_tags() {
|
||||
let session = sample_session();
|
||||
|
||||
@@ -17,14 +17,24 @@ pub const INFO: &str = "/api/info";
|
||||
pub const DIRS: &str = "/api/dirs";
|
||||
/// `GET` spawn history.
|
||||
pub const RECENT_DIRS: &str = "/api/recent-dirs";
|
||||
/// `GET` discovered git repositories and worktrees, newest activity first.
|
||||
pub const PROJECTS: &str = "/api/projects";
|
||||
/// `GET` the enclosing repository of a path. Takes a `path` query param.
|
||||
pub const GITROOT: &str = "/api/gitroot";
|
||||
/// `GET` one repository's local branches. Takes a `path` query param.
|
||||
pub const BRANCHES: &str = "/api/branches";
|
||||
/// `GET` a batch of generated session handles (#53). Takes a `count` query
|
||||
/// param (default 1, capped at [`maestro_names`]' batch cap).
|
||||
pub const NAMES: &str = "/api/names";
|
||||
/// `WS` catalog snapshot + session deltas.
|
||||
pub const EVENTS: &str = "/api/events";
|
||||
|
||||
/// Name of the query parameter [`DIRS`] and [`GITROOT`] both take.
|
||||
/// Name of the query parameter [`DIRS`], [`GITROOT`], and [`BRANCHES`] take.
|
||||
pub const PATH_QUERY: &str = "path";
|
||||
|
||||
/// Name of the query parameter [`NAMES`] takes.
|
||||
pub const COUNT_QUERY: &str = "count";
|
||||
|
||||
/// `DELETE /api/sessions/:id`.
|
||||
pub fn session(id: Uuid) -> String {
|
||||
format!("{SESSIONS}/{id}")
|
||||
@@ -172,7 +182,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn every_route_sits_under_the_api_prefix() {
|
||||
for path in [SESSIONS, INFO, DIRS, RECENT_DIRS, GITROOT, EVENTS] {
|
||||
for path in [
|
||||
SESSIONS,
|
||||
INFO,
|
||||
DIRS,
|
||||
RECENT_DIRS,
|
||||
PROJECTS,
|
||||
GITROOT,
|
||||
BRANCHES,
|
||||
NAMES,
|
||||
EVENTS,
|
||||
] {
|
||||
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
|
||||
}
|
||||
assert!(attach(id()).starts_with(API_PREFIX));
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -38,7 +38,7 @@ pub enum Action {
|
||||
// Catalog actions on the highlighted sidebar row
|
||||
AttachHighlighted,
|
||||
ToggleAttachHighlighted,
|
||||
OpenSpawnModal,
|
||||
NewDraftPane,
|
||||
KillHighlighted,
|
||||
|
||||
// Tabs
|
||||
@@ -78,7 +78,7 @@ impl Action {
|
||||
"SidebarPrev" => return Ok(Action::SidebarPrev),
|
||||
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
|
||||
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
|
||||
"OpenSpawnModal" => return Ok(Action::OpenSpawnModal),
|
||||
"NewDraftPane" => return Ok(Action::NewDraftPane),
|
||||
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
||||
"NewTab" => return Ok(Action::NewTab),
|
||||
"CloseTab" => return Ok(Action::CloseTab),
|
||||
@@ -202,6 +202,7 @@ mod tests {
|
||||
for a in [
|
||||
Action::EnterInsert,
|
||||
Action::Quit,
|
||||
Action::NewDraftPane,
|
||||
Action::FocusPane(Direction::Right),
|
||||
Action::GotoTab(7),
|
||||
] {
|
||||
@@ -209,4 +210,14 @@ mod tests {
|
||||
assert_eq!(Action::parse(&s).unwrap(), a, "round-trip for {a:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The spawn modal is gone (issue #63); its old action string must not
|
||||
/// resolve to anything.
|
||||
#[test]
|
||||
fn the_removed_spawn_modal_action_no_longer_parses() {
|
||||
assert_eq!(
|
||||
Action::parse("OpenSpawnModal").unwrap_err(),
|
||||
ActionParseError::Unknown("OpenSpawnModal".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+588
-89
@@ -6,17 +6,19 @@
|
||||
//! 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};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Terminal;
|
||||
use uuid::Uuid;
|
||||
@@ -24,15 +26,16 @@ use uuid::Uuid;
|
||||
use crate::action::Action;
|
||||
use crate::catalog::CatalogStore;
|
||||
use crate::config::TuiConfig;
|
||||
use crate::draft::DraftInputResult;
|
||||
use crate::emulators::SessionEmulators;
|
||||
use crate::input::{canonicalise, ChordResult, ChordState, ClickTracker, InputKey};
|
||||
use crate::keymap::Mode;
|
||||
use crate::overlays::{HelpOverlay, SpawnModal};
|
||||
use crate::overlays::HelpOverlay;
|
||||
use crate::pane::{Pane, PaneStatus};
|
||||
use crate::render::{draw, RegionMap, RenderInputs};
|
||||
use crate::session_task::{SessionInput, SessionTask};
|
||||
use crate::sidebar::Sidebar;
|
||||
use crate::state::{AppState, Focus, OverlayKind};
|
||||
use crate::state::{AppState, Focus, OverlayKind, PaneRef};
|
||||
use crate::tabs::TabBar;
|
||||
use crate::TuiError;
|
||||
|
||||
@@ -49,10 +52,12 @@ pub struct App {
|
||||
catalog: CatalogStore,
|
||||
emulators: SessionEmulators,
|
||||
state: AppState,
|
||||
spawn: SpawnModal,
|
||||
help: HelpOverlay,
|
||||
chord: ChordState,
|
||||
daemon: DaemonClient,
|
||||
/// Direct HTTP for the one endpoint [`DaemonClient`] doesn't cover yet:
|
||||
/// `GET /api/names` (#64).
|
||||
http: reqwest::Client,
|
||||
region: RegionMap,
|
||||
last_sent_size: HashMap<Uuid, (u16, u16)>,
|
||||
clicks: ClickTracker,
|
||||
@@ -76,7 +81,23 @@ pub enum Effect {
|
||||
Attach(Uuid),
|
||||
Spawn(CreateSessionRequest),
|
||||
Kill(Uuid),
|
||||
Forward { id: Uuid, bytes: Vec<u8> },
|
||||
Forward {
|
||||
id: Uuid,
|
||||
bytes: Vec<u8>,
|
||||
},
|
||||
/// Fetch presets / projects / names for a freshly opened draft (#64).
|
||||
LoadDraft(usize),
|
||||
/// A reroll drained the draft's local name batch (#64).
|
||||
RefillNames(usize),
|
||||
/// 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),
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -85,15 +106,20 @@ impl App {
|
||||
let help = HelpOverlay {
|
||||
lines: render_help_lines(&cfg),
|
||||
};
|
||||
let http = reqwest::Client::builder()
|
||||
.connect_timeout(maestro_protocol::client::DEFAULT_CONNECT_TIMEOUT)
|
||||
.timeout(maestro_protocol::client::DEFAULT_REQUEST_TIMEOUT)
|
||||
.build()
|
||||
.expect("build reqwest client");
|
||||
Self {
|
||||
cfg,
|
||||
catalog,
|
||||
emulators: SessionEmulators::default(),
|
||||
state: AppState::new(),
|
||||
spawn: SpawnModal::default(),
|
||||
help,
|
||||
chord: ChordState::new(chord_timeout),
|
||||
daemon,
|
||||
http,
|
||||
region: RegionMap::default(),
|
||||
last_sent_size: HashMap::new(),
|
||||
clicks: ClickTracker::default(),
|
||||
@@ -152,9 +178,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 +201,12 @@ impl App {
|
||||
Effect::Attach(id) => self.attach(id),
|
||||
Effect::Spawn(req) => self.submit_spawn(req).await,
|
||||
Effect::Kill(id) => self.submit_kill(id).await,
|
||||
Effect::LoadDraft(tab_idx) => self.load_draft_data(tab_idx).await,
|
||||
Effect::RefillNames(tab_idx) => self.refill_names(tab_idx).await,
|
||||
Effect::ProbeGitRoot { tab, seq, path } => {
|
||||
self.probe_git_root(tab, seq, path).await
|
||||
}
|
||||
Effect::LoadBranches(tab) => self.load_branches(tab).await,
|
||||
Effect::Forward { id, bytes } => {
|
||||
if let Some(handle) = self.emulators.get(id) {
|
||||
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
||||
@@ -179,27 +217,7 @@ impl App {
|
||||
}
|
||||
|
||||
fn handle_overlay_input(&mut self, key: &InputKey) -> (OverlayDecision, Vec<Effect>) {
|
||||
use crate::overlays::SpawnInputResult;
|
||||
match self.state.overlay().cloned() {
|
||||
Some(OverlayKind::Spawn) => {
|
||||
let mut effects = Vec::new();
|
||||
match self.spawn.on_input(key) {
|
||||
SpawnInputResult::Consumed => {}
|
||||
SpawnInputResult::Cancel => {
|
||||
self.state.dismiss_overlay();
|
||||
}
|
||||
SpawnInputResult::Submit => {
|
||||
effects.push(Effect::Spawn(CreateSessionRequest {
|
||||
cwd: std::path::PathBuf::from(self.spawn.cwd.clone()),
|
||||
command: self.spawn.command.clone(),
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
}));
|
||||
}
|
||||
}
|
||||
(OverlayDecision::Consumed, effects)
|
||||
}
|
||||
Some(OverlayKind::KillConfirm(id)) => match key {
|
||||
InputKey::Char { c: 'y', .. } => {
|
||||
self.state.dismiss_overlay();
|
||||
@@ -265,13 +283,9 @@ 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::AttachHighlighted | Action::ToggleAttachHighlighted => {
|
||||
if let Some(sid) = self.pick_sidebar_target() {
|
||||
@@ -322,43 +336,204 @@ impl App {
|
||||
/// Encode a keystroke for the focused pane. `None` when there is nothing
|
||||
/// attached to send it to.
|
||||
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
|
||||
let sid = self.state.active_tab().pane?;
|
||||
let sid = self.state.active_session()?;
|
||||
self.emulators.get(sid)?;
|
||||
let bytes = match key {
|
||||
InputKey::Char { c, ctrl, alt, .. } => {
|
||||
let mut out = Vec::with_capacity(8);
|
||||
if *alt {
|
||||
out.push(0x1b);
|
||||
}
|
||||
if *ctrl && c.is_ascii_alphabetic() {
|
||||
out.push((c.to_ascii_uppercase() as u8) - b'@');
|
||||
} else {
|
||||
let mut b = [0u8; 4];
|
||||
let s = c.encode_utf8(&mut b);
|
||||
out.extend_from_slice(s.as_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
InputKey::Enter => b"\r".to_vec(),
|
||||
InputKey::Tab => b"\t".to_vec(),
|
||||
InputKey::BackTab => b"\x1b[Z".to_vec(),
|
||||
InputKey::Backspace => vec![0x7f],
|
||||
InputKey::Esc => vec![0x1b],
|
||||
InputKey::Up => b"\x1b[A".to_vec(),
|
||||
InputKey::Down => b"\x1b[B".to_vec(),
|
||||
InputKey::Right => b"\x1b[C".to_vec(),
|
||||
InputKey::Left => b"\x1b[D".to_vec(),
|
||||
InputKey::Mouse { .. } => return None,
|
||||
};
|
||||
let bytes = encode_key(key)?;
|
||||
Some(Effect::Forward { id: sid, bytes })
|
||||
}
|
||||
|
||||
/// Feed one keystroke into the active tab's draft form. Returns the spawn
|
||||
/// effect when the user accepted a valid form; an invalid one names the
|
||||
/// missing field inside the form (#64) instead of posting.
|
||||
fn handle_draft_input(&mut self, key: &InputKey) -> Vec<Effect> {
|
||||
let tab_idx = self.state.active_tab_idx;
|
||||
let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() else {
|
||||
return Vec::new();
|
||||
};
|
||||
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
|
||||
}
|
||||
|
||||
/// One `/api/names` batch. [`DaemonClient`] has no typed call for this
|
||||
/// route yet, so it goes through plain reqwest against the same base URL
|
||||
/// and query contract (`routes::NAMES` + `routes::COUNT_QUERY`).
|
||||
async fn fetch_names(&self, count: usize) -> Vec<String> {
|
||||
let url = format!(
|
||||
"{}{}",
|
||||
maestro_protocol::routes::normalize_base(self.daemon.base()),
|
||||
routes::NAMES
|
||||
);
|
||||
match self
|
||||
.http
|
||||
.get(url)
|
||||
.query(&[(routes::COUNT_QUERY, count)])
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp
|
||||
.json::<NameBatch>()
|
||||
.await
|
||||
.map(|batch| batch.names)
|
||||
.unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Populate a freshly opened draft: presets from `/api/info` (falling back
|
||||
/// to the built-ins already in place), projects from `/api/projects`, and
|
||||
/// a first name batch. Applies only if that tab still shows the draft —
|
||||
/// a slow response must never clobber what replaced it.
|
||||
async fn load_draft_data(&mut self, tab_idx: usize) {
|
||||
let (info, projects) = tokio::join!(self.daemon.daemon_info(), self.daemon.list_projects());
|
||||
let names = self.fetch_names(crate::draft::NAME_BATCH_SIZE).await;
|
||||
let Some(PaneRef::Draft(draft)) = self
|
||||
.state
|
||||
.tabs
|
||||
.get_mut(tab_idx)
|
||||
.and_then(|t| t.pane.as_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Ok(info) = info {
|
||||
draft.home_dir.clone_from(&info.home_dir);
|
||||
if !info.presets.is_empty() {
|
||||
draft.set_presets(info.presets);
|
||||
}
|
||||
}
|
||||
if let Ok(projects) = projects {
|
||||
draft.set_projects(projects, Utc::now());
|
||||
}
|
||||
if !names.is_empty() {
|
||||
draft.seed_names(names);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch a fresh name batch after a reroll drained the local queue.
|
||||
async fn refill_names(&mut self, tab_idx: usize) {
|
||||
let names = self.fetch_names(crate::draft::NAME_BATCH_SIZE).await;
|
||||
let Some(PaneRef::Draft(draft)) = self
|
||||
.state
|
||||
.tabs
|
||||
.get_mut(tab_idx)
|
||||
.and_then(|t| t.pane.as_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !names.is_empty() {
|
||||
draft.extend_names(names);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the draft target's enclosing repository via `/api/gitroot`
|
||||
/// (#65). A miss unticks the worktree toggle inside `resolve_gitroot`;
|
||||
/// a hit may schedule a branch load (Existing mode), served inline.
|
||||
async fn probe_git_root(&mut self, tab_idx: usize, seq: u32, path: PathBuf) {
|
||||
let root = self
|
||||
.daemon
|
||||
.git_root(&path.to_string_lossy())
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|g| g.root);
|
||||
let Some(PaneRef::Draft(draft)) = self
|
||||
.state
|
||||
.tabs
|
||||
.get_mut(tab_idx)
|
||||
.and_then(|t| t.pane.as_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
draft.resolve_gitroot(seq, root);
|
||||
if draft.take_branch_load().is_some() {
|
||||
self.load_branches(tab_idx).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// One `/api/branches` batch for the draft's current cwd (#65). Same
|
||||
/// reqwest route as [`Self::fetch_names`]: no typed client call exists.
|
||||
async fn load_branches(&mut self, tab_idx: usize) {
|
||||
let Some(path) = self
|
||||
.state
|
||||
.tabs
|
||||
.get(tab_idx)
|
||||
.and_then(|t| t.pane.as_ref())
|
||||
.and_then(|p| match p {
|
||||
PaneRef::Draft(d) => d.cwd(),
|
||||
_ => None,
|
||||
})
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let url = format!(
|
||||
"{}{}",
|
||||
maestro_protocol::routes::normalize_base(self.daemon.base()),
|
||||
routes::BRANCHES
|
||||
);
|
||||
let resp = self
|
||||
.http
|
||||
.get(url)
|
||||
.query(&[(routes::PATH_QUERY, path.to_string_lossy().as_ref())])
|
||||
.send()
|
||||
.await;
|
||||
// On failure the picker stays empty; "new" mode still works.
|
||||
let branches = match resp {
|
||||
Ok(resp) => resp.json::<Vec<BranchInfo>>().await.unwrap_or_default(),
|
||||
Err(_) => return,
|
||||
};
|
||||
let Some(PaneRef::Draft(draft)) = self
|
||||
.state
|
||||
.tabs
|
||||
.get_mut(tab_idx)
|
||||
.and_then(|t| t.pane.as_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
draft.set_branches(&path, branches);
|
||||
}
|
||||
|
||||
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}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,7 +549,12 @@ impl App {
|
||||
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||
) -> Result<(), TuiError> {
|
||||
let snap = self.catalog.snapshot();
|
||||
let attached: HashSet<Uuid> = self.state.tabs.iter().filter_map(|t| t.pane).collect();
|
||||
let attached: HashSet<Uuid> = self
|
||||
.state
|
||||
.tabs
|
||||
.iter()
|
||||
.filter_map(crate::state::Tab::session)
|
||||
.collect();
|
||||
let sidebar = Sidebar {
|
||||
sessions: snap.sessions.clone(),
|
||||
highlight: self.state.sidebar_highlight,
|
||||
@@ -384,7 +564,7 @@ impl App {
|
||||
tab_names: self.state.tabs.iter().map(|t| t.name.clone()).collect(),
|
||||
active_idx: self.state.active_tab_idx,
|
||||
};
|
||||
let active_sid = self.state.active_tab().pane;
|
||||
let active_sid = self.state.active_session();
|
||||
let pane = active_sid.and_then(|sid| {
|
||||
self.emulators.get(sid).map(|h| Pane {
|
||||
screen: h.screen.clone(),
|
||||
@@ -392,13 +572,17 @@ impl App {
|
||||
focused: matches!(self.state.focus, Focus::Pane),
|
||||
})
|
||||
});
|
||||
let draft = match self.state.active_tab().pane.as_ref() {
|
||||
Some(PaneRef::Draft(d)) => Some(d),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let inputs = RenderInputs {
|
||||
sidebar: &sidebar,
|
||||
tab_bar: &tab_bar,
|
||||
pane: pane.as_ref(),
|
||||
state: &self.state,
|
||||
spawn: &self.spawn,
|
||||
draft,
|
||||
help: &self.help,
|
||||
};
|
||||
|
||||
@@ -446,6 +630,52 @@ fn visible_sidebar_order(snap: &crate::catalog::CatalogSnapshot) -> Vec<Uuid> {
|
||||
snap.sessions.iter().map(|s| s.id).collect()
|
||||
}
|
||||
|
||||
/// Wire encoding for a keystroke headed to a session's PTY. `None` for keys
|
||||
/// with no byte representation (mouse events). A bracketed paste is forwarded
|
||||
/// verbatim — the remote readline reassembles it, since the pane's PTY also
|
||||
/// has bracketed paste enabled.
|
||||
fn encode_key(key: &InputKey) -> Option<Vec<u8>> {
|
||||
match key {
|
||||
InputKey::Char { c, ctrl, alt, .. } => {
|
||||
let mut out = Vec::with_capacity(8);
|
||||
if *alt {
|
||||
out.push(0x1b);
|
||||
}
|
||||
if *ctrl && c.is_ascii_alphabetic() {
|
||||
out.push((c.to_ascii_uppercase() as u8) - b'@');
|
||||
} else {
|
||||
let mut b = [0u8; 4];
|
||||
let s = c.encode_utf8(&mut b);
|
||||
out.extend_from_slice(s.as_bytes());
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
InputKey::Enter => Some(b"\r".to_vec()),
|
||||
InputKey::Tab => Some(b"\t".to_vec()),
|
||||
InputKey::BackTab => Some(b"\x1b[Z".to_vec()),
|
||||
InputKey::Backspace => Some(vec![0x7f]),
|
||||
InputKey::Esc => Some(vec![0x1b]),
|
||||
InputKey::Up => Some(b"\x1b[A".to_vec()),
|
||||
InputKey::Down => Some(b"\x1b[B".to_vec()),
|
||||
InputKey::Right => Some(b"\x1b[C".to_vec()),
|
||||
InputKey::Left => Some(b"\x1b[D".to_vec()),
|
||||
InputKey::Home => Some(b"\x1b[H".to_vec()),
|
||||
InputKey::End => Some(b"\x1b[F".to_vec()),
|
||||
InputKey::Delete => Some(b"\x1b[3~".to_vec()),
|
||||
InputKey::Paste(text) => {
|
||||
// Wrap in the DECSET 2004 markers. Raw bytes look like typed keys
|
||||
// to an app that enabled bracketed paste, so a multi-line paste
|
||||
// would execute line by line instead of arriving as one block.
|
||||
let mut out = Vec::with_capacity(text.len() + 12);
|
||||
out.extend_from_slice(b"\x1b[200~");
|
||||
out.extend_from_slice(text.as_bytes());
|
||||
out.extend_from_slice(b"\x1b[201~");
|
||||
Some(out)
|
||||
}
|
||||
InputKey::Mouse { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the lines shown by the help overlay from the live keymap, so the
|
||||
/// user always sees the bindings that are actually active (defaults + their
|
||||
/// own `[tui.keymap.*]` overrides).
|
||||
@@ -473,16 +703,17 @@ fn render_help_lines(cfg: &TuiConfig) -> Vec<String> {
|
||||
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 +744,33 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_spawn_modal_sets_overlay() {
|
||||
fn new_draft_pane_opens_a_focused_draft_in_the_empty_tab() {
|
||||
let mut app = make_app();
|
||||
app.dispatch(Action::OpenSpawnModal);
|
||||
assert!(matches!(app.state.overlay(), Some(OverlayKind::Spawn)));
|
||||
app.dispatch(Action::NewDraftPane);
|
||||
assert!(matches!(
|
||||
app.state.active_tab().pane,
|
||||
Some(PaneRef::Draft(_))
|
||||
));
|
||||
assert_eq!(app.state.focus, Focus::Pane);
|
||||
assert_eq!(app.state.mode, Mode::Insert);
|
||||
assert!(app.state.overlay().is_none(), "no overlay involved");
|
||||
}
|
||||
|
||||
/// A tab already showing a session gets a new tab for the draft.
|
||||
#[test]
|
||||
fn new_draft_pane_over_a_session_opens_a_new_tab() {
|
||||
let sid = Uuid::from_u128(10);
|
||||
let mut app = app_with_highlighted_session(sid);
|
||||
app.dispatch(Action::AttachHighlighted);
|
||||
|
||||
app.dispatch(Action::NewDraftPane);
|
||||
assert_eq!(app.state.tabs.len(), 2);
|
||||
assert_eq!(app.state.active_tab_idx, 1);
|
||||
assert!(matches!(
|
||||
app.state.active_tab().pane,
|
||||
Some(PaneRef::Draft(_))
|
||||
));
|
||||
assert_eq!(app.state.tabs[0].session(), Some(sid));
|
||||
}
|
||||
|
||||
/// Seed the catalog with one session and highlight it.
|
||||
@@ -551,7 +805,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 +838,235 @@ 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() {
|
||||
/// 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<serde_json::Value> {
|
||||
Json(json!({
|
||||
"hostname": "box",
|
||||
"version": "0.1.0",
|
||||
"home_dir": "/home/u",
|
||||
"presets": [
|
||||
{"id": "aider", "label": "Aider", "command": "aider"},
|
||||
{"id": "custom", "label": "Custom…", "command": ""}
|
||||
]
|
||||
}))
|
||||
}
|
||||
async fn projects() -> Json<serde_json::Value> {
|
||||
Json(json!([
|
||||
{"path": "/repos/maestro", "name": "maestro",
|
||||
"last_activity": chrono::Utc::now().to_rfc3339(),
|
||||
"last_command": "claude"}
|
||||
]))
|
||||
}
|
||||
async fn names() -> Json<serde_json::Value> {
|
||||
Json(json!({"names": ["crimson-otter", "amber-falcon"]}))
|
||||
}
|
||||
|
||||
let app_router = axum::Router::new()
|
||||
.route(maestro_protocol::routes::INFO, get(info))
|
||||
.route(maestro_protocol::routes::PROJECTS, get(projects))
|
||||
.route(maestro_protocol::routes::NAMES, get(names));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() });
|
||||
|
||||
let mut app = make_app_at(&base);
|
||||
let effects = app.dispatch(Action::NewDraftPane);
|
||||
assert!(matches!(effects.as_slice(), [Effect::LoadDraft(0)]));
|
||||
app.perform(effects).await;
|
||||
|
||||
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
|
||||
panic!("expected a draft pane");
|
||||
};
|
||||
assert_eq!(draft.command(), "aider");
|
||||
assert_eq!(draft.rows.len(), 1);
|
||||
assert_eq!(draft.rows[0].project.name, "maestro");
|
||||
assert_eq!(draft.home_dir, "/home/u");
|
||||
assert_eq!(draft.name.value(), "crimson-otter");
|
||||
}
|
||||
|
||||
/// Keys fed while no draft exists must be ignored, not panic.
|
||||
#[test]
|
||||
fn draft_input_is_a_no_op_without_a_draft() {
|
||||
let mut app = make_app();
|
||||
assert!(app.handle_draft_input(&InputKey::char('x')).is_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 +1095,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;
|
||||
|
||||
@@ -45,7 +45,7 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
||||
("up", "SidebarPrev"),
|
||||
// Catalog actions
|
||||
("enter", "AttachHighlighted"),
|
||||
("s", "OpenSpawnModal"),
|
||||
("s", "NewDraftPane"),
|
||||
("x", "KillHighlighted"),
|
||||
// Tabs
|
||||
("ctrl-t", "NewTab"),
|
||||
@@ -155,6 +155,10 @@ mod tests {
|
||||
cfg.keymap.resolve(Mode::Normal, "g t"),
|
||||
Some(Action::NextTab),
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Normal, "s"),
|
||||
Some(Action::NewDraftPane),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)";
|
||||
|
||||
@@ -7,17 +7,20 @@ pub mod app;
|
||||
pub mod catalog;
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
pub mod draft;
|
||||
pub mod emulators;
|
||||
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;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -1,163 +1,16 @@
|
||||
//! Overlay widgets: spawn modal, kill-confirm prompt, help overlay,
|
||||
//! transient toast.
|
||||
//! Overlay widgets: kill-confirm prompt, help overlay, transient toast.
|
||||
//!
|
||||
//! The spawn form is no longer an overlay (issue #63) — it lives in draft
|
||||
//! panes ([`crate::draft::DraftPane`]).
|
||||
//!
|
||||
//! All overlays are centered popups drawn over the rest of the UI; the
|
||||
//! `Clear` widget wipes the underlying buffer before drawing.
|
||||
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SpawnField {
|
||||
#[default]
|
||||
Cwd,
|
||||
Command,
|
||||
Submit,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct SpawnModal {
|
||||
pub cwd: String,
|
||||
pub command: String,
|
||||
pub focused_field: SpawnField,
|
||||
}
|
||||
|
||||
impl SpawnModal {
|
||||
pub fn next_field(&mut self) {
|
||||
self.focused_field = match self.focused_field {
|
||||
SpawnField::Cwd => SpawnField::Command,
|
||||
SpawnField::Command => SpawnField::Submit,
|
||||
SpawnField::Submit => SpawnField::Cwd,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn prev_field(&mut self) {
|
||||
self.focused_field = match self.focused_field {
|
||||
SpawnField::Cwd => SpawnField::Submit,
|
||||
SpawnField::Command => SpawnField::Cwd,
|
||||
SpawnField::Submit => SpawnField::Command,
|
||||
};
|
||||
}
|
||||
|
||||
/// Handle one keystroke while this modal owns input. Returns
|
||||
/// [`SpawnInputResult::Submit`] when the user accepts; the caller fires
|
||||
/// the POST and dismisses the modal.
|
||||
pub fn on_input(&mut self, key: &crate::input::InputKey) -> SpawnInputResult {
|
||||
use crate::input::InputKey;
|
||||
match key {
|
||||
InputKey::Tab => {
|
||||
self.next_field();
|
||||
SpawnInputResult::Consumed
|
||||
}
|
||||
InputKey::BackTab => {
|
||||
self.prev_field();
|
||||
SpawnInputResult::Consumed
|
||||
}
|
||||
InputKey::Esc => SpawnInputResult::Cancel,
|
||||
InputKey::Enter => match self.focused_field {
|
||||
SpawnField::Submit => SpawnInputResult::Submit,
|
||||
_ => {
|
||||
self.next_field();
|
||||
SpawnInputResult::Consumed
|
||||
}
|
||||
},
|
||||
InputKey::Char { c, .. } => {
|
||||
match self.focused_field {
|
||||
SpawnField::Cwd => self.cwd.push(*c),
|
||||
SpawnField::Command => self.command.push(*c),
|
||||
SpawnField::Submit => {}
|
||||
}
|
||||
SpawnInputResult::Consumed
|
||||
}
|
||||
InputKey::Backspace => {
|
||||
match self.focused_field {
|
||||
SpawnField::Cwd => {
|
||||
self.cwd.pop();
|
||||
}
|
||||
SpawnField::Command => {
|
||||
self.command.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
SpawnInputResult::Consumed
|
||||
}
|
||||
_ => SpawnInputResult::Consumed,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(&self, f: &mut Frame, parent: Rect) {
|
||||
let area = centered(parent, 60, 10);
|
||||
f.render_widget(Clear, area);
|
||||
let block = Block::default().title(" spawn ").borders(Borders::ALL);
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
let rows = Layout::vertical([
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(inner);
|
||||
let highlight = |field: SpawnField| {
|
||||
if self.focused_field == field {
|
||||
Style::default().add_modifier(Modifier::REVERSED)
|
||||
} else {
|
||||
Style::default()
|
||||
}
|
||||
};
|
||||
let field_row = |label: &str, value: &str, style: Style| {
|
||||
Paragraph::new(Line::from(vec![
|
||||
ratatui::text::Span::raw(format!("{label:8} ")),
|
||||
ratatui::text::Span::styled(value.to_string(), style),
|
||||
]))
|
||||
};
|
||||
f.render_widget(
|
||||
field_row("cwd:", &self.cwd, highlight(SpawnField::Cwd)),
|
||||
rows[0],
|
||||
);
|
||||
f.render_widget(
|
||||
field_row("command:", &self.command, highlight(SpawnField::Command)),
|
||||
rows[1],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![ratatui::text::Span::styled(
|
||||
"[ Spawn ]",
|
||||
highlight(SpawnField::Submit),
|
||||
)])),
|
||||
rows[2],
|
||||
);
|
||||
|
||||
// Place the cursor at the end of the currently-focused text field so
|
||||
// the user can see where their typing lands. Label is fixed-width
|
||||
// 8 chars + 1 space = column 9 inside the row.
|
||||
let cursor_col_for = |value: &str| -> u16 {
|
||||
inner
|
||||
.x
|
||||
.saturating_add(9)
|
||||
.saturating_add(value.chars().count() as u16)
|
||||
};
|
||||
match self.focused_field {
|
||||
SpawnField::Cwd => f.set_cursor_position((cursor_col_for(&self.cwd), rows[0].y)),
|
||||
SpawnField::Command => {
|
||||
f.set_cursor_position((cursor_col_for(&self.command), rows[1].y))
|
||||
}
|
||||
SpawnField::Submit => {
|
||||
// The submit button is indicated by the REVERSED style; no
|
||||
// cursor placement.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SpawnInputResult {
|
||||
Consumed,
|
||||
Cancel,
|
||||
Submit,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KillConfirm {
|
||||
pub label: String,
|
||||
@@ -264,33 +117,6 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_modal_renders_field_labels() {
|
||||
let modal = SpawnModal {
|
||||
cwd: "/x".into(),
|
||||
command: "bash".into(),
|
||||
focused_field: SpawnField::Cwd,
|
||||
};
|
||||
let rendered = render_to_string(80, 20, |f| modal.render(f, f.area()));
|
||||
for s in ["cwd", "command", "Spawn", "/x", "bash"] {
|
||||
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_modal_field_rotation() {
|
||||
let mut modal = SpawnModal::default();
|
||||
assert_eq!(modal.focused_field, SpawnField::Cwd);
|
||||
modal.next_field();
|
||||
assert_eq!(modal.focused_field, SpawnField::Command);
|
||||
modal.next_field();
|
||||
assert_eq!(modal.focused_field, SpawnField::Submit);
|
||||
modal.next_field();
|
||||
assert_eq!(modal.focused_field, SpawnField::Cwd);
|
||||
modal.prev_field();
|
||||
assert_eq!(modal.focused_field, SpawnField::Submit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kill_confirm_renders_prompt() {
|
||||
let modal = KillConfirm {
|
||||
@@ -308,34 +134,11 @@ mod tests {
|
||||
let help = HelpOverlay {
|
||||
lines: vec![
|
||||
"i EnterInsert".into(),
|
||||
"s OpenSpawnModal".into(),
|
||||
"s NewDraftPane".into(),
|
||||
],
|
||||
};
|
||||
let rendered = render_to_string(80, 12, |f| help.render(f, f.area()));
|
||||
assert!(rendered.contains("EnterInsert"));
|
||||
assert!(rendered.contains("OpenSpawnModal"));
|
||||
}
|
||||
/// Shift-Tab walked nowhere: `prev_field` existed with no caller, so the
|
||||
/// spawn modal could only be cycled forwards.
|
||||
#[test]
|
||||
fn shift_tab_walks_the_spawn_modal_backwards() {
|
||||
let mut modal = SpawnModal::default();
|
||||
assert_eq!(modal.focused_field, SpawnField::Cwd);
|
||||
|
||||
modal.on_input(&crate::input::InputKey::BackTab);
|
||||
assert_eq!(modal.focused_field, SpawnField::Submit);
|
||||
|
||||
modal.on_input(&crate::input::InputKey::BackTab);
|
||||
assert_eq!(modal.focused_field, SpawnField::Command);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_and_shift_tab_are_inverses() {
|
||||
let mut modal = SpawnModal::default();
|
||||
for _ in 0..3 {
|
||||
modal.on_input(&crate::input::InputKey::Tab);
|
||||
modal.on_input(&crate::input::InputKey::BackTab);
|
||||
assert_eq!(modal.focused_field, SpawnField::Cwd);
|
||||
}
|
||||
assert!(rendered.contains("NewDraftPane"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Pure helpers behind the draft pane's project picker (#64): staleness,
|
||||
//! recency ordering, worktree grouping, and free-text filtering. Mirrors
|
||||
//! `web/src/projectList.ts` so both clients order and dim identically — the
|
||||
//! daemon reports the timestamps; what "stale" means is a client decision
|
||||
//! (#54).
|
||||
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use maestro_protocol::Project;
|
||||
|
||||
/// Projects whose last activity is older than this are dimmed and sort after
|
||||
/// fresh ones — still reachable, visibly stale.
|
||||
pub const STALE_AFTER_DAYS: i64 = 7;
|
||||
|
||||
/// True when a project has no usable activity timestamp, one in the future,
|
||||
/// or its last activity is older than [`STALE_AFTER_DAYS`] days.
|
||||
pub fn is_stale(project: &Project, now: DateTime<Utc>) -> bool {
|
||||
match project.last_activity {
|
||||
None => true,
|
||||
Some(t) => {
|
||||
let age = now.signed_duration_since(t);
|
||||
age < TimeDelta::zero() || age > TimeDelta::days(STALE_AFTER_DAYS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn time_of(project: &Project) -> DateTime<Utc> {
|
||||
project.last_activity.unwrap_or(DateTime::UNIX_EPOCH)
|
||||
}
|
||||
|
||||
/// Fresh projects first, each block newest-activity-first; unknown or future
|
||||
/// timestamps sort as stalest. Ties fall back to name, then path, so the
|
||||
/// order is stable.
|
||||
pub fn sort_projects(mut projects: Vec<Project>, now: DateTime<Utc>) -> Vec<Project> {
|
||||
projects.sort_by(|a, b| {
|
||||
let stale = is_stale(a, now).cmp(&is_stale(b, now));
|
||||
stale
|
||||
.then_with(|| time_of(b).cmp(&time_of(a)))
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
.then_with(|| a.path.cmp(&b.path))
|
||||
});
|
||||
projects
|
||||
}
|
||||
|
||||
/// Substring filter over project name and full path, case-insensitive.
|
||||
pub fn matches_filter(project: &Project, query: &str) -> bool {
|
||||
let q = query.trim().to_lowercase();
|
||||
if q.is_empty() {
|
||||
return true;
|
||||
}
|
||||
project.name.to_lowercase().contains(&q)
|
||||
|| project.path.to_string_lossy().to_lowercase().contains(&q)
|
||||
}
|
||||
|
||||
/// One repository group: the main checkout followed by its linked worktrees.
|
||||
/// A group's position follows its freshest member, since a worktree worked on
|
||||
/// today should pull its (dormant) base repo up with it.
|
||||
#[derive(Debug)]
|
||||
pub struct ProjectGroup<'a> {
|
||||
pub main: &'a Project,
|
||||
pub worktrees: Vec<&'a Project>,
|
||||
}
|
||||
|
||||
/// Group `sorted` projects by their base repository. A worktree nests under
|
||||
/// its main checkout even when the main sorts later; a main checkout missing
|
||||
/// from the list doesn't drop its worktrees.
|
||||
pub fn group_projects(sorted: &[Project]) -> Vec<ProjectGroup<'_>> {
|
||||
let mut members: Vec<(String, Vec<&Project>)> = Vec::new();
|
||||
for p in sorted {
|
||||
let key = p
|
||||
.base_repo
|
||||
.as_ref()
|
||||
.unwrap_or(&p.path)
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
match members.iter_mut().find(|(k, _)| *k == key) {
|
||||
Some((_, list)) => list.push(p),
|
||||
None => members.push((key, vec![p])),
|
||||
}
|
||||
}
|
||||
members
|
||||
.into_iter()
|
||||
.map(|(key, mut list)| {
|
||||
let main_pos = list
|
||||
.iter()
|
||||
.position(|p| p.path.to_string_lossy() == key && !p.is_worktree)
|
||||
.unwrap_or(0);
|
||||
let main = list.remove(main_pos);
|
||||
// Keep the daemon's newest-first order within the group.
|
||||
list.sort_by_key(|p| std::cmp::Reverse(time_of(p)));
|
||||
ProjectGroup {
|
||||
main,
|
||||
worktrees: list,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn days_ago(days: i64) -> DateTime<Utc> {
|
||||
Utc::now() - TimeDelta::days(days)
|
||||
}
|
||||
|
||||
fn project(name: &str, path: &str, last_activity: Option<DateTime<Utc>>) -> Project {
|
||||
Project {
|
||||
path: PathBuf::from(path),
|
||||
name: name.into(),
|
||||
is_worktree: false,
|
||||
base_repo: None,
|
||||
last_activity,
|
||||
last_command: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn worktree(
|
||||
name: &str,
|
||||
path: &str,
|
||||
base: &str,
|
||||
last_activity: Option<DateTime<Utc>>,
|
||||
) -> Project {
|
||||
Project {
|
||||
is_worktree: true,
|
||||
base_repo: Some(PathBuf::from(base)),
|
||||
..project(name, path, last_activity)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_and_future_activity_are_stale() {
|
||||
let now = Utc::now();
|
||||
assert!(is_stale(&project("x", "/x", None), now));
|
||||
assert!(is_stale(
|
||||
&project("x", "/x", Some(now + TimeDelta::hours(1))),
|
||||
now
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_within_a_week_is_fresh_beyond_is_stale() {
|
||||
let now = Utc::now();
|
||||
assert!(!is_stale(&project("x", "/x", Some(days_ago(6))), now));
|
||||
assert!(is_stale(&project("x", "/x", Some(days_ago(8))), now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_sorts_before_stale_then_newest_first() {
|
||||
let now = Utc::now();
|
||||
let sorted = sort_projects(
|
||||
vec![
|
||||
project("old-fresh", "/a", Some(days_ago(5))),
|
||||
project("stale", "/b", Some(days_ago(30))),
|
||||
project("newest", "/c", Some(days_ago(1))),
|
||||
project("unknown", "/d", None),
|
||||
],
|
||||
now,
|
||||
);
|
||||
let names: Vec<&str> = sorted.iter().map(|p| p.name.as_str()).collect();
|
||||
assert_eq!(names, ["newest", "old-fresh", "stale", "unknown"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ties_break_by_name_then_path() {
|
||||
let now = Utc::now();
|
||||
let t = Some(days_ago(2));
|
||||
let sorted = sort_projects(vec![project("b", "/b", t), project("a", "/a", t)], now);
|
||||
let names: Vec<&str> = sorted.iter().map(|p| p.name.as_str()).collect();
|
||||
assert_eq!(names, ["a", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_matches_name_or_path_case_insensitively() {
|
||||
let p = project("Maestro", "/home/u/tea/Maestro", None);
|
||||
assert!(matches_filter(&p, "maes"));
|
||||
assert!(matches_filter(&p, " tea "));
|
||||
assert!(!matches_filter(&p, "zzz"));
|
||||
assert!(matches_filter(&p, ""), "empty query matches everything");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktrees_nest_under_their_main_checkout() {
|
||||
let now = Utc::now();
|
||||
let main = project("maestro", "/repos/maestro", Some(days_ago(10)));
|
||||
let wt = worktree("64-tui", "/repos/wt64", "/repos/maestro", Some(days_ago(1)));
|
||||
let other = project("other", "/repos/other", Some(days_ago(2)));
|
||||
let sorted = sort_projects(vec![main, wt, other], now);
|
||||
let groups = group_projects(&sorted);
|
||||
assert_eq!(groups.len(), 2);
|
||||
assert_eq!(
|
||||
groups[0].main.name, "maestro",
|
||||
"fresh worktree pulls its repo up"
|
||||
);
|
||||
assert_eq!(groups[0].worktrees.len(), 1);
|
||||
assert_eq!(groups[0].worktrees[0].name, "64-tui");
|
||||
assert_eq!(groups[1].main.name, "other");
|
||||
}
|
||||
|
||||
/// A main checkout absent from the scan must not drop its worktrees.
|
||||
#[test]
|
||||
fn worktrees_survive_a_missing_main_checkout() {
|
||||
let now = Utc::now();
|
||||
let wt = worktree(
|
||||
"solo-wt",
|
||||
"/repos/solo-wt",
|
||||
"/repos/solo",
|
||||
Some(days_ago(1)),
|
||||
);
|
||||
let sorted = sort_projects(vec![wt], now);
|
||||
let groups = group_projects(&sorted);
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].main.name, "solo-wt");
|
||||
assert!(groups[0].worktrees.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use ratatui::Frame;
|
||||
|
||||
use crate::input::Region;
|
||||
use crate::keymap::Mode;
|
||||
use crate::overlays::{HelpOverlay, KillConfirm, SpawnModal, Toast};
|
||||
use crate::overlays::{HelpOverlay, KillConfirm, Toast};
|
||||
use crate::pane::Pane;
|
||||
use crate::sidebar::Sidebar;
|
||||
use crate::state::{AppState, Focus, OverlayKind};
|
||||
@@ -63,8 +63,9 @@ pub struct RenderInputs<'a> {
|
||||
pub sidebar: &'a Sidebar,
|
||||
pub tab_bar: &'a TabBar,
|
||||
pub pane: Option<&'a Pane>,
|
||||
/// Inline spawn form when the active tab's pane is a draft (issue #63).
|
||||
pub draft: Option<&'a crate::draft::DraftPane>,
|
||||
pub state: &'a AppState,
|
||||
pub spawn: &'a SpawnModal,
|
||||
pub help: &'a HelpOverlay,
|
||||
}
|
||||
|
||||
@@ -131,6 +132,9 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
if let Some(p) = inputs.pane {
|
||||
p.render(f, pane_area);
|
||||
}
|
||||
if let Some(d) = inputs.draft {
|
||||
d.render(f, pane_area, pane_focused);
|
||||
}
|
||||
map.pane = Some(pane_area);
|
||||
|
||||
// Status bar — always visible, never eclipsed by overlays so the user can
|
||||
@@ -138,10 +142,6 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
render_status_bar(f, status_area, inputs.state);
|
||||
|
||||
match inputs.state.overlay() {
|
||||
Some(OverlayKind::Spawn) => {
|
||||
inputs.spawn.render(f, area);
|
||||
map.modal = Some(area);
|
||||
}
|
||||
Some(OverlayKind::KillConfirm(id)) => {
|
||||
let kc = KillConfirm {
|
||||
label: id.to_string(),
|
||||
@@ -186,7 +186,7 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) {
|
||||
Focus::Pane => "pane",
|
||||
};
|
||||
let hint = match state.mode {
|
||||
Mode::Normal => "j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit",
|
||||
Mode::Normal => "j/k: nav • enter: attach • s: new draft • ?: help • ^Wq: quit",
|
||||
Mode::Insert => "^a: back to normal",
|
||||
};
|
||||
let mode_style = match state.mode {
|
||||
@@ -266,4 +266,64 @@ mod tests {
|
||||
assert_eq!(map.region_at(15, 10), Region::Modal);
|
||||
assert_eq!(map.region_at(3, 12), Region::SidebarRow); // outside modal
|
||||
}
|
||||
|
||||
/// Issue #63: an empty pane is a draft — the frame must show the spawn
|
||||
/// form where the terminal would otherwise be.
|
||||
#[test]
|
||||
fn a_draft_pane_renders_the_spawn_form_in_its_pane_area() {
|
||||
use crate::overlays::HelpOverlay;
|
||||
use crate::state::PaneRef;
|
||||
use std::collections::HashSet;
|
||||
|
||||
let mut state = AppState::new();
|
||||
state.open_draft_pane();
|
||||
let draft = match &state.tabs[0].pane {
|
||||
Some(PaneRef::Draft(d)) => d,
|
||||
other => panic!("expected a draft pane, got {other:?}"),
|
||||
};
|
||||
let sidebar = Sidebar {
|
||||
sessions: Vec::new(),
|
||||
highlight: None,
|
||||
attached: HashSet::new(),
|
||||
};
|
||||
let tab_bar = TabBar {
|
||||
tab_names: vec!["1".into()],
|
||||
active_idx: 0,
|
||||
};
|
||||
let help = HelpOverlay { lines: Vec::new() };
|
||||
let inputs = RenderInputs {
|
||||
sidebar: &sidebar,
|
||||
tab_bar: &tab_bar,
|
||||
pane: None,
|
||||
draft: Some(draft),
|
||||
state: &state,
|
||||
help: &help,
|
||||
};
|
||||
|
||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
draw(f, inputs);
|
||||
})
|
||||
.unwrap();
|
||||
let buf = terminal.backend().buffer().clone();
|
||||
let mut out = String::new();
|
||||
for y in 0..buf.area.height {
|
||||
for x in 0..buf.area.width {
|
||||
out.push_str(buf[(x, y)].symbol());
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
for s in [
|
||||
"draft",
|
||||
"cmd:",
|
||||
"dir:",
|
||||
"name:",
|
||||
"[ Spawn ]",
|
||||
"Other directory…",
|
||||
] {
|
||||
assert!(out.contains(s), "missing {s:?}:\n{out}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+112
-15
@@ -1,14 +1,16 @@
|
||||
//! `AppState` — the top-level mutable state of the running TUI.
|
||||
//!
|
||||
//! v1 keeps it small on purpose: a list of tabs (each bound to at most one
|
||||
//! session), an active tab index, focus split between sidebar and pane, the
|
||||
//! current input mode, and an optional overlay (spawn modal, kill confirm,
|
||||
//! help, transient toast).
|
||||
//! session or a draft form), an active tab index, focus split between sidebar
|
||||
//! and pane, the current input mode, and an optional overlay (kill confirm,
|
||||
//! help, transient toast). The spawn form is no longer an overlay — an empty
|
||||
//! pane is a draft pane (issue #63).
|
||||
//!
|
||||
//! v1.5 will replace `Tab::pane: Option<SessionId>` with a split tree.
|
||||
//! v1.5 will replace `Tab::pane`'s single slot with a split tree.
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::draft::DraftPane;
|
||||
use crate::keymap::Mode;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -17,15 +19,34 @@ pub enum Focus {
|
||||
Pane,
|
||||
}
|
||||
|
||||
/// What a tab's pane slot holds: a live session, or an inline draft form.
|
||||
// The draft variant dwarfs `Session(Uuid)`; tabs are moved rarely enough
|
||||
// that boxing every access costs more than the copy does.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum PaneRef {
|
||||
Session(Uuid),
|
||||
Draft(DraftPane),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Tab {
|
||||
pub name: String,
|
||||
pub pane: Option<Uuid>,
|
||||
pub pane: Option<PaneRef>,
|
||||
}
|
||||
|
||||
impl Tab {
|
||||
/// The session this tab shows, if it shows one. Draft panes have none.
|
||||
pub fn session(&self) -> Option<Uuid> {
|
||||
match &self.pane {
|
||||
Some(PaneRef::Session(sid)) => Some(*sid),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum OverlayKind {
|
||||
Spawn,
|
||||
KillConfirm(Uuid),
|
||||
Help,
|
||||
Toast(String),
|
||||
@@ -108,7 +129,8 @@ impl AppState {
|
||||
///
|
||||
/// Returns the session that tab was showing, if any, so the caller can
|
||||
/// release its emulator. Without that the tab's WebSocket task and vt100
|
||||
/// parser outlive the tab forever.
|
||||
/// parser outlive the tab forever. A draft pane holds no session and
|
||||
/// releases nothing.
|
||||
pub fn close_tab(&mut self) -> Option<Uuid> {
|
||||
if self.tabs.len() <= 1 {
|
||||
return None;
|
||||
@@ -117,9 +139,10 @@ impl AppState {
|
||||
if self.active_tab_idx >= self.tabs.len() {
|
||||
self.active_tab_idx = self.tabs.len() - 1;
|
||||
}
|
||||
let sid = closed.session()?;
|
||||
// Only release it if no other tab still shows the same session.
|
||||
let still_shown = self.tabs.iter().any(|t| t.pane == closed.pane);
|
||||
closed.pane.filter(|_| !still_shown)
|
||||
let still_shown = self.tabs.iter().any(|t| t.session() == Some(sid));
|
||||
(!still_shown).then_some(sid)
|
||||
}
|
||||
|
||||
pub fn next_tab(&mut self) {
|
||||
@@ -147,11 +170,37 @@ impl AppState {
|
||||
/// drop straight into Insert mode so keystrokes go to the session
|
||||
/// without an extra `i` press. The leader returns to Normal.
|
||||
pub fn attach_to_focused(&mut self, session_id: Uuid) {
|
||||
self.active_tab_mut().pane = Some(session_id);
|
||||
self.active_tab_mut().pane = Some(PaneRef::Session(session_id));
|
||||
self.focus = Focus::Pane;
|
||||
self.mode = Mode::Insert;
|
||||
}
|
||||
|
||||
/// Open a draft form in a pane (issue #63): reuse the empty active pane,
|
||||
/// otherwise open a fresh tab for it — v1 has no split tree, so "split"
|
||||
/// means a new tab. Focus lands in the pane, Insert mode on, so typing
|
||||
/// starts immediately, same flow as [`Self::attach_to_focused`].
|
||||
pub fn open_draft_pane(&mut self) {
|
||||
if self.active_tab().pane.is_some() {
|
||||
self.new_tab();
|
||||
}
|
||||
self.active_tab_mut().pane = Some(PaneRef::Draft(DraftPane::default()));
|
||||
self.focus = Focus::Pane;
|
||||
self.mode = Mode::Insert;
|
||||
}
|
||||
|
||||
/// A draft submit succeeded: replace whatever the active tab showed with
|
||||
/// the new session in place, matching the web client's behaviour (#57).
|
||||
pub fn complete_spawn(&mut self, sid: Uuid) {
|
||||
self.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
||||
self.focus = Focus::Pane;
|
||||
self.mode = Mode::Insert;
|
||||
}
|
||||
|
||||
/// The session shown by the active tab's pane, if any.
|
||||
pub fn active_session(&self) -> Option<Uuid> {
|
||||
self.active_tab().session()
|
||||
}
|
||||
|
||||
/// Move the sidebar highlight by `delta` (positive = down) within the
|
||||
/// given ordered list of selectable session ids. Wraps. No-op on empty.
|
||||
pub fn move_sidebar_highlight(&mut self, order: &[Uuid], delta: i32) {
|
||||
@@ -288,7 +337,7 @@ mod tests {
|
||||
let mut state = AppState::new();
|
||||
let sid = Uuid::from_u128(7);
|
||||
state.attach_to_focused(sid);
|
||||
assert_eq!(state.tabs[0].pane, Some(sid));
|
||||
assert_eq!(state.tabs[0].session(), Some(sid));
|
||||
assert_eq!(state.focus, Focus::Pane);
|
||||
// The user expects to type into the session right away; the leader
|
||||
// (default ctrl-a) is the way back.
|
||||
@@ -314,7 +363,7 @@ mod tests {
|
||||
let mut state = AppState::new();
|
||||
let sid = Uuid::from_u128(3);
|
||||
state.new_tab();
|
||||
state.active_tab_mut().pane = Some(sid);
|
||||
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
||||
|
||||
assert_eq!(state.close_tab(), Some(sid));
|
||||
}
|
||||
@@ -324,9 +373,9 @@ mod tests {
|
||||
fn close_tab_keeps_a_session_another_tab_still_shows() {
|
||||
let mut state = AppState::new();
|
||||
let sid = Uuid::from_u128(4);
|
||||
state.active_tab_mut().pane = Some(sid);
|
||||
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
||||
state.new_tab();
|
||||
state.active_tab_mut().pane = Some(sid);
|
||||
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
||||
|
||||
assert_eq!(state.close_tab(), None);
|
||||
}
|
||||
@@ -334,7 +383,7 @@ mod tests {
|
||||
#[test]
|
||||
fn close_tab_is_a_no_op_on_the_last_tab() {
|
||||
let mut state = AppState::new();
|
||||
state.active_tab_mut().pane = Some(Uuid::from_u128(5));
|
||||
state.active_tab_mut().pane = Some(PaneRef::Session(Uuid::from_u128(5)));
|
||||
assert_eq!(state.close_tab(), None);
|
||||
assert_eq!(state.tabs.len(), 1);
|
||||
}
|
||||
@@ -347,4 +396,52 @@ mod tests {
|
||||
assert!(state.dismiss_overlay());
|
||||
assert!(state.overlay().is_none());
|
||||
}
|
||||
|
||||
/// Issue #63: `s` over an empty pane turns that pane into a draft —
|
||||
/// focused, Insert mode on, typing starts immediately.
|
||||
#[test]
|
||||
fn open_draft_reuses_the_empty_active_pane() {
|
||||
let mut state = AppState::new();
|
||||
state.open_draft_pane();
|
||||
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_))));
|
||||
assert_eq!(state.focus, Focus::Pane);
|
||||
assert_eq!(state.mode, Mode::Insert);
|
||||
assert_eq!(state.tabs.len(), 1);
|
||||
}
|
||||
|
||||
/// A tab already showing a session gets a fresh tab for the draft; v1
|
||||
/// has no split tree, so a new tab is the "split".
|
||||
#[test]
|
||||
fn open_draft_over_a_session_opens_a_new_tab() {
|
||||
let mut state = AppState::new();
|
||||
state.attach_to_focused(Uuid::from_u128(9));
|
||||
state.open_draft_pane();
|
||||
assert_eq!(state.tabs.len(), 2);
|
||||
assert_eq!(state.active_tab_idx, 1);
|
||||
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_))));
|
||||
// The session tab is untouched.
|
||||
assert_eq!(state.tabs[0].session(), Some(Uuid::from_u128(9)));
|
||||
}
|
||||
|
||||
/// Web parity (#57): submitting fills the pane in place.
|
||||
#[test]
|
||||
fn complete_spawn_replaces_the_draft_with_the_session() {
|
||||
let mut state = AppState::new();
|
||||
state.open_draft_pane();
|
||||
let sid = Uuid::from_u128(21);
|
||||
state.complete_spawn(sid);
|
||||
assert_eq!(state.active_tab().session(), Some(sid));
|
||||
assert_eq!(state.focus, Focus::Pane);
|
||||
assert_eq!(state.mode, Mode::Insert);
|
||||
}
|
||||
|
||||
/// A closed draft tab holds no session, so nothing is released.
|
||||
#[test]
|
||||
fn close_tab_reports_nothing_for_a_draft_pane() {
|
||||
let mut state = AppState::new();
|
||||
state.open_draft_pane();
|
||||
state.new_tab();
|
||||
state.active_tab_idx = 0;
|
||||
assert_eq!(state.close_tab(), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
|
||||
use std::io;
|
||||
|
||||
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
|
||||
use crossterm::event::{
|
||||
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
|
||||
};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{
|
||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||
@@ -26,7 +28,12 @@ impl TerminalGuard {
|
||||
pub fn enter() -> Result<Self, TerminalError> {
|
||||
enable_raw_mode()?;
|
||||
let mut out = io::stdout();
|
||||
execute!(out, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
execute!(
|
||||
out,
|
||||
EnterAlternateScreen,
|
||||
EnableMouseCapture,
|
||||
EnableBracketedPaste
|
||||
)?;
|
||||
Ok(TerminalGuard)
|
||||
}
|
||||
|
||||
@@ -34,7 +41,12 @@ impl TerminalGuard {
|
||||
/// call it without producing weird errors.
|
||||
fn restore() {
|
||||
let mut out = io::stdout();
|
||||
let _ = execute!(out, DisableMouseCapture, LeaveAlternateScreen);
|
||||
let _ = execute!(
|
||||
out,
|
||||
DisableBracketedPaste,
|
||||
DisableMouseCapture,
|
||||
LeaveAlternateScreen
|
||||
);
|
||||
let _ = disable_raw_mode();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
//! Single-line text-input primitive shared by every TUI form field.
|
||||
//!
|
||||
//! Owns a `String`, a character-indexed cursor, and horizontal scroll state.
|
||||
//! All editing goes through [`TextInput::handle_key`] so callers feed the same
|
||||
//! canonical [`InputKey`] stream the rest of the app uses; bracketed paste
|
||||
//! arrives as one [`InputKey::Paste`] and is inserted as a single operation.
|
||||
//!
|
||||
//! This is the one piece of the TUI form layer that unit-tests without a
|
||||
//! terminal: drive it with `InputKey` sequences and inspect value/cursor.
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
|
||||
use crate::input::InputKey;
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct TextInput {
|
||||
value: String,
|
||||
/// Cursor position as a char offset into `value`.
|
||||
cursor: usize,
|
||||
/// Char offset of the leftmost visible cell. A `Cell` so `render` can
|
||||
/// re-clamp it without `&mut self` — widgets are drawn through shared
|
||||
/// references in this codebase.
|
||||
scroll: std::cell::Cell<usize>,
|
||||
}
|
||||
|
||||
impl TextInput {
|
||||
pub fn with_value(value: impl Into<String>) -> Self {
|
||||
let mut input = Self {
|
||||
value: value.into(),
|
||||
..Self::default()
|
||||
};
|
||||
input.cursor = input.value.chars().count();
|
||||
input
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &str {
|
||||
&self.value
|
||||
}
|
||||
|
||||
pub fn cursor(&self) -> usize {
|
||||
self.cursor
|
||||
}
|
||||
|
||||
/// Char offset of the leftmost visible cell; only meaningful after
|
||||
/// [`TextInput::render`] has clamped it to the field width.
|
||||
pub fn scroll_offset(&self) -> usize {
|
||||
self.scroll.get()
|
||||
}
|
||||
|
||||
/// Insert `text` at the cursor as one operation. Used both for typed
|
||||
/// characters and for whole bracketed-paste payloads.
|
||||
pub fn insert(&mut self, text: &str) {
|
||||
let at = self.byte_offset(self.cursor);
|
||||
self.value.insert_str(at, text);
|
||||
self.cursor += text.chars().count();
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: &InputKey) {
|
||||
match key {
|
||||
InputKey::Char {
|
||||
c,
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
..
|
||||
} => self.insert(&c.to_string()),
|
||||
InputKey::Char {
|
||||
c: 'w', ctrl: true, ..
|
||||
} => self.delete_word_backwards(),
|
||||
InputKey::Char {
|
||||
c: 'k', ctrl: true, ..
|
||||
} => self.kill_to_end(),
|
||||
// Emacs-style word motion; ctrl-arrow reaches us as a plain
|
||||
// Left/Right today, so these are the dependable binding.
|
||||
InputKey::Char {
|
||||
c: 'b', ctrl: true, ..
|
||||
} => self.cursor = self.word_left(),
|
||||
InputKey::Char {
|
||||
c: 'f', ctrl: true, ..
|
||||
} => self.cursor = self.word_right(),
|
||||
InputKey::Left => {
|
||||
self.cursor = self.cursor.saturating_sub(1);
|
||||
}
|
||||
InputKey::Right => {
|
||||
if self.cursor < self.value.chars().count() {
|
||||
self.cursor += 1;
|
||||
}
|
||||
}
|
||||
InputKey::Home => self.cursor = 0,
|
||||
InputKey::End => self.cursor = self.value.chars().count(),
|
||||
InputKey::Backspace => {
|
||||
if self.cursor > 0 {
|
||||
let at = self.byte_offset(self.cursor - 1);
|
||||
let end = self.byte_offset(self.cursor);
|
||||
self.value.replace_range(at..end, "");
|
||||
self.cursor -= 1;
|
||||
}
|
||||
}
|
||||
InputKey::Delete => {
|
||||
if self.cursor < self.value.chars().count() {
|
||||
let at = self.byte_offset(self.cursor);
|
||||
let end = self.byte_offset(self.cursor + 1);
|
||||
self.value.replace_range(at..end, "");
|
||||
}
|
||||
}
|
||||
InputKey::Paste(text) => self.insert(text),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw into `area` (expected one row tall), scrolling horizontally so the
|
||||
/// cursor stays visible, and return the screen column where the caller
|
||||
/// should place the terminal cursor.
|
||||
pub fn render(&self, area: Rect, buf: &mut Buffer, style: Style) -> u16 {
|
||||
let width = area.width as usize;
|
||||
let len = self.value.chars().count();
|
||||
let scroll = self.scroll.get().min(len);
|
||||
let scroll = if self.cursor < scroll {
|
||||
self.cursor
|
||||
} else if width > 0 && self.cursor.saturating_sub(scroll) >= width {
|
||||
self.cursor + 1 - width
|
||||
} else {
|
||||
scroll
|
||||
};
|
||||
self.scroll.set(scroll);
|
||||
for (col, c) in self.value.chars().skip(scroll).take(width).enumerate() {
|
||||
buf[(area.x + col as u16, area.y)]
|
||||
.set_symbol(&c.to_string())
|
||||
.set_style(style);
|
||||
}
|
||||
area.x + self.cursor.saturating_sub(scroll) as u16
|
||||
}
|
||||
|
||||
fn byte_offset(&self, char_index: usize) -> usize {
|
||||
self.value
|
||||
.char_indices()
|
||||
.nth(char_index)
|
||||
.map(|(b, _)| b)
|
||||
.unwrap_or(self.value.len())
|
||||
}
|
||||
|
||||
fn is_word_char(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
/// Start of the word before the cursor, skipping any separators first —
|
||||
/// emacs `backward-word`.
|
||||
fn word_left(&self) -> usize {
|
||||
let chars: Vec<char> = self.value.chars().collect();
|
||||
let mut i = self.cursor;
|
||||
while i > 0 && !Self::is_word_char(chars[i - 1]) {
|
||||
i -= 1;
|
||||
}
|
||||
while i > 0 && Self::is_word_char(chars[i - 1]) {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Start of the word after the cursor, skipping the rest of the current
|
||||
/// word first — emacs `forward-word`.
|
||||
fn word_right(&self) -> usize {
|
||||
let chars: Vec<char> = self.value.chars().collect();
|
||||
let len = chars.len();
|
||||
let mut i = self.cursor;
|
||||
while i < len && !Self::is_word_char(chars[i]) {
|
||||
i += 1;
|
||||
}
|
||||
while i < len && Self::is_word_char(chars[i]) {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
fn kill_to_end(&mut self) {
|
||||
let at = self.byte_offset(self.cursor);
|
||||
self.value.truncate(at);
|
||||
}
|
||||
|
||||
/// Shell-style ctrl-w: kill back to the previous whitespace, taking any
|
||||
/// trailing whitespace run with it — so `cd /tmp/foo` loses the whole
|
||||
/// path segment in one keystroke.
|
||||
fn delete_word_backwards(&mut self) {
|
||||
let chars: Vec<char> = self.value.chars().collect();
|
||||
let mut i = self.cursor;
|
||||
while i > 0 && chars[i - 1].is_whitespace() {
|
||||
i -= 1;
|
||||
}
|
||||
while i > 0 && !chars[i - 1].is_whitespace() {
|
||||
i -= 1;
|
||||
}
|
||||
let at = self.byte_offset(i);
|
||||
let end = self.byte_offset(self.cursor);
|
||||
self.value.replace_range(at..end, "");
|
||||
self.cursor = i;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use InputKey::{Backspace, Delete, End, Home, Left, Right};
|
||||
|
||||
fn typed(s: &str) -> Vec<InputKey> {
|
||||
s.chars().map(InputKey::char).collect()
|
||||
}
|
||||
|
||||
fn fed(keys: &[InputKey]) -> TextInput {
|
||||
let mut input = TextInput::default();
|
||||
for k in keys {
|
||||
input.handle_key(k);
|
||||
}
|
||||
input
|
||||
}
|
||||
|
||||
fn ctrl(c: char) -> InputKey {
|
||||
InputKey::ctrl_char(c)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_lands_in_order() {
|
||||
let input = fed(&typed("hello"));
|
||||
assert_eq!(input.value(), "hello");
|
||||
assert_eq!(input.cursor(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_removes_the_last_typed_char() {
|
||||
let mut input = fed(&typed("hello"));
|
||||
input.handle_key(&Backspace);
|
||||
assert_eq!(input.value(), "hell");
|
||||
assert_eq!(input.cursor(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_then_insert_fixes_a_middle_typo() {
|
||||
let mut input = fed(&typed("helo"));
|
||||
input.handle_key(&Left);
|
||||
input.handle_key(&Left);
|
||||
input.handle_key(&InputKey::char('l'));
|
||||
assert_eq!(input.value(), "hello");
|
||||
assert_eq!(input.cursor(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_keys_stop_at_the_edges() {
|
||||
let mut input = fed(&typed("abc"));
|
||||
for _ in 0..5 {
|
||||
input.handle_key(&Left);
|
||||
}
|
||||
assert_eq!(input.cursor(), 0);
|
||||
input.handle_key(&Left);
|
||||
assert_eq!(input.cursor(), 0);
|
||||
for _ in 0..5 {
|
||||
input.handle_key(&Right);
|
||||
}
|
||||
assert_eq!(input.cursor(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_and_end_jump_to_the_ends() {
|
||||
let mut input = fed(&typed("abcdef"));
|
||||
input.handle_key(&Home);
|
||||
assert_eq!(input.cursor(), 0);
|
||||
input.handle_key(&InputKey::char('x'));
|
||||
assert_eq!(input.value(), "xabcdef");
|
||||
input.handle_key(&End);
|
||||
input.handle_key(&InputKey::char('!'));
|
||||
assert_eq!(input.value(), "xabcdef!");
|
||||
}
|
||||
|
||||
/// Cursor starts after the final `/`; word-left lands on word starts,
|
||||
/// never inside a separator run.
|
||||
#[test]
|
||||
fn ctrl_b_walks_left_one_word_at_a_time() {
|
||||
let mut input = TextInput::with_value("/tmp/foo bar-baz/");
|
||||
assert_eq!(input.cursor(), 17);
|
||||
for expected in [13, 9, 5, 1, 0, 0] {
|
||||
input.handle_key(&ctrl('b'));
|
||||
assert_eq!(input.cursor(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_f_walks_right_one_word_at_a_time() {
|
||||
let mut input = TextInput::with_value("/tmp/foo bar");
|
||||
input.handle_key(&Home);
|
||||
for expected in [4, 8, 12, 12] {
|
||||
input.handle_key(&ctrl('f'));
|
||||
assert_eq!(input.cursor(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_deletes_before_the_cursor() {
|
||||
let mut input = fed(&typed("abcdef"));
|
||||
input.handle_key(&Left);
|
||||
input.handle_key(&Left);
|
||||
input.handle_key(&Left);
|
||||
input.handle_key(&Backspace);
|
||||
assert_eq!(input.value(), "abdef");
|
||||
assert_eq!(input.cursor(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_on_empty_input_is_a_noop() {
|
||||
let mut input = TextInput::default();
|
||||
input.handle_key(&Backspace);
|
||||
assert_eq!(input.value(), "");
|
||||
assert_eq!(input.cursor(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_removes_the_char_under_the_cursor() {
|
||||
let mut input = fed(&typed("abcdef"));
|
||||
input.handle_key(&Home);
|
||||
input.handle_key(&Delete);
|
||||
assert_eq!(input.value(), "bcdef");
|
||||
assert_eq!(input.cursor(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_past_the_end_is_a_noop() {
|
||||
let mut input = TextInput::with_value("abc");
|
||||
input.handle_key(&End);
|
||||
input.handle_key(&Delete);
|
||||
assert_eq!(input.value(), "abc");
|
||||
}
|
||||
|
||||
/// `cd /tmp/foo` with ctrl-w leaves `cd ` — the whole path segment is one
|
||||
/// word to a shell (readline kills back to whitespace, spaces included).
|
||||
#[test]
|
||||
fn ctrl_w_deletes_the_word_behind_the_cursor() {
|
||||
let mut input = fed(&[typed("cd ").as_slice(), typed("/tmp/foo").as_slice()].concat());
|
||||
input.handle_key(&ctrl('w'));
|
||||
assert_eq!(input.value(), "cd ");
|
||||
assert_eq!(input.cursor(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_w_stops_before_the_separator_run() {
|
||||
let mut input = TextInput::with_value("a b");
|
||||
input.cursor = 5;
|
||||
input.handle_key(&ctrl('w'));
|
||||
assert_eq!(input.value(), "a ");
|
||||
assert_eq!(input.cursor(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_k_kills_everything_after_the_cursor() {
|
||||
let mut input = fed(&typed("spawn --debug extra"));
|
||||
for _ in 0..14 {
|
||||
input.handle_key(&Left);
|
||||
}
|
||||
input.handle_key(&ctrl('k'));
|
||||
assert_eq!(input.value(), "spawn");
|
||||
assert_eq!(input.cursor(), 5);
|
||||
}
|
||||
|
||||
/// A paste arrives as one event carrying the whole payload and must land
|
||||
/// at the cursor as a single insertion, not per-character.
|
||||
#[test]
|
||||
fn paste_inserts_whole_payload_at_the_cursor() {
|
||||
let mut input = fed(&typed("cd "));
|
||||
input.handle_key(&InputKey::Paste("/srv/work".into()));
|
||||
assert_eq!(input.value(), "cd /srv/work");
|
||||
assert_eq!(input.cursor(), 12);
|
||||
// What follows still behaves like ordinary typed text.
|
||||
input.handle_key(&InputKey::char('/'));
|
||||
assert_eq!(input.value(), "cd /srv/work/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_between_existing_text_keeps_both_halves() {
|
||||
let mut input = TextInput::with_value("ac");
|
||||
input.handle_key(&Left);
|
||||
input.handle_key(&InputKey::Paste("b×".into()));
|
||||
assert_eq!(input.value(), "ab×c");
|
||||
assert_eq!(input.cursor(), 3, "cursor counts chars, not bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendering_an_empty_field_points_at_its_first_column() {
|
||||
let input = TextInput::default();
|
||||
let area = Rect::new(10, 5, 20, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let col = input.render(area, &mut buf, Style::default());
|
||||
assert_eq!(col, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendering_reports_the_visible_cursor_column() {
|
||||
let mut input = TextInput::with_value("hello");
|
||||
input.handle_key(&Left);
|
||||
input.handle_key(&Left);
|
||||
let area = Rect::new(10, 5, 20, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let col = input.render(area, &mut buf, Style::default());
|
||||
assert_eq!(col, 13);
|
||||
for (i, expected) in ["h", "e", "l", "l", "o"].iter().enumerate() {
|
||||
assert_eq!(buf[(10 + i as u16, 5)].symbol(), *expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendering_a_value_wider_than_the_field_shows_its_tail() {
|
||||
let value = "0123456789abcdefghij";
|
||||
let input = TextInput::with_value(value);
|
||||
let area = Rect::new(0, 0, 10, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let col = input.render(area, &mut buf, Style::default());
|
||||
assert_eq!(col, 9, "cursor sits on the last visible column");
|
||||
for (i, expected) in value.chars().skip(input.scroll_offset()).enumerate() {
|
||||
assert_eq!(
|
||||
buf[(i as u16, 0)].symbol(),
|
||||
expected.to_string(),
|
||||
"cell {i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Moving left into the hidden region must scroll the window back so the
|
||||
/// cursor never disappears off the left edge.
|
||||
#[test]
|
||||
fn moving_left_into_the_hidden_region_scrolls_back() {
|
||||
let value = "0123456789abcdefghij";
|
||||
let mut input = TextInput::with_value(value);
|
||||
let area = Rect::new(0, 0, 10, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
input.render(area, &mut buf, Style::default());
|
||||
assert_eq!(input.cursor(), 20);
|
||||
|
||||
input.handle_key(&Home);
|
||||
for _ in 0..5 {
|
||||
input.handle_key(&Right);
|
||||
}
|
||||
let mut buf = Buffer::empty(area);
|
||||
let col = input.render(area, &mut buf, Style::default());
|
||||
assert_eq!(input.cursor(), 5);
|
||||
assert_eq!(
|
||||
input.value().chars().nth(input.scroll_offset()),
|
||||
Some('5'),
|
||||
"window scrolled back to the cursor"
|
||||
);
|
||||
assert_eq!(col, 0);
|
||||
assert_eq!(buf[(0, 0)].symbol(), "5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrinking_the_value_clamps_scroll_and_cursor() {
|
||||
let mut input = TextInput::with_value("0123456789abcdefghij");
|
||||
input.handle_key(&Home);
|
||||
input.handle_key(&ctrl('k'));
|
||||
let area = Rect::new(0, 0, 10, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let col = input.render(area, &mut buf, Style::default());
|
||||
assert_eq!(input.value(), "");
|
||||
assert_eq!(col, 0);
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -280,8 +280,9 @@ maestro daemon # foreground; or wire your own service manager
|
||||
```
|
||||
|
||||
Every setting has a default, so `~/.config/maestro/config.toml` is optional —
|
||||
create it only to change the bind address, worktree root, spawn presets, or TUI
|
||||
keymap.
|
||||
create it only to change the bind address, spawn presets, or TUI
|
||||
keymap. (Worktrees live inside their base repos at `<repo>/worktrees/<branch>`;
|
||||
set `[daemon].worktree_root` only to override that.)
|
||||
|
||||
## Non-Linux hosts
|
||||
|
||||
|
||||
+5
-4
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+93
-11
@@ -15,15 +15,18 @@ importers:
|
||||
specifier: ^5.5.0
|
||||
version: 5.5.0
|
||||
devDependencies:
|
||||
happy-dom:
|
||||
specifier: ^20.11.6
|
||||
version: 20.11.6
|
||||
typescript:
|
||||
specifier: ^5.6.3
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^5.4.10
|
||||
version: 5.4.21
|
||||
version: 5.4.21(@types/node@26.2.0)
|
||||
vitest:
|
||||
specifier: ^2.1.9
|
||||
version: 2.1.9
|
||||
version: 2.1.9(@types/node@26.2.0)(happy-dom@20.11.6)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -296,6 +299,15 @@ packages:
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
'@types/node@26.2.0':
|
||||
resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==}
|
||||
|
||||
'@types/whatwg-mimetype@3.0.2':
|
||||
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@vitest/expect@2.1.9':
|
||||
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
|
||||
|
||||
@@ -337,6 +349,10 @@ packages:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
buffer-image-size@0.6.4:
|
||||
resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
cac@6.7.14:
|
||||
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -362,6 +378,10 @@ packages:
|
||||
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
entities@7.0.1:
|
||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
es-module-lexer@1.7.0:
|
||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||
|
||||
@@ -382,6 +402,10 @@ packages:
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
happy-dom@20.11.6:
|
||||
resolution: {integrity: sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
loupe@3.2.1:
|
||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||
|
||||
@@ -451,6 +475,9 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@8.3.0:
|
||||
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||
|
||||
vite-node@2.1.9:
|
||||
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
@@ -512,11 +539,27 @@ packages:
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
whatwg-mimetype@3.0.0:
|
||||
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
ws@8.21.3:
|
||||
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@esbuild/aix-ppc64@0.21.5':
|
||||
@@ -667,6 +710,16 @@ snapshots:
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/node@26.2.0':
|
||||
dependencies:
|
||||
undici-types: 8.3.0
|
||||
|
||||
'@types/whatwg-mimetype@3.0.2': {}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 26.2.0
|
||||
|
||||
'@vitest/expect@2.1.9':
|
||||
dependencies:
|
||||
'@vitest/spy': 2.1.9
|
||||
@@ -674,13 +727,13 @@ snapshots:
|
||||
chai: 5.3.3
|
||||
tinyrainbow: 1.2.0
|
||||
|
||||
'@vitest/mocker@2.1.9(vite@5.4.21)':
|
||||
'@vitest/mocker@2.1.9(vite@5.4.21(@types/node@26.2.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 2.1.9
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 5.4.21
|
||||
vite: 5.4.21(@types/node@26.2.0)
|
||||
|
||||
'@vitest/pretty-format@2.1.9':
|
||||
dependencies:
|
||||
@@ -715,6 +768,10 @@ snapshots:
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
buffer-image-size@0.6.4:
|
||||
dependencies:
|
||||
'@types/node': 26.2.0
|
||||
|
||||
cac@6.7.14: {}
|
||||
|
||||
chai@5.3.3:
|
||||
@@ -733,6 +790,8 @@ snapshots:
|
||||
|
||||
deep-eql@5.0.2: {}
|
||||
|
||||
entities@7.0.1: {}
|
||||
|
||||
es-module-lexer@1.7.0: {}
|
||||
|
||||
esbuild@0.21.5:
|
||||
@@ -770,6 +829,19 @@ snapshots:
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
happy-dom@20.11.6:
|
||||
dependencies:
|
||||
'@types/node': 26.2.0
|
||||
'@types/whatwg-mimetype': 3.0.2
|
||||
'@types/ws': 8.18.1
|
||||
buffer-image-size: 0.6.4
|
||||
entities: 7.0.1
|
||||
whatwg-mimetype: 3.0.0
|
||||
ws: 8.21.3
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
loupe@3.2.1: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
@@ -843,13 +915,15 @@ snapshots:
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
vite-node@2.1.9:
|
||||
undici-types@8.3.0: {}
|
||||
|
||||
vite-node@2.1.9(@types/node@26.2.0):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 1.1.2
|
||||
vite: 5.4.21
|
||||
vite: 5.4.21(@types/node@26.2.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- less
|
||||
@@ -861,18 +935,19 @@ snapshots:
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
vite@5.4.21:
|
||||
vite@5.4.21(@types/node@26.2.0):
|
||||
dependencies:
|
||||
esbuild: 0.21.5
|
||||
postcss: 8.5.15
|
||||
rollup: 4.60.4
|
||||
optionalDependencies:
|
||||
'@types/node': 26.2.0
|
||||
fsevents: 2.3.3
|
||||
|
||||
vitest@2.1.9:
|
||||
vitest@2.1.9(@types/node@26.2.0)(happy-dom@20.11.6):
|
||||
dependencies:
|
||||
'@vitest/expect': 2.1.9
|
||||
'@vitest/mocker': 2.1.9(vite@5.4.21)
|
||||
'@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@26.2.0))
|
||||
'@vitest/pretty-format': 2.1.9
|
||||
'@vitest/runner': 2.1.9
|
||||
'@vitest/snapshot': 2.1.9
|
||||
@@ -888,9 +963,12 @@ snapshots:
|
||||
tinyexec: 0.3.2
|
||||
tinypool: 1.1.1
|
||||
tinyrainbow: 1.2.0
|
||||
vite: 5.4.21
|
||||
vite-node: 2.1.9
|
||||
vite: 5.4.21(@types/node@26.2.0)
|
||||
vite-node: 2.1.9(@types/node@26.2.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 26.2.0
|
||||
happy-dom: 20.11.6
|
||||
transitivePeerDependencies:
|
||||
- less
|
||||
- lightningcss
|
||||
@@ -902,7 +980,11 @@ snapshots:
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
whatwg-mimetype@3.0.0: {}
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
ws@8.21.3: {}
|
||||
|
||||
+24
-2
@@ -1,10 +1,13 @@
|
||||
import * as routes from "./routes.js";
|
||||
import type {
|
||||
ApiError,
|
||||
Branches,
|
||||
CreateSessionRequest,
|
||||
DaemonInfo,
|
||||
DirListing,
|
||||
GitRoot,
|
||||
NameBatch,
|
||||
Projects,
|
||||
RecentDirs,
|
||||
Session,
|
||||
Uuid,
|
||||
@@ -61,12 +64,17 @@ export async function getDaemonInfo(): Promise<DaemonInfo> {
|
||||
return jsonOrError(await fetch(routes.INFO, { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
/** Recently-used working dirs, newest-first and deduped by dir (#26). Drives the
|
||||
* spawn form's recents picker. */
|
||||
/** Recently-used working dirs, newest-first and deduped by dir (#26). */
|
||||
export async function recentDirs(): Promise<RecentDirs> {
|
||||
return jsonOrError(await fetch(routes.RECENT_DIRS, { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
/** Every discovered git repository / worktree, newest-activity-first (#54).
|
||||
* Drives the spawn form's project picker. */
|
||||
export async function projects(): Promise<Projects> {
|
||||
return jsonOrError(await fetch(routes.PROJECTS, { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
/** Probe the enclosing git repo of `path` (#26). `root` is absent when the path
|
||||
* isn't inside a repo. `path` may be `~`/relative/absolute; the daemon resolves
|
||||
* it against its home dir. */
|
||||
@@ -75,4 +83,18 @@ export async function gitRoot(path: string): Promise<GitRoot> {
|
||||
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
/** A batch of fresh session handles for the spawn form's instant reroll (#53).
|
||||
* Rerolls consume the batch locally; only exhaustion goes back to the daemon. */
|
||||
export async function fetchNames(count: number): Promise<NameBatch> {
|
||||
const url = `${routes.NAMES}?${routes.COUNT_QUERY}=${encodeURIComponent(count)}`;
|
||||
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
/** One repository's local branches, with worktree occupancy (#55). Empty for
|
||||
* a path outside any repo. Drives the spawn form's existing-branch picker. */
|
||||
export async function fetchBranches(path: string): Promise<Branches> {
|
||||
const url = routes.withPathQuery(routes.BRANCHES, path);
|
||||
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
export { eventsWsUrl } from "./routes.js";
|
||||
|
||||
+11
-12
@@ -6,9 +6,15 @@ import type { Session } from "./types.js";
|
||||
import { abbreviateFolderName, sessionName } from "./types.js";
|
||||
import type { TreeNode } from "./tree.js";
|
||||
import { buildTree } from "./tree.js";
|
||||
import { openSpawnModal } from "./spawn.js";
|
||||
import { createThemeToggle } from "./theme.js";
|
||||
|
||||
/** Ask the shell to open a new draft pane (#62). Handled in main.ts, where the
|
||||
* multi-view controller lives; dispatched from every Spawn affordance. */
|
||||
export const OPEN_SPAWN_EVENT = "catalog-open-spawn";
|
||||
export function requestOpenSpawn(): void {
|
||||
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
|
||||
}
|
||||
|
||||
const collapsed = new Set<string>();
|
||||
|
||||
function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
@@ -190,7 +196,7 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
|
||||
spawnBtn.type = "button";
|
||||
spawnBtn.className = "btn btn-primary spawn-btn";
|
||||
spawnBtn.textContent = "+ Spawn";
|
||||
spawnBtn.addEventListener("click", () => openSpawnModal());
|
||||
spawnBtn.addEventListener("click", () => requestOpenSpawn());
|
||||
actions.appendChild(spawnBtn);
|
||||
return header;
|
||||
}
|
||||
@@ -342,7 +348,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 +370,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
const rerenderHandler = () => refresh();
|
||||
document.addEventListener("catalog-rerender", rerenderHandler);
|
||||
|
||||
// Spawn similar: reopen the spawn modal pre-seeded from a session's dir and
|
||||
// command (matched back to a preset by the form) — but with a freshly
|
||||
// generated name rather than the original's, since it's a new session.
|
||||
const spawnSimilarHandler = (ev: Event) => {
|
||||
const s = (ev as CustomEvent<Session>).detail;
|
||||
openSpawnModal({ cwd: s.cwd, command: s.command });
|
||||
};
|
||||
document.addEventListener("catalog-spawn-similar", spawnSimilarHandler);
|
||||
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
|
||||
// multi-view controller can seed a draft pane with the session's dir/command.
|
||||
|
||||
const unsubscribe = store.subscribe(refresh);
|
||||
|
||||
@@ -391,7 +391,6 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
},
|
||||
dispose() {
|
||||
document.removeEventListener("catalog-rerender", rerenderHandler);
|
||||
document.removeEventListener("catalog-spawn-similar", spawnSimilarHandler);
|
||||
unsubscribe();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./api.js", () => ({ listDir: vi.fn() }));
|
||||
|
||||
import { attachDirAutocomplete, type DirAutocompleteHandle } from "./dirAutocomplete.js";
|
||||
import { listDir } from "./api.js";
|
||||
|
||||
const listDirMock = vi.mocked(listDir);
|
||||
|
||||
let input: HTMLInputElement;
|
||||
let nextField: HTMLInputElement;
|
||||
let docKeydown: ReturnType<typeof vi.fn>;
|
||||
let ac: DirAutocompleteHandle | undefined;
|
||||
|
||||
function popup(): HTMLUListElement {
|
||||
return document.querySelector(".dir-ac") as HTMLUListElement;
|
||||
}
|
||||
|
||||
function items(): HTMLElement[] {
|
||||
return [...popup().querySelectorAll<HTMLElement>(".dir-ac-item")];
|
||||
}
|
||||
|
||||
async function type(value: string): Promise<void> {
|
||||
input.focus();
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await vi.advanceTimersByTimeAsync(100); // past the fetch debounce
|
||||
}
|
||||
|
||||
/** Dispatch a cancelable, bubbling keydown on the input; report whether the
|
||||
* default browser behaviour (focus move, form submit) was suppressed. */
|
||||
function press(key: string): { defaultPrevented: boolean } {
|
||||
const evt = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
|
||||
input.dispatchEvent(evt);
|
||||
return { defaultPrevented: evt.defaultPrevented };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
document.body.replaceChildren();
|
||||
input = document.createElement("input");
|
||||
nextField = document.createElement("input");
|
||||
document.body.append(input, nextField);
|
||||
docKeydown = vi.fn();
|
||||
document.addEventListener("keydown", docKeydown);
|
||||
listDirMock.mockReset().mockResolvedValue({ path: "/tmp", entries: ["proj-a", "proj-b", "other"], truncated: false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ac?.destroy();
|
||||
document.removeEventListener("keydown", docKeydown);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("dirAutocomplete dismissal", () => {
|
||||
it("Escape closes the popup, keeps the typed value and does not propagate", async () => {
|
||||
ac = attachDirAutocomplete(input);
|
||||
await type("/tmp/pro");
|
||||
expect(popup().hidden).toBe(false);
|
||||
|
||||
const { defaultPrevented } = press("Escape");
|
||||
|
||||
expect(defaultPrevented).toBe(true);
|
||||
expect(popup().hidden).toBe(true);
|
||||
expect(input.value).toBe("/tmp/pro");
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect(docKeydown).not.toHaveBeenCalled(); // a draft pane's Escape handler must not fire
|
||||
});
|
||||
|
||||
it("blur dismisses the popup", async () => {
|
||||
ac = attachDirAutocomplete(input);
|
||||
await type("/tmp/pro");
|
||||
expect(popup().hidden).toBe(false);
|
||||
|
||||
input.blur();
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
|
||||
expect(popup().hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("does not reopen on refocus until the operator types again", async () => {
|
||||
ac = attachDirAutocomplete(input);
|
||||
await type("/tmp/pro");
|
||||
input.blur();
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
|
||||
input.focus();
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
expect(popup().hidden).toBe(true);
|
||||
|
||||
await type("/tmp/ot");
|
||||
expect(popup().hidden).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dirAutocomplete keyboard model", () => {
|
||||
it("Tab commits the highlighted entry and lets focus move to the next field", async () => {
|
||||
ac = attachDirAutocomplete(input);
|
||||
await type("/tmp/pro"); // highlight defaults to the first match: proj-a
|
||||
|
||||
nextField.focus();
|
||||
const { defaultPrevented } = press("Tab");
|
||||
|
||||
expect(input.value).toBe("/tmp/proj-a/");
|
||||
expect(defaultPrevented).toBe(false); // native tab navigation proceeds
|
||||
expect(document.activeElement).toBe(nextField); // focus was not yanked back
|
||||
});
|
||||
|
||||
it("Tab with nothing highlighted just moves on", async () => {
|
||||
ac = attachDirAutocomplete(input);
|
||||
await type("/tmp/nope-matches-nothing");
|
||||
expect(popup().hidden).toBe(true);
|
||||
|
||||
const { defaultPrevented } = press("Tab");
|
||||
|
||||
expect(defaultPrevented).toBe(false);
|
||||
expect(input.value).toBe("/tmp/nope-matches-nothing");
|
||||
});
|
||||
|
||||
it("arrows move the highlight and Enter commits it without submitting", async () => {
|
||||
ac = attachDirAutocomplete(input);
|
||||
await type("/tmp/");
|
||||
|
||||
press("ArrowDown");
|
||||
expect(items()[1].classList.contains("is-active")).toBe(true);
|
||||
|
||||
const enter = press("Enter");
|
||||
expect(enter.defaultPrevented).toBe(true); // no form submit
|
||||
expect(input.value).toBe("/tmp/proj-b/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dirAutocomplete opening", () => {
|
||||
it("still opens on focus when not previously dismissed", async () => {
|
||||
ac = attachDirAutocomplete(input);
|
||||
input.focus();
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
expect(popup().hidden).toBe(false);
|
||||
expect(listDirMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+44
-27
@@ -4,15 +4,19 @@
|
||||
// per directory, and filters the trailing path segment locally — so typing within a segment is instant and only crossing a `/`
|
||||
// hits the network. Keyboard model mirrors `cd dir/sub<tab>`:
|
||||
//
|
||||
// Tab complete the longest common prefix; if one match, accept it (and
|
||||
// append `/` so the next level loads); if nothing to extend, cycle.
|
||||
// Tab commit the highlighted entry and let focus move on; with nothing
|
||||
// highlighted, plain tab navigation
|
||||
// ↑/↓ move the highlight
|
||||
// Enter accept the highlighted entry (and suppress form submit)
|
||||
// Esc close the dropdown
|
||||
// Esc close the dropdown, refocus the input, and swallow the key so it
|
||||
// can't also discard a surrounding draft pane
|
||||
//
|
||||
// Once dismissed (Esc or blur) the popup stays closed until the operator types
|
||||
// again — refocusing alone doesn't reopen it.
|
||||
//
|
||||
// Failures (offline daemon, permission denied) degrade to no suggestions.
|
||||
|
||||
import { longestCommonPrefix, splitPath } from "./paths.js";
|
||||
import { splitPath } from "./paths.js";
|
||||
import { listDir } from "./api.js";
|
||||
|
||||
export interface DirAutocompleteHandle {
|
||||
@@ -42,6 +46,9 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
||||
let fetchSeq = 0;
|
||||
let debounce: number | undefined;
|
||||
let open = false;
|
||||
// Set when the operator dismissed the popup (Esc, blur, outside click); a
|
||||
// refocus must not reopen it — only new typing does.
|
||||
let dismissed = false;
|
||||
|
||||
|
||||
function close(): void {
|
||||
@@ -54,6 +61,11 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
||||
window.removeEventListener("resize", reposition);
|
||||
}
|
||||
|
||||
function dismiss(): void {
|
||||
dismissed = true;
|
||||
close();
|
||||
}
|
||||
|
||||
function reposition(): void {
|
||||
const r = input.getBoundingClientRect();
|
||||
dropdown.style.left = `${r.left}px`;
|
||||
@@ -97,6 +109,14 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
||||
}
|
||||
|
||||
function applyFilter(entries: string[], prefix: string): void {
|
||||
// A fetch can outlive the dismissal that was meant to close this popup
|
||||
// (Tab-commit re-arms a refresh, then blur dismisses 120ms later). Showing
|
||||
// it now would anchor a dropdown under an unfocused input that nothing
|
||||
// closes until the next interaction.
|
||||
if (dismissed || document.activeElement !== input) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
const lower = prefix.toLowerCase();
|
||||
filtered = entries.filter((e) => e.toLowerCase().startsWith(lower));
|
||||
highlight = filtered.length > 0 ? 0 : -1;
|
||||
@@ -132,21 +152,28 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
||||
}
|
||||
|
||||
// Accept `name` as the next path segment: complete it and append `/` so the
|
||||
// child directory's listing loads immediately (the drill-in gesture).
|
||||
function accept(name: string): void {
|
||||
// child directory's listing loads on the next interaction (the drill-in
|
||||
// gesture). Unlike `accept`, doesn't steal focus — Tab uses this to commit
|
||||
// and move to the next field in one gesture.
|
||||
function commit(name: string): void {
|
||||
const { dir } = splitPath(input.value);
|
||||
input.value = `${dir}${name}/`;
|
||||
input.focus();
|
||||
// Notify other listeners (spawn.ts marks the field user-edited) and load
|
||||
// the child level.
|
||||
// Notify other listeners (spawnForm marks the field user-edited).
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function accept(name: string): void {
|
||||
commit(name);
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function onInput(): void {
|
||||
dismissed = false;
|
||||
scheduleRefresh();
|
||||
}
|
||||
|
||||
function onFocus(): void {
|
||||
if (dismissed) return;
|
||||
refresh();
|
||||
}
|
||||
|
||||
@@ -154,27 +181,17 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
||||
if (e.key === "Escape") {
|
||||
if (open) {
|
||||
e.preventDefault();
|
||||
close();
|
||||
// Swallow the key: an Escape that bubbles would also discard the
|
||||
// draft pane hosting this form.
|
||||
e.stopPropagation();
|
||||
dismiss();
|
||||
input.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
if (!open || filtered.length === 0) return; // let focus move normally
|
||||
e.preventDefault();
|
||||
if (filtered.length === 1) {
|
||||
accept(filtered[0]);
|
||||
return;
|
||||
}
|
||||
const { dir, prefix } = splitPath(input.value);
|
||||
const lcp = longestCommonPrefix(filtered);
|
||||
if (lcp.length > prefix.length) {
|
||||
input.value = `${dir}${lcp}`;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
} else {
|
||||
// Nothing to extend — cycle the highlight like a second shell <tab>.
|
||||
highlight = (highlight + 1) % filtered.length;
|
||||
render();
|
||||
}
|
||||
if (!open || highlight < 0 || highlight >= filtered.length) return; // plain tab navigation
|
||||
commit(filtered[highlight]); // no preventDefault — focus moves to the next field
|
||||
return;
|
||||
}
|
||||
if (!open) return;
|
||||
@@ -196,7 +213,7 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
||||
|
||||
function onBlur(): void {
|
||||
// Delay so a dropdown mousedown can complete first.
|
||||
window.setTimeout(close, 120);
|
||||
window.setTimeout(dismiss, 120);
|
||||
}
|
||||
|
||||
input.addEventListener("input", onInput);
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
// One HTML escaper. Previously written four times, in catalog.ts, spawn.ts,
|
||||
// types.ts and attach.ts.
|
||||
// One HTML escaper. Previously written four times, in catalog.ts, types.ts
|
||||
// and attach.ts.
|
||||
|
||||
export function escapeHtml(s: string): string {
|
||||
return s
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@
|
||||
background: color-mix(in oklab, var(--surface) 40%, var(--bg));
|
||||
}
|
||||
|
||||
.kit-slot .modal-body {
|
||||
.kit-slot .spawn-form {
|
||||
padding: 0;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
+3
-3
@@ -135,7 +135,7 @@ export function renderKit(): HTMLElement {
|
||||
{
|
||||
const { el, body } = section(
|
||||
"Buttons — current",
|
||||
"The shipping button styles — now the bracket terminal style, as used across the header, modal, and empty states.",
|
||||
"The shipping button styles — now the bracket terminal style, as used across the header, panes, and empty states.",
|
||||
);
|
||||
body.append(
|
||||
specimen("btn-primary", btn("btn btn-primary", "+ Spawn")),
|
||||
@@ -170,12 +170,12 @@ export function renderKit(): HTMLElement {
|
||||
search.type = "search";
|
||||
search.className = "search";
|
||||
search.placeholder = "Search sessions…";
|
||||
const text = h("div", "modal-body");
|
||||
const text = h("div", "spawn-form");
|
||||
const ti = document.createElement("input");
|
||||
ti.type = "text";
|
||||
ti.placeholder = "branch-name";
|
||||
text.appendChild(ti);
|
||||
const sel = h("div", "modal-body");
|
||||
const sel = h("div", "spawn-form");
|
||||
const s = document.createElement("select");
|
||||
s.innerHTML = `<option>thinkpad</option><option>tower</option><option>pi</option>`;
|
||||
sel.appendChild(s);
|
||||
|
||||
+17
-3
@@ -7,9 +7,9 @@ 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 Session } from "./types.js";
|
||||
import { installViewportSizing } from "./viewport.js";
|
||||
import "./styles.css";
|
||||
|
||||
@@ -212,7 +212,7 @@ function renderWelcome(): HTMLElement {
|
||||
spawn.type = "button";
|
||||
spawn.className = "btn btn-primary";
|
||||
spawn.textContent = "+ Spawn session";
|
||||
spawn.addEventListener("click", () => openSpawnModal());
|
||||
spawn.addEventListener("click", () => requestOpenSpawn());
|
||||
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
|
||||
return el;
|
||||
}
|
||||
@@ -251,6 +251,20 @@ function showMultiView(): void {
|
||||
updateShellRoute();
|
||||
}
|
||||
|
||||
// Spawn affordances (#62): the header button, the sidebar's empty state, and
|
||||
// `?mock&spawn` all open a draft pane; "Spawn similar" seeds one with the
|
||||
// cloned session's dir + command.
|
||||
function requestOpenSpawn(): void {
|
||||
showMultiView();
|
||||
mv.openDraft();
|
||||
}
|
||||
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
|
||||
document.addEventListener("catalog-spawn-similar", (ev) => {
|
||||
const s = (ev as CustomEvent<Session>).detail;
|
||||
showMultiView();
|
||||
mv.openDraft({ cwd: s.cwd, command: s.command });
|
||||
});
|
||||
|
||||
type Route =
|
||||
| { kind: "catalog" }
|
||||
| { kind: "attach"; sessionId: string }
|
||||
|
||||
+37
-14
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { emptyLayout, leaves, type Layout } from "./layout.js";
|
||||
import { splitLeaf } from "./layout.js";
|
||||
import { createMultiView, type MultiView } from "./multiview.js";
|
||||
import type { ConnectionPool } from "./connections.js";
|
||||
import type { SidebarHandle } from "./catalog.js";
|
||||
|
||||
function stubFetch(): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
const body = url.includes("/api/info")
|
||||
? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }
|
||||
: url.includes("/api/projects")
|
||||
? []
|
||||
: url.includes("/api/names")
|
||||
? { names: ["allegro-cello"] }
|
||||
: undefined;
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function fakePool(): ConnectionPool {
|
||||
return {
|
||||
setVisible() {},
|
||||
setFocus() {},
|
||||
} as unknown as ConnectionPool;
|
||||
}
|
||||
|
||||
function fakeSidebar(): SidebarHandle {
|
||||
return {
|
||||
el: document.createElement("aside"),
|
||||
setActive() {},
|
||||
setHighlights() {},
|
||||
dispose() {},
|
||||
};
|
||||
}
|
||||
|
||||
function makeMv(layout: Layout): MultiView {
|
||||
return createMultiView({
|
||||
pool: fakePool(),
|
||||
layout,
|
||||
sidebar: fakeSidebar(),
|
||||
infoFor: () => ({ name: "sess", command: "bash" }),
|
||||
onDisconnect: () => {},
|
||||
renderWelcome: () => document.createElement("div"),
|
||||
});
|
||||
}
|
||||
|
||||
/** Drain the microtask queue so the draft form's async seed application lands. */
|
||||
async function flush(): Promise<void> {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("multiview openDraft (#62)", () => {
|
||||
beforeEach(() => {
|
||||
stubFetch();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("turns an empty tab into a draft pane", async () => {
|
||||
const layout = emptyLayout();
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
|
||||
mv.openDraft();
|
||||
await flush();
|
||||
|
||||
const ls = leaves(layout.tabs[0].root);
|
||||
expect(ls).toHaveLength(1);
|
||||
expect(ls[0].sessionId).toBeNull();
|
||||
expect(ls[0].draft).not.toBeNull();
|
||||
expect(mv.el.querySelector(".mv-draft")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("splits the focused session pane into a fresh draft", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
const first = splitLeaf(tab, "", "s1", "row", true);
|
||||
tab.focusedLeafId = first;
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
|
||||
mv.openDraft();
|
||||
await flush();
|
||||
|
||||
const ls = leaves(tab.root);
|
||||
expect(ls).toHaveLength(2);
|
||||
expect(ls[0].sessionId).toBe("s1");
|
||||
const draftLeaf = ls.find((l) => l.sessionId === null)!;
|
||||
expect(draftLeaf.draft).not.toBeNull();
|
||||
expect(tab.focusedLeafId).toBe(draftLeaf.id);
|
||||
expect(mv.el.querySelectorAll(".mv-pane")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("reuses the focused pane when it is already empty", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
const first = splitLeaf(tab, "", "s1", "row", true);
|
||||
const empty = splitLeaf(tab, first, null, "row", true);
|
||||
tab.focusedLeafId = empty;
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
|
||||
mv.openDraft();
|
||||
await flush();
|
||||
|
||||
const ls = leaves(tab.root);
|
||||
expect(ls).toHaveLength(2);
|
||||
const target = ls.find((l) => l.id === empty)!;
|
||||
expect(target.draft).not.toBeNull();
|
||||
expect(tab.focusedLeafId).toBe(empty);
|
||||
});
|
||||
|
||||
it("keeps the draft form instance when an unrelated session closes (#68)", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
const s1 = splitLeaf(tab, "", "s1", "row", true);
|
||||
const draftLeaf = splitLeaf(tab, s1, null, "row", true);
|
||||
tab.focusedLeafId = draftLeaf;
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
await flush();
|
||||
|
||||
const nameInput = mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement;
|
||||
nameInput.value = "wip";
|
||||
nameInput.dispatchEvent(new Event("input"));
|
||||
await flush();
|
||||
expect(leaves(tab.root).find((l) => l.id === draftLeaf)?.draft?.name).toBe("wip");
|
||||
|
||||
const formBefore = mv.el.querySelector(".mv-draft");
|
||||
mv.closeSession("s1");
|
||||
await flush();
|
||||
|
||||
expect(mv.el.querySelector(".mv-draft")).toBe(formBefore);
|
||||
expect((mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement).value).toBe("wip");
|
||||
});
|
||||
|
||||
it("seeds a clone's cwd and command into the draft", async () => {
|
||||
const layout = emptyLayout();
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
|
||||
mv.openDraft({ cwd: "/home/user/proj", command: "claude" });
|
||||
await flush();
|
||||
|
||||
const leaf = leaves(layout.tabs[0].root)[0];
|
||||
expect(leaf.draft?.cwd).toBe("/home/user/proj");
|
||||
expect(leaf.draft?.command).toBe("claude");
|
||||
});
|
||||
});
|
||||
+83
-22
@@ -13,6 +13,7 @@ import {
|
||||
closeTab,
|
||||
findLeaf,
|
||||
type Dir,
|
||||
type Draft,
|
||||
type Layout,
|
||||
leaves,
|
||||
locateSession,
|
||||
@@ -52,6 +53,10 @@ export interface MultiView {
|
||||
closeSession: (sessionId: string) => void;
|
||||
/** Every session currently shown in some pane, across all tabs. */
|
||||
openSessions: () => Set<string>;
|
||||
/** Open a draft pane (issue #62): reuse the focused pane if it is already
|
||||
* empty, otherwise split the focused pane. `seed` pre-fills the form
|
||||
* ("Spawn similar" clones a session's cwd + command). */
|
||||
openDraft: (seed?: Partial<Draft>) => void;
|
||||
/** Start dragging a sidebar session into the canvas (issue #32). The
|
||||
* controller owns drag-vs-tap: a tap lets the row's normal click open it, a
|
||||
* drag shows drop zones to place it. */
|
||||
@@ -65,9 +70,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts;
|
||||
const persist = () => opts.onLayoutChange?.();
|
||||
|
||||
// Draft-pane forms (#57) carry global listeners (dir autocomplete), so each
|
||||
// render tears down the previous generation before rebuilding the canvas.
|
||||
const draftForms = new Set<SpawnFormHandle>();
|
||||
// Draft-pane forms (#57) carry global listeners (dir autocomplete), so forms
|
||||
// whose leaves are gone are destroyed on each render. Forms for draft leaves
|
||||
// that survive the render are reused in place (#68): rebuilding one mid-edit
|
||||
// would drop focus, caret and selection, and re-fetch info/projects/names.
|
||||
const draftForms = new Map<string, SpawnFormHandle>();
|
||||
|
||||
const el = document.createElement("div");
|
||||
el.className = "mv";
|
||||
@@ -145,23 +152,35 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
// Draft pane (#57): an empty pane is a spawn form. Values hang off the
|
||||
// leaf (never persisted) so each draft keeps its own half-filled state;
|
||||
// submitting fills this leaf with the new session in place.
|
||||
const form = createSpawnForm({
|
||||
seed: leaf.draft ?? undefined,
|
||||
class: "mv-draft",
|
||||
onChange: (values) => {
|
||||
leaf.draft = values;
|
||||
},
|
||||
onSuccess: (session) => {
|
||||
leaf.sessionId = session.id;
|
||||
leaf.draft = null;
|
||||
const t = activeTab(layout);
|
||||
if (leaves(t.root).includes(leaf)) t.focusedLeafId = leaf.id;
|
||||
render();
|
||||
opts.onOpenSessionsChange?.();
|
||||
},
|
||||
});
|
||||
draftForms.add(form);
|
||||
host.appendChild(form.el);
|
||||
const form = draftForms.get(leaf.id);
|
||||
if (form) {
|
||||
host.appendChild(form.el);
|
||||
} else {
|
||||
const created = createSpawnForm({
|
||||
seed: leaf.draft ?? undefined,
|
||||
class: "mv-draft",
|
||||
onChange: (values) => {
|
||||
leaf.draft = values;
|
||||
},
|
||||
onSuccess: (session) => {
|
||||
// The pane can be closed while the POST is in flight. Mounting the
|
||||
// session onto a leaf no longer in the tree would strand it in no
|
||||
// pane at all, so only claim the leaf while it is still there —
|
||||
// searching every tab, since the operator may have switched tabs
|
||||
// while the request was in flight.
|
||||
const host = layout.tabs.find((t) => leaves(t.root).includes(leaf));
|
||||
if (host) {
|
||||
leaf.sessionId = session.id;
|
||||
leaf.draft = null;
|
||||
host.focusedLeafId = leaf.id;
|
||||
}
|
||||
render();
|
||||
opts.onOpenSessionsChange?.();
|
||||
},
|
||||
});
|
||||
draftForms.set(leaf.id, created);
|
||||
host.appendChild(created.el);
|
||||
}
|
||||
}
|
||||
|
||||
const tools = document.createElement("div");
|
||||
@@ -359,11 +378,49 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
render();
|
||||
}
|
||||
|
||||
function openDraft(seed?: Partial<Draft>): void {
|
||||
const tab = activeTab(layout);
|
||||
const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed });
|
||||
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
|
||||
// Empty tab (or stale focus): the draft becomes the first pane.
|
||||
tab.focusedLeafId = splitLeaf(tab, "", null, "row", true);
|
||||
findLeaf(tab.root!, tab.focusedLeafId)!.draft = draft();
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const target = findLeaf(tab.root, tab.focusedLeafId)!;
|
||||
if (!target.sessionId && !target.draft) {
|
||||
// An already-empty pane becomes the draft in place.
|
||||
target.draft = draft();
|
||||
} else {
|
||||
// Split a fresh draft beside the focused pane.
|
||||
tab.focusedLeafId = splitLeaf(tab, target.id, null, "row", true);
|
||||
findLeaf(tab.root, tab.focusedLeafId)!.draft = draft();
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
// ---- top-level render ----
|
||||
|
||||
/** Destroy forms whose leaf is no longer a draft (leaf removed or filled by
|
||||
* a session). Surviving draft leaves keep their form instance (#68). */
|
||||
function pruneForms(tab: { root: Node | null }): void {
|
||||
const alive = new Set(
|
||||
tab.root
|
||||
? leaves(tab.root)
|
||||
.filter((l) => !l.sessionId)
|
||||
.map((l) => l.id)
|
||||
: [],
|
||||
);
|
||||
for (const [id, f] of draftForms) {
|
||||
if (!alive.has(id)) {
|
||||
f.destroy();
|
||||
draftForms.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
for (const f of draftForms) f.destroy();
|
||||
draftForms.clear();
|
||||
renderTabs();
|
||||
canvas.replaceChildren();
|
||||
const tab = activeTab(layout);
|
||||
@@ -372,6 +429,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
el.classList.toggle("mv-mobile-solo", isMobile && layout.tabs.length === 1);
|
||||
|
||||
if (!tab.root) {
|
||||
pruneForms(tab);
|
||||
pool.setVisible([]);
|
||||
pool.setFocus(null);
|
||||
canvas.appendChild(renderWelcome());
|
||||
@@ -387,12 +445,14 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
|
||||
if (isMobile) {
|
||||
renderMobile(tab);
|
||||
pruneForms(tab);
|
||||
persist();
|
||||
return;
|
||||
}
|
||||
|
||||
const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = [];
|
||||
canvas.appendChild(buildTree(tab.root, hosts));
|
||||
pruneForms(tab);
|
||||
pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })));
|
||||
syncFocus();
|
||||
persist();
|
||||
@@ -633,6 +693,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
return {
|
||||
el,
|
||||
openSession,
|
||||
openDraft,
|
||||
closeSession,
|
||||
openSessions: () => openSessions(layout),
|
||||
beginSessionDrag,
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
// Client-side session-handle generator for the spawn form's instant reroll
|
||||
// (#23/#27). The spawn form rerolls without a round trip, so the wordlists
|
||||
// live here; nothing server-side generates handles.
|
||||
|
||||
/** Musical adjectives — tempo, dynamics, and articulation markings. */
|
||||
const ADJECTIVES = [
|
||||
"allegro", "andante", "adagio", "presto", "vivace", "largo", "grave", "lento",
|
||||
"moderato", "crescendo", "staccato", "legato", "forte", "dolce", "agitato",
|
||||
"cantabile", "sostenuto", "tremolo", "rubato", "marcato", "brillante",
|
||||
"maestoso", "grazioso", "espressivo", "tranquillo", "animato", "spirito",
|
||||
"sotto", "vivo", "feroce",
|
||||
];
|
||||
|
||||
/** Musical nouns — instruments and forms from the concert hall. */
|
||||
const NOUNS = [
|
||||
"cello", "violin", "viola", "oboe", "flute", "clarinet", "bassoon", "trumpet",
|
||||
"trombone", "tuba", "horn", "timpani", "harp", "piano", "organ", "lyre",
|
||||
"lute", "fugue", "sonata", "octave", "chord", "cadence", "prelude", "rondo",
|
||||
"nocturne", "scherzo", "refrain", "coda", "motif", "overture",
|
||||
];
|
||||
|
||||
function pick<T>(list: T[]): T {
|
||||
return list[Math.floor(Math.random() * list.length)];
|
||||
}
|
||||
|
||||
/** A fresh `adjective-noun` handle, e.g. `allegro-cello`. */
|
||||
export function generateName(): string {
|
||||
return `${pick(ADJECTIVES)}-${pick(NOUNS)}`;
|
||||
}
|
||||
@@ -99,4 +99,30 @@ describe("serializeLayout", () => {
|
||||
}
|
||||
expect(leaves(restored!.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1", null]);
|
||||
});
|
||||
|
||||
it("keeps the draft out of the stored blob itself, not just out of hydrate", () => {
|
||||
// A draft pane only exists once a tab has split, so the strip has to walk
|
||||
// into split branches; asserting on the restored layout alone passes even
|
||||
// when the draft was written to localStorage, because hydrate drops it.
|
||||
const layout = hydrate({
|
||||
tabs: [
|
||||
{
|
||||
id: "t",
|
||||
root: {
|
||||
kind: "split",
|
||||
dir: "row",
|
||||
a: { kind: "leaf", id: "l1", sessionId: "s1" },
|
||||
b: { kind: "leaf", id: "l2", sessionId: null },
|
||||
},
|
||||
},
|
||||
],
|
||||
activeTabId: "t",
|
||||
}) as Layout;
|
||||
const draftLeaf = leaves(layout.tabs[0].root).find((l) => !l.sessionId)!;
|
||||
draftLeaf.draft = { command: "claude", cwd: "/home/user/secret-project", name: "amber-fox" };
|
||||
|
||||
const raw = serializeLayout(layout);
|
||||
expect(raw).not.toContain("secret-project");
|
||||
expect(raw).not.toContain("amber-fox");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,12 @@ export function serializeLayout(layout: Layout): string {
|
||||
}
|
||||
|
||||
function stripDrafts(node: Node | null): Node | null {
|
||||
if (!node || node.kind !== "leaf") return node;
|
||||
if (!node) return node;
|
||||
// Recurse: a draft pane only exists once a tab has split, so a shallow strip
|
||||
// would persist every half-filled form it was meant to drop.
|
||||
if (node.kind === "split") {
|
||||
return { ...node, a: stripDrafts(node.a)!, b: stripDrafts(node.b)! };
|
||||
}
|
||||
return { ...node, draft: null };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
||||
import type { Project } from "./types.js";
|
||||
|
||||
const NOW = new Date("2026-08-23T12:00:00Z");
|
||||
|
||||
function project(overrides: Partial<Project>): Project {
|
||||
return {
|
||||
path: `/home/user/${overrides.name ?? "proj"}`,
|
||||
name: "proj",
|
||||
is_worktree: false,
|
||||
last_activity: undefined,
|
||||
last_command: undefined,
|
||||
...overrides,
|
||||
} as Project;
|
||||
}
|
||||
|
||||
describe("isStale", () => {
|
||||
it("marks projects with no known activity stale", () => {
|
||||
expect(isStale(project({ name: "never" }), NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("is fresh within 7 days and stale beyond", () => {
|
||||
const fresh = project({ name: "fresh", last_activity: "2026-08-17T00:00:00Z" });
|
||||
const stale = project({ name: "stale", last_activity: "2026-08-16T11:59:00Z" });
|
||||
expect(isStale(fresh, NOW)).toBe(false);
|
||||
expect(isStale(stale, NOW)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortProjects", () => {
|
||||
it("sorts newest-activity first", () => {
|
||||
const a = project({ name: "a", last_activity: "2026-08-20T00:00:00Z" });
|
||||
const b = project({ name: "b", last_activity: "2026-08-22T00:00:00Z" });
|
||||
expect(sortProjects([a, b], NOW).map((p) => p.name)).toEqual(["b", "a"]);
|
||||
});
|
||||
|
||||
it("sorts stale rows after fresh ones, each block newest first", () => {
|
||||
const freshOld = project({ name: "fresh-old", last_activity: "2026-08-18T00:00:00Z" });
|
||||
const freshNew = project({ name: "fresh-new", last_activity: "2026-08-23T00:00:00Z" });
|
||||
const staleNew = project({ name: "stale-new", last_activity: "2026-08-10T00:00:00Z" });
|
||||
const staleNone = project({ name: "stale-none" });
|
||||
const sorted = sortProjects([staleNew, freshOld, staleNone, freshNew], NOW);
|
||||
expect(sorted.map((p) => p.name)).toEqual(["fresh-new", "fresh-old", "stale-new", "stale-none"]);
|
||||
});
|
||||
|
||||
it("does not mutate the input", () => {
|
||||
const a = project({ name: "a", last_activity: "2026-08-01T00:00:00Z" });
|
||||
const b = project({ name: "b", last_activity: "2026-08-02T00:00:00Z" });
|
||||
sortProjects([a, b], NOW);
|
||||
expect(a.name).toBe("a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupProjects", () => {
|
||||
it("keeps plain repositories as their own single-entry group", () => {
|
||||
const solo = project({ name: "solo" });
|
||||
expect(groupProjects(sortProjects([solo], NOW))).toEqual([{ main: solo, worktrees: [] }]);
|
||||
});
|
||||
|
||||
it("nests worktrees under their base repository even when fresher than it", () => {
|
||||
const main = project({ name: "maestro" });
|
||||
const wt = project({
|
||||
name: "wt",
|
||||
path: "/elsewhere/wt",
|
||||
is_worktree: true,
|
||||
base_repo: main.path,
|
||||
last_activity: "2026-08-23T00:00:00Z",
|
||||
});
|
||||
const groups = groupProjects(sortProjects([wt, main], NOW));
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].main).toBe(main);
|
||||
expect(groups[0].worktrees).toEqual([wt]);
|
||||
});
|
||||
|
||||
it("orders groups by the freshest member and worktrees by activity", () => {
|
||||
const mainA = project({ name: "a", path: "/w/a", last_activity: "2026-08-01T00:00:00Z" });
|
||||
const wtA = project({
|
||||
name: "a-wt",
|
||||
path: "/w/a-wt",
|
||||
is_worktree: true,
|
||||
base_repo: "/w/a",
|
||||
last_activity: "2026-08-20T00:00:00Z",
|
||||
});
|
||||
const mainB = project({ name: "b", path: "/w/b", last_activity: "2026-08-15T00:00:00Z" });
|
||||
const wtB1 = project({
|
||||
name: "b-wt1",
|
||||
path: "/w/b-wt1",
|
||||
is_worktree: true,
|
||||
base_repo: "/w/b",
|
||||
last_activity: "2026-08-19T00:00:00Z",
|
||||
});
|
||||
const wtB2 = project({
|
||||
name: "b-wt2",
|
||||
path: "/w/b-wt2",
|
||||
is_worktree: true,
|
||||
base_repo: "/w/b",
|
||||
last_activity: "2026-08-21T00:00:00Z",
|
||||
});
|
||||
|
||||
const groups = groupProjects(sortProjects([mainA, mainB, wtB1, wtA, wtB2], NOW));
|
||||
expect(groups.map((g) => g.main.name)).toEqual(["b", "a"]);
|
||||
const gA = groups.find((g) => g.main.name === "a")!;
|
||||
const gB = groups.find((g) => g.main.name === "b")!;
|
||||
expect(gA.worktrees.map((p) => p.name)).toEqual(["a-wt"]);
|
||||
expect(gB.worktrees.map((p) => p.name)).toEqual(["b-wt2", "b-wt1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterProjects", () => {
|
||||
const maestro = project({ name: "maestro", path: "/home/u/git/maestro" });
|
||||
const tea = project({ name: "tea", path: "/home/u/work/tea" });
|
||||
|
||||
it("matches on name case-insensitively", () => {
|
||||
expect(filterProjects([maestro, tea], "MAES")).toEqual([maestro]);
|
||||
});
|
||||
|
||||
it("matches on the full path", () => {
|
||||
expect(filterProjects([maestro, tea], "work")).toEqual([tea]);
|
||||
});
|
||||
|
||||
it("returns everything unchanged for an empty query", () => {
|
||||
expect(filterProjects([maestro, tea], " ")).toEqual([maestro, tea]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// Pure helpers behind the spawn form's project picker (#59): staleness,
|
||||
// recency ordering, worktree grouping, and free-text filtering. The daemon
|
||||
// reports the timestamps; what "stale" means is a client decision (#54).
|
||||
|
||||
import type { Project } from "./types.js";
|
||||
|
||||
/** Projects whose last activity is older than this are dimmed and sort after
|
||||
* fresh ones — still reachable, visibly stale (#59). */
|
||||
export const STALE_AFTER_DAYS = 7;
|
||||
|
||||
export function isStale(project: Project, now: Date): boolean {
|
||||
if (!project.last_activity) return true;
|
||||
const age = now.getTime() - Date.parse(project.last_activity);
|
||||
return !(age >= 0) || age > STALE_AFTER_DAYS * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
/** Fresh rows first, each block newest-activity-first; unknown or future
|
||||
* timestamps sort as stalest. Ties fall back to name so the order is stable. */
|
||||
export function sortProjects(projects: Project[], now: Date): Project[] {
|
||||
return [...projects].sort((a, b) => {
|
||||
const staleDiff = (isStale(a, now) ? 1 : 0) - (isStale(b, now) ? 1 : 0);
|
||||
if (staleDiff !== 0) return staleDiff;
|
||||
const ta = timeOf(a);
|
||||
const tb = timeOf(b);
|
||||
if (ta !== tb) return tb - ta;
|
||||
return a.name.localeCompare(b.name) || a.path.localeCompare(b.path);
|
||||
});
|
||||
}
|
||||
|
||||
function timeOf(project: Project): number {
|
||||
return project.last_activity ? Date.parse(project.last_activity) : Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
/** One repository group: the main checkout followed by its linked worktrees.
|
||||
* A group's position follows its freshest member, since a worktree worked on
|
||||
* today should pull its (dormant) base repo up with it. */
|
||||
export interface ProjectGroup {
|
||||
main: Project;
|
||||
worktrees: Project[];
|
||||
}
|
||||
|
||||
/** Group `sorted` projects by their base repository. A worktree nests under
|
||||
* its main checkout even when the main sorts later; a main checkout missing
|
||||
* from the list doesn't drop its worktrees. */
|
||||
export function groupProjects(sorted: Project[]): ProjectGroup[] {
|
||||
const members = new Map<string, Project[]>();
|
||||
const keys: string[] = [];
|
||||
for (const p of sorted) {
|
||||
const key = p.base_repo ?? p.path;
|
||||
let list = members.get(key);
|
||||
if (!list) {
|
||||
list = [];
|
||||
members.set(key, list);
|
||||
keys.push(key);
|
||||
}
|
||||
list.push(p);
|
||||
}
|
||||
return keys.map((key) => {
|
||||
const list = members.get(key)!;
|
||||
const main = list.find((p) => p.path === key && !p.is_worktree) ?? list[0];
|
||||
// Keep the daemon's newest-first order within the group.
|
||||
const worktrees = list
|
||||
.filter((p) => p !== main)
|
||||
.sort((a, b) => timeOf(b) - timeOf(a));
|
||||
return { main, worktrees };
|
||||
});
|
||||
}
|
||||
|
||||
/** Substring filter over project name and full path, case-insensitive. */
|
||||
export function filterProjects(projects: Project[], query: string): Project[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [...projects];
|
||||
return projects.filter(
|
||||
(p) => p.name.toLowerCase().includes(q) || p.path.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
||||
import type { Project } from "./types.js";
|
||||
|
||||
const NOW = "2026-08-23T12:00:00Z";
|
||||
|
||||
function project(overrides: Partial<Project>): Project {
|
||||
return {
|
||||
path: `/home/user/${overrides.name ?? "proj"}`,
|
||||
name: "proj",
|
||||
is_worktree: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Three fetches gate the form's initial load: /api/info, /api/projects, and
|
||||
* the /api/names batch (#53). */
|
||||
function stubFetch(info: unknown, projects: Project[]): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
const body = url.includes("/api/info")
|
||||
? info
|
||||
: url.includes("/api/projects")
|
||||
? projects
|
||||
: url.includes("/api/names")
|
||||
? { names: ["allegro-cello", "presto-fugue", "adagio-lyre"] }
|
||||
: undefined;
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function rowPaths(form: HTMLFormElement): string[] {
|
||||
return [...form.querySelectorAll<HTMLButtonElement>(".project-row:not(.project-other)")].map(
|
||||
(r) => r.dataset.cwd ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
describe("spawnForm project picker", () => {
|
||||
let handle: SpawnFormHandle | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ now: Date.parse(NOW) });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
handle?.destroy();
|
||||
handle = null;
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function mount(projects: Project[], seed?: Parameters<typeof createSpawnForm>[0]["seed"]) {
|
||||
stubFetch({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }, projects);
|
||||
handle = createSpawnForm({
|
||||
seed,
|
||||
onSuccess() {},
|
||||
});
|
||||
document.body.appendChild(handle.el);
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
return handle;
|
||||
}
|
||||
|
||||
it("renders one row per project, fresh before stale", async () => {
|
||||
const fresh = project({ name: "fresh", path: "/home/user/fresh", last_activity: NOW });
|
||||
const stale = project({
|
||||
name: "stale",
|
||||
path: "/home/user/stale",
|
||||
last_activity: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
const form = (await mount([stale, fresh])).el;
|
||||
expect(rowPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
|
||||
const rows = [...form.querySelectorAll<HTMLButtonElement>(".project-row")];
|
||||
expect(rows[1].classList.contains("is-stale")).toBe(true);
|
||||
expect(rows[0].classList.contains("is-stale")).toBe(false);
|
||||
});
|
||||
|
||||
it("nests worktrees under their base repository", async () => {
|
||||
const main = project({ name: "maestro", path: "/home/user/maestro" });
|
||||
const wt = project({
|
||||
name: "wt",
|
||||
path: "/elsewhere/wt",
|
||||
is_worktree: true,
|
||||
base_repo: main.path,
|
||||
});
|
||||
const form = (await mount([wt, main])).el;
|
||||
expect(rowPaths(form)).toEqual(["/home/user/maestro", "/elsewhere/wt"]);
|
||||
const [mainRow, wtRow] = [
|
||||
...form.querySelectorAll(".project-row:not(.project-other)"),
|
||||
];
|
||||
expect(wtRow.classList.contains("is-worktree")).toBe(true);
|
||||
expect(mainRow.classList.contains("is-worktree")).toBe(false);
|
||||
});
|
||||
|
||||
it("filters rows as the operator types", async () => {
|
||||
const a = project({ name: "maestro", path: "/home/user/maestro" });
|
||||
const b = project({ name: "tea", path: "/home/user/work/tea" });
|
||||
const form = (await mount([a, b])).el;
|
||||
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
|
||||
filter.value = "tea";
|
||||
filter.dispatchEvent(new Event("input"));
|
||||
expect(rowPaths(form)).toEqual(["/home/user/work/tea"]);
|
||||
filter.value = "zzz";
|
||||
filter.dispatchEvent(new Event("input"));
|
||||
expect(rowPaths(form)).toEqual([]);
|
||||
expect(form.querySelector(".picker-empty")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("picking a project selects its cwd and pre-selects its last command", async () => {
|
||||
const p = project({
|
||||
name: "maestro",
|
||||
path: "/home/user/maestro",
|
||||
last_command: "claude",
|
||||
});
|
||||
const onChange = vi.fn();
|
||||
stubFetch(
|
||||
{
|
||||
hostname: "h",
|
||||
version: "0",
|
||||
home_dir: "/home/user",
|
||||
presets: [
|
||||
{ id: "claude", label: "Claude", command: "claude" },
|
||||
{ id: "custom", label: "Custom…", command: "" },
|
||||
],
|
||||
},
|
||||
[p],
|
||||
);
|
||||
handle = createSpawnForm({ onSuccess() {}, onChange });
|
||||
const form = handle.el;
|
||||
document.body.appendChild(form);
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
onChange.mockClear();
|
||||
|
||||
const row = form.querySelector<HTMLButtonElement>(".project-row")!;
|
||||
row.click();
|
||||
|
||||
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
|
||||
const last = onChange.mock.lastCall![0];
|
||||
expect(last.cwd).toBe("/home/user/maestro");
|
||||
expect(last.command).toBe("claude");
|
||||
expect(values).toBeTruthy();
|
||||
// The raw input stays hidden — the picked row is the source of truth.
|
||||
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(true);
|
||||
expect(row.classList.contains("is-active")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an 'Other directory…' escape hatch that reveals the raw input", async () => {
|
||||
const p = project({ name: "maestro", path: "/home/user/maestro" });
|
||||
const form = (await mount([p])).el;
|
||||
const other = form.querySelector<HTMLButtonElement>(".project-other")!;
|
||||
other.click();
|
||||
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the raw input when no projects exist", async () => {
|
||||
const form = (await mount([])).el;
|
||||
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(false);
|
||||
const raw = form.querySelector<HTMLInputElement>(".cwd-raw")!;
|
||||
expect(raw.value).toBe("/home/user");
|
||||
});
|
||||
});
|
||||
@@ -9,12 +9,18 @@ export const SESSIONS = "/api/sessions";
|
||||
export const INFO = "/api/info";
|
||||
export const DIRS = "/api/dirs";
|
||||
export const RECENT_DIRS = "/api/recent-dirs";
|
||||
export const PROJECTS = "/api/projects";
|
||||
export const GITROOT = "/api/gitroot";
|
||||
export const NAMES = "/api/names";
|
||||
export const BRANCHES = "/api/branches";
|
||||
export const EVENTS = "/api/events";
|
||||
|
||||
/** Name of the query parameter `DIRS` and `GITROOT` both take. */
|
||||
export const PATH_QUERY = "path";
|
||||
|
||||
/** Name of the query parameter `NAMES` takes. */
|
||||
export const COUNT_QUERY = "count";
|
||||
|
||||
export function session(id: Uuid): string {
|
||||
return `${SESSIONS}/${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
// The spawn modal (issue #27): bottom-sheet/dialog chrome around the shared
|
||||
// spawn form (spawnForm.ts). The modal coexists with the inline draft panes
|
||||
// (#57) until a later issue deletes it.
|
||||
|
||||
import { createSpawnForm, type SpawnFormSeed } from "./spawnForm.js";
|
||||
|
||||
/** Seed values for the spawn form — used by "clone" to reopen the dialog with
|
||||
* the same cwd, command, and name as an existing session. */
|
||||
export type SpawnPrefill = SpawnFormSeed;
|
||||
|
||||
export function openSpawnModal(prefill?: SpawnPrefill): void {
|
||||
const existing = document.getElementById("modal-host");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const host = document.createElement("div");
|
||||
host.id = "modal-host";
|
||||
host.className = "modal-backdrop";
|
||||
host.innerHTML = `
|
||||
<div class="modal spawn-modal" role="dialog" aria-modal="true" aria-labelledby="spawn-title">
|
||||
<header class="modal-header">
|
||||
<h2 id="spawn-title">Spawn session</h2>
|
||||
<button type="button" class="btn btn-icon close" aria-label="Close">✕</button>
|
||||
</header>
|
||||
<div class="form-slot"></div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(host);
|
||||
|
||||
const formHandle = createSpawnForm({
|
||||
seed: prefill,
|
||||
class: "modal-body",
|
||||
onSuccess(session) {
|
||||
close();
|
||||
location.hash = `#/attach/${encodeURIComponent(session.id)}`;
|
||||
},
|
||||
});
|
||||
(host.querySelector(".form-slot") as HTMLElement).replaceWith(formHandle.el);
|
||||
|
||||
const close = () => {
|
||||
formHandle.destroy();
|
||||
host.remove();
|
||||
};
|
||||
// The modal keeps its Cancel affordance; the shared form ships only Submit.
|
||||
const actions = formHandle.el.querySelector(".form-actions") as HTMLElement;
|
||||
const cancel = document.createElement("button");
|
||||
cancel.type = "button";
|
||||
cancel.className = "btn btn-ghost cancel";
|
||||
cancel.textContent = "Cancel";
|
||||
cancel.addEventListener("click", close);
|
||||
actions.prepend(cancel);
|
||||
|
||||
host.querySelector(".close")?.addEventListener("click", close);
|
||||
host.addEventListener("click", (e) => {
|
||||
if (e.target === host) close();
|
||||
});
|
||||
window.addEventListener(
|
||||
"keydown",
|
||||
(e) => {
|
||||
if (e.key === "Escape") close();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
// ---- clone seed ------------------------------------------------------
|
||||
if (prefill) {
|
||||
(host.querySelector("#spawn-title") as HTMLElement).textContent = "Clone session";
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ const base: SpawnFormValues = {
|
||||
name: "amber-fox",
|
||||
worktreeEnabled: false,
|
||||
worktreeBranch: "",
|
||||
worktreeCleanup: false,
|
||||
worktreeBranchMode: "new",
|
||||
};
|
||||
|
||||
describe("buildSpawnRequest", () => {
|
||||
@@ -24,9 +24,9 @@ describe("buildSpawnRequest", () => {
|
||||
expect(res).toEqual({ req: { cwd: base.cwd, command: base.command, metadata: {} } });
|
||||
});
|
||||
|
||||
it("attaches a trimmed branch and cleanup flag when worktree is enabled", () => {
|
||||
it("attaches a trimmed branch when worktree is enabled, and always cleans up", () => {
|
||||
const res = buildSpawnRequest(
|
||||
{ ...base, worktreeEnabled: true, worktreeBranch: " feat/foo ", worktreeCleanup: true },
|
||||
{ ...base, worktreeEnabled: true, worktreeBranch: " feat/foo " },
|
||||
"/home/user/project",
|
||||
);
|
||||
expect(res).toEqual({
|
||||
@@ -34,11 +34,50 @@ describe("buildSpawnRequest", () => {
|
||||
cwd: base.cwd,
|
||||
command: base.command,
|
||||
metadata: { name: "amber-fox" },
|
||||
worktree: { base_repo: "/home/user/project", branch: "feat/foo", cleanup_on_kill: true },
|
||||
worktree: {
|
||||
base_repo: "/home/user/project",
|
||||
branch: "feat/foo",
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("checks out an existing branch when the mode is existing (#61)", () => {
|
||||
const res = buildSpawnRequest(
|
||||
{
|
||||
...base,
|
||||
worktreeEnabled: true,
|
||||
worktreeBranch: "spawn-v2",
|
||||
worktreeBranchMode: "existing",
|
||||
},
|
||||
"/home/user/project",
|
||||
);
|
||||
expect(res).toEqual({
|
||||
req: {
|
||||
cwd: base.cwd,
|
||||
command: base.command,
|
||||
metadata: { name: "amber-fox" },
|
||||
worktree: {
|
||||
base_repo: "/home/user/project",
|
||||
branch: "spawn-v2",
|
||||
cleanup_on_kill: true,
|
||||
create_branch: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an existing-mode worktree with no branch picked", () => {
|
||||
expect(
|
||||
buildSpawnRequest(
|
||||
{ ...base, worktreeEnabled: true, worktreeBranch: "", worktreeBranchMode: "existing" },
|
||||
"/repo",
|
||||
),
|
||||
).toHaveProperty("error");
|
||||
});
|
||||
|
||||
it("rejects a missing working directory", () => {
|
||||
expect(buildSpawnRequest({ ...base, cwd: "" }, null)).toHaveProperty("error");
|
||||
});
|
||||
|
||||
+319
-99
@@ -1,20 +1,31 @@
|
||||
import { escapeHtml } from "./html.js";
|
||||
import { createSession, getDaemonInfo, recentDirs, gitRoot } from "./api.js";
|
||||
import {
|
||||
createSession,
|
||||
fetchBranches,
|
||||
fetchNames,
|
||||
getDaemonInfo,
|
||||
projects as fetchProjects,
|
||||
gitRoot,
|
||||
} from "./api.js";
|
||||
import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
||||
import { generateName } from "./names.js";
|
||||
import type { CreateSessionRequest, RecentDir, Session, SpawnPreset } from "./types.js";
|
||||
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
||||
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
||||
import { abbreviateCwd, abbreviateFolderName } from "./types.js";
|
||||
|
||||
/** Seed values for the spawn form — used by "clone" to reopen with the same
|
||||
* cwd, command, and name as an existing session, and by draft panes to restore
|
||||
* their own half-filled state after a re-render. */
|
||||
/** Where a worktree's branch comes from: freshly created (default) or picked
|
||||
* from the repository's existing branches (#61). */
|
||||
export type WorktreeBranchMode = "new" | "existing";
|
||||
|
||||
/** Seed values for the spawn form — used by "clone" to open a draft with the
|
||||
* same cwd, command, and name as an existing session, and by draft panes to
|
||||
* restore their own half-filled state after a re-render. */
|
||||
export interface SpawnFormSeed {
|
||||
command?: string;
|
||||
cwd?: string;
|
||||
name?: string;
|
||||
worktreeEnabled?: boolean;
|
||||
worktreeBranch?: string;
|
||||
worktreeCleanup?: boolean;
|
||||
worktreeBranchMode?: WorktreeBranchMode;
|
||||
cwdRawVisible?: boolean;
|
||||
}
|
||||
|
||||
@@ -26,7 +37,8 @@ export interface SpawnFormValues {
|
||||
name: string;
|
||||
worktreeEnabled: boolean;
|
||||
worktreeBranch: string;
|
||||
worktreeCleanup: boolean;
|
||||
/** "new" creates the branch; "existing" checks out a picked one (#61). */
|
||||
worktreeBranchMode: WorktreeBranchMode;
|
||||
/** Whether the cwd lives in the raw text input (vs a picked recent row). */
|
||||
cwdRawVisible?: boolean;
|
||||
}
|
||||
@@ -49,13 +61,21 @@ export function buildSpawnRequest(
|
||||
};
|
||||
|
||||
if (values.worktreeEnabled) {
|
||||
const mode = values.worktreeBranchMode ?? "new";
|
||||
const branch = values.worktreeBranch.trim();
|
||||
if (!baseRepo) return { error: "Worktree needs a directory inside a git repository." };
|
||||
if (!branch) return { error: "Worktree requires a branch name." };
|
||||
if (!branch) {
|
||||
return { error: mode === "existing" ? "Pick an existing branch." : "Worktree requires a branch name." };
|
||||
}
|
||||
req.worktree = {
|
||||
base_repo: baseRepo,
|
||||
branch,
|
||||
cleanup_on_kill: values.worktreeCleanup,
|
||||
// The daemon always cleans up, guarded against unsaved work (#56);
|
||||
// the wire field stays for older daemons.
|
||||
cleanup_on_kill: true,
|
||||
// Existing-mode picks must check out as-is; the daemon creates
|
||||
// anything else with `worktree add -b` (#56/#61).
|
||||
create_branch: mode !== "existing",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,7 +99,7 @@ export interface SpawnFormHandle {
|
||||
|
||||
export interface SpawnFormOptions {
|
||||
seed?: SpawnFormSeed;
|
||||
/** Extra class(es) for the <form> root (e.g. `modal-body` in the modal). */
|
||||
/** Extra class(es) for the <form> root (e.g. `mv-draft` in a draft pane). */
|
||||
class?: string;
|
||||
/** Fired whenever any value changes (input, tile pick, checkbox). */
|
||||
onChange?: (values: SpawnFormValues) => void;
|
||||
@@ -87,8 +107,8 @@ export interface SpawnFormOptions {
|
||||
onSuccess: (session: Session) => void;
|
||||
}
|
||||
|
||||
/** The spawn form itself — preset tiles, recent-dirs picker, name, worktree.
|
||||
* Shared by the modal (`spawn.ts`) and inline draft panes (issue #57). */
|
||||
/** The spawn form itself — preset tiles, project picker, name, worktree.
|
||||
* Mounted inline by draft panes (issue #57). */
|
||||
export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
const prefill = opts.seed ?? {};
|
||||
|
||||
@@ -103,7 +123,9 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Working directory</span>
|
||||
<div class="picker recents"></div>
|
||||
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
|
||||
autocomplete="off" />
|
||||
<div class="picker projects"></div>
|
||||
<div class="raw-wrap cwd-raw-wrap" hidden>
|
||||
<input class="raw-input cwd-raw" name="cwd_raw" type="text"
|
||||
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
||||
@@ -126,13 +148,23 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
<p class="wt-hint" hidden></p>
|
||||
<div class="wt-body" hidden>
|
||||
<p class="wt-repo"></p>
|
||||
<label>
|
||||
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
|
||||
<label class="row-inline">
|
||||
<input type="radio" name="worktree_branch_mode" value="new" />
|
||||
New branch
|
||||
</label>
|
||||
<label class="row-inline">
|
||||
<input type="radio" name="worktree_branch_mode" value="existing" />
|
||||
Existing branch
|
||||
</label>
|
||||
</div>
|
||||
<label class="wt-new-wrap">
|
||||
Branch
|
||||
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
||||
</label>
|
||||
<label class="row-inline">
|
||||
<input type="checkbox" name="worktree_cleanup_on_kill" />
|
||||
Remove worktree when session is killed
|
||||
<label class="wt-exist-wrap" hidden>
|
||||
Branch
|
||||
<select class="branch-select"></select>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -142,22 +174,26 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
</div>
|
||||
`;
|
||||
// Field queries are scoped to this form instance — several forms can be
|
||||
// mounted at once (modal + draft panes), so ids would collide.
|
||||
// mounted at once (one per draft pane), so ids would collide.
|
||||
|
||||
const errEl = form.querySelector(".form-error") as HTMLElement;
|
||||
const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement;
|
||||
const cmdRaw = form.querySelector(".cmd-raw") as HTMLInputElement;
|
||||
const recentsEl = form.querySelector(".recents") as HTMLElement;
|
||||
const recentsEl = form.querySelector(".projects") as HTMLElement;
|
||||
const filterInput = form.querySelector(".project-filter") as HTMLInputElement;
|
||||
const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
|
||||
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
|
||||
const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement;
|
||||
const wtHint = form.querySelector(".wt-hint") as HTMLElement;
|
||||
const wtBody = form.querySelector(".wt-body") as HTMLElement;
|
||||
const wtRepo = form.querySelector(".wt-repo") as HTMLElement;
|
||||
const modeRadios = [
|
||||
...form.querySelectorAll<HTMLInputElement>("input[name=worktree_branch_mode]"),
|
||||
];
|
||||
const newWrap = form.querySelector(".wt-new-wrap") as HTMLElement;
|
||||
const existWrap = form.querySelector(".wt-exist-wrap") as HTMLElement;
|
||||
const branchInput = form.querySelector("input[name=worktree_branch]") as HTMLInputElement;
|
||||
const cleanupInput = form.querySelector(
|
||||
"input[name=worktree_cleanup_on_kill]",
|
||||
) as HTMLInputElement;
|
||||
const branchSelect = form.querySelector(".branch-select") as HTMLSelectElement;
|
||||
const nameInput = form.querySelector("input[name=name]") as HTMLInputElement;
|
||||
|
||||
// ---- form state ------------------------------------------------------
|
||||
@@ -166,20 +202,34 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
let selectedPresetId = "";
|
||||
let command = ""; // resolved command (preset's, or the raw custom input)
|
||||
let cwd = ""; // chosen working directory
|
||||
let inferredBaseRepo: string | null = null; // from the gitroot probe
|
||||
let inferredBaseRepo: string | null = null; // from the gitroot probe or the project scan
|
||||
let probeToken = 0; // guards async gitroot probes against races
|
||||
// A seed's cwd, applied once the (async) recents load for the initial host
|
||||
// A seed's cwd, applied once the (async) projects load for the initial host
|
||||
// settles — so the fetch can't race past and clobber the seeded directory.
|
||||
let seedCwd: string | null = prefill.cwd ?? null;
|
||||
let branchMode: WorktreeBranchMode = prefill.worktreeBranchMode ?? "new";
|
||||
let branchToken = 0; // guards async branch-list loads against races
|
||||
// Branch list for the current cwd — refetched whenever the target changes.
|
||||
let branchCache: { cwd: string; branches: BranchInfo[] } | null = null;
|
||||
|
||||
// `emit` stays silent until the initial load settles and goes silent again
|
||||
// once the form is destroyed. Construction runs with `cwd === ""` and the
|
||||
// seed is only restored two fetches later, so emitting in between would
|
||||
// blank the leaf's draft; and `multiview.render()` rebuilds forms while the
|
||||
// old one's fetches are still in flight, so a destroyed form emitting would
|
||||
// clobber the live one's leaf (#57 review).
|
||||
let ready = false;
|
||||
let destroyed = false;
|
||||
const branchValue = () => (branchMode === "existing" ? branchSelect.value : branchInput.value);
|
||||
const emit = () => {
|
||||
if (!ready || destroyed) return;
|
||||
opts.onChange?.({
|
||||
command,
|
||||
cwd,
|
||||
name: nameInput.value,
|
||||
worktreeEnabled: wtEnabled.checked,
|
||||
worktreeBranch: branchInput.value,
|
||||
worktreeCleanup: cleanupInput.checked,
|
||||
worktreeBranch: branchValue(),
|
||||
worktreeBranchMode: branchMode,
|
||||
cwdRawVisible: !cwdRawWrap.hidden,
|
||||
});
|
||||
};
|
||||
@@ -187,13 +237,51 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
// ---- name (also seeds the worktree branch) ---------------------------
|
||||
// The worktree branch defaults to the session name — including a clone's
|
||||
// prefilled name — and tracks it on reroll/edit until the user types their
|
||||
// own branch.
|
||||
// own branch. Only in "new" mode: an existing-branch pick is not a name.
|
||||
let branchEdited = Boolean(prefill.worktreeBranch);
|
||||
const syncBranchToName = () => {
|
||||
if (!branchEdited) branchInput.value = nameInput.value;
|
||||
if (!branchEdited && branchMode === "new") branchInput.value = nameInput.value;
|
||||
};
|
||||
nameInput.value = prefill.name ?? generateName();
|
||||
// Handles come from `GET /api/names` in batches (#53): the first one seeds
|
||||
// the field, rerolls consume the rest locally so they stay instant, and a
|
||||
// new batch is fetched when (and only when) the queue runs dry. One refill
|
||||
// at a time keeps rapid rerolls from storming the daemon.
|
||||
const NAME_BATCH_SIZE = 20;
|
||||
let nameQueue: string[] = [];
|
||||
let nameFetch: Promise<void> | null = null;
|
||||
// Guards against a late batch clobbering something the operator already
|
||||
// typed or rerolled.
|
||||
let nameTouched = Boolean(prefill.name);
|
||||
const refillNames = (): Promise<void> => {
|
||||
if (!nameFetch) {
|
||||
nameFetch = fetchNames(NAME_BATCH_SIZE)
|
||||
.then((batch) => {
|
||||
if (!destroyed) nameQueue.push(...batch.names);
|
||||
})
|
||||
.catch(() => {
|
||||
/* reroll just stays on the current name */
|
||||
})
|
||||
.finally(() => {
|
||||
nameFetch = null;
|
||||
});
|
||||
}
|
||||
return nameFetch;
|
||||
};
|
||||
|
||||
nameInput.value = prefill.name ?? "";
|
||||
if (!nameTouched) {
|
||||
void refillNames().then(() => {
|
||||
if (destroyed || nameTouched || nameInput.value) return;
|
||||
const next = nameQueue.shift();
|
||||
if (next !== undefined) {
|
||||
nameInput.value = next;
|
||||
syncBranchToName();
|
||||
emit();
|
||||
}
|
||||
});
|
||||
}
|
||||
nameInput.addEventListener("input", () => {
|
||||
nameTouched = true;
|
||||
syncBranchToName();
|
||||
emit();
|
||||
});
|
||||
@@ -202,21 +290,36 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
emit();
|
||||
});
|
||||
(form.querySelector(".reroll") as HTMLButtonElement).addEventListener("click", () => {
|
||||
nameInput.value = generateName();
|
||||
syncBranchToName();
|
||||
emit();
|
||||
nameTouched = true;
|
||||
const apply = () => {
|
||||
if (destroyed) return;
|
||||
const next = nameQueue.shift();
|
||||
if (next === undefined) return;
|
||||
nameInput.value = next;
|
||||
syncBranchToName();
|
||||
emit();
|
||||
};
|
||||
// Only hit the daemon when the local batch is exhausted.
|
||||
if (nameQueue.length === 0) {
|
||||
void refillNames().then(apply);
|
||||
} else {
|
||||
apply();
|
||||
}
|
||||
});
|
||||
syncBranchToName(); // seed the branch from the initial name
|
||||
|
||||
// ---- command tiles ---------------------------------------------------
|
||||
function selectPreset(id: string): void {
|
||||
// `focus` is false for the two initial applications (construction, and again
|
||||
// when `/api/info` resolves): a draft pane must not grab the caret out from
|
||||
// under the operator seconds after it mounts.
|
||||
function selectPreset(id: string, focus = true): void {
|
||||
selectedPresetId = id;
|
||||
const preset = presets.find((p) => p.id === id);
|
||||
const isCustom = !preset || preset.command === "";
|
||||
cmdRaw.hidden = !isCustom;
|
||||
if (isCustom) {
|
||||
command = cmdRaw.value.trim();
|
||||
cmdRaw.focus();
|
||||
if (focus) cmdRaw.focus();
|
||||
} else {
|
||||
command = preset!.command;
|
||||
}
|
||||
@@ -270,17 +373,17 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
if (wanted) {
|
||||
const match = presets.find((p) => p.command === wanted && p.command !== "");
|
||||
if (match) {
|
||||
selectPreset(match.id);
|
||||
selectPreset(match.id, false);
|
||||
} else {
|
||||
const custom = presets.find((p) => p.command === "");
|
||||
cmdRaw.value = wanted;
|
||||
selectPreset(custom ? custom.id : "custom");
|
||||
selectPreset(custom ? custom.id : "custom", false);
|
||||
command = wanted;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const first = presets.find((p) => p.command !== "");
|
||||
if (first) selectPreset(first.id);
|
||||
if (first) selectPreset(first.id, false);
|
||||
}
|
||||
|
||||
// ---- working-directory picker ---------------------------------------
|
||||
@@ -288,21 +391,25 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
cwdRawWrap.hidden = false;
|
||||
if (prefillValue != null) cwd = prefillValue;
|
||||
cwdRaw.value = cwd;
|
||||
markActiveRecent(null);
|
||||
markActiveProject(null);
|
||||
if (focus) {
|
||||
cwdRaw.focus();
|
||||
cwdRaw.select();
|
||||
}
|
||||
// Both the visibility and the directory are draft state: without this a
|
||||
// render before the operator types snaps back to the project picker.
|
||||
emit();
|
||||
}
|
||||
|
||||
cwdRaw.addEventListener("input", () => {
|
||||
cwd = cwdRaw.value.trim();
|
||||
branchCache = null;
|
||||
emit();
|
||||
if (wtEnabled.checked) probeGitRoot();
|
||||
});
|
||||
|
||||
function markActiveRecent(activeCwd: string | null): void {
|
||||
for (const row of recentsEl.querySelectorAll(".recent-row")) {
|
||||
function markActiveProject(activeCwd: string | null): void {
|
||||
for (const row of recentsEl.querySelectorAll(".project-row")) {
|
||||
row.classList.toggle(
|
||||
"is-active",
|
||||
(row as HTMLElement).dataset.cwd === activeCwd,
|
||||
@@ -310,75 +417,101 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
}
|
||||
}
|
||||
|
||||
function selectRecent(dir: RecentDir): void {
|
||||
cwd = dir.cwd;
|
||||
function selectProject(project: Project): void {
|
||||
cwd = project.path;
|
||||
cwdRawWrap.hidden = true;
|
||||
markActiveRecent(dir.cwd);
|
||||
markActiveProject(project.path);
|
||||
// The scan already knows this directory is inside a repo (#61): the
|
||||
// toggle defaults back on and no /api/gitroot round trip is needed.
|
||||
wtEnabled.checked = true;
|
||||
applyProjectRepo(project.base_repo ?? project.path);
|
||||
emit();
|
||||
// Picking a recent dir can pre-select the command it was last used with.
|
||||
const match = presets.find((p) => p.command === dir.last_command && p.command !== "");
|
||||
// Picking a project pre-selects the command it was last used with.
|
||||
const match = presets.find((p) => p.command === project.last_command && p.command !== "");
|
||||
if (match) selectPreset(match.id);
|
||||
if (wtEnabled.checked) probeGitRoot();
|
||||
}
|
||||
|
||||
async function loadRecents(): Promise<void> {
|
||||
recentsEl.replaceChildren(loadingRow());
|
||||
let dirs: RecentDir[] = [];
|
||||
try {
|
||||
dirs = (await recentDirs()).dirs ?? [];
|
||||
} catch {
|
||||
dirs = [];
|
||||
}
|
||||
let discovered: Project[] = []; // daemon snapshot, daemon-sorted
|
||||
|
||||
function renderProjects(): void {
|
||||
recentsEl.replaceChildren();
|
||||
for (const dir of dirs) {
|
||||
recentsEl.appendChild(recentRow(dir));
|
||||
const sorted = sortProjects(filterProjects(discovered, filterInput.value), new Date());
|
||||
for (const group of groupProjects(sorted)) {
|
||||
recentsEl.appendChild(projectRow(group.main));
|
||||
for (const wt of group.worktrees) recentsEl.appendChild(projectRow(wt));
|
||||
}
|
||||
if (discovered.length > 0 && sorted.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "picker-empty";
|
||||
empty.textContent = "No projects match.";
|
||||
recentsEl.appendChild(empty);
|
||||
}
|
||||
// Persistent escape hatch + empty-history fallback.
|
||||
recentsEl.appendChild(otherDirRow());
|
||||
markActiveProject(cwdRawWrap.hidden ? cwd : null);
|
||||
}
|
||||
|
||||
filterInput.addEventListener("input", renderProjects);
|
||||
|
||||
async function loadProjects(): Promise<void> {
|
||||
recentsEl.replaceChildren(loadingRow());
|
||||
try {
|
||||
discovered = await fetchProjects();
|
||||
} catch {
|
||||
discovered = [];
|
||||
}
|
||||
if (destroyed) return;
|
||||
renderProjects();
|
||||
if (seedCwd) {
|
||||
// A seed sets the directory verbatim, regardless of recorded history.
|
||||
// A seed sets the directory verbatim, regardless of what the scan found.
|
||||
// `cwdRawVisible === false` marks a draft restore whose cwd came from a
|
||||
// picked recent row: keep the picker and don't steal focus. Otherwise
|
||||
// land focused in the raw input (clone / empty-history flows).
|
||||
// picked row: keep the picker and don't steal focus. Otherwise land
|
||||
// focused in the raw input (clone / no-match flows).
|
||||
if (prefill.cwdRawVisible === false) {
|
||||
cwd = seedCwd;
|
||||
cwdRaw.value = seedCwd;
|
||||
markActiveRecent(cwd);
|
||||
markActiveProject(cwd);
|
||||
} else {
|
||||
showRawCwd(seedCwd);
|
||||
showRawCwd(seedCwd, false);
|
||||
}
|
||||
seedCwd = null;
|
||||
} else if (dirs.length === 0) {
|
||||
// No history: default to raw input prefilled with the daemon's home dir.
|
||||
showRawCwd(homeDir);
|
||||
} else if (discovered.length === 0) {
|
||||
// Nothing discovered yet: default to raw input prefilled with home.
|
||||
showRawCwd(homeDir, false);
|
||||
} else {
|
||||
cwdRawWrap.hidden = true;
|
||||
}
|
||||
// The seeded cwd is in place — a restored worktree can probe now.
|
||||
if (wtEnabled.checked) void probeGitRoot();
|
||||
// Everything seeded: the form may publish from here on.
|
||||
ready = true;
|
||||
resolveBaseRepo();
|
||||
emit();
|
||||
}
|
||||
|
||||
function recentRow(dir: RecentDir): HTMLElement {
|
||||
function projectRow(project: Project): HTMLElement {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "recent-row";
|
||||
row.dataset.cwd = dir.cwd;
|
||||
row.className = `project-row${project.is_worktree ? " is-worktree" : ""}${
|
||||
isStale(project, new Date()) ? " is-stale" : ""
|
||||
}`;
|
||||
row.dataset.cwd = project.path;
|
||||
// Abbreviate exactly like the sidebar: home → `~`, then collapse leading
|
||||
// segments to initials (`~/g/n/maestro`). The full path stays in the tooltip
|
||||
// and `dataset.cwd` (what's actually submitted).
|
||||
const display = abbreviateFolderName(abbreviateCwd(dir.cwd, homeDir));
|
||||
const display = abbreviateFolderName(abbreviateCwd(project.path, homeDir));
|
||||
row.innerHTML = `
|
||||
<span class="recent-dir" title="${escapeHtml(dir.cwd)}">${display}</span>
|
||||
<span class="recent-cmd">${escapeHtml(dir.last_command)}</span>`;
|
||||
row.addEventListener("click", () => selectRecent(dir));
|
||||
<span class="project-info">
|
||||
<span class="project-name">${escapeHtml(project.name)}</span>
|
||||
<span class="recent-dir" title="${escapeHtml(project.path)}">${display}</span>
|
||||
</span>
|
||||
<span class="recent-cmd">${escapeHtml(project.last_command ?? "")}</span>`;
|
||||
row.addEventListener("click", () => selectProject(project));
|
||||
return row;
|
||||
}
|
||||
|
||||
function otherDirRow(): HTMLElement {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "recent-row recent-other";
|
||||
row.innerHTML = `<span class="recent-dir">Other directory…</span>`;
|
||||
row.className = "project-row project-other";
|
||||
row.innerHTML = `<span class="project-info"><span class="recent-dir">Other directory…</span></span>`;
|
||||
row.addEventListener("click", () => showRawCwd(cwd || homeDir));
|
||||
return row;
|
||||
}
|
||||
@@ -386,11 +519,41 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
function loadingRow(): HTMLElement {
|
||||
const p = document.createElement("p");
|
||||
p.className = "picker-loading";
|
||||
p.textContent = "Loading recent directories…";
|
||||
p.textContent = "Loading projects…";
|
||||
return p;
|
||||
}
|
||||
|
||||
// ---- worktree (branch-only; base_repo inferred via /gitroot) ----------
|
||||
// ---- worktree (branch-only; base_repo inferred via scan or /gitroot) --
|
||||
// The project scan already reports repo membership, so a picked project
|
||||
// resolves its base repo locally (#61); only the free-text escape hatch
|
||||
// pays for a /api/gitroot probe.
|
||||
function applyProjectRepo(repo: string): void {
|
||||
inferredBaseRepo = repo;
|
||||
branchCache = null;
|
||||
wtHint.hidden = true;
|
||||
wtBody.hidden = false;
|
||||
wtRepo.textContent = `repo: ${repo}`;
|
||||
if (branchMode === "existing") void loadBranches();
|
||||
}
|
||||
|
||||
/** Decide how the worktree section describes the current target. Called
|
||||
* once the initial load has settled `cwd`. */
|
||||
function resolveBaseRepo(): void {
|
||||
if (!cwd) {
|
||||
inferredBaseRepo = null;
|
||||
wtBody.hidden = true;
|
||||
wtHint.hidden = false;
|
||||
wtHint.textContent = "Choose a working directory first.";
|
||||
return;
|
||||
}
|
||||
const project = discovered.find((p) => p.path === cwd);
|
||||
if (project) {
|
||||
applyProjectRepo(project.base_repo ?? project.path);
|
||||
return;
|
||||
}
|
||||
void probeGitRoot();
|
||||
}
|
||||
|
||||
async function probeGitRoot(): Promise<void> {
|
||||
const token = ++probeToken;
|
||||
if (!cwd) {
|
||||
@@ -411,45 +574,101 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
}
|
||||
if (token !== probeToken) return; // a newer probe superseded us
|
||||
if (root) {
|
||||
inferredBaseRepo = root;
|
||||
wtHint.hidden = true;
|
||||
wtBody.hidden = false;
|
||||
wtRepo.textContent = `repo: ${root} (inferred)`;
|
||||
applyProjectRepo(root);
|
||||
} else {
|
||||
inferredBaseRepo = null;
|
||||
// Publish the untick too, or the next render seeds the box checked with
|
||||
// no inferred repo and submitting fails.
|
||||
wtEnabled.checked = false;
|
||||
emit();
|
||||
wtBody.hidden = true;
|
||||
wtHint.hidden = false;
|
||||
wtHint.textContent = "Not inside a git repository — pick a dir in a repo to use a worktree.";
|
||||
}
|
||||
}
|
||||
|
||||
// ---- existing-branch picker (#61) -------------------------------------
|
||||
function setBranchMode(mode: WorktreeBranchMode): void {
|
||||
branchMode = mode;
|
||||
for (const radio of modeRadios) radio.checked = radio.value === mode;
|
||||
newWrap.hidden = mode !== "new";
|
||||
existWrap.hidden = mode !== "existing";
|
||||
if (mode === "existing") void loadBranches();
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Fetch the target repo's branches once per cwd. Like every async load in
|
||||
* this form: token-guarded, and nothing touches the DOM (or emits) after
|
||||
* destroy() — a late response into a rebuilt form would corrupt that
|
||||
* form's draft (#57 review). */
|
||||
async function loadBranches(): Promise<void> {
|
||||
if (!cwd || !inferredBaseRepo) return;
|
||||
const token = ++branchToken;
|
||||
if (branchCache?.cwd !== cwd) {
|
||||
const target = cwd;
|
||||
let branches: BranchInfo[];
|
||||
try {
|
||||
branches = await fetchBranches(target);
|
||||
} catch {
|
||||
return; // picker just stays empty; "new" mode still works
|
||||
}
|
||||
if (destroyed || token !== branchToken || cwd !== target) return;
|
||||
branchCache = { cwd: target, branches };
|
||||
}
|
||||
renderBranchOptions();
|
||||
}
|
||||
|
||||
function renderBranchOptions(): void {
|
||||
if (destroyed) return;
|
||||
const branches = branchCache?.cwd === cwd ? branchCache.branches : [];
|
||||
branchSelect.replaceChildren();
|
||||
const opt = (label: string, value: string) => {
|
||||
const o = document.createElement("option");
|
||||
o.textContent = label;
|
||||
o.value = value;
|
||||
return o;
|
||||
};
|
||||
if (branches.length === 0) {
|
||||
branchSelect.appendChild(opt("No local branches found.", ""));
|
||||
return;
|
||||
}
|
||||
for (const b of branches) {
|
||||
// A branch another worktree holds stays visible but unpickable — git
|
||||
// would refuse to check it out twice; the path says who holds it.
|
||||
const o = opt(b.worktree ? `${b.name} — held by ${b.worktree}` : b.name, b.name);
|
||||
o.disabled = Boolean(b.worktree);
|
||||
o.title = b.worktree ?? "";
|
||||
branchSelect.appendChild(o);
|
||||
}
|
||||
}
|
||||
|
||||
for (const radio of modeRadios) {
|
||||
radio.addEventListener("change", () => {
|
||||
if (radio.checked) setBranchMode(radio.value as WorktreeBranchMode);
|
||||
});
|
||||
}
|
||||
branchSelect.addEventListener("change", () => emit());
|
||||
|
||||
wtEnabled.addEventListener("change", () => {
|
||||
emit();
|
||||
if (wtEnabled.checked) {
|
||||
probeGitRoot();
|
||||
resolveBaseRepo();
|
||||
} else {
|
||||
inferredBaseRepo = null;
|
||||
wtBody.hidden = true;
|
||||
wtHint.hidden = true;
|
||||
}
|
||||
});
|
||||
cleanupInput.addEventListener("change", emit);
|
||||
|
||||
// A seeded worktree restores its checkbox/body up front; its gitroot probe
|
||||
// waits for loadRecents to apply the seeded cwd.
|
||||
if (prefill.worktreeEnabled || prefill.worktreeBranch || prefill.worktreeCleanup) {
|
||||
wtEnabled.checked = Boolean(prefill.worktreeEnabled);
|
||||
if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch;
|
||||
cleanupInput.checked = Boolean(prefill.worktreeCleanup);
|
||||
wtBody.hidden = !wtEnabled.checked;
|
||||
wtHint.hidden = true;
|
||||
emit();
|
||||
}
|
||||
// The toggle defaults ON (#61): the operator almost always wants a
|
||||
// worktree, and every non-repo target unticks it via the gitroot probe.
|
||||
wtEnabled.checked = prefill.worktreeEnabled ?? true;
|
||||
if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch;
|
||||
setBranchMode(branchMode);
|
||||
|
||||
// Seed ordering: the raw-cwd application races the async info load (which
|
||||
// provides homeDir for the recents' abbreviations), so recents wait for it.
|
||||
void infoLoad.then(() => loadRecents());
|
||||
// provides homeDir for the projects' abbreviations), so projects wait for it.
|
||||
void infoLoad.then(() => loadProjects());
|
||||
|
||||
// ---- live dir autocomplete on the raw cwd input (NAP-329) ------------
|
||||
const autocomplete = attachDirAutocomplete(cwdRaw);
|
||||
@@ -464,8 +683,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
cwd,
|
||||
name: nameInput.value,
|
||||
worktreeEnabled: wtEnabled.checked,
|
||||
worktreeBranch: branchInput.value,
|
||||
worktreeCleanup: cleanupInput.checked,
|
||||
worktreeBranch: branchValue(),
|
||||
worktreeBranchMode: branchMode,
|
||||
},
|
||||
inferredBaseRepo,
|
||||
);
|
||||
@@ -488,6 +707,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
return {
|
||||
el: form,
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
autocomplete.destroy();
|
||||
form.remove();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
||||
|
||||
const BATCH_A = ["allegro-cello", "presto-fugue", "adagio-lyre"];
|
||||
const BATCH_B = ["forte-oboe", "largo-tuba", "vivace-harp"];
|
||||
|
||||
/** Stub the three fetches the form's initial load makes. Records every
|
||||
* /api/names URL so tests can assert how many batches were fetched. */
|
||||
function stubFetch(batches: string[][]): string[] {
|
||||
let batch = 0;
|
||||
const nameUrls: string[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/api/names")) {
|
||||
nameUrls.push(url);
|
||||
const names = batches[Math.min(batch, batches.length - 1)];
|
||||
batch += 1;
|
||||
return new Response(JSON.stringify({ names }), { status: 200 });
|
||||
}
|
||||
const body = url.includes("/api/info")
|
||||
? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }
|
||||
: [];
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}),
|
||||
);
|
||||
return nameUrls;
|
||||
}
|
||||
|
||||
describe("spawnForm name batch (#53)", () => {
|
||||
let handle: SpawnFormHandle | null = null;
|
||||
let nameUrls: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
handle?.destroy();
|
||||
handle = null;
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function mount(seed?: Parameters<typeof createSpawnForm>[0]["seed"]) {
|
||||
nameUrls = stubFetch([BATCH_A, BATCH_B]);
|
||||
handle = createSpawnForm({ seed, onSuccess() {} });
|
||||
document.body.appendChild(handle.el);
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
return handle.el;
|
||||
}
|
||||
|
||||
const nameOf = (form: HTMLFormElement) =>
|
||||
form.querySelector<HTMLInputElement>("input[name=name]")!.value;
|
||||
|
||||
function reroll(form: HTMLFormElement): void {
|
||||
form.querySelector<HTMLButtonElement>(".reroll")!.click();
|
||||
}
|
||||
|
||||
async function drain(): Promise<void> {
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
it("seeds its name from a fetched batch when no seed provides one", async () => {
|
||||
const form = await mount();
|
||||
expect(BATCH_A).toContain(nameOf(form));
|
||||
expect(nameUrls).toHaveLength(1);
|
||||
expect(nameUrls[0]).toContain("/api/names");
|
||||
expect(nameUrls[0]).toContain("count=");
|
||||
});
|
||||
|
||||
it("keeps a seeded name and defers fetching until the first reroll", async () => {
|
||||
const form = await mount({ name: "my-session" });
|
||||
expect(nameOf(form)).toBe("my-session");
|
||||
expect(nameUrls ?? []).toHaveLength(0);
|
||||
|
||||
reroll(form);
|
||||
await drain();
|
||||
expect(nameOf(form)).not.toBe("my-session");
|
||||
expect(nameUrls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rerolls locally from the batch with no extra round trips", async () => {
|
||||
const form = await mount();
|
||||
expect(nameUrls).toHaveLength(1);
|
||||
const seen = new Set([nameOf(form)]);
|
||||
|
||||
for (let i = 0; i < BATCH_A.length; i++) {
|
||||
reroll(form);
|
||||
seen.add(nameOf(form));
|
||||
}
|
||||
await drain();
|
||||
|
||||
// Every value came from the one fetched batch.
|
||||
expect(seen.size).toBeGreaterThanOrEqual(2);
|
||||
for (const value of seen) {
|
||||
expect([...BATCH_A, ...BATCH_B]).toContain(value);
|
||||
}
|
||||
expect(nameUrls).toHaveLength(2); // refetch fired when the batch ran out
|
||||
expect(nameUrls[1]).toContain("/api/names");
|
||||
});
|
||||
|
||||
it("refetches only once even if rerolls outpace the network", async () => {
|
||||
const form = await mount();
|
||||
// Drain the batch without awaiting between rerolls.
|
||||
for (let i = 0; i < BATCH_A.length + 1; i++) reroll(form);
|
||||
await drain();
|
||||
// One refill in flight covers the whole run — no fetch storm.
|
||||
expect(nameUrls.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("does not clobber an operator-typed name when the batch arrives late", async () => {
|
||||
nameUrls = stubFetch([BATCH_A]);
|
||||
handle = createSpawnForm({ onSuccess() {} });
|
||||
const form = handle.el;
|
||||
document.body.appendChild(form);
|
||||
|
||||
// The operator types before the fetch resolves.
|
||||
const input = form.querySelector<HTMLInputElement>("input[name=name]")!;
|
||||
input.value = "hand-typed";
|
||||
input.dispatchEvent(new Event("input"));
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
|
||||
expect(nameOf(form)).toBe("hand-typed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js";
|
||||
|
||||
const PROJECTS = [
|
||||
{ path: "/home/user/maestro", name: "maestro", last_activity: "2026-08-20T10:00:00Z" },
|
||||
];
|
||||
|
||||
const BRANCHES = [
|
||||
{ name: "main", is_head: true },
|
||||
{ name: "spawn-v2", is_head: false, worktree: "/home/user/wt-spawn" },
|
||||
{ name: "free-branch", is_head: false },
|
||||
];
|
||||
|
||||
/** Stub the fetches the form makes. `gateBranches` (when given) holds the
|
||||
* /api/branches response until released, so tests can destroy the form while
|
||||
* its branch list is still in flight. */
|
||||
function stubFetch(opts?: {
|
||||
gateBranches?: (release: () => void) => void;
|
||||
}): string[] {
|
||||
const urls: string[] = [];
|
||||
const gated = new Promise<void>((resolve) => {
|
||||
if (opts?.gateBranches) opts.gateBranches(resolve);
|
||||
else resolve();
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
urls.push(url);
|
||||
if (url.includes("/api/names")) {
|
||||
return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 });
|
||||
}
|
||||
if (url.includes("/api/branches")) {
|
||||
await gated;
|
||||
return new Response(JSON.stringify(BRANCHES), { status: 200 });
|
||||
}
|
||||
if (url.includes("/api/projects")) {
|
||||
return new Response(JSON.stringify(PROJECTS), { status: 200 });
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }),
|
||||
{ status: 200 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
return urls;
|
||||
}
|
||||
|
||||
describe("spawnForm worktree defaults and branch picker (#61)", () => {
|
||||
let handle: SpawnFormHandle | null = null;
|
||||
let urls: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
handle?.destroy();
|
||||
handle = null;
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function mount(
|
||||
seed?: Parameters<typeof createSpawnForm>[0]["seed"],
|
||||
onChange?: (values: SpawnFormValues) => void,
|
||||
) {
|
||||
urls = stubFetch();
|
||||
handle = createSpawnForm({ seed, onSuccess() {}, onChange });
|
||||
document.body.appendChild(handle.el);
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
return handle.el;
|
||||
}
|
||||
|
||||
const drain = async () => {
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
function pickProject(form: HTMLFormElement): void {
|
||||
form.querySelector<HTMLButtonElement>(".project-row.project-other")!.click();
|
||||
form
|
||||
.querySelectorAll<HTMLButtonElement>(".project-row")
|
||||
.forEach((row) => row.dataset.cwd === "/home/user/maestro" && row.click());
|
||||
}
|
||||
|
||||
it("defaults the worktree toggle on for a picked project with no gitroot probe", async () => {
|
||||
const form = await mount();
|
||||
expect(form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
pickProject(form);
|
||||
await drain();
|
||||
|
||||
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
|
||||
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain(
|
||||
"/home/user/maestro",
|
||||
);
|
||||
});
|
||||
|
||||
it("still probes the gitroot for a free-text directory", async () => {
|
||||
const form = await mount();
|
||||
|
||||
// Escape hatch: switch to a free-text dir; only now does /api/gitroot fire.
|
||||
form.querySelector<HTMLElement>(".project-row.project-other")!.click();
|
||||
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
|
||||
raw.value = "/opt/somewhere";
|
||||
raw.dispatchEvent(new Event("input"));
|
||||
await drain();
|
||||
|
||||
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(true);
|
||||
});
|
||||
|
||||
it("populates existing branches, disabling ones held by another worktree", async () => {
|
||||
const form = await mount();
|
||||
pickProject(form);
|
||||
|
||||
const radio = form.querySelector<HTMLInputElement>(
|
||||
'input[name=worktree_branch_mode][value="existing"]',
|
||||
)!;
|
||||
radio.click();
|
||||
await drain();
|
||||
|
||||
const select = form.querySelector<HTMLSelectElement>(".branch-select")!;
|
||||
expect(urls.some((u) => u.includes("/api/branches?path="))).toBe(true);
|
||||
const options = [...select.options];
|
||||
expect(options.map((o) => o.value)).toEqual(["main", "spawn-v2", "free-branch"]);
|
||||
const held = options.find((o) => o.value === "spawn-v2")!;
|
||||
expect(held.disabled).toBe(true);
|
||||
expect(held.textContent).toContain("/home/user/wt-spawn");
|
||||
expect(options.find((o) => o.value === "free-branch")!.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("emits the branch mode as draft state when it changes", async () => {
|
||||
const changes: SpawnFormValues[] = [];
|
||||
const form = await mount(undefined, (v) => changes.push(v));
|
||||
await drain();
|
||||
|
||||
const radio = form.querySelector<HTMLInputElement>(
|
||||
'input[name=worktree_branch_mode][value="existing"]',
|
||||
)!;
|
||||
radio.click();
|
||||
await drain();
|
||||
|
||||
const modes = changes.map((c) => (c as { worktreeBranchMode?: string }).worktreeBranchMode);
|
||||
expect(modes).toContain("new");
|
||||
expect(modes.at(-1)).toBe("existing");
|
||||
});
|
||||
|
||||
it("does not repopulate a destroyed form when the branch list lands late", async () => {
|
||||
const gate: { release?: () => void } = {};
|
||||
urls = stubFetch({ gateBranches: (r) => (gate.release = r) });
|
||||
handle = createSpawnForm({ onSuccess() {} });
|
||||
document.body.appendChild(handle.el);
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
|
||||
pickProject(handle.el);
|
||||
handle.el
|
||||
.querySelector<HTMLInputElement>('input[name=worktree_branch_mode][value="existing"]')!
|
||||
.click();
|
||||
|
||||
handle.destroy();
|
||||
gate.release?.();
|
||||
await drain();
|
||||
|
||||
expect(
|
||||
handle.el.querySelector<HTMLSelectElement>(".branch-select")!.options.length,
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
+61
-118
@@ -1205,72 +1205,9 @@ textarea {
|
||||
border-color: var(--accent-strong);
|
||||
}
|
||||
|
||||
/* ---------- modal ---------- */
|
||||
/* ---------- forms ---------- */
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: oklch(0.12 0.01 256 / 0.6);
|
||||
backdrop-filter: blur(3px);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
z-index: var(--z-modal);
|
||||
animation: fade var(--dur) var(--ease-out);
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.modal-backdrop {
|
||||
align-items: center;
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
border-radius: var(--r-3) var(--r-3) 0 0;
|
||||
border: 1px solid var(--border);
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
max-height: 92vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--shadow-2);
|
||||
animation: sheet-up var(--dur-slow) var(--ease-out);
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.modal {
|
||||
border-radius: var(--r-3);
|
||||
animation: pop var(--dur) var(--ease-out);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-15);
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.modal-header h2::before {
|
||||
content: "+";
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
.spawn-form {
|
||||
padding: var(--sp-3);
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
@@ -1280,7 +1217,6 @@ textarea {
|
||||
scrollbar-color: var(--border-strong) transparent;
|
||||
}
|
||||
|
||||
.modal-body label,
|
||||
.spawn-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1290,7 +1226,6 @@ textarea {
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.modal-body label.row-inline,
|
||||
.spawn-form label.row-inline {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@@ -1300,10 +1235,9 @@ textarea {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-body input[type="text"],
|
||||
.modal-body input[type="search"],
|
||||
.modal-body select,
|
||||
.spawn-form input[type="text"] {
|
||||
.spawn-form input[type="text"],
|
||||
.spawn-form input[type="search"],
|
||||
.spawn-form select {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
@@ -1318,23 +1252,20 @@ textarea {
|
||||
background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.modal-body input[type="text"]:hover,
|
||||
.modal-body select:hover,
|
||||
.spawn-form input[type="text"]:hover {
|
||||
.spawn-form input[type="text"]:hover,
|
||||
.spawn-form select:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.modal-body input[type="text"]:focus,
|
||||
.modal-body input[type="search"]:focus,
|
||||
.modal-body select:focus,
|
||||
.spawn-form input[type="text"]:focus {
|
||||
.spawn-form input[type="text"]:focus,
|
||||
.spawn-form input[type="search"]:focus,
|
||||
.spawn-form select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 0 0 3px var(--accent-tint);
|
||||
}
|
||||
|
||||
.modal-body input[type="checkbox"],
|
||||
.spawn-form input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
@@ -1359,13 +1290,6 @@ textarea {
|
||||
font-size: var(--fs-13);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: var(--danger);
|
||||
margin: 0;
|
||||
@@ -1381,7 +1305,6 @@ textarea {
|
||||
|
||||
/* ---------- spawn form: tiles + recents picker (#27) ---------- */
|
||||
|
||||
.spawn-modal .field,
|
||||
.spawn-form .field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1448,7 +1371,8 @@ textarea {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Recent-dirs picker rows. */
|
||||
/* Project picker rows (#59). Worktree rows are indented under their base
|
||||
repository; rows stale beyond 7 days are dimmed but still selectable. */
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1459,7 +1383,11 @@ textarea {
|
||||
scrollbar-color: var(--border-strong) transparent;
|
||||
}
|
||||
|
||||
.recent-row {
|
||||
.project-filter {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.project-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
@@ -1477,18 +1405,40 @@ textarea {
|
||||
background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.recent-row:hover {
|
||||
.project-row:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.recent-row.is-active {
|
||||
.project-row.is-active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
|
||||
.project-row.is-worktree {
|
||||
margin-left: var(--sp-4);
|
||||
}
|
||||
|
||||
.project-row.is-stale {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.project-info {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-size: var(--fs-13);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recent-dir {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-14);
|
||||
font-size: var(--fs-12);
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -1501,12 +1451,18 @@ textarea {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.recent-other .recent-dir {
|
||||
color: var(--muted);
|
||||
.project-other .recent-dir {
|
||||
font-family: inherit;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.picker-empty {
|
||||
margin: 0;
|
||||
padding: 6px var(--sp-3);
|
||||
color: var(--faint);
|
||||
font-size: var(--fs-13);
|
||||
}
|
||||
|
||||
.picker-loading {
|
||||
margin: 0;
|
||||
padding: 6px var(--sp-3);
|
||||
@@ -1542,6 +1498,16 @@ textarea {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* New-vs-existing branch source (#61). */
|
||||
.wt-mode {
|
||||
display: flex;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.branch-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Generated-name field with a reroll button. */
|
||||
.name-row {
|
||||
display: flex;
|
||||
@@ -1568,7 +1534,7 @@ textarea {
|
||||
|
||||
.dir-ac {
|
||||
position: fixed;
|
||||
z-index: var(--z-toast); /* above the modal it completes within */
|
||||
z-index: var(--z-toast); /* above the draft pane it completes within */
|
||||
margin: 0;
|
||||
padding: var(--sp-1);
|
||||
list-style: none;
|
||||
@@ -1716,26 +1682,6 @@ textarea {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sheet-up {
|
||||
from {
|
||||
transform: translateY(16px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pop {
|
||||
from {
|
||||
transform: scale(0.98);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- responsive ---------- */
|
||||
|
||||
@media (max-width: 520px) {
|
||||
@@ -2076,9 +2022,6 @@ textarea {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-strong) transparent;
|
||||
}
|
||||
|
||||
/* pane tools cluster (disconnect / split / close), top-right corner. The attach
|
||||
|
||||
@@ -83,12 +83,56 @@ export interface GitRoot {
|
||||
root?: string;
|
||||
}
|
||||
|
||||
/** `GET /api/names?count=N` — a batch of fresh session handles (#53). Mirrors
|
||||
* `maestro_protocol::NameBatch`. */
|
||||
export interface NameBatch {
|
||||
names: string[];
|
||||
}
|
||||
|
||||
/** One discovered git repository (or linked worktree) for `GET /api/projects`
|
||||
* (#54). Mirrors `maestro_protocol::Project`. */
|
||||
export interface Project {
|
||||
/** Absolute path of the repository/worktree toplevel. */
|
||||
path: string;
|
||||
/** Basename of `path`. */
|
||||
name: string;
|
||||
is_worktree?: boolean;
|
||||
/** The main checkout this worktree belongs to; absent on plain repos. */
|
||||
base_repo?: string;
|
||||
/** Later of the repo's git HEAD mtime and the newest spawn-history use of
|
||||
* any directory inside it; absent when neither is known. */
|
||||
last_activity?: IsoTimestamp;
|
||||
/** Command last spawned in this project, from spawn history. */
|
||||
last_command?: string;
|
||||
}
|
||||
|
||||
/** `GET /api/projects` — every discovered project, sorted by the daemon
|
||||
* newest-activity-first (#54). */
|
||||
export type Projects = Project[];
|
||||
|
||||
export interface WorktreeSpec {
|
||||
base_repo: string;
|
||||
branch: string;
|
||||
cleanup_on_kill?: boolean;
|
||||
/** When false, `branch` already exists and is checked out as-is (#61).
|
||||
* Defaults to true (create a new branch) on older daemons. */
|
||||
create_branch?: boolean;
|
||||
}
|
||||
|
||||
/** One local branch of the enclosing repository, from `GET /api/branches`
|
||||
* (#55). Mirrors `maestro_protocol::BranchInfo`. */
|
||||
export interface BranchInfo {
|
||||
/** Short branch name (`main`, `feature/foo`). */
|
||||
name: string;
|
||||
/** Whether this branch is the main checkout's current HEAD. */
|
||||
is_head: boolean;
|
||||
/** Toplevel path of the linked worktree holding this branch, if any. */
|
||||
worktree?: string | null;
|
||||
}
|
||||
|
||||
/** `GET /api/branches?path=` — the local branches of one repository (#55). */
|
||||
export type Branches = BranchInfo[];
|
||||
|
||||
export interface CreateSessionRequest {
|
||||
cwd: string;
|
||||
command: string;
|
||||
|
||||
Reference in New Issue
Block a user