Compare commits
26
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 |
Generated
+9
@@ -1102,6 +1102,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"libc",
|
"libc",
|
||||||
"maestro-config",
|
"maestro-config",
|
||||||
|
"maestro-names",
|
||||||
"maestro-protocol",
|
"maestro-protocol",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1146,6 +1147,13 @@ dependencies = [
|
|||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "maestro-names"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"rand 0.8.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "maestro-protocol"
|
name = "maestro-protocol"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1185,6 +1193,7 @@ dependencies = [
|
|||||||
"maestro-protocol",
|
"maestro-protocol",
|
||||||
"maestro-testing",
|
"maestro-testing",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
|
"reqwest",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ dirs = "5"
|
|||||||
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
maestro-config = { path = "crates/maestro-config" }
|
maestro-config = { path = "crates/maestro-config" }
|
||||||
|
maestro-names = { path = "crates/maestro-names" }
|
||||||
maestro-protocol = { path = "crates/maestro-protocol" }
|
maestro-protocol = { path = "crates/maestro-protocol" }
|
||||||
maestro-testing = { path = "crates/maestro-testing" }
|
maestro-testing = { path = "crates/maestro-testing" }
|
||||||
maestro-tui = { path = "crates/maestro-tui" }
|
maestro-tui = { path = "crates/maestro-tui" }
|
||||||
|
|||||||
@@ -79,16 +79,15 @@ no bounce. Tokens `--dur-fast`/`--dur`/`--dur-slow`.
|
|||||||
`connecting` state pill pulses.
|
`connecting` state pill pulses.
|
||||||
- Interaction: row hover/focus, button press (1px), input focus ring, chevron
|
- Interaction: row hover/focus, button press (1px), input focus ring, chevron
|
||||||
rotation, theme-toggle sun/moon swap.
|
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,
|
- Every animation has a `prefers-reduced-motion: reduce` path (pulses off,
|
||||||
transitions near-instant).
|
transitions near-instant).
|
||||||
|
|
||||||
## Layout & structure
|
## Layout & structure
|
||||||
|
|
||||||
Mobile-first, structural responsiveness (not fluid type). Single `≤520px`
|
Mobile-first, structural responsiveness (not fluid type). Single `≤520px`
|
||||||
breakpoint stacks the header (full-width search, wrapped actions). `≥600px`
|
breakpoint stacks the header (full-width search, wrapped actions). A faint
|
||||||
switches the spawn modal from bottom-sheet to centered dialog. A faint slate grid texture sits on `--bg` as
|
slate grid texture sits on `--bg` as terminal atmosphere (very low contrast,
|
||||||
terminal atmosphere (very low contrast, not decoration).
|
not decoration).
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
@@ -101,8 +100,10 @@ Three surfaces, all DOM built in TS, styled by class:
|
|||||||
no-sessions, no-search-match.
|
no-sessions, no-search-match.
|
||||||
- **Attach** — header (back, `session <id>`, live/closed/error state pill) over
|
- **Attach** — header (back, `session <id>`, live/closed/error state pill) over
|
||||||
a framed dark xterm with a green cursor.
|
a framed dark xterm with a green cursor.
|
||||||
- **Spawn modal** — bottom-sheet/dialog form; mono inputs with green focus
|
- **Draft pane** — the spawn form mounted inline in an empty split pane,
|
||||||
ring; grouped worktree fieldset.
|
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
|
Interactive elements ship default / hover / focus-visible / active / disabled
|
||||||
states; the accent green is used only for action, selection, focus, and live
|
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)
|
## Dev review aids (DEV-only, stripped from production)
|
||||||
|
|
||||||
`web/src/mock.ts` + query params seed a representative catalog without a running
|
`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`.
|
`?theme=light|dark` (pin a theme). Guarded by `import.meta.env.DEV`.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ dirs.workspace = true
|
|||||||
futures-util.workspace = true
|
futures-util.workspace = true
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
maestro-config.workspace = true
|
maestro-config.workspace = true
|
||||||
|
maestro-names.workspace = true
|
||||||
maestro-protocol.workspace = true
|
maestro-protocol.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
@@ -32,5 +33,6 @@ reqwest.workspace = true
|
|||||||
tokio-tungstenite.workspace = true
|
tokio-tungstenite.workspace = true
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
maestro-names.workspace = true
|
||||||
# `test-util` enables `start_paused` for instant timeout tests (NAP-270).
|
# `test-util` enables `start_paused` for instant timeout tests (NAP-270).
|
||||||
tokio = { workspace = true, features = ["test-util"] }
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
pub mod activity;
|
pub mod activity;
|
||||||
pub mod attach;
|
pub mod attach;
|
||||||
|
pub mod branches;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod dirlist;
|
pub mod dirlist;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use axum::routing::{any, delete, get};
|
|||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use maestro_config::SpawnPreset;
|
use maestro_config::SpawnPreset;
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, Session,
|
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch, Session,
|
||||||
PROTOCOL_VERSION,
|
PROTOCOL_VERSION,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -48,6 +48,8 @@ pub fn router(state: LocalApiState) -> Router {
|
|||||||
.route(routes::RECENT_DIRS, get(recent_dirs))
|
.route(routes::RECENT_DIRS, get(recent_dirs))
|
||||||
.route(routes::PROJECTS, get(projects))
|
.route(routes::PROJECTS, get(projects))
|
||||||
.route(routes::GITROOT, get(git_root))
|
.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::INFO, get(daemon_info))
|
||||||
.route(routes::EVENTS, any(events_ws))
|
.route(routes::EVENTS, any(events_ws))
|
||||||
.route(routes::patterns::ATTACH, any(attach_ws))
|
.route(routes::patterns::ATTACH, any(attach_ws))
|
||||||
@@ -121,6 +123,22 @@ async fn git_root(State(s): State<LocalApiState>, Query(q): Query<DirQuery>) ->
|
|||||||
.into_response()
|
.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> {
|
async fn daemon_info(State(s): State<LocalApiState>) -> Json<DaemonInfo> {
|
||||||
Json(DaemonInfo {
|
Json(DaemonInfo {
|
||||||
hostname: (*s.hostname).clone(),
|
hostname: (*s.hostname).clone(),
|
||||||
|
|||||||
@@ -29,9 +29,14 @@ pub const DEFAULT_HOME_IGNORE: &[&str] =
|
|||||||
&["node_modules", "Downloads", "Library", "go", "snap", "opt"];
|
&["node_modules", "Downloads", "Library", "go", "snap", "opt"];
|
||||||
|
|
||||||
/// How long a discovery snapshot is served as-is before the next request
|
/// How long a discovery snapshot is served as-is before the next request
|
||||||
/// triggers a rescan. Short: a scan is a bounded filesystem walk plus one
|
/// triggers a rescan.
|
||||||
/// `git worktree list` per hit.
|
///
|
||||||
pub const CACHE_TTL: Duration = Duration::from_secs(5);
|
/// 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
|
/// Background refresh cadence. Repositories appear rarely; this keeps the
|
||||||
/// served list eventually fresh without anyone asking.
|
/// served list eventually fresh without anyone asking.
|
||||||
pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
|
pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
|
||||||
@@ -154,9 +159,15 @@ fn list_worktrees(repo: &Path) -> Option<Vec<PathBuf>> {
|
|||||||
(!paths.is_empty()).then_some(paths)
|
(!paths.is_empty()).then_some(paths)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mtime of the checkout's HEAD file. For a linked worktree `.git` is a file
|
/// Mtime of the checkout's reflog, falling back to `HEAD` itself.
|
||||||
/// holding a `gitdir:` pointer; resolve it so worktrees report their own HEAD
|
///
|
||||||
/// rather than nothing.
|
/// `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>> {
|
fn head_mtime(repo: &Path) -> Option<DateTime<Utc>> {
|
||||||
let dot_git = repo.join(".git");
|
let dot_git = repo.join(".git");
|
||||||
let head = if dot_git.is_dir() {
|
let head = if dot_git.is_dir() {
|
||||||
@@ -173,6 +184,9 @@ fn head_mtime(repo: &Path) -> Option<DateTime<Utc>> {
|
|||||||
};
|
};
|
||||||
p.join("HEAD")
|
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()?;
|
let mtime = std::fs::metadata(head).ok()?.modified().ok()?;
|
||||||
Some(DateTime::<Utc>::from(mtime))
|
Some(DateTime::<Utc>::from(mtime))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,14 +39,32 @@ impl WorktreeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Where a worktree for `branch` will be created: the override root, or
|
/// Where a worktree for `branch` will be created: the override root, or
|
||||||
/// `<base_repo>/worktrees/<sanitized branch>` by default. The path is
|
/// `<main checkout>/worktrees/<sanitized branch>` by default. The path is
|
||||||
/// predictable (no UUID suffix), so an existing directory at the target
|
/// predictable (no UUID suffix), so an existing directory at the target
|
||||||
/// is an error rather than a silently different location.
|
/// 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 {
|
fn target_path(&self, base_repo: &Path, branch: &str) -> PathBuf {
|
||||||
let slug = sanitize_branch_for_path(branch);
|
let slug = sanitize_branch_for_path(branch);
|
||||||
|
let main = main_checkout(base_repo);
|
||||||
match &self.root_override {
|
match &self.root_override {
|
||||||
Some(root) => root.join(slug),
|
Some(root) => {
|
||||||
None => base_repo.join("worktrees").join(slug),
|
let repo = main
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| "repo".into());
|
||||||
|
root.join(repo).join(slug)
|
||||||
|
}
|
||||||
|
None => main.join("worktrees").join(slug),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +126,12 @@ impl WorktreeManager {
|
|||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return prune(base_repo);
|
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)? {
|
if let Some(reason) = unsafe_to_remove(path)? {
|
||||||
return Err(WorktreeError::Guarded {
|
return Err(WorktreeError::Guarded {
|
||||||
path: path.to_path_buf(),
|
path: path.to_path_buf(),
|
||||||
@@ -124,6 +148,45 @@ impl WorktreeManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The repository's main checkout. `--git-common-dir` points at the shared
|
||||||
|
/// `.git` for every linked worktree, so its parent is the main tree. Falls
|
||||||
|
/// back to the input when git can't answer.
|
||||||
|
fn main_checkout(path: &Path) -> PathBuf {
|
||||||
|
let out = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(path)
|
||||||
|
.args(["rev-parse", "--path-format=absolute", "--git-common-dir"])
|
||||||
|
.output();
|
||||||
|
let Ok(out) = out else {
|
||||||
|
return path.to_path_buf();
|
||||||
|
};
|
||||||
|
if !out.status.success() {
|
||||||
|
return path.to_path_buf();
|
||||||
|
}
|
||||||
|
let common = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim().to_string());
|
||||||
|
common
|
||||||
|
.parent()
|
||||||
|
.map(Path::to_path_buf)
|
||||||
|
.unwrap_or_else(|| path.to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Some(inner)` when a registered worktree lives under `path`. Git omits
|
||||||
|
/// those directories from `git status`, so the dirty-work guard cannot see
|
||||||
|
/// them and removal would delete them (#54 review).
|
||||||
|
fn nested_worktree(path: &Path) -> Option<PathBuf> {
|
||||||
|
let out = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(path)
|
||||||
|
.args(["worktree", "list", "--porcelain"])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
let text = String::from_utf8_lossy(&out.stdout);
|
||||||
|
text.lines()
|
||||||
|
.filter_map(|l| l.strip_prefix("worktree "))
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.find(|p| p != path && p.starts_with(path))
|
||||||
|
}
|
||||||
|
|
||||||
fn sanitize_branch_for_path(branch: &str) -> String {
|
fn sanitize_branch_for_path(branch: &str) -> String {
|
||||||
let mut out = String::with_capacity(branch.len());
|
let mut out = String::with_capacity(branch.len());
|
||||||
for c in branch.chars() {
|
for c in branch.chars() {
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -232,6 +232,57 @@ async fn projects_returns_empty_for_a_disabled_scanner() {
|
|||||||
assert!(projects.is_empty());
|
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]
|
#[tokio::test]
|
||||||
async fn gitroot_probes_path() {
|
async fn gitroot_probes_path() {
|
||||||
// #26: resolves the query against the daemon home dir and walks up for a
|
// #26: resolves the query against the daemon home dir and walks up for a
|
||||||
|
|||||||
@@ -70,8 +70,11 @@ fn worktree_root_override_redirects_placement() {
|
|||||||
let wt_root = TempDir::new().unwrap();
|
let wt_root = TempDir::new().unwrap();
|
||||||
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
|
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");
|
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
|
||||||
assert_eq!(path, wt_root.path().join("feat-example"));
|
assert_eq!(path, wt_root.path().join(repo_name).join("feat-example"));
|
||||||
assert!(path.exists());
|
assert!(path.exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,3 +291,75 @@ fn add_fails_when_branch_already_exists() {
|
|||||||
let msg = format!("{err}");
|
let msg = format!("{err}");
|
||||||
assert!(msg.contains("git exited"), "{msg}");
|
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 std::time::Duration;
|
||||||
|
|
||||||
use futures_util::{SinkExt, StreamExt};
|
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::client::IntoClientRequest;
|
||||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||||
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
|
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
|
||||||
@@ -62,6 +64,14 @@ impl TestClient {
|
|||||||
Ok(self.daemon.kill_session(id).await?)
|
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
|
/// Connect to the daemon's per-session WS and return the stream. The
|
||||||
/// caller drives the conversation.
|
/// caller drives the conversation.
|
||||||
pub async fn attach(
|
pub async fn attach(
|
||||||
|
|||||||
@@ -29,11 +29,32 @@ impl Daemon {
|
|||||||
Self::spawn_at(SocketAddr::from((Ipv4Addr::LOCALHOST, free_port()))).await
|
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.
|
/// Spawn on a caller-supplied bind address.
|
||||||
pub async fn spawn_at(bind: SocketAddr) -> Self {
|
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 home = TempDir::new().expect("home tmpdir");
|
||||||
let tmux = TempDir::new().expect("tmux 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 child = launch(bind, home.path(), tmux.path());
|
||||||
let me = Self {
|
let me = Self {
|
||||||
bind_addr: bind,
|
bind_addr: bind,
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ pub mod daemon;
|
|||||||
pub mod util;
|
pub mod util;
|
||||||
|
|
||||||
pub use build::workspace_target_bin;
|
pub use build::workspace_target_bin;
|
||||||
pub use client::TestClient;
|
pub use client::{ClientError, TestClient};
|
||||||
pub use daemon::Daemon;
|
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")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn lists_spawned_sessions() {
|
async fn lists_spawned_sessions() {
|
||||||
let (_daemon, client) = bring_up().await;
|
let (_daemon, client) = bring_up().await;
|
||||||
@@ -204,6 +220,19 @@ async fn kill_spares_worktree_with_uncommitted_changes() {
|
|||||||
wt_path.exists(),
|
wt_path.exists(),
|
||||||
"guarded worktree must be left on disk after kill"
|
"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")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
@@ -240,6 +269,276 @@ async fn spawn_on_existing_branch_checks_it_out() {
|
|||||||
client.kill_session(s.id).await.expect("kill");
|
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")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn daemon_restart_recovers_session() {
|
async fn daemon_restart_recovers_session() {
|
||||||
let (mut daemon, client) = bring_up().await;
|
let (mut daemon, client) = bring_up().await;
|
||||||
@@ -428,6 +727,15 @@ fn init_repo(dir: &std::path::Path) {
|
|||||||
run_git(&["commit", "-q", "-m", "initial"], dir);
|
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) {
|
fn run_git(args: &[&str], cwd: &std::path::Path) {
|
||||||
let out = Command::new("git")
|
let out = Command::new("git")
|
||||||
.arg("-C")
|
.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,8 +15,8 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::routes::{self, UrlError};
|
use crate::routes::{self, UrlError};
|
||||||
use crate::{
|
use crate::{
|
||||||
ApiError, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, Project,
|
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
||||||
RecentDirs, Session,
|
Project, RecentDirs, Session,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Time budget for establishing a TCP connection to the daemon.
|
/// Time budget for establishing a TCP connection to the daemon.
|
||||||
@@ -143,6 +143,11 @@ impl DaemonClient {
|
|||||||
self.get(routes::PROJECTS, &[]).await
|
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 -----------------------------------------------------------
|
// -- plumbing -----------------------------------------------------------
|
||||||
|
|
||||||
async fn get<T: DeserializeOwned>(
|
async fn get<T: DeserializeOwned>(
|
||||||
|
|||||||
@@ -212,6 +212,21 @@ pub struct Project {
|
|||||||
pub last_command: Option<String>,
|
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.
|
/// Structured failure used everywhere we don't want to leak a string-only error.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct ErrorInfo {
|
pub struct ErrorInfo {
|
||||||
@@ -279,6 +294,13 @@ pub struct DaemonInfo {
|
|||||||
pub presets: Vec<SpawnPreset>,
|
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`)
|
// Catalog events (`WS /api/events`)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -21,12 +21,20 @@ pub const RECENT_DIRS: &str = "/api/recent-dirs";
|
|||||||
pub const PROJECTS: &str = "/api/projects";
|
pub const PROJECTS: &str = "/api/projects";
|
||||||
/// `GET` the enclosing repository of a path. Takes a `path` query param.
|
/// `GET` the enclosing repository of a path. Takes a `path` query param.
|
||||||
pub const GITROOT: &str = "/api/gitroot";
|
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.
|
/// `WS` catalog snapshot + session deltas.
|
||||||
pub const EVENTS: &str = "/api/events";
|
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";
|
pub const PATH_QUERY: &str = "path";
|
||||||
|
|
||||||
|
/// Name of the query parameter [`NAMES`] takes.
|
||||||
|
pub const COUNT_QUERY: &str = "count";
|
||||||
|
|
||||||
/// `DELETE /api/sessions/:id`.
|
/// `DELETE /api/sessions/:id`.
|
||||||
pub fn session(id: Uuid) -> String {
|
pub fn session(id: Uuid) -> String {
|
||||||
format!("{SESSIONS}/{id}")
|
format!("{SESSIONS}/{id}")
|
||||||
@@ -174,7 +182,17 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_route_sits_under_the_api_prefix() {
|
fn every_route_sits_under_the_api_prefix() {
|
||||||
for path in [SESSIONS, INFO, DIRS, RECENT_DIRS, PROJECTS, GITROOT, EVENTS] {
|
for path in [
|
||||||
|
SESSIONS,
|
||||||
|
INFO,
|
||||||
|
DIRS,
|
||||||
|
RECENT_DIRS,
|
||||||
|
PROJECTS,
|
||||||
|
GITROOT,
|
||||||
|
BRANCHES,
|
||||||
|
NAMES,
|
||||||
|
EVENTS,
|
||||||
|
] {
|
||||||
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
|
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
|
||||||
}
|
}
|
||||||
assert!(attach(id()).starts_with(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."
|
description = "Terminal UI client over the local maestro daemon's `/api/*` surface."
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
chrono.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
crossterm.workspace = true
|
crossterm.workspace = true
|
||||||
futures-util.workspace = true
|
futures-util.workspace = true
|
||||||
maestro-config.workspace = true
|
maestro-config.workspace = true
|
||||||
maestro-protocol = { workspace = true, features = ["client"] }
|
maestro-protocol = { workspace = true, features = ["client"] }
|
||||||
ratatui.workspace = true
|
ratatui.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tokio = { workspace = true, features = ["sync", "time", "macros", "rt"] }
|
tokio = { workspace = true, features = ["sync", "time", "macros", "rt"] }
|
||||||
|
|||||||
+435
-36
@@ -12,11 +12,13 @@
|
|||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::io::Stdout;
|
use std::io::Stdout;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
use crossterm::event::{Event as CtEvent, EventStream};
|
use crossterm::event::{Event as CtEvent, EventStream};
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use maestro_protocol::{CreateSessionRequest, DaemonClient};
|
use maestro_protocol::{routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch};
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -53,6 +55,9 @@ pub struct App {
|
|||||||
help: HelpOverlay,
|
help: HelpOverlay,
|
||||||
chord: ChordState,
|
chord: ChordState,
|
||||||
daemon: DaemonClient,
|
daemon: DaemonClient,
|
||||||
|
/// Direct HTTP for the one endpoint [`DaemonClient`] doesn't cover yet:
|
||||||
|
/// `GET /api/names` (#64).
|
||||||
|
http: reqwest::Client,
|
||||||
region: RegionMap,
|
region: RegionMap,
|
||||||
last_sent_size: HashMap<Uuid, (u16, u16)>,
|
last_sent_size: HashMap<Uuid, (u16, u16)>,
|
||||||
clicks: ClickTracker,
|
clicks: ClickTracker,
|
||||||
@@ -76,7 +81,23 @@ pub enum Effect {
|
|||||||
Attach(Uuid),
|
Attach(Uuid),
|
||||||
Spawn(CreateSessionRequest),
|
Spawn(CreateSessionRequest),
|
||||||
Kill(Uuid),
|
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 {
|
impl App {
|
||||||
@@ -85,6 +106,11 @@ impl App {
|
|||||||
let help = HelpOverlay {
|
let help = HelpOverlay {
|
||||||
lines: render_help_lines(&cfg),
|
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 {
|
Self {
|
||||||
cfg,
|
cfg,
|
||||||
catalog,
|
catalog,
|
||||||
@@ -93,6 +119,7 @@ impl App {
|
|||||||
help,
|
help,
|
||||||
chord: ChordState::new(chord_timeout),
|
chord: ChordState::new(chord_timeout),
|
||||||
daemon,
|
daemon,
|
||||||
|
http,
|
||||||
region: RegionMap::default(),
|
region: RegionMap::default(),
|
||||||
last_sent_size: HashMap::new(),
|
last_sent_size: HashMap::new(),
|
||||||
clicks: ClickTracker::default(),
|
clicks: ClickTracker::default(),
|
||||||
@@ -153,15 +180,13 @@ impl App {
|
|||||||
{
|
{
|
||||||
// Insert mode over a focused pane: a draft eats the key
|
// Insert mode over a focused pane: a draft eats the key
|
||||||
// into its form, a session gets the raw bytes.
|
// into its form, a session gets the raw bytes.
|
||||||
let effect = if matches!(self.state.active_tab().pane, Some(PaneRef::Draft(_)))
|
let effects = if matches!(self.state.active_tab().pane, Some(PaneRef::Draft(_)))
|
||||||
{
|
{
|
||||||
self.handle_draft_input(&key)
|
self.handle_draft_input(&key)
|
||||||
} else {
|
} else {
|
||||||
self.forward_to_session(&key)
|
self.forward_to_session(&key).into_iter().collect()
|
||||||
};
|
};
|
||||||
if let Some(effect) = effect {
|
self.perform(effects).await;
|
||||||
self.perform(vec![effect]).await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
@@ -176,6 +201,12 @@ impl App {
|
|||||||
Effect::Attach(id) => self.attach(id),
|
Effect::Attach(id) => self.attach(id),
|
||||||
Effect::Spawn(req) => self.submit_spawn(req).await,
|
Effect::Spawn(req) => self.submit_spawn(req).await,
|
||||||
Effect::Kill(id) => self.submit_kill(id).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 } => {
|
Effect::Forward { id, bytes } => {
|
||||||
if let Some(handle) = self.emulators.get(id) {
|
if let Some(handle) = self.emulators.get(id) {
|
||||||
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
||||||
@@ -252,7 +283,10 @@ impl App {
|
|||||||
};
|
};
|
||||||
self.state.show_overlay(OverlayKind::Help);
|
self.state.show_overlay(OverlayKind::Help);
|
||||||
}
|
}
|
||||||
Action::NewDraftPane => self.state.open_draft_pane(),
|
Action::NewDraftPane => {
|
||||||
|
self.state.open_draft_pane();
|
||||||
|
effects.push(Effect::LoadDraft(self.state.active_tab_idx));
|
||||||
|
}
|
||||||
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
|
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
|
||||||
if let Some(sid) = self.pick_sidebar_target() {
|
if let Some(sid) = self.pick_sidebar_target() {
|
||||||
effects.push(Effect::Attach(sid));
|
effects.push(Effect::Attach(sid));
|
||||||
@@ -309,21 +343,173 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Feed one keystroke into the active tab's draft form. Returns the spawn
|
/// Feed one keystroke into the active tab's draft form. Returns the spawn
|
||||||
/// effect when the user accepted the form.
|
/// effect when the user accepted a valid form; an invalid one names the
|
||||||
fn handle_draft_input(&mut self, key: &InputKey) -> Option<Effect> {
|
/// missing field inside the form (#64) instead of posting.
|
||||||
let PaneRef::Draft(draft) = self.state.active_tab_mut().pane.as_mut()? else {
|
fn handle_draft_input(&mut self, key: &InputKey) -> Vec<Effect> {
|
||||||
return None;
|
let tab_idx = self.state.active_tab_idx;
|
||||||
|
let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() else {
|
||||||
|
return Vec::new();
|
||||||
};
|
};
|
||||||
if draft.on_input(key) != DraftInputResult::Submit {
|
let mut effects = Vec::new();
|
||||||
return None;
|
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()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Some(Effect::Spawn(CreateSessionRequest {
|
if draft.take_name_refill() {
|
||||||
cwd: std::path::PathBuf::from(draft.cwd.value().to_string()),
|
effects.push(Effect::RefillNames(tab_idx));
|
||||||
command: draft.command.value().to_string(),
|
}
|
||||||
env: Default::default(),
|
if let Some((seq, path)) = draft.take_gitroot_probe() {
|
||||||
metadata: Default::default(),
|
effects.push(Effect::ProbeGitRoot {
|
||||||
worktree: None,
|
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) {
|
async fn submit_spawn(&mut self, req: CreateSessionRequest) {
|
||||||
@@ -335,7 +521,19 @@ impl App {
|
|||||||
self.state.complete_spawn(sid);
|
self.state.complete_spawn(sid);
|
||||||
self.attach(sid);
|
self.attach(sid);
|
||||||
}
|
}
|
||||||
Err(e) => self.state.toast(format!("spawn failed: {e}")),
|
Err(e) => {
|
||||||
|
// A failed POST belongs in the form it came from (#64), not a
|
||||||
|
// toast that dismisses on the next keystroke.
|
||||||
|
let message = e
|
||||||
|
.daemon_message()
|
||||||
|
.map(String::from)
|
||||||
|
.unwrap_or_else(|| e.to_string());
|
||||||
|
if let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() {
|
||||||
|
draft.error = Some(format!("spawn failed: {message}"));
|
||||||
|
} else {
|
||||||
|
self.state.toast(format!("spawn failed: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,16 +703,17 @@ fn render_help_lines(cfg: &TuiConfig) -> Vec<String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::action::Action;
|
use crate::action::Action;
|
||||||
|
use crate::draft::DraftField;
|
||||||
use crate::keymap::Mode;
|
use crate::keymap::Mode;
|
||||||
use maestro_config::TuiSection;
|
use maestro_config::TuiSection;
|
||||||
|
|
||||||
fn make_app() -> App {
|
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();
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||||
App::new(
|
App::new(cfg, CatalogStore::default(), DaemonClient::new(base))
|
||||||
cfg,
|
|
||||||
CatalogStore::default(),
|
|
||||||
DaemonClient::new("http://127.0.0.1:7070"),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -638,34 +837,234 @@ mod tests {
|
|||||||
assert!(app.state.overlay().is_none());
|
assert!(app.state.overlay().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_draft_pane_schedules_a_data_load() {
|
||||||
|
let mut app = make_app();
|
||||||
|
assert_eq!(
|
||||||
|
app.dispatch(Action::NewDraftPane),
|
||||||
|
vec![Effect::LoadDraft(0)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A valid form flows through: pick a project (set directly — the picker
|
||||||
|
/// itself is covered in `draft.rs`), select the custom tile, type a
|
||||||
|
/// command, then submit from the Spawn row.
|
||||||
#[test]
|
#[test]
|
||||||
fn typing_into_the_draft_then_enter_carries_the_typed_fields() {
|
fn typing_into_the_draft_then_enter_carries_the_typed_fields() {
|
||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
app.dispatch(Action::NewDraftPane);
|
app.dispatch(Action::NewDraftPane);
|
||||||
|
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
|
||||||
|
panic!("expected a draft pane");
|
||||||
|
};
|
||||||
|
|
||||||
for c in "/srv/work".chars() {
|
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work"));
|
||||||
app.handle_draft_input(&InputKey::char(c));
|
draft.base_repo = Some(std::path::PathBuf::from("/srv/work"));
|
||||||
}
|
draft.probed_cwd = Some(std::path::PathBuf::from("/srv/work"));
|
||||||
app.handle_draft_input(&InputKey::Tab); // cwd → command
|
draft.selected_preset = draft.presets.len() - 1; // custom
|
||||||
|
app.handle_draft_input(&InputKey::Tab); // preset → custom command
|
||||||
for c in "claude".chars() {
|
for c in "claude".chars() {
|
||||||
app.handle_draft_input(&InputKey::char(c));
|
app.handle_draft_input(&InputKey::char(c));
|
||||||
}
|
}
|
||||||
app.handle_draft_input(&InputKey::Tab); // command → submit
|
app.handle_draft_input(&InputKey::Tab); // → dir picker
|
||||||
|
app.handle_draft_input(&InputKey::Tab); // → name
|
||||||
|
for c in "crimson-otter".chars() {
|
||||||
|
app.handle_draft_input(&InputKey::char(c));
|
||||||
|
}
|
||||||
|
app.handle_draft_input(&InputKey::Tab); // → 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) {
|
match app.handle_draft_input(&InputKey::Enter).as_slice() {
|
||||||
Some(Effect::Spawn(req)) => {
|
[Effect::Spawn(req)] => {
|
||||||
assert_eq!(req.cwd, std::path::PathBuf::from("/srv/work"));
|
assert_eq!(req.cwd, std::path::PathBuf::from("/srv/work"));
|
||||||
assert_eq!(req.command, "claude");
|
assert_eq!(req.command, "claude");
|
||||||
|
assert_eq!(
|
||||||
|
req.metadata.get("name").and_then(|v| v.as_str()),
|
||||||
|
Some("crimson-otter"),
|
||||||
|
"the generated name rides along as metadata.name"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
other => panic!("expected a Spawn effect, got {other:?}"),
|
other => panic!("expected a single Spawn effect, got {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// Keys fed while no draft exists must be ignored, not panic.
|
||||||
#[test]
|
#[test]
|
||||||
fn draft_input_is_a_no_op_without_a_draft() {
|
fn draft_input_is_a_no_op_without_a_draft() {
|
||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
assert!(app.handle_draft_input(&InputKey::char('x')).is_none());
|
assert!(app.handle_draft_input(&InputKey::char('x')).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regression: `Toast` returned `Pass` and nothing ever cleared it, so a
|
/// Regression: `Toast` returned `Pass` and nothing ever cleared it, so a
|
||||||
|
|||||||
+1845
-139
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ pub mod input;
|
|||||||
pub mod keymap;
|
pub mod keymap;
|
||||||
pub mod overlays;
|
pub mod overlays;
|
||||||
pub mod pane;
|
pub mod pane;
|
||||||
|
pub mod projects;
|
||||||
pub mod render;
|
pub mod render;
|
||||||
pub mod session_task;
|
pub mod session_task;
|
||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -315,7 +315,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
}
|
}
|
||||||
for s in ["draft", "cwd:", "command:", "[ Spawn ]"] {
|
for s in [
|
||||||
|
"draft",
|
||||||
|
"cmd:",
|
||||||
|
"dir:",
|
||||||
|
"name:",
|
||||||
|
"[ Spawn ]",
|
||||||
|
"Other directory…",
|
||||||
|
] {
|
||||||
assert!(out.contains(s), "missing {s:?}:\n{out}");
|
assert!(out.contains(s), "missing {s:?}:\n{out}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ pub enum Focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// What a tab's pane slot holds: a live session, or an inline draft form.
|
/// 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)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum PaneRef {
|
pub enum PaneRef {
|
||||||
Session(Uuid),
|
Session(Uuid),
|
||||||
|
|||||||
+5
-4
@@ -20,9 +20,10 @@ src/
|
|||||||
main.ts # entry point, hash-route dispatch
|
main.ts # entry point, hash-route dispatch
|
||||||
api.ts # daemon HTTP API client
|
api.ts # daemon HTTP API client
|
||||||
events.ts # `/api/events` WebSocket subscriber
|
events.ts # `/api/events` WebSocket subscriber
|
||||||
catalog.ts # session tree view (groups by working directory)
|
catalog.ts # session tree view (groups by working directory)
|
||||||
attach.ts # xterm.js + attach WS view
|
attach.ts # xterm.js + attach WS view
|
||||||
spawn.ts # spawn-session modal
|
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
|
theme.ts # dark/light theme toggle + persistence
|
||||||
types.ts # mirror of `maestro-protocol` JSON shapes
|
types.ts # mirror of `maestro-protocol` JSON shapes
|
||||||
styles.css # design-system tokens + component styles
|
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
|
`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,
|
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:
|
Routing is hash-based:
|
||||||
- `#/` → catalog
|
- `#/` → catalog
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import * as routes from "./routes.js";
|
import * as routes from "./routes.js";
|
||||||
import type {
|
import type {
|
||||||
ApiError,
|
ApiError,
|
||||||
|
Branches,
|
||||||
CreateSessionRequest,
|
CreateSessionRequest,
|
||||||
DaemonInfo,
|
DaemonInfo,
|
||||||
DirListing,
|
DirListing,
|
||||||
GitRoot,
|
GitRoot,
|
||||||
|
NameBatch,
|
||||||
Projects,
|
Projects,
|
||||||
RecentDirs,
|
RecentDirs,
|
||||||
Session,
|
Session,
|
||||||
@@ -81,4 +83,18 @@ export async function gitRoot(path: string): Promise<GitRoot> {
|
|||||||
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
|
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";
|
export { eventsWsUrl } from "./routes.js";
|
||||||
|
|||||||
+11
-12
@@ -6,9 +6,15 @@ import type { Session } from "./types.js";
|
|||||||
import { abbreviateFolderName, sessionName } from "./types.js";
|
import { abbreviateFolderName, sessionName } from "./types.js";
|
||||||
import type { TreeNode } from "./tree.js";
|
import type { TreeNode } from "./tree.js";
|
||||||
import { buildTree } from "./tree.js";
|
import { buildTree } from "./tree.js";
|
||||||
import { openSpawnModal } from "./spawn.js";
|
|
||||||
import { createThemeToggle } from "./theme.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>();
|
const collapsed = new Set<string>();
|
||||||
|
|
||||||
function renderNode(node: TreeNode, depth: number): HTMLElement {
|
function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||||
@@ -190,7 +196,7 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
|
|||||||
spawnBtn.type = "button";
|
spawnBtn.type = "button";
|
||||||
spawnBtn.className = "btn btn-primary spawn-btn";
|
spawnBtn.className = "btn btn-primary spawn-btn";
|
||||||
spawnBtn.textContent = "+ Spawn";
|
spawnBtn.textContent = "+ Spawn";
|
||||||
spawnBtn.addEventListener("click", () => openSpawnModal());
|
spawnBtn.addEventListener("click", () => requestOpenSpawn());
|
||||||
actions.appendChild(spawnBtn);
|
actions.appendChild(spawnBtn);
|
||||||
return header;
|
return header;
|
||||||
}
|
}
|
||||||
@@ -342,7 +348,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
treeHost.replaceChildren();
|
treeHost.replaceChildren();
|
||||||
if (sessions.length === 0) {
|
if (sessions.length === 0) {
|
||||||
treeHost.appendChild(
|
treeHost.appendChild(
|
||||||
renderEmpty(filter.trim().length > 0, () => openSpawnModal()),
|
renderEmpty(filter.trim().length > 0, () => requestOpenSpawn()),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -364,14 +370,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
const rerenderHandler = () => refresh();
|
const rerenderHandler = () => refresh();
|
||||||
document.addEventListener("catalog-rerender", rerenderHandler);
|
document.addEventListener("catalog-rerender", rerenderHandler);
|
||||||
|
|
||||||
// Spawn similar: reopen the spawn modal pre-seeded from a session's dir and
|
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
|
||||||
// command (matched back to a preset by the form) — but with a freshly
|
// multi-view controller can seed a draft pane with the session's dir/command.
|
||||||
// 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);
|
|
||||||
|
|
||||||
const unsubscribe = store.subscribe(refresh);
|
const unsubscribe = store.subscribe(refresh);
|
||||||
|
|
||||||
@@ -391,7 +391,6 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
},
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
document.removeEventListener("catalog-rerender", rerenderHandler);
|
document.removeEventListener("catalog-rerender", rerenderHandler);
|
||||||
document.removeEventListener("catalog-spawn-similar", spawnSimilarHandler);
|
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
// One HTML escaper. Previously written four times, in catalog.ts, spawn.ts,
|
// One HTML escaper. Previously written four times, in catalog.ts, types.ts
|
||||||
// types.ts and attach.ts.
|
// and attach.ts.
|
||||||
|
|
||||||
export function escapeHtml(s: string): string {
|
export function escapeHtml(s: string): string {
|
||||||
return s
|
return s
|
||||||
|
|||||||
+1
-1
@@ -102,7 +102,7 @@
|
|||||||
background: color-mix(in oklab, var(--surface) 40%, var(--bg));
|
background: color-mix(in oklab, var(--surface) 40%, var(--bg));
|
||||||
}
|
}
|
||||||
|
|
||||||
.kit-slot .modal-body {
|
.kit-slot .spawn-form {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -135,7 +135,7 @@ export function renderKit(): HTMLElement {
|
|||||||
{
|
{
|
||||||
const { el, body } = section(
|
const { el, body } = section(
|
||||||
"Buttons — current",
|
"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(
|
body.append(
|
||||||
specimen("btn-primary", btn("btn btn-primary", "+ Spawn")),
|
specimen("btn-primary", btn("btn btn-primary", "+ Spawn")),
|
||||||
@@ -170,12 +170,12 @@ export function renderKit(): HTMLElement {
|
|||||||
search.type = "search";
|
search.type = "search";
|
||||||
search.className = "search";
|
search.className = "search";
|
||||||
search.placeholder = "Search sessions…";
|
search.placeholder = "Search sessions…";
|
||||||
const text = h("div", "modal-body");
|
const text = h("div", "spawn-form");
|
||||||
const ti = document.createElement("input");
|
const ti = document.createElement("input");
|
||||||
ti.type = "text";
|
ti.type = "text";
|
||||||
ti.placeholder = "branch-name";
|
ti.placeholder = "branch-name";
|
||||||
text.appendChild(ti);
|
text.appendChild(ti);
|
||||||
const sel = h("div", "modal-body");
|
const sel = h("div", "spawn-form");
|
||||||
const s = document.createElement("select");
|
const s = document.createElement("select");
|
||||||
s.innerHTML = `<option>thinkpad</option><option>tower</option><option>pi</option>`;
|
s.innerHTML = `<option>thinkpad</option><option>tower</option><option>pi</option>`;
|
||||||
sel.appendChild(s);
|
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 { emptyLayout, type Layout, openSessions } from "./layout.js";
|
||||||
import { createMultiView, type MultiView } from "./multiview.js";
|
import { createMultiView, type MultiView } from "./multiview.js";
|
||||||
import { loadLayout, saveLayout } from "./persistence.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 { initTheme } from "./theme.js";
|
||||||
import { sessionName } from "./types.js";
|
import { sessionName, type Session } from "./types.js";
|
||||||
import { installViewportSizing } from "./viewport.js";
|
import { installViewportSizing } from "./viewport.js";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
@@ -212,7 +212,7 @@ function renderWelcome(): HTMLElement {
|
|||||||
spawn.type = "button";
|
spawn.type = "button";
|
||||||
spawn.className = "btn btn-primary";
|
spawn.className = "btn btn-primary";
|
||||||
spawn.textContent = "+ Spawn session";
|
spawn.textContent = "+ Spawn session";
|
||||||
spawn.addEventListener("click", () => openSpawnModal());
|
spawn.addEventListener("click", () => requestOpenSpawn());
|
||||||
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
|
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
@@ -251,6 +251,20 @@ function showMultiView(): void {
|
|||||||
updateShellRoute();
|
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 =
|
type Route =
|
||||||
| { kind: "catalog" }
|
| { kind: "catalog" }
|
||||||
| { kind: "attach"; sessionId: string }
|
| { kind: "attach"; sessionId: string }
|
||||||
|
|||||||
+2
-2
@@ -135,10 +135,10 @@ export function seedMock(store: CatalogStore): void {
|
|||||||
sessions,
|
sessions,
|
||||||
});
|
});
|
||||||
|
|
||||||
// `?mock&spawn` auto-opens the spawn modal for visual review, with the spawn
|
// `?mock&spawn` auto-opens a draft pane for visual review, with the spawn
|
||||||
// backend endpoints stubbed so presets/projects/gitroot all render.
|
// backend endpoints stubbed so presets/projects/gitroot all render.
|
||||||
if (location.search.includes("spawn")) {
|
if (location.search.includes("spawn")) {
|
||||||
stubSpawnApi();
|
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
-28
@@ -13,6 +13,7 @@ import {
|
|||||||
closeTab,
|
closeTab,
|
||||||
findLeaf,
|
findLeaf,
|
||||||
type Dir,
|
type Dir,
|
||||||
|
type Draft,
|
||||||
type Layout,
|
type Layout,
|
||||||
leaves,
|
leaves,
|
||||||
locateSession,
|
locateSession,
|
||||||
@@ -52,6 +53,10 @@ export interface MultiView {
|
|||||||
closeSession: (sessionId: string) => void;
|
closeSession: (sessionId: string) => void;
|
||||||
/** Every session currently shown in some pane, across all tabs. */
|
/** Every session currently shown in some pane, across all tabs. */
|
||||||
openSessions: () => Set<string>;
|
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
|
/** 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
|
* controller owns drag-vs-tap: a tap lets the row's normal click open it, a
|
||||||
* drag shows drop zones to place it. */
|
* 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 { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts;
|
||||||
const persist = () => opts.onLayoutChange?.();
|
const persist = () => opts.onLayoutChange?.();
|
||||||
|
|
||||||
// Draft-pane forms (#57) carry global listeners (dir autocomplete), so each
|
// Draft-pane forms (#57) carry global listeners (dir autocomplete), so forms
|
||||||
// render tears down the previous generation before rebuilding the canvas.
|
// whose leaves are gone are destroyed on each render. Forms for draft leaves
|
||||||
const draftForms = new Set<SpawnFormHandle>();
|
// 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");
|
const el = document.createElement("div");
|
||||||
el.className = "mv";
|
el.className = "mv";
|
||||||
@@ -145,29 +152,35 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
// Draft pane (#57): an empty pane is a spawn form. Values hang off the
|
// 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;
|
// leaf (never persisted) so each draft keeps its own half-filled state;
|
||||||
// submitting fills this leaf with the new session in place.
|
// submitting fills this leaf with the new session in place.
|
||||||
const form = createSpawnForm({
|
const form = draftForms.get(leaf.id);
|
||||||
seed: leaf.draft ?? undefined,
|
if (form) {
|
||||||
class: "mv-draft",
|
host.appendChild(form.el);
|
||||||
onChange: (values) => {
|
} else {
|
||||||
leaf.draft = values;
|
const created = createSpawnForm({
|
||||||
},
|
seed: leaf.draft ?? undefined,
|
||||||
onSuccess: (session) => {
|
class: "mv-draft",
|
||||||
// The pane can be closed while the POST is in flight. Mounting the
|
onChange: (values) => {
|
||||||
// session onto a leaf no longer in the tree would strand it in no
|
leaf.draft = values;
|
||||||
// pane at all, so only claim the leaf while it is still there.
|
},
|
||||||
const t = activeTab(layout);
|
onSuccess: (session) => {
|
||||||
const attached = leaves(t.root).includes(leaf);
|
// The pane can be closed while the POST is in flight. Mounting the
|
||||||
if (attached) {
|
// session onto a leaf no longer in the tree would strand it in no
|
||||||
leaf.sessionId = session.id;
|
// pane at all, so only claim the leaf while it is still there —
|
||||||
leaf.draft = null;
|
// searching every tab, since the operator may have switched tabs
|
||||||
t.focusedLeafId = leaf.id;
|
// while the request was in flight.
|
||||||
}
|
const host = layout.tabs.find((t) => leaves(t.root).includes(leaf));
|
||||||
render();
|
if (host) {
|
||||||
opts.onOpenSessionsChange?.();
|
leaf.sessionId = session.id;
|
||||||
},
|
leaf.draft = null;
|
||||||
});
|
host.focusedLeafId = leaf.id;
|
||||||
draftForms.add(form);
|
}
|
||||||
host.appendChild(form.el);
|
render();
|
||||||
|
opts.onOpenSessionsChange?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
draftForms.set(leaf.id, created);
|
||||||
|
host.appendChild(created.el);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tools = document.createElement("div");
|
const tools = document.createElement("div");
|
||||||
@@ -365,11 +378,49 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
render();
|
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 ----
|
// ---- 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 {
|
function render(): void {
|
||||||
for (const f of draftForms) f.destroy();
|
|
||||||
draftForms.clear();
|
|
||||||
renderTabs();
|
renderTabs();
|
||||||
canvas.replaceChildren();
|
canvas.replaceChildren();
|
||||||
const tab = activeTab(layout);
|
const tab = activeTab(layout);
|
||||||
@@ -378,6 +429,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
el.classList.toggle("mv-mobile-solo", isMobile && layout.tabs.length === 1);
|
el.classList.toggle("mv-mobile-solo", isMobile && layout.tabs.length === 1);
|
||||||
|
|
||||||
if (!tab.root) {
|
if (!tab.root) {
|
||||||
|
pruneForms(tab);
|
||||||
pool.setVisible([]);
|
pool.setVisible([]);
|
||||||
pool.setFocus(null);
|
pool.setFocus(null);
|
||||||
canvas.appendChild(renderWelcome());
|
canvas.appendChild(renderWelcome());
|
||||||
@@ -393,12 +445,14 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
renderMobile(tab);
|
renderMobile(tab);
|
||||||
|
pruneForms(tab);
|
||||||
persist();
|
persist();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = [];
|
const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = [];
|
||||||
canvas.appendChild(buildTree(tab.root, hosts));
|
canvas.appendChild(buildTree(tab.root, hosts));
|
||||||
|
pruneForms(tab);
|
||||||
pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })));
|
pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })));
|
||||||
syncFocus();
|
syncFocus();
|
||||||
persist();
|
persist();
|
||||||
@@ -639,6 +693,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
return {
|
return {
|
||||||
el,
|
el,
|
||||||
openSession,
|
openSession,
|
||||||
|
openDraft,
|
||||||
closeSession,
|
closeSession,
|
||||||
openSessions: () => openSessions(layout),
|
openSessions: () => openSessions(layout),
|
||||||
beginSessionDrag,
|
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)}`;
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,8 @@ function project(overrides: Partial<Project>): Project {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Two fetches gate the form's initial load: /api/info then /api/projects. */
|
/** Three fetches gate the form's initial load: /api/info, /api/projects, and
|
||||||
|
* the /api/names batch (#53). */
|
||||||
function stubFetch(info: unknown, projects: Project[]): void {
|
function stubFetch(info: unknown, projects: Project[]): void {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
@@ -25,7 +26,9 @@ function stubFetch(info: unknown, projects: Project[]): void {
|
|||||||
? info
|
? info
|
||||||
: url.includes("/api/projects")
|
: url.includes("/api/projects")
|
||||||
? projects
|
? projects
|
||||||
: undefined;
|
: url.includes("/api/names")
|
||||||
|
? { names: ["allegro-cello", "presto-fugue", "adagio-lyre"] }
|
||||||
|
: undefined;
|
||||||
return new Response(JSON.stringify(body), { status: 200 });
|
return new Response(JSON.stringify(body), { status: 200 });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,11 +11,16 @@ export const DIRS = "/api/dirs";
|
|||||||
export const RECENT_DIRS = "/api/recent-dirs";
|
export const RECENT_DIRS = "/api/recent-dirs";
|
||||||
export const PROJECTS = "/api/projects";
|
export const PROJECTS = "/api/projects";
|
||||||
export const GITROOT = "/api/gitroot";
|
export const GITROOT = "/api/gitroot";
|
||||||
|
export const NAMES = "/api/names";
|
||||||
|
export const BRANCHES = "/api/branches";
|
||||||
export const EVENTS = "/api/events";
|
export const EVENTS = "/api/events";
|
||||||
|
|
||||||
/** Name of the query parameter `DIRS` and `GITROOT` both take. */
|
/** Name of the query parameter `DIRS` and `GITROOT` both take. */
|
||||||
export const PATH_QUERY = "path";
|
export const PATH_QUERY = "path";
|
||||||
|
|
||||||
|
/** Name of the query parameter `NAMES` takes. */
|
||||||
|
export const COUNT_QUERY = "count";
|
||||||
|
|
||||||
export function session(id: Uuid): string {
|
export function session(id: Uuid): string {
|
||||||
return `${SESSIONS}/${encodeURIComponent(id)}`;
|
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,6 +8,7 @@ const base: SpawnFormValues = {
|
|||||||
name: "amber-fox",
|
name: "amber-fox",
|
||||||
worktreeEnabled: false,
|
worktreeEnabled: false,
|
||||||
worktreeBranch: "",
|
worktreeBranch: "",
|
||||||
|
worktreeBranchMode: "new",
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("buildSpawnRequest", () => {
|
describe("buildSpawnRequest", () => {
|
||||||
@@ -33,11 +34,50 @@ describe("buildSpawnRequest", () => {
|
|||||||
cwd: base.cwd,
|
cwd: base.cwd,
|
||||||
command: base.command,
|
command: base.command,
|
||||||
metadata: { name: "amber-fox" },
|
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", () => {
|
it("rejects a missing working directory", () => {
|
||||||
expect(buildSpawnRequest({ ...base, cwd: "" }, null)).toHaveProperty("error");
|
expect(buildSpawnRequest({ ...base, cwd: "" }, null)).toHaveProperty("error");
|
||||||
});
|
});
|
||||||
|
|||||||
+223
-38
@@ -1,20 +1,31 @@
|
|||||||
import { escapeHtml } from "./html.js";
|
import { escapeHtml } from "./html.js";
|
||||||
import { createSession, getDaemonInfo, projects as fetchProjects, gitRoot } from "./api.js";
|
import {
|
||||||
|
createSession,
|
||||||
|
fetchBranches,
|
||||||
|
fetchNames,
|
||||||
|
getDaemonInfo,
|
||||||
|
projects as fetchProjects,
|
||||||
|
gitRoot,
|
||||||
|
} from "./api.js";
|
||||||
import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
||||||
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
||||||
import { generateName } from "./names.js";
|
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
||||||
import type { CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
|
||||||
import { abbreviateCwd, abbreviateFolderName } from "./types.js";
|
import { abbreviateCwd, abbreviateFolderName } from "./types.js";
|
||||||
|
|
||||||
/** Seed values for the spawn form — used by "clone" to reopen with the same
|
/** Where a worktree's branch comes from: freshly created (default) or picked
|
||||||
* cwd, command, and name as an existing session, and by draft panes to restore
|
* from the repository's existing branches (#61). */
|
||||||
* their own half-filled state after a re-render. */
|
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 {
|
export interface SpawnFormSeed {
|
||||||
command?: string;
|
command?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
worktreeEnabled?: boolean;
|
worktreeEnabled?: boolean;
|
||||||
worktreeBranch?: string;
|
worktreeBranch?: string;
|
||||||
|
worktreeBranchMode?: WorktreeBranchMode;
|
||||||
cwdRawVisible?: boolean;
|
cwdRawVisible?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +37,8 @@ export interface SpawnFormValues {
|
|||||||
name: string;
|
name: string;
|
||||||
worktreeEnabled: boolean;
|
worktreeEnabled: boolean;
|
||||||
worktreeBranch: string;
|
worktreeBranch: string;
|
||||||
|
/** "new" creates the branch; "existing" checks out a picked one (#61). */
|
||||||
|
worktreeBranchMode: WorktreeBranchMode;
|
||||||
/** Whether the cwd lives in the raw text input (vs a picked recent row). */
|
/** Whether the cwd lives in the raw text input (vs a picked recent row). */
|
||||||
cwdRawVisible?: boolean;
|
cwdRawVisible?: boolean;
|
||||||
}
|
}
|
||||||
@@ -48,15 +61,21 @@ export function buildSpawnRequest(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (values.worktreeEnabled) {
|
if (values.worktreeEnabled) {
|
||||||
|
const mode = values.worktreeBranchMode ?? "new";
|
||||||
const branch = values.worktreeBranch.trim();
|
const branch = values.worktreeBranch.trim();
|
||||||
if (!baseRepo) return { error: "Worktree needs a directory inside a git repository." };
|
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 = {
|
req.worktree = {
|
||||||
base_repo: baseRepo,
|
base_repo: baseRepo,
|
||||||
branch,
|
branch,
|
||||||
// The daemon always cleans up, guarded against unsaved work (#56);
|
// The daemon always cleans up, guarded against unsaved work (#56);
|
||||||
// the wire field stays for older daemons.
|
// the wire field stays for older daemons.
|
||||||
cleanup_on_kill: true,
|
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",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +99,7 @@ export interface SpawnFormHandle {
|
|||||||
|
|
||||||
export interface SpawnFormOptions {
|
export interface SpawnFormOptions {
|
||||||
seed?: SpawnFormSeed;
|
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;
|
class?: string;
|
||||||
/** Fired whenever any value changes (input, tile pick, checkbox). */
|
/** Fired whenever any value changes (input, tile pick, checkbox). */
|
||||||
onChange?: (values: SpawnFormValues) => void;
|
onChange?: (values: SpawnFormValues) => void;
|
||||||
@@ -89,7 +108,7 @@ export interface SpawnFormOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The spawn form itself — preset tiles, project picker, name, worktree.
|
/** The spawn form itself — preset tiles, project picker, name, worktree.
|
||||||
* Shared by the modal (`spawn.ts`) and inline draft panes (issue #57). */
|
* Mounted inline by draft panes (issue #57). */
|
||||||
export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||||
const prefill = opts.seed ?? {};
|
const prefill = opts.seed ?? {};
|
||||||
|
|
||||||
@@ -129,10 +148,24 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
<p class="wt-hint" hidden></p>
|
<p class="wt-hint" hidden></p>
|
||||||
<div class="wt-body" hidden>
|
<div class="wt-body" hidden>
|
||||||
<p class="wt-repo"></p>
|
<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
|
Branch
|
||||||
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
||||||
</label>
|
</label>
|
||||||
|
<label class="wt-exist-wrap" hidden>
|
||||||
|
Branch
|
||||||
|
<select class="branch-select"></select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<p class="form-error" role="alert" aria-live="polite"></p>
|
<p class="form-error" role="alert" aria-live="polite"></p>
|
||||||
@@ -141,7 +174,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
// Field queries are scoped to this form instance — several forms can be
|
// 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 errEl = form.querySelector(".form-error") as HTMLElement;
|
||||||
const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement;
|
const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement;
|
||||||
@@ -154,7 +187,13 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
const wtHint = form.querySelector(".wt-hint") as HTMLElement;
|
const wtHint = form.querySelector(".wt-hint") as HTMLElement;
|
||||||
const wtBody = form.querySelector(".wt-body") as HTMLElement;
|
const wtBody = form.querySelector(".wt-body") as HTMLElement;
|
||||||
const wtRepo = form.querySelector(".wt-repo") 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 branchInput = form.querySelector("input[name=worktree_branch]") as HTMLInputElement;
|
||||||
|
const branchSelect = form.querySelector(".branch-select") as HTMLSelectElement;
|
||||||
const nameInput = form.querySelector("input[name=name]") as HTMLInputElement;
|
const nameInput = form.querySelector("input[name=name]") as HTMLInputElement;
|
||||||
|
|
||||||
// ---- form state ------------------------------------------------------
|
// ---- form state ------------------------------------------------------
|
||||||
@@ -163,11 +202,15 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
let selectedPresetId = "";
|
let selectedPresetId = "";
|
||||||
let command = ""; // resolved command (preset's, or the raw custom input)
|
let command = ""; // resolved command (preset's, or the raw custom input)
|
||||||
let cwd = ""; // chosen working directory
|
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
|
let probeToken = 0; // guards async gitroot probes against races
|
||||||
// A seed's cwd, applied once the (async) projects 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.
|
// settles — so the fetch can't race past and clobber the seeded directory.
|
||||||
let seedCwd: string | null = prefill.cwd ?? null;
|
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
|
// `emit` stays silent until the initial load settles and goes silent again
|
||||||
// once the form is destroyed. Construction runs with `cwd === ""` and the
|
// once the form is destroyed. Construction runs with `cwd === ""` and the
|
||||||
@@ -177,6 +220,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
// clobber the live one's leaf (#57 review).
|
// clobber the live one's leaf (#57 review).
|
||||||
let ready = false;
|
let ready = false;
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
|
const branchValue = () => (branchMode === "existing" ? branchSelect.value : branchInput.value);
|
||||||
const emit = () => {
|
const emit = () => {
|
||||||
if (!ready || destroyed) return;
|
if (!ready || destroyed) return;
|
||||||
opts.onChange?.({
|
opts.onChange?.({
|
||||||
@@ -184,7 +228,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
cwd,
|
cwd,
|
||||||
name: nameInput.value,
|
name: nameInput.value,
|
||||||
worktreeEnabled: wtEnabled.checked,
|
worktreeEnabled: wtEnabled.checked,
|
||||||
worktreeBranch: branchInput.value,
|
worktreeBranch: branchValue(),
|
||||||
|
worktreeBranchMode: branchMode,
|
||||||
cwdRawVisible: !cwdRawWrap.hidden,
|
cwdRawVisible: !cwdRawWrap.hidden,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -192,13 +237,51 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
// ---- name (also seeds the worktree branch) ---------------------------
|
// ---- name (also seeds the worktree branch) ---------------------------
|
||||||
// The worktree branch defaults to the session name — including a clone's
|
// 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
|
// 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);
|
let branchEdited = Boolean(prefill.worktreeBranch);
|
||||||
const syncBranchToName = () => {
|
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", () => {
|
nameInput.addEventListener("input", () => {
|
||||||
|
nameTouched = true;
|
||||||
syncBranchToName();
|
syncBranchToName();
|
||||||
emit();
|
emit();
|
||||||
});
|
});
|
||||||
@@ -207,9 +290,21 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
emit();
|
emit();
|
||||||
});
|
});
|
||||||
(form.querySelector(".reroll") as HTMLButtonElement).addEventListener("click", () => {
|
(form.querySelector(".reroll") as HTMLButtonElement).addEventListener("click", () => {
|
||||||
nameInput.value = generateName();
|
nameTouched = true;
|
||||||
syncBranchToName();
|
const apply = () => {
|
||||||
emit();
|
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
|
syncBranchToName(); // seed the branch from the initial name
|
||||||
|
|
||||||
@@ -308,6 +403,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
|
|
||||||
cwdRaw.addEventListener("input", () => {
|
cwdRaw.addEventListener("input", () => {
|
||||||
cwd = cwdRaw.value.trim();
|
cwd = cwdRaw.value.trim();
|
||||||
|
branchCache = null;
|
||||||
emit();
|
emit();
|
||||||
if (wtEnabled.checked) probeGitRoot();
|
if (wtEnabled.checked) probeGitRoot();
|
||||||
});
|
});
|
||||||
@@ -325,11 +421,14 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
cwd = project.path;
|
cwd = project.path;
|
||||||
cwdRawWrap.hidden = true;
|
cwdRawWrap.hidden = true;
|
||||||
markActiveProject(project.path);
|
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();
|
emit();
|
||||||
// Picking a project pre-selects the command it was last used with.
|
// Picking a project pre-selects the command it was last used with.
|
||||||
const match = presets.find((p) => p.command === project.last_command && p.command !== "");
|
const match = presets.find((p) => p.command === project.last_command && p.command !== "");
|
||||||
if (match) selectPreset(match.id);
|
if (match) selectPreset(match.id);
|
||||||
if (wtEnabled.checked) probeGitRoot();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let discovered: Project[] = []; // daemon snapshot, daemon-sorted
|
let discovered: Project[] = []; // daemon snapshot, daemon-sorted
|
||||||
@@ -383,7 +482,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
}
|
}
|
||||||
// Everything seeded: the form may publish from here on.
|
// Everything seeded: the form may publish from here on.
|
||||||
ready = true;
|
ready = true;
|
||||||
if (wtEnabled.checked) void probeGitRoot();
|
resolveBaseRepo();
|
||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,7 +523,37 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
return p;
|
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> {
|
async function probeGitRoot(): Promise<void> {
|
||||||
const token = ++probeToken;
|
const token = ++probeToken;
|
||||||
if (!cwd) {
|
if (!cwd) {
|
||||||
@@ -445,10 +574,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
}
|
}
|
||||||
if (token !== probeToken) return; // a newer probe superseded us
|
if (token !== probeToken) return; // a newer probe superseded us
|
||||||
if (root) {
|
if (root) {
|
||||||
inferredBaseRepo = root;
|
applyProjectRepo(root);
|
||||||
wtHint.hidden = true;
|
|
||||||
wtBody.hidden = false;
|
|
||||||
wtRepo.textContent = `repo: ${root} (inferred)`;
|
|
||||||
} else {
|
} else {
|
||||||
inferredBaseRepo = null;
|
inferredBaseRepo = null;
|
||||||
// Publish the untick too, or the next render seeds the box checked with
|
// Publish the untick too, or the next render seeds the box checked with
|
||||||
@@ -461,10 +587,72 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- existing-branch picker (#61) -------------------------------------
|
||||||
|
function setBranchMode(mode: WorktreeBranchMode): void {
|
||||||
|
branchMode = mode;
|
||||||
|
for (const radio of modeRadios) radio.checked = radio.value === mode;
|
||||||
|
newWrap.hidden = mode !== "new";
|
||||||
|
existWrap.hidden = mode !== "existing";
|
||||||
|
if (mode === "existing") void loadBranches();
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch the target repo's branches once per cwd. Like every async load in
|
||||||
|
* this form: token-guarded, and nothing touches the DOM (or emits) after
|
||||||
|
* destroy() — a late response into a rebuilt form would corrupt that
|
||||||
|
* form's draft (#57 review). */
|
||||||
|
async function loadBranches(): Promise<void> {
|
||||||
|
if (!cwd || !inferredBaseRepo) return;
|
||||||
|
const token = ++branchToken;
|
||||||
|
if (branchCache?.cwd !== cwd) {
|
||||||
|
const target = cwd;
|
||||||
|
let branches: BranchInfo[];
|
||||||
|
try {
|
||||||
|
branches = await fetchBranches(target);
|
||||||
|
} catch {
|
||||||
|
return; // picker just stays empty; "new" mode still works
|
||||||
|
}
|
||||||
|
if (destroyed || token !== branchToken || cwd !== target) return;
|
||||||
|
branchCache = { cwd: target, branches };
|
||||||
|
}
|
||||||
|
renderBranchOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBranchOptions(): void {
|
||||||
|
if (destroyed) return;
|
||||||
|
const branches = branchCache?.cwd === cwd ? branchCache.branches : [];
|
||||||
|
branchSelect.replaceChildren();
|
||||||
|
const opt = (label: string, value: string) => {
|
||||||
|
const o = document.createElement("option");
|
||||||
|
o.textContent = label;
|
||||||
|
o.value = value;
|
||||||
|
return o;
|
||||||
|
};
|
||||||
|
if (branches.length === 0) {
|
||||||
|
branchSelect.appendChild(opt("No local branches found.", ""));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const b of branches) {
|
||||||
|
// A branch another worktree holds stays visible but unpickable — git
|
||||||
|
// would refuse to check it out twice; the path says who holds it.
|
||||||
|
const o = opt(b.worktree ? `${b.name} — held by ${b.worktree}` : b.name, b.name);
|
||||||
|
o.disabled = Boolean(b.worktree);
|
||||||
|
o.title = b.worktree ?? "";
|
||||||
|
branchSelect.appendChild(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const radio of modeRadios) {
|
||||||
|
radio.addEventListener("change", () => {
|
||||||
|
if (radio.checked) setBranchMode(radio.value as WorktreeBranchMode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
branchSelect.addEventListener("change", () => emit());
|
||||||
|
|
||||||
wtEnabled.addEventListener("change", () => {
|
wtEnabled.addEventListener("change", () => {
|
||||||
emit();
|
emit();
|
||||||
if (wtEnabled.checked) {
|
if (wtEnabled.checked) {
|
||||||
probeGitRoot();
|
resolveBaseRepo();
|
||||||
} else {
|
} else {
|
||||||
inferredBaseRepo = null;
|
inferredBaseRepo = null;
|
||||||
wtBody.hidden = true;
|
wtBody.hidden = true;
|
||||||
@@ -472,15 +660,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// A seeded worktree restores its checkbox/body up front; its gitroot probe
|
// The toggle defaults ON (#61): the operator almost always wants a
|
||||||
// waits for loadRecents to apply the seeded cwd.
|
// worktree, and every non-repo target unticks it via the gitroot probe.
|
||||||
if (prefill.worktreeEnabled || prefill.worktreeBranch) {
|
wtEnabled.checked = prefill.worktreeEnabled ?? true;
|
||||||
wtEnabled.checked = Boolean(prefill.worktreeEnabled);
|
if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch;
|
||||||
if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch;
|
setBranchMode(branchMode);
|
||||||
wtBody.hidden = !wtEnabled.checked;
|
|
||||||
wtHint.hidden = true;
|
|
||||||
emit();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seed ordering: the raw-cwd application races the async info load (which
|
// Seed ordering: the raw-cwd application races the async info load (which
|
||||||
// provides homeDir for the projects' abbreviations), so projects wait for it.
|
// provides homeDir for the projects' abbreviations), so projects wait for it.
|
||||||
@@ -499,8 +683,9 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
cwd,
|
cwd,
|
||||||
name: nameInput.value,
|
name: nameInput.value,
|
||||||
worktreeEnabled: wtEnabled.checked,
|
worktreeEnabled: wtEnabled.checked,
|
||||||
worktreeBranch: branchInput.value,
|
worktreeBranch: branchValue(),
|
||||||
},
|
worktreeBranchMode: branchMode,
|
||||||
|
},
|
||||||
inferredBaseRepo,
|
inferredBaseRepo,
|
||||||
);
|
);
|
||||||
if ("error" in built) {
|
if ("error" in built) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+21
-117
@@ -1205,72 +1205,9 @@ textarea {
|
|||||||
border-color: var(--accent-strong);
|
border-color: var(--accent-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- modal ---------- */
|
/* ---------- forms ---------- */
|
||||||
|
|
||||||
.modal-backdrop {
|
.spawn-form {
|
||||||
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 {
|
|
||||||
padding: var(--sp-3);
|
padding: var(--sp-3);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1280,7 +1217,6 @@ textarea {
|
|||||||
scrollbar-color: var(--border-strong) transparent;
|
scrollbar-color: var(--border-strong) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body label,
|
|
||||||
.spawn-form label {
|
.spawn-form label {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1290,7 +1226,6 @@ textarea {
|
|||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body label.row-inline,
|
|
||||||
.spawn-form label.row-inline {
|
.spawn-form label.row-inline {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1300,10 +1235,9 @@ textarea {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body input[type="text"],
|
.spawn-form input[type="text"],
|
||||||
.modal-body input[type="search"],
|
.spawn-form input[type="search"],
|
||||||
.modal-body select,
|
.spawn-form select {
|
||||||
.spawn-form input[type="text"] {
|
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--r-2);
|
border-radius: var(--r-2);
|
||||||
@@ -1318,23 +1252,20 @@ textarea {
|
|||||||
background var(--dur-fast) var(--ease-out);
|
background var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body input[type="text"]:hover,
|
.spawn-form input[type="text"]:hover,
|
||||||
.modal-body select:hover,
|
.spawn-form select:hover {
|
||||||
.spawn-form input[type="text"]:hover {
|
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body input[type="text"]:focus,
|
.spawn-form input[type="text"]:focus,
|
||||||
.modal-body input[type="search"]:focus,
|
.spawn-form input[type="search"]:focus,
|
||||||
.modal-body select:focus,
|
.spawn-form select:focus {
|
||||||
.spawn-form input[type="text"]:focus {
|
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
box-shadow: 0 0 0 3px var(--accent-tint);
|
box-shadow: 0 0 0 3px var(--accent-tint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body input[type="checkbox"],
|
|
||||||
.spawn-form input[type="checkbox"] {
|
.spawn-form input[type="checkbox"] {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
@@ -1359,13 +1290,6 @@ textarea {
|
|||||||
font-size: var(--fs-13);
|
font-size: var(--fs-13);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-error {
|
.form-error {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -1381,7 +1305,6 @@ textarea {
|
|||||||
|
|
||||||
/* ---------- spawn form: tiles + recents picker (#27) ---------- */
|
/* ---------- spawn form: tiles + recents picker (#27) ---------- */
|
||||||
|
|
||||||
.spawn-modal .field,
|
|
||||||
.spawn-form .field {
|
.spawn-form .field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1575,6 +1498,16 @@ textarea {
|
|||||||
color: var(--muted);
|
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. */
|
/* Generated-name field with a reroll button. */
|
||||||
.name-row {
|
.name-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1601,7 +1534,7 @@ textarea {
|
|||||||
|
|
||||||
.dir-ac {
|
.dir-ac {
|
||||||
position: fixed;
|
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;
|
margin: 0;
|
||||||
padding: var(--sp-1);
|
padding: var(--sp-1);
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -1749,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 ---------- */
|
/* ---------- responsive ---------- */
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 520px) {
|
||||||
@@ -2109,15 +2022,6 @@ textarea {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--border-strong) transparent;
|
|
||||||
/* The modal form got its stacking and padding from `.modal-body`; in a pane
|
|
||||||
the form is the host, so it carries them itself. */
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
padding: var(--sp-3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* pane tools cluster (disconnect / split / close), top-right corner. The attach
|
/* pane tools cluster (disconnect / split / close), top-right corner. The attach
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ export interface GitRoot {
|
|||||||
root?: string;
|
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`
|
/** One discovered git repository (or linked worktree) for `GET /api/projects`
|
||||||
* (#54). Mirrors `maestro_protocol::Project`. */
|
* (#54). Mirrors `maestro_protocol::Project`. */
|
||||||
export interface Project {
|
export interface Project {
|
||||||
@@ -108,8 +114,25 @@ export interface WorktreeSpec {
|
|||||||
base_repo: string;
|
base_repo: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
cleanup_on_kill?: boolean;
|
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 {
|
export interface CreateSessionRequest {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
command: string;
|
command: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user