Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9829bc56f | ||
|
|
9f582263b8 | ||
|
|
b5cbf94fa8 | ||
|
|
8376122dfe | ||
|
|
4a814d5b17 | ||
|
|
1809f5d581 | ||
|
|
ec51b0221c | ||
|
|
33fb50be6e | ||
|
|
b6cf87244f |
@@ -67,6 +67,11 @@ pub struct SpawnArgs {
|
|||||||
/// Branch name for the worktree (required with --worktree-from).
|
/// Branch name for the worktree (required with --worktree-from).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub branch: Option<String>,
|
pub branch: Option<String>,
|
||||||
|
/// Check out an existing branch instead of creating one. Without it the
|
||||||
|
/// daemon runs `worktree add -b`, which fails if the branch is already
|
||||||
|
/// there.
|
||||||
|
#[arg(long)]
|
||||||
|
pub existing_branch: bool,
|
||||||
/// Remove the worktree when the session is killed. Kept for wire
|
/// Remove the worktree when the session is killed. Kept for wire
|
||||||
/// compatibility (#56): the daemon now always cleans up on kill, guarded
|
/// compatibility (#56): the daemon now always cleans up on kill, guarded
|
||||||
/// against unsaved work.
|
/// against unsaved work.
|
||||||
@@ -170,6 +175,31 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Review fix: without a flag the CLI always sent `create_branch: true`,
|
||||||
|
/// so the daemon's existing-branch path was unreachable and spawning onto
|
||||||
|
/// a branch that already existed just failed.
|
||||||
|
#[test]
|
||||||
|
fn parses_spawn_onto_an_existing_branch() {
|
||||||
|
let cli = Cli::try_parse_from([
|
||||||
|
"maestro",
|
||||||
|
"spawn",
|
||||||
|
"--cwd",
|
||||||
|
"/tmp",
|
||||||
|
"--command",
|
||||||
|
"bash",
|
||||||
|
"--worktree-from",
|
||||||
|
"/repo",
|
||||||
|
"--branch",
|
||||||
|
"feat/existing",
|
||||||
|
"--existing-branch",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let Command::Spawn(a) = cli.command else {
|
||||||
|
panic!("expected spawn")
|
||||||
|
};
|
||||||
|
assert!(a.existing_branch);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn spawn_worktree_requires_branch() {
|
fn spawn_worktree_requires_branch() {
|
||||||
let err = Cli::try_parse_from([
|
let err = Cli::try_parse_from([
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
|
|||||||
base_repo: repo.clone(),
|
base_repo: repo.clone(),
|
||||||
branch: branch.clone(),
|
branch: branch.clone(),
|
||||||
cleanup_on_kill: args.cleanup_on_kill,
|
cleanup_on_kill: args.cleanup_on_kill,
|
||||||
create_branch: true,
|
create_branch: !args.existing_branch,
|
||||||
}),
|
}),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -162,6 +162,15 @@ pub struct SpawnSection {
|
|||||||
/// it resolves to just the `custom` tile.
|
/// it resolves to just the `custom` tile.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub presets: Option<Vec<SpawnPreset>>,
|
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 {
|
impl SpawnSection {
|
||||||
@@ -387,6 +396,7 @@ mod tests {
|
|||||||
label: "fish".into(),
|
label: "fish".into(),
|
||||||
command: "fish".into(),
|
command: "fish".into(),
|
||||||
}]),
|
}]),
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
let ids: Vec<String> = section
|
let ids: Vec<String> = section
|
||||||
.resolve()
|
.resolve()
|
||||||
@@ -403,6 +413,7 @@ mod tests {
|
|||||||
// only the implicit custom tile.
|
// only the implicit custom tile.
|
||||||
let section = SpawnSection {
|
let section = SpawnSection {
|
||||||
presets: Some(vec![]),
|
presets: Some(vec![]),
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
let ids: Vec<String> = section
|
let ids: Vec<String> = section
|
||||||
.resolve()
|
.resolve()
|
||||||
@@ -421,6 +432,7 @@ mod tests {
|
|||||||
label: "x".into(),
|
label: "x".into(),
|
||||||
command: "x".into(),
|
command: "x".into(),
|
||||||
}]),
|
}]),
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
assert_eq!(empty_id.resolve().unwrap_err(), SpawnConfigError::EmptyId);
|
assert_eq!(empty_id.resolve().unwrap_err(), SpawnConfigError::EmptyId);
|
||||||
|
|
||||||
@@ -430,6 +442,7 @@ mod tests {
|
|||||||
label: "x".into(),
|
label: "x".into(),
|
||||||
command: "".into(),
|
command: "".into(),
|
||||||
}]),
|
}]),
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
empty_cmd.resolve().unwrap_err(),
|
empty_cmd.resolve().unwrap_err(),
|
||||||
@@ -452,6 +465,7 @@ mod tests {
|
|||||||
command: "a2".into(),
|
command: "a2".into(),
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
dup.resolve().unwrap_err(),
|
dup.resolve().unwrap_err(),
|
||||||
@@ -464,6 +478,7 @@ mod tests {
|
|||||||
label: "Custom".into(),
|
label: "Custom".into(),
|
||||||
command: "whatever".into(),
|
command: "whatever".into(),
|
||||||
}]),
|
}]),
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
reserved.resolve().unwrap_err(),
|
reserved.resolve().unwrap_err(),
|
||||||
@@ -497,6 +512,30 @@ presets = [
|
|||||||
assert_eq!(ids, ["claude", "zsh", "custom"]);
|
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]
|
#[test]
|
||||||
fn partial_file_loads_with_section_defaults() {
|
fn partial_file_loads_with_section_defaults() {
|
||||||
// Only [daemon] present (typical single-host install).
|
// Only [daemon] present (typical single-host install).
|
||||||
|
|||||||
@@ -72,11 +72,19 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
|||||||
tracing::info!(recovered, "adopted existing tmux sessions");
|
tracing::info!(recovered, "adopted existing tmux sessions");
|
||||||
manager.start_liveness_watcher(DEFAULT_LIVENESS_INTERVAL);
|
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(
|
let router = local_api::router(local_api::state_for(
|
||||||
manager,
|
manager,
|
||||||
config.hostname.clone(),
|
config.hostname.clone(),
|
||||||
config.home_dir.clone(),
|
config.home_dir.clone(),
|
||||||
config.spawn_presets.clone(),
|
config.spawn_presets.clone(),
|
||||||
|
projects,
|
||||||
));
|
));
|
||||||
|
|
||||||
let listener = TcpListener::bind(config.bind_addr).await?;
|
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.
|
/// `[daemon].spawn_history_days` is unset. `0` in config disables history.
|
||||||
pub const DEFAULT_SPAWN_HISTORY_DAYS: u32 = 30;
|
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`.
|
/// Env var that overrides `[daemon].bind_addr`.
|
||||||
pub const ENV_DAEMON_BIND_ADDR: &str = "MAESTRO_DAEMON_BIND_ADDR";
|
pub const ENV_DAEMON_BIND_ADDR: &str = "MAESTRO_DAEMON_BIND_ADDR";
|
||||||
/// Env var that overrides `[daemon].tmux_tmpdir`.
|
/// Env var that overrides `[daemon].tmux_tmpdir`.
|
||||||
@@ -74,6 +82,12 @@ pub struct DaemonConfig {
|
|||||||
/// built-ins) with the implicit `custom` tile appended. Advertised to
|
/// built-ins) with the implicit `custom` tile appended. Advertised to
|
||||||
/// clients in `GET /api/info`.
|
/// clients in `GET /api/info`.
|
||||||
pub spawn_presets: Vec<maestro_config::SpawnPreset>,
|
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 {
|
impl DaemonConfig {
|
||||||
@@ -147,6 +161,25 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
|
|||||||
.spawn_history_days
|
.spawn_history_days
|
||||||
.unwrap_or(DEFAULT_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 {
|
Ok(DaemonConfig {
|
||||||
hostname,
|
hostname,
|
||||||
home_dir,
|
home_dir,
|
||||||
@@ -157,9 +190,24 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
|
|||||||
tmux_tmpdir,
|
tmux_tmpdir,
|
||||||
spawn_history_days,
|
spawn_history_days,
|
||||||
spawn_presets,
|
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> {
|
pub fn default_state_dir() -> Result<PathBuf, ConfigError> {
|
||||||
Ok(maestro_config::default_state_dir("maestro")?)
|
Ok(maestro_config::default_state_dir("maestro")?)
|
||||||
}
|
}
|
||||||
@@ -280,6 +328,56 @@ spawn_history_days = 0
|
|||||||
assert_eq!(cfg.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]
|
#[test]
|
||||||
fn system_hostname_is_nonempty() {
|
fn system_hostname_is_nonempty() {
|
||||||
let h = system_hostname();
|
let h = system_hostname();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ pub mod gitroot;
|
|||||||
pub mod local_api;
|
pub mod local_api;
|
||||||
pub mod panes;
|
pub mod panes;
|
||||||
pub mod proc;
|
pub mod proc;
|
||||||
|
pub mod projects;
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod sidecar;
|
pub mod sidecar;
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ pub struct LocalApiState {
|
|||||||
pub home_dir: Arc<String>,
|
pub home_dir: Arc<String>,
|
||||||
/// Resolved spawn presets (#22/#26) advertised in `GET /api/info`.
|
/// Resolved spawn presets (#22/#26) advertised in `GET /api/info`.
|
||||||
pub presets: Arc<Vec<SpawnPreset>>,
|
pub presets: Arc<Vec<SpawnPreset>>,
|
||||||
|
/// Project discovery (#54) backing `GET /api/projects`.
|
||||||
|
pub projects: Arc<crate::projects::ProjectScanner>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(state: LocalApiState) -> Router {
|
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::patterns::SESSION, delete(kill_session))
|
||||||
.route(routes::DIRS, get(list_dir))
|
.route(routes::DIRS, get(list_dir))
|
||||||
.route(routes::RECENT_DIRS, get(recent_dirs))
|
.route(routes::RECENT_DIRS, get(recent_dirs))
|
||||||
|
.route(routes::PROJECTS, get(projects))
|
||||||
.route(routes::GITROOT, get(git_root))
|
.route(routes::GITROOT, get(git_root))
|
||||||
.route(routes::INFO, get(daemon_info))
|
.route(routes::INFO, get(daemon_info))
|
||||||
.route(routes::EVENTS, any(events_ws))
|
.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()
|
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`
|
/// 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
|
/// for the spawn form (#26). `path` is resolved against the daemon's home dir
|
||||||
/// (empty/`~`/relative), like [`list_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
|
/// 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(
|
pub fn state_for(
|
||||||
manager: SessionManager,
|
manager: SessionManager,
|
||||||
hostname: impl Into<String>,
|
hostname: impl Into<String>,
|
||||||
home_dir: impl Into<String>,
|
home_dir: impl Into<String>,
|
||||||
presets: Vec<SpawnPreset>,
|
presets: Vec<SpawnPreset>,
|
||||||
|
projects: Arc<crate::projects::ProjectScanner>,
|
||||||
) -> LocalApiState {
|
) -> LocalApiState {
|
||||||
LocalApiState {
|
LocalApiState {
|
||||||
manager,
|
manager,
|
||||||
hostname: Arc::new(hostname.into()),
|
hostname: Arc::new(hostname.into()),
|
||||||
home_dir: Arc::new(home_dir.into()),
|
home_dir: Arc::new(home_dir.into()),
|
||||||
presets: Arc::new(presets),
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,6 +169,16 @@ impl SessionManager {
|
|||||||
.unwrap_or_default()
|
.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 —
|
/// Subscribe to session deltas. Each subscriber gets its own lag domain —
|
||||||
/// a slow client makes only itself resync.
|
/// a slow client makes only itself resync.
|
||||||
pub fn events(&self) -> broadcast::Receiver<CatalogEvent> {
|
pub fn events(&self) -> broadcast::Receiver<CatalogEvent> {
|
||||||
|
|||||||
@@ -143,11 +143,23 @@ impl SpawnHistory {
|
|||||||
/// Recent working dirs, newest use first, deduped by `cwd`, within the
|
/// Recent working dirs, newest use first, deduped by `cwd`, within the
|
||||||
/// recency cap, capped at `max_results`. Empty when disabled or unreadable.
|
/// recency cap, capped at `max_results`. Empty when disabled or unreadable.
|
||||||
pub fn recent_dirs(&self) -> Vec<RecentDir> {
|
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.
|
/// [`recent_dirs`](Self::recent_dirs) with an explicit clock for testing.
|
||||||
|
#[cfg(test)]
|
||||||
fn recent_dirs_as_of(&self, now: DateTime<Utc>) -> Vec<RecentDir> {
|
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() {
|
if !self.is_enabled() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
@@ -168,7 +180,7 @@ impl SpawnHistory {
|
|||||||
last_command: rec.command,
|
last_command: rec.command,
|
||||||
last_used: rec.at,
|
last_used: rec.at,
|
||||||
});
|
});
|
||||||
if out.len() >= self.max_results {
|
if out.len() >= max_results {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -362,4 +374,28 @@ mod tests {
|
|||||||
assert_eq!(recents[0].cwd, PathBuf::from("/d4"));
|
assert_eq!(recents[0].cwd, PathBuf::from("/d4"));
|
||||||
assert_eq!(recents[1].cwd, PathBuf::from("/d3"));
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ impl WorktreeManager {
|
|||||||
create_branch: bool,
|
create_branch: bool,
|
||||||
) -> Result<PathBuf, WorktreeError> {
|
) -> Result<PathBuf, WorktreeError> {
|
||||||
let path = self.target_path(base_repo, branch);
|
let path = self.target_path(base_repo, branch);
|
||||||
|
// Clear registrations whose directory is gone before testing the
|
||||||
|
// path, so a previously-leaked entry doesn't block this branch.
|
||||||
|
prune(base_repo)?;
|
||||||
if path.symlink_metadata().is_ok() {
|
if path.symlink_metadata().is_ok() {
|
||||||
return Err(WorktreeError::PathExists { path });
|
return Err(WorktreeError::PathExists { path });
|
||||||
}
|
}
|
||||||
@@ -97,6 +100,14 @@ impl WorktreeManager {
|
|||||||
/// which makes this guard the only thing standing between a stray kill
|
/// which makes this guard the only thing standing between a stray kill
|
||||||
/// and destroyed work.
|
/// and destroyed work.
|
||||||
pub fn remove(&self, base_repo: &Path, path: &Path) -> Result<(), WorktreeError> {
|
pub fn remove(&self, base_repo: &Path, path: &Path) -> Result<(), WorktreeError> {
|
||||||
|
// Gone from disk already (operator `rm -rf`, or a create that failed
|
||||||
|
// half-way). Probing it with git would fail and be misread as unsaved
|
||||||
|
// work, stranding the registration — and since paths are
|
||||||
|
// deterministic, `worktree add` would then refuse that branch forever
|
||||||
|
// with "missing but already registered" (#56 review).
|
||||||
|
if !path.exists() {
|
||||||
|
return prune(base_repo);
|
||||||
|
}
|
||||||
if let Some(reason) = unsafe_to_remove(path)? {
|
if let Some(reason) = unsafe_to_remove(path)? {
|
||||||
return Err(WorktreeError::Guarded {
|
return Err(WorktreeError::Guarded {
|
||||||
path: path.to_path_buf(),
|
path: path.to_path_buf(),
|
||||||
@@ -128,55 +139,35 @@ fn sanitize_branch_for_path(branch: &str) -> String {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `Some(reason)` when deleting `path` would destroy work: uncommitted
|
/// `Some(reason)` when deleting `path` would destroy work.
|
||||||
/// 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
|
/// Only an unclean working tree qualifies. `git worktree remove` deletes the
|
||||||
/// anymore — treat its commits as at risk anyway, per #56).
|
/// checkout but keeps `refs/heads/<branch>`, so committed work — pushed or
|
||||||
|
/// not — stays reachable and is never at risk. Guarding on "commits on no
|
||||||
|
/// other ref" instead would refuse every ordinary unpushed feature branch,
|
||||||
|
/// and because the path is deterministic that branch could then never be
|
||||||
|
/// spawned again (#56 review).
|
||||||
fn unsafe_to_remove(path: &Path) -> Result<Option<String>, WorktreeError> {
|
fn unsafe_to_remove(path: &Path) -> Result<Option<String>, WorktreeError> {
|
||||||
let status = run_git(path, ["status", "--porcelain"])?;
|
// `--ignored=no` keeps build output out of it; untracked sources count,
|
||||||
|
// since removal would delete them.
|
||||||
|
let status = run_git(path, ["status", "--porcelain", "--ignored=no"])?;
|
||||||
if !status.stdout.is_empty() {
|
if !status.stdout.is_empty() {
|
||||||
return Ok(Some("has uncommitted changes".into()));
|
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)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `git -C <base_repo> worktree prune` — drops registrations whose directory
|
||||||
|
/// no longer exists.
|
||||||
|
fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
|
||||||
|
let out = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(base_repo)
|
||||||
|
.args(["worktree", "prune"])
|
||||||
|
.output()?;
|
||||||
|
check(out)
|
||||||
|
}
|
||||||
|
|
||||||
fn run_git(
|
fn run_git(
|
||||||
dir: &Path,
|
dir: &Path,
|
||||||
args: impl IntoIterator<Item = &'static str>,
|
args: impl IntoIterator<Item = &'static str>,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ async fn spawn_daemon_ws() -> Harness {
|
|||||||
"test-host",
|
"test-host",
|
||||||
"/home/test",
|
"/home/test",
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
|
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||||
));
|
));
|
||||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -26,6 +26,17 @@ struct Harness {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn spawn(hostname: &str) -> 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 fx = DaemonFixture::new();
|
||||||
let manager = fx.manager.clone();
|
let manager = fx.manager.clone();
|
||||||
// Built-in presets (claude/opencode/zsh + custom) so `/api/info` advertises
|
// Built-in presets (claude/opencode/zsh + custom) so `/api/info` advertises
|
||||||
@@ -36,6 +47,7 @@ async fn spawn(hostname: &str) -> Harness {
|
|||||||
hostname,
|
hostname,
|
||||||
"/home/test",
|
"/home/test",
|
||||||
presets,
|
presets,
|
||||||
|
projects,
|
||||||
));
|
));
|
||||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
.await
|
.await
|
||||||
@@ -153,6 +165,73 @@ async fn recent_dirs_returns_empty_without_history() {
|
|||||||
assert_eq!(resp["truncated"], false);
|
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]
|
#[tokio::test]
|
||||||
async fn gitroot_probes_path() {
|
async fn gitroot_probes_path() {
|
||||||
// #26: resolves the query against the daemon home dir and walks up for a
|
// #26: resolves the query against the daemon home dir and walks up for a
|
||||||
|
|||||||
@@ -206,18 +206,50 @@ fn remove_refuses_worktree_with_uncommitted_changes() {
|
|||||||
.expect_err("dirty worktree must be refused");
|
.expect_err("dirty worktree must be refused");
|
||||||
assert!(path.exists(), "guarded worktree must stay on disk");
|
assert!(path.exists(), "guarded worktree must stay on disk");
|
||||||
|
|
||||||
// Committing the change doesn't help while it is the only copy…
|
// Committing it makes removal safe: `worktree remove` keeps
|
||||||
|
// `refs/heads/feat/dirty`, so the work is still reachable. Refusing here
|
||||||
|
// instead would make every unpushed branch a one-shot, since the path is
|
||||||
|
// deterministic and the leftover tree blocks the next spawn (#56 review).
|
||||||
run_git(&path, &["add", "."]);
|
run_git(&path, &["add", "."]);
|
||||||
run_git(&path, &["commit", "-q", "-m", "unsaved"]);
|
run_git(&path, &["commit", "-q", "-m", "saved"]);
|
||||||
|
|
||||||
|
mgr.remove(&repo, &path).expect("committed work can go");
|
||||||
|
assert!(!path.exists());
|
||||||
|
assert!(
|
||||||
|
worktree_branches(&repo).contains(&"main".to_string()),
|
||||||
|
"the branch survives removal"
|
||||||
|
);
|
||||||
|
let branches = String::from_utf8(
|
||||||
|
Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&repo)
|
||||||
|
.args(["for-each-ref", "--format=%(refname:short)", "refs/heads"])
|
||||||
|
.output()
|
||||||
|
.unwrap()
|
||||||
|
.stdout,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(branches.contains("feat/dirty"), "{branches}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A worktree whose directory vanished must be pruned, not misread as holding
|
||||||
|
/// unsaved work — otherwise the registration is stranded and, because paths
|
||||||
|
/// are deterministic, that branch can never be spawned again (#56 review).
|
||||||
|
#[test]
|
||||||
|
fn remove_prunes_a_worktree_whose_directory_vanished() {
|
||||||
|
let repo_dir = TempDir::new().unwrap();
|
||||||
|
let repo = repo_dir.path().to_path_buf();
|
||||||
|
init_repo(&repo);
|
||||||
|
|
||||||
|
let mgr = WorktreeManager::new(None);
|
||||||
|
let path = mgr.add_new(&repo, "feat/gone").expect("add");
|
||||||
|
std::fs::remove_dir_all(&path).unwrap();
|
||||||
|
|
||||||
mgr.remove(&repo, &path)
|
mgr.remove(&repo, &path)
|
||||||
.expect_err("unique commit must be refused");
|
.expect("a vanished worktree prunes");
|
||||||
assert!(path.exists());
|
// The registration is gone too, so the same branch can be spawned again.
|
||||||
|
mgr.add_existing(&repo, "feat/gone")
|
||||||
// …but once the commit is reachable from main, removal is safe.
|
.expect("branch is spawnable again");
|
||||||
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]) {
|
fn run_git(dir: &std::path::Path, args: &[&str]) {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::routes::{self, UrlError};
|
use crate::routes::{self, UrlError};
|
||||||
use crate::{
|
use crate::{
|
||||||
ApiError, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, RecentDirs, Session,
|
ApiError, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, Project,
|
||||||
|
RecentDirs, Session,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Time budget for establishing a TCP connection to the daemon.
|
/// Time budget for establishing a TCP connection to the daemon.
|
||||||
@@ -138,6 +139,10 @@ impl DaemonClient {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_projects(&self) -> Result<Vec<Project>, ClientError> {
|
||||||
|
self.get(routes::PROJECTS, &[]).await
|
||||||
|
}
|
||||||
|
|
||||||
// -- plumbing -----------------------------------------------------------
|
// -- plumbing -----------------------------------------------------------
|
||||||
|
|
||||||
async fn get<T: DeserializeOwned>(
|
async fn get<T: DeserializeOwned>(
|
||||||
|
|||||||
@@ -188,6 +188,30 @@ pub struct GitRoot {
|
|||||||
pub root: Option<PathBuf>,
|
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.
|
/// Structured failure used everywhere we don't want to leak a string-only error.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct ErrorInfo {
|
pub struct ErrorInfo {
|
||||||
@@ -329,6 +353,34 @@ mod tests {
|
|||||||
serde_json::from_str(&json).expect("deserialize")
|
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 {
|
fn sample_session() -> Session {
|
||||||
Session {
|
Session {
|
||||||
id: Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef),
|
id: Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef),
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ pub const INFO: &str = "/api/info";
|
|||||||
pub const DIRS: &str = "/api/dirs";
|
pub const DIRS: &str = "/api/dirs";
|
||||||
/// `GET` spawn history.
|
/// `GET` spawn history.
|
||||||
pub const RECENT_DIRS: &str = "/api/recent-dirs";
|
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.
|
/// `GET` the enclosing repository of a path. Takes a `path` query param.
|
||||||
pub const GITROOT: &str = "/api/gitroot";
|
pub const GITROOT: &str = "/api/gitroot";
|
||||||
/// `WS` catalog snapshot + session deltas.
|
/// `WS` catalog snapshot + session deltas.
|
||||||
@@ -172,7 +174,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_route_sits_under_the_api_prefix() {
|
fn every_route_sits_under_the_api_prefix() {
|
||||||
for path in [SESSIONS, INFO, DIRS, RECENT_DIRS, GITROOT, EVENTS] {
|
for path in [SESSIONS, INFO, DIRS, RECENT_DIRS, PROJECTS, GITROOT, EVENTS] {
|
||||||
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
|
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
|
||||||
}
|
}
|
||||||
assert!(attach(id()).starts_with(API_PREFIX));
|
assert!(attach(id()).starts_with(API_PREFIX));
|
||||||
|
|||||||
@@ -464,7 +464,16 @@ fn encode_key(key: &InputKey) -> Option<Vec<u8>> {
|
|||||||
InputKey::Home => Some(b"\x1b[H".to_vec()),
|
InputKey::Home => Some(b"\x1b[H".to_vec()),
|
||||||
InputKey::End => Some(b"\x1b[F".to_vec()),
|
InputKey::End => Some(b"\x1b[F".to_vec()),
|
||||||
InputKey::Delete => Some(b"\x1b[3~".to_vec()),
|
InputKey::Delete => Some(b"\x1b[3~".to_vec()),
|
||||||
InputKey::Paste(text) => Some(text.as_bytes().to_vec()),
|
InputKey::Paste(text) => {
|
||||||
|
// Wrap in the DECSET 2004 markers. Raw bytes look like typed keys
|
||||||
|
// to an app that enabled bracketed paste, so a multi-line paste
|
||||||
|
// would execute line by line instead of arriving as one block.
|
||||||
|
let mut out = Vec::with_capacity(text.len() + 12);
|
||||||
|
out.extend_from_slice(b"\x1b[200~");
|
||||||
|
out.extend_from_slice(text.as_bytes());
|
||||||
|
out.extend_from_slice(b"\x1b[201~");
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
InputKey::Mouse { .. } => None,
|
InputKey::Mouse { .. } => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -697,12 +706,14 @@ mod tests {
|
|||||||
|
|
||||||
/// Regression review fix: `Paste(_)` used to be silently dropped, so a
|
/// Regression review fix: `Paste(_)` used to be silently dropped, so a
|
||||||
/// paste into an attached session vanished once bracketed paste was
|
/// paste into an attached session vanished once bracketed paste was
|
||||||
/// enabled — the payload must be forwarded verbatim.
|
/// enabled. It also has to keep its DECSET 2004 markers — without them a
|
||||||
|
/// remote shell reads the payload as typed keys and runs a multi-line
|
||||||
|
/// paste line by line.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_bracketed_paste_is_forwarded_verbatim_to_the_session() {
|
fn a_bracketed_paste_reaches_the_session_with_its_markers() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
encode_key(&InputKey::Paste("cd /tmp/foo".into())),
|
encode_key(&InputKey::Paste("cd /tmp/foo\nls".into())),
|
||||||
Some(b"cd /tmp/foo".to_vec())
|
Some(b"\x1b[200~cd /tmp/foo\nls\x1b[201~".to_vec())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -5,6 +5,7 @@ import type {
|
|||||||
DaemonInfo,
|
DaemonInfo,
|
||||||
DirListing,
|
DirListing,
|
||||||
GitRoot,
|
GitRoot,
|
||||||
|
Projects,
|
||||||
RecentDirs,
|
RecentDirs,
|
||||||
Session,
|
Session,
|
||||||
Uuid,
|
Uuid,
|
||||||
@@ -61,12 +62,17 @@ export async function getDaemonInfo(): Promise<DaemonInfo> {
|
|||||||
return jsonOrError(await fetch(routes.INFO, { credentials: "same-origin" }));
|
return jsonOrError(await fetch(routes.INFO, { credentials: "same-origin" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Recently-used working dirs, newest-first and deduped by dir (#26). Drives the
|
/** Recently-used working dirs, newest-first and deduped by dir (#26). */
|
||||||
* spawn form's recents picker. */
|
|
||||||
export async function recentDirs(): Promise<RecentDirs> {
|
export async function recentDirs(): Promise<RecentDirs> {
|
||||||
return jsonOrError(await fetch(routes.RECENT_DIRS, { credentials: "same-origin" }));
|
return jsonOrError(await fetch(routes.RECENT_DIRS, { credentials: "same-origin" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Every discovered git repository / worktree, newest-activity-first (#54).
|
||||||
|
* Drives the spawn form's project picker. */
|
||||||
|
export async function projects(): Promise<Projects> {
|
||||||
|
return jsonOrError(await fetch(routes.PROJECTS, { credentials: "same-origin" }));
|
||||||
|
}
|
||||||
|
|
||||||
/** Probe the enclosing git repo of `path` (#26). `root` is absent when the path
|
/** Probe the enclosing git repo of `path` (#26). `root` is absent when the path
|
||||||
* isn't inside a repo. `path` may be `~`/relative/absolute; the daemon resolves
|
* isn't inside a repo. `path` may be `~`/relative/absolute; the daemon resolves
|
||||||
* it against its home dir. */
|
* it against its home dir. */
|
||||||
|
|||||||
@@ -109,6 +109,14 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyFilter(entries: string[], prefix: string): void {
|
function applyFilter(entries: string[], prefix: string): void {
|
||||||
|
// A fetch can outlive the dismissal that was meant to close this popup
|
||||||
|
// (Tab-commit re-arms a refresh, then blur dismisses 120ms later). Showing
|
||||||
|
// it now would anchor a dropdown under an unfocused input that nothing
|
||||||
|
// closes until the next interaction.
|
||||||
|
if (dismissed || document.activeElement !== input) {
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const lower = prefix.toLowerCase();
|
const lower = prefix.toLowerCase();
|
||||||
filtered = entries.filter((e) => e.toLowerCase().startsWith(lower));
|
filtered = entries.filter((e) => e.toLowerCase().startsWith(lower));
|
||||||
highlight = filtered.length > 0 ? 0 : -1;
|
highlight = filtered.length > 0 ? 0 : -1;
|
||||||
|
|||||||
+35
-12
@@ -33,8 +33,8 @@ function sess(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stub the spawn-form backend endpoints (#26) so `?mock&spawn` renders the full
|
/** Stub the spawn-form backend endpoints (#26/#59) so `?mock&spawn` renders the
|
||||||
* tile-first flow — presets, recents, gitroot — with no daemon running. Dev-only;
|
* full flow — presets, projects, gitroot — with no daemon running. Dev-only;
|
||||||
* delegates every other request to the real `fetch`. */
|
* delegates every other request to the real `fetch`. */
|
||||||
function stubSpawnApi(): void {
|
function stubSpawnApi(): void {
|
||||||
const real = globalThis.fetch.bind(globalThis);
|
const real = globalThis.fetch.bind(globalThis);
|
||||||
@@ -61,16 +61,39 @@ function stubSpawnApi(): void {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (path.includes("/recent-dirs")) {
|
if (path.includes("/projects")) {
|
||||||
|
const now = Date.now();
|
||||||
|
const iso = (daysAgo: number) => new Date(now - daysAgo * 86_400_000).toISOString();
|
||||||
return Promise.resolve(
|
return Promise.resolve(
|
||||||
json({
|
json([
|
||||||
dirs: [
|
{
|
||||||
{ cwd: "/home/dev/git/maestro", last_command: "claude", last_used: "2026-06-20T10:00:00Z" },
|
path: "/home/dev/git/maestro",
|
||||||
{ cwd: "/home/dev/git/dotfiles", last_command: "zsh", last_used: "2026-06-19T18:00:00Z" },
|
name: "maestro",
|
||||||
{ cwd: "/home/dev/work/acme/api", last_command: "opencode", last_used: "2026-06-18T09:00:00Z" },
|
is_worktree: false,
|
||||||
],
|
last_activity: iso(0),
|
||||||
truncated: false,
|
last_command: "claude",
|
||||||
}),
|
},
|
||||||
|
{
|
||||||
|
path: "/home/dev/worktrees/maestro-59",
|
||||||
|
name: "maestro-59",
|
||||||
|
is_worktree: true,
|
||||||
|
base_repo: "/home/dev/git/maestro",
|
||||||
|
last_activity: iso(1),
|
||||||
|
last_command: "opencode",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/home/dev/git/dotfiles",
|
||||||
|
name: "dotfiles",
|
||||||
|
is_worktree: false,
|
||||||
|
last_activity: iso(30),
|
||||||
|
last_command: "zsh",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/home/dev/work/acme/api",
|
||||||
|
name: "api",
|
||||||
|
is_worktree: false,
|
||||||
|
},
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (path.includes("/gitroot")) {
|
if (path.includes("/gitroot")) {
|
||||||
@@ -113,7 +136,7 @@ export function seedMock(store: CatalogStore): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// `?mock&spawn` auto-opens the spawn modal for visual review, with the spawn
|
// `?mock&spawn` auto-opens the spawn modal for visual review, with the spawn
|
||||||
// backend endpoints stubbed so presets/recents/gitroot all render.
|
// backend endpoints stubbed so presets/projects/gitroot all render.
|
||||||
if (location.search.includes("spawn")) {
|
if (location.search.includes("spawn")) {
|
||||||
stubSpawnApi();
|
stubSpawnApi();
|
||||||
void import("./spawn.js").then((m) => m.openSpawnModal());
|
void import("./spawn.js").then((m) => m.openSpawnModal());
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
||||||
|
import type { Project } from "./types.js";
|
||||||
|
|
||||||
|
const NOW = new Date("2026-08-23T12:00:00Z");
|
||||||
|
|
||||||
|
function project(overrides: Partial<Project>): Project {
|
||||||
|
return {
|
||||||
|
path: `/home/user/${overrides.name ?? "proj"}`,
|
||||||
|
name: "proj",
|
||||||
|
is_worktree: false,
|
||||||
|
last_activity: undefined,
|
||||||
|
last_command: undefined,
|
||||||
|
...overrides,
|
||||||
|
} as Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("isStale", () => {
|
||||||
|
it("marks projects with no known activity stale", () => {
|
||||||
|
expect(isStale(project({ name: "never" }), NOW)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is fresh within 7 days and stale beyond", () => {
|
||||||
|
const fresh = project({ name: "fresh", last_activity: "2026-08-17T00:00:00Z" });
|
||||||
|
const stale = project({ name: "stale", last_activity: "2026-08-16T11:59:00Z" });
|
||||||
|
expect(isStale(fresh, NOW)).toBe(false);
|
||||||
|
expect(isStale(stale, NOW)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sortProjects", () => {
|
||||||
|
it("sorts newest-activity first", () => {
|
||||||
|
const a = project({ name: "a", last_activity: "2026-08-20T00:00:00Z" });
|
||||||
|
const b = project({ name: "b", last_activity: "2026-08-22T00:00:00Z" });
|
||||||
|
expect(sortProjects([a, b], NOW).map((p) => p.name)).toEqual(["b", "a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts stale rows after fresh ones, each block newest first", () => {
|
||||||
|
const freshOld = project({ name: "fresh-old", last_activity: "2026-08-18T00:00:00Z" });
|
||||||
|
const freshNew = project({ name: "fresh-new", last_activity: "2026-08-23T00:00:00Z" });
|
||||||
|
const staleNew = project({ name: "stale-new", last_activity: "2026-08-10T00:00:00Z" });
|
||||||
|
const staleNone = project({ name: "stale-none" });
|
||||||
|
const sorted = sortProjects([staleNew, freshOld, staleNone, freshNew], NOW);
|
||||||
|
expect(sorted.map((p) => p.name)).toEqual(["fresh-new", "fresh-old", "stale-new", "stale-none"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate the input", () => {
|
||||||
|
const a = project({ name: "a", last_activity: "2026-08-01T00:00:00Z" });
|
||||||
|
const b = project({ name: "b", last_activity: "2026-08-02T00:00:00Z" });
|
||||||
|
sortProjects([a, b], NOW);
|
||||||
|
expect(a.name).toBe("a");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("groupProjects", () => {
|
||||||
|
it("keeps plain repositories as their own single-entry group", () => {
|
||||||
|
const solo = project({ name: "solo" });
|
||||||
|
expect(groupProjects(sortProjects([solo], NOW))).toEqual([{ main: solo, worktrees: [] }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nests worktrees under their base repository even when fresher than it", () => {
|
||||||
|
const main = project({ name: "maestro" });
|
||||||
|
const wt = project({
|
||||||
|
name: "wt",
|
||||||
|
path: "/elsewhere/wt",
|
||||||
|
is_worktree: true,
|
||||||
|
base_repo: main.path,
|
||||||
|
last_activity: "2026-08-23T00:00:00Z",
|
||||||
|
});
|
||||||
|
const groups = groupProjects(sortProjects([wt, main], NOW));
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
expect(groups[0].main).toBe(main);
|
||||||
|
expect(groups[0].worktrees).toEqual([wt]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders groups by the freshest member and worktrees by activity", () => {
|
||||||
|
const mainA = project({ name: "a", path: "/w/a", last_activity: "2026-08-01T00:00:00Z" });
|
||||||
|
const wtA = project({
|
||||||
|
name: "a-wt",
|
||||||
|
path: "/w/a-wt",
|
||||||
|
is_worktree: true,
|
||||||
|
base_repo: "/w/a",
|
||||||
|
last_activity: "2026-08-20T00:00:00Z",
|
||||||
|
});
|
||||||
|
const mainB = project({ name: "b", path: "/w/b", last_activity: "2026-08-15T00:00:00Z" });
|
||||||
|
const wtB1 = project({
|
||||||
|
name: "b-wt1",
|
||||||
|
path: "/w/b-wt1",
|
||||||
|
is_worktree: true,
|
||||||
|
base_repo: "/w/b",
|
||||||
|
last_activity: "2026-08-19T00:00:00Z",
|
||||||
|
});
|
||||||
|
const wtB2 = project({
|
||||||
|
name: "b-wt2",
|
||||||
|
path: "/w/b-wt2",
|
||||||
|
is_worktree: true,
|
||||||
|
base_repo: "/w/b",
|
||||||
|
last_activity: "2026-08-21T00:00:00Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = groupProjects(sortProjects([mainA, mainB, wtB1, wtA, wtB2], NOW));
|
||||||
|
expect(groups.map((g) => g.main.name)).toEqual(["b", "a"]);
|
||||||
|
const gA = groups.find((g) => g.main.name === "a")!;
|
||||||
|
const gB = groups.find((g) => g.main.name === "b")!;
|
||||||
|
expect(gA.worktrees.map((p) => p.name)).toEqual(["a-wt"]);
|
||||||
|
expect(gB.worktrees.map((p) => p.name)).toEqual(["b-wt2", "b-wt1"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("filterProjects", () => {
|
||||||
|
const maestro = project({ name: "maestro", path: "/home/u/git/maestro" });
|
||||||
|
const tea = project({ name: "tea", path: "/home/u/work/tea" });
|
||||||
|
|
||||||
|
it("matches on name case-insensitively", () => {
|
||||||
|
expect(filterProjects([maestro, tea], "MAES")).toEqual([maestro]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches on the full path", () => {
|
||||||
|
expect(filterProjects([maestro, tea], "work")).toEqual([tea]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns everything unchanged for an empty query", () => {
|
||||||
|
expect(filterProjects([maestro, tea], " ")).toEqual([maestro, tea]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Pure helpers behind the spawn form's project picker (#59): staleness,
|
||||||
|
// recency ordering, worktree grouping, and free-text filtering. The daemon
|
||||||
|
// reports the timestamps; what "stale" means is a client decision (#54).
|
||||||
|
|
||||||
|
import type { Project } from "./types.js";
|
||||||
|
|
||||||
|
/** Projects whose last activity is older than this are dimmed and sort after
|
||||||
|
* fresh ones — still reachable, visibly stale (#59). */
|
||||||
|
export const STALE_AFTER_DAYS = 7;
|
||||||
|
|
||||||
|
export function isStale(project: Project, now: Date): boolean {
|
||||||
|
if (!project.last_activity) return true;
|
||||||
|
const age = now.getTime() - Date.parse(project.last_activity);
|
||||||
|
return !(age >= 0) || age > STALE_AFTER_DAYS * 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fresh rows first, each block newest-activity-first; unknown or future
|
||||||
|
* timestamps sort as stalest. Ties fall back to name so the order is stable. */
|
||||||
|
export function sortProjects(projects: Project[], now: Date): Project[] {
|
||||||
|
return [...projects].sort((a, b) => {
|
||||||
|
const staleDiff = (isStale(a, now) ? 1 : 0) - (isStale(b, now) ? 1 : 0);
|
||||||
|
if (staleDiff !== 0) return staleDiff;
|
||||||
|
const ta = timeOf(a);
|
||||||
|
const tb = timeOf(b);
|
||||||
|
if (ta !== tb) return tb - ta;
|
||||||
|
return a.name.localeCompare(b.name) || a.path.localeCompare(b.path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeOf(project: Project): number {
|
||||||
|
return project.last_activity ? Date.parse(project.last_activity) : Number.NEGATIVE_INFINITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One repository group: the main checkout followed by its linked worktrees.
|
||||||
|
* A group's position follows its freshest member, since a worktree worked on
|
||||||
|
* today should pull its (dormant) base repo up with it. */
|
||||||
|
export interface ProjectGroup {
|
||||||
|
main: Project;
|
||||||
|
worktrees: Project[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Group `sorted` projects by their base repository. A worktree nests under
|
||||||
|
* its main checkout even when the main sorts later; a main checkout missing
|
||||||
|
* from the list doesn't drop its worktrees. */
|
||||||
|
export function groupProjects(sorted: Project[]): ProjectGroup[] {
|
||||||
|
const members = new Map<string, Project[]>();
|
||||||
|
const keys: string[] = [];
|
||||||
|
for (const p of sorted) {
|
||||||
|
const key = p.base_repo ?? p.path;
|
||||||
|
let list = members.get(key);
|
||||||
|
if (!list) {
|
||||||
|
list = [];
|
||||||
|
members.set(key, list);
|
||||||
|
keys.push(key);
|
||||||
|
}
|
||||||
|
list.push(p);
|
||||||
|
}
|
||||||
|
return keys.map((key) => {
|
||||||
|
const list = members.get(key)!;
|
||||||
|
const main = list.find((p) => p.path === key && !p.is_worktree) ?? list[0];
|
||||||
|
// Keep the daemon's newest-first order within the group.
|
||||||
|
const worktrees = list
|
||||||
|
.filter((p) => p !== main)
|
||||||
|
.sort((a, b) => timeOf(b) - timeOf(a));
|
||||||
|
return { main, worktrees };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Substring filter over project name and full path, case-insensitive. */
|
||||||
|
export function filterProjects(projects: Project[], query: string): Project[] {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return [...projects];
|
||||||
|
return projects.filter(
|
||||||
|
(p) => p.name.toLowerCase().includes(q) || p.path.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
||||||
|
import type { Project } from "./types.js";
|
||||||
|
|
||||||
|
const NOW = "2026-08-23T12:00:00Z";
|
||||||
|
|
||||||
|
function project(overrides: Partial<Project>): Project {
|
||||||
|
return {
|
||||||
|
path: `/home/user/${overrides.name ?? "proj"}`,
|
||||||
|
name: "proj",
|
||||||
|
is_worktree: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two fetches gate the form's initial load: /api/info then /api/projects. */
|
||||||
|
function stubFetch(info: unknown, projects: Project[]): void {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
const body = url.includes("/api/info")
|
||||||
|
? info
|
||||||
|
: url.includes("/api/projects")
|
||||||
|
? projects
|
||||||
|
: undefined;
|
||||||
|
return new Response(JSON.stringify(body), { status: 200 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowPaths(form: HTMLFormElement): string[] {
|
||||||
|
return [...form.querySelectorAll<HTMLButtonElement>(".project-row:not(.project-other)")].map(
|
||||||
|
(r) => r.dataset.cwd ?? "",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("spawnForm project picker", () => {
|
||||||
|
let handle: SpawnFormHandle | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ now: Date.parse(NOW) });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
handle?.destroy();
|
||||||
|
handle = null;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function mount(projects: Project[], seed?: Parameters<typeof createSpawnForm>[0]["seed"]) {
|
||||||
|
stubFetch({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }, projects);
|
||||||
|
handle = createSpawnForm({
|
||||||
|
seed,
|
||||||
|
onSuccess() {},
|
||||||
|
});
|
||||||
|
document.body.appendChild(handle.el);
|
||||||
|
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("renders one row per project, fresh before stale", async () => {
|
||||||
|
const fresh = project({ name: "fresh", path: "/home/user/fresh", last_activity: NOW });
|
||||||
|
const stale = project({
|
||||||
|
name: "stale",
|
||||||
|
path: "/home/user/stale",
|
||||||
|
last_activity: "2026-01-01T00:00:00Z",
|
||||||
|
});
|
||||||
|
const form = (await mount([stale, fresh])).el;
|
||||||
|
expect(rowPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
|
||||||
|
const rows = [...form.querySelectorAll<HTMLButtonElement>(".project-row")];
|
||||||
|
expect(rows[1].classList.contains("is-stale")).toBe(true);
|
||||||
|
expect(rows[0].classList.contains("is-stale")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nests worktrees under their base repository", async () => {
|
||||||
|
const main = project({ name: "maestro", path: "/home/user/maestro" });
|
||||||
|
const wt = project({
|
||||||
|
name: "wt",
|
||||||
|
path: "/elsewhere/wt",
|
||||||
|
is_worktree: true,
|
||||||
|
base_repo: main.path,
|
||||||
|
});
|
||||||
|
const form = (await mount([wt, main])).el;
|
||||||
|
expect(rowPaths(form)).toEqual(["/home/user/maestro", "/elsewhere/wt"]);
|
||||||
|
const [mainRow, wtRow] = [
|
||||||
|
...form.querySelectorAll(".project-row:not(.project-other)"),
|
||||||
|
];
|
||||||
|
expect(wtRow.classList.contains("is-worktree")).toBe(true);
|
||||||
|
expect(mainRow.classList.contains("is-worktree")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters rows as the operator types", async () => {
|
||||||
|
const a = project({ name: "maestro", path: "/home/user/maestro" });
|
||||||
|
const b = project({ name: "tea", path: "/home/user/work/tea" });
|
||||||
|
const form = (await mount([a, b])).el;
|
||||||
|
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
|
||||||
|
filter.value = "tea";
|
||||||
|
filter.dispatchEvent(new Event("input"));
|
||||||
|
expect(rowPaths(form)).toEqual(["/home/user/work/tea"]);
|
||||||
|
filter.value = "zzz";
|
||||||
|
filter.dispatchEvent(new Event("input"));
|
||||||
|
expect(rowPaths(form)).toEqual([]);
|
||||||
|
expect(form.querySelector(".picker-empty")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picking a project selects its cwd and pre-selects its last command", async () => {
|
||||||
|
const p = project({
|
||||||
|
name: "maestro",
|
||||||
|
path: "/home/user/maestro",
|
||||||
|
last_command: "claude",
|
||||||
|
});
|
||||||
|
const onChange = vi.fn();
|
||||||
|
stubFetch(
|
||||||
|
{
|
||||||
|
hostname: "h",
|
||||||
|
version: "0",
|
||||||
|
home_dir: "/home/user",
|
||||||
|
presets: [
|
||||||
|
{ id: "claude", label: "Claude", command: "claude" },
|
||||||
|
{ id: "custom", label: "Custom…", command: "" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[p],
|
||||||
|
);
|
||||||
|
handle = createSpawnForm({ onSuccess() {}, onChange });
|
||||||
|
const form = handle.el;
|
||||||
|
document.body.appendChild(form);
|
||||||
|
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||||
|
onChange.mockClear();
|
||||||
|
|
||||||
|
const row = form.querySelector<HTMLButtonElement>(".project-row")!;
|
||||||
|
row.click();
|
||||||
|
|
||||||
|
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
|
||||||
|
const last = onChange.mock.lastCall![0];
|
||||||
|
expect(last.cwd).toBe("/home/user/maestro");
|
||||||
|
expect(last.command).toBe("claude");
|
||||||
|
expect(values).toBeTruthy();
|
||||||
|
// The raw input stays hidden — the picked row is the source of truth.
|
||||||
|
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(true);
|
||||||
|
expect(row.classList.contains("is-active")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an 'Other directory…' escape hatch that reveals the raw input", async () => {
|
||||||
|
const p = project({ name: "maestro", path: "/home/user/maestro" });
|
||||||
|
const form = (await mount([p])).el;
|
||||||
|
const other = form.querySelector<HTMLButtonElement>(".project-other")!;
|
||||||
|
other.click();
|
||||||
|
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the raw input when no projects exist", async () => {
|
||||||
|
const form = (await mount([])).el;
|
||||||
|
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(false);
|
||||||
|
const raw = form.querySelector<HTMLInputElement>(".cwd-raw")!;
|
||||||
|
expect(raw.value).toBe("/home/user");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ export const SESSIONS = "/api/sessions";
|
|||||||
export const INFO = "/api/info";
|
export const INFO = "/api/info";
|
||||||
export const DIRS = "/api/dirs";
|
export const DIRS = "/api/dirs";
|
||||||
export const RECENT_DIRS = "/api/recent-dirs";
|
export const RECENT_DIRS = "/api/recent-dirs";
|
||||||
|
export const PROJECTS = "/api/projects";
|
||||||
export const GITROOT = "/api/gitroot";
|
export const GITROOT = "/api/gitroot";
|
||||||
export const EVENTS = "/api/events";
|
export const EVENTS = "/api/events";
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ const base: SpawnFormValues = {
|
|||||||
name: "amber-fox",
|
name: "amber-fox",
|
||||||
worktreeEnabled: false,
|
worktreeEnabled: false,
|
||||||
worktreeBranch: "",
|
worktreeBranch: "",
|
||||||
worktreeCleanup: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("buildSpawnRequest", () => {
|
describe("buildSpawnRequest", () => {
|
||||||
@@ -24,9 +23,9 @@ describe("buildSpawnRequest", () => {
|
|||||||
expect(res).toEqual({ req: { cwd: base.cwd, command: base.command, metadata: {} } });
|
expect(res).toEqual({ req: { cwd: base.cwd, command: base.command, metadata: {} } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("attaches a trimmed branch and cleanup flag when worktree is enabled", () => {
|
it("attaches a trimmed branch when worktree is enabled, and always cleans up", () => {
|
||||||
const res = buildSpawnRequest(
|
const res = buildSpawnRequest(
|
||||||
{ ...base, worktreeEnabled: true, worktreeBranch: " feat/foo ", worktreeCleanup: true },
|
{ ...base, worktreeEnabled: true, worktreeBranch: " feat/foo " },
|
||||||
"/home/user/project",
|
"/home/user/project",
|
||||||
);
|
);
|
||||||
expect(res).toEqual({
|
expect(res).toEqual({
|
||||||
|
|||||||
+91
-62
@@ -1,8 +1,9 @@
|
|||||||
import { escapeHtml } from "./html.js";
|
import { escapeHtml } from "./html.js";
|
||||||
import { createSession, getDaemonInfo, recentDirs, gitRoot } from "./api.js";
|
import { createSession, getDaemonInfo, projects as fetchProjects, gitRoot } from "./api.js";
|
||||||
import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
||||||
|
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
||||||
import { generateName } from "./names.js";
|
import { generateName } from "./names.js";
|
||||||
import type { CreateSessionRequest, RecentDir, Session, SpawnPreset } from "./types.js";
|
import type { CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
||||||
import { abbreviateCwd, abbreviateFolderName } from "./types.js";
|
import { abbreviateCwd, abbreviateFolderName } from "./types.js";
|
||||||
|
|
||||||
/** Seed values for the spawn form — used by "clone" to reopen with the same
|
/** Seed values for the spawn form — used by "clone" to reopen with the same
|
||||||
@@ -14,7 +15,6 @@ export interface SpawnFormSeed {
|
|||||||
name?: string;
|
name?: string;
|
||||||
worktreeEnabled?: boolean;
|
worktreeEnabled?: boolean;
|
||||||
worktreeBranch?: string;
|
worktreeBranch?: string;
|
||||||
worktreeCleanup?: boolean;
|
|
||||||
cwdRawVisible?: boolean;
|
cwdRawVisible?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +26,6 @@ export interface SpawnFormValues {
|
|||||||
name: string;
|
name: string;
|
||||||
worktreeEnabled: boolean;
|
worktreeEnabled: boolean;
|
||||||
worktreeBranch: string;
|
worktreeBranch: string;
|
||||||
worktreeCleanup: boolean;
|
|
||||||
/** Whether the cwd lives in the raw text input (vs a picked recent row). */
|
/** Whether the cwd lives in the raw text input (vs a picked recent row). */
|
||||||
cwdRawVisible?: boolean;
|
cwdRawVisible?: boolean;
|
||||||
}
|
}
|
||||||
@@ -55,7 +54,9 @@ export function buildSpawnRequest(
|
|||||||
req.worktree = {
|
req.worktree = {
|
||||||
base_repo: baseRepo,
|
base_repo: baseRepo,
|
||||||
branch,
|
branch,
|
||||||
cleanup_on_kill: values.worktreeCleanup,
|
// The daemon always cleans up, guarded against unsaved work (#56);
|
||||||
|
// the wire field stays for older daemons.
|
||||||
|
cleanup_on_kill: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +88,7 @@ export interface SpawnFormOptions {
|
|||||||
onSuccess: (session: Session) => void;
|
onSuccess: (session: Session) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The spawn form itself — preset tiles, recent-dirs picker, name, worktree.
|
/** The spawn form itself — preset tiles, project picker, name, worktree.
|
||||||
* Shared by the modal (`spawn.ts`) and inline draft panes (issue #57). */
|
* Shared by the modal (`spawn.ts`) and inline draft panes (issue #57). */
|
||||||
export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||||
const prefill = opts.seed ?? {};
|
const prefill = opts.seed ?? {};
|
||||||
@@ -103,7 +104,9 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="field-label">Working directory</span>
|
<span class="field-label">Working directory</span>
|
||||||
<div class="picker recents"></div>
|
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
|
||||||
|
autocomplete="off" />
|
||||||
|
<div class="picker projects"></div>
|
||||||
<div class="raw-wrap cwd-raw-wrap" hidden>
|
<div class="raw-wrap cwd-raw-wrap" hidden>
|
||||||
<input class="raw-input cwd-raw" name="cwd_raw" type="text"
|
<input class="raw-input cwd-raw" name="cwd_raw" type="text"
|
||||||
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
||||||
@@ -130,10 +133,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
Branch
|
Branch
|
||||||
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
||||||
</label>
|
</label>
|
||||||
<label class="row-inline">
|
|
||||||
<input type="checkbox" name="worktree_cleanup_on_kill" />
|
|
||||||
Remove worktree when session is killed
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<p class="form-error" role="alert" aria-live="polite"></p>
|
<p class="form-error" role="alert" aria-live="polite"></p>
|
||||||
@@ -147,7 +146,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
const errEl = form.querySelector(".form-error") as HTMLElement;
|
const errEl = form.querySelector(".form-error") as HTMLElement;
|
||||||
const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement;
|
const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement;
|
||||||
const cmdRaw = form.querySelector(".cmd-raw") as HTMLInputElement;
|
const cmdRaw = form.querySelector(".cmd-raw") as HTMLInputElement;
|
||||||
const recentsEl = form.querySelector(".recents") as HTMLElement;
|
const recentsEl = form.querySelector(".projects") as HTMLElement;
|
||||||
|
const filterInput = form.querySelector(".project-filter") as HTMLInputElement;
|
||||||
const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
|
const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
|
||||||
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
|
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
|
||||||
const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement;
|
const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement;
|
||||||
@@ -155,9 +155,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
const wtBody = form.querySelector(".wt-body") as HTMLElement;
|
const wtBody = form.querySelector(".wt-body") as HTMLElement;
|
||||||
const wtRepo = form.querySelector(".wt-repo") as HTMLElement;
|
const wtRepo = form.querySelector(".wt-repo") as HTMLElement;
|
||||||
const branchInput = form.querySelector("input[name=worktree_branch]") as HTMLInputElement;
|
const branchInput = form.querySelector("input[name=worktree_branch]") as HTMLInputElement;
|
||||||
const cleanupInput = form.querySelector(
|
|
||||||
"input[name=worktree_cleanup_on_kill]",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const nameInput = form.querySelector("input[name=name]") as HTMLInputElement;
|
const nameInput = form.querySelector("input[name=name]") as HTMLInputElement;
|
||||||
|
|
||||||
// ---- form state ------------------------------------------------------
|
// ---- form state ------------------------------------------------------
|
||||||
@@ -168,18 +165,26 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
let cwd = ""; // chosen working directory
|
let cwd = ""; // chosen working directory
|
||||||
let inferredBaseRepo: string | null = null; // from the gitroot probe
|
let inferredBaseRepo: string | null = null; // from the gitroot probe
|
||||||
let probeToken = 0; // guards async gitroot probes against races
|
let probeToken = 0; // guards async gitroot probes against races
|
||||||
// A seed's cwd, applied once the (async) recents load for the initial host
|
// A seed's cwd, applied once the (async) projects load for the initial host
|
||||||
// settles — so the fetch can't race past and clobber the seeded directory.
|
// settles — so the fetch can't race past and clobber the seeded directory.
|
||||||
let seedCwd: string | null = prefill.cwd ?? null;
|
let seedCwd: string | null = prefill.cwd ?? null;
|
||||||
|
|
||||||
|
// `emit` stays silent until the initial load settles and goes silent again
|
||||||
|
// once the form is destroyed. Construction runs with `cwd === ""` and the
|
||||||
|
// seed is only restored two fetches later, so emitting in between would
|
||||||
|
// blank the leaf's draft; and `multiview.render()` rebuilds forms while the
|
||||||
|
// old one's fetches are still in flight, so a destroyed form emitting would
|
||||||
|
// clobber the live one's leaf (#57 review).
|
||||||
|
let ready = false;
|
||||||
|
let destroyed = false;
|
||||||
const emit = () => {
|
const emit = () => {
|
||||||
|
if (!ready || destroyed) return;
|
||||||
opts.onChange?.({
|
opts.onChange?.({
|
||||||
command,
|
command,
|
||||||
cwd,
|
cwd,
|
||||||
name: nameInput.value,
|
name: nameInput.value,
|
||||||
worktreeEnabled: wtEnabled.checked,
|
worktreeEnabled: wtEnabled.checked,
|
||||||
worktreeBranch: branchInput.value,
|
worktreeBranch: branchInput.value,
|
||||||
worktreeCleanup: cleanupInput.checked,
|
|
||||||
cwdRawVisible: !cwdRawWrap.hidden,
|
cwdRawVisible: !cwdRawWrap.hidden,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -291,11 +296,14 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
cwdRawWrap.hidden = false;
|
cwdRawWrap.hidden = false;
|
||||||
if (prefillValue != null) cwd = prefillValue;
|
if (prefillValue != null) cwd = prefillValue;
|
||||||
cwdRaw.value = cwd;
|
cwdRaw.value = cwd;
|
||||||
markActiveRecent(null);
|
markActiveProject(null);
|
||||||
if (focus) {
|
if (focus) {
|
||||||
cwdRaw.focus();
|
cwdRaw.focus();
|
||||||
cwdRaw.select();
|
cwdRaw.select();
|
||||||
}
|
}
|
||||||
|
// Both the visibility and the directory are draft state: without this a
|
||||||
|
// render before the operator types snaps back to the project picker.
|
||||||
|
emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
cwdRaw.addEventListener("input", () => {
|
cwdRaw.addEventListener("input", () => {
|
||||||
@@ -304,8 +312,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
if (wtEnabled.checked) probeGitRoot();
|
if (wtEnabled.checked) probeGitRoot();
|
||||||
});
|
});
|
||||||
|
|
||||||
function markActiveRecent(activeCwd: string | null): void {
|
function markActiveProject(activeCwd: string | null): void {
|
||||||
for (const row of recentsEl.querySelectorAll(".recent-row")) {
|
for (const row of recentsEl.querySelectorAll(".project-row")) {
|
||||||
row.classList.toggle(
|
row.classList.toggle(
|
||||||
"is-active",
|
"is-active",
|
||||||
(row as HTMLElement).dataset.cwd === activeCwd,
|
(row as HTMLElement).dataset.cwd === activeCwd,
|
||||||
@@ -313,78 +321,98 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectRecent(dir: RecentDir): void {
|
function selectProject(project: Project): void {
|
||||||
cwd = dir.cwd;
|
cwd = project.path;
|
||||||
cwdRawWrap.hidden = true;
|
cwdRawWrap.hidden = true;
|
||||||
markActiveRecent(dir.cwd);
|
markActiveProject(project.path);
|
||||||
emit();
|
emit();
|
||||||
// Picking a recent dir can pre-select the command it was last used with.
|
// Picking a project pre-selects the command it was last used with.
|
||||||
const match = presets.find((p) => p.command === dir.last_command && p.command !== "");
|
const match = presets.find((p) => p.command === project.last_command && p.command !== "");
|
||||||
if (match) selectPreset(match.id);
|
if (match) selectPreset(match.id);
|
||||||
if (wtEnabled.checked) probeGitRoot();
|
if (wtEnabled.checked) probeGitRoot();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRecents(): Promise<void> {
|
let discovered: Project[] = []; // daemon snapshot, daemon-sorted
|
||||||
recentsEl.replaceChildren(loadingRow());
|
|
||||||
let dirs: RecentDir[] = [];
|
function renderProjects(): void {
|
||||||
try {
|
|
||||||
dirs = (await recentDirs()).dirs ?? [];
|
|
||||||
} catch {
|
|
||||||
dirs = [];
|
|
||||||
}
|
|
||||||
recentsEl.replaceChildren();
|
recentsEl.replaceChildren();
|
||||||
for (const dir of dirs) {
|
const sorted = sortProjects(filterProjects(discovered, filterInput.value), new Date());
|
||||||
recentsEl.appendChild(recentRow(dir));
|
for (const group of groupProjects(sorted)) {
|
||||||
|
recentsEl.appendChild(projectRow(group.main));
|
||||||
|
for (const wt of group.worktrees) recentsEl.appendChild(projectRow(wt));
|
||||||
|
}
|
||||||
|
if (discovered.length > 0 && sorted.length === 0) {
|
||||||
|
const empty = document.createElement("p");
|
||||||
|
empty.className = "picker-empty";
|
||||||
|
empty.textContent = "No projects match.";
|
||||||
|
recentsEl.appendChild(empty);
|
||||||
}
|
}
|
||||||
// Persistent escape hatch + empty-history fallback.
|
|
||||||
recentsEl.appendChild(otherDirRow());
|
recentsEl.appendChild(otherDirRow());
|
||||||
|
markActiveProject(cwdRawWrap.hidden ? cwd : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
filterInput.addEventListener("input", renderProjects);
|
||||||
|
|
||||||
|
async function loadProjects(): Promise<void> {
|
||||||
|
recentsEl.replaceChildren(loadingRow());
|
||||||
|
try {
|
||||||
|
discovered = await fetchProjects();
|
||||||
|
} catch {
|
||||||
|
discovered = [];
|
||||||
|
}
|
||||||
|
if (destroyed) return;
|
||||||
|
renderProjects();
|
||||||
if (seedCwd) {
|
if (seedCwd) {
|
||||||
// A seed sets the directory verbatim, regardless of recorded history.
|
// A seed sets the directory verbatim, regardless of what the scan found.
|
||||||
// `cwdRawVisible === false` marks a draft restore whose cwd came from a
|
// `cwdRawVisible === false` marks a draft restore whose cwd came from a
|
||||||
// picked recent row: keep the picker and don't steal focus. Otherwise
|
// picked row: keep the picker and don't steal focus. Otherwise land
|
||||||
// land focused in the raw input (clone / empty-history flows).
|
// focused in the raw input (clone / no-match flows).
|
||||||
if (prefill.cwdRawVisible === false) {
|
if (prefill.cwdRawVisible === false) {
|
||||||
cwd = seedCwd;
|
cwd = seedCwd;
|
||||||
cwdRaw.value = seedCwd;
|
cwdRaw.value = seedCwd;
|
||||||
markActiveRecent(cwd);
|
markActiveProject(cwd);
|
||||||
} else {
|
} else {
|
||||||
showRawCwd(seedCwd, false);
|
showRawCwd(seedCwd, false);
|
||||||
}
|
}
|
||||||
seedCwd = null;
|
seedCwd = null;
|
||||||
} else if (dirs.length === 0) {
|
} else if (discovered.length === 0) {
|
||||||
// No history: default to raw input prefilled with the daemon's home dir.
|
// Nothing discovered yet: default to raw input prefilled with home.
|
||||||
showRawCwd(homeDir, false);
|
showRawCwd(homeDir, false);
|
||||||
} else {
|
} else {
|
||||||
cwdRawWrap.hidden = true;
|
cwdRawWrap.hidden = true;
|
||||||
}
|
}
|
||||||
// The seeded cwd is in place — a restored worktree can probe now.
|
// Everything seeded: the form may publish from here on.
|
||||||
|
ready = true;
|
||||||
if (wtEnabled.checked) void probeGitRoot();
|
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();
|
emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function recentRow(dir: RecentDir): HTMLElement {
|
function projectRow(project: Project): HTMLElement {
|
||||||
const row = document.createElement("button");
|
const row = document.createElement("button");
|
||||||
row.type = "button";
|
row.type = "button";
|
||||||
row.className = "recent-row";
|
row.className = `project-row${project.is_worktree ? " is-worktree" : ""}${
|
||||||
row.dataset.cwd = dir.cwd;
|
isStale(project, new Date()) ? " is-stale" : ""
|
||||||
|
}`;
|
||||||
|
row.dataset.cwd = project.path;
|
||||||
// Abbreviate exactly like the sidebar: home → `~`, then collapse leading
|
// Abbreviate exactly like the sidebar: home → `~`, then collapse leading
|
||||||
// segments to initials (`~/g/n/maestro`). The full path stays in the tooltip
|
// segments to initials (`~/g/n/maestro`). The full path stays in the tooltip
|
||||||
// and `dataset.cwd` (what's actually submitted).
|
// and `dataset.cwd` (what's actually submitted).
|
||||||
const display = abbreviateFolderName(abbreviateCwd(dir.cwd, homeDir));
|
const display = abbreviateFolderName(abbreviateCwd(project.path, homeDir));
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<span class="recent-dir" title="${escapeHtml(dir.cwd)}">${display}</span>
|
<span class="project-info">
|
||||||
<span class="recent-cmd">${escapeHtml(dir.last_command)}</span>`;
|
<span class="project-name">${escapeHtml(project.name)}</span>
|
||||||
row.addEventListener("click", () => selectRecent(dir));
|
<span class="recent-dir" title="${escapeHtml(project.path)}">${display}</span>
|
||||||
|
</span>
|
||||||
|
<span class="recent-cmd">${escapeHtml(project.last_command ?? "")}</span>`;
|
||||||
|
row.addEventListener("click", () => selectProject(project));
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
function otherDirRow(): HTMLElement {
|
function otherDirRow(): HTMLElement {
|
||||||
const row = document.createElement("button");
|
const row = document.createElement("button");
|
||||||
row.type = "button";
|
row.type = "button";
|
||||||
row.className = "recent-row recent-other";
|
row.className = "project-row project-other";
|
||||||
row.innerHTML = `<span class="recent-dir">Other directory…</span>`;
|
row.innerHTML = `<span class="project-info"><span class="recent-dir">Other directory…</span></span>`;
|
||||||
row.addEventListener("click", () => showRawCwd(cwd || homeDir));
|
row.addEventListener("click", () => showRawCwd(cwd || homeDir));
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
@@ -392,7 +420,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
function loadingRow(): HTMLElement {
|
function loadingRow(): HTMLElement {
|
||||||
const p = document.createElement("p");
|
const p = document.createElement("p");
|
||||||
p.className = "picker-loading";
|
p.className = "picker-loading";
|
||||||
p.textContent = "Loading recent directories…";
|
p.textContent = "Loading projects…";
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,7 +451,10 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
wtRepo.textContent = `repo: ${root} (inferred)`;
|
wtRepo.textContent = `repo: ${root} (inferred)`;
|
||||||
} else {
|
} else {
|
||||||
inferredBaseRepo = null;
|
inferredBaseRepo = null;
|
||||||
|
// Publish the untick too, or the next render seeds the box checked with
|
||||||
|
// no inferred repo and submitting fails.
|
||||||
wtEnabled.checked = false;
|
wtEnabled.checked = false;
|
||||||
|
emit();
|
||||||
wtBody.hidden = true;
|
wtBody.hidden = true;
|
||||||
wtHint.hidden = false;
|
wtHint.hidden = false;
|
||||||
wtHint.textContent = "Not inside a git repository — pick a dir in a repo to use a worktree.";
|
wtHint.textContent = "Not inside a git repository — pick a dir in a repo to use a worktree.";
|
||||||
@@ -440,22 +471,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
wtHint.hidden = true;
|
wtHint.hidden = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
cleanupInput.addEventListener("change", emit);
|
|
||||||
|
|
||||||
// A seeded worktree restores its checkbox/body up front; its gitroot probe
|
// A seeded worktree restores its checkbox/body up front; its gitroot probe
|
||||||
// waits for loadRecents to apply the seeded cwd.
|
// waits for loadRecents to apply the seeded cwd.
|
||||||
if (prefill.worktreeEnabled || prefill.worktreeBranch || prefill.worktreeCleanup) {
|
if (prefill.worktreeEnabled || prefill.worktreeBranch) {
|
||||||
wtEnabled.checked = Boolean(prefill.worktreeEnabled);
|
wtEnabled.checked = Boolean(prefill.worktreeEnabled);
|
||||||
if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch;
|
if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch;
|
||||||
cleanupInput.checked = Boolean(prefill.worktreeCleanup);
|
|
||||||
wtBody.hidden = !wtEnabled.checked;
|
wtBody.hidden = !wtEnabled.checked;
|
||||||
wtHint.hidden = true;
|
wtHint.hidden = true;
|
||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed ordering: the raw-cwd application races the async info load (which
|
// Seed ordering: the raw-cwd application races the async info load (which
|
||||||
// provides homeDir for the recents' abbreviations), so recents wait for it.
|
// provides homeDir for the projects' abbreviations), so projects wait for it.
|
||||||
void infoLoad.then(() => loadRecents());
|
void infoLoad.then(() => loadProjects());
|
||||||
|
|
||||||
// ---- live dir autocomplete on the raw cwd input (NAP-329) ------------
|
// ---- live dir autocomplete on the raw cwd input (NAP-329) ------------
|
||||||
const autocomplete = attachDirAutocomplete(cwdRaw);
|
const autocomplete = attachDirAutocomplete(cwdRaw);
|
||||||
@@ -471,8 +500,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
name: nameInput.value,
|
name: nameInput.value,
|
||||||
worktreeEnabled: wtEnabled.checked,
|
worktreeEnabled: wtEnabled.checked,
|
||||||
worktreeBranch: branchInput.value,
|
worktreeBranch: branchInput.value,
|
||||||
worktreeCleanup: cleanupInput.checked,
|
},
|
||||||
},
|
|
||||||
inferredBaseRepo,
|
inferredBaseRepo,
|
||||||
);
|
);
|
||||||
if ("error" in built) {
|
if ("error" in built) {
|
||||||
@@ -494,6 +522,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
return {
|
return {
|
||||||
el: form,
|
el: form,
|
||||||
destroy() {
|
destroy() {
|
||||||
|
destroyed = true;
|
||||||
autocomplete.destroy();
|
autocomplete.destroy();
|
||||||
form.remove();
|
form.remove();
|
||||||
},
|
},
|
||||||
|
|||||||
+40
-7
@@ -1448,7 +1448,8 @@ textarea {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Recent-dirs picker rows. */
|
/* Project picker rows (#59). Worktree rows are indented under their base
|
||||||
|
repository; rows stale beyond 7 days are dimmed but still selectable. */
|
||||||
.picker {
|
.picker {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1459,7 +1460,11 @@ textarea {
|
|||||||
scrollbar-color: var(--border-strong) transparent;
|
scrollbar-color: var(--border-strong) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recent-row {
|
.project-filter {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -1477,18 +1482,40 @@ textarea {
|
|||||||
background var(--dur-fast) var(--ease-out);
|
background var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
.recent-row:hover {
|
.project-row:hover {
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.recent-row.is-active {
|
.project-row.is-active {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: var(--accent-tint);
|
background: var(--accent-tint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-row.is-worktree {
|
||||||
|
margin-left: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-row.is-stale {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-name {
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.recent-dir {
|
.recent-dir {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--fs-14);
|
font-size: var(--fs-12);
|
||||||
|
color: var(--muted);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -1501,12 +1528,18 @@ textarea {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.recent-other .recent-dir {
|
.project-other .recent-dir {
|
||||||
color: var(--muted);
|
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.picker-empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px var(--sp-3);
|
||||||
|
color: var(--faint);
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
}
|
||||||
|
|
||||||
.picker-loading {
|
.picker-loading {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 6px var(--sp-3);
|
padding: 6px var(--sp-3);
|
||||||
|
|||||||
@@ -83,6 +83,27 @@ export interface GitRoot {
|
|||||||
root?: string;
|
root?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One discovered git repository (or linked worktree) for `GET /api/projects`
|
||||||
|
* (#54). Mirrors `maestro_protocol::Project`. */
|
||||||
|
export interface Project {
|
||||||
|
/** Absolute path of the repository/worktree toplevel. */
|
||||||
|
path: string;
|
||||||
|
/** Basename of `path`. */
|
||||||
|
name: string;
|
||||||
|
is_worktree?: boolean;
|
||||||
|
/** The main checkout this worktree belongs to; absent on plain repos. */
|
||||||
|
base_repo?: string;
|
||||||
|
/** Later of the repo's git HEAD mtime and the newest spawn-history use of
|
||||||
|
* any directory inside it; absent when neither is known. */
|
||||||
|
last_activity?: IsoTimestamp;
|
||||||
|
/** Command last spawned in this project, from spawn history. */
|
||||||
|
last_command?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /api/projects` — every discovered project, sorted by the daemon
|
||||||
|
* newest-activity-first (#54). */
|
||||||
|
export type Projects = Project[];
|
||||||
|
|
||||||
export interface WorktreeSpec {
|
export interface WorktreeSpec {
|
||||||
base_repo: string;
|
base_repo: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user