Compare commits

..
Author SHA1 Message Date
Miguel Palhas 431f9def6b Merge branch 'blitz/spawn-v2' of https://git.naps.pt/naps62/maestro into spawn-v2/56-worktree-place 2026-08-23 20:17:21 +01:00
Miguel Palhas d8acc3adde feat(maestro): always clean up worktrees on kill behind a dirty-work guard 2026-08-23 20:17:08 +01:00
Miguel Palhas 6f8de8de7c fix(maestro): draft-pane regressions from the integration review
Six findings from reviewing the accumulated blitz/spawn-v2 diff:

- loadRecents restored the seeded cwd but never emitted it, so the leaf's
  draft kept cwd:"" and the next render reseeded the form blank.
- stripDrafts did not recurse, so every half-filled draft in a split tab
  was written to localStorage; the old test passed only because hydrate
  discards draft on the way back in.
- showRawCwd and selectPreset grabbed focus from async init paths, so a
  draft pane stole the caret seconds after mounting.
- .mv-draft inherited no layout once the form left .modal-body.
- onSuccess mounted the new session onto a leaf that may have been closed
  while the POST was in flight, stranding it in no pane.
2026-08-23 20:12:28 +01:00
Miguel Palhas 018f44727c feat(maestro): place worktrees at <repo>/worktrees/<branch> 2026-08-23 20:09:38 +01:00
Miguel Palhas 85e7749e5a feat(maestro): worktree specs can check out an existing branch 2026-08-23 20:05:26 +01:00
Miguel Palhas 35bc5bfb40 merge: tui text-input widget (#58) 2026-08-23 20:00:47 +01:00
Miguel Palhas 059ae86fb8 Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/58-tui-textinput 2026-08-23 19:58:03 +01:00
Miguel Palhas e3996dec76 feat(maestro): wire spawn overlay fields to the text-input widget 2026-08-23 19:57:24 +01:00
Miguel Palhas 503f01ae26 merge: web inline draft pane (#57) 2026-08-23 19:54:50 +01:00
Miguel Palhas dfc4ee91e1 feat(maestro): add shared text-input widget with cursor motion and word ops 2026-08-23 19:50:37 +01:00
Miguel Palhas d5485b8f5d feat(maestro): add home/end/delete and bracketed-paste InputKey variants 2026-08-23 19:44:14 +01:00
25 changed files with 1243 additions and 160 deletions
+25 -2
View File
@@ -67,8 +67,10 @@ pub struct SpawnArgs {
/// Branch name for the worktree (required with --worktree-from).
#[arg(long)]
pub branch: Option<String>,
/// Remove the worktree when the session is killed.
#[arg(long)]
/// Remove the worktree when the session is killed. Kept for wire
/// compatibility (#56): the daemon now always cleans up on kill, guarded
/// against unsaved work.
#[arg(long, default_value_t = true)]
pub cleanup_on_kill: bool,
/// After spawning, attach immediately.
#[arg(long, short = 'a')]
@@ -147,6 +149,27 @@ 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"),
}
}
#[test]
fn spawn_worktree_requires_branch() {
let err = Cli::try_parse_from([
+1
View File
@@ -44,6 +44,7 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
base_repo: repo.clone(),
branch: branch.clone(),
cleanup_on_kill: args.cleanup_on_kill,
create_branch: true,
}),
_ => None,
};
+3 -2
View File
@@ -87,8 +87,9 @@ pub struct DaemonSection {
/// Root for everything daemon-local: sidecars, FIFOs, etc.
#[serde(default)]
pub state_dir: Option<PathBuf>,
/// Root for ephemeral worktrees the daemon creates on `CreateSession`
/// with a `worktree` spec.
/// Override root for ephemeral worktrees the daemon creates on
/// `CreateSession` with a `worktree` spec. Unset by default: worktrees
/// then live inside their base repo at `<repo>/worktrees/<branch>` (#56).
#[serde(default)]
pub worktree_root: Option<PathBuf>,
/// Per-session PTY replay buffer cap in bytes (NAP-229). Each new attach
+2 -1
View File
@@ -47,7 +47,8 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
let _ = default_state_dir();
std::fs::create_dir_all(config.sidecar_dir())?;
std::fs::create_dir_all(config.fifo_dir())?;
std::fs::create_dir_all(&config.worktree_root)?;
// No worktree dir to pre-create: without an override the worktrees live
// under their base repos, created on demand (#56).
let sidecar = SidecarStore::new(config.sidecar_dir());
let worktree_mgr = WorktreeManager::new(config.worktree_root.clone());
+6 -6
View File
@@ -53,7 +53,10 @@ pub struct DaemonConfig {
pub home_dir: String,
pub bind_addr: SocketAddr,
pub state_dir: PathBuf,
pub worktree_root: PathBuf,
/// `[daemon].worktree_root` override (#56). `None` — the default — places
/// worktrees inside their base repo at `<repo>/worktrees/<branch>`;
/// operators who want them elsewhere set this.
pub worktree_root: Option<PathBuf>,
/// Per-session PTY replay buffer cap in bytes (NAP-229). Each new attach
/// receives this much of the prior PTY output before the live stream.
/// Default `pty::DEFAULT_REPLAY_BYTES`; `0` disables.
@@ -131,9 +134,6 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
Some(p) => p,
None => default_state_dir()?,
};
let worktree_root = daemon
.worktree_root
.unwrap_or_else(|| state_dir.join("worktrees"));
let pty_replay_bytes = daemon
.pty_replay_bytes
@@ -152,7 +152,7 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
home_dir,
bind_addr,
state_dir,
worktree_root,
worktree_root: daemon.worktree_root,
pty_replay_bytes,
tmux_tmpdir,
spawn_history_days,
@@ -188,7 +188,7 @@ mod tests {
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.bind_addr, default_bind_addr());
assert!(cfg.state_dir.ends_with("maestro"));
assert!(cfg.worktree_root.ends_with("worktrees"));
assert!(cfg.worktree_root.is_none());
assert!(!cfg.hostname.is_empty());
}
+27 -24
View File
@@ -38,13 +38,12 @@ pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
pub struct SessionRecord {
pub session: Session,
pub fifo_path: PathBuf,
/// Worktree we created at spawn time and that should be cleaned up on
/// kill (if `cleanup_worktree_on_kill`).
/// Worktree we created at spawn time; removed on kill unless the
/// dirty-work guard refuses (#56).
pub worktree_path: Option<PathBuf>,
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
/// worktree remove` is invoked from the base repo.
pub worktree_base_repo: Option<PathBuf>,
pub cleanup_worktree_on_kill: bool,
pub bridge: PtyBridge,
}
@@ -213,24 +212,20 @@ impl SessionManager {
/// Spawn a new session. On success returns the freshly-created `Session`.
pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
let (effective_cwd, worktree_path, worktree_base_repo) = match &req.worktree {
Some(WorktreeSpec {
base_repo, branch, ..
}) => {
let path = self
.inner
.worktree_mgr
.add(base_repo, branch)
.map_err(SessionError::Worktree)?;
Some(spec) => {
let WorktreeSpec {
base_repo, branch, ..
} = spec;
let path = if spec.create_branch {
self.inner.worktree_mgr.add_new(base_repo, branch)
} else {
self.inner.worktree_mgr.add_existing(base_repo, branch)
}
.map_err(SessionError::Worktree)?;
(path.clone(), Some(path), Some(base_repo.clone()))
}
None => (req.cwd.clone(), None, None),
};
let cleanup_worktree_on_kill = req
.worktree
.as_ref()
.map(|w| w.cleanup_on_kill)
.unwrap_or(false);
let id = Uuid::new_v4();
// Build the FIFO + reader task *before* asking tmux to spawn the
// command — otherwise the child's first bytes race the separate
@@ -315,7 +310,6 @@ impl SessionManager {
fifo_path,
worktree_path,
worktree_base_repo,
cleanup_worktree_on_kill,
bridge,
},
);
@@ -357,8 +351,9 @@ impl SessionManager {
}
}
/// Kill a session and remove its on-disk artifacts. The worktree is only
/// pruned if the create request set `cleanup_on_kill: true`.
/// Kill a session and remove its on-disk artifacts. The worktree is
/// always pruned -- unless it holds uncommitted changes or commits not on
/// any other ref, in which case the guard leaves it on disk (#56).
///
/// This is also how an already-dead session is dismissed. tmux is only
/// asked to kill something it still has — calling `kill-session` on a
@@ -454,7 +449,6 @@ impl SessionManager {
fifo_path,
worktree_path: None,
worktree_base_repo: None,
cleanup_worktree_on_kill: false,
bridge,
},
);
@@ -703,9 +697,18 @@ async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kil
if let Err(e) = inner.sidecar.delete(id) {
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
}
if record.cleanup_worktree_on_kill {
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
let _ = inner.worktree_mgr.remove(base, path);
// Cleanup is unconditional now (#56): `cleanup_on_kill` is no longer a
// user-facing choice. The manager's guard is what protects unsaved work --
// when it refuses, leave the tree on disk and say where it is so the
// operator can find it.
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
if let Err(e) = inner.worktree_mgr.remove(base, path) {
tracing::warn!(
%id,
path = %path.display(),
error = %e,
"left worktree on disk"
);
}
}
let _ = inner.events.send(CatalogEvent::SessionRemoved { id });
+143 -29
View File
@@ -4,64 +4,113 @@
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
pub enum WorktreeError {
#[error("failed to spawn git: {0}")]
Spawn(#[from] std::io::Error),
#[error("git exited with code {code}: {stderr}")]
NonZero { code: i32, stderr: String },
/// A worktree already occupies the target path. The caller decides what
/// to do; the daemon never relocates to a different directory (#56).
#[error("worktree path already exists: {}", .path.display())]
PathExists { path: PathBuf },
/// The worktree holds unsaved work; removal was refused (#56).
#[error("left {} on disk: {}", .path.display(), .reason)]
Guarded { path: PathBuf, reason: String },
}
/// Generates a unique path under `root` and runs `git worktree add` rooted at
/// `base_repo`. Each call appends a UUID-suffixed directory so concurrent
/// `add()`s never collide.
/// Runs `git worktree add` rooted at `base_repo`. By default worktrees land
/// inside the repository itself at `<repo>/worktrees/<branch>` (#56) so they
/// are findable from the repo; an operator-set root overrides that.
#[derive(Debug, Clone)]
pub struct WorktreeManager {
root: PathBuf,
/// `[daemon].worktree_root` override. `None` uses the default
/// `<repo>/worktrees/<branch>` placement.
root_override: Option<PathBuf>,
}
impl WorktreeManager {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
pub fn new(root_override: Option<PathBuf>) -> Self {
Self { root_override }
}
pub fn root(&self) -> &Path {
&self.root
pub fn root(&self) -> Option<&Path> {
self.root_override.as_deref()
}
/// `git -C <base_repo> worktree add -b <branch> <generated-path>`.
/// Returns the path of the new worktree on success.
pub fn add(&self, base_repo: &Path, branch: &str) -> Result<PathBuf, WorktreeError> {
std::fs::create_dir_all(&self.root)?;
let path = self.generate_path(branch);
let out = Command::new("git")
.arg("-C")
.arg(base_repo)
.args(["worktree", "add", "-b", branch])
.arg(&path)
.output()?;
/// Where a worktree for `branch` will be created: the override root, or
/// `<base_repo>/worktrees/<sanitized branch>` by default. The path is
/// predictable (no UUID suffix), so an existing directory at the target
/// is an error rather than a silently different location.
fn target_path(&self, base_repo: &Path, branch: &str) -> PathBuf {
let slug = sanitize_branch_for_path(branch);
match &self.root_override {
Some(root) => root.join(slug),
None => base_repo.join("worktrees").join(slug),
}
}
/// `git -C <base_repo> worktree add -b <branch> <path>` — creates the
/// branch. Returns the path of the new worktree on success.
pub fn add_new(&self, base_repo: &Path, branch: &str) -> Result<PathBuf, WorktreeError> {
self.run_add(base_repo, branch, true)
}
/// `git -C <base_repo> worktree add <path> <branch>` — checks out a branch
/// that must already exist (#56).
pub fn add_existing(&self, base_repo: &Path, branch: &str) -> Result<PathBuf, WorktreeError> {
self.run_add(base_repo, branch, false)
}
fn run_add(
&self,
base_repo: &Path,
branch: &str,
create_branch: bool,
) -> Result<PathBuf, WorktreeError> {
let path = self.target_path(base_repo, branch);
if path.symlink_metadata().is_ok() {
return Err(WorktreeError::PathExists { path });
}
// The parent (`<repo>/worktrees` or the override root) may not exist
// yet; the target itself must not.
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut cmd = Command::new("git");
cmd.arg("-C").arg(base_repo).args(["worktree", "add"]);
if create_branch {
cmd.arg("-b").arg(branch).arg(&path);
} else {
// Existing branch: `worktree add <path> <branch>` with no `-b`.
cmd.arg(&path).arg(branch);
}
let out = cmd.output()?;
check(out)?;
Ok(path)
}
/// `git -C <base_repo> worktree remove --force <path>`.
/// `git -C <base_repo> worktree remove <path>` — but only when the
/// worktree holds nothing unique (#56). A worktree with uncommitted
/// changes, or with commits reachable from no other ref, is refused and
/// left on disk so the operator can rescue it; cleanup is unconditional,
/// which makes this guard the only thing standing between a stray kill
/// and destroyed work.
pub fn remove(&self, base_repo: &Path, path: &Path) -> Result<(), WorktreeError> {
if let Some(reason) = unsafe_to_remove(path)? {
return Err(WorktreeError::Guarded {
path: path.to_path_buf(),
reason,
});
}
let out = Command::new("git")
.arg("-C")
.arg(base_repo)
.args(["worktree", "remove", "--force"])
.args(["worktree", "remove"])
.arg(path)
.output()?;
check(out)
}
fn generate_path(&self, branch: &str) -> PathBuf {
let slug = sanitize_branch_for_path(branch);
let unique = Uuid::new_v4().simple();
self.root.join(format!("{slug}-{unique}"))
}
}
fn sanitize_branch_for_path(branch: &str) -> String {
@@ -79,6 +128,71 @@ fn sanitize_branch_for_path(branch: &str) -> String {
out
}
/// `Some(reason)` when deleting `path` would destroy work: uncommitted
/// changes, or commits reachable from no ref other than the worktree's own
/// branch (`git worktree remove` keeps the branch, but nothing checks it out
/// anymore — treat its commits as at risk anyway, per #56).
fn unsafe_to_remove(path: &Path) -> Result<Option<String>, WorktreeError> {
let status = run_git(path, ["status", "--porcelain"])?;
if !status.stdout.is_empty() {
return Ok(Some("has uncommitted changes".into()));
}
let head = String::from_utf8_lossy(&run_git(path, ["rev-parse", "HEAD"])?.stdout)
.trim()
.to_string();
let branch =
String::from_utf8_lossy(&run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"])?.stdout)
.trim()
.to_string();
// Every ref except this worktree's own branch counts as a safe home for
// its commits. Detached HEAD excludes everything.
let refs =
String::from_utf8_lossy(&run_git(path, ["for-each-ref", "--format=%(refname)"])?.stdout)
.lines()
.filter(|r| match branch.as_str() {
"HEAD" => true,
b => *r != format!("refs/heads/{b}"),
})
.map(str::to_string)
.collect::<Vec<_>>();
let mut cmd = Command::new("git");
cmd.arg("-C").arg(path).args(["rev-list", "--count", &head]);
if !refs.is_empty() {
cmd.arg("--not").args(&refs);
}
let out = cmd.output()?;
check(out.clone())?;
let count: usize = String::from_utf8_lossy(&out.stdout)
.trim()
.parse()
.unwrap_or(0);
if count > 0 {
return Ok(Some(format!(
"has {count} commit(s) not present on any other ref"
)));
}
Ok(None)
}
fn run_git(
dir: &Path,
args: impl IntoIterator<Item = &'static str>,
) -> Result<Output, WorktreeError> {
let mut cmd = Command::new("git");
cmd.arg("-C").arg(dir).args(args);
let out = cmd.output()?;
if !out.status.success() {
return Err(WorktreeError::NonZero {
code: out.status.code().unwrap_or(-1),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
});
}
Ok(out)
}
fn check(out: Output) -> Result<(), WorktreeError> {
if out.status.success() {
Ok(())
+2 -4
View File
@@ -115,7 +115,6 @@ pub struct DaemonFixture {
pub tmux: TmuxFixture,
pub manager: SessionManager,
pub state_dir: TempDir,
pub worktree_root: TempDir,
}
impl DaemonFixture {
@@ -132,9 +131,9 @@ impl DaemonFixture {
fn build(history_days: Option<u32>) -> Self {
let tmux = TmuxFixture::new();
let state_dir = TempDir::new().unwrap();
let worktree_root = TempDir::new().unwrap();
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
let worktree_mgr = WorktreeManager::new(worktree_root.path().to_path_buf());
// Default placement: worktrees land under their base repo (#56).
let worktree_mgr = WorktreeManager::new(None);
let mut manager = SessionManager::new(
Arc::new(tmux.tmux.clone()),
sidecar,
@@ -149,7 +148,6 @@ impl DaemonFixture {
tmux,
manager,
state_dir,
worktree_root,
}
}
@@ -38,7 +38,7 @@ impl Fx {
let manager = SessionManager::new(
fake.clone(),
SidecarStore::new(pick(tmp.path())),
WorktreeManager::new(tmp.path().join("worktrees")),
WorktreeManager::new(Some(tmp.path().join("worktrees"))),
fifo_dir,
);
Self { manager, fake, tmp }
@@ -387,7 +387,7 @@ fn attaching_spawn_history_after_cloning_panics() {
let manager = SessionManager::new(
fake,
SidecarStore::new(tmp.path().join("sidecars")),
WorktreeManager::new(tmp.path().join("worktrees")),
WorktreeManager::new(Some(tmp.path().join("worktrees"))),
tmp.path().join("fifos"),
);
let _clone = manager.clone();
+70 -6
View File
@@ -7,9 +7,9 @@ use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use common::{wait_for_async, DaemonFixture};
use common::{init_repo, wait_for_async, DaemonFixture};
use maestro_protocol::CreateSessionRequest;
use maestro_protocol::{CatalogEvent, SessionStatus};
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
use tempfile::TempDir;
#[tokio::test]
@@ -123,8 +123,7 @@ async fn recover_adopts_existing_tmux_sessions() {
// build a parallel manager pointing at the same dirs + same tmux server
let sidecar = maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir());
let worktree_mgr =
maestro_daemon::worktree::WorktreeManager::new(fx.worktree_root.path().to_path_buf());
let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(None);
let revived = maestro_daemon::SessionManager::new(
Arc::new(fx.tmux.tmux.clone()),
sidecar,
@@ -214,7 +213,6 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
let sidecar_dir = fx.sidecar_dir();
let fifo_dir = fx.fifo_dir();
let tmux = fx.tmux.tmux.clone();
let worktree_root = fx.worktree_root.path().to_path_buf();
// Drop the original manager. Its PtyBridge closes the FIFO's only
// reader, so tmux's `cat >> fifo` writer hits SIGPIPE and exits — but
@@ -223,7 +221,7 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
drop(fx.manager);
let sidecar = maestro_daemon::sidecar::SidecarStore::new(sidecar_dir);
let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(worktree_root);
let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(None);
let revived =
maestro_daemon::SessionManager::new(Arc::new(tmux), sidecar, worktree_mgr, fifo_dir);
assert_eq!(revived.recover().await.expect("recover"), 1);
@@ -365,6 +363,72 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
assert!(!fx.fifo_dir().join(format!("{}.fifo", s.id)).exists());
}
/// Cleanup is unconditional (#56): even a spec that says "don't clean up"
/// loses its worktree on kill — the guard, not the flag, protects work.
#[tokio::test]
async fn kill_removes_worktree_even_when_spec_says_keep() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let wt_path = repo.path().join("worktrees").join("feat-keep");
let s = fx
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/keep".into(),
cleanup_on_kill: false,
create_branch: true,
}),
})
.await
.expect("create");
assert!(wt_path.exists());
fx.manager.kill(s.id).await.expect("kill");
assert!(!wt_path.exists(), "worktree must be removed on kill");
}
/// The guard (#56): a worktree holding uncommitted changes survives the kill,
/// left on disk where the operator can find it.
#[tokio::test]
async fn kill_leaves_worktree_with_unsaved_work_on_disk() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let wt_path = repo.path().join("worktrees").join("feat-guarded");
let s = fx
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/guarded".into(),
cleanup_on_kill: true,
create_branch: true,
}),
})
.await
.expect("create");
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
fx.manager.kill(s.id).await.expect("kill");
assert!(
wt_path.exists(),
"dirty worktree must be left on disk, not destroyed"
);
assert!(fx.manager.snapshot().await.is_empty());
}
#[tokio::test]
async fn liveness_watcher_leaves_running_sessions_alone() {
let fx = DaemonFixture::new();
+164 -9
View File
@@ -31,9 +31,9 @@ fn add_creates_worktree_on_new_branch() {
init_repo(&repo);
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(wt_root.path());
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let path = mgr.add(&repo, "feat/example").expect("add worktree");
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
assert!(path.exists());
assert!(path.join("README").exists());
assert!(path.starts_with(wt_root.path()));
@@ -42,6 +42,63 @@ fn add_creates_worktree_on_new_branch() {
assert!(branches.iter().any(|b| b == "feat/example"), "{branches:?}");
}
#[test]
fn add_places_worktree_inside_the_base_repo_by_default() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
// No override: the worktree must land where the repository keeps its
// own worktrees (#56), not under some daemon state dir.
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
assert_eq!(path, repo.join("worktrees").join("feat-example"));
assert!(path.exists());
assert!(path.join("README").exists());
let branches = worktree_branches(&repo);
assert!(branches.iter().any(|b| b == "feat/example"), "{branches:?}");
}
#[test]
fn worktree_root_override_redirects_placement() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
assert_eq!(path, wt_root.path().join("feat-example"));
assert!(path.exists());
}
#[test]
fn existing_directory_at_target_path_is_an_error_carrying_the_path() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
// A predictable path means collisions are possible; they must be
// reported, never silently relocated.
std::fs::create_dir_all(repo.join("worktrees").join("feat-collide")).unwrap();
let mgr = WorktreeManager::new(None);
let err = mgr.add_new(&repo, "feat/collide").unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains(
repo.join("worktrees")
.join("feat-collide")
.to_str()
.unwrap()
),
"{msg}"
);
}
#[test]
fn add_twice_generates_distinct_paths() {
let repo_dir = TempDir::new().unwrap();
@@ -49,10 +106,10 @@ fn add_twice_generates_distinct_paths() {
init_repo(&repo);
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(wt_root.path());
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let p1 = mgr.add(&repo, "feat/a").expect("add a");
let p2 = mgr.add(&repo, "feat/b").expect("add b");
let p1 = mgr.add_new(&repo, "feat/a").expect("add a");
let p2 = mgr.add_new(&repo, "feat/b").expect("add b");
assert_ne!(p1, p2);
assert!(p1.exists());
assert!(p2.exists());
@@ -65,9 +122,9 @@ fn remove_prunes_worktree() {
init_repo(&repo);
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(wt_root.path());
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let path = mgr.add(&repo, "feat/gone").expect("add");
let path = mgr.add_new(&repo, "feat/gone").expect("add");
assert!(path.exists());
mgr.remove(&repo, &path).expect("remove");
@@ -79,6 +136,104 @@ fn remove_prunes_worktree() {
assert_eq!(branches, vec!["main".to_string()]);
}
#[test]
fn add_existing_checks_out_branch_without_creating_it() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
// Pre-create the branch with its own commit so we can tell a checkout
// from an accidental `-b`.
let created = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["checkout", "-q", "-b", "feat/dup"])
.status()
.unwrap();
assert!(created.success());
std::fs::write(repo.join("FEATURE"), b"dup\n").unwrap();
let staged = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["add", "."])
.status()
.unwrap();
assert!(staged.success());
let committed = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["commit", "-q", "-am", "wip"])
.status()
.unwrap();
assert!(committed.success());
let back = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["checkout", "-q", "main"])
.status()
.unwrap();
assert!(back.success());
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let path = mgr.add_existing(&repo, "feat/dup").expect("add existing");
assert!(path.exists());
assert!(path.join("README").exists());
// The branch set is unchanged: `feat/dup` was checked out, not recreated.
let branches = worktree_branches(&repo);
let mut sorted = branches.clone();
sorted.sort();
assert_eq!(
sorted,
vec!["feat/dup".to_string(), "main".to_string()],
"{branches:?}"
);
}
#[test]
fn remove_refuses_worktree_with_uncommitted_changes() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/dirty").expect("add");
std::fs::write(path.join("README"), b"unsaved edits\n").unwrap();
mgr.remove(&repo, &path)
.expect_err("dirty worktree must be refused");
assert!(path.exists(), "guarded worktree must stay on disk");
// Committing the change doesn't help while it is the only copy…
run_git(&path, &["add", "."]);
run_git(&path, &["commit", "-q", "-m", "unsaved"]);
mgr.remove(&repo, &path)
.expect_err("unique commit must be refused");
assert!(path.exists());
// …but once the commit is reachable from main, removal is safe.
run_git(&repo, &["merge", "-q", "--no-edit", "feat/dirty"]);
mgr.remove(&repo, &path).expect("merged worktree can go");
assert!(!path.exists());
}
fn run_git(dir: &std::path::Path, args: &[&str]) {
let out = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("git spawn");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn add_fails_when_branch_already_exists() {
let repo_dir = TempDir::new().unwrap();
@@ -95,9 +250,9 @@ fn add_fails_when_branch_already_exists() {
assert!(status.success());
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(wt_root.path());
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let err = mgr.add(&repo, "feat/dup").unwrap_err();
let err = mgr.add_new(&repo, "feat/dup").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("git exited"), "{msg}");
}
+87 -7
View File
@@ -114,7 +114,7 @@ async fn multi_attach_sees_same_output() {
}
#[tokio::test(flavor = "multi_thread")]
async fn cleanup_on_kill_removes_worktree() {
async fn kill_removes_clean_worktree() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
@@ -129,18 +129,19 @@ async fn cleanup_on_kill_removes_worktree() {
base_repo: base.path().to_path_buf(),
branch: "feature/e2e".into(),
cleanup_on_kill: true,
create_branch: true,
}),
};
let s = client.spawn_session(&req).await.expect("spawn worktree");
// The daemon's WorktreeManager puts new worktrees under
// `<state_dir>/worktrees/<branch>`. We can't peek the daemon's state dir
// directly from here, but we can verify by asking git: a worktree must
// exist that lives outside the original repo.
// Worktrees live inside the base repo at `<repo>/worktrees/<branch>`
// (#56), so `git worktree list` must show the predictable in-repo path.
let worktrees = git_worktree_list(base.path());
assert!(
worktrees.iter().any(|w| w.contains("feature/e2e")),
"expected a worktree for feature/e2e branch, got: {worktrees:?}"
worktrees
.iter()
.any(|w| w.contains("worktrees/feature-e2e")),
"expected a worktree for feature/e2e under <repo>/worktrees, got: {worktrees:?}"
);
client.kill_session(s.id).await.expect("kill");
@@ -160,6 +161,85 @@ async fn cleanup_on_kill_removes_worktree() {
);
}
/// The guard (#56): killing a session whose worktree holds uncommitted
/// changes must leave the tree on disk — cleanup is unconditional, so this
/// guard is what keeps a stray kill from destroying work.
#[tokio::test(flavor = "multi_thread")]
async fn kill_spares_worktree_with_uncommitted_changes() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let wt_path = base.path().join("worktrees").join("feature-unsaved");
let req = CreateSessionRequest {
cwd: base.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: base.path().to_path_buf(),
branch: "feature/unsaved".into(),
cleanup_on_kill: true,
create_branch: true,
}),
};
let s = client.spawn_session(&req).await.expect("spawn worktree");
assert!(wt_path.exists());
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
client.kill_session(s.id).await.expect("kill");
// The session is gone but the dirty worktree stays findable on disk.
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
let sessions = client.list_sessions().await.unwrap_or_default();
if sessions.is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
assert!(
wt_path.exists(),
"guarded worktree must be left on disk after kill"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn spawn_on_existing_branch_checks_it_out() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
run_git(&["branch", "feature/existing"], base.path());
let req = CreateSessionRequest {
cwd: base.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: base.path().to_path_buf(),
branch: "feature/existing".into(),
cleanup_on_kill: true,
create_branch: false,
}),
};
let s = client
.spawn_session(&req)
.await
.expect("spawn on existing branch");
let worktrees = git_worktree_list(base.path());
assert!(
worktrees.iter().any(|w| w.contains("feature/existing")),
"expected a worktree checked out at feature/existing, got: {worktrees:?}"
);
client.kill_session(s.id).await.expect("kill");
}
#[tokio::test(flavor = "multi_thread")]
async fn daemon_restart_recovers_session() {
let (mut daemon, client) = bring_up().await;
+39 -1
View File
@@ -111,8 +111,20 @@ pub enum ActivityState {
pub struct WorktreeSpec {
pub base_repo: PathBuf,
pub branch: String,
#[serde(default)]
/// Kept on the wire for CLI compatibility (#56) but no longer a
/// user-facing choice: the daemon always cleans up on kill, guarded
/// against unsaved work. Defaults to `true`.
#[serde(default = "default_true")]
pub cleanup_on_kill: bool,
/// When false, `branch` must already exist and is checked out as-is
/// (#56); when true (the default, and what every payload meant before
/// the field existed) the daemon creates it with `worktree add -b`.
#[serde(default = "default_true")]
pub create_branch: bool,
}
fn default_true() -> bool {
true
}
/// HTTP `POST /api/sessions` body.
@@ -393,6 +405,7 @@ mod tests {
base_repo: PathBuf::from("/home/naps62/projects/maestro"),
branch: "feat/foo".into(),
cleanup_on_kill: true,
create_branch: true,
}),
};
assert_eq!(req, round_trip(&req));
@@ -410,6 +423,31 @@ mod tests {
assert!(req.metadata.is_empty());
}
/// A client that predates the new/existing discriminator sends no
/// `create_branch`; the daemon must read that as "create a new branch" —
/// exactly what every payload meant before the field existed (#56).
#[test]
fn worktree_spec_defaults_on_legacy_payload() {
let json = serde_json::json!({
"base_repo": "/home/naps62/projects/maestro",
"branch": "feat/foo",
});
let spec: WorktreeSpec = serde_json::from_value(json).unwrap();
assert!(spec.create_branch);
assert!(spec.cleanup_on_kill);
}
#[test]
fn worktree_spec_round_trips_existing_branch_mode() {
let spec = WorktreeSpec {
base_repo: PathBuf::from("/work"),
branch: "feat/foo".into(),
cleanup_on_kill: true,
create_branch: false,
};
assert_eq!(spec, round_trip(&spec));
}
#[test]
fn catalog_event_round_trips_and_tags() {
let session = sample_session();
+10 -7
View File
@@ -190,8 +190,8 @@ impl App {
}
SpawnInputResult::Submit => {
effects.push(Effect::Spawn(CreateSessionRequest {
cwd: std::path::PathBuf::from(self.spawn.cwd.clone()),
command: self.spawn.command.clone(),
cwd: std::path::PathBuf::from(self.spawn.cwd.value().to_string()),
command: self.spawn.command.value().to_string(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
@@ -267,8 +267,8 @@ impl App {
}
Action::OpenSpawnModal => {
self.spawn = SpawnModal {
cwd: String::new(),
command: String::new(),
cwd: Default::default(),
command: Default::default(),
focused_field: Default::default(),
};
self.state.show_overlay(OverlayKind::Spawn);
@@ -348,7 +348,10 @@ impl App {
InputKey::Down => b"\x1b[B".to_vec(),
InputKey::Right => b"\x1b[C".to_vec(),
InputKey::Left => b"\x1b[D".to_vec(),
InputKey::Mouse { .. } => return None,
InputKey::Home => b"\x1b[H".to_vec(),
InputKey::End => b"\x1b[F".to_vec(),
InputKey::Delete => b"\x1b[3~".to_vec(),
InputKey::Paste(_) | InputKey::Mouse { .. } => return None,
};
Some(Effect::Forward { id: sid, bytes })
}
@@ -587,8 +590,8 @@ mod tests {
fn submitting_the_spawn_modal_carries_the_typed_fields() {
let mut app = make_app();
app.dispatch(Action::OpenSpawnModal);
app.spawn.cwd = "/srv/work".into();
app.spawn.command = "claude".into();
app.spawn.cwd = crate::text_input::TextInput::with_value("/srv/work");
app.spawn.command = crate::text_input::TextInput::with_value("claude");
app.spawn.focused_field = crate::overlays::SpawnField::Submit;
let (_, effects) = app.handle_overlay_input(&InputKey::Enter);
+32
View File
@@ -34,6 +34,12 @@ pub enum InputKey {
Down,
Left,
Right,
Home,
End,
Delete,
/// Bracketed paste: crossterm delivers the whole payload as one event, so
/// text fields can insert it as a single operation.
Paste(String),
Mouse {
kind: MouseKind,
region: Region,
@@ -126,6 +132,12 @@ impl InputKey {
InputKey::Down => "down".into(),
InputKey::Left => "left".into(),
InputKey::Right => "right".into(),
InputKey::Home => "home".into(),
InputKey::End => "end".into(),
InputKey::Delete => "delete".into(),
// Pastes are not bindable; the canonical form exists only to
// satisfy exhaustiveness and never round-trips through `parse`.
InputKey::Paste(_) => "paste".into(),
InputKey::Mouse { kind, region, .. } => {
let k = match kind {
MouseKind::Click => "click",
@@ -191,6 +203,9 @@ impl InputKey {
"down" => return Ok(InputKey::Down),
"left" => return Ok(InputKey::Left),
"right" => return Ok(InputKey::Right),
"home" => return Ok(InputKey::Home),
"end" => return Ok(InputKey::End),
"delete" => return Ok(InputKey::Delete),
_ => {}
}
// Modifier chain: ctrl-, alt-, shift- prefixes, then a single char.
@@ -262,9 +277,13 @@ where
KeyCode::Down => InputKey::Down,
KeyCode::Left => InputKey::Left,
KeyCode::Right => InputKey::Right,
KeyCode::Home => InputKey::Home,
KeyCode::End => InputKey::End,
KeyCode::Delete => InputKey::Delete,
_ => return None,
})
}
CtEvent::Paste(text) => Some(InputKey::Paste(text.clone())),
CtEvent::Mouse(MouseEvent {
kind, column, row, ..
}) => {
@@ -578,12 +597,25 @@ mod tests {
"down",
"left",
"right",
"home",
"end",
"delete",
] {
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s, "round-trip {s:?}");
}
}
/// A bracketed paste must arrive as one key carrying the whole payload,
/// not a burst of per-character keys.
#[test]
fn canonicalise_paste_event_carries_the_whole_payload() {
use crossterm::event::Event as CtEvent;
let ev = CtEvent::Paste("/srv/work/maestro".into());
let k = canonicalise(&ev, |_, _| Region::Unknown).unwrap();
assert_eq!(k, InputKey::Paste("/srv/work/maestro".into()));
}
#[test]
fn canonical_round_trips_mouse() {
let s = "mouse:click(pane)";
+1
View File
@@ -18,6 +18,7 @@ pub mod sidebar;
pub mod state;
pub mod tabs;
pub mod terminal;
pub mod text_input;
use std::path::PathBuf;
use std::time::Duration;
+89 -44
View File
@@ -20,8 +20,8 @@ pub enum SpawnField {
#[derive(Debug, Default, Clone)]
pub struct SpawnModal {
pub cwd: String,
pub command: String,
pub cwd: crate::text_input::TextInput,
pub command: crate::text_input::TextInput,
pub focused_field: SpawnField,
}
@@ -64,27 +64,16 @@ impl SpawnModal {
SpawnInputResult::Consumed
}
},
InputKey::Char { c, .. } => {
// Everything else — characters, motion, editing, paste — goes to
// the focused field's text input.
other => {
match self.focused_field {
SpawnField::Cwd => self.cwd.push(*c),
SpawnField::Command => self.command.push(*c),
SpawnField::Cwd => self.cwd.handle_key(other),
SpawnField::Command => self.command.handle_key(other),
SpawnField::Submit => {}
}
SpawnInputResult::Consumed
}
InputKey::Backspace => {
match self.focused_field {
SpawnField::Cwd => {
self.cwd.pop();
}
SpawnField::Command => {
self.command.pop();
}
_ => {}
}
SpawnInputResult::Consumed
}
_ => SpawnInputResult::Consumed,
}
}
@@ -107,20 +96,40 @@ impl SpawnModal {
Style::default()
}
};
let field_row = |label: &str, value: &str, style: Style| {
Paragraph::new(Line::from(vec![
ratatui::text::Span::raw(format!("{label:8} ")),
ratatui::text::Span::styled(value.to_string(), style),
]))
// Each text field renders through the shared `TextInput`, which
// handles horizontal scrolling and reports where the terminal cursor
// belongs. Label is fixed-width 8 chars + 1 space = column 9.
let label =
|text: &str| Paragraph::new(Line::from(ratatui::text::Span::raw(format!("{text:8} "))));
f.render_widget(label("cwd:"), rows[0]);
f.render_widget(label("command:"), rows[1]);
let draw_field = |f: &mut Frame,
input: &crate::text_input::TextInput,
row: Rect,
focused: bool|
-> u16 {
let style = if focused {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
let area = Rect {
x: row.x.saturating_add(9),
y: row.y,
width: row.width.saturating_sub(9),
height: 1,
};
input.render(area, f.buffer_mut(), style)
};
f.render_widget(
field_row("cwd:", &self.cwd, highlight(SpawnField::Cwd)),
rows[0],
);
f.render_widget(
field_row("command:", &self.command, highlight(SpawnField::Command)),
let cwd_col = draw_field(f, &self.cwd, rows[0], self.focused_field == SpawnField::Cwd);
let cmd_col = draw_field(
f,
&self.command,
rows[1],
self.focused_field == SpawnField::Command,
);
f.render_widget(
Paragraph::new(Line::from(vec![ratatui::text::Span::styled(
"[ Spawn ]",
@@ -129,20 +138,9 @@ impl SpawnModal {
rows[2],
);
// Place the cursor at the end of the currently-focused text field so
// the user can see where their typing lands. Label is fixed-width
// 8 chars + 1 space = column 9 inside the row.
let cursor_col_for = |value: &str| -> u16 {
inner
.x
.saturating_add(9)
.saturating_add(value.chars().count() as u16)
};
match self.focused_field {
SpawnField::Cwd => f.set_cursor_position((cursor_col_for(&self.cwd), rows[0].y)),
SpawnField::Command => {
f.set_cursor_position((cursor_col_for(&self.command), rows[1].y))
}
SpawnField::Cwd => f.set_cursor_position((cwd_col, rows[0].y)),
SpawnField::Command => f.set_cursor_position((cmd_col, rows[1].y)),
SpawnField::Submit => {
// The submit button is indicated by the REVERSED style; no
// cursor placement.
@@ -267,8 +265,8 @@ mod tests {
#[test]
fn spawn_modal_renders_field_labels() {
let modal = SpawnModal {
cwd: "/x".into(),
command: "bash".into(),
cwd: crate::text_input::TextInput::with_value("/x"),
command: crate::text_input::TextInput::with_value("bash"),
focused_field: SpawnField::Cwd,
};
let rendered = render_to_string(80, 20, |f| modal.render(f, f.area()));
@@ -338,4 +336,51 @@ mod tests {
assert_eq!(modal.focused_field, SpawnField::Cwd);
}
}
/// Editing keys must reach the focused field's text input, not a bare
/// String — this is what gives the operator cursor motion and word ops.
#[test]
fn editing_keys_reach_the_focused_text_input() {
let mut modal = SpawnModal::default();
assert_eq!(modal.focused_field, SpawnField::Cwd);
for k in [
crate::input::InputKey::char('/'),
crate::input::InputKey::char('t'),
crate::input::InputKey::char('m'),
crate::input::InputKey::char('p'),
] {
modal.on_input(&k);
}
modal.on_input(&crate::input::InputKey::Left);
modal.on_input(&crate::input::InputKey::Backspace);
assert_eq!(modal.cwd.value(), "/tp");
// Tab to command, then a paste and ctrl-w land there, not in cwd.
modal.on_input(&crate::input::InputKey::Tab);
assert_eq!(modal.focused_field, SpawnField::Command);
modal.on_input(&crate::input::InputKey::Paste("/tmp/foo".into()));
assert_eq!(modal.command.value(), "/tmp/foo");
modal.on_input(&crate::input::InputKey::ctrl_char('w'));
assert_eq!(modal.command.value(), "");
assert_eq!(modal.cwd.value(), "/tp", "cwd untouched while unfocused");
}
/// The terminal cursor must sit right after the typed text in the focused
/// field (label is 8 chars + 1 space, modal is centered on screen).
#[test]
fn render_places_the_cursor_after_the_typed_text() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let modal = SpawnModal {
cwd: crate::text_input::TextInput::with_value("abc"),
..SpawnModal::default()
};
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| modal.render(f, f.area())).unwrap();
// Modal: x = (80-60)/2 = 10; block border → inner.x = 11; label col 9.
let pos = terminal.get_cursor_position().unwrap();
assert_eq!(pos, ratatui::layout::Position::new(11 + 9 + 3, 6));
}
}
+15 -3
View File
@@ -5,7 +5,9 @@
use std::io;
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::event::{
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
@@ -26,7 +28,12 @@ impl TerminalGuard {
pub fn enter() -> Result<Self, TerminalError> {
enable_raw_mode()?;
let mut out = io::stdout();
execute!(out, EnterAlternateScreen, EnableMouseCapture)?;
execute!(
out,
EnterAlternateScreen,
EnableMouseCapture,
EnableBracketedPaste
)?;
Ok(TerminalGuard)
}
@@ -34,7 +41,12 @@ impl TerminalGuard {
/// call it without producing weird errors.
fn restore() {
let mut out = io::stdout();
let _ = execute!(out, DisableMouseCapture, LeaveAlternateScreen);
let _ = execute!(
out,
DisableBracketedPaste,
DisableMouseCapture,
LeaveAlternateScreen
);
let _ = disable_raw_mode();
}
}
+462
View File
@@ -0,0 +1,462 @@
//! Single-line text-input primitive shared by every TUI form field.
//!
//! Owns a `String`, a character-indexed cursor, and horizontal scroll state.
//! All editing goes through [`TextInput::handle_key`] so callers feed the same
//! canonical [`InputKey`] stream the rest of the app uses; bracketed paste
//! arrives as one [`InputKey::Paste`] and is inserted as a single operation.
//!
//! This is the one piece of the TUI form layer that unit-tests without a
//! terminal: drive it with `InputKey` sequences and inspect value/cursor.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use crate::input::InputKey;
#[derive(Debug, Default, Clone)]
pub struct TextInput {
value: String,
/// Cursor position as a char offset into `value`.
cursor: usize,
/// Char offset of the leftmost visible cell. A `Cell` so `render` can
/// re-clamp it without `&mut self` — widgets are drawn through shared
/// references in this codebase.
scroll: std::cell::Cell<usize>,
}
impl TextInput {
pub fn with_value(value: impl Into<String>) -> Self {
let mut input = Self {
value: value.into(),
..Self::default()
};
input.cursor = input.value.chars().count();
input
}
pub fn value(&self) -> &str {
&self.value
}
pub fn cursor(&self) -> usize {
self.cursor
}
/// Char offset of the leftmost visible cell; only meaningful after
/// [`TextInput::render`] has clamped it to the field width.
pub fn scroll_offset(&self) -> usize {
self.scroll.get()
}
/// Insert `text` at the cursor as one operation. Used both for typed
/// characters and for whole bracketed-paste payloads.
pub fn insert(&mut self, text: &str) {
let at = self.byte_offset(self.cursor);
self.value.insert_str(at, text);
self.cursor += text.chars().count();
}
pub fn handle_key(&mut self, key: &InputKey) {
match key {
InputKey::Char {
c,
ctrl: false,
alt: false,
..
} => self.insert(&c.to_string()),
InputKey::Char {
c: 'w', ctrl: true, ..
} => self.delete_word_backwards(),
InputKey::Char {
c: 'k', ctrl: true, ..
} => self.kill_to_end(),
// Emacs-style word motion; ctrl-arrow reaches us as a plain
// Left/Right today, so these are the dependable binding.
InputKey::Char {
c: 'b', ctrl: true, ..
} => self.cursor = self.word_left(),
InputKey::Char {
c: 'f', ctrl: true, ..
} => self.cursor = self.word_right(),
InputKey::Left => {
self.cursor = self.cursor.saturating_sub(1);
}
InputKey::Right => {
if self.cursor < self.value.chars().count() {
self.cursor += 1;
}
}
InputKey::Home => self.cursor = 0,
InputKey::End => self.cursor = self.value.chars().count(),
InputKey::Backspace => {
if self.cursor > 0 {
let at = self.byte_offset(self.cursor - 1);
let end = self.byte_offset(self.cursor);
self.value.replace_range(at..end, "");
self.cursor -= 1;
}
}
InputKey::Delete => {
if self.cursor < self.value.chars().count() {
let at = self.byte_offset(self.cursor);
let end = self.byte_offset(self.cursor + 1);
self.value.replace_range(at..end, "");
}
}
InputKey::Paste(text) => self.insert(text),
_ => {}
}
}
/// Draw into `area` (expected one row tall), scrolling horizontally so the
/// cursor stays visible, and return the screen column where the caller
/// should place the terminal cursor.
pub fn render(&self, area: Rect, buf: &mut Buffer, style: Style) -> u16 {
let width = area.width as usize;
let len = self.value.chars().count();
let scroll = self.scroll.get().min(len);
let scroll = if self.cursor < scroll {
self.cursor
} else if width > 0 && self.cursor.saturating_sub(scroll) >= width {
self.cursor + 1 - width
} else {
scroll
};
self.scroll.set(scroll);
for (col, c) in self.value.chars().skip(scroll).take(width).enumerate() {
buf[(area.x + col as u16, area.y)]
.set_symbol(&c.to_string())
.set_style(style);
}
area.x + self.cursor.saturating_sub(scroll) as u16
}
fn byte_offset(&self, char_index: usize) -> usize {
self.value
.char_indices()
.nth(char_index)
.map(|(b, _)| b)
.unwrap_or(self.value.len())
}
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
/// Start of the word before the cursor, skipping any separators first —
/// emacs `backward-word`.
fn word_left(&self) -> usize {
let chars: Vec<char> = self.value.chars().collect();
let mut i = self.cursor;
while i > 0 && !Self::is_word_char(chars[i - 1]) {
i -= 1;
}
while i > 0 && Self::is_word_char(chars[i - 1]) {
i -= 1;
}
i
}
/// Start of the word after the cursor, skipping the rest of the current
/// word first — emacs `forward-word`.
fn word_right(&self) -> usize {
let chars: Vec<char> = self.value.chars().collect();
let len = chars.len();
let mut i = self.cursor;
while i < len && !Self::is_word_char(chars[i]) {
i += 1;
}
while i < len && Self::is_word_char(chars[i]) {
i += 1;
}
i
}
fn kill_to_end(&mut self) {
let at = self.byte_offset(self.cursor);
self.value.truncate(at);
}
/// Shell-style ctrl-w: kill back to the previous whitespace, taking any
/// trailing whitespace run with it — so `cd /tmp/foo` loses the whole
/// path segment in one keystroke.
fn delete_word_backwards(&mut self) {
let chars: Vec<char> = self.value.chars().collect();
let mut i = self.cursor;
while i > 0 && chars[i - 1].is_whitespace() {
i -= 1;
}
while i > 0 && !chars[i - 1].is_whitespace() {
i -= 1;
}
let at = self.byte_offset(i);
let end = self.byte_offset(self.cursor);
self.value.replace_range(at..end, "");
self.cursor = i;
}
}
#[cfg(test)]
mod tests {
use super::*;
use InputKey::{Backspace, Delete, End, Home, Left, Right};
fn typed(s: &str) -> Vec<InputKey> {
s.chars().map(InputKey::char).collect()
}
fn fed(keys: &[InputKey]) -> TextInput {
let mut input = TextInput::default();
for k in keys {
input.handle_key(k);
}
input
}
fn ctrl(c: char) -> InputKey {
InputKey::ctrl_char(c)
}
#[test]
fn typing_lands_in_order() {
let input = fed(&typed("hello"));
assert_eq!(input.value(), "hello");
assert_eq!(input.cursor(), 5);
}
#[test]
fn backspace_removes_the_last_typed_char() {
let mut input = fed(&typed("hello"));
input.handle_key(&Backspace);
assert_eq!(input.value(), "hell");
assert_eq!(input.cursor(), 4);
}
#[test]
fn left_then_insert_fixes_a_middle_typo() {
let mut input = fed(&typed("helo"));
input.handle_key(&Left);
input.handle_key(&Left);
input.handle_key(&InputKey::char('l'));
assert_eq!(input.value(), "hello");
assert_eq!(input.cursor(), 3);
}
#[test]
fn motion_keys_stop_at_the_edges() {
let mut input = fed(&typed("abc"));
for _ in 0..5 {
input.handle_key(&Left);
}
assert_eq!(input.cursor(), 0);
input.handle_key(&Left);
assert_eq!(input.cursor(), 0);
for _ in 0..5 {
input.handle_key(&Right);
}
assert_eq!(input.cursor(), 3);
}
#[test]
fn home_and_end_jump_to_the_ends() {
let mut input = fed(&typed("abcdef"));
input.handle_key(&Home);
assert_eq!(input.cursor(), 0);
input.handle_key(&InputKey::char('x'));
assert_eq!(input.value(), "xabcdef");
input.handle_key(&End);
input.handle_key(&InputKey::char('!'));
assert_eq!(input.value(), "xabcdef!");
}
/// Cursor starts after the final `/`; word-left lands on word starts,
/// never inside a separator run.
#[test]
fn ctrl_b_walks_left_one_word_at_a_time() {
let mut input = TextInput::with_value("/tmp/foo bar-baz/");
assert_eq!(input.cursor(), 17);
for expected in [13, 9, 5, 1, 0, 0] {
input.handle_key(&ctrl('b'));
assert_eq!(input.cursor(), expected);
}
}
#[test]
fn ctrl_f_walks_right_one_word_at_a_time() {
let mut input = TextInput::with_value("/tmp/foo bar");
input.handle_key(&Home);
for expected in [4, 8, 12, 12] {
input.handle_key(&ctrl('f'));
assert_eq!(input.cursor(), expected);
}
}
#[test]
fn backspace_deletes_before_the_cursor() {
let mut input = fed(&typed("abcdef"));
input.handle_key(&Left);
input.handle_key(&Left);
input.handle_key(&Left);
input.handle_key(&Backspace);
assert_eq!(input.value(), "abdef");
assert_eq!(input.cursor(), 2);
}
#[test]
fn backspace_on_empty_input_is_a_noop() {
let mut input = TextInput::default();
input.handle_key(&Backspace);
assert_eq!(input.value(), "");
assert_eq!(input.cursor(), 0);
}
#[test]
fn delete_removes_the_char_under_the_cursor() {
let mut input = fed(&typed("abcdef"));
input.handle_key(&Home);
input.handle_key(&Delete);
assert_eq!(input.value(), "bcdef");
assert_eq!(input.cursor(), 0);
}
#[test]
fn delete_past_the_end_is_a_noop() {
let mut input = TextInput::with_value("abc");
input.handle_key(&End);
input.handle_key(&Delete);
assert_eq!(input.value(), "abc");
}
/// `cd /tmp/foo` with ctrl-w leaves `cd ` — the whole path segment is one
/// word to a shell (readline kills back to whitespace, spaces included).
#[test]
fn ctrl_w_deletes_the_word_behind_the_cursor() {
let mut input = fed(&[typed("cd ").as_slice(), typed("/tmp/foo").as_slice()].concat());
input.handle_key(&ctrl('w'));
assert_eq!(input.value(), "cd ");
assert_eq!(input.cursor(), 3);
}
#[test]
fn ctrl_w_stops_before_the_separator_run() {
let mut input = TextInput::with_value("a b");
input.cursor = 5;
input.handle_key(&ctrl('w'));
assert_eq!(input.value(), "a ");
assert_eq!(input.cursor(), 4);
}
#[test]
fn ctrl_k_kills_everything_after_the_cursor() {
let mut input = fed(&typed("spawn --debug extra"));
for _ in 0..14 {
input.handle_key(&Left);
}
input.handle_key(&ctrl('k'));
assert_eq!(input.value(), "spawn");
assert_eq!(input.cursor(), 5);
}
/// A paste arrives as one event carrying the whole payload and must land
/// at the cursor as a single insertion, not per-character.
#[test]
fn paste_inserts_whole_payload_at_the_cursor() {
let mut input = fed(&typed("cd "));
input.handle_key(&InputKey::Paste("/srv/work".into()));
assert_eq!(input.value(), "cd /srv/work");
assert_eq!(input.cursor(), 12);
// What follows still behaves like ordinary typed text.
input.handle_key(&InputKey::char('/'));
assert_eq!(input.value(), "cd /srv/work/");
}
#[test]
fn paste_between_existing_text_keeps_both_halves() {
let mut input = TextInput::with_value("ac");
input.handle_key(&Left);
input.handle_key(&InputKey::Paste("b×".into()));
assert_eq!(input.value(), "ab×c");
assert_eq!(input.cursor(), 3, "cursor counts chars, not bytes");
}
#[test]
fn rendering_an_empty_field_points_at_its_first_column() {
let input = TextInput::default();
let area = Rect::new(10, 5, 20, 1);
let mut buf = Buffer::empty(area);
let col = input.render(area, &mut buf, Style::default());
assert_eq!(col, 10);
}
#[test]
fn rendering_reports_the_visible_cursor_column() {
let mut input = TextInput::with_value("hello");
input.handle_key(&Left);
input.handle_key(&Left);
let area = Rect::new(10, 5, 20, 1);
let mut buf = Buffer::empty(area);
let col = input.render(area, &mut buf, Style::default());
assert_eq!(col, 13);
for (i, expected) in ["h", "e", "l", "l", "o"].iter().enumerate() {
assert_eq!(buf[(10 + i as u16, 5)].symbol(), *expected);
}
}
#[test]
fn rendering_a_value_wider_than_the_field_shows_its_tail() {
let value = "0123456789abcdefghij";
let input = TextInput::with_value(value);
let area = Rect::new(0, 0, 10, 1);
let mut buf = Buffer::empty(area);
let col = input.render(area, &mut buf, Style::default());
assert_eq!(col, 9, "cursor sits on the last visible column");
for (i, expected) in value.chars().skip(input.scroll_offset()).enumerate() {
assert_eq!(
buf[(i as u16, 0)].symbol(),
expected.to_string(),
"cell {i}"
);
}
}
/// Moving left into the hidden region must scroll the window back so the
/// cursor never disappears off the left edge.
#[test]
fn moving_left_into_the_hidden_region_scrolls_back() {
let value = "0123456789abcdefghij";
let mut input = TextInput::with_value(value);
let area = Rect::new(0, 0, 10, 1);
let mut buf = Buffer::empty(area);
input.render(area, &mut buf, Style::default());
assert_eq!(input.cursor(), 20);
input.handle_key(&Home);
for _ in 0..5 {
input.handle_key(&Right);
}
let mut buf = Buffer::empty(area);
let col = input.render(area, &mut buf, Style::default());
assert_eq!(input.cursor(), 5);
assert_eq!(
input.value().chars().nth(input.scroll_offset()),
Some('5'),
"window scrolled back to the cursor"
);
assert_eq!(col, 0);
assert_eq!(buf[(0, 0)].symbol(), "5");
}
#[test]
fn shrinking_the_value_clamps_scroll_and_cursor() {
let mut input = TextInput::with_value("0123456789abcdefghij");
input.handle_key(&Home);
input.handle_key(&ctrl('k'));
let area = Rect::new(0, 0, 10, 1);
let mut buf = Buffer::empty(area);
let col = input.render(area, &mut buf, Style::default());
assert_eq!(input.value(), "");
assert_eq!(col, 0);
}
}
+3 -2
View File
@@ -280,8 +280,9 @@ maestro daemon # foreground; or wire your own service manager
```
Every setting has a default, so `~/.config/maestro/config.toml` is optional —
create it only to change the bind address, worktree root, spawn presets, or TUI
keymap.
create it only to change the bind address, spawn presets, or TUI
keymap. (Worktrees live inside their base repos at `<repo>/worktrees/<branch>`;
set `[daemon].worktree_root` only to override that.)
## Non-Linux hosts
+9 -3
View File
@@ -152,10 +152,16 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
leaf.draft = values;
},
onSuccess: (session) => {
leaf.sessionId = session.id;
leaf.draft = null;
// The pane can be closed while the POST is in flight. Mounting the
// session onto a leaf no longer in the tree would strand it in no
// pane at all, so only claim the leaf while it is still there.
const t = activeTab(layout);
if (leaves(t.root).includes(leaf)) t.focusedLeafId = leaf.id;
const attached = leaves(t.root).includes(leaf);
if (attached) {
leaf.sessionId = session.id;
leaf.draft = null;
t.focusedLeafId = leaf.id;
}
render();
opts.onOpenSessionsChange?.();
},
+26
View File
@@ -99,4 +99,30 @@ describe("serializeLayout", () => {
}
expect(leaves(restored!.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1", null]);
});
it("keeps the draft out of the stored blob itself, not just out of hydrate", () => {
// A draft pane only exists once a tab has split, so the strip has to walk
// into split branches; asserting on the restored layout alone passes even
// when the draft was written to localStorage, because hydrate drops it.
const layout = hydrate({
tabs: [
{
id: "t",
root: {
kind: "split",
dir: "row",
a: { kind: "leaf", id: "l1", sessionId: "s1" },
b: { kind: "leaf", id: "l2", sessionId: null },
},
},
],
activeTabId: "t",
}) as Layout;
const draftLeaf = leaves(layout.tabs[0].root).find((l) => !l.sessionId)!;
draftLeaf.draft = { command: "claude", cwd: "/home/user/secret-project", name: "amber-fox" };
const raw = serializeLayout(layout);
expect(raw).not.toContain("secret-project");
expect(raw).not.toContain("amber-fox");
});
});
+6 -1
View File
@@ -34,7 +34,12 @@ export function serializeLayout(layout: Layout): string {
}
function stripDrafts(node: Node | null): Node | null {
if (!node || node.kind !== "leaf") return node;
if (!node) return node;
// Recurse: a draft pane only exists once a tab has split, so a shallow strip
// would persist every half-filled form it was meant to drop.
if (node.kind === "split") {
return { ...node, a: stripDrafts(node.a)!, b: stripDrafts(node.b)! };
}
return { ...node, draft: null };
}
+13 -7
View File
@@ -209,14 +209,17 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
syncBranchToName(); // seed the branch from the initial name
// ---- command tiles ---------------------------------------------------
function selectPreset(id: string): void {
// `focus` is false for the two initial applications (construction, and again
// when `/api/info` resolves): a draft pane must not grab the caret out from
// under the operator seconds after it mounts.
function selectPreset(id: string, focus = true): void {
selectedPresetId = id;
const preset = presets.find((p) => p.id === id);
const isCustom = !preset || preset.command === "";
cmdRaw.hidden = !isCustom;
if (isCustom) {
command = cmdRaw.value.trim();
cmdRaw.focus();
if (focus) cmdRaw.focus();
} else {
command = preset!.command;
}
@@ -270,17 +273,17 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
if (wanted) {
const match = presets.find((p) => p.command === wanted && p.command !== "");
if (match) {
selectPreset(match.id);
selectPreset(match.id, false);
} else {
const custom = presets.find((p) => p.command === "");
cmdRaw.value = wanted;
selectPreset(custom ? custom.id : "custom");
selectPreset(custom ? custom.id : "custom", false);
command = wanted;
}
return;
}
const first = presets.find((p) => p.command !== "");
if (first) selectPreset(first.id);
if (first) selectPreset(first.id, false);
}
// ---- working-directory picker ---------------------------------------
@@ -345,17 +348,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
cwdRaw.value = seedCwd;
markActiveRecent(cwd);
} else {
showRawCwd(seedCwd);
showRawCwd(seedCwd, false);
}
seedCwd = null;
} else if (dirs.length === 0) {
// No history: default to raw input prefilled with the daemon's home dir.
showRawCwd(homeDir);
showRawCwd(homeDir, false);
} else {
cwdRawWrap.hidden = true;
}
// The seeded cwd is in place — a restored worktree can probe now.
if (wtEnabled.checked) void probeGitRoot();
// Publish it: construction ran `emit()` with an empty cwd, so without this
// the leaf's draft keeps `cwd: ""` and the next render reseeds it blank.
emit();
}
function recentRow(dir: RecentDir): HTMLElement {
+6
View File
@@ -2079,6 +2079,12 @@ textarea {
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
/* The modal form got its stacking and padding from `.modal-body`; in a pane
the form is the host, so it carries them itself. */
display: flex;
flex-direction: column;
gap: var(--sp-2);
padding: var(--sp-3);
}
/* pane tools cluster (disconnect / split / close), top-right corner. The attach