Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a814d5b17 | ||
|
|
1809f5d581 | ||
|
|
33fb50be6e | ||
|
|
b6cf87244f | ||
|
|
6d21061799 | ||
|
|
431f9def6b | ||
|
|
d8acc3adde | ||
|
|
6f8de8de7c | ||
|
|
018f44727c | ||
|
|
85e7749e5a | ||
|
|
35bc5bfb40 | ||
|
|
059ae86fb8 | ||
|
|
e3996dec76 | ||
|
|
503f01ae26 | ||
|
|
dfc4ee91e1 | ||
|
|
d5485b8f5d |
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -87,8 +87,9 @@ pub struct DaemonSection {
|
||||
/// Root for everything daemon-local: sidecars, FIFOs, etc.
|
||||
#[serde(default)]
|
||||
pub state_dir: Option<PathBuf>,
|
||||
/// Root for ephemeral worktrees the daemon creates on `CreateSession`
|
||||
/// with a `worktree` spec.
|
||||
/// Override root for ephemeral worktrees the daemon creates on
|
||||
/// `CreateSession` with a `worktree` spec. Unset by default: worktrees
|
||||
/// then live inside their base repo at `<repo>/worktrees/<branch>` (#56).
|
||||
#[serde(default)]
|
||||
pub worktree_root: Option<PathBuf>,
|
||||
/// Per-session PTY replay buffer cap in bytes (NAP-229). Each new attach
|
||||
@@ -161,6 +162,15 @@ pub struct SpawnSection {
|
||||
/// it resolves to just the `custom` tile.
|
||||
#[serde(default)]
|
||||
pub presets: Option<Vec<SpawnPreset>>,
|
||||
/// Root directories the daemon scans for git repositories (#54), `~`
|
||||
/// expanded by the daemon against its own home. When absent the daemon
|
||||
/// scans `["~"]`.
|
||||
#[serde(default)]
|
||||
pub project_roots: Option<Vec<String>>,
|
||||
/// How many directory levels below each [`Self::project_roots`] entry the
|
||||
/// scanner descends (#54). When absent the daemon uses `3`.
|
||||
#[serde(default)]
|
||||
pub project_scan_depth: Option<u32>,
|
||||
}
|
||||
|
||||
impl SpawnSection {
|
||||
@@ -386,6 +396,7 @@ mod tests {
|
||||
label: "fish".into(),
|
||||
command: "fish".into(),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
let ids: Vec<String> = section
|
||||
.resolve()
|
||||
@@ -402,6 +413,7 @@ mod tests {
|
||||
// only the implicit custom tile.
|
||||
let section = SpawnSection {
|
||||
presets: Some(vec![]),
|
||||
..Default::default()
|
||||
};
|
||||
let ids: Vec<String> = section
|
||||
.resolve()
|
||||
@@ -420,6 +432,7 @@ mod tests {
|
||||
label: "x".into(),
|
||||
command: "x".into(),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(empty_id.resolve().unwrap_err(), SpawnConfigError::EmptyId);
|
||||
|
||||
@@ -429,6 +442,7 @@ mod tests {
|
||||
label: "x".into(),
|
||||
command: "".into(),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
empty_cmd.resolve().unwrap_err(),
|
||||
@@ -451,6 +465,7 @@ mod tests {
|
||||
command: "a2".into(),
|
||||
},
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
dup.resolve().unwrap_err(),
|
||||
@@ -463,6 +478,7 @@ mod tests {
|
||||
label: "Custom".into(),
|
||||
command: "whatever".into(),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
reserved.resolve().unwrap_err(),
|
||||
@@ -496,6 +512,30 @@ presets = [
|
||||
assert_eq!(ids, ["claude", "zsh", "custom"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_project_scan_fields_default_to_none() {
|
||||
// Absent [spawn].project_* → None; the daemon applies its own defaults.
|
||||
assert_eq!(SpawnSection::default().project_roots, None);
|
||||
assert_eq!(SpawnSection::default().project_scan_depth, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_project_scan_fields_parse_from_toml() {
|
||||
let cfg = toml::from_str::<MaestroConfigFile>(
|
||||
r#"
|
||||
[spawn]
|
||||
project_roots = ["~/code", "/srv/work"]
|
||||
project_scan_depth = 5
|
||||
"#,
|
||||
)
|
||||
.expect("parse");
|
||||
assert_eq!(
|
||||
cfg.spawn.project_roots,
|
||||
Some(vec!["~/code".to_string(), "/srv/work".to_string()])
|
||||
);
|
||||
assert_eq!(cfg.spawn.project_scan_depth, Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_file_loads_with_section_defaults() {
|
||||
// Only [daemon] present (typical single-host install).
|
||||
|
||||
@@ -47,7 +47,8 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
||||
let _ = default_state_dir();
|
||||
std::fs::create_dir_all(config.sidecar_dir())?;
|
||||
std::fs::create_dir_all(config.fifo_dir())?;
|
||||
std::fs::create_dir_all(&config.worktree_root)?;
|
||||
// No worktree dir to pre-create: without an override the worktrees live
|
||||
// under their base repos, created on demand (#56).
|
||||
|
||||
let sidecar = SidecarStore::new(config.sidecar_dir());
|
||||
let worktree_mgr = WorktreeManager::new(config.worktree_root.clone());
|
||||
@@ -71,11 +72,19 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
||||
tracing::info!(recovered, "adopted existing tmux sessions");
|
||||
manager.start_liveness_watcher(DEFAULT_LIVENESS_INTERVAL);
|
||||
|
||||
let projects = std::sync::Arc::new(crate::projects::ProjectScanner::new(
|
||||
config.project_roots.clone(),
|
||||
config.project_scan_depth,
|
||||
PathBuf::from(&config.home_dir),
|
||||
));
|
||||
projects.start_background_refresh();
|
||||
|
||||
let router = local_api::router(local_api::state_for(
|
||||
manager,
|
||||
config.hostname.clone(),
|
||||
config.home_dir.clone(),
|
||||
config.spawn_presets.clone(),
|
||||
projects,
|
||||
));
|
||||
|
||||
let listener = TcpListener::bind(config.bind_addr).await?;
|
||||
|
||||
@@ -23,6 +23,14 @@ pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:7070";
|
||||
/// `[daemon].spawn_history_days` is unset. `0` in config disables history.
|
||||
pub const DEFAULT_SPAWN_HISTORY_DAYS: u32 = 30;
|
||||
|
||||
/// Default scan roots for project discovery (#54) when `[spawn].project_roots`
|
||||
/// is unset: the whole home directory, with the scanner's own ignore list
|
||||
/// keeping it cheap.
|
||||
pub const DEFAULT_PROJECT_ROOTS: [&str; 1] = ["~"];
|
||||
/// Default directory depth for project discovery (#54) when
|
||||
/// `[spawn].project_scan_depth` is unset.
|
||||
pub const DEFAULT_PROJECT_SCAN_DEPTH: u32 = 3;
|
||||
|
||||
/// Env var that overrides `[daemon].bind_addr`.
|
||||
pub const ENV_DAEMON_BIND_ADDR: &str = "MAESTRO_DAEMON_BIND_ADDR";
|
||||
/// Env var that overrides `[daemon].tmux_tmpdir`.
|
||||
@@ -53,7 +61,10 @@ pub struct DaemonConfig {
|
||||
pub home_dir: String,
|
||||
pub bind_addr: SocketAddr,
|
||||
pub state_dir: PathBuf,
|
||||
pub worktree_root: PathBuf,
|
||||
/// `[daemon].worktree_root` override (#56). `None` — the default — places
|
||||
/// worktrees inside their base repo at `<repo>/worktrees/<branch>`;
|
||||
/// operators who want them elsewhere set this.
|
||||
pub worktree_root: Option<PathBuf>,
|
||||
/// Per-session PTY replay buffer cap in bytes (NAP-229). Each new attach
|
||||
/// receives this much of the prior PTY output before the live stream.
|
||||
/// Default `pty::DEFAULT_REPLAY_BYTES`; `0` disables.
|
||||
@@ -71,6 +82,12 @@ pub struct DaemonConfig {
|
||||
/// built-ins) with the implicit `custom` tile appended. Advertised to
|
||||
/// clients in `GET /api/info`.
|
||||
pub spawn_presets: Vec<maestro_config::SpawnPreset>,
|
||||
/// Project-discovery scan roots (#54): `[spawn].project_roots` (default
|
||||
/// [`DEFAULT_PROJECT_ROOTS`]) with `~` expanded against the daemon's home.
|
||||
pub project_roots: Vec<PathBuf>,
|
||||
/// Project-discovery depth (#54): `[spawn].project_scan_depth`, default
|
||||
/// [`DEFAULT_PROJECT_SCAN_DEPTH`].
|
||||
pub project_scan_depth: u32,
|
||||
}
|
||||
|
||||
impl DaemonConfig {
|
||||
@@ -131,9 +148,6 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
|
||||
Some(p) => p,
|
||||
None => default_state_dir()?,
|
||||
};
|
||||
let worktree_root = daemon
|
||||
.worktree_root
|
||||
.unwrap_or_else(|| state_dir.join("worktrees"));
|
||||
|
||||
let pty_replay_bytes = daemon
|
||||
.pty_replay_bytes
|
||||
@@ -147,19 +161,53 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
|
||||
.spawn_history_days
|
||||
.unwrap_or(DEFAULT_SPAWN_HISTORY_DAYS);
|
||||
|
||||
// `~` in scan roots means the daemon's own home; an unresolvable home
|
||||
// leaves the tilde as-is and the scanner treats the root as missing.
|
||||
let project_roots: Vec<PathBuf> = file
|
||||
.spawn
|
||||
.project_roots
|
||||
.unwrap_or_else(|| {
|
||||
DEFAULT_PROJECT_ROOTS
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
})
|
||||
.iter()
|
||||
.map(|root| expand_tilde(root, &home_dir))
|
||||
.collect();
|
||||
let project_scan_depth = file
|
||||
.spawn
|
||||
.project_scan_depth
|
||||
.unwrap_or(DEFAULT_PROJECT_SCAN_DEPTH);
|
||||
|
||||
Ok(DaemonConfig {
|
||||
hostname,
|
||||
home_dir,
|
||||
bind_addr,
|
||||
state_dir,
|
||||
worktree_root,
|
||||
worktree_root: daemon.worktree_root,
|
||||
pty_replay_bytes,
|
||||
tmux_tmpdir,
|
||||
spawn_history_days,
|
||||
spawn_presets,
|
||||
project_roots,
|
||||
project_scan_depth,
|
||||
})
|
||||
}
|
||||
|
||||
/// Expand a leading `~` (or `~/…`) against `home`. Other paths pass through.
|
||||
fn expand_tilde(path: &str, home: &str) -> PathBuf {
|
||||
match path.strip_prefix("~") {
|
||||
Some(_) if home.is_empty() => PathBuf::from(path),
|
||||
Some(rest) => {
|
||||
let mut p = PathBuf::from(home);
|
||||
p.push(rest.trim_start_matches('/'));
|
||||
p
|
||||
}
|
||||
None => PathBuf::from(path),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_state_dir() -> Result<PathBuf, ConfigError> {
|
||||
Ok(maestro_config::default_state_dir("maestro")?)
|
||||
}
|
||||
@@ -188,7 +236,7 @@ mod tests {
|
||||
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||
assert_eq!(cfg.bind_addr, default_bind_addr());
|
||||
assert!(cfg.state_dir.ends_with("maestro"));
|
||||
assert!(cfg.worktree_root.ends_with("worktrees"));
|
||||
assert!(cfg.worktree_root.is_none());
|
||||
assert!(!cfg.hostname.is_empty());
|
||||
}
|
||||
|
||||
@@ -280,6 +328,56 @@ spawn_history_days = 0
|
||||
assert_eq!(cfg.spawn_history_days, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_scan_defaults_to_home_root_at_depth_3() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(&path, "").unwrap();
|
||||
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||
assert_eq!(cfg.project_scan_depth, DEFAULT_PROJECT_SCAN_DEPTH);
|
||||
assert_eq!(
|
||||
cfg.project_roots,
|
||||
vec![PathBuf::from(cfg.home_dir.clone())],
|
||||
"bare ~ must expand to the daemon's home"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_scan_roots_expand_tilde_and_pass_through_absolutes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
[spawn]
|
||||
project_roots = ["~/code", "/srv/work"]
|
||||
project_scan_depth = 5
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||
assert_eq!(cfg.project_scan_depth, 5);
|
||||
assert_eq!(
|
||||
cfg.project_roots,
|
||||
vec![
|
||||
PathBuf::from(cfg.home_dir.clone()).join("code"),
|
||||
PathBuf::from("/srv/work"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilde_expansion_handles_bare_and_nested_forms() {
|
||||
assert_eq!(expand_tilde("~", "/home/u"), PathBuf::from("/home/u"));
|
||||
assert_eq!(
|
||||
expand_tilde("~/a/b", "/home/u"),
|
||||
PathBuf::from("/home/u/a/b")
|
||||
);
|
||||
assert_eq!(expand_tilde("/abs", "/home/u"), PathBuf::from("/abs"));
|
||||
// Unresolvable home leaves the tilde as-is rather than mangling it.
|
||||
assert_eq!(expand_tilde("~", ""), PathBuf::from("~"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_hostname_is_nonempty() {
|
||||
let h = system_hostname();
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod gitroot;
|
||||
pub mod local_api;
|
||||
pub mod panes;
|
||||
pub mod proc;
|
||||
pub mod projects;
|
||||
pub mod pty;
|
||||
pub mod session;
|
||||
pub mod sidecar;
|
||||
|
||||
@@ -36,6 +36,8 @@ pub struct LocalApiState {
|
||||
pub home_dir: Arc<String>,
|
||||
/// Resolved spawn presets (#22/#26) advertised in `GET /api/info`.
|
||||
pub presets: Arc<Vec<SpawnPreset>>,
|
||||
/// Project discovery (#54) backing `GET /api/projects`.
|
||||
pub projects: Arc<crate::projects::ProjectScanner>,
|
||||
}
|
||||
|
||||
pub fn router(state: LocalApiState) -> Router {
|
||||
@@ -44,6 +46,7 @@ pub fn router(state: LocalApiState) -> Router {
|
||||
.route(routes::patterns::SESSION, delete(kill_session))
|
||||
.route(routes::DIRS, get(list_dir))
|
||||
.route(routes::RECENT_DIRS, get(recent_dirs))
|
||||
.route(routes::PROJECTS, get(projects))
|
||||
.route(routes::GITROOT, get(git_root))
|
||||
.route(routes::INFO, get(daemon_info))
|
||||
.route(routes::EVENTS, any(events_ws))
|
||||
@@ -98,6 +101,15 @@ async fn recent_dirs(State(s): State<LocalApiState>) -> Response {
|
||||
Json(crate::spawn_history::to_wire(s.manager.recent_dirs())).into_response()
|
||||
}
|
||||
|
||||
/// Discovered git repositories and worktrees, newest activity first (#54).
|
||||
/// `last_activity` is the later of the repo's git HEAD mtime and the newest
|
||||
/// spawn-history use of any directory inside it.
|
||||
async fn projects(State(s): State<LocalApiState>) -> Response {
|
||||
let repos = s.projects.repos().await;
|
||||
let history = s.manager.all_recent_dirs();
|
||||
Json(crate::projects::to_projects(&repos, &history)).into_response()
|
||||
}
|
||||
|
||||
/// Probe the enclosing git repo of `path`, to infer the worktree `base_repo`
|
||||
/// for the spawn form (#26). `path` is resolved against the daemon's home dir
|
||||
/// (empty/`~`/relative), like [`list_dir`].
|
||||
@@ -157,18 +169,23 @@ fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
|
||||
}
|
||||
|
||||
/// Convenience constructor — pulls the bits `LocalApiState` needs from
|
||||
/// `DaemonConfig` so callers don't have to assemble it by hand.
|
||||
/// `DaemonConfig` so callers don't have to assemble it by hand. The project
|
||||
/// scanner starts with its cache cold; wire
|
||||
/// [`ProjectScanner::start_background_refresh`](crate::projects::ProjectScanner::start_background_refresh)
|
||||
/// on the returned handle's scanner to keep it eventually fresh.
|
||||
pub fn state_for(
|
||||
manager: SessionManager,
|
||||
hostname: impl Into<String>,
|
||||
home_dir: impl Into<String>,
|
||||
presets: Vec<SpawnPreset>,
|
||||
projects: Arc<crate::projects::ProjectScanner>,
|
||||
) -> LocalApiState {
|
||||
LocalApiState {
|
||||
manager,
|
||||
hostname: Arc::new(hostname.into()),
|
||||
home_dir: Arc::new(home_dir.into()),
|
||||
presets: Arc::new(presets),
|
||||
projects,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
//! Project discovery for `GET /api/projects` (#54).
|
||||
//!
|
||||
//! Scans `[spawn].project_roots` (default the whole home) for `.git` entries —
|
||||
//! directory or file, so linked worktrees count too — up to
|
||||
//! `[spawn].project_scan_depth` levels. Descent stops at the first repo found:
|
||||
//! nested independent repos are not discovered, but worktrees of every hit
|
||||
//! still are, via `git worktree list`, which also catches linked worktrees
|
||||
//! living outside the roots.
|
||||
//!
|
||||
//! Hidden directories are skipped everywhere; heavy well-known directories
|
||||
//! (`node_modules`, `Downloads`, …) are skipped when they sit directly in the
|
||||
//! home directory, so scanning all of home stays cheap.
|
||||
//!
|
||||
//! Results are cached behind a short TTL and refreshed on a slow background
|
||||
//! timer ([`ProjectScanner::start_background_refresh`]) — repositories appear
|
||||
//! rarely, and `/api/projects` may be polled.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::spawn_history::RecentDir;
|
||||
|
||||
/// Directory names never descended when they sit directly in the home dir, so
|
||||
/// scanning all of home stays cheap (#54). Hidden dirs are skipped everywhere
|
||||
/// regardless.
|
||||
pub const DEFAULT_HOME_IGNORE: &[&str] =
|
||||
&["node_modules", "Downloads", "Library", "go", "snap", "opt"];
|
||||
|
||||
/// How long a discovery snapshot is served as-is before the next request
|
||||
/// triggers a rescan. Short: a scan is a bounded filesystem walk plus one
|
||||
/// `git worktree list` per hit.
|
||||
pub const CACHE_TTL: Duration = Duration::from_secs(5);
|
||||
/// Background refresh cadence. Repositories appear rarely; this keeps the
|
||||
/// served list eventually fresh without anyone asking.
|
||||
pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// One repository or linked worktree found by [`discover`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DiscoveredRepo {
|
||||
pub path: PathBuf,
|
||||
/// Basename of `path`.
|
||||
pub name: String,
|
||||
pub is_worktree: bool,
|
||||
/// The main checkout this worktree belongs to; set only when
|
||||
/// [`Self::is_worktree`].
|
||||
pub base_repo: Option<PathBuf>,
|
||||
/// Mtime of the checkout's git HEAD — "someone committed / checked out
|
||||
/// here". The later-of merge with spawn history happens in
|
||||
/// [`to_projects`], which also sorts.
|
||||
pub head_mtime: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Repo toplevels under each root of `roots`, at most `max_depth` levels deep.
|
||||
/// A dir holding `.git` (dir *or* file) is a hit and is not descended into;
|
||||
/// hidden directories are never entered, and `DEFAULT_HOME_IGNORE` names are
|
||||
/// skipped directly under `home`.
|
||||
pub fn scan_roots(roots: &[PathBuf], max_depth: u32, home: &Path) -> Vec<PathBuf> {
|
||||
let mut found = Vec::new();
|
||||
for root in roots {
|
||||
walk(root, 0, max_depth, home, &mut found);
|
||||
}
|
||||
found.sort();
|
||||
found.dedup();
|
||||
found
|
||||
}
|
||||
|
||||
fn walk(dir: &Path, depth: u32, max_depth: u32, home: &Path, found: &mut Vec<PathBuf>) {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
let mut subdirs = Vec::new();
|
||||
let mut has_git = false;
|
||||
for entry in entries.flatten() {
|
||||
match entry.file_name().to_string_lossy().into_owned() {
|
||||
name if name == ".git" => has_git = true,
|
||||
name if name.starts_with('.') => {}
|
||||
name if dir == home && DEFAULT_HOME_IGNORE.contains(&name.as_str()) => {}
|
||||
_ => {
|
||||
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
||||
subdirs.push(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if has_git {
|
||||
// Match `git worktree list` output, which prints real paths.
|
||||
found.push(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
|
||||
return;
|
||||
}
|
||||
if depth >= max_depth {
|
||||
return;
|
||||
}
|
||||
for sub in subdirs {
|
||||
walk(&sub, depth + 1, max_depth, home, found);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every discovered project: the scanned repos plus all worktrees their
|
||||
/// `git worktree list` reports (linked worktrees can live outside the roots).
|
||||
/// A hit whose git invocation fails still lists, un-worktreed.
|
||||
pub fn discover(roots: &[PathBuf], max_depth: u32, home: &Path) -> Vec<DiscoveredRepo> {
|
||||
let scanned = scan_roots(roots, max_depth, home);
|
||||
let mut map: Vec<(PathBuf, PathBuf)> = Vec::new(); // (checkout, main)
|
||||
for repo in scanned {
|
||||
if map.iter().any(|(c, _)| c == &repo) {
|
||||
continue;
|
||||
}
|
||||
let worktrees = list_worktrees(&repo).unwrap_or_else(|| vec![repo.clone()]);
|
||||
let main = worktrees[0].clone();
|
||||
for wt in worktrees {
|
||||
if !map.iter().any(|(c, _)| c == &wt) {
|
||||
map.push((wt, main.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
map.into_iter()
|
||||
.map(|(path, main)| {
|
||||
let is_worktree = path != main;
|
||||
DiscoveredRepo {
|
||||
name: path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default(),
|
||||
is_worktree,
|
||||
base_repo: is_worktree.then_some(main),
|
||||
head_mtime: head_mtime(&path),
|
||||
path,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `git worktree list --porcelain` from `repo`: absolute paths, main first.
|
||||
/// `None` when git fails (broken/bare checkout).
|
||||
fn list_worktrees(repo: &Path) -> Option<Vec<PathBuf>> {
|
||||
let out = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(["worktree", "list", "--porcelain"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let mut paths = Vec::new();
|
||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||
if let Some(p) = line.strip_prefix("worktree ") {
|
||||
paths.push(PathBuf::from(p));
|
||||
}
|
||||
}
|
||||
(!paths.is_empty()).then_some(paths)
|
||||
}
|
||||
|
||||
/// Mtime of the checkout's HEAD file. For a linked worktree `.git` is a file
|
||||
/// holding a `gitdir:` pointer; resolve it so worktrees report their own HEAD
|
||||
/// rather than nothing.
|
||||
fn head_mtime(repo: &Path) -> Option<DateTime<Utc>> {
|
||||
let dot_git = repo.join(".git");
|
||||
let head = if dot_git.is_dir() {
|
||||
dot_git.join("HEAD")
|
||||
} else {
|
||||
let ptr = std::fs::read_to_string(&dot_git).ok()?;
|
||||
let gitdir = ptr.trim().strip_prefix("gitdir:")?.trim();
|
||||
// Relative pointers are relative to the worktree.
|
||||
let p = Path::new(gitdir);
|
||||
let p = if p.is_absolute() {
|
||||
p.to_path_buf()
|
||||
} else {
|
||||
repo.join(p)
|
||||
};
|
||||
p.join("HEAD")
|
||||
};
|
||||
let mtime = std::fs::metadata(head).ok()?.modified().ok()?;
|
||||
Some(DateTime::<Utc>::from(mtime))
|
||||
}
|
||||
|
||||
/// Fold spawn history into the discovery snapshot and produce the wire list,
|
||||
/// sorted newest-activity-first. `history` must be newest-first (what
|
||||
/// [`SpawnHistory::all_recent_dirs`](crate::spawn_history::SpawnHistory)
|
||||
/// returns): the first entry whose cwd sits inside a project is its most
|
||||
/// recent use.
|
||||
pub fn to_projects(
|
||||
repos: &[DiscoveredRepo],
|
||||
history: &[RecentDir],
|
||||
) -> Vec<maestro_protocol::Project> {
|
||||
let mut projects: Vec<maestro_protocol::Project> = repos
|
||||
.iter()
|
||||
.map(|repo| {
|
||||
let last_use = history.iter().find(|h| contains(&repo.path, &h.cwd));
|
||||
maestro_protocol::Project {
|
||||
path: repo.path.clone(),
|
||||
name: repo.name.clone(),
|
||||
is_worktree: repo.is_worktree,
|
||||
base_repo: repo.base_repo.clone(),
|
||||
last_activity: max_opt(repo.head_mtime, last_use.map(|u| u.last_used)),
|
||||
last_command: last_use.map(|u| u.last_command.clone()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
projects.sort_by_key(|p| std::cmp::Reverse(p.last_activity));
|
||||
projects
|
||||
}
|
||||
|
||||
/// Whether `dir` is `project` itself or anywhere inside it.
|
||||
fn contains(project: &Path, dir: &Path) -> bool {
|
||||
dir == project || dir.starts_with(project)
|
||||
}
|
||||
|
||||
fn max_opt(a: Option<DateTime<Utc>>, b: Option<DateTime<Utc>>) -> Option<DateTime<Utc>> {
|
||||
match (a, b) {
|
||||
(Some(a), Some(b)) => Some(a.max(b)),
|
||||
(a, b) => a.or(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached discovery behind a short TTL plus an optional slow background
|
||||
/// refresh. Clone-free shared handle via `Arc` in [`crate::local_api`].
|
||||
#[derive(Debug)]
|
||||
pub struct ProjectScanner {
|
||||
roots: Vec<PathBuf>,
|
||||
max_depth: u32,
|
||||
home: PathBuf,
|
||||
ttl: Duration,
|
||||
cache: tokio::sync::Mutex<Option<(Instant, std::sync::Arc<Vec<DiscoveredRepo>>)>>,
|
||||
}
|
||||
|
||||
impl ProjectScanner {
|
||||
/// Scanner over the resolved config roots. An empty `roots` list scans
|
||||
/// nothing and always serves an empty list.
|
||||
pub fn new(roots: Vec<PathBuf>, max_depth: u32, home: PathBuf) -> Self {
|
||||
Self {
|
||||
roots,
|
||||
max_depth,
|
||||
home,
|
||||
ttl: CACHE_TTL,
|
||||
cache: tokio::sync::Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Scanner that never discovers anything — tests and embedders that don't
|
||||
/// want the endpoint populated.
|
||||
pub fn disabled() -> Self {
|
||||
Self::new(Vec::new(), 0, PathBuf::new())
|
||||
}
|
||||
|
||||
/// Cached snapshot, rescanning (off-thread) when the TTL has lapsed.
|
||||
pub async fn repos(&self) -> std::sync::Arc<Vec<DiscoveredRepo>> {
|
||||
let mut cache = self.cache.lock().await;
|
||||
if let Some((at, repos)) = cache.as_ref() {
|
||||
if at.elapsed() < self.ttl {
|
||||
return repos.clone();
|
||||
}
|
||||
}
|
||||
let roots = self.roots.clone();
|
||||
let max_depth = self.max_depth;
|
||||
let home = self.home.clone();
|
||||
// Blocking fs walk + git subprocesses must not stall the reactor.
|
||||
let repos = std::sync::Arc::new(
|
||||
tokio::task::spawn_blocking(move || discover(&roots, max_depth, &home))
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
*cache = Some((Instant::now(), repos.clone()));
|
||||
repos
|
||||
}
|
||||
|
||||
/// Rescan now, ignoring the TTL. Called by the background timer.
|
||||
async fn refresh(&self) {
|
||||
self.repos().await;
|
||||
}
|
||||
|
||||
/// Spawn the slow background refresh task. Drop the returned handle to
|
||||
/// detach it; it stops when the runtime shuts down.
|
||||
pub fn start_background_refresh(self: &std::sync::Arc<Self>) -> tokio::task::JoinHandle<()> {
|
||||
let scanner = std::sync::Arc::clone(self);
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(REFRESH_INTERVAL);
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
ticker.tick().await; // the immediate first tick — wait a full interval
|
||||
loop {
|
||||
scanner.refresh().await;
|
||||
ticker.tick().await;
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::spawn_history::RecentDir;
|
||||
|
||||
/// Real git repo at `<root>/<name>`, one initial commit so HEAD exists.
|
||||
fn make_repo(root: &Path, name: &str) -> PathBuf {
|
||||
let path = root.join(name);
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
run_git(&path, &["init", "-q"]);
|
||||
std::fs::write(path.join("f.txt"), "x\n").unwrap();
|
||||
run_git(&path, &["add", "-A"]);
|
||||
run_git(
|
||||
&path,
|
||||
&[
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"commit",
|
||||
"-qm",
|
||||
"init",
|
||||
],
|
||||
);
|
||||
path
|
||||
}
|
||||
|
||||
fn run_git(dir: &Path, args: &[&str]) {
|
||||
let out = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(dir)
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {args:?} failed: {} | {}",
|
||||
String::from_utf8_lossy(&out.stderr),
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
);
|
||||
}
|
||||
|
||||
fn names(repos: &[DiscoveredRepo]) -> Vec<String> {
|
||||
let mut v: Vec<String> = repos.iter().map(|r| r.name.clone()).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_finds_repos_and_stops_at_the_first_hit() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let outer = make_repo(tmp.path(), "outer");
|
||||
// A nested independent repo inside `outer` is NOT discovered
|
||||
// separately: descent stops once a repo is found.
|
||||
make_repo(&outer, "vendored");
|
||||
std::fs::create_dir_all(tmp.path().join("plain/deep/deeper")).unwrap();
|
||||
|
||||
let found = scan_roots(
|
||||
&[tmp.path().to_path_buf()],
|
||||
3,
|
||||
Path::new("/nonexistent-home"),
|
||||
);
|
||||
assert_eq!(found, vec![outer]);
|
||||
|
||||
let discovered = discover(
|
||||
&[tmp.path().to_path_buf()],
|
||||
3,
|
||||
Path::new("/nonexistent-home"),
|
||||
);
|
||||
assert_eq!(names(&discovered), ["outer"]);
|
||||
assert!(!discovered[0].is_worktree);
|
||||
assert_eq!(discovered[0].base_repo, None);
|
||||
assert!(discovered[0].head_mtime.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_respects_depth_hidden_dirs_and_home_ignore() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path().join("home");
|
||||
|
||||
// Hidden dirs are never entered, at any depth.
|
||||
std::fs::create_dir_all(home.join(".secret/.git")).unwrap();
|
||||
// node_modules directly under home is not descended…
|
||||
let _ignored = make_repo(&home.join("node_modules"), "dep");
|
||||
// …but the same name deeper down is fair game.
|
||||
let kept = make_repo(&home.join("code/node_modules"), "dep2");
|
||||
|
||||
// Depth cap: a repo three levels below the root is the last tier —
|
||||
// found at depth 3, but a fourth-level repo isn't.
|
||||
let at_three = home.join("a/b/c");
|
||||
std::fs::create_dir_all(at_three.join(".git")).unwrap();
|
||||
std::fs::create_dir_all(home.join("a/b/c/d/.git")).unwrap();
|
||||
|
||||
let found = scan_roots(std::slice::from_ref(&home), 3, &home);
|
||||
assert!(found.iter().all(|p| !p.starts_with(home.join(".secret"))));
|
||||
assert!(found
|
||||
.iter()
|
||||
.all(|p| !p.starts_with(home.join("node_modules"))));
|
||||
assert!(found.contains(&kept.canonicalize().unwrap()));
|
||||
assert!(found.contains(&at_three.canonicalize().unwrap()));
|
||||
assert!(!found.iter().any(|p| p.ends_with("d")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktrees_outside_the_roots_are_discovered_via_git_worktree_list() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("scan-root");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let main = make_repo(&root, "main-repo");
|
||||
// Linked worktree deliberately outside the scan root.
|
||||
let wt = tmp.path().join("elsewhere/wt-of-main");
|
||||
std::fs::create_dir_all(wt.parent().unwrap()).unwrap();
|
||||
run_git(
|
||||
&main,
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
"-q",
|
||||
"-b",
|
||||
"feature",
|
||||
&wt.to_string_lossy(),
|
||||
],
|
||||
);
|
||||
|
||||
let discovered = discover(&[root], 3, Path::new("/nonexistent-home"));
|
||||
assert_eq!(names(&discovered), ["main-repo", "wt-of-main"]);
|
||||
let wt_entry = discovered.iter().find(|d| d.name == "wt-of-main").unwrap();
|
||||
assert!(wt_entry.is_worktree);
|
||||
assert_eq!(
|
||||
wt_entry.base_repo.as_deref(),
|
||||
Some(main.canonicalize().unwrap().as_path())
|
||||
);
|
||||
let main_entry = discovered.iter().find(|d| d.name == "main-repo").unwrap();
|
||||
assert!(!main_entry.is_worktree);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_mtime_resolves_a_worktrees_git_file_pointer() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let main = make_repo(tmp.path(), "m");
|
||||
let wt = tmp.path().join("wt");
|
||||
run_git(
|
||||
&main,
|
||||
&["worktree", "add", "-q", "-b", "b2", &wt.to_string_lossy()],
|
||||
);
|
||||
assert!(head_mtime(&wt).is_some());
|
||||
}
|
||||
|
||||
fn recent(cwd: &str, cmd: &str, at: &str) -> RecentDir {
|
||||
RecentDir {
|
||||
cwd: PathBuf::from(cwd),
|
||||
last_command: cmd.into(),
|
||||
last_used: DateTime::parse_from_rfc3339(at)
|
||||
.unwrap()
|
||||
.with_timezone(&Utc),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_projects_merges_history_and_sorts_newest_first() {
|
||||
let repo_old = DiscoveredRepo {
|
||||
path: PathBuf::from("/w/old"),
|
||||
name: "old".into(),
|
||||
is_worktree: false,
|
||||
base_repo: None,
|
||||
// Committed recently but never spawned in.
|
||||
head_mtime: Some(t("2026-08-23T10:00:00Z")),
|
||||
};
|
||||
let repo_hist = DiscoveredRepo {
|
||||
path: PathBuf::from("/w/hist"),
|
||||
name: "hist".into(),
|
||||
is_worktree: false,
|
||||
base_repo: None,
|
||||
// Old commit…
|
||||
head_mtime: Some(t("2026-08-01T10:00:00Z")),
|
||||
};
|
||||
let repo_stale = DiscoveredRepo {
|
||||
path: PathBuf::from("/w/stale"),
|
||||
name: "stale".into(),
|
||||
is_worktree: false,
|
||||
base_repo: None,
|
||||
head_mtime: None,
|
||||
};
|
||||
|
||||
let history = vec![
|
||||
// Newest overall: a subdir of /w/hist (first match wins for it).
|
||||
recent("/w/hist/crates", "opencode", "2026-08-22T09:00:00Z"),
|
||||
recent("/w/hist", "claude", "2026-08-20T09:00:00Z"),
|
||||
];
|
||||
let projects = to_projects(&[repo_old, repo_hist, repo_stale], &history);
|
||||
|
||||
let paths: Vec<&str> = projects.iter().map(|p| p.path.to_str().unwrap()).collect();
|
||||
assert_eq!(paths, ["/w/old", "/w/hist", "/w/stale"]);
|
||||
|
||||
// old: git activity wins (never spawned).
|
||||
assert_eq!(projects[0].last_activity, Some(t("2026-08-23T10:00:00Z")));
|
||||
assert_eq!(projects[0].last_command, None);
|
||||
// hist: spawn history beats the old commit; command from the newest
|
||||
// matching entry (the crates/ one, not the repo root one).
|
||||
assert_eq!(projects[1].last_activity, Some(t("2026-08-22T09:00:00Z")));
|
||||
assert_eq!(projects[1].last_command.as_deref(), Some("opencode"));
|
||||
// stale: neither source knows anything.
|
||||
assert_eq!(projects[2].last_activity, None);
|
||||
assert_eq!(projects[2].last_command, None);
|
||||
}
|
||||
|
||||
fn t(s: &str) -> DateTime<Utc> {
|
||||
DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_caches_within_ttl_and_rescans_after() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut scanner = ProjectScanner::new(
|
||||
vec![tmp.path().to_path_buf()],
|
||||
3,
|
||||
PathBuf::from("/nonexistent"),
|
||||
);
|
||||
|
||||
let empty = scanner.repos().await;
|
||||
assert!(empty.is_empty());
|
||||
|
||||
make_repo(tmp.path(), "late");
|
||||
// Within the TTL the cached (empty) snapshot is still served.
|
||||
let still_empty = scanner.repos().await;
|
||||
assert!(still_empty.is_empty());
|
||||
|
||||
// After the TTL lapses the rescan sees the new repo.
|
||||
scanner.ttl = Duration::ZERO;
|
||||
let fresh = scanner.repos().await;
|
||||
assert_eq!(fresh.len(), 1);
|
||||
assert_eq!(fresh[0].name, "late");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_scanner_always_serves_empty() {
|
||||
let scanner = ProjectScanner::disabled();
|
||||
assert_eq!(scanner.roots, Vec::<PathBuf>::new());
|
||||
}
|
||||
}
|
||||
@@ -38,13 +38,12 @@ pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
|
||||
pub struct SessionRecord {
|
||||
pub session: Session,
|
||||
pub fifo_path: PathBuf,
|
||||
/// Worktree we created at spawn time and that should be cleaned up on
|
||||
/// kill (if `cleanup_worktree_on_kill`).
|
||||
/// Worktree we created at spawn time; removed on kill unless the
|
||||
/// dirty-work guard refuses (#56).
|
||||
pub worktree_path: Option<PathBuf>,
|
||||
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
|
||||
/// worktree remove` is invoked from the base repo.
|
||||
pub worktree_base_repo: Option<PathBuf>,
|
||||
pub cleanup_worktree_on_kill: bool,
|
||||
pub bridge: PtyBridge,
|
||||
}
|
||||
|
||||
@@ -170,6 +169,16 @@ impl SessionManager {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Every deduped recent dir, newest-first, without the picker's result
|
||||
/// cap (#54): project enrichment folds the whole history.
|
||||
pub fn all_recent_dirs(&self) -> Vec<RecentDir> {
|
||||
self.inner
|
||||
.spawn_history
|
||||
.as_ref()
|
||||
.map(|h| h.all_recent_dirs())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Subscribe to session deltas. Each subscriber gets its own lag domain —
|
||||
/// a slow client makes only itself resync.
|
||||
pub fn events(&self) -> broadcast::Receiver<CatalogEvent> {
|
||||
@@ -213,24 +222,20 @@ impl SessionManager {
|
||||
/// Spawn a new session. On success returns the freshly-created `Session`.
|
||||
pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
|
||||
let (effective_cwd, worktree_path, worktree_base_repo) = match &req.worktree {
|
||||
Some(WorktreeSpec {
|
||||
base_repo, branch, ..
|
||||
}) => {
|
||||
let path = self
|
||||
.inner
|
||||
.worktree_mgr
|
||||
.add(base_repo, branch)
|
||||
.map_err(SessionError::Worktree)?;
|
||||
Some(spec) => {
|
||||
let WorktreeSpec {
|
||||
base_repo, branch, ..
|
||||
} = spec;
|
||||
let path = if spec.create_branch {
|
||||
self.inner.worktree_mgr.add_new(base_repo, branch)
|
||||
} else {
|
||||
self.inner.worktree_mgr.add_existing(base_repo, branch)
|
||||
}
|
||||
.map_err(SessionError::Worktree)?;
|
||||
(path.clone(), Some(path), Some(base_repo.clone()))
|
||||
}
|
||||
None => (req.cwd.clone(), None, None),
|
||||
};
|
||||
let cleanup_worktree_on_kill = req
|
||||
.worktree
|
||||
.as_ref()
|
||||
.map(|w| w.cleanup_on_kill)
|
||||
.unwrap_or(false);
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
// Build the FIFO + reader task *before* asking tmux to spawn the
|
||||
// command — otherwise the child's first bytes race the separate
|
||||
@@ -315,7 +320,6 @@ impl SessionManager {
|
||||
fifo_path,
|
||||
worktree_path,
|
||||
worktree_base_repo,
|
||||
cleanup_worktree_on_kill,
|
||||
bridge,
|
||||
},
|
||||
);
|
||||
@@ -357,8 +361,9 @@ impl SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill a session and remove its on-disk artifacts. The worktree is only
|
||||
/// pruned if the create request set `cleanup_on_kill: true`.
|
||||
/// Kill a session and remove its on-disk artifacts. The worktree is
|
||||
/// always pruned -- unless it holds uncommitted changes or commits not on
|
||||
/// any other ref, in which case the guard leaves it on disk (#56).
|
||||
///
|
||||
/// This is also how an already-dead session is dismissed. tmux is only
|
||||
/// asked to kill something it still has — calling `kill-session` on a
|
||||
@@ -454,7 +459,6 @@ impl SessionManager {
|
||||
fifo_path,
|
||||
worktree_path: None,
|
||||
worktree_base_repo: None,
|
||||
cleanup_worktree_on_kill: false,
|
||||
bridge,
|
||||
},
|
||||
);
|
||||
@@ -703,9 +707,18 @@ async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kil
|
||||
if let Err(e) = inner.sidecar.delete(id) {
|
||||
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
|
||||
}
|
||||
if record.cleanup_worktree_on_kill {
|
||||
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
|
||||
let _ = inner.worktree_mgr.remove(base, path);
|
||||
// Cleanup is unconditional now (#56): `cleanup_on_kill` is no longer a
|
||||
// user-facing choice. The manager's guard is what protects unsaved work --
|
||||
// when it refuses, leave the tree on disk and say where it is so the
|
||||
// operator can find it.
|
||||
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
|
||||
if let Err(e) = inner.worktree_mgr.remove(base, path) {
|
||||
tracing::warn!(
|
||||
%id,
|
||||
path = %path.display(),
|
||||
error = %e,
|
||||
"left worktree on disk"
|
||||
);
|
||||
}
|
||||
}
|
||||
let _ = inner.events.send(CatalogEvent::SessionRemoved { id });
|
||||
|
||||
@@ -143,11 +143,23 @@ impl SpawnHistory {
|
||||
/// Recent working dirs, newest use first, deduped by `cwd`, within the
|
||||
/// recency cap, capped at `max_results`. Empty when disabled or unreadable.
|
||||
pub fn recent_dirs(&self) -> Vec<RecentDir> {
|
||||
self.recent_dirs_as_of(Utc::now())
|
||||
self.recent_dirs_capped(Utc::now(), self.max_results)
|
||||
}
|
||||
|
||||
/// Every deduped recent dir, newest-first, without the picker's result
|
||||
/// cap (#54): project enrichment folds the whole history, not its first
|
||||
/// page, so a project spawned in long ago still reads as fresh.
|
||||
pub fn all_recent_dirs(&self) -> Vec<RecentDir> {
|
||||
self.recent_dirs_capped(Utc::now(), usize::MAX)
|
||||
}
|
||||
|
||||
/// [`recent_dirs`](Self::recent_dirs) with an explicit clock for testing.
|
||||
#[cfg(test)]
|
||||
fn recent_dirs_as_of(&self, now: DateTime<Utc>) -> Vec<RecentDir> {
|
||||
self.recent_dirs_capped(now, self.max_results)
|
||||
}
|
||||
|
||||
fn recent_dirs_capped(&self, now: DateTime<Utc>, max_results: usize) -> Vec<RecentDir> {
|
||||
if !self.is_enabled() {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -168,7 +180,7 @@ impl SpawnHistory {
|
||||
last_command: rec.command,
|
||||
last_used: rec.at,
|
||||
});
|
||||
if out.len() >= self.max_results {
|
||||
if out.len() >= max_results {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -362,4 +374,28 @@ mod tests {
|
||||
assert_eq!(recents[0].cwd, PathBuf::from("/d4"));
|
||||
assert_eq!(recents[1].cwd, PathBuf::from("/d3"));
|
||||
}
|
||||
|
||||
/// Project enrichment (#54) folds the whole log, not just the picker's
|
||||
/// first page.
|
||||
#[test]
|
||||
fn all_recent_dirs_skips_the_result_cap() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut h = SpawnHistory::new(tmp.path().join("h.jsonl"), 30);
|
||||
h.max_results = 2;
|
||||
// Recent enough to survive the 30-day cap against the real clock.
|
||||
let base = Utc::now() - Duration::hours(1);
|
||||
for i in 0..5 {
|
||||
h.append(&rec(
|
||||
&format!("/d{i}"),
|
||||
"claude",
|
||||
base + Duration::minutes(i),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(h.recent_dirs().len(), 2);
|
||||
let all = h.all_recent_dirs();
|
||||
assert_eq!(all.len(), 5);
|
||||
assert_eq!(all[0].cwd, PathBuf::from("/d4"));
|
||||
assert_eq!(all[4].cwd, PathBuf::from("/d0"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,64 +4,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(())
|
||||
|
||||
@@ -37,6 +37,7 @@ async fn spawn_daemon_ws() -> Harness {
|
||||
"test-host",
|
||||
"/home/test",
|
||||
Vec::new(),
|
||||
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
|
||||
@@ -115,7 +115,6 @@ pub struct DaemonFixture {
|
||||
pub tmux: TmuxFixture,
|
||||
pub manager: SessionManager,
|
||||
pub state_dir: TempDir,
|
||||
pub worktree_root: TempDir,
|
||||
}
|
||||
|
||||
impl DaemonFixture {
|
||||
@@ -132,9 +131,9 @@ impl DaemonFixture {
|
||||
fn build(history_days: Option<u32>) -> Self {
|
||||
let tmux = TmuxFixture::new();
|
||||
let state_dir = TempDir::new().unwrap();
|
||||
let worktree_root = TempDir::new().unwrap();
|
||||
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
|
||||
let worktree_mgr = WorktreeManager::new(worktree_root.path().to_path_buf());
|
||||
// Default placement: worktrees land under their base repo (#56).
|
||||
let worktree_mgr = WorktreeManager::new(None);
|
||||
let mut manager = SessionManager::new(
|
||||
Arc::new(tmux.tmux.clone()),
|
||||
sidecar,
|
||||
@@ -149,7 +148,6 @@ impl DaemonFixture {
|
||||
tmux,
|
||||
manager,
|
||||
state_dir,
|
||||
worktree_root,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,17 @@ struct Harness {
|
||||
}
|
||||
|
||||
async fn spawn(hostname: &str) -> Harness {
|
||||
spawn_with(
|
||||
hostname,
|
||||
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn spawn_with(
|
||||
hostname: &str,
|
||||
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
|
||||
) -> Harness {
|
||||
let fx = DaemonFixture::new();
|
||||
let manager = fx.manager.clone();
|
||||
// Built-in presets (claude/opencode/zsh + custom) so `/api/info` advertises
|
||||
@@ -36,6 +47,7 @@ async fn spawn(hostname: &str) -> Harness {
|
||||
hostname,
|
||||
"/home/test",
|
||||
presets,
|
||||
projects,
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
@@ -153,6 +165,73 @@ async fn recent_dirs_returns_empty_without_history() {
|
||||
assert_eq!(resp["truncated"], false);
|
||||
}
|
||||
|
||||
/// #54: `GET /api/projects` lists every git repo under the scan roots, with
|
||||
/// real git checkouts — not mocks.
|
||||
#[tokio::test]
|
||||
async fn projects_lists_repos_under_the_scan_roots() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
for name in ["alpha", "beta"] {
|
||||
let repo = tmp.path().join(name);
|
||||
std::fs::create_dir_all(&repo).unwrap();
|
||||
for args in [
|
||||
vec!["init", "-q"],
|
||||
vec![
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"commit",
|
||||
"-qm",
|
||||
"init",
|
||||
"--allow-empty",
|
||||
],
|
||||
] {
|
||||
let out = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(&args)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success());
|
||||
}
|
||||
}
|
||||
|
||||
let scanner = std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::new(
|
||||
vec![tmp.path().to_path_buf()],
|
||||
3,
|
||||
tmp.path().to_path_buf(),
|
||||
));
|
||||
let h = spawn_with("naps-laptop", scanner).await;
|
||||
|
||||
let projects: Vec<maestro_protocol::Project> = reqwest::get(url(h.addr, "/api/projects"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut names: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["alpha", "beta"]);
|
||||
for p in &projects {
|
||||
assert!(!p.is_worktree);
|
||||
assert_eq!(p.base_repo, None);
|
||||
assert!(p.last_activity.is_some(), "fresh commit ⇒ HEAD mtime known");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn projects_returns_empty_for_a_disabled_scanner() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let projects: Vec<maestro_protocol::Project> = reqwest::get(url(h.addr, "/api/projects"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(projects.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gitroot_probes_path() {
|
||||
// #26: resolves the query against the daemon home dir and walks up for a
|
||||
|
||||
@@ -38,7 +38,7 @@ impl Fx {
|
||||
let manager = SessionManager::new(
|
||||
fake.clone(),
|
||||
SidecarStore::new(pick(tmp.path())),
|
||||
WorktreeManager::new(tmp.path().join("worktrees")),
|
||||
WorktreeManager::new(Some(tmp.path().join("worktrees"))),
|
||||
fifo_dir,
|
||||
);
|
||||
Self { manager, fake, tmp }
|
||||
@@ -387,7 +387,7 @@ fn attaching_spawn_history_after_cloning_panics() {
|
||||
let manager = SessionManager::new(
|
||||
fake,
|
||||
SidecarStore::new(tmp.path().join("sidecars")),
|
||||
WorktreeManager::new(tmp.path().join("worktrees")),
|
||||
WorktreeManager::new(Some(tmp.path().join("worktrees"))),
|
||||
tmp.path().join("fifos"),
|
||||
);
|
||||
let _clone = manager.clone();
|
||||
|
||||
@@ -7,9 +7,9 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{wait_for_async, DaemonFixture};
|
||||
use common::{init_repo, wait_for_async, DaemonFixture};
|
||||
use maestro_protocol::CreateSessionRequest;
|
||||
use maestro_protocol::{CatalogEvent, SessionStatus};
|
||||
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -123,8 +123,7 @@ async fn recover_adopts_existing_tmux_sessions() {
|
||||
|
||||
// build a parallel manager pointing at the same dirs + same tmux server
|
||||
let sidecar = maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir());
|
||||
let worktree_mgr =
|
||||
maestro_daemon::worktree::WorktreeManager::new(fx.worktree_root.path().to_path_buf());
|
||||
let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(None);
|
||||
let revived = maestro_daemon::SessionManager::new(
|
||||
Arc::new(fx.tmux.tmux.clone()),
|
||||
sidecar,
|
||||
@@ -214,7 +213,6 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
|
||||
let sidecar_dir = fx.sidecar_dir();
|
||||
let fifo_dir = fx.fifo_dir();
|
||||
let tmux = fx.tmux.tmux.clone();
|
||||
let worktree_root = fx.worktree_root.path().to_path_buf();
|
||||
|
||||
// Drop the original manager. Its PtyBridge closes the FIFO's only
|
||||
// reader, so tmux's `cat >> fifo` writer hits SIGPIPE and exits — but
|
||||
@@ -223,7 +221,7 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
|
||||
drop(fx.manager);
|
||||
|
||||
let sidecar = maestro_daemon::sidecar::SidecarStore::new(sidecar_dir);
|
||||
let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(worktree_root);
|
||||
let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(None);
|
||||
let revived =
|
||||
maestro_daemon::SessionManager::new(Arc::new(tmux), sidecar, worktree_mgr, fifo_dir);
|
||||
assert_eq!(revived.recover().await.expect("recover"), 1);
|
||||
@@ -365,6 +363,72 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
|
||||
assert!(!fx.fifo_dir().join(format!("{}.fifo", s.id)).exists());
|
||||
}
|
||||
|
||||
/// Cleanup is unconditional (#56): even a spec that says "don't clean up"
|
||||
/// loses its worktree on kill — the guard, not the flag, protects work.
|
||||
#[tokio::test]
|
||||
async fn kill_removes_worktree_even_when_spec_says_keep() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
let wt_path = repo.path().join("worktrees").join("feat-keep");
|
||||
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/keep".into(),
|
||||
cleanup_on_kill: false,
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
assert!(wt_path.exists());
|
||||
|
||||
fx.manager.kill(s.id).await.expect("kill");
|
||||
assert!(!wt_path.exists(), "worktree must be removed on kill");
|
||||
}
|
||||
|
||||
/// The guard (#56): a worktree holding uncommitted changes survives the kill,
|
||||
/// left on disk where the operator can find it.
|
||||
#[tokio::test]
|
||||
async fn kill_leaves_worktree_with_unsaved_work_on_disk() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
let wt_path = repo.path().join("worktrees").join("feat-guarded");
|
||||
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/guarded".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||
|
||||
fx.manager.kill(s.id).await.expect("kill");
|
||||
assert!(
|
||||
wt_path.exists(),
|
||||
"dirty worktree must be left on disk, not destroyed"
|
||||
);
|
||||
assert!(fx.manager.snapshot().await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn liveness_watcher_leaves_running_sessions_alone() {
|
||||
let fx = DaemonFixture::new();
|
||||
|
||||
@@ -31,9 +31,9 @@ fn add_creates_worktree_on_new_branch() {
|
||||
init_repo(&repo);
|
||||
|
||||
let wt_root = TempDir::new().unwrap();
|
||||
let mgr = WorktreeManager::new(wt_root.path());
|
||||
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
|
||||
|
||||
let path = mgr.add(&repo, "feat/example").expect("add worktree");
|
||||
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
|
||||
assert!(path.exists());
|
||||
assert!(path.join("README").exists());
|
||||
assert!(path.starts_with(wt_root.path()));
|
||||
@@ -42,6 +42,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}");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,7 +15,8 @@ use uuid::Uuid;
|
||||
|
||||
use crate::routes::{self, UrlError};
|
||||
use crate::{
|
||||
ApiError, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, RecentDirs, Session,
|
||||
ApiError, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, Project,
|
||||
RecentDirs, Session,
|
||||
};
|
||||
|
||||
/// Time budget for establishing a TCP connection to the daemon.
|
||||
@@ -138,6 +139,10 @@ impl DaemonClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_projects(&self) -> Result<Vec<Project>, ClientError> {
|
||||
self.get(routes::PROJECTS, &[]).await
|
||||
}
|
||||
|
||||
// -- plumbing -----------------------------------------------------------
|
||||
|
||||
async fn get<T: DeserializeOwned>(
|
||||
|
||||
@@ -111,8 +111,20 @@ pub enum ActivityState {
|
||||
pub struct WorktreeSpec {
|
||||
pub base_repo: PathBuf,
|
||||
pub branch: String,
|
||||
#[serde(default)]
|
||||
/// Kept on the wire for CLI compatibility (#56) but no longer a
|
||||
/// user-facing choice: the daemon always cleans up on kill, guarded
|
||||
/// against unsaved work. Defaults to `true`.
|
||||
#[serde(default = "default_true")]
|
||||
pub cleanup_on_kill: bool,
|
||||
/// When false, `branch` must already exist and is checked out as-is
|
||||
/// (#56); when true (the default, and what every payload meant before
|
||||
/// the field existed) the daemon creates it with `worktree add -b`.
|
||||
#[serde(default = "default_true")]
|
||||
pub create_branch: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// HTTP `POST /api/sessions` body.
|
||||
@@ -176,6 +188,30 @@ pub struct GitRoot {
|
||||
pub root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// One discovered git repository (or linked worktree) for `GET /api/projects`
|
||||
/// (#54). The daemon reports the timestamp; clients decide what "stale" means
|
||||
/// (the web UI dims entries older than 7 days).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
/// Absolute path of the repository/worktree toplevel.
|
||||
pub path: PathBuf,
|
||||
/// Basename of `path`.
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub is_worktree: bool,
|
||||
/// The main checkout this worktree belongs to. Set only when
|
||||
/// [`Self::is_worktree`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub base_repo: Option<PathBuf>,
|
||||
/// Later of the repo's git HEAD mtime and the newest spawn-history use of
|
||||
/// any directory inside it. `None` when neither is known.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_activity: Option<DateTime<Utc>>,
|
||||
/// Command last spawned in this project, from spawn history.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_command: Option<String>,
|
||||
}
|
||||
|
||||
/// Structured failure used everywhere we don't want to leak a string-only error.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ErrorInfo {
|
||||
@@ -317,6 +353,34 @@ mod tests {
|
||||
serde_json::from_str(&json).expect("deserialize")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_round_trips_and_defaults_on_legacy_payload() {
|
||||
let full = Project {
|
||||
path: PathBuf::from("/home/u/tea/maestro"),
|
||||
name: "maestro".into(),
|
||||
is_worktree: true,
|
||||
base_repo: Some(PathBuf::from("/home/u/tea/maestro-main")),
|
||||
last_activity: Some(
|
||||
DateTime::parse_from_rfc3339("2026-08-23T12:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc),
|
||||
),
|
||||
last_command: Some("claude".into()),
|
||||
};
|
||||
assert_eq!(full, round_trip(&full));
|
||||
|
||||
// A payload from a daemon predating the optional fields still decodes.
|
||||
let legacy = serde_json::json!({
|
||||
"path": "/home/u/proj",
|
||||
"name": "proj",
|
||||
});
|
||||
let p: Project = serde_json::from_value(legacy).unwrap();
|
||||
assert!(!p.is_worktree);
|
||||
assert_eq!(p.base_repo, None);
|
||||
assert_eq!(p.last_activity, None);
|
||||
assert_eq!(p.last_command, None);
|
||||
}
|
||||
|
||||
fn sample_session() -> Session {
|
||||
Session {
|
||||
id: Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef),
|
||||
@@ -393,6 +457,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 +475,31 @@ mod tests {
|
||||
assert!(req.metadata.is_empty());
|
||||
}
|
||||
|
||||
/// A client that predates the new/existing discriminator sends no
|
||||
/// `create_branch`; the daemon must read that as "create a new branch" —
|
||||
/// exactly what every payload meant before the field existed (#56).
|
||||
#[test]
|
||||
fn worktree_spec_defaults_on_legacy_payload() {
|
||||
let json = serde_json::json!({
|
||||
"base_repo": "/home/naps62/projects/maestro",
|
||||
"branch": "feat/foo",
|
||||
});
|
||||
let spec: WorktreeSpec = serde_json::from_value(json).unwrap();
|
||||
assert!(spec.create_branch);
|
||||
assert!(spec.cleanup_on_kill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_spec_round_trips_existing_branch_mode() {
|
||||
let spec = WorktreeSpec {
|
||||
base_repo: PathBuf::from("/work"),
|
||||
branch: "feat/foo".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: false,
|
||||
};
|
||||
assert_eq!(spec, round_trip(&spec));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_event_round_trips_and_tags() {
|
||||
let session = sample_session();
|
||||
|
||||
@@ -17,6 +17,8 @@ pub const INFO: &str = "/api/info";
|
||||
pub const DIRS: &str = "/api/dirs";
|
||||
/// `GET` spawn history.
|
||||
pub const RECENT_DIRS: &str = "/api/recent-dirs";
|
||||
/// `GET` discovered git repositories and worktrees, newest activity first.
|
||||
pub const PROJECTS: &str = "/api/projects";
|
||||
/// `GET` the enclosing repository of a path. Takes a `path` query param.
|
||||
pub const GITROOT: &str = "/api/gitroot";
|
||||
/// `WS` catalog snapshot + session deltas.
|
||||
@@ -172,7 +174,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn every_route_sits_under_the_api_prefix() {
|
||||
for path in [SESSIONS, INFO, DIRS, RECENT_DIRS, GITROOT, EVENTS] {
|
||||
for path in [SESSIONS, INFO, DIRS, RECENT_DIRS, PROJECTS, GITROOT, EVENTS] {
|
||||
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
|
||||
}
|
||||
assert!(attach(id()).starts_with(API_PREFIX));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
|
||||
use std::io;
|
||||
|
||||
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
|
||||
use crossterm::event::{
|
||||
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
|
||||
};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{
|
||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||
@@ -26,7 +28,12 @@ impl TerminalGuard {
|
||||
pub fn enter() -> Result<Self, TerminalError> {
|
||||
enable_raw_mode()?;
|
||||
let mut out = io::stdout();
|
||||
execute!(out, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
execute!(
|
||||
out,
|
||||
EnterAlternateScreen,
|
||||
EnableMouseCapture,
|
||||
EnableBracketedPaste
|
||||
)?;
|
||||
Ok(TerminalGuard)
|
||||
}
|
||||
|
||||
@@ -34,7 +41,12 @@ impl TerminalGuard {
|
||||
/// call it without producing weird errors.
|
||||
fn restore() {
|
||||
let mut out = io::stdout();
|
||||
let _ = execute!(out, DisableMouseCapture, LeaveAlternateScreen);
|
||||
let _ = execute!(
|
||||
out,
|
||||
DisableBracketedPaste,
|
||||
DisableMouseCapture,
|
||||
LeaveAlternateScreen
|
||||
);
|
||||
let _ = disable_raw_mode();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
//! Single-line text-input primitive shared by every TUI form field.
|
||||
//!
|
||||
//! Owns a `String`, a character-indexed cursor, and horizontal scroll state.
|
||||
//! All editing goes through [`TextInput::handle_key`] so callers feed the same
|
||||
//! canonical [`InputKey`] stream the rest of the app uses; bracketed paste
|
||||
//! arrives as one [`InputKey::Paste`] and is inserted as a single operation.
|
||||
//!
|
||||
//! This is the one piece of the TUI form layer that unit-tests without a
|
||||
//! terminal: drive it with `InputKey` sequences and inspect value/cursor.
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
|
||||
use crate::input::InputKey;
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct TextInput {
|
||||
value: String,
|
||||
/// Cursor position as a char offset into `value`.
|
||||
cursor: usize,
|
||||
/// Char offset of the leftmost visible cell. A `Cell` so `render` can
|
||||
/// re-clamp it without `&mut self` — widgets are drawn through shared
|
||||
/// references in this codebase.
|
||||
scroll: std::cell::Cell<usize>,
|
||||
}
|
||||
|
||||
impl TextInput {
|
||||
pub fn with_value(value: impl Into<String>) -> Self {
|
||||
let mut input = Self {
|
||||
value: value.into(),
|
||||
..Self::default()
|
||||
};
|
||||
input.cursor = input.value.chars().count();
|
||||
input
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &str {
|
||||
&self.value
|
||||
}
|
||||
|
||||
pub fn cursor(&self) -> usize {
|
||||
self.cursor
|
||||
}
|
||||
|
||||
/// Char offset of the leftmost visible cell; only meaningful after
|
||||
/// [`TextInput::render`] has clamped it to the field width.
|
||||
pub fn scroll_offset(&self) -> usize {
|
||||
self.scroll.get()
|
||||
}
|
||||
|
||||
/// Insert `text` at the cursor as one operation. Used both for typed
|
||||
/// characters and for whole bracketed-paste payloads.
|
||||
pub fn insert(&mut self, text: &str) {
|
||||
let at = self.byte_offset(self.cursor);
|
||||
self.value.insert_str(at, text);
|
||||
self.cursor += text.chars().count();
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: &InputKey) {
|
||||
match key {
|
||||
InputKey::Char {
|
||||
c,
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
..
|
||||
} => self.insert(&c.to_string()),
|
||||
InputKey::Char {
|
||||
c: 'w', ctrl: true, ..
|
||||
} => self.delete_word_backwards(),
|
||||
InputKey::Char {
|
||||
c: 'k', ctrl: true, ..
|
||||
} => self.kill_to_end(),
|
||||
// Emacs-style word motion; ctrl-arrow reaches us as a plain
|
||||
// Left/Right today, so these are the dependable binding.
|
||||
InputKey::Char {
|
||||
c: 'b', ctrl: true, ..
|
||||
} => self.cursor = self.word_left(),
|
||||
InputKey::Char {
|
||||
c: 'f', ctrl: true, ..
|
||||
} => self.cursor = self.word_right(),
|
||||
InputKey::Left => {
|
||||
self.cursor = self.cursor.saturating_sub(1);
|
||||
}
|
||||
InputKey::Right => {
|
||||
if self.cursor < self.value.chars().count() {
|
||||
self.cursor += 1;
|
||||
}
|
||||
}
|
||||
InputKey::Home => self.cursor = 0,
|
||||
InputKey::End => self.cursor = self.value.chars().count(),
|
||||
InputKey::Backspace => {
|
||||
if self.cursor > 0 {
|
||||
let at = self.byte_offset(self.cursor - 1);
|
||||
let end = self.byte_offset(self.cursor);
|
||||
self.value.replace_range(at..end, "");
|
||||
self.cursor -= 1;
|
||||
}
|
||||
}
|
||||
InputKey::Delete => {
|
||||
if self.cursor < self.value.chars().count() {
|
||||
let at = self.byte_offset(self.cursor);
|
||||
let end = self.byte_offset(self.cursor + 1);
|
||||
self.value.replace_range(at..end, "");
|
||||
}
|
||||
}
|
||||
InputKey::Paste(text) => self.insert(text),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw into `area` (expected one row tall), scrolling horizontally so the
|
||||
/// cursor stays visible, and return the screen column where the caller
|
||||
/// should place the terminal cursor.
|
||||
pub fn render(&self, area: Rect, buf: &mut Buffer, style: Style) -> u16 {
|
||||
let width = area.width as usize;
|
||||
let len = self.value.chars().count();
|
||||
let scroll = self.scroll.get().min(len);
|
||||
let scroll = if self.cursor < scroll {
|
||||
self.cursor
|
||||
} else if width > 0 && self.cursor.saturating_sub(scroll) >= width {
|
||||
self.cursor + 1 - width
|
||||
} else {
|
||||
scroll
|
||||
};
|
||||
self.scroll.set(scroll);
|
||||
for (col, c) in self.value.chars().skip(scroll).take(width).enumerate() {
|
||||
buf[(area.x + col as u16, area.y)]
|
||||
.set_symbol(&c.to_string())
|
||||
.set_style(style);
|
||||
}
|
||||
area.x + self.cursor.saturating_sub(scroll) as u16
|
||||
}
|
||||
|
||||
fn byte_offset(&self, char_index: usize) -> usize {
|
||||
self.value
|
||||
.char_indices()
|
||||
.nth(char_index)
|
||||
.map(|(b, _)| b)
|
||||
.unwrap_or(self.value.len())
|
||||
}
|
||||
|
||||
fn is_word_char(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
/// Start of the word before the cursor, skipping any separators first —
|
||||
/// emacs `backward-word`.
|
||||
fn word_left(&self) -> usize {
|
||||
let chars: Vec<char> = self.value.chars().collect();
|
||||
let mut i = self.cursor;
|
||||
while i > 0 && !Self::is_word_char(chars[i - 1]) {
|
||||
i -= 1;
|
||||
}
|
||||
while i > 0 && Self::is_word_char(chars[i - 1]) {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Start of the word after the cursor, skipping the rest of the current
|
||||
/// word first — emacs `forward-word`.
|
||||
fn word_right(&self) -> usize {
|
||||
let chars: Vec<char> = self.value.chars().collect();
|
||||
let len = chars.len();
|
||||
let mut i = self.cursor;
|
||||
while i < len && !Self::is_word_char(chars[i]) {
|
||||
i += 1;
|
||||
}
|
||||
while i < len && Self::is_word_char(chars[i]) {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
fn kill_to_end(&mut self) {
|
||||
let at = self.byte_offset(self.cursor);
|
||||
self.value.truncate(at);
|
||||
}
|
||||
|
||||
/// Shell-style ctrl-w: kill back to the previous whitespace, taking any
|
||||
/// trailing whitespace run with it — so `cd /tmp/foo` loses the whole
|
||||
/// path segment in one keystroke.
|
||||
fn delete_word_backwards(&mut self) {
|
||||
let chars: Vec<char> = self.value.chars().collect();
|
||||
let mut i = self.cursor;
|
||||
while i > 0 && chars[i - 1].is_whitespace() {
|
||||
i -= 1;
|
||||
}
|
||||
while i > 0 && !chars[i - 1].is_whitespace() {
|
||||
i -= 1;
|
||||
}
|
||||
let at = self.byte_offset(i);
|
||||
let end = self.byte_offset(self.cursor);
|
||||
self.value.replace_range(at..end, "");
|
||||
self.cursor = i;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use InputKey::{Backspace, Delete, End, Home, Left, Right};
|
||||
|
||||
fn typed(s: &str) -> Vec<InputKey> {
|
||||
s.chars().map(InputKey::char).collect()
|
||||
}
|
||||
|
||||
fn fed(keys: &[InputKey]) -> TextInput {
|
||||
let mut input = TextInput::default();
|
||||
for k in keys {
|
||||
input.handle_key(k);
|
||||
}
|
||||
input
|
||||
}
|
||||
|
||||
fn ctrl(c: char) -> InputKey {
|
||||
InputKey::ctrl_char(c)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_lands_in_order() {
|
||||
let input = fed(&typed("hello"));
|
||||
assert_eq!(input.value(), "hello");
|
||||
assert_eq!(input.cursor(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_removes_the_last_typed_char() {
|
||||
let mut input = fed(&typed("hello"));
|
||||
input.handle_key(&Backspace);
|
||||
assert_eq!(input.value(), "hell");
|
||||
assert_eq!(input.cursor(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_then_insert_fixes_a_middle_typo() {
|
||||
let mut input = fed(&typed("helo"));
|
||||
input.handle_key(&Left);
|
||||
input.handle_key(&Left);
|
||||
input.handle_key(&InputKey::char('l'));
|
||||
assert_eq!(input.value(), "hello");
|
||||
assert_eq!(input.cursor(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_keys_stop_at_the_edges() {
|
||||
let mut input = fed(&typed("abc"));
|
||||
for _ in 0..5 {
|
||||
input.handle_key(&Left);
|
||||
}
|
||||
assert_eq!(input.cursor(), 0);
|
||||
input.handle_key(&Left);
|
||||
assert_eq!(input.cursor(), 0);
|
||||
for _ in 0..5 {
|
||||
input.handle_key(&Right);
|
||||
}
|
||||
assert_eq!(input.cursor(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_and_end_jump_to_the_ends() {
|
||||
let mut input = fed(&typed("abcdef"));
|
||||
input.handle_key(&Home);
|
||||
assert_eq!(input.cursor(), 0);
|
||||
input.handle_key(&InputKey::char('x'));
|
||||
assert_eq!(input.value(), "xabcdef");
|
||||
input.handle_key(&End);
|
||||
input.handle_key(&InputKey::char('!'));
|
||||
assert_eq!(input.value(), "xabcdef!");
|
||||
}
|
||||
|
||||
/// Cursor starts after the final `/`; word-left lands on word starts,
|
||||
/// never inside a separator run.
|
||||
#[test]
|
||||
fn ctrl_b_walks_left_one_word_at_a_time() {
|
||||
let mut input = TextInput::with_value("/tmp/foo bar-baz/");
|
||||
assert_eq!(input.cursor(), 17);
|
||||
for expected in [13, 9, 5, 1, 0, 0] {
|
||||
input.handle_key(&ctrl('b'));
|
||||
assert_eq!(input.cursor(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_f_walks_right_one_word_at_a_time() {
|
||||
let mut input = TextInput::with_value("/tmp/foo bar");
|
||||
input.handle_key(&Home);
|
||||
for expected in [4, 8, 12, 12] {
|
||||
input.handle_key(&ctrl('f'));
|
||||
assert_eq!(input.cursor(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_deletes_before_the_cursor() {
|
||||
let mut input = fed(&typed("abcdef"));
|
||||
input.handle_key(&Left);
|
||||
input.handle_key(&Left);
|
||||
input.handle_key(&Left);
|
||||
input.handle_key(&Backspace);
|
||||
assert_eq!(input.value(), "abdef");
|
||||
assert_eq!(input.cursor(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_on_empty_input_is_a_noop() {
|
||||
let mut input = TextInput::default();
|
||||
input.handle_key(&Backspace);
|
||||
assert_eq!(input.value(), "");
|
||||
assert_eq!(input.cursor(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_removes_the_char_under_the_cursor() {
|
||||
let mut input = fed(&typed("abcdef"));
|
||||
input.handle_key(&Home);
|
||||
input.handle_key(&Delete);
|
||||
assert_eq!(input.value(), "bcdef");
|
||||
assert_eq!(input.cursor(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_past_the_end_is_a_noop() {
|
||||
let mut input = TextInput::with_value("abc");
|
||||
input.handle_key(&End);
|
||||
input.handle_key(&Delete);
|
||||
assert_eq!(input.value(), "abc");
|
||||
}
|
||||
|
||||
/// `cd /tmp/foo` with ctrl-w leaves `cd ` — the whole path segment is one
|
||||
/// word to a shell (readline kills back to whitespace, spaces included).
|
||||
#[test]
|
||||
fn ctrl_w_deletes_the_word_behind_the_cursor() {
|
||||
let mut input = fed(&[typed("cd ").as_slice(), typed("/tmp/foo").as_slice()].concat());
|
||||
input.handle_key(&ctrl('w'));
|
||||
assert_eq!(input.value(), "cd ");
|
||||
assert_eq!(input.cursor(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_w_stops_before_the_separator_run() {
|
||||
let mut input = TextInput::with_value("a b");
|
||||
input.cursor = 5;
|
||||
input.handle_key(&ctrl('w'));
|
||||
assert_eq!(input.value(), "a ");
|
||||
assert_eq!(input.cursor(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_k_kills_everything_after_the_cursor() {
|
||||
let mut input = fed(&typed("spawn --debug extra"));
|
||||
for _ in 0..14 {
|
||||
input.handle_key(&Left);
|
||||
}
|
||||
input.handle_key(&ctrl('k'));
|
||||
assert_eq!(input.value(), "spawn");
|
||||
assert_eq!(input.cursor(), 5);
|
||||
}
|
||||
|
||||
/// A paste arrives as one event carrying the whole payload and must land
|
||||
/// at the cursor as a single insertion, not per-character.
|
||||
#[test]
|
||||
fn paste_inserts_whole_payload_at_the_cursor() {
|
||||
let mut input = fed(&typed("cd "));
|
||||
input.handle_key(&InputKey::Paste("/srv/work".into()));
|
||||
assert_eq!(input.value(), "cd /srv/work");
|
||||
assert_eq!(input.cursor(), 12);
|
||||
// What follows still behaves like ordinary typed text.
|
||||
input.handle_key(&InputKey::char('/'));
|
||||
assert_eq!(input.value(), "cd /srv/work/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_between_existing_text_keeps_both_halves() {
|
||||
let mut input = TextInput::with_value("ac");
|
||||
input.handle_key(&Left);
|
||||
input.handle_key(&InputKey::Paste("b×".into()));
|
||||
assert_eq!(input.value(), "ab×c");
|
||||
assert_eq!(input.cursor(), 3, "cursor counts chars, not bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendering_an_empty_field_points_at_its_first_column() {
|
||||
let input = TextInput::default();
|
||||
let area = Rect::new(10, 5, 20, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let col = input.render(area, &mut buf, Style::default());
|
||||
assert_eq!(col, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendering_reports_the_visible_cursor_column() {
|
||||
let mut input = TextInput::with_value("hello");
|
||||
input.handle_key(&Left);
|
||||
input.handle_key(&Left);
|
||||
let area = Rect::new(10, 5, 20, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let col = input.render(area, &mut buf, Style::default());
|
||||
assert_eq!(col, 13);
|
||||
for (i, expected) in ["h", "e", "l", "l", "o"].iter().enumerate() {
|
||||
assert_eq!(buf[(10 + i as u16, 5)].symbol(), *expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendering_a_value_wider_than_the_field_shows_its_tail() {
|
||||
let value = "0123456789abcdefghij";
|
||||
let input = TextInput::with_value(value);
|
||||
let area = Rect::new(0, 0, 10, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let col = input.render(area, &mut buf, Style::default());
|
||||
assert_eq!(col, 9, "cursor sits on the last visible column");
|
||||
for (i, expected) in value.chars().skip(input.scroll_offset()).enumerate() {
|
||||
assert_eq!(
|
||||
buf[(i as u16, 0)].symbol(),
|
||||
expected.to_string(),
|
||||
"cell {i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Moving left into the hidden region must scroll the window back so the
|
||||
/// cursor never disappears off the left edge.
|
||||
#[test]
|
||||
fn moving_left_into_the_hidden_region_scrolls_back() {
|
||||
let value = "0123456789abcdefghij";
|
||||
let mut input = TextInput::with_value(value);
|
||||
let area = Rect::new(0, 0, 10, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
input.render(area, &mut buf, Style::default());
|
||||
assert_eq!(input.cursor(), 20);
|
||||
|
||||
input.handle_key(&Home);
|
||||
for _ in 0..5 {
|
||||
input.handle_key(&Right);
|
||||
}
|
||||
let mut buf = Buffer::empty(area);
|
||||
let col = input.render(area, &mut buf, Style::default());
|
||||
assert_eq!(input.cursor(), 5);
|
||||
assert_eq!(
|
||||
input.value().chars().nth(input.scroll_offset()),
|
||||
Some('5'),
|
||||
"window scrolled back to the cursor"
|
||||
);
|
||||
assert_eq!(col, 0);
|
||||
assert_eq!(buf[(0, 0)].symbol(), "5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrinking_the_value_clamps_scroll_and_cursor() {
|
||||
let mut input = TextInput::with_value("0123456789abcdefghij");
|
||||
input.handle_key(&Home);
|
||||
input.handle_key(&ctrl('k'));
|
||||
let area = Rect::new(0, 0, 10, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let col = input.render(area, &mut buf, Style::default());
|
||||
assert_eq!(input.value(), "");
|
||||
assert_eq!(col, 0);
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -280,8 +280,9 @@ maestro daemon # foreground; or wire your own service manager
|
||||
```
|
||||
|
||||
Every setting has a default, so `~/.config/maestro/config.toml` is optional —
|
||||
create it only to change the bind address, worktree root, spawn presets, or TUI
|
||||
keymap.
|
||||
create it only to change the bind address, spawn presets, or TUI
|
||||
keymap. (Worktrees live inside their base repos at `<repo>/worktrees/<branch>`;
|
||||
set `[daemon].worktree_root` only to override that.)
|
||||
|
||||
## Non-Linux hosts
|
||||
|
||||
|
||||
@@ -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?.();
|
||||
},
|
||||
|
||||
@@ -99,4 +99,30 @@ describe("serializeLayout", () => {
|
||||
}
|
||||
expect(leaves(restored!.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1", null]);
|
||||
});
|
||||
|
||||
it("keeps the draft out of the stored blob itself, not just out of hydrate", () => {
|
||||
// A draft pane only exists once a tab has split, so the strip has to walk
|
||||
// into split branches; asserting on the restored layout alone passes even
|
||||
// when the draft was written to localStorage, because hydrate drops it.
|
||||
const layout = hydrate({
|
||||
tabs: [
|
||||
{
|
||||
id: "t",
|
||||
root: {
|
||||
kind: "split",
|
||||
dir: "row",
|
||||
a: { kind: "leaf", id: "l1", sessionId: "s1" },
|
||||
b: { kind: "leaf", id: "l2", sessionId: null },
|
||||
},
|
||||
},
|
||||
],
|
||||
activeTabId: "t",
|
||||
}) as Layout;
|
||||
const draftLeaf = leaves(layout.tabs[0].root).find((l) => !l.sessionId)!;
|
||||
draftLeaf.draft = { command: "claude", cwd: "/home/user/secret-project", name: "amber-fox" };
|
||||
|
||||
const raw = serializeLayout(layout);
|
||||
expect(raw).not.toContain("secret-project");
|
||||
expect(raw).not.toContain("amber-fox");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,12 @@ export function serializeLayout(layout: Layout): string {
|
||||
}
|
||||
|
||||
function stripDrafts(node: Node | null): Node | null {
|
||||
if (!node || node.kind !== "leaf") return node;
|
||||
if (!node) return node;
|
||||
// Recurse: a draft pane only exists once a tab has split, so a shallow strip
|
||||
// would persist every half-filled form it was meant to drop.
|
||||
if (node.kind === "split") {
|
||||
return { ...node, a: stripDrafts(node.a)!, b: stripDrafts(node.b)! };
|
||||
}
|
||||
return { ...node, draft: null };
|
||||
}
|
||||
|
||||
|
||||
+13
-7
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user