Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b646cc5adf | ||
|
|
cfc125144f | ||
|
|
d3098a9400 | ||
|
|
36cb03961c | ||
|
|
5e34ce7a61 | ||
|
|
1fa9ce798e | ||
|
|
2c89902829 | ||
|
|
8949fdfdf6 | ||
|
|
596e7e2f49 | ||
|
|
16609eff9e | ||
|
|
9b0e9751ec | ||
|
|
d1ed4f1fdc | ||
|
|
2c8758ed14 | ||
|
|
2a2da7f779 | ||
|
|
5eab434647 | ||
|
|
8e9ed580ea | ||
|
|
2d5e2f1718 | ||
|
|
b80d604408 | ||
|
|
56d1afe9bd | ||
|
|
73e9ecfc85 | ||
|
|
095f810176 | ||
|
|
3e809db1bc | ||
|
|
e8bd8a4952 | ||
|
|
a85773cef0 | ||
|
|
9c6e06ec4f | ||
|
|
3029de667e | ||
|
|
a8d693c0a3 | ||
|
|
6c1bcf042c | ||
|
|
fb87a00906 | ||
|
|
d4dcfe7f00 | ||
|
|
6161cabe77 | ||
|
|
524a848fc6 | ||
|
|
187bcb01d6 | ||
|
|
003e3839bc | ||
|
|
d2079cac3a | ||
|
|
8aa007c73b | ||
|
|
f4d12fa02d | ||
|
|
31702e03e3 |
@@ -97,3 +97,24 @@ jobs:
|
||||
|
||||
- name: build
|
||||
run: pnpm -C web build
|
||||
|
||||
# The flake pins pnpmDeps.hash against web/pnpm-lock.yaml (#98). A lockfile
|
||||
# change invalidates the pin silently elsewhere, so the packaged build has to
|
||||
# fail here too.
|
||||
flake:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install nix
|
||||
run: |
|
||||
curl -fsSL https://nixos.org/nix/install | sh -s -- --daemon --yes
|
||||
echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: build flake packages
|
||||
run: |
|
||||
nix build \
|
||||
.#webFrontend \
|
||||
.#maestro \
|
||||
.#maestro-web \
|
||||
--no-link
|
||||
|
||||
@@ -102,11 +102,6 @@ pub struct SpawnArgs {
|
||||
/// there.
|
||||
#[arg(long)]
|
||||
pub existing_branch: bool,
|
||||
/// Remove the worktree when the session is killed. Kept for wire
|
||||
/// compatibility (#56): the daemon now always cleans up on kill, guarded
|
||||
/// against unsaved work.
|
||||
#[arg(long, default_value_t = true)]
|
||||
pub cleanup_on_kill: bool,
|
||||
/// After spawning, attach immediately.
|
||||
#[arg(long, short = 'a')]
|
||||
pub attach: bool,
|
||||
@@ -168,7 +163,6 @@ mod tests {
|
||||
"/repos/foo",
|
||||
"--branch",
|
||||
"feat/x",
|
||||
"--cleanup-on-kill",
|
||||
"-e",
|
||||
"FOO=bar",
|
||||
"-e",
|
||||
@@ -179,7 +173,6 @@ mod tests {
|
||||
Command::Spawn(a) => {
|
||||
assert_eq!(a.command, "bash");
|
||||
assert_eq!(a.branch.as_deref(), Some("feat/x"));
|
||||
assert!(a.cleanup_on_kill);
|
||||
assert_eq!(
|
||||
a.env,
|
||||
vec![("FOO".into(), "bar".into()), ("BAZ".into(), "qux".into()),]
|
||||
@@ -189,27 +182,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_on_kill_defaults_true_for_the_wire() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"maestro",
|
||||
"spawn",
|
||||
"--cwd",
|
||||
"/tmp",
|
||||
"--command",
|
||||
"bash",
|
||||
"--worktree-from",
|
||||
"/repos/foo",
|
||||
"--branch",
|
||||
"feat/x",
|
||||
])
|
||||
.unwrap();
|
||||
match cli.command {
|
||||
Command::Spawn(a) => assert!(a.cleanup_on_kill),
|
||||
_ => panic!("wrong subcommand"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Review fix: without a flag the CLI always sent `create_branch: true`,
|
||||
/// so the daemon's existing-branch path was unreachable and spawning onto
|
||||
/// a branch that already existed just failed.
|
||||
|
||||
@@ -62,6 +62,11 @@ pub async fn dispatch(
|
||||
run_attach_url(&daemon.attach_url(args.session_id)?, detach).await?;
|
||||
}
|
||||
Command::Kill(args) => {
|
||||
// #83: warn BEFORE the attempt, so the refusal is not a surprise.
|
||||
// Best-effort: an unreachable list must not block the kill.
|
||||
if !args.force {
|
||||
warn_if_blocked(&daemon, args.session_id).await;
|
||||
}
|
||||
let outcome = run_kill(&daemon, &args).await?;
|
||||
println!("killed {}", args.session_id);
|
||||
if let Some(w) = outcome.retained {
|
||||
@@ -99,6 +104,25 @@ pub async fn dispatch(
|
||||
/// Default detach byte, matching `maestro attach --detach-key`'s `^q`.
|
||||
const CTRL_Q: u8 = 0x11;
|
||||
|
||||
/// #83: say "this will be refused" before the operator commits to it, in the
|
||||
/// same words [`maestro_protocol::InTheWay`] uses everywhere else.
|
||||
async fn warn_if_blocked(daemon: &DaemonClient, id: uuid::Uuid) {
|
||||
let Ok(sessions) = daemon.list_sessions().await else {
|
||||
return;
|
||||
};
|
||||
if let Some(b) = sessions
|
||||
.iter()
|
||||
.find(|s| s.id == id)
|
||||
.and_then(|s| s.blocked_work)
|
||||
{
|
||||
if !b.is_clear() {
|
||||
eprintln!(
|
||||
"warning: this kill will be refused — the session's worktree holds unsaved work ({b}); use --force to retain it"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP client pointed at the daemon's API. That surface needs no auth —
|
||||
/// reaching `bind_addr` is the authorization.
|
||||
#[allow(clippy::result_large_err)]
|
||||
|
||||
@@ -43,7 +43,6 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
|
||||
(Some(repo), Some(branch)) => Some(WorktreeSpec {
|
||||
base_repo: repo.clone(),
|
||||
branch: branch.clone(),
|
||||
cleanup_on_kill: args.cleanup_on_kill,
|
||||
create_branch: !args.existing_branch,
|
||||
}),
|
||||
_ => None,
|
||||
@@ -94,8 +93,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
" {:<36} {:<8} {:<9} {:<28} {}",
|
||||
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND"
|
||||
" {:<36} {:<8} {:<9} {:<28} {:<40} {}",
|
||||
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
|
||||
)
|
||||
.ok();
|
||||
for s in sorted {
|
||||
@@ -116,15 +115,28 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
||||
Some(fg) if fg != &s.command => format!("{} [{}]", s.command, fg),
|
||||
_ => s.command.clone(),
|
||||
};
|
||||
// #83: what an unforced kill would be refused for, in InTheWay's own
|
||||
// words; blank while nothing blocks (or hasn't been probed yet).
|
||||
let blocked = blocked_label(s);
|
||||
writeln!(
|
||||
out,
|
||||
" {:<36} {:<8} {:<9} {:<28} {command}",
|
||||
s.id, status, activity, dir
|
||||
" {:<36} {:<8} {:<9} {:<28} {:<40} {blocked}",
|
||||
s.id, status, activity, dir, command
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// The blocked-work indicator (#83), phrased by [`InTheWay`]'s `Display`.
|
||||
/// Empty unless something actually blocks — "no unsaved work" would just be
|
||||
/// noise on every row.
|
||||
pub(crate) fn blocked_label(s: &Session) -> String {
|
||||
s.blocked_work
|
||||
.filter(|b| !b.is_clear())
|
||||
.map(|b| b.to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Render a path with the daemon's home dir collapsed to `~`. Mirrors the web
|
||||
/// UI's abbreviation so CLI and browser show the same directory shorthand.
|
||||
pub(crate) fn abbreviate_dir(path: &std::path::Path, home: &str) -> String {
|
||||
@@ -165,6 +177,7 @@ mod tests {
|
||||
activity: Default::default(),
|
||||
last_activity_at: chrono::Utc::now(),
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,4 +238,37 @@ mod tests {
|
||||
super::print_session_table(&mut buf, &[], &info(""));
|
||||
assert_eq!(String::from_utf8(buf).unwrap(), "no sessions\n");
|
||||
}
|
||||
|
||||
/// #83: a blocked session shows what an unforced kill would be refused
|
||||
/// for, in `InTheWay`'s own words; a clear or unprobed one shows nothing.
|
||||
#[test]
|
||||
fn shows_the_blocked_work_summary_only_when_something_blocks() {
|
||||
use maestro_protocol::InTheWay;
|
||||
|
||||
let mut blocked = session("/tmp", "claude", 1);
|
||||
blocked.blocked_work = Some(InTheWay {
|
||||
uncommitted_files: 3,
|
||||
unpushed_commits: 2,
|
||||
..InTheWay::default()
|
||||
});
|
||||
let mut clear = session("/tmp", "bash", 2);
|
||||
clear.blocked_work = Some(InTheWay::default());
|
||||
let unprobed = session("/tmp", "zsh", 3);
|
||||
|
||||
let mut buf = Vec::new();
|
||||
super::print_session_table(&mut buf, &[blocked, clear, unprobed], &info(""));
|
||||
let out = String::from_utf8(buf).unwrap();
|
||||
assert!(
|
||||
out.contains("3 uncommitted files, 2 unpushed commits"),
|
||||
"{out}"
|
||||
);
|
||||
// Clear and unprobed rows stay blank in the BLOCKED column.
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
let header = lines.iter().position(|l| l.contains("BLOCKED")).unwrap();
|
||||
for line in &lines[header + 1..] {
|
||||
if !line.contains("uncommitted files") {
|
||||
assert!(!line.contains("unsaved work"), "{line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,17 +261,13 @@ impl ProjectScanner {
|
||||
}
|
||||
|
||||
/// Cached snapshot, rescanning (off-thread) when the TTL has lapsed.
|
||||
/// Also the background timer's entry point: a tick inside the TTL is a
|
||||
/// no-op (#75).
|
||||
pub async fn repos(&self) -> std::sync::Arc<Vec<DiscoveredRepo>> {
|
||||
self.repos_inner(false).await
|
||||
}
|
||||
|
||||
async fn repos_inner(&self, force: bool) -> std::sync::Arc<Vec<DiscoveredRepo>> {
|
||||
let mut cache = self.cache.lock().await;
|
||||
if !force {
|
||||
if let Some((at, repos)) = cache.as_ref() {
|
||||
if at.elapsed() < self.ttl {
|
||||
return repos.clone();
|
||||
}
|
||||
if let Some((at, repos)) = cache.as_ref() {
|
||||
if at.elapsed() < self.ttl {
|
||||
return repos.clone();
|
||||
}
|
||||
}
|
||||
let roots = self.roots.clone();
|
||||
@@ -287,11 +283,11 @@ impl ProjectScanner {
|
||||
repos
|
||||
}
|
||||
|
||||
/// Rescan now, ignoring the TTL. Called by the background timer — going
|
||||
/// through `repos()` would hit the cache and make most ticks no-ops,
|
||||
/// leaving requests to pay for the rescan instead (#54 review).
|
||||
/// One background-refresh tick: rescan only if the cached snapshot has
|
||||
/// passed its TTL (#75) — the timer used to bypass it and re-walk the
|
||||
/// roots every [`REFRESH_INTERVAL`].
|
||||
async fn refresh(&self) {
|
||||
self.repos_inner(true).await;
|
||||
self.repos().await;
|
||||
}
|
||||
|
||||
/// Spawn the slow background refresh task. Drop the returned handle to
|
||||
@@ -544,6 +540,32 @@ mod tests {
|
||||
assert_eq!(fresh[0].name, "late");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_refresh_honours_the_ttl() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut scanner = ProjectScanner::new(
|
||||
vec![tmp.path().to_path_buf()],
|
||||
3,
|
||||
PathBuf::from("/nonexistent"),
|
||||
);
|
||||
|
||||
let empty = scanner.repos().await;
|
||||
assert!(empty.is_empty());
|
||||
|
||||
make_repo(tmp.path(), "late");
|
||||
// A background tick inside the TTL must not re-walk the filesystem:
|
||||
// the new repo stays invisible until the cache expires.
|
||||
scanner.refresh().await;
|
||||
assert!(scanner.repos().await.is_empty());
|
||||
|
||||
// Once the TTL lapses, the next tick picks it up.
|
||||
scanner.ttl = Duration::ZERO;
|
||||
scanner.refresh().await;
|
||||
let fresh = scanner.repos().await;
|
||||
assert_eq!(fresh.len(), 1);
|
||||
assert_eq!(fresh[0].name, "late");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_scanner_always_serves_empty() {
|
||||
let scanner = ProjectScanner::disabled();
|
||||
|
||||
@@ -33,6 +33,13 @@ pub const EVENTS_CAPACITY: usize = maestro_config::EVENTS_CAPACITY;
|
||||
/// How often the liveness watcher polls tmux for sessions that disappeared.
|
||||
pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
|
||||
|
||||
/// How often the liveness watcher re-probes worktrees for the blocked-work
|
||||
/// indicator (#83). Each probe costs three git subprocesses per session, so
|
||||
/// the pass rides the same tick as everything else but runs on a slower
|
||||
/// cadence: an indicator a few seconds stale still warns before the kill
|
||||
/// attempt, which is all #83 needs.
|
||||
pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Per-session daemon-local bookkeeping that complements the public `Session`.
|
||||
#[derive(Debug)]
|
||||
pub struct SessionRecord {
|
||||
@@ -344,6 +351,9 @@ impl SessionManager {
|
||||
last_activity_at: Utc::now(),
|
||||
|
||||
current_cwd: None,
|
||||
// The watcher fills this in on its first blocked-work pass; a
|
||||
// fresh tree has nothing in the way anyway.
|
||||
blocked_work: None,
|
||||
};
|
||||
|
||||
if let Err(e) = self.inner.sidecar.write(&session) {
|
||||
@@ -489,6 +499,17 @@ impl SessionManager {
|
||||
/// `SessionManager` clone is dropped — call this once from the daemon
|
||||
/// startup path after [`Self::recover`] has populated the map.
|
||||
pub fn start_liveness_watcher(&self, interval: Duration) {
|
||||
self.start_liveness_watcher_with_blocked_work(interval, DEFAULT_BLOCKED_WORK_INTERVAL);
|
||||
}
|
||||
|
||||
/// [`Self::start_liveness_watcher`] with the blocked-work cadence (#83)
|
||||
/// made explicit. Tests pass a small `blocked_work_interval` to watch the
|
||||
/// probe actually happen.
|
||||
pub fn start_liveness_watcher_with_blocked_work(
|
||||
&self,
|
||||
interval: Duration,
|
||||
blocked_work_interval: Duration,
|
||||
) {
|
||||
let weak = Arc::downgrade(&self.inner);
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(interval);
|
||||
@@ -496,6 +517,9 @@ impl SessionManager {
|
||||
// first tick fires immediately — skip it so we don't reap before
|
||||
// the caller has a chance to create anything.
|
||||
ticker.tick().await;
|
||||
// Start "due" so the first live tick probes: a recovered daemon
|
||||
// should not wait a full interval to show what blocks.
|
||||
let mut since_probe = blocked_work_interval;
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
let Some(inner) = weak.upgrade() else {
|
||||
@@ -505,6 +529,11 @@ impl SessionManager {
|
||||
// Same tick, no new timer (NAP-340): refresh activity for the
|
||||
// survivors and emit SessionUpdated on any transition.
|
||||
run_activity_pass(&inner).await;
|
||||
since_probe += interval;
|
||||
if since_probe >= blocked_work_interval {
|
||||
since_probe = Duration::ZERO;
|
||||
run_blocked_work_pass(&inner).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -541,6 +570,7 @@ impl SessionManager {
|
||||
last_activity_at: Utc::now(),
|
||||
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
},
|
||||
};
|
||||
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
|
||||
@@ -809,6 +839,61 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh each running session's blocked-work indicator (#83): what its
|
||||
/// worktree holds right now that would block an unforced kill. Rides the
|
||||
/// liveness tick on its own (slower) cadence — see
|
||||
/// [`DEFAULT_BLOCKED_WORK_INTERVAL`] — because every probe is three git
|
||||
/// subprocesses per session.
|
||||
///
|
||||
/// Sessions without a worktree are filtered out BEFORE any probe: they can
|
||||
/// never block a kill, so their indicator must cost nothing at all. A failed
|
||||
/// probe keeps the previous value rather than clearing it — a transient git
|
||||
/// error must not read as "nothing blocks". Like the activity pass, this only
|
||||
/// emits `SessionUpdated` when the value actually changed.
|
||||
async fn run_blocked_work_pass(inner: &Arc<Inner>) {
|
||||
let targets: Vec<(Uuid, (PathBuf, PathBuf))> = {
|
||||
let sessions = inner.sessions.lock().await;
|
||||
sessions
|
||||
.iter()
|
||||
.filter(|(_, r)| r.session.status == SessionStatus::Running)
|
||||
.filter_map(|(id, r)| {
|
||||
let wt = r.worktree_base_repo.clone().zip(r.worktree_path.clone())?;
|
||||
Some((*id, wt))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
if targets.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
for (id, wt) in targets {
|
||||
// The kill guard's own probe (#79), so the indicator can never
|
||||
// disagree with what a kill attempt would find.
|
||||
let breakdown = match probe_worktree(&wt).await {
|
||||
Ok(breakdown) => breakdown,
|
||||
Err(e) => {
|
||||
tracing::debug!(%id, error = ?e, "blocked-work: probe failed; keeping previous value");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let updated = {
|
||||
let mut sessions = inner.sessions.lock().await;
|
||||
let Some(rec) = sessions.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
if rec.session.blocked_work == Some(breakdown) {
|
||||
None
|
||||
} else {
|
||||
rec.session.blocked_work = Some(breakdown);
|
||||
Some(rec.session.clone())
|
||||
}
|
||||
};
|
||||
if let Some(session) = updated {
|
||||
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a session whose tmux session has disappeared as [`SessionStatus::Dead`]
|
||||
/// and settle its worktree per the natural-exit rule (#82).
|
||||
///
|
||||
@@ -852,10 +937,13 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
||||
|
||||
if let Some(wt) = &worktree {
|
||||
if settle_worktree_on_exit(inner, id, wt, branch).await {
|
||||
session.worktree = None;
|
||||
// The tree is settled (removed or retained); a stale "in the
|
||||
// way" would describe something the session no longer holds.
|
||||
session.blocked_work = None;
|
||||
let mut sessions = inner.sessions.lock().await;
|
||||
if let Some(record) = sessions.get_mut(&id) {
|
||||
record.session.worktree = None;
|
||||
record.session.blocked_work = None;
|
||||
record.worktree_path = None;
|
||||
record.worktree_base_repo = None;
|
||||
record.worktree_branch = None;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! The performance contract of #83: the blocked-work pass must cost NO git
|
||||
//! probe at all for a session without a worktree, while a session with one is
|
||||
//! still probed.
|
||||
//!
|
||||
//! `git` itself is shimmed through PATH with a wrapper that logs every
|
||||
//! invocation before exec'ing the real binary, so the assertion counts actual
|
||||
//! probes rather than inferring them from state. This lives in its own test
|
||||
//! binary because it mutates the process environment.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
|
||||
use maestro_protocol::{CreateSessionRequest, InTheWay, WorktreeSpec};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> CreateSessionRequest {
|
||||
CreateSessionRequest {
|
||||
cwd: cwd.to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.to_path_buf(),
|
||||
branch: branch.into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_session_without_a_worktree_costs_no_probe() {
|
||||
let fx = DaemonFixture::new();
|
||||
let log = TempDir::new().unwrap();
|
||||
let log_path = log.path().join("git-calls.log");
|
||||
|
||||
// Session A owns a real (pristine, pushed) worktree; session B has none.
|
||||
let repo_a = TempDir::new().unwrap();
|
||||
init_repo(repo_a.path());
|
||||
init_bare_repo(&repo_a.path().join("origin.git"));
|
||||
push_all_to_origin(repo_a.path(), &repo_a.path().join("origin.git"));
|
||||
fx.manager
|
||||
.create(request(repo_a.path(), repo_a.path(), "feat/probed"))
|
||||
.await
|
||||
.expect("create A");
|
||||
|
||||
let cwd_b = TempDir::new().unwrap();
|
||||
let mut req_b = request(cwd_b.path(), cwd_b.path(), "feat/unused");
|
||||
req_b.worktree = None;
|
||||
fx.manager.create(req_b).await.expect("create B");
|
||||
|
||||
// Shim `git` AFTER setup so only watcher-driven probes get logged.
|
||||
let real_git = String::from_utf8(
|
||||
Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("command -v git")
|
||||
.output()
|
||||
.expect("locate git")
|
||||
.stdout,
|
||||
)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string();
|
||||
let shim_dir = TempDir::new().unwrap();
|
||||
let script = format!(
|
||||
"#!/bin/sh\nprintf '%s\\n' \"$*\" >> {}\nexec {} \"$@\"\n",
|
||||
log_path.display(),
|
||||
real_git,
|
||||
);
|
||||
std::fs::write(shim_dir.path().join("git"), script).unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(
|
||||
shim_dir.path().join("git"),
|
||||
std::fs::Permissions::from_mode(0o755),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let old_path = std::env::var("PATH").unwrap_or_default();
|
||||
std::env::set_var(
|
||||
"PATH",
|
||||
format!("{}:{}", shim_dir.path().display(), old_path),
|
||||
);
|
||||
|
||||
fx.manager.start_liveness_watcher_with_blocked_work(
|
||||
Duration::from_millis(50),
|
||||
Duration::from_millis(100),
|
||||
);
|
||||
|
||||
// The pass demonstrably runs and probed A's tree...
|
||||
wait_for_async(Duration::from_secs(5), || async {
|
||||
fx.manager
|
||||
.snapshot()
|
||||
.await
|
||||
.iter()
|
||||
.any(|s| s.worktree.is_some() && s.blocked_work == Some(InTheWay::default()))
|
||||
})
|
||||
.await;
|
||||
|
||||
std::env::set_var("PATH", &old_path);
|
||||
|
||||
let calls = std::fs::read_to_string(&log_path).unwrap_or_default();
|
||||
assert!(
|
||||
!calls.is_empty(),
|
||||
"the pass never probed anything — the shim saw no git call at all"
|
||||
);
|
||||
// ...while B — no worktree, so nothing to probe — was never touched.
|
||||
assert!(
|
||||
!calls.contains(cwd_b.path().to_str().unwrap()),
|
||||
"a worktree-less session was probed:\n{calls}"
|
||||
);
|
||||
}
|
||||
@@ -363,7 +363,6 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/guarded".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
@@ -417,7 +416,6 @@ async fn delete_with_force_ends_the_session_and_answers_what_was_retained() {
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/guarded".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -367,6 +367,67 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
|
||||
assert!(!fx.fifo_dir().join(format!("{}.fifo", s.id)).exists());
|
||||
}
|
||||
|
||||
/// #83: the watcher refreshes `Session.blocked_work` on its own cadence —
|
||||
/// first "nothing blocks" for a pristine tree, then the breakdown once the
|
||||
/// tree picks up unsaved work, so clients can warn before the kill attempt.
|
||||
#[tokio::test]
|
||||
async fn watcher_refreshes_the_blocked_work_indicator() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
let wt_path = repo.path().join("worktrees").join("feat").join("indicator");
|
||||
|
||||
fx.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/indicator".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
// Pushed and clean: nothing in the way, and the indicator must say so.
|
||||
let origin = TempDir::new().unwrap();
|
||||
init_bare_repo(origin.path());
|
||||
push_all_to_origin(repo.path(), origin.path());
|
||||
|
||||
fx.manager.start_liveness_watcher_with_blocked_work(
|
||||
Duration::from_millis(50),
|
||||
Duration::from_millis(100),
|
||||
);
|
||||
|
||||
wait_for_async(Duration::from_secs(5), || async {
|
||||
fx.manager.snapshot().await[0].blocked_work == Some(maestro_protocol::InTheWay::default())
|
||||
})
|
||||
.await;
|
||||
|
||||
// Unsaved work appears; the next pass must surface it.
|
||||
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||
std::fs::write(wt_path.join("notes.txt"), b"untracked\n").unwrap();
|
||||
wait_for_async(Duration::from_secs(5), || async {
|
||||
matches!(
|
||||
&fx.manager.snapshot().await[0].blocked_work,
|
||||
Some(b) if b.uncommitted_files >= 1 && b.untracked_files >= 1
|
||||
)
|
||||
})
|
||||
.await;
|
||||
|
||||
// A steady state must not re-emit every tick (#83 change-detection).
|
||||
let mut events = fx.manager.events();
|
||||
while events.try_recv().is_ok() {}
|
||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||
assert!(
|
||||
!matches!(events.try_recv(), Ok(CatalogEvent::SessionUpdated { .. })),
|
||||
"an unchanged worktree must not re-emit SessionUpdated"
|
||||
);
|
||||
}
|
||||
|
||||
/// Cleanup on kill is unconditional once the guard passes (#79): even a spec
|
||||
/// that says "don't clean up" loses its worktree — the branch was pushed, so
|
||||
/// nothing in the tree is unique and removal goes ahead.
|
||||
@@ -387,7 +448,6 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/keep".into(),
|
||||
cleanup_on_kill: false,
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
@@ -425,7 +485,6 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/guarded".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
@@ -489,7 +548,6 @@ async fn killing_refuses_until_the_branch_is_pushed() {
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/pushed".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
@@ -541,7 +599,6 @@ async fn force_killing_a_guarded_worktree_retains_it_in_the_store() {
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/forced".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
@@ -607,7 +664,6 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/clear".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
@@ -650,7 +706,6 @@ async fn killing_an_ignored_only_worktree_succeeds() {
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/rusty".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
@@ -693,7 +748,6 @@ async fn natural_exit_of_a_guarded_worktree_retains_it_in_the_store() {
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/exited".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
@@ -728,7 +782,8 @@ async fn natural_exit_of_a_guarded_worktree_retains_it_in_the_store() {
|
||||
// settled, so dismissal is neither refused nor retained twice.
|
||||
// (`Dead` is observable a moment before the settle step finishes.)
|
||||
wait_for_async(Duration::from_secs(3), || async {
|
||||
fx.manager.snapshot().await[0].worktree.is_none()
|
||||
let s = &fx.manager.snapshot().await[0];
|
||||
s.worktree.is_none() && s.blocked_work.is_none()
|
||||
})
|
||||
.await;
|
||||
let snap = fx.manager.snapshot().await;
|
||||
@@ -767,7 +822,6 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/clean".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
@@ -829,7 +883,6 @@ async fn natural_exit_without_a_retained_store_destroys_nothing() {
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/nostore".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -28,6 +28,7 @@ fn sample(id: Uuid) -> Session {
|
||||
activity: Default::default(),
|
||||
last_activity_at: chrono::Utc::now(),
|
||||
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
|
||||
blocked_work: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,12 +39,11 @@ fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
|
||||
/// together (#87 dropped `cleanup_on_kill` from the struct), so the JSON
|
||||
/// object carries only the fields the lifecycle actually honours.
|
||||
fn worktree_spec(base_repo: &std::path::Path, branch: &str, create_branch: bool) -> WorktreeSpec {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"base_repo": base_repo,
|
||||
"branch": branch,
|
||||
"create_branch": create_branch,
|
||||
}))
|
||||
.expect("worktree spec")
|
||||
WorktreeSpec {
|
||||
base_repo: base_repo.to_path_buf(),
|
||||
branch: branch.into(),
|
||||
create_branch,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn request carrying a worktree spec against `base`.
|
||||
|
||||
@@ -87,6 +87,13 @@ pub struct Session {
|
||||
/// from daemons that predate the field.
|
||||
#[serde(default)]
|
||||
pub current_cwd: Option<PathBuf>,
|
||||
/// Whether the session's worktree currently holds anything that would
|
||||
/// block an unforced kill (#83), summarised by [`InTheWay`]'s `Display`.
|
||||
/// Refreshed by the daemon's watcher; `None` for sessions without a
|
||||
/// worktree and until the first probe. Defaults on the wire so payloads
|
||||
/// from daemons that predate the field stay forward-compatible.
|
||||
#[serde(default)]
|
||||
pub blocked_work: Option<InTheWay>,
|
||||
}
|
||||
|
||||
/// Default for [`Session::last_activity_at`] when an older payload omits it.
|
||||
@@ -119,11 +126,6 @@ pub enum ActivityState {
|
||||
pub struct WorktreeSpec {
|
||||
pub base_repo: PathBuf,
|
||||
pub branch: String,
|
||||
/// Kept on the wire for CLI compatibility (#56) but no longer a
|
||||
/// user-facing choice: the daemon always cleans up on kill, guarded
|
||||
/// against unsaved work. Defaults to `true`.
|
||||
#[serde(default = "default_true")]
|
||||
pub cleanup_on_kill: bool,
|
||||
/// When false, `branch` must already exist and is checked out as-is
|
||||
/// (#56); when true (the default, and what every payload meant before
|
||||
/// the field existed) the daemon creates it with `worktree add -b`.
|
||||
@@ -536,6 +538,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.with_timezone(&Utc),
|
||||
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
|
||||
blocked_work: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,6 +558,34 @@ mod tests {
|
||||
assert_eq!(s.activity, ActivityState::Idle);
|
||||
assert_eq!(s.last_activity_at, epoch());
|
||||
assert!(s.current_cwd.is_none());
|
||||
assert!(s.blocked_work.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_work_defaults_to_none_on_legacy_payload() {
|
||||
// A pre-#83 daemon omits the indicator; it must deserialize to `None`
|
||||
// rather than failing, and a present value round-trips.
|
||||
let json = serde_json::json!({
|
||||
"id": Uuid::from_u128(1),
|
||||
"cwd": "/tmp",
|
||||
"command": "bash",
|
||||
"status": "Running",
|
||||
"created_at": "2026-05-24T12:00:00Z"
|
||||
});
|
||||
let s: Session = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(s.blocked_work, None);
|
||||
|
||||
let mut with_blocker = sample_session();
|
||||
with_blocker.blocked_work = Some(InTheWay {
|
||||
uncommitted_files: 3,
|
||||
..InTheWay::default()
|
||||
});
|
||||
assert_eq!(
|
||||
with_blocker.clone().blocked_work,
|
||||
serde_json::from_value::<Session>(serde_json::to_value(&with_blocker).unwrap())
|
||||
.unwrap()
|
||||
.blocked_work
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -594,7 +625,6 @@ mod tests {
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: PathBuf::from("/home/naps62/projects/maestro"),
|
||||
branch: "feat/foo".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
}),
|
||||
};
|
||||
@@ -624,7 +654,6 @@ mod tests {
|
||||
});
|
||||
let spec: WorktreeSpec = serde_json::from_value(json).unwrap();
|
||||
assert!(spec.create_branch);
|
||||
assert!(spec.cleanup_on_kill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -632,7 +661,6 @@ mod tests {
|
||||
let spec = WorktreeSpec {
|
||||
base_repo: PathBuf::from("/work"),
|
||||
branch: "feat/foo".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: false,
|
||||
};
|
||||
assert_eq!(spec, round_trip(&spec));
|
||||
|
||||
@@ -43,6 +43,7 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
|
||||
activity: Default::default(),
|
||||
last_activity_at: Utc::now(),
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -175,7 +175,8 @@ impl App {
|
||||
// Render on next loop iteration.
|
||||
}
|
||||
_ = tokio::time::sleep(Duration::from_millis(33)) => {
|
||||
// 30 Hz heartbeat.
|
||||
let effects = self.poll_due_probes_at(std::time::Instant::now());
|
||||
self.perform(effects).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -439,7 +440,7 @@ impl App {
|
||||
if draft.take_name_refill() {
|
||||
effects.push(Effect::RefillNames(tab_idx));
|
||||
}
|
||||
if let Some((seq, path)) = draft.take_gitroot_probe() {
|
||||
if let Some((seq, path)) = draft.take_gitroot_probe(std::time::Instant::now()) {
|
||||
effects.push(Effect::ProbeGitRoot {
|
||||
tab: tab_idx,
|
||||
seq,
|
||||
@@ -452,6 +453,26 @@ impl App {
|
||||
effects
|
||||
}
|
||||
|
||||
/// Fire any draft gitroot probes whose debounce window has elapsed (#74).
|
||||
/// The 30 Hz heartbeat calls this so a burst of typing issues its one
|
||||
/// probe even after the last keystroke.
|
||||
fn poll_due_probes_at(&mut self, now: std::time::Instant) -> Vec<Effect> {
|
||||
let mut effects = Vec::new();
|
||||
for tab_idx in 0..self.state.tabs.len() {
|
||||
let Some(PaneRef::Draft(draft)) = self.state.tabs[tab_idx].pane.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
if let Some((seq, path)) = draft.take_gitroot_probe(now) {
|
||||
effects.push(Effect::ProbeGitRoot {
|
||||
tab: tab_idx,
|
||||
seq,
|
||||
path,
|
||||
});
|
||||
}
|
||||
}
|
||||
effects
|
||||
}
|
||||
|
||||
/// Populate a freshly opened draft: presets from `/api/info` (falling back
|
||||
/// to the built-ins already in place), projects from `/api/projects`, and
|
||||
/// a first name batch. Applies only if that tab still shows the draft —
|
||||
@@ -1107,6 +1128,7 @@ mod tests {
|
||||
activity: Default::default(),
|
||||
last_activity_at: Utc::now(),
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
}],
|
||||
});
|
||||
app.dispatch(Action::SidebarNext);
|
||||
@@ -1346,10 +1368,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Editing the raw path with the worktree toggle on schedules a
|
||||
/// `/api/gitroot` probe so the section learns its base repo (#65).
|
||||
/// Editing the raw path schedules a `/api/gitroot` probe only once the
|
||||
/// keystroke burst settles (#74): nothing fires inside the debounce
|
||||
/// window; the heartbeat picks the due probe up afterwards.
|
||||
#[test]
|
||||
fn typing_a_raw_path_schedules_a_gitroot_probe() {
|
||||
fn typing_a_raw_path_schedules_a_debounced_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 {
|
||||
@@ -1358,13 +1381,24 @@ mod tests {
|
||||
draft.cwd_raw_visible = true;
|
||||
draft.focused_field = DraftField::CwdRaw;
|
||||
|
||||
let effects = app.handle_draft_input(&InputKey::char('/'));
|
||||
// A burst of keystrokes fires nothing.
|
||||
for c in "/opt/tooling".chars() {
|
||||
assert!(app.handle_draft_input(&InputKey::char(c)).is_empty());
|
||||
assert!(app.poll_due_probes_at(std::time::Instant::now()).is_empty());
|
||||
}
|
||||
|
||||
// Once the quiet period passes, exactly one probe is due.
|
||||
let due = std::time::Instant::now()
|
||||
+ std::time::Duration::from_millis(crate::draft::GITROOT_PROBE_DEBOUNCE_MS) * 10;
|
||||
match app.poll_due_probes_at(due).as_slice() {
|
||||
[Effect::ProbeGitRoot { tab: 0, path, .. }] => {
|
||||
assert_eq!(path, &std::path::PathBuf::from("/opt/tooling"));
|
||||
}
|
||||
other => panic!("expected one ProbeGitRoot effect, got {other:?}"),
|
||||
}
|
||||
assert!(
|
||||
matches!(
|
||||
effects.as_slice(),
|
||||
[Effect::ProbeGitRoot { tab: 0, seq: 2, .. }]
|
||||
),
|
||||
"{effects:?}"
|
||||
app.poll_due_probes_at(due).is_empty(),
|
||||
"the probe is consumed once"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1519,8 +1553,8 @@ mod tests {
|
||||
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.groups.len(), 1);
|
||||
assert_eq!(draft.groups[0].main.name, "maestro");
|
||||
assert_eq!(draft.home_dir, "/home/u");
|
||||
assert_eq!(draft.name.value(), "crimson-otter");
|
||||
}
|
||||
@@ -1614,6 +1648,7 @@ mod tests {
|
||||
last_activity_at: chrono::Utc::now(),
|
||||
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
}],
|
||||
});
|
||||
app.dispatch(Action::KillHighlighted);
|
||||
|
||||
@@ -195,6 +195,7 @@ mod tests {
|
||||
activity: Default::default(),
|
||||
last_activity_at: chrono::Utc::now(),
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -238,6 +238,26 @@ mod tests {
|
||||
assert!(s.contains("ctrl-glorb"), "diagnostic: {s}");
|
||||
}
|
||||
|
||||
/// Regression for #70: the previous canonical form of a real Shift+S
|
||||
/// keypress was `"shift-S"`, which the default `"S"` binding never
|
||||
/// matched. Drive the actual crossterm event, not the binding string,
|
||||
/// so the dead-key bug can't come back unnoticed.
|
||||
#[test]
|
||||
fn real_shift_s_keypress_resolves_to_fast_forward_spawn() {
|
||||
use crossterm::event::{Event as CtEvent, KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use crate::input::{canonicalise, Region};
|
||||
|
||||
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||
let ev = CtEvent::Key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
|
||||
let key = canonicalise(&ev, |_, _| Region::Unknown).expect("key event canonicalises");
|
||||
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Normal, &key.canonical()),
|
||||
Some(Action::FastForwardSpawn),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_chord_timeout_is_applied() {
|
||||
let mut section = TuiSection::default();
|
||||
|
||||
+591
-92
@@ -7,16 +7,35 @@
|
||||
//!
|
||||
//! Field groups (#64), mirroring the web form (`web/src/spawnForm.ts`):
|
||||
//! command presets from `/api/info` (with an implicit `custom` free-text
|
||||
//! entry), a project picker fed by [`crate::projects`] over `/api/projects`
|
||||
//! plus an `Other directory…` escape hatch, a generated session name from
|
||||
//! entry), a two-step project/worktree picker (#96, mirroring the web
|
||||
//! redesign in #95) fed by [`crate::projects`] over `/api/projects` plus an
|
||||
//! `Other directory…` escape hatch, a generated session name from
|
||||
//! `/api/names` with a reroll key, and validation that reports which field is
|
||||
//! missing instead of posting an empty request. A worktree section (#65)
|
||||
//! mirrors `web/src/spawnForm.ts`: on by default over a git repository,
|
||||
//! branch tracking the generated name until touched, with a new-or-existing
|
||||
//! branch switch fed by `/api/branches`.
|
||||
//!
|
||||
//! The picker itself is two steps (#96), verbatim from the rules in
|
||||
//! `web/src/spawnForm.ts`'s "working-directory picker (#95)" comment:
|
||||
//! 1. Step one is one box per repository (name, then full path). A worktree
|
||||
//! is a box only when its main checkout is missing from the scan.
|
||||
//! 2. The filter keeps a repository when the query matches it or any of its
|
||||
//! worktrees, so a worktree name still finds its box.
|
||||
//! 3. Picking a box reveals step two: "+ new", then that repository's
|
||||
//! worktrees newest-first. "Other directory…" clears both steps.
|
||||
//! 4. "+ new" is the default selection — except on a box that is itself a
|
||||
//! linked worktree, which defaults to that worktree.
|
||||
//! 5. "+ new" means create: cwd is the repository, create-worktree on.
|
||||
//! An existing worktree means run in it: cwd is that worktree,
|
||||
//! create-worktree OFF. The daemon never adopts (docs/WORKTREES.md), so
|
||||
//! these are separate acts.
|
||||
//! 6. Neither step probes `/api/gitroot`; the scan already reports
|
||||
//! repository membership. Only the free-text hatch pays for a probe.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use maestro_config::{builtin_presets, custom_preset};
|
||||
@@ -35,6 +54,12 @@ use crate::projects;
|
||||
/// dry.
|
||||
pub const NAME_BATCH_SIZE: usize = 20;
|
||||
|
||||
/// Quiet period after raw-path typing before a `/api/gitroot` probe fires
|
||||
/// (#74). Every keystroke resets it, so a burst issues one request once the
|
||||
/// operator pauses. Must stay equal to `GITROOT_PROBE_DEBOUNCE_MS` in
|
||||
/// web/src/spawnForm.ts so both clients debounce alike.
|
||||
pub const GITROOT_PROBE_DEBOUNCE_MS: u64 = 250;
|
||||
|
||||
/// Width of the fixed label gutter every form line starts with (`cmd: `,
|
||||
/// `dir: `, …).
|
||||
const GUTTER: usize = 5;
|
||||
@@ -45,10 +70,15 @@ pub enum DraftField {
|
||||
Preset,
|
||||
/// Free-text command, reachable only while a custom preset is selected.
|
||||
CustomCommand,
|
||||
/// Project picker: typing filters, Up / Down highlight, Enter picks.
|
||||
/// Step one: repository picker. Typing filters, Up / Down highlight,
|
||||
/// Enter picks and reveals step two (#96).
|
||||
DirPicker,
|
||||
/// Free-text path, revealed by picking `Other directory…`.
|
||||
CwdRaw,
|
||||
/// Step two, shown once a repository is picked: "+ new" then that
|
||||
/// repository's worktrees newest-first (#96 rule 3). Up / Down move and
|
||||
/// apply immediately, mirroring the web form's per-click choice.
|
||||
WorktreeChoice,
|
||||
Name,
|
||||
/// Worktree on/off for this session (#65). Enter / Space toggles.
|
||||
WorktreeToggle,
|
||||
@@ -71,14 +101,50 @@ pub enum WorktreeBranchMode {
|
||||
Existing,
|
||||
}
|
||||
|
||||
/// One flattened project-picker entry: a discovered repo/worktree plus what
|
||||
/// rendering needs to know about it. `indent` marks worktrees nested under
|
||||
/// their main checkout.
|
||||
/// "new" (create a worktree) or an existing worktree to run in — the TUI
|
||||
/// mirror of the web form's `WorktreeChoice` (#96 rule 5).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DraftRow {
|
||||
pub enum WorktreeChoice {
|
||||
New,
|
||||
Existing(PathBuf),
|
||||
}
|
||||
|
||||
/// One repository box for step one of the picker (#96 rule 1): the main
|
||||
/// checkout (or, when it's missing from the scan, its freshest worktree
|
||||
/// standing in for it) plus every other worktree of the same repository.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DraftGroup {
|
||||
pub main: Project,
|
||||
pub main_stale: bool,
|
||||
pub worktrees: Vec<DraftWorktreeRow>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DraftWorktreeRow {
|
||||
pub project: Project,
|
||||
pub stale: bool,
|
||||
pub indent: bool,
|
||||
}
|
||||
|
||||
/// The repository key a [`DraftGroup`] was formed under — same key
|
||||
/// `projects::group_projects` groups by, kept separately because the main
|
||||
/// box's own path only equals it when the main checkout itself was found.
|
||||
fn group_key(main: &Project) -> PathBuf {
|
||||
main.base_repo.clone().unwrap_or_else(|| main.path.clone())
|
||||
}
|
||||
|
||||
/// Every worktree of a group, including the group's own box when the scan
|
||||
/// found no main checkout to nest it under — the TUI mirror of the web
|
||||
/// form's `worktreesOf`. Already newest-first: when the main box stands in
|
||||
/// for a missing main checkout it is the freshest member by construction
|
||||
/// ([`projects::group_projects`]), and `worktrees` itself is sorted
|
||||
/// newest-first.
|
||||
fn worktree_rows_of(group: &DraftGroup) -> Vec<Project> {
|
||||
let mut rows = Vec::new();
|
||||
if group.main.is_worktree {
|
||||
rows.push(group.main.clone());
|
||||
}
|
||||
rows.extend(group.worktrees.iter().map(|w| w.project.clone()));
|
||||
rows
|
||||
}
|
||||
|
||||
/// Why a submit was refused. The message names the missing field so the form
|
||||
@@ -123,15 +189,23 @@ pub struct DraftPane {
|
||||
pub presets: Vec<SpawnPreset>,
|
||||
pub selected_preset: usize,
|
||||
pub custom_command: crate::text_input::TextInput,
|
||||
/// Discovered projects, sorted fresh-first with worktrees nested.
|
||||
pub rows: Vec<DraftRow>,
|
||||
/// Discovered projects, sorted fresh-first and grouped by repository
|
||||
/// (#96 step one).
|
||||
pub groups: Vec<DraftGroup>,
|
||||
pub filter: crate::text_input::TextInput,
|
||||
/// Highlighted entry within the visible list; the implicit
|
||||
/// `Other directory…` entry sits at index `visible_rows().len()`.
|
||||
/// Highlighted entry within the visible step-one list; the implicit
|
||||
/// `Other directory…` entry sits at index `visible_groups().len()`.
|
||||
pub highlighted: usize,
|
||||
pub cwd_raw_visible: bool,
|
||||
pub cwd_raw: crate::text_input::TextInput,
|
||||
pub picked_cwd: Option<PathBuf>,
|
||||
/// The repository picked in step one ([`group_key`] of its main box),
|
||||
/// `None` until a box is picked. Looked up fresh in `groups` rather than
|
||||
/// cached, so a project-list refresh can't leave it stale.
|
||||
pub selected_group_key: Option<PathBuf>,
|
||||
/// Step two's current pick (#96 rule 4/5). Meaningless while
|
||||
/// `selected_group_key` is `None`.
|
||||
pub worktree_choice: WorktreeChoice,
|
||||
pub name: crate::text_input::TextInput,
|
||||
pub name_queue: VecDeque<String>,
|
||||
/// Set once the operator types or rerolls a name; guards refills against
|
||||
@@ -149,6 +223,9 @@ pub struct DraftPane {
|
||||
/// Bumped on every target change; answers carrying an older seq are stale.
|
||||
probe_seq: u32,
|
||||
needs_probe: bool,
|
||||
/// When the pending probe may fire (debounce, #74). Typing into the raw
|
||||
/// path resets it; a discrete action like the toggle makes it due at once.
|
||||
probe_due_at: Option<Instant>,
|
||||
/// Set when a probe found no repository (drives the rendered hint).
|
||||
repo_missing: bool,
|
||||
/// New-branch name. Tracks [`Self::name`] until edited, like the web form.
|
||||
@@ -183,12 +260,14 @@ impl Default for DraftPane {
|
||||
presets,
|
||||
selected_preset: 0,
|
||||
custom_command: crate::text_input::TextInput::default(),
|
||||
rows: Vec::new(),
|
||||
groups: Vec::new(),
|
||||
filter: crate::text_input::TextInput::default(),
|
||||
highlighted: 0,
|
||||
cwd_raw_visible: false,
|
||||
cwd_raw: crate::text_input::TextInput::default(),
|
||||
picked_cwd: None,
|
||||
selected_group_key: None,
|
||||
worktree_choice: WorktreeChoice::New,
|
||||
name: crate::text_input::TextInput::default(),
|
||||
name_queue: VecDeque::new(),
|
||||
name_touched: false,
|
||||
@@ -198,6 +277,7 @@ impl Default for DraftPane {
|
||||
probed_cwd: None,
|
||||
probe_seq: 0,
|
||||
needs_probe: false,
|
||||
probe_due_at: None,
|
||||
repo_missing: false,
|
||||
branch: crate::text_input::TextInput::default(),
|
||||
branch_edited: false,
|
||||
@@ -229,24 +309,24 @@ impl DraftPane {
|
||||
.unwrap_or(0);
|
||||
}
|
||||
|
||||
/// Replace the project snapshot: sort fresh-first (#54), nest worktrees
|
||||
/// under their main checkout, and precompute staleness for rendering.
|
||||
/// Replace the project snapshot: sort fresh-first (#54), group by
|
||||
/// repository (#96), and precompute staleness for rendering.
|
||||
pub fn set_projects(&mut self, discovered: Vec<Project>, now: DateTime<Utc>) {
|
||||
let sorted = projects::sort_projects(discovered, now);
|
||||
let groups = projects::group_projects(&sorted);
|
||||
self.rows = groups
|
||||
self.groups = groups
|
||||
.iter()
|
||||
.flat_map(|g| {
|
||||
std::iter::once(DraftRow {
|
||||
stale: projects::is_stale(g.main, now),
|
||||
indent: false,
|
||||
project: g.main.clone(),
|
||||
})
|
||||
.chain(g.worktrees.iter().map(|wt| DraftRow {
|
||||
stale: projects::is_stale(wt, now),
|
||||
indent: true,
|
||||
project: (*wt).clone(),
|
||||
}))
|
||||
.map(|g| DraftGroup {
|
||||
main: g.main.clone(),
|
||||
main_stale: projects::is_stale(g.main, now),
|
||||
worktrees: g
|
||||
.worktrees
|
||||
.iter()
|
||||
.map(|wt| DraftWorktreeRow {
|
||||
project: (*wt).clone(),
|
||||
stale: projects::is_stale(wt, now),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect();
|
||||
self.highlighted = self.highlighted.min(self.entry_count().saturating_sub(1));
|
||||
@@ -320,18 +400,23 @@ impl DraftPane {
|
||||
self.repo_missing = false;
|
||||
self.probe_seq += 1;
|
||||
self.needs_probe = false;
|
||||
self.probe_due_at = None;
|
||||
self.probed_cwd = self.cwd();
|
||||
self.branches_for = None;
|
||||
self.schedule_branch_load();
|
||||
}
|
||||
|
||||
/// A target with an unknown repo (raw path edit or the Other… hatch).
|
||||
/// Cancels any in-flight probe and requests a fresh one.
|
||||
/// Cancels any in-flight probe and schedules a fresh one after the
|
||||
/// debounce window (#74).
|
||||
fn note_unknown_repo(&mut self) {
|
||||
self.base_repo = None;
|
||||
self.repo_missing = false;
|
||||
self.probe_seq += 1;
|
||||
self.needs_probe = self.worktree_enabled && self.cwd().is_some();
|
||||
self.probe_due_at = self
|
||||
.needs_probe
|
||||
.then(|| Instant::now() + Duration::from_millis(GITROOT_PROBE_DEBOUNCE_MS));
|
||||
self.branches_for = None;
|
||||
self.schedule_branch_load();
|
||||
}
|
||||
@@ -342,14 +427,19 @@ impl DraftPane {
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume the gitroot-probe request: the caller fetches
|
||||
/// Consume the gitroot-probe request once its debounce window has passed
|
||||
/// (`now` at or after [`Self::probe_due_at`]): the caller fetches
|
||||
/// `GET /api/gitroot?path=…` and reports back via [`Self::resolve_gitroot`]
|
||||
/// with the same seq.
|
||||
pub fn take_gitroot_probe(&mut self) -> Option<(u32, PathBuf)> {
|
||||
/// with the same seq. Returns `None` while typing is still in flight.
|
||||
pub fn take_gitroot_probe(&mut self, now: Instant) -> Option<(u32, PathBuf)> {
|
||||
if !self.needs_probe {
|
||||
return None;
|
||||
}
|
||||
if now < self.probe_due_at? {
|
||||
return None;
|
||||
}
|
||||
self.needs_probe = false;
|
||||
self.probe_due_at = None;
|
||||
let cwd = self.cwd()?;
|
||||
self.probe_seq += 1;
|
||||
Some((self.probe_seq, cwd))
|
||||
@@ -363,6 +453,7 @@ impl DraftPane {
|
||||
return;
|
||||
}
|
||||
self.needs_probe = false;
|
||||
self.probe_due_at = None;
|
||||
self.probed_cwd = self.cwd();
|
||||
match root {
|
||||
Some(repo) => {
|
||||
@@ -434,7 +525,9 @@ impl DraftPane {
|
||||
}
|
||||
if self.base_repo.is_none() {
|
||||
self.probe_seq += 1;
|
||||
// A discrete toggle, not a keystroke burst: due immediately.
|
||||
self.needs_probe = cwd.is_some();
|
||||
self.probe_due_at = self.needs_probe.then(Instant::now);
|
||||
}
|
||||
self.schedule_branch_load();
|
||||
}
|
||||
@@ -549,7 +642,6 @@ impl DraftPane {
|
||||
WorktreeSpec {
|
||||
base_repo,
|
||||
branch: branch.to_string(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
}
|
||||
}
|
||||
@@ -562,7 +654,6 @@ impl DraftPane {
|
||||
WorktreeSpec {
|
||||
base_repo,
|
||||
branch: name,
|
||||
cleanup_on_kill: true,
|
||||
create_branch: false,
|
||||
}
|
||||
}
|
||||
@@ -578,6 +669,8 @@ impl DraftPane {
|
||||
order.push(DraftField::DirPicker);
|
||||
if self.cwd_raw_visible {
|
||||
order.push(DraftField::CwdRaw);
|
||||
} else if self.selected_group_key.is_some() {
|
||||
order.push(DraftField::WorktreeChoice);
|
||||
}
|
||||
order.push(DraftField::Name);
|
||||
order.push(DraftField::WorktreeToggle);
|
||||
@@ -619,18 +712,25 @@ impl DraftPane {
|
||||
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
|
||||
}
|
||||
|
||||
/// Rows surviving the type-to-filter, in display order.
|
||||
pub fn visible_rows(&self) -> Vec<&DraftRow> {
|
||||
/// Step-one boxes surviving the type-to-filter, in display order. A
|
||||
/// group survives when the query matches its box or any of its
|
||||
/// worktrees (#96 rule 2), so a worktree name still finds its box.
|
||||
pub fn visible_groups(&self) -> Vec<&DraftGroup> {
|
||||
let query = self.filter.value();
|
||||
self.rows
|
||||
self.groups
|
||||
.iter()
|
||||
.filter(|r| projects::matches_filter(&r.project, query))
|
||||
.filter(|g| {
|
||||
projects::matches_filter(&g.main, query)
|
||||
|| g.worktrees
|
||||
.iter()
|
||||
.any(|w| projects::matches_filter(&w.project, query))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Visible entries including the trailing implicit `Other directory…`.
|
||||
pub fn entry_count(&self) -> usize {
|
||||
self.visible_rows().len() + 1
|
||||
self.visible_groups().len() + 1
|
||||
}
|
||||
|
||||
/// Highlight clamped to the current entry count.
|
||||
@@ -646,30 +746,47 @@ impl DraftPane {
|
||||
self.highlighted = ((self.selection() as i32) + delta).rem_euclid(count) as usize;
|
||||
}
|
||||
|
||||
/// Act on the highlighted picker entry: a project becomes the working
|
||||
/// directory (and pre-selects the command it was last used with), while
|
||||
/// the trailing entry reveals the free-text path input.
|
||||
/// The step-one group currently picked, looked up fresh against the
|
||||
/// latest snapshot so a project-list refresh can't leave a stale clone
|
||||
/// behind.
|
||||
pub fn selected_group(&self) -> Option<DraftGroup> {
|
||||
let key = self.selected_group_key.as_ref()?;
|
||||
self.groups
|
||||
.iter()
|
||||
.find(|g| &group_key(&g.main) == key)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Step two's rows: "+ new" first, then the repository's worktrees
|
||||
/// newest-first (#96 rule 3). Empty while no group is picked.
|
||||
pub fn worktree_choice_rows(&self) -> Vec<(WorktreeChoice, String, String)> {
|
||||
let Some(group) = self.selected_group() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut rows = vec![(
|
||||
WorktreeChoice::New,
|
||||
"+ new".to_string(),
|
||||
format!("new worktree of {}", group.main.name),
|
||||
)];
|
||||
rows.extend(worktree_rows_of(&group).into_iter().map(|p| {
|
||||
(
|
||||
WorktreeChoice::Existing(p.path.clone()),
|
||||
p.name.clone(),
|
||||
p.path.to_string_lossy().into_owned(),
|
||||
)
|
||||
}));
|
||||
rows
|
||||
}
|
||||
|
||||
/// Act on the highlighted step-one box: reveals step two, defaulting per
|
||||
/// rule 4. The trailing entry reveals the free-text path input and
|
||||
/// clears both steps (rule 3).
|
||||
pub fn select_entry(&mut self) {
|
||||
let visible = self.visible_rows();
|
||||
let visible = self.visible_groups();
|
||||
let sel = self.selection();
|
||||
if sel < visible.len() {
|
||||
let project = visible[sel].project.clone();
|
||||
self.picked_cwd = Some(project.path.clone());
|
||||
self.cwd_raw_visible = false;
|
||||
// The scan already knows this directory's repo (#61): no probe
|
||||
// round trip needed, exactly like the web form.
|
||||
let repo = project
|
||||
.base_repo
|
||||
.clone()
|
||||
.unwrap_or_else(|| project.path.clone());
|
||||
if let Some(cmd) = project.last_command.as_deref() {
|
||||
if !cmd.is_empty() {
|
||||
if let Some(idx) = self.presets.iter().position(|p| p.command == cmd) {
|
||||
self.selected_preset = idx;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.note_picked_repo(repo);
|
||||
let group = visible[sel].clone();
|
||||
self.select_group(&group);
|
||||
return;
|
||||
}
|
||||
let seed = self.cwd().unwrap_or_else(|| {
|
||||
@@ -679,6 +796,7 @@ impl DraftPane {
|
||||
PathBuf::from(&self.home_dir)
|
||||
}
|
||||
});
|
||||
self.clear_project_selection();
|
||||
self.picked_cwd = None;
|
||||
self.cwd_raw_visible = true;
|
||||
self.cwd_raw = crate::text_input::TextInput::with_value(seed.to_string_lossy());
|
||||
@@ -686,6 +804,95 @@ impl DraftPane {
|
||||
self.note_unknown_repo();
|
||||
}
|
||||
|
||||
/// Pick a step-one box: remember it and default step two (#96 rule 4) —
|
||||
/// "+ new", except a box that is itself a linked worktree (the scan
|
||||
/// found no main checkout for it), which defaults to that worktree.
|
||||
fn select_group(&mut self, group: &DraftGroup) {
|
||||
self.selected_group_key = Some(group_key(&group.main));
|
||||
let default = if group.main.is_worktree {
|
||||
WorktreeChoice::Existing(group.main.path.clone())
|
||||
} else {
|
||||
WorktreeChoice::New
|
||||
};
|
||||
self.apply_worktree_choice(default);
|
||||
}
|
||||
|
||||
/// Clear both picker steps (#96 rule 3), e.g. for `Other directory…`.
|
||||
fn clear_project_selection(&mut self) {
|
||||
self.selected_group_key = None;
|
||||
self.worktree_choice = WorktreeChoice::New;
|
||||
}
|
||||
|
||||
/// Apply a step-two pick (#96 rule 5): "+ new" targets the repository
|
||||
/// itself with worktree creation on; an existing worktree targets it
|
||||
/// directly with worktree creation off — the daemon never adopts
|
||||
/// (docs/WORKTREES.md). Neither path probes `/api/gitroot` (rule 6): the
|
||||
/// scan already reported this target's repository.
|
||||
pub fn apply_worktree_choice(&mut self, choice: WorktreeChoice) {
|
||||
let Some(group) = self.selected_group() else {
|
||||
return;
|
||||
};
|
||||
let target = match &choice {
|
||||
WorktreeChoice::New => group.main.clone(),
|
||||
WorktreeChoice::Existing(path) => {
|
||||
match worktree_rows_of(&group)
|
||||
.into_iter()
|
||||
.find(|p| &p.path == path)
|
||||
{
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
}
|
||||
}
|
||||
};
|
||||
self.worktree_choice = choice.clone();
|
||||
self.picked_cwd = Some(target.path.clone());
|
||||
self.cwd_raw_visible = false;
|
||||
if let Some(cmd) = target.last_command.as_deref() {
|
||||
if !cmd.is_empty() {
|
||||
if let Some(idx) = self.presets.iter().position(|p| p.command == cmd) {
|
||||
self.selected_preset = idx;
|
||||
}
|
||||
}
|
||||
}
|
||||
match choice {
|
||||
WorktreeChoice::New => {
|
||||
self.worktree_enabled = true;
|
||||
let repo = target
|
||||
.base_repo
|
||||
.clone()
|
||||
.unwrap_or_else(|| target.path.clone());
|
||||
self.note_picked_repo(repo);
|
||||
}
|
||||
WorktreeChoice::Existing(_) => {
|
||||
self.worktree_enabled = false;
|
||||
self.base_repo = None;
|
||||
self.repo_missing = false;
|
||||
self.probe_seq += 1;
|
||||
self.needs_probe = false;
|
||||
self.probe_due_at = None;
|
||||
self.probed_cwd = self.cwd();
|
||||
self.branches_for = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move step two's highlight and apply the newly-highlighted choice at
|
||||
/// once, matching the web form's per-click pick — there is no separate
|
||||
/// confirm step.
|
||||
pub fn move_worktree_choice(&mut self, delta: i32) {
|
||||
let rows = self.worktree_choice_rows();
|
||||
let count = rows.len() as i32;
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
let cur = rows
|
||||
.iter()
|
||||
.position(|(c, _, _)| *c == self.worktree_choice)
|
||||
.unwrap_or(0) as i32;
|
||||
let next = ((cur + delta).rem_euclid(count)) as usize;
|
||||
self.apply_worktree_choice(rows[next].0.clone());
|
||||
}
|
||||
|
||||
/// Handle one keystroke while this draft owns input (Insert mode, pane
|
||||
/// focused). Returns [`DraftInputResult::Submit`] when the user accepts;
|
||||
/// the caller validates, fires the POST and replaces the draft with the
|
||||
@@ -733,6 +940,14 @@ impl DraftPane {
|
||||
self.move_selection(1);
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
InputKey::Up if self.focused_field == DraftField::WorktreeChoice => {
|
||||
self.move_worktree_choice(-1);
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
InputKey::Down if self.focused_field == DraftField::WorktreeChoice => {
|
||||
self.move_worktree_choice(1);
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
InputKey::Left if self.focused_field == DraftField::Preset => {
|
||||
self.cycle_preset(-1);
|
||||
DraftInputResult::Consumed
|
||||
@@ -805,6 +1020,15 @@ impl DraftPane {
|
||||
|
||||
let show_custom = self.is_custom();
|
||||
let show_raw = self.cwd_raw_visible;
|
||||
let show_wt_choice = self.selected_group_key.is_some() && !show_raw;
|
||||
let wt_choice_rows_data = if show_wt_choice {
|
||||
self.worktree_choice_rows()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
// Up to four choices (new + worktrees) stay visible around the
|
||||
// selection.
|
||||
let wt_choice_capacity = wt_choice_rows_data.len().clamp(1, 4) as u16;
|
||||
let show_wt_body = self.worktree_body_visible();
|
||||
// Up to three branch rows stay visible around the selection; held
|
||||
// branches need their reason on screen, so they aren't collapsed.
|
||||
@@ -812,6 +1036,7 @@ impl DraftPane {
|
||||
let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* worktree */ + 1 /* spawn */
|
||||
+ u16::from(show_custom)
|
||||
+ u16::from(show_raw)
|
||||
+ u16::from(show_wt_choice) * wt_choice_capacity
|
||||
+ u16::from(show_wt_body) * (1 + if self.branch_mode == WorktreeBranchMode::New { 1 } else { branch_rows })
|
||||
+ u16::from(self.error.is_some());
|
||||
let list_capacity = (inner.height.saturating_sub(fixed)).max(1) as usize;
|
||||
@@ -824,6 +1049,8 @@ impl DraftPane {
|
||||
constraints.push(Constraint::Length(list_capacity as u16));
|
||||
if show_raw {
|
||||
constraints.push(Constraint::Length(1));
|
||||
} else if show_wt_choice {
|
||||
constraints.push(Constraint::Length(wt_choice_capacity));
|
||||
}
|
||||
constraints.push(Constraint::Length(1)); // name
|
||||
constraints.push(Constraint::Length(1)); // worktree toggle
|
||||
@@ -900,7 +1127,10 @@ impl DraftPane {
|
||||
cursor = Some((col, filter_row.y));
|
||||
}
|
||||
|
||||
let visible = self.visible_rows();
|
||||
// Step one (#96 rule 1): one box per repository, name then the FULL
|
||||
// path — not the abbreviated form other lists use — so two projects
|
||||
// sharing a basename stay distinguishable.
|
||||
let visible = self.visible_groups();
|
||||
let sel = self.selection();
|
||||
let count = visible.len() + 1;
|
||||
let capacity = list_capacity.min(count);
|
||||
@@ -913,7 +1143,7 @@ impl DraftPane {
|
||||
.iter()
|
||||
.take(start + capacity)
|
||||
.skip(start)
|
||||
.map(|row| row.project.name.chars().count() + usize::from(row.indent) * 2)
|
||||
.map(|group| group.main.name.chars().count())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.min(18);
|
||||
@@ -926,31 +1156,19 @@ impl DraftPane {
|
||||
}
|
||||
let active = pane_focused && self.focused_field == DraftField::DirPicker && i == sel;
|
||||
let line = if i < visible.len() {
|
||||
let row = visible[i];
|
||||
let group = visible[i];
|
||||
let mut base = Style::default();
|
||||
if row.stale {
|
||||
if group.main_stale {
|
||||
base = base.add_modifier(Modifier::DIM);
|
||||
}
|
||||
if active {
|
||||
base = base.add_modifier(Modifier::REVERSED);
|
||||
}
|
||||
let name = if row.indent {
|
||||
format!(" {}", row.project.name)
|
||||
} else {
|
||||
row.project.name.clone()
|
||||
};
|
||||
Line::from(vec![
|
||||
Span::styled(if active { ">" } else { " " }, base),
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("{name:<name_width$}"), base),
|
||||
Span::styled(
|
||||
format!(" {}", abbreviate_path(&row.project.path, &self.home_dir)),
|
||||
base,
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {}", row.project.last_command.clone().unwrap_or_default()),
|
||||
base,
|
||||
),
|
||||
Span::styled(format!("{:<name_width$}", group.main.name), base),
|
||||
Span::styled(format!(" {}", group.main.path.display()), base),
|
||||
])
|
||||
} else {
|
||||
let mut style = Style::default();
|
||||
@@ -978,6 +1196,53 @@ impl DraftPane {
|
||||
if self.focused_field == DraftField::CwdRaw {
|
||||
cursor = Some((col, row.y));
|
||||
}
|
||||
} else if show_wt_choice {
|
||||
// Step two (#96 rule 3): "+ new" first, then the repository's
|
||||
// worktrees newest-first.
|
||||
let choice_rows = wt_choice_rows_data;
|
||||
let csel = choice_rows
|
||||
.iter()
|
||||
.position(|(c, _, _)| *c == self.worktree_choice)
|
||||
.unwrap_or(0);
|
||||
let ccap = (wt_choice_capacity as usize).min(choice_rows.len().max(1));
|
||||
let cstart = if csel >= ccap { csel + 1 - ccap } else { 0 };
|
||||
let label_width = choice_rows
|
||||
.iter()
|
||||
.skip(cstart)
|
||||
.take(ccap)
|
||||
.map(|(_, label, _)| label.chars().count())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.min(18);
|
||||
let choice_row = next_row();
|
||||
for i in cstart..cstart + ccap {
|
||||
let y = choice_row.y + (i - cstart) as u16;
|
||||
if y >= choice_row.y + choice_row.height || i >= choice_rows.len() {
|
||||
break;
|
||||
}
|
||||
let (_, label, sub) = &choice_rows[i];
|
||||
let active =
|
||||
pane_focused && self.focused_field == DraftField::WorktreeChoice && i == csel;
|
||||
let mut base = Style::default();
|
||||
if active {
|
||||
base = base.add_modifier(Modifier::REVERSED);
|
||||
}
|
||||
let line = Line::from(vec![
|
||||
Span::styled(if active { ">" } else { " " }, base),
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("{label:<label_width$}"), base),
|
||||
Span::styled(format!(" {sub}"), base),
|
||||
]);
|
||||
f.render_widget(
|
||||
Paragraph::new(line),
|
||||
Rect {
|
||||
x: choice_row.x + GUTTER as u16,
|
||||
y,
|
||||
width: choice_row.width.saturating_sub(GUTTER as u16),
|
||||
height: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -- name + reroll ---------------------------------------------------------
|
||||
@@ -1274,6 +1539,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A moment comfortably past the probe debounce window (#74).
|
||||
fn probe_due() -> Instant {
|
||||
Instant::now() + Duration::from_millis(GITROOT_PROBE_DEBOUNCE_MS) * 10
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_fall_back_to_builtin_presets_with_custom_last() {
|
||||
let draft = DraftPane::default();
|
||||
@@ -1373,12 +1643,8 @@ mod tests {
|
||||
let stale_pos = rendered.find("stale-repo").unwrap();
|
||||
assert!(fresh_pos < stale_pos, "fresh before stale:\n{rendered}");
|
||||
assert!(
|
||||
rendered.contains("~/n/repo"),
|
||||
"collapsed path stem:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("claude"),
|
||||
"last command shown:\n{rendered}"
|
||||
rendered.contains("/home/u/new/repo"),
|
||||
"the full path, not an abbreviation (#96 rule 1):\n{rendered}"
|
||||
);
|
||||
|
||||
// Staleness dims the row's cells.
|
||||
@@ -1407,7 +1673,7 @@ mod tests {
|
||||
assert_eq!(draft.focused_field, DraftField::DirPicker);
|
||||
typed(&mut draft, "maes");
|
||||
assert_eq!(
|
||||
draft.visible_rows().len(),
|
||||
draft.visible_groups().len(),
|
||||
1,
|
||||
"type-to-filter narrows the list"
|
||||
);
|
||||
@@ -1461,7 +1727,7 @@ mod tests {
|
||||
typed(&mut draft, "/opt/tooling");
|
||||
assert_eq!(draft.cwd(), Some(PathBuf::from("/home/u/opt/tooling")));
|
||||
// The raw target probed clean, so the worktree spec carries its repo.
|
||||
let (seq, _) = draft.take_gitroot_probe().unwrap();
|
||||
let (seq, _) = draft.take_gitroot_probe(probe_due()).unwrap();
|
||||
draft.resolve_gitroot(seq, Some(PathBuf::from("/home/u/opt/tooling")));
|
||||
draft.seed_names(vec!["amber-falcon".into()]);
|
||||
let req = draft.build_request().unwrap();
|
||||
@@ -1682,14 +1948,148 @@ mod tests {
|
||||
Some(Path::new("/repos/maestro")),
|
||||
"a picked project resolves its base repo without a probe"
|
||||
);
|
||||
assert!(draft.take_gitroot_probe().is_none());
|
||||
assert!(draft.take_gitroot_probe(probe_due()).is_none());
|
||||
draft.seed_names(vec!["crimson-otter".into()]);
|
||||
|
||||
let wt = draft.build_request().unwrap().worktree.unwrap();
|
||||
assert_eq!(wt.base_repo, PathBuf::from("/repos/maestro"));
|
||||
assert_eq!(wt.branch, "crimson-otter", "branch defaults to the name");
|
||||
assert!(wt.create_branch);
|
||||
assert!(wt.cleanup_on_kill);
|
||||
}
|
||||
|
||||
/// A worktree of `group.main` (matching a solo worktree, is_worktree true).
|
||||
fn linked_worktree(name: &str, path: &str, base: &str, days_ago: i64) -> Project {
|
||||
let mut wt = repo_project(name, path, base, days_ago, None);
|
||||
wt.is_worktree = true;
|
||||
wt
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_query_matching_only_a_worktree_still_finds_its_repository_box() {
|
||||
// #96 rule 2: the filter keeps a repository when the query matches
|
||||
// it or any of its worktrees.
|
||||
let mut draft = DraftPane::default();
|
||||
draft.set_projects(
|
||||
vec![
|
||||
repo_project("main", "/repos/main", "/repos/main", 2, None),
|
||||
linked_worktree("wt-91", "/repos/wt91", "/repos/main", 1),
|
||||
],
|
||||
Utc::now(),
|
||||
);
|
||||
draft.focused_field = DraftField::DirPicker;
|
||||
typed(&mut draft, "wt-91");
|
||||
assert_eq!(
|
||||
draft.visible_groups().len(),
|
||||
1,
|
||||
"the worktree's name matches its repository's box"
|
||||
);
|
||||
assert_eq!(draft.visible_groups()[0].main.name, "main");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picking_a_repository_box_reveals_step_two_defaulting_to_new() {
|
||||
// #96 rule 3 (reveal) + rule 4 (default): a repository box that is
|
||||
// itself a real main checkout defaults step two to "+ new".
|
||||
let mut draft = DraftPane::default();
|
||||
draft.set_projects(
|
||||
vec![
|
||||
repo_project("main", "/repos/main", "/repos/main", 2, None),
|
||||
linked_worktree("wt-91", "/repos/wt91", "/repos/main", 1),
|
||||
],
|
||||
Utc::now(),
|
||||
);
|
||||
draft.focused_field = DraftField::DirPicker;
|
||||
draft.on_input(&InputKey::Enter); // picks the only box: "main"
|
||||
|
||||
assert_eq!(draft.worktree_choice, WorktreeChoice::New);
|
||||
assert_eq!(draft.picked_cwd.as_deref(), Some(Path::new("/repos/main")));
|
||||
assert!(draft.worktree_enabled, "\"+ new\" means create-worktree on");
|
||||
assert!(
|
||||
draft.field_order().contains(&DraftField::WorktreeChoice),
|
||||
"step two is now reachable"
|
||||
);
|
||||
assert!(
|
||||
draft.take_gitroot_probe(probe_due()).is_none(),
|
||||
"rule 6: the scan already knows this repository"
|
||||
);
|
||||
|
||||
// Step two: move onto the worktree and pick it (rule 5 — existing
|
||||
// worktree means run in it, create-worktree off).
|
||||
draft.focused_field = DraftField::WorktreeChoice;
|
||||
draft.on_input(&InputKey::Down);
|
||||
assert_eq!(
|
||||
draft.worktree_choice,
|
||||
WorktreeChoice::Existing(PathBuf::from("/repos/wt91"))
|
||||
);
|
||||
assert_eq!(draft.picked_cwd.as_deref(), Some(Path::new("/repos/wt91")));
|
||||
assert!(!draft.worktree_enabled);
|
||||
assert!(
|
||||
draft.build_request().unwrap().worktree.is_none(),
|
||||
"no worktree record — the session just runs there"
|
||||
);
|
||||
assert!(
|
||||
draft.take_gitroot_probe(probe_due()).is_none(),
|
||||
"rule 6: still no probe for a scan-known worktree"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_that_is_itself_a_linked_worktree_defaults_to_running_there() {
|
||||
// #96 rule 4: when the scan found no main checkout, the box IS a
|
||||
// worktree, and step two defaults to that worktree instead of
|
||||
// "+ new".
|
||||
let mut draft = DraftPane::default();
|
||||
draft.set_projects(
|
||||
vec![linked_worktree(
|
||||
"solo-wt",
|
||||
"/repos/solo-wt",
|
||||
"/repos/solo",
|
||||
1,
|
||||
)],
|
||||
Utc::now(),
|
||||
);
|
||||
draft.focused_field = DraftField::DirPicker;
|
||||
draft.on_input(&InputKey::Enter);
|
||||
|
||||
assert_eq!(
|
||||
draft.worktree_choice,
|
||||
WorktreeChoice::Existing(PathBuf::from("/repos/solo-wt"))
|
||||
);
|
||||
assert_eq!(
|
||||
draft.picked_cwd.as_deref(),
|
||||
Some(Path::new("/repos/solo-wt"))
|
||||
);
|
||||
assert!(!draft.worktree_enabled, "defaults to running in place");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_directory_clears_both_picker_steps() {
|
||||
// #96 rule 3.
|
||||
let mut draft = DraftPane::default();
|
||||
draft.set_projects(
|
||||
vec![
|
||||
repo_project("main", "/repos/main", "/repos/main", 2, None),
|
||||
linked_worktree("wt-91", "/repos/wt91", "/repos/main", 1),
|
||||
],
|
||||
Utc::now(),
|
||||
);
|
||||
draft.focused_field = DraftField::DirPicker;
|
||||
draft.on_input(&InputKey::Enter); // picks "main", reveals step two
|
||||
assert!(draft.selected_group_key.is_some());
|
||||
|
||||
draft.focused_field = DraftField::DirPicker;
|
||||
draft.move_selection(1); // onto Other directory…
|
||||
draft.on_input(&InputKey::Enter);
|
||||
|
||||
assert!(draft.cwd_raw_visible);
|
||||
assert!(
|
||||
draft.selected_group_key.is_none(),
|
||||
"step one's pick is cleared"
|
||||
);
|
||||
assert!(
|
||||
draft.worktree_choice_rows().is_empty(),
|
||||
"step two is cleared too"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1701,13 +2101,18 @@ mod tests {
|
||||
draft.on_input(&InputKey::Enter); // no projects → Other directory…
|
||||
typed(&mut draft, "/opt/tool");
|
||||
|
||||
let (seq, path) = draft.take_gitroot_probe().expect("probe requested");
|
||||
let (seq, path) = draft
|
||||
.take_gitroot_probe(probe_due())
|
||||
.expect("probe requested");
|
||||
assert_eq!(path, PathBuf::from("/opt/tool"));
|
||||
assert!(draft.take_gitroot_probe().is_none(), "one probe at a time");
|
||||
assert!(
|
||||
draft.take_gitroot_probe(probe_due()).is_none(),
|
||||
"one probe at a time"
|
||||
);
|
||||
|
||||
// Further typing supersedes the in-flight probe…
|
||||
typed(&mut draft, "-x");
|
||||
let (seq2, _) = draft.take_gitroot_probe().unwrap();
|
||||
let (seq2, _) = draft.take_gitroot_probe(probe_due()).unwrap();
|
||||
assert_ne!(seq2, seq);
|
||||
// …so a late answer to the old one must be ignored.
|
||||
draft.resolve_gitroot(seq, Some(PathBuf::from("/somewhere/else")));
|
||||
@@ -1728,7 +2133,7 @@ mod tests {
|
||||
};
|
||||
draft.on_input(&InputKey::Enter);
|
||||
typed(&mut draft, "/not/a/repo");
|
||||
let (seq, _) = draft.take_gitroot_probe().unwrap();
|
||||
let (seq, _) = draft.take_gitroot_probe(probe_due()).unwrap();
|
||||
|
||||
draft.resolve_gitroot(seq, None);
|
||||
assert!(!draft.worktree_enabled);
|
||||
@@ -1872,6 +2277,61 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_keystroke_burst_schedules_one_debounced_gitroot_probe() {
|
||||
let mut draft = DraftPane {
|
||||
focused_field: DraftField::DirPicker,
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.on_input(&InputKey::Enter); // no projects → Other directory…
|
||||
|
||||
// Burst: every keystroke reschedules; nothing may fire inside the
|
||||
// debounce window (#74).
|
||||
for c in "/opt/tooling".chars() {
|
||||
draft.on_input(&InputKey::char(c));
|
||||
assert!(
|
||||
draft.take_gitroot_probe(Instant::now()).is_none(),
|
||||
"no probe while typing is still in flight"
|
||||
);
|
||||
}
|
||||
|
||||
// Once the quiet period passes, exactly one probe fires.
|
||||
let (seq, path) = draft
|
||||
.take_gitroot_probe(probe_due())
|
||||
.expect("probe due after the quiet period");
|
||||
assert_eq!(path, PathBuf::from("/opt/tooling"));
|
||||
assert!(
|
||||
draft.take_gitroot_probe(probe_due()).is_none(),
|
||||
"one probe total"
|
||||
);
|
||||
|
||||
// The answer still lands through the unchanged seq guard.
|
||||
draft.resolve_gitroot(seq, Some(PathBuf::from("/opt/tooling")));
|
||||
assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/opt/tooling")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_probe_answer_cannot_overwrite_a_newer_target_after_the_debounce() {
|
||||
let mut draft = DraftPane {
|
||||
focused_field: DraftField::DirPicker,
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.on_input(&InputKey::Enter); // Other directory…
|
||||
|
||||
typed(&mut draft, "/repo/old");
|
||||
let (old_seq, _) = draft.take_gitroot_probe(probe_due()).unwrap();
|
||||
|
||||
// Retyping supersedes the in-flight probe and gets a newer seq…
|
||||
typed(&mut draft, "/repo/new");
|
||||
let (new_seq, _) = draft.take_gitroot_probe(probe_due()).unwrap();
|
||||
assert_ne!(new_seq, old_seq);
|
||||
|
||||
// …so the late answer to the old target must be ignored.
|
||||
draft.resolve_gitroot(new_seq, Some(PathBuf::from("/repo/new")));
|
||||
draft.resolve_gitroot(old_seq, Some(PathBuf::from("/repo/old")));
|
||||
assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/repo/new")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggling_worktree_off_spawns_in_the_checkout_itself() {
|
||||
let mut draft = DraftPane::default();
|
||||
@@ -1897,7 +2357,7 @@ mod tests {
|
||||
draft.on_input(&InputKey::Enter);
|
||||
assert!(draft.worktree_enabled);
|
||||
assert!(
|
||||
draft.take_gitroot_probe().is_none(),
|
||||
draft.take_gitroot_probe(probe_due()).is_none(),
|
||||
"re-enabling reuses the known repo for an unchanged target"
|
||||
);
|
||||
}
|
||||
@@ -1940,6 +2400,45 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_shows_step_two_wording_full_paths_and_newest_first_order() {
|
||||
let mut draft = DraftPane {
|
||||
home_dir: "/home/u".into(),
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.set_projects(
|
||||
vec![
|
||||
repo_project("main", "/home/u/repos/main", "/home/u/repos/main", 5, None),
|
||||
linked_worktree("older-wt", "/home/u/repos/older", "/home/u/repos/main", 3),
|
||||
linked_worktree("newer-wt", "/home/u/repos/newer", "/home/u/repos/main", 1),
|
||||
],
|
||||
Utc::now(),
|
||||
);
|
||||
draft.focused_field = DraftField::DirPicker;
|
||||
draft.on_input(&InputKey::Enter); // picks the "main" box
|
||||
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||
|
||||
assert!(
|
||||
rendered.contains("/home/u/repos/main"),
|
||||
"step one shows the full repository path:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("+ new"),
|
||||
"step two's create choice:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("/home/u/repos/newer"),
|
||||
"step two shows worktrees' full paths:\n{rendered}"
|
||||
);
|
||||
let new_pos = rendered.find("+ new").unwrap();
|
||||
let newer_pos = rendered.find("newer-wt").unwrap();
|
||||
let older_pos = rendered.find("older-wt").unwrap();
|
||||
assert!(
|
||||
new_pos < newer_pos && newer_pos < older_pos,
|
||||
"\"+ new\" first, then worktrees newest-first:\n{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_shows_the_worktree_section() {
|
||||
let mut draft = DraftPane {
|
||||
|
||||
@@ -80,7 +80,6 @@ impl FastForwardDefaults {
|
||||
.clone()
|
||||
.unwrap_or_else(|| project.path.clone()),
|
||||
branch: name.to_string(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -117,7 +117,9 @@ impl InputKey {
|
||||
if *alt {
|
||||
out.push_str("alt-");
|
||||
}
|
||||
if *shift {
|
||||
// Uppercase already implies shift; crossterm sets the flag
|
||||
// on every such char, so skip the redundant prefix (#70).
|
||||
if *shift && !c.is_uppercase() {
|
||||
out.push_str("shift-");
|
||||
}
|
||||
out.push(*c);
|
||||
@@ -580,12 +582,28 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn canonical_round_trips_simple_chars() {
|
||||
for s in ["a", "ctrl-a", "ctrl-w", "alt-x", "shift-A"] {
|
||||
for s in ["a", "ctrl-a", "ctrl-w", "alt-x"] {
|
||||
let k = InputKey::parse(s).unwrap();
|
||||
assert_eq!(k.canonical(), s, "round-trip {s:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// crossterm 0.28 attaches `KeyModifiers::SHIFT` to any uppercase char,
|
||||
/// so a real Shift+S delivers `Char { c: 'S', shift: true }`. Its
|
||||
/// canonical form must be the bare `"S"` a keymap binds, not `"shift-S"` —
|
||||
/// the uppercase letter already says "shifted"; a redundant prefix would
|
||||
/// make the default `S` binding unreachable from the keyboard (#70).
|
||||
#[test]
|
||||
fn shift_flag_is_redundant_for_an_already_uppercase_char() {
|
||||
let k = InputKey::Char {
|
||||
c: 'S',
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: true,
|
||||
};
|
||||
assert_eq!(k.canonical(), "S");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_round_trips_specials() {
|
||||
for s in [
|
||||
|
||||
@@ -14,23 +14,28 @@ use ratatui::Frame;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KillConfirm {
|
||||
pub label: String,
|
||||
/// #83: what the daemon's watcher last saw in the worktree, phrased by
|
||||
/// [`maestro_protocol::InTheWay`]'s Display — so "this will be refused"
|
||||
/// is visible before the attempt. `None` while nothing blocks.
|
||||
pub blockers: Option<String>,
|
||||
}
|
||||
|
||||
impl KillConfirm {
|
||||
pub fn render(&self, f: &mut Frame, parent: Rect) {
|
||||
let area = centered(parent, 50, 5);
|
||||
let area = centered(parent, 60, if self.blockers.is_some() { 6 } else { 5 });
|
||||
f.render_widget(Clear, area);
|
||||
let block = Block::default().title(" kill ").borders(Borders::ALL);
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
f.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(format!("Kill session {}?", self.label)),
|
||||
Line::from(""),
|
||||
Line::from("[y] yes [n] no"),
|
||||
]),
|
||||
inner,
|
||||
);
|
||||
let mut lines = vec![Line::from(format!("Kill session {}?", self.label))];
|
||||
if let Some(blockers) = &self.blockers {
|
||||
// Same phrasing the reclaim confirmation uses for its own guard.
|
||||
lines.push(Line::from(format!("Currently in the way: {blockers}")));
|
||||
lines.push(Line::from("The kill will be refused unless forced."));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from("[y] yes [n] no"));
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +126,7 @@ mod tests {
|
||||
fn kill_confirm_renders_prompt() {
|
||||
let modal = KillConfirm {
|
||||
label: "session-7".into(),
|
||||
blockers: None,
|
||||
};
|
||||
let rendered = render_to_string(60, 10, |f| modal.render(f, f.area()));
|
||||
assert!(rendered.contains("Kill"));
|
||||
@@ -129,6 +135,22 @@ mod tests {
|
||||
assert!(rendered.contains("no"));
|
||||
}
|
||||
|
||||
/// #83: when the worktree blocks, the confirm names it before the
|
||||
/// operator commits — in `InTheWay`'s own words.
|
||||
#[test]
|
||||
fn kill_confirm_names_what_blocks() {
|
||||
let modal = KillConfirm {
|
||||
label: "session-7".into(),
|
||||
blockers: Some("2 uncommitted files".into()),
|
||||
};
|
||||
let rendered = render_to_string(70, 10, |f| modal.render(f, f.area()));
|
||||
assert!(
|
||||
rendered.contains("Currently in the way: 2 uncommitted files"),
|
||||
"{rendered}"
|
||||
);
|
||||
assert!(rendered.contains("refused"), "{rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_overlay_lists_lines() {
|
||||
let help = HelpOverlay {
|
||||
|
||||
@@ -146,8 +146,19 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
|
||||
match inputs.state.overlay() {
|
||||
Some(OverlayKind::KillConfirm(id)) => {
|
||||
// #83: surface what the watcher last saw in the worktree, in
|
||||
// InTheWay's own words, before the operator commits to the kill.
|
||||
let blockers = inputs
|
||||
.sidebar
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|s| &s.id == id)
|
||||
.and_then(|s| s.blocked_work)
|
||||
.filter(|b| !b.is_clear())
|
||||
.map(|b| b.to_string());
|
||||
let kc = KillConfirm {
|
||||
label: id.to_string(),
|
||||
blockers,
|
||||
};
|
||||
kc.render(f, area);
|
||||
map.modal = Some(area);
|
||||
|
||||
@@ -52,6 +52,13 @@ impl Sidebar {
|
||||
(SessionStatus::Running, false) => "○",
|
||||
};
|
||||
let label = name_for(s);
|
||||
// #83: a session whose worktree blocks a kill gets a warning
|
||||
// glyph; the kill confirm names the specifics.
|
||||
let blocked = s
|
||||
.blocked_work
|
||||
.filter(|b| !b.is_clear())
|
||||
.map(|_| " ⚠")
|
||||
.unwrap_or_default();
|
||||
let style = if self.highlight == Some(s.id) {
|
||||
Style::default().add_modifier(Modifier::REVERSED)
|
||||
} else {
|
||||
@@ -59,7 +66,7 @@ impl Sidebar {
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("{glyph} {label}"), style),
|
||||
Span::styled(format!("{glyph} {label}{blocked}"), style),
|
||||
]));
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
@@ -99,6 +106,7 @@ mod tests {
|
||||
activity: Default::default(),
|
||||
last_activity_at: chrono::Utc::now(),
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +180,40 @@ mod tests {
|
||||
assert!(line.contains('✗'), "expected dead glyph in: {line:?}");
|
||||
}
|
||||
|
||||
/// #83: a session whose worktree blocks a kill carries the warning glyph;
|
||||
/// a clear or unprobed one does not.
|
||||
#[test]
|
||||
fn blocked_session_gets_a_warning_glyph() {
|
||||
use maestro_protocol::InTheWay;
|
||||
|
||||
let mut blocked = session_named(1, "claude");
|
||||
blocked.blocked_work = Some(InTheWay {
|
||||
unpushed_commits: 4,
|
||||
..InTheWay::default()
|
||||
});
|
||||
let mut clear = session_named(2, "bash");
|
||||
clear.blocked_work = Some(InTheWay::default());
|
||||
let unprobed = session_named(3, "vim");
|
||||
let sb = Sidebar {
|
||||
sessions: vec![blocked, clear, unprobed],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let rendered = render_to_string(&sb, 40, 8);
|
||||
let line = rendered
|
||||
.lines()
|
||||
.find(|l| l.contains("claude"))
|
||||
.expect("blocked row");
|
||||
assert!(line.contains('⚠'), "expected warning glyph in: {line:?}");
|
||||
for name in ["bash", "vim"] {
|
||||
let line = rendered
|
||||
.lines()
|
||||
.find(|l| l.contains(name))
|
||||
.expect("clear row");
|
||||
assert!(!line.contains('⚠'), "{name} must not warn: {line:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_command_when_name_metadata_is_absent() {
|
||||
let s = Session {
|
||||
@@ -188,6 +230,7 @@ mod tests {
|
||||
last_activity_at: chrono::Utc::now(),
|
||||
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
};
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
|
||||
inherit (finalAttrs) pname version src;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-Lhi41AtCK69SM4+7zih19+Njf7o2hyXrAV0W6VO9YxU=";
|
||||
hash = "sha256-D9JdgI92vzAY8en2zGEEPd4hwOLVi3sr1joT4b17Be4=";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { renderSidebar } from "./catalog.js";
|
||||
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||
import type { InTheWay, Session } from "./types.js";
|
||||
|
||||
// happy-dom runs without a backing store; renderSidebar reads the persisted
|
||||
// sidebar width on mount.
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: { getItem: () => null, setItem: () => {} },
|
||||
});
|
||||
// No daemon here; renderSidebar treats a failed info fetch as "absolute
|
||||
// paths are a fine fallback".
|
||||
window.fetch = () => Promise.reject(new Error("offline"));
|
||||
});
|
||||
|
||||
function session(overrides: Partial<Session>): Session {
|
||||
return {
|
||||
id: "11111111-2222-3333-4444-555555555555",
|
||||
cwd: "/home/naps/tea/maestro",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-08-24T10:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function storeWith(sessions: Session[]): CatalogStore {
|
||||
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
|
||||
return {
|
||||
snapshot: () => state,
|
||||
// Like the real store, deliver an immediate snapshot so the sidebar
|
||||
// renders its rows during construction.
|
||||
subscribe: (fn: CatalogListener) => {
|
||||
fn(state);
|
||||
return () => {};
|
||||
},
|
||||
} as unknown as CatalogStore;
|
||||
}
|
||||
|
||||
function rowFor(el: HTMLElement, id: string): HTMLElement | null {
|
||||
return el.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
|
||||
}
|
||||
|
||||
describe("blocked-work indicator (#83)", () => {
|
||||
it("marks the row of a blocked session with the shared phrasing", () => {
|
||||
const blocked = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000001",
|
||||
blocked_work: { uncommitted_files: 3, unpushed_commits: 2 },
|
||||
});
|
||||
const el = renderSidebar(storeWith([blocked])).el;
|
||||
const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked");
|
||||
expect(badge?.textContent).toContain("⚠");
|
||||
expect(badge?.textContent).toContain("3 uncommitted files, 2 unpushed commits");
|
||||
});
|
||||
|
||||
it("does not mark clear or unprobed sessions", () => {
|
||||
const clear = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000002",
|
||||
blocked_work: {} as InTheWay,
|
||||
});
|
||||
const unprobed = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000003",
|
||||
});
|
||||
const el = renderSidebar(storeWith([clear, unprobed])).el;
|
||||
expect(rowFor(el, clear.id)?.querySelector(".leaf-blocked")).toBeNull();
|
||||
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
|
||||
});
|
||||
|
||||
it("restates the warning at the kill confirm", async () => {
|
||||
const confirmations: string[] = [];
|
||||
window.confirm = (msg?: string) => {
|
||||
confirmations.push(msg ?? "");
|
||||
return false;
|
||||
};
|
||||
const blocked = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000004",
|
||||
command: "bash",
|
||||
blocked_work: { untracked_files: 1 },
|
||||
});
|
||||
const el = renderSidebar(storeWith([blocked])).el;
|
||||
const kill = rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill");
|
||||
kill?.click();
|
||||
await Promise.resolve();
|
||||
expect(confirmations).toHaveLength(1);
|
||||
expect(confirmations[0]).toContain("1 untracked file");
|
||||
expect(confirmations[0]).toContain("refused unless forced");
|
||||
});
|
||||
|
||||
it("confirms plainly when nothing blocks", async () => {
|
||||
const confirmations: string[] = [];
|
||||
window.confirm = (msg?: string) => {
|
||||
confirmations.push(msg ?? "");
|
||||
return false;
|
||||
};
|
||||
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" });
|
||||
const el = renderSidebar(storeWith([plain])).el;
|
||||
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||
await Promise.resolve();
|
||||
expect(confirmations).toEqual(['Kill session "claude"?']);
|
||||
});
|
||||
});
|
||||
+11
-2
@@ -3,7 +3,7 @@ import { getDaemonInfo, killSession } from "./api.js";
|
||||
import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
import type { Session } from "./types.js";
|
||||
import { abbreviateFolderName, sessionName } from "./types.js";
|
||||
import { abbreviateFolderName, formatInTheWay, sessionBlockers, sessionName } from "./types.js";
|
||||
import type { TreeNode } from "./tree.js";
|
||||
import { buildTree } from "./tree.js";
|
||||
import { createThemeToggle } from "./theme.js";
|
||||
@@ -64,10 +64,14 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
// The directory is the enclosing folder, so only surface the command — and
|
||||
// only when it isn't already what the name shows.
|
||||
const showCmd = s.command !== name;
|
||||
// #83: warn on the row itself, in `formatInTheWay`'s shared phrasing, so
|
||||
// "this will be refused" is visible before the kill is attempted.
|
||||
const blockers = sessionBlockers(s);
|
||||
row.innerHTML = `
|
||||
<span class="leaf-name">${escapeHtml(name)}</span>
|
||||
${showCmd ? `<span class="leaf-meta"><span class="leaf-cmd">${escapeHtml(s.command)}</span></span>` : ""}
|
||||
${running ? "" : `<span class="leaf-badge">exited</span>`}
|
||||
${blockers ? `<span class="leaf-badge leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>` : ""}
|
||||
<span class="leaf-actions">
|
||||
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
|
||||
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
|
||||
@@ -83,7 +87,12 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
killBtn.addEventListener("click", async (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (!confirm(`Kill session "${sessionName(s)}"?`)) return;
|
||||
// #83: the confirm restates the warning at the moment of decision.
|
||||
const blockers = sessionBlockers(s);
|
||||
const question = blockers
|
||||
? `Kill session "${name}"? Its worktree still has ${formatInTheWay(blockers)} — the kill will be refused unless forced.`
|
||||
: `Kill session "${name}"?`;
|
||||
if (!confirm(question)) return;
|
||||
try {
|
||||
await killSession(s.id);
|
||||
} catch (e) {
|
||||
|
||||
@@ -43,7 +43,6 @@ describe("resolveFastForward (#67)", () => {
|
||||
expect(req.worktree).toEqual({
|
||||
base_repo: "/repos/fresh",
|
||||
branch: "amber-falcon",
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +42,6 @@ export function buildFastForwardRequest(
|
||||
worktree: {
|
||||
base_repo: project.base_repo ?? project.path,
|
||||
branch: name,
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -147,6 +147,48 @@ describe("multiview openDraft (#62)", () => {
|
||||
expect((mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement).value).toBe("wip");
|
||||
});
|
||||
|
||||
it("reuses an already-synced empty pane instead of splitting (#72)", 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();
|
||||
// Let the mounted form's loadProjects() settle and auto-sync its picked
|
||||
// defaults into leaf.draft before openDraft runs (#72's dead-branch window).
|
||||
await flush();
|
||||
expect(leaves(tab.root).find((l) => l.id === empty)?.draft).not.toBeNull();
|
||||
|
||||
mv.openDraft();
|
||||
await flush();
|
||||
|
||||
expect(leaves(tab.root)).toHaveLength(2);
|
||||
expect(tab.focusedLeafId).toBe(empty);
|
||||
});
|
||||
|
||||
it("keeps a seed after reusing an already-synced empty pane (#72)", 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();
|
||||
await flush();
|
||||
|
||||
mv.openDraft({ cwd: "/home/user/proj", command: "claude" });
|
||||
await flush();
|
||||
|
||||
const leaf = leaves(tab.root).find((l) => l.id === empty)!;
|
||||
expect(leaf.draft?.cwd).toBe("/home/user/proj");
|
||||
expect(leaf.draft?.command).toBe("claude");
|
||||
const cwdRaw = mv.el.querySelector(".mv-draft .cwd-raw") as HTMLInputElement;
|
||||
expect(cwdRaw.value).toBe("/home/user/proj");
|
||||
});
|
||||
|
||||
it("seeds a clone's cwd and command into the draft", async () => {
|
||||
const layout = emptyLayout();
|
||||
const mv = makeMv(layout);
|
||||
@@ -160,3 +202,83 @@ describe("multiview openDraft (#62)", () => {
|
||||
expect(leaf.draft?.command).toBe("claude");
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiview pane-drag swap (#73)", () => {
|
||||
beforeEach(() => {
|
||||
stubFetch();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/** Drag `sourcePane`'s title onto `targetPane`'s center, as the real pane
|
||||
* drag handler does — modelled after the pointer sequence in runDrag()/
|
||||
* updateDock() in multiview.ts, since neither is exposed on MultiView. */
|
||||
async function dragPaneOntoCenter(sourcePane: HTMLElement, targetPane: HTMLElement): Promise<void> {
|
||||
// The real drag-start gate requires the pointerdown target to sit inside
|
||||
// an `.attach-header` (attach.ts's session title bar). Draft panes mount
|
||||
// no such element, so stand one in for the gate check itself.
|
||||
const marker = document.createElement("div");
|
||||
marker.className = "attach-header";
|
||||
sourcePane.appendChild(marker);
|
||||
|
||||
vi.spyOn(targetPane, "getBoundingClientRect").mockReturnValue({
|
||||
left: 100,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
right: 200,
|
||||
bottom: 100,
|
||||
x: 100,
|
||||
y: 0,
|
||||
toJSON() {},
|
||||
} as DOMRect);
|
||||
(document as unknown as { elementsFromPoint: (x: number, y: number) => Element[] }).elementsFromPoint = () => [
|
||||
targetPane,
|
||||
];
|
||||
|
||||
marker.dispatchEvent(
|
||||
new PointerEvent("pointerdown", { clientX: 0, clientY: 0, bubbles: true, cancelable: true }),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new PointerEvent("pointermove", { clientX: 150, clientY: 50, bubbles: true, cancelable: true }),
|
||||
);
|
||||
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 150, clientY: 50, bubbles: true, cancelable: true }));
|
||||
await flush();
|
||||
}
|
||||
|
||||
it("moves each draft's form along with its data when two draft panes are swapped", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
const leaf1 = splitLeaf(tab, "", null, "row", true);
|
||||
const leaf2 = splitLeaf(tab, leaf1, null, "row", true);
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
await flush();
|
||||
|
||||
const paneOf = (leafId: string) => mv.el.querySelector(`[data-leaf-id="${leafId}"]`) as HTMLElement;
|
||||
const nameInputOf = (leafId: string) => paneOf(leafId).querySelector("input[name=name]") as HTMLInputElement;
|
||||
|
||||
nameInputOf(leaf1).value = "west";
|
||||
nameInputOf(leaf1).dispatchEvent(new Event("input"));
|
||||
nameInputOf(leaf2).value = "east";
|
||||
nameInputOf(leaf2).dispatchEvent(new Event("input"));
|
||||
await flush();
|
||||
expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("west");
|
||||
expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("east");
|
||||
|
||||
await dragPaneOntoCenter(paneOf(leaf1), paneOf(leaf2));
|
||||
|
||||
// The leaf data swaps (already correct pre-fix)...
|
||||
expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("east");
|
||||
expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("west");
|
||||
// ...and the mounted forms must follow, not stay behind showing the old
|
||||
// pane's values (#73).
|
||||
expect(nameInputOf(leaf1).value).toBe("east");
|
||||
expect(nameInputOf(leaf2).value).toBe("west");
|
||||
});
|
||||
});
|
||||
|
||||
+18
-2
@@ -423,9 +423,15 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
return;
|
||||
}
|
||||
const target = findLeaf(tab.root, tab.focusedLeafId)!;
|
||||
if (!target.sessionId && !target.draft) {
|
||||
// An already-empty pane becomes the draft in place.
|
||||
if (!target.sessionId) {
|
||||
// Empty = no session, not `!draft` (#72): the mounted form auto-syncs
|
||||
// picked defaults into `draft` once ready, so that's never reliably null.
|
||||
target.draft = draft();
|
||||
const stale = draftForms.get(target.id);
|
||||
if (stale) {
|
||||
stale.destroy();
|
||||
draftForms.delete(target.id);
|
||||
}
|
||||
} else {
|
||||
// Split a fresh draft beside the focused pane.
|
||||
tab.focusedLeafId = splitLeaf(tab, target.id, null, "row", true);
|
||||
@@ -695,6 +701,16 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
const dragged = findLeaf(tab.root, self)!;
|
||||
[dragged.sessionId, target.sessionId] = [target.sessionId, dragged.sessionId];
|
||||
[dragged.draft, target.draft] = [target.draft, dragged.draft];
|
||||
// The leaf data moved but each pane's mounted form is still keyed to
|
||||
// its old leaf (#73) — tear both down like openDraft's reuse case
|
||||
// (#72) so render() remounts fresh forms seeded from the swapped data.
|
||||
for (const id of [dragged.id, target.id]) {
|
||||
const stale = draftForms.get(id);
|
||||
if (stale) {
|
||||
stale.destroy();
|
||||
draftForms.delete(id);
|
||||
}
|
||||
}
|
||||
tab.focusedLeafId = target.id;
|
||||
} else if (sessionId) {
|
||||
const loc = locateSession(layout, sessionId);
|
||||
|
||||
@@ -34,13 +34,13 @@ function stubFetch(info: unknown, projects: Project[]): void {
|
||||
);
|
||||
}
|
||||
|
||||
function rowPaths(form: HTMLFormElement): string[] {
|
||||
return [...form.querySelectorAll<HTMLButtonElement>(".project-row:not(.project-other)")].map(
|
||||
(r) => r.dataset.cwd ?? "",
|
||||
function boxPaths(form: HTMLFormElement): string[] {
|
||||
return [...form.querySelectorAll<HTMLButtonElement>(".project-box")].map(
|
||||
(b) => b.dataset.cwd ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
describe("spawnForm project picker", () => {
|
||||
describe("spawnForm project picker (#95: boxes, full paths, mains only)", () => {
|
||||
let handle: SpawnFormHandle | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -65,7 +65,7 @@ describe("spawnForm project picker", () => {
|
||||
return handle;
|
||||
}
|
||||
|
||||
it("renders one row per project, fresh before stale", async () => {
|
||||
it("renders one box per project, fresh before stale", async () => {
|
||||
const fresh = project({ name: "fresh", path: "/home/user/fresh", last_activity: NOW });
|
||||
const stale = project({
|
||||
name: "stale",
|
||||
@@ -73,40 +73,56 @@ describe("spawnForm project picker", () => {
|
||||
last_activity: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
const form = (await mount([stale, fresh])).el;
|
||||
expect(rowPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
|
||||
const rows = [...form.querySelectorAll<HTMLButtonElement>(".project-row")];
|
||||
expect(rows[1].classList.contains("is-stale")).toBe(true);
|
||||
expect(rows[0].classList.contains("is-stale")).toBe(false);
|
||||
expect(boxPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
|
||||
const boxes = [...form.querySelectorAll<HTMLButtonElement>(".project-box")];
|
||||
expect(boxes[1].classList.contains("is-stale")).toBe(true);
|
||||
expect(boxes[0].classList.contains("is-stale")).toBe(false);
|
||||
});
|
||||
|
||||
it("nests worktrees under their base repository", async () => {
|
||||
it("shows the name as the primary line and the full path unabbreviated", async () => {
|
||||
const p = project({ name: "nixos-config", path: "/home/user/tea/nixos-config" });
|
||||
const form = (await mount([p])).el;
|
||||
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
|
||||
expect(box.querySelector(".project-name")!.textContent).toBe("nixos-config");
|
||||
// Not `~/t/nixos-config`: the abbreviation hid what distinguishes two
|
||||
// projects sharing a basename (#95).
|
||||
expect(box.querySelector(".project-path")!.textContent).toBe("/home/user/tea/nixos-config");
|
||||
});
|
||||
|
||||
it("lists only main checkouts as boxes — worktrees belong to step two", async () => {
|
||||
const main = project({ name: "maestro", path: "/home/user/maestro" });
|
||||
const wt = project({
|
||||
name: "wt",
|
||||
path: "/elsewhere/wt",
|
||||
path: "/home/user/maestro/worktrees/wt",
|
||||
is_worktree: true,
|
||||
base_repo: main.path,
|
||||
});
|
||||
const form = (await mount([wt, main])).el;
|
||||
expect(rowPaths(form)).toEqual(["/home/user/maestro", "/elsewhere/wt"]);
|
||||
const [mainRow, wtRow] = [
|
||||
...form.querySelectorAll(".project-row:not(.project-other)"),
|
||||
];
|
||||
expect(wtRow.classList.contains("is-worktree")).toBe(true);
|
||||
expect(mainRow.classList.contains("is-worktree")).toBe(false);
|
||||
expect(boxPaths(form)).toEqual(["/home/user/maestro"]);
|
||||
expect(form.querySelector(".project-box.is-worktree")).toBeNull();
|
||||
});
|
||||
|
||||
it("filters rows as the operator types", async () => {
|
||||
it("filters boxes as the operator types, keeping a repo its worktree matched", async () => {
|
||||
const a = project({ name: "maestro", path: "/home/user/maestro" });
|
||||
const b = project({ name: "tea", path: "/home/user/work/tea" });
|
||||
const form = (await mount([a, b])).el;
|
||||
const wt = project({
|
||||
name: "bulgarians",
|
||||
path: "/home/user/maestro/worktrees/bulgarians",
|
||||
is_worktree: true,
|
||||
base_repo: a.path,
|
||||
});
|
||||
const form = (await mount([a, b, wt])).el;
|
||||
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
|
||||
filter.value = "tea";
|
||||
filter.dispatchEvent(new Event("input"));
|
||||
expect(rowPaths(form)).toEqual(["/home/user/work/tea"]);
|
||||
expect(boxPaths(form)).toEqual(["/home/user/work/tea"]);
|
||||
// A query that only matches a worktree still surfaces its repository box.
|
||||
filter.value = "bulgarians";
|
||||
filter.dispatchEvent(new Event("input"));
|
||||
expect(boxPaths(form)).toEqual(["/home/user/maestro"]);
|
||||
filter.value = "zzz";
|
||||
filter.dispatchEvent(new Event("input"));
|
||||
expect(rowPaths(form)).toEqual([]);
|
||||
expect(boxPaths(form)).toEqual([]);
|
||||
expect(form.querySelector(".picker-empty")).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -135,17 +151,17 @@ describe("spawnForm project picker", () => {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
onChange.mockClear();
|
||||
|
||||
const row = form.querySelector<HTMLButtonElement>(".project-row")!;
|
||||
row.click();
|
||||
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
|
||||
box.click();
|
||||
|
||||
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
|
||||
const last = onChange.mock.lastCall![0];
|
||||
expect(last.cwd).toBe("/home/user/maestro");
|
||||
expect(last.command).toBe("claude");
|
||||
expect(values).toBeTruthy();
|
||||
// The raw input stays hidden — the picked row is the source of truth.
|
||||
// The raw input stays hidden — the picked box is the source of truth.
|
||||
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(true);
|
||||
expect(row.classList.contains("is-active")).toBe(true);
|
||||
expect(box.classList.contains("is-active")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an 'Other directory…' escape hatch that reveals the raw input", async () => {
|
||||
|
||||
@@ -38,7 +38,6 @@ describe("buildSpawnRequest", () => {
|
||||
worktree: {
|
||||
base_repo: "/home/user/project",
|
||||
branch: "feat/foo",
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
},
|
||||
},
|
||||
@@ -63,7 +62,6 @@ describe("buildSpawnRequest", () => {
|
||||
worktree: {
|
||||
base_repo: "/home/user/project",
|
||||
branch: "spawn-v2",
|
||||
cleanup_on_kill: true,
|
||||
create_branch: false,
|
||||
},
|
||||
},
|
||||
|
||||
+209
-68
@@ -9,8 +9,8 @@ import {
|
||||
} from "./api.js";
|
||||
import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
||||
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
||||
import type { ProjectGroup } from "./projectList.js";
|
||||
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
||||
import { abbreviateCwd, abbreviateFolderName } from "./types.js";
|
||||
|
||||
/** Where a worktree's branch comes from: freshly created (default) or picked
|
||||
* from the repository's existing branches (#61). */
|
||||
@@ -75,9 +75,6 @@ export function buildSpawnRequest(
|
||||
req.worktree = {
|
||||
base_repo: baseRepo,
|
||||
branch,
|
||||
// The daemon always cleans up, guarded against unsaved work (#56);
|
||||
// the wire field stays for older daemons.
|
||||
cleanup_on_kill: true,
|
||||
// Existing-mode picks must check out as-is; the daemon creates
|
||||
// anything else with `worktree add -b` (#56/#61).
|
||||
create_branch: mode !== "existing",
|
||||
@@ -97,6 +94,11 @@ export const FALLBACK_PRESETS: SpawnPreset[] = [
|
||||
{ id: "custom", label: "Custom…", command: "" },
|
||||
];
|
||||
|
||||
/** Quiet period after raw-path typing before `/api/gitroot` fires (#74).
|
||||
* Must stay equal to `GITROOT_PROBE_DEBOUNCE_MS` in
|
||||
* crates/maestro-tui/src/draft.rs so both clients debounce alike. */
|
||||
const GITROOT_PROBE_DEBOUNCE_MS = 250;
|
||||
|
||||
export interface SpawnFormHandle {
|
||||
el: HTMLFormElement;
|
||||
/** Remove global listeners (dir autocomplete) — call when unmounting. */
|
||||
@@ -128,15 +130,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
placeholder="bash -l" autocomplete="off" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Working directory</span>
|
||||
<span class="field-label">Project</span>
|
||||
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
|
||||
autocomplete="off" />
|
||||
<div class="picker projects"></div>
|
||||
<button type="button" class="project-other">Other directory…</button>
|
||||
<div class="raw-wrap cwd-raw-wrap" hidden>
|
||||
<input class="raw-input cwd-raw" name="cwd_raw" type="text"
|
||||
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field wt-step" hidden>
|
||||
<span class="field-label">Worktree</span>
|
||||
<div class="picker wt-choices"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Name</span>
|
||||
<div class="name-row">
|
||||
@@ -185,8 +192,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
const errEl = form.querySelector(".form-error") as HTMLElement;
|
||||
const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement;
|
||||
const cmdRaw = form.querySelector(".cmd-raw") as HTMLInputElement;
|
||||
const recentsEl = form.querySelector(".projects") as HTMLElement;
|
||||
const projectsEl = form.querySelector(".projects") as HTMLElement;
|
||||
const filterInput = form.querySelector(".project-filter") as HTMLInputElement;
|
||||
const otherBtn = form.querySelector(".project-other") as HTMLButtonElement;
|
||||
const wtStep = form.querySelector(".wt-step") as HTMLElement;
|
||||
const wtChoices = form.querySelector(".wt-choices") as HTMLElement;
|
||||
const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
|
||||
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
|
||||
const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement;
|
||||
@@ -340,6 +350,15 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Re-highlight the operator's already-picked tile after `renderCommandTiles`
|
||||
* rebuilds the row from scratch (new tiles carry no `is-active` class) —
|
||||
* without touching `command`, since that was already settled. */
|
||||
function reapplyActiveTile(): void {
|
||||
for (const tile of cmdTiles.querySelectorAll(".tile")) {
|
||||
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === selectedPresetId);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCommandTiles(): void {
|
||||
cmdTiles.replaceChildren();
|
||||
for (const p of presets) {
|
||||
@@ -348,13 +367,23 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
tile.className = "tile";
|
||||
tile.dataset.id = p.id;
|
||||
tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
|
||||
tile.addEventListener("click", () => selectPreset(p.id));
|
||||
tile.addEventListener("click", () => {
|
||||
commandTouched = true;
|
||||
selectPreset(p.id);
|
||||
});
|
||||
cmdTiles.appendChild(tile);
|
||||
}
|
||||
}
|
||||
|
||||
// Set once the operator picks a tile or types a custom command — guards
|
||||
// `applyInitialCommand()` the same way `nameTouched` guards a late name
|
||||
// batch: a late `/api/info` resolution must never overwrite a field the
|
||||
// operator has already touched (#76).
|
||||
let commandTouched = false;
|
||||
|
||||
cmdRaw.addEventListener("input", () => {
|
||||
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
|
||||
commandTouched = true;
|
||||
command = cmdRaw.value.trim();
|
||||
emit();
|
||||
}
|
||||
@@ -370,7 +399,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
if (info.presets?.length) {
|
||||
presets = info.presets;
|
||||
renderCommandTiles();
|
||||
applyInitialCommand();
|
||||
if (commandTouched) {
|
||||
reapplyActiveTile();
|
||||
} else {
|
||||
applyInitialCommand();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -397,12 +430,36 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
if (first) selectPreset(first.id, false);
|
||||
}
|
||||
|
||||
// ---- working-directory picker ---------------------------------------
|
||||
// ---- working-directory picker (#95) ----------------------------------
|
||||
// Two steps, and the TUI mirror (#96) must match these rules verbatim:
|
||||
// 1. Step one is one box per repository (name, then full path). A worktree
|
||||
// is a box only when its main checkout is missing from the scan.
|
||||
// 2. The filter keeps a repository when the query matches it or any of its
|
||||
// worktrees, so a worktree name still finds its box.
|
||||
// 3. Picking a box reveals step two: "+ new", then that repository's
|
||||
// worktrees newest-first. "Other directory…" clears both steps.
|
||||
// 4. "+ new" is the default selection — except on a box that is itself a
|
||||
// linked worktree, which defaults to that worktree.
|
||||
// 5. "+ new" means create: cwd is the repository, create-worktree on.
|
||||
// An existing worktree means run in it: cwd is that worktree,
|
||||
// create-worktree OFF. The daemon never adopts (docs/WORKTREES.md), so
|
||||
// these are separate acts — #91's rule, now an explicit choice.
|
||||
// 6. Neither step probes /api/gitroot; the scan already reports repository
|
||||
// membership. Only the free-text hatch pays for a probe.
|
||||
|
||||
/** "new" (create a worktree) or the path of an existing worktree to run in. */
|
||||
type WorktreeChoice = "new" | string;
|
||||
|
||||
let discovered: Project[] = []; // daemon snapshot, daemon-sorted
|
||||
let groups: ProjectGroup[] = []; // rebuilt on every render
|
||||
let selectedGroup: ProjectGroup | null = null;
|
||||
let worktreeChoice: WorktreeChoice = "new";
|
||||
|
||||
function showRawCwd(prefillValue?: string, focus = true): void {
|
||||
cwdRawWrap.hidden = false;
|
||||
if (prefillValue != null) cwd = prefillValue;
|
||||
cwdRaw.value = cwd;
|
||||
markActiveProject(null);
|
||||
clearProjectSelection();
|
||||
if (focus) {
|
||||
cwdRaw.focus();
|
||||
cwdRaw.select();
|
||||
@@ -416,55 +473,92 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
cwd = cwdRaw.value.trim();
|
||||
branchCache = null;
|
||||
emit();
|
||||
if (wtEnabled.checked) probeGitRoot();
|
||||
if (wtEnabled.checked) scheduleGitRootProbe();
|
||||
});
|
||||
|
||||
function markActiveProject(activeCwd: string | null): void {
|
||||
for (const row of recentsEl.querySelectorAll(".project-row")) {
|
||||
row.classList.toggle(
|
||||
"is-active",
|
||||
(row as HTMLElement).dataset.cwd === activeCwd,
|
||||
);
|
||||
/** Every worktree of a group, including the group's own box when the scan
|
||||
* found no main checkout to nest it under. */
|
||||
function worktreesOf(group: ProjectGroup): Project[] {
|
||||
return [group.main, ...group.worktrees].filter((p) => p.is_worktree);
|
||||
}
|
||||
|
||||
function markActiveBox(): void {
|
||||
const active = selectedGroup?.main.path ?? null;
|
||||
for (const box of projectsEl.querySelectorAll(".project-box")) {
|
||||
box.classList.toggle("is-active", (box as HTMLElement).dataset.cwd === active);
|
||||
}
|
||||
}
|
||||
|
||||
function selectProject(project: Project): void {
|
||||
cwd = project.path;
|
||||
function markActiveChoice(): void {
|
||||
for (const btn of wtChoices.querySelectorAll(".wt-choice")) {
|
||||
const el = btn as HTMLElement;
|
||||
const value = el.dataset.kind === "new" ? "new" : (el.dataset.cwd ?? "");
|
||||
el.classList.toggle("is-active", value === worktreeChoice);
|
||||
}
|
||||
}
|
||||
|
||||
function clearProjectSelection(): void {
|
||||
selectedGroup = null;
|
||||
worktreeChoice = "new";
|
||||
wtChoices.replaceChildren();
|
||||
wtStep.hidden = true;
|
||||
markActiveBox();
|
||||
}
|
||||
|
||||
function selectProject(group: ProjectGroup): void {
|
||||
selectedGroup = group;
|
||||
cwdRawWrap.hidden = true;
|
||||
markActiveProject(project.path);
|
||||
// The scan already knows this directory is inside a repo (#61): the
|
||||
// toggle defaults back on and no /api/gitroot round trip is needed.
|
||||
wtEnabled.checked = true;
|
||||
applyProjectRepo(project.base_repo ?? project.path);
|
||||
renderWorktreeChoices(group);
|
||||
markActiveBox();
|
||||
// Rule 4: a box that is itself a linked worktree defaults to running there.
|
||||
applyWorktreeChoice(group.main.is_worktree ? group.main.path : "new");
|
||||
}
|
||||
|
||||
function applyWorktreeChoice(choice: WorktreeChoice): void {
|
||||
const group = selectedGroup;
|
||||
if (!group) return;
|
||||
const target =
|
||||
choice === "new" ? group.main : worktreesOf(group).find((p) => p.path === choice);
|
||||
if (!target) return;
|
||||
worktreeChoice = choice;
|
||||
cwd = target.path;
|
||||
branchCache = null;
|
||||
markActiveChoice();
|
||||
if (choice === "new") {
|
||||
wtEnabled.checked = true;
|
||||
applyProjectRepo(group.main.base_repo ?? group.main.path);
|
||||
} else {
|
||||
wtEnabled.checked = false;
|
||||
inferredBaseRepo = null;
|
||||
wtBody.hidden = true;
|
||||
wtHint.hidden = true;
|
||||
}
|
||||
emit();
|
||||
// Picking a project pre-selects the command it was last used with.
|
||||
const match = presets.find((p) => p.command === project.last_command && p.command !== "");
|
||||
// The chosen directory pre-selects the command it was last used with.
|
||||
const match = presets.find((p) => p.command === target.last_command && p.command !== "");
|
||||
if (match) selectPreset(match.id);
|
||||
}
|
||||
|
||||
let discovered: Project[] = []; // daemon snapshot, daemon-sorted
|
||||
|
||||
function renderProjects(): void {
|
||||
recentsEl.replaceChildren();
|
||||
const sorted = sortProjects(filterProjects(discovered, filterInput.value), new Date());
|
||||
for (const group of groupProjects(sorted)) {
|
||||
recentsEl.appendChild(projectRow(group.main));
|
||||
for (const wt of group.worktrees) recentsEl.appendChild(projectRow(wt));
|
||||
}
|
||||
if (discovered.length > 0 && sorted.length === 0) {
|
||||
projectsEl.replaceChildren();
|
||||
groups = groupProjects(sortProjects(discovered, new Date()));
|
||||
const query = filterInput.value;
|
||||
const visible = groups.filter((g) => filterProjects([g.main, ...g.worktrees], query).length > 0);
|
||||
for (const group of visible) projectsEl.appendChild(projectBox(group));
|
||||
if (discovered.length > 0 && visible.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "picker-empty";
|
||||
empty.textContent = "No projects match.";
|
||||
recentsEl.appendChild(empty);
|
||||
projectsEl.appendChild(empty);
|
||||
}
|
||||
recentsEl.appendChild(otherDirRow());
|
||||
markActiveProject(cwdRawWrap.hidden ? cwd : null);
|
||||
markActiveBox();
|
||||
}
|
||||
|
||||
filterInput.addEventListener("input", renderProjects);
|
||||
otherBtn.addEventListener("click", () => showRawCwd(cwd || homeDir));
|
||||
|
||||
async function loadProjects(): Promise<void> {
|
||||
recentsEl.replaceChildren(loadingRow());
|
||||
projectsEl.replaceChildren(loadingRow());
|
||||
try {
|
||||
discovered = await fetchProjects();
|
||||
} catch {
|
||||
@@ -475,12 +569,13 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
if (seedCwd) {
|
||||
// A seed sets the directory verbatim, regardless of what the scan found.
|
||||
// `cwdRawVisible === false` marks a draft restore whose cwd came from a
|
||||
// picked row: keep the picker and don't steal focus. Otherwise land
|
||||
// picked box: keep the picker and don't steal focus. Otherwise land
|
||||
// focused in the raw input (clone / no-match flows).
|
||||
if (prefill.cwdRawVisible === false) {
|
||||
cwd = seedCwd;
|
||||
cwdRaw.value = seedCwd;
|
||||
markActiveProject(cwd);
|
||||
// Restoring must not touch the worktree toggle — the seed carries it.
|
||||
if (!restoreSelection(seedCwd)) clearProjectSelection();
|
||||
} else {
|
||||
showRawCwd(seedCwd, false);
|
||||
}
|
||||
@@ -497,34 +592,66 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
emit();
|
||||
}
|
||||
|
||||
function projectRow(project: Project): HTMLElement {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = `project-row${project.is_worktree ? " is-worktree" : ""}${
|
||||
isStale(project, new Date()) ? " is-stale" : ""
|
||||
}`;
|
||||
row.dataset.cwd = project.path;
|
||||
// Abbreviate exactly like the sidebar: home → `~`, then collapse leading
|
||||
// segments to initials (`~/g/n/maestro`). The full path stays in the tooltip
|
||||
// and `dataset.cwd` (what's actually submitted).
|
||||
const display = abbreviateFolderName(abbreviateCwd(project.path, homeDir));
|
||||
row.innerHTML = `
|
||||
<span class="project-info">
|
||||
<span class="project-name">${escapeHtml(project.name)}</span>
|
||||
<span class="recent-dir" title="${escapeHtml(project.path)}">${display}</span>
|
||||
</span>
|
||||
<span class="recent-cmd">${escapeHtml(project.last_command ?? "")}</span>`;
|
||||
row.addEventListener("click", () => selectProject(project));
|
||||
return row;
|
||||
/** Put the two steps back on a restored draft's cwd, without re-running the
|
||||
* side effects of a fresh pick. */
|
||||
function restoreSelection(path: string): boolean {
|
||||
for (const group of groups) {
|
||||
const wt = worktreesOf(group).find((p) => p.path === path);
|
||||
const choice: WorktreeChoice | null =
|
||||
wt ? wt.path : group.main.path === path ? "new" : null;
|
||||
if (choice === null) continue;
|
||||
selectedGroup = group;
|
||||
worktreeChoice = choice;
|
||||
renderWorktreeChoices(group);
|
||||
markActiveChoice();
|
||||
markActiveBox();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function otherDirRow(): HTMLElement {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "project-row project-other";
|
||||
row.innerHTML = `<span class="project-info"><span class="recent-dir">Other directory…</span></span>`;
|
||||
row.addEventListener("click", () => showRawCwd(cwd || homeDir));
|
||||
return row;
|
||||
function projectBox(group: ProjectGroup): HTMLElement {
|
||||
const project = group.main;
|
||||
const box = document.createElement("button");
|
||||
box.type = "button";
|
||||
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
|
||||
box.dataset.cwd = project.path;
|
||||
const trees = worktreesOf(group).length;
|
||||
// The full path, not the sidebar's `~/t/n/maestro` abbreviation: it is what
|
||||
// tells two projects sharing a basename apart (#95).
|
||||
const meta = [trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "", project.last_command ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
box.innerHTML = `
|
||||
<span class="project-name">${escapeHtml(project.name)}</span>
|
||||
<span class="project-path">${escapeHtml(project.path)}</span>
|
||||
<span class="project-meta">${escapeHtml(meta)}</span>`;
|
||||
box.addEventListener("click", () => selectProject(group));
|
||||
return box;
|
||||
}
|
||||
|
||||
function renderWorktreeChoices(group: ProjectGroup): void {
|
||||
wtChoices.replaceChildren();
|
||||
wtChoices.appendChild(
|
||||
choiceButton("new", "+ new", `new worktree of ${group.main.name}`),
|
||||
);
|
||||
for (const wt of worktreesOf(group)) {
|
||||
wtChoices.appendChild(choiceButton(wt.path, wt.name, wt.path));
|
||||
}
|
||||
wtStep.hidden = false;
|
||||
}
|
||||
|
||||
function choiceButton(choice: WorktreeChoice, label: string, sub: string): HTMLElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = `wt-choice${choice === "new" ? " wt-choice-new" : ""}`;
|
||||
btn.dataset.kind = choice === "new" ? "new" : "existing";
|
||||
if (choice !== "new") btn.dataset.cwd = choice;
|
||||
btn.innerHTML = `
|
||||
<span class="wt-choice-name">${escapeHtml(label)}</span>
|
||||
<span class="wt-choice-sub">${escapeHtml(sub)}</span>`;
|
||||
btn.addEventListener("click", () => applyWorktreeChoice(choice));
|
||||
return btn;
|
||||
}
|
||||
|
||||
function loadingRow(): HTMLElement {
|
||||
@@ -572,7 +699,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
applyProjectRepo(project.base_repo ?? project.path);
|
||||
return;
|
||||
}
|
||||
void probeGitRoot();
|
||||
scheduleGitRootProbe();
|
||||
}
|
||||
|
||||
// Every keystroke resets this timer, so a burst of typing issues one
|
||||
// request once the operator pauses. `probeToken` inside probeGitRoot still
|
||||
// owns ordering: a scheduled probe that fires after a newer one started
|
||||
// loses the token race and is dropped.
|
||||
let probeTimer: number | undefined;
|
||||
function scheduleGitRootProbe(): void {
|
||||
window.clearTimeout(probeTimer);
|
||||
probeTimer = window.setTimeout(() => {
|
||||
probeTimer = undefined;
|
||||
void probeGitRoot();
|
||||
}, GITROOT_PROBE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function probeGitRoot(): Promise<void> {
|
||||
@@ -733,6 +873,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
el: form,
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
window.clearTimeout(probeTimer);
|
||||
autocomplete.destroy();
|
||||
form.remove();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js";
|
||||
|
||||
/** Stubs fetch so `/api/info` only resolves once `resolveInfo()` is called —
|
||||
* everything else (names, projects, gitroot) answers immediately with an
|
||||
* empty/default body. Lets a test pick a preset (or type a custom command)
|
||||
* mid-flight, then observe what the late `/api/info` resolution does to it. */
|
||||
function stubDeferredInfo(): { resolveInfo: () => void } {
|
||||
let releaseInfo: () => void = () => {};
|
||||
const infoGate = new Promise<void>((resolve) => {
|
||||
releaseInfo = resolve;
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/api/info")) {
|
||||
await infoGate;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
hostname: "h",
|
||||
version: "0",
|
||||
home_dir: "/home/user",
|
||||
presets: [
|
||||
{ id: "claude", label: "Claude", command: "claude" },
|
||||
{ id: "fish", label: "Fish", command: "fish" },
|
||||
{ id: "custom", label: "Custom…", command: "" },
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url.includes("/api/names")) {
|
||||
return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify([]), { status: 200 });
|
||||
}),
|
||||
);
|
||||
return { resolveInfo: releaseInfo };
|
||||
}
|
||||
|
||||
describe("spawnForm preset selection vs. late /api/info (#76)", () => {
|
||||
let handle: SpawnFormHandle | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
handle?.destroy();
|
||||
handle = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function drain(): Promise<void> {
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
it("keeps an operator-picked tile after /api/info resolves late", async () => {
|
||||
const { resolveInfo } = stubDeferredInfo();
|
||||
let values: SpawnFormValues | undefined;
|
||||
handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} });
|
||||
const form = handle.el;
|
||||
document.body.appendChild(form);
|
||||
await drain();
|
||||
|
||||
// Fallback presets are up; the operator picks "zsh" before /api/info lands.
|
||||
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
|
||||
(t) => t.dataset.id === "zsh",
|
||||
)!;
|
||||
zshTile.click();
|
||||
expect(zshTile.classList.contains("is-active")).toBe(true);
|
||||
|
||||
// /api/info lands late with a preset list that doesn't even include "zsh".
|
||||
resolveInfo();
|
||||
await drain();
|
||||
|
||||
// The operator's pick must not be discarded back to the new list's first
|
||||
// entry — `onChange` only starts firing once the initial load settles
|
||||
// (`ready`), which happens right here, so this is the first place the
|
||||
// published command is observable.
|
||||
expect(values?.command).toBe("zsh");
|
||||
});
|
||||
|
||||
it("keeps an operator-typed custom command after /api/info resolves late", async () => {
|
||||
const { resolveInfo } = stubDeferredInfo();
|
||||
let values: SpawnFormValues | undefined;
|
||||
handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} });
|
||||
const form = handle.el;
|
||||
document.body.appendChild(form);
|
||||
await drain();
|
||||
|
||||
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
|
||||
(t) => t.dataset.id === "custom",
|
||||
)!;
|
||||
customTile.click();
|
||||
const cmdRaw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
|
||||
cmdRaw.value = "htop";
|
||||
cmdRaw.dispatchEvent(new Event("input"));
|
||||
|
||||
resolveInfo();
|
||||
await drain();
|
||||
|
||||
expect(values?.command).toBe("htop");
|
||||
expect(cmdRaw.value).toBe("htop");
|
||||
});
|
||||
|
||||
it("still applies the real presets' first entry when the operator never touched the command", async () => {
|
||||
const { resolveInfo } = stubDeferredInfo();
|
||||
let values: SpawnFormValues | undefined;
|
||||
handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} });
|
||||
const form = handle.el;
|
||||
document.body.appendChild(form);
|
||||
await drain();
|
||||
|
||||
resolveInfo();
|
||||
await drain();
|
||||
|
||||
// Untouched: the refined preset list is still free to settle the command.
|
||||
expect(values?.command).toBe("claude");
|
||||
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
|
||||
(t) => t.dataset.id === "fish",
|
||||
);
|
||||
expect(fishTile).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
||||
|
||||
/** Debounce window under test — must equal GITROOT_PROBE_DEBOUNCE_MS in
|
||||
* spawnForm.ts (#74). */
|
||||
const DEBOUNCE_MS = 250;
|
||||
|
||||
const PROJECTS = [
|
||||
{ path: "/home/user/maestro", name: "maestro", last_activity: "2026-08-20T10:00:00Z" },
|
||||
];
|
||||
|
||||
/** Stub the fetches the form makes. Gitroot responses for a given path can be
|
||||
* held until `releaseGitRoot(path)` is called, so tests can land answers out
|
||||
* of order. */
|
||||
function stubFetch(opts?: { holdGitRootFor?: string[] }): {
|
||||
urls: string[];
|
||||
gitRootCalls: string[];
|
||||
releaseGitRoot: (path: string) => void;
|
||||
} {
|
||||
const urls: string[] = [];
|
||||
const gitRootCalls: string[] = [];
|
||||
const releases = new Map<string, () => void>();
|
||||
const held = new Map<string, Promise<void>>();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
urls.push(url);
|
||||
if (url.includes("/api/dirs")) {
|
||||
return new Response(JSON.stringify({ entries: [] }), { status: 200 });
|
||||
}
|
||||
if (url.includes("/api/gitroot")) {
|
||||
const path = new URL(url, "http://localhost").searchParams.get("path") ?? "";
|
||||
gitRootCalls.push(path);
|
||||
if (opts?.holdGitRootFor?.includes(path)) {
|
||||
let release!: () => void;
|
||||
held.set(
|
||||
path,
|
||||
new Promise<void>((r) => {
|
||||
release = r;
|
||||
}),
|
||||
);
|
||||
releases.set(path, release);
|
||||
}
|
||||
await held.get(path);
|
||||
return new Response(JSON.stringify({ root: path }), { status: 200 });
|
||||
}
|
||||
if (url.includes("/api/names")) {
|
||||
return new Response(JSON.stringify({ names: ["amber-fox"] }), { 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,
|
||||
gitRootCalls,
|
||||
releaseGitRoot: (path: string) => releases.get(path)?.(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("spawnForm gitroot probe debounce (#74)", () => {
|
||||
let handle: SpawnFormHandle | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
handle?.destroy();
|
||||
handle = null;
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function mount(opts?: Parameters<typeof stubFetch>[0]) {
|
||||
const stub = stubFetch(opts);
|
||||
handle = createSpawnForm({ onSuccess() {} });
|
||||
document.body.appendChild(handle.el);
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
return { form: handle.el, stub };
|
||||
}
|
||||
|
||||
const drain = async () => {
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
function typeRaw(form: HTMLFormElement, value: string): void {
|
||||
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
|
||||
raw.value = value;
|
||||
raw.dispatchEvent(new Event("input"));
|
||||
}
|
||||
|
||||
it("a keystroke burst fires exactly one probe after the quiet period", async () => {
|
||||
const { form, stub } = await mount();
|
||||
|
||||
form.querySelector<HTMLElement>(".project-other")!.click();
|
||||
// Burst: every keystroke reschedules; nothing may hit /api/gitroot yet.
|
||||
for (let i = 1; i <= "/opt/somewhere".length; i++) {
|
||||
typeRaw(form, "/opt/somewhere".slice(0, i));
|
||||
await drain();
|
||||
expect(stub.gitRootCalls).toEqual([]);
|
||||
}
|
||||
|
||||
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
|
||||
await drain();
|
||||
|
||||
expect(stub.gitRootCalls).toEqual(["/opt/somewhere"]);
|
||||
expect(stub.urls.filter((u) => u.includes("/api/gitroot")).length).toBe(1);
|
||||
});
|
||||
|
||||
it("a stale probe answer cannot overwrite a newer input", async () => {
|
||||
const { form, stub } = await mount({ holdGitRootFor: ["/repo/old"] });
|
||||
|
||||
form.querySelector<HTMLElement>(".project-other")!.click();
|
||||
|
||||
// Probe for the old target, held in flight.
|
||||
typeRaw(form, "/repo/old");
|
||||
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
|
||||
|
||||
// Newer target supersedes it and answers immediately.
|
||||
typeRaw(form, "/repo/new");
|
||||
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
|
||||
await drain();
|
||||
|
||||
expect(stub.gitRootCalls).toEqual(["/repo/old", "/repo/new"]);
|
||||
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain("/repo/new");
|
||||
expect(
|
||||
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked,
|
||||
).toBe(true);
|
||||
|
||||
// The late answer to the old target lands last but must lose.
|
||||
stub.releaseGitRoot("/repo/old");
|
||||
await drain();
|
||||
|
||||
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain("/repo/new");
|
||||
expect(
|
||||
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ const BRANCHES = [
|
||||
* its branch list is still in flight. */
|
||||
function stubFetch(opts?: {
|
||||
gateBranches?: (release: () => void) => void;
|
||||
projects?: unknown[];
|
||||
}): string[] {
|
||||
const urls: string[] = [];
|
||||
const gated = new Promise<void>((resolve) => {
|
||||
@@ -37,7 +38,7 @@ function stubFetch(opts?: {
|
||||
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(opts?.projects ?? PROJECTS), { status: 200 });
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }),
|
||||
@@ -79,10 +80,10 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
|
||||
};
|
||||
|
||||
function pickProject(form: HTMLFormElement): void {
|
||||
form.querySelector<HTMLButtonElement>(".project-row.project-other")!.click();
|
||||
form.querySelector<HTMLButtonElement>(".project-other")!.click();
|
||||
form
|
||||
.querySelectorAll<HTMLButtonElement>(".project-row")
|
||||
.forEach((row) => row.dataset.cwd === "/home/user/maestro" && row.click());
|
||||
.querySelectorAll<HTMLButtonElement>(".project-box")
|
||||
.forEach((box) => box.dataset.cwd === "/home/user/maestro" && box.click());
|
||||
}
|
||||
|
||||
it("defaults the worktree toggle on for a picked project with no gitroot probe", async () => {
|
||||
@@ -100,14 +101,44 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults the worktree toggle off for a row that is itself a worktree (#91)", async () => {
|
||||
urls = stubFetch({
|
||||
projects: [
|
||||
{
|
||||
path: "/home/user/wt-91",
|
||||
name: "wt-91",
|
||||
is_worktree: true,
|
||||
base_repo: "/home/user/maestro",
|
||||
last_activity: "2026-08-20T10:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
handle = createSpawnForm({ onSuccess() {} });
|
||||
document.body.appendChild(handle.el);
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
|
||||
handle.el
|
||||
.querySelectorAll<HTMLButtonElement>(".project-box")
|
||||
.forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click());
|
||||
await drain();
|
||||
|
||||
expect(handle.el.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked).toBe(
|
||||
false,
|
||||
);
|
||||
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
|
||||
expect(handle.el.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
|
||||
});
|
||||
|
||||
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();
|
||||
// Escape hatch: switch to a free-text dir; only now does /api/gitroot
|
||||
// fire — debounced, so the quiet period must elapse first (#74).
|
||||
form.querySelector<HTMLElement>(".project-other")!.click();
|
||||
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
|
||||
raw.value = "/opt/somewhere";
|
||||
raw.dispatchEvent(new Event("input"));
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
await drain();
|
||||
|
||||
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(true);
|
||||
|
||||
+130
-39
@@ -727,10 +727,8 @@ textarea {
|
||||
}
|
||||
|
||||
/* Earlier path segments collapse to their initial; the trailing segment (the
|
||||
one that actually names the dir) stays full. Dim the collapsed prefix. Shared
|
||||
by the sidebar tree and the spawn dialog's recent-directory picker. */
|
||||
.folder-name .folder-stem,
|
||||
.recent-dir .folder-stem {
|
||||
one that actually names the dir) stays full. Dim the collapsed prefix. */
|
||||
.folder-name .folder-stem {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
@@ -831,6 +829,13 @@ textarea {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* #83: the session row's blocked-work warning — an unforced kill will be
|
||||
refused, and the operator should see that before pressing the button. */
|
||||
.leaf-badge.leaf-blocked {
|
||||
color: var(--warn, #b45309);
|
||||
border-color: var(--warn, #b45309);
|
||||
}
|
||||
|
||||
.leaf-actions {
|
||||
position: absolute;
|
||||
right: var(--sp-1);
|
||||
@@ -1208,6 +1213,9 @@ textarea {
|
||||
/* ---------- forms ---------- */
|
||||
|
||||
.spawn-form {
|
||||
width: 100%;
|
||||
max-width: 60ch;
|
||||
margin: 0 auto;
|
||||
padding: var(--sp-3);
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
@@ -1371,8 +1379,9 @@ textarea {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Project picker rows (#59). Worktree rows are indented under their base
|
||||
repository; rows stale beyond 7 days are dimmed but still selectable. */
|
||||
/* Working-directory picker (#95). Two steps: a grid of project boxes, then the
|
||||
picked project's worktree choices. Stale beyond 7 days dims but stays
|
||||
selectable. */
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1387,73 +1396,155 @@ textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.project-row {
|
||||
/* `display` from `.field`/`.raw-wrap` outranks the UA sheet's `[hidden]`, so
|
||||
step two and the raw-cwd hatch stayed visible while hidden. */
|
||||
.wt-step[hidden],
|
||||
.raw-wrap[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.picker.projects {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
align-content: start;
|
||||
gap: var(--sp-2);
|
||||
max-height: 300px;
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.project-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
color: var(--ink);
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--dur-fast) var(--ease-out),
|
||||
background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.project-box:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.project-box.is-active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
|
||||
.project-box.is-stale {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-size: var(--fs-13);
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The whole path, never abbreviated — it is what tells two projects sharing a
|
||||
basename apart. Wraps rather than truncating so none of it is hidden. */
|
||||
.project-path {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-12);
|
||||
line-height: 1.35;
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.project-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-12);
|
||||
color: var(--faint);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-meta:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.project-other {
|
||||
align-self: flex-start;
|
||||
padding: 4px 0;
|
||||
min-height: 30px;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--muted);
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-13);
|
||||
font-style: italic;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.project-other:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Step two: "+ new" (create one) or an existing worktree (run in it). */
|
||||
.picker.wt-choices {
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.wt-choice {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
min-height: 32px;
|
||||
padding: 6px var(--sp-3);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
color: var(--ink);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--dur-fast) var(--ease-out),
|
||||
background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.project-row:hover {
|
||||
.wt-choice:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.project-row.is-active {
|
||||
.wt-choice.is-active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
|
||||
.project-row.is-worktree {
|
||||
margin-left: var(--sp-4);
|
||||
}
|
||||
|
||||
.project-row.is-stale {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.project-info {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
.wt-choice-name {
|
||||
flex: 0 0 auto;
|
||||
font-size: var(--fs-13);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recent-dir {
|
||||
.wt-choice-new .wt-choice-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-12);
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.recent-cmd {
|
||||
flex: 0 0 auto;
|
||||
.wt-choice-sub {
|
||||
min-width: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-12);
|
||||
line-height: 1.35;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.project-other .recent-dir {
|
||||
font-family: inherit;
|
||||
font-style: italic;
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.picker-empty {
|
||||
|
||||
+18
-1
@@ -31,6 +31,11 @@ export interface Session {
|
||||
* observed it (`#{pane_current_path}`) — distinct from `cwd` (the spawn dir).
|
||||
* Drives the directory-based session tree. Absent until first observed. */
|
||||
current_cwd?: string | null;
|
||||
/** What currently blocks an unforced kill of this session (#83), as the
|
||||
* daemon's watcher last probed it. Absent until probed and for sessions
|
||||
* without a worktree; all counts default to 0 on the wire for older
|
||||
* daemons. */
|
||||
blocked_work?: InTheWay | null;
|
||||
}
|
||||
|
||||
/** One directory's immediate subdirectories (NAP-329). */
|
||||
@@ -113,7 +118,6 @@ export type Projects = Project[];
|
||||
export interface WorktreeSpec {
|
||||
base_repo: string;
|
||||
branch: string;
|
||||
cleanup_on_kill?: boolean;
|
||||
/** When false, `branch` already exists and is checked out as-is (#61).
|
||||
* Defaults to true (create a new branch) on older daemons. */
|
||||
create_branch?: boolean;
|
||||
@@ -181,6 +185,19 @@ export function reasonLabel(reason: RetainedReason): string {
|
||||
return reason;
|
||||
}
|
||||
|
||||
/** #83: this session's blocked-work breakdown when something actually blocks
|
||||
* an unforced kill; `null` while clear or not yet probed. Render it with
|
||||
* {@link formatInTheWay} so every client phrases it identically. */
|
||||
export function sessionBlockers(s: Session): InTheWay | null {
|
||||
const w = s.blocked_work;
|
||||
if (!w) return null;
|
||||
const blocked =
|
||||
(w.uncommitted_files ?? 0) > 0 ||
|
||||
(w.untracked_files ?? 0) > 0 ||
|
||||
(w.unpushed_commits ?? 0) > 0;
|
||||
return blocked ? w : null;
|
||||
}
|
||||
|
||||
export interface CreateSessionRequest {
|
||||
cwd: string;
|
||||
command: string;
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
// @vitest-environment happy-dom
|
||||
// Second step of the working-directory picker (#95). See the interaction rules
|
||||
// block in spawnForm.ts for what each choice means.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js";
|
||||
import type { Project } from "./types.js";
|
||||
|
||||
const MAESTRO: Project = {
|
||||
path: "/home/user/tea/maestro",
|
||||
name: "maestro",
|
||||
is_worktree: false,
|
||||
last_activity: "2026-08-20T10:00:00Z",
|
||||
last_command: "claude",
|
||||
};
|
||||
|
||||
const WT_A: Project = {
|
||||
path: "/home/user/tea/maestro/worktrees/bulgarians",
|
||||
name: "bulgarians",
|
||||
is_worktree: true,
|
||||
base_repo: MAESTRO.path,
|
||||
last_activity: "2026-08-22T10:00:00Z",
|
||||
};
|
||||
|
||||
const WT_B: Project = {
|
||||
path: "/home/user/tea/maestro/worktrees/spawn-v2",
|
||||
name: "spawn-v2",
|
||||
is_worktree: true,
|
||||
base_repo: MAESTRO.path,
|
||||
last_activity: "2026-08-21T10:00:00Z",
|
||||
};
|
||||
|
||||
const OTHER: Project = {
|
||||
path: "/home/user/tea/rev",
|
||||
name: "rev",
|
||||
is_worktree: false,
|
||||
last_activity: "2026-08-19T10:00:00Z",
|
||||
};
|
||||
|
||||
function stubFetch(projects: Project[]): string[] {
|
||||
const urls: string[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
urls.push(url);
|
||||
const body = url.includes("/api/projects")
|
||||
? projects
|
||||
: url.includes("/api/names")
|
||||
? { names: ["amber-fox"] }
|
||||
: url.includes("/api/branches")
|
||||
? []
|
||||
: { hostname: "h", version: "0", home_dir: "/home/user", presets: [] };
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}),
|
||||
);
|
||||
return urls;
|
||||
}
|
||||
|
||||
describe("spawnForm worktree step (#95)", () => {
|
||||
let handle: SpawnFormHandle | null = null;
|
||||
let urls: string[] = [];
|
||||
let changes: SpawnFormValues[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ now: Date.parse("2026-08-23T12:00:00Z") });
|
||||
changes = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
handle?.destroy();
|
||||
handle = null;
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const drain = async () => {
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
async function mount(projects: Project[]): Promise<HTMLFormElement> {
|
||||
urls = stubFetch(projects);
|
||||
handle = createSpawnForm({ onSuccess() {}, onChange: (v) => changes.push(v) });
|
||||
document.body.appendChild(handle.el);
|
||||
await drain();
|
||||
return handle.el;
|
||||
}
|
||||
|
||||
const step = (form: HTMLFormElement) => form.querySelector<HTMLElement>(".wt-step")!;
|
||||
const choices = (form: HTMLFormElement) => [
|
||||
...form.querySelectorAll<HTMLButtonElement>(".wt-choice"),
|
||||
];
|
||||
const activeChoice = (form: HTMLFormElement) =>
|
||||
form.querySelector<HTMLButtonElement>(".wt-choice.is-active");
|
||||
const box = (form: HTMLFormElement, path: string) =>
|
||||
form.querySelector<HTMLButtonElement>(`.project-box[data-cwd="${path}"]`)!;
|
||||
const toggle = (form: HTMLFormElement) =>
|
||||
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!;
|
||||
|
||||
it("hides the worktree step until a project is picked", async () => {
|
||||
const form = await mount([MAESTRO, WT_A]);
|
||||
expect(step(form).hidden).toBe(true);
|
||||
expect(choices(form)).toEqual([]);
|
||||
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
|
||||
expect(step(form).hidden).toBe(false);
|
||||
});
|
||||
|
||||
it("lists '+ new' first and selected by default, then the project's worktrees", async () => {
|
||||
const form = await mount([MAESTRO, WT_A, WT_B]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
|
||||
const labels = choices(form).map((c) => c.querySelector(".wt-choice-name")!.textContent);
|
||||
expect(labels).toEqual(["+ new", "bulgarians", "spawn-v2"]);
|
||||
expect(activeChoice(form)!.dataset.kind).toBe("new");
|
||||
});
|
||||
|
||||
it("taking the '+ new' default runs in the repo with the create toggle on", async () => {
|
||||
const form = await mount([MAESTRO, WT_A]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
|
||||
expect(changes.at(-1)!.cwd).toBe(MAESTRO.path);
|
||||
expect(changes.at(-1)!.worktreeEnabled).toBe(true);
|
||||
expect(toggle(form).checked).toBe(true);
|
||||
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain(MAESTRO.path);
|
||||
// The scan already knows the repo — no probe.
|
||||
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
|
||||
});
|
||||
|
||||
it("picking an existing worktree runs in it with the create toggle off (#91)", async () => {
|
||||
const form = await mount([MAESTRO, WT_A, WT_B]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
|
||||
const existing = choices(form).find((c) => c.dataset.cwd === WT_A.path)!;
|
||||
existing.click();
|
||||
await drain();
|
||||
|
||||
expect(changes.at(-1)!.cwd).toBe(WT_A.path);
|
||||
expect(changes.at(-1)!.worktreeEnabled).toBe(false);
|
||||
expect(toggle(form).checked).toBe(false);
|
||||
expect(form.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
|
||||
expect(existing.classList.contains("is-active")).toBe(true);
|
||||
// Still no probe: the scan reported this worktree's repo membership.
|
||||
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
|
||||
// The project box stays marked — it is what the worktree list belongs to.
|
||||
expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true);
|
||||
});
|
||||
|
||||
it("switching back to '+ new' re-enables the create toggle", async () => {
|
||||
const form = await mount([MAESTRO, WT_A]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
choices(form)
|
||||
.find((c) => c.dataset.cwd === WT_A.path)!
|
||||
.click();
|
||||
await drain();
|
||||
|
||||
choices(form)
|
||||
.find((c) => c.dataset.kind === "new")!
|
||||
.click();
|
||||
await drain();
|
||||
|
||||
expect(changes.at(-1)!.cwd).toBe(MAESTRO.path);
|
||||
expect(changes.at(-1)!.worktreeEnabled).toBe(true);
|
||||
expect(toggle(form).checked).toBe(true);
|
||||
});
|
||||
|
||||
it("repopulates and resets to '+ new' when another project is picked", async () => {
|
||||
const form = await mount([MAESTRO, WT_A, OTHER]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
choices(form)
|
||||
.find((c) => c.dataset.cwd === WT_A.path)!
|
||||
.click();
|
||||
await drain();
|
||||
|
||||
box(form, OTHER.path).click();
|
||||
await drain();
|
||||
|
||||
expect(choices(form).map((c) => c.dataset.kind)).toEqual(["new"]);
|
||||
expect(activeChoice(form)!.dataset.kind).toBe("new");
|
||||
expect(changes.at(-1)!.cwd).toBe(OTHER.path);
|
||||
expect(changes.at(-1)!.worktreeEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults to the worktree itself when the box is a linked worktree (#91)", async () => {
|
||||
// No main checkout in the scan, so the orphan worktree is its own box.
|
||||
const form = await mount([WT_A]);
|
||||
box(form, WT_A.path).click();
|
||||
await drain();
|
||||
|
||||
expect(activeChoice(form)!.dataset.cwd).toBe(WT_A.path);
|
||||
expect(changes.at(-1)!.cwd).toBe(WT_A.path);
|
||||
expect(changes.at(-1)!.worktreeEnabled).toBe(false);
|
||||
expect(toggle(form).checked).toBe(false);
|
||||
expect(form.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("hides the worktree step again when the operator takes the raw-input hatch", async () => {
|
||||
const form = await mount([MAESTRO, WT_A]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
expect(step(form).hidden).toBe(false);
|
||||
|
||||
form.querySelector<HTMLButtonElement>(".project-other")!.click();
|
||||
await drain();
|
||||
|
||||
expect(step(form).hidden).toBe(true);
|
||||
expect(choices(form)).toEqual([]);
|
||||
expect(form.querySelector(".project-box.is-active")).toBeNull();
|
||||
});
|
||||
|
||||
it("restores the picked worktree from a draft seed", async () => {
|
||||
urls = stubFetch([MAESTRO, WT_A, WT_B]);
|
||||
handle = createSpawnForm({
|
||||
onSuccess() {},
|
||||
seed: { cwd: WT_A.path, cwdRawVisible: false, worktreeEnabled: false },
|
||||
});
|
||||
document.body.appendChild(handle.el);
|
||||
await drain();
|
||||
const form = handle.el;
|
||||
|
||||
expect(step(form).hidden).toBe(false);
|
||||
expect(activeChoice(form)!.dataset.cwd).toBe(WT_A.path);
|
||||
expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true);
|
||||
expect(toggle(form).checked).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user