Compare commits

..
15 changed files with 1058 additions and 7 deletions
+11 -1
View File
@@ -11,7 +11,9 @@ use std::sync::Arc;
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{CatalogEvent, CreateSessionRequest, DaemonClient, Session};
use maestro_protocol::{
BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, Project, Session,
};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::Message as WsMessage;
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
@@ -62,6 +64,14 @@ impl TestClient {
Ok(self.daemon.kill_session(id).await?)
}
pub async fn list_projects(&self) -> Result<Vec<Project>, ClientError> {
Ok(self.daemon.list_projects().await?)
}
pub async fn list_branches(&self, path: &str) -> Result<Vec<BranchInfo>, ClientError> {
Ok(self.daemon.list_branches(path).await?)
}
/// Connect to the daemon's per-session WS and return the stream. The
/// caller drives the conversation.
pub async fn attach(
+21
View File
@@ -29,11 +29,32 @@ impl Daemon {
Self::spawn_at(SocketAddr::from((Ipv4Addr::LOCALHOST, free_port()))).await
}
/// Spawn with a config file pre-seeded at the daemon's expected path
/// (`$XDG_CONFIG_HOME/maestro/config.toml`).
pub async fn spawn_with_config(toml: &str) -> Self {
Self::spawn_config_at(
SocketAddr::from((Ipv4Addr::LOCALHOST, free_port())),
Some(toml),
)
.await
}
/// Spawn on a caller-supplied bind address.
pub async fn spawn_at(bind: SocketAddr) -> Self {
Self::spawn_config_at(bind, None).await
}
/// Spawn on a caller-supplied bind address, with an optional config file.
async fn spawn_config_at(bind: SocketAddr, config: impl Into<Option<&str>>) -> Self {
let home = TempDir::new().expect("home tmpdir");
let tmux = TempDir::new().expect("tmux tmpdir");
if let Some(toml) = config.into() {
let dir = home.path().join(".config").join("maestro");
std::fs::create_dir_all(&dir).expect("config dir");
std::fs::write(dir.join("config.toml"), toml).expect("write config.toml");
}
let child = launch(bind, home.path(), tmux.path());
let me = Self {
bind_addr: bind,
+1 -1
View File
@@ -14,5 +14,5 @@ pub mod daemon;
pub mod util;
pub use build::workspace_target_bin;
pub use client::TestClient;
pub use client::{ClientError, TestClient};
pub use daemon::Daemon;
+308
View File
@@ -35,6 +35,22 @@ fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
}
}
/// Spawn request carrying a worktree spec against `base`.
fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest {
CreateSessionRequest {
cwd: base.to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: base.to_path_buf(),
branch: branch.into(),
cleanup_on_kill: true,
create_branch,
}),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn lists_spawned_sessions() {
let (_daemon, client) = bring_up().await;
@@ -204,6 +220,19 @@ async fn kill_spares_worktree_with_uncommitted_changes() {
wt_path.exists(),
"guarded worktree must be left on disk after kill"
);
// Left findable, not silently half-deleted: git still registers it and
// the uncommitted edit survives byte for byte (#66).
assert!(
git_worktree_list(base.path())
.iter()
.any(|w| w.contains("feature/unsaved")),
"guarded worktree must stay registered in git worktree list"
);
assert_eq!(
std::fs::read(wt_path.join("README")).unwrap(),
b"unsaved edits\n",
"uncommitted changes must survive the guarded kill"
);
}
#[tokio::test(flavor = "multi_thread")]
@@ -240,6 +269,276 @@ async fn spawn_on_existing_branch_checks_it_out() {
client.kill_session(s.id).await.expect("kill");
}
/// #66: a fresh branch spawns a worktree at `<repo>/worktrees/<branch>` and
/// the session actually runs inside it — `current_cwd` comes from tmux's live
/// pane state, not from the spawn request.
#[tokio::test(flavor = "multi_thread")]
async fn spawn_new_branch_creates_worktree_and_runs_in_it() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let wt_path = base.path().join("worktrees").join("feature-fresh");
let s = client
.spawn_session(&worktree_req(base.path(), "feature/fresh", true))
.await
.expect("spawn new-branch worktree");
assert_eq!(
s.cwd, wt_path,
"session must report the worktree as its cwd"
);
assert!(wt_path.is_dir(), "worktree directory must exist on disk");
// The activity watcher fills `current_cwd` from tmux's pane_current_path,
// so this is the daemon confirming where the process really runs.
wait_for_async(
"session's live cwd is the worktree",
Duration::from_secs(20),
|| {
let client = client.clone();
let id = s.id;
let want = wt_path.clone();
async move {
client
.list_sessions()
.await
.ok()
.and_then(|ss| ss.iter().find(|s| s.id == id).cloned())
.map(|s| s.current_cwd == Some(want.clone()))
.unwrap_or(false)
}
},
)
.await;
client.kill_session(s.id).await.expect("kill");
}
/// #66: a second spawn onto a branch that already has a worktree is rejected
/// with a structured worktree error; the first worktree stays intact.
#[tokio::test(flavor = "multi_thread")]
async fn second_spawn_on_same_branch_is_rejected() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let first = client
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
.await
.expect("first spawn");
let dup = client
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
.await;
match dup {
Err(maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. })) => {
assert_eq!(
info.code, "worktree_failed",
"expected worktree error: {info:?}"
);
}
other => panic!("expected rejection for duplicate branch, got {other:?}"),
}
// Exactly one registration, one directory, still usable.
let holders = git_worktree_list(base.path())
.iter()
.filter(|w| w.contains("feature/dup"))
.count();
assert_eq!(holders, 1, "duplicate spawn must not register another tree");
assert!(
base.path().join("worktrees").join("feature-dup").is_dir(),
"original worktree must be untouched"
);
client.kill_session(first.id).await.expect("kill");
}
/// #66/#56: when the client picks a *linked worktree* as base_repo, placement
/// resolves through `--git-common-dir` to the main checkout, so the new tree
/// lands at `<main>/worktrees/<branch>` instead of nesting inside another
/// worktree.
#[tokio::test(flavor = "multi_thread")]
async fn spawn_from_linked_worktree_places_in_main_checkout() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let linked = base.path().join("linked");
run_git(
&[
"worktree",
"add",
"-q",
"-b",
"other",
&linked.to_string_lossy(),
],
base.path(),
);
let s = client
.spawn_session(&worktree_req(&linked, "feature/nested", true))
.await
.expect("spawn from linked worktree");
let want = base.path().join("worktrees").join("feature-nested");
assert_eq!(
s.cwd, want,
"worktree must be placed under the main checkout"
);
assert!(want.is_dir());
assert!(
!s.cwd.starts_with(&linked),
"worktrees must never nest inside another worktree"
);
client.kill_session(s.id).await.expect("kill");
}
// ---------------------------------------------------------------------------
// GET /api/projects + /api/branches (#54/#55/#66)
// ---------------------------------------------------------------------------
/// #66: two repos under a configured `[spawn].project_roots` root are both
/// discovered by `GET /api/projects`; git activity gives every repo a
/// `last_activity`, and spawning inside one merges its command into the
/// project entry via spawn history.
#[tokio::test(flavor = "multi_thread")]
async fn projects_endpoint_discovers_roots_and_merges_activity() {
let root = TempDir::new().unwrap();
let alpha = make_repo(root.path(), "alpha");
make_repo(root.path(), "beta");
let daemon = Daemon::spawn_with_config(&format!(
"[spawn]\nproject_roots = [\"{}\"]\nproject_scan_depth = 2\n",
root.path().display()
))
.await;
let client = TestClient::new(&daemon.http_base());
let projects = client.list_projects().await.expect("list projects");
let names: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect();
assert!(
names.contains(&"alpha") && names.contains(&"beta"),
"both repos must be discovered, got: {names:?}"
);
let find = |name: &str| {
projects
.iter()
.find(|p| p.name == name)
.cloned()
.expect("project present")
};
// Both have commits, so git activity alone populates last_activity…
assert!(
find("alpha").last_activity.is_some(),
"git activity must surface"
);
assert!(
find("beta").last_activity.is_some(),
"git activity must surface"
);
// …and neither has been spawned into yet.
assert_eq!(find("alpha").last_command, None);
assert_eq!(find("beta").last_command, None);
// Spawning inside alpha writes durable history; /api/projects folds it in.
let s = client
.spawn_session(&create_req(&alpha, "sleep 30"))
.await
.expect("spawn in alpha");
wait_for_async(
"alpha's project entry reflects spawn history",
Duration::from_secs(5),
|| {
let client = client.clone();
async move {
client
.list_projects()
.await
.ok()
.map(|ps| {
ps.iter()
.find(|p| p.name == "alpha")
.map(|p| p.last_command.as_deref() == Some("sleep 30"))
.unwrap_or(false)
})
.unwrap_or(false)
}
},
)
.await;
// beta was never spawned into: no command recorded there.
let beta_after = client
.list_projects()
.await
.expect("list after spawn")
.into_iter()
.find(|p| p.name == "beta")
.unwrap();
assert_eq!(beta_after.last_command, None);
client.kill_session(s.id).await.expect("kill");
}
/// #66: `GET /api/branches` lists every local branch, flags the main
/// checkout's HEAD with `is_head`, and reports which linked worktree holds an
/// occupied branch.
#[tokio::test(flavor = "multi_thread")]
async fn branches_endpoint_lists_branches_and_flags_occupied() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
run_git(&["branch", "feature/taken"], base.path());
run_git(&["branch", "feature/free"], base.path());
let holder = base.path().join("holder-wt");
run_git(
&[
"worktree",
"add",
"-q",
&holder.to_string_lossy(),
"feature/taken",
],
base.path(),
);
let branches = client
.list_branches(&base.path().to_string_lossy())
.await
.expect("list branches");
let by_name = |name: &str| {
branches
.iter()
.find(|b| b.name == name)
.cloned()
.unwrap_or_else(|| panic!("branch {name} missing from {branches:?}"))
};
let main = by_name("main");
assert!(main.is_head, "main is the main checkout's HEAD");
assert_eq!(main.worktree, None);
let taken = by_name("feature/taken");
assert!(!taken.is_head);
assert_eq!(
taken.worktree.as_deref(),
Some(holder.canonicalize().unwrap().as_path()),
"occupied branch must report its holding worktree"
);
let free = by_name("feature/free");
assert!(!free.is_head);
assert_eq!(free.worktree, None, "unoccupied branch must be spawnable");
}
#[tokio::test(flavor = "multi_thread")]
async fn daemon_restart_recovers_session() {
let (mut daemon, client) = bring_up().await;
@@ -428,6 +727,15 @@ fn init_repo(dir: &std::path::Path) {
run_git(&["commit", "-q", "-m", "initial"], dir);
}
/// Real git repo at `<root>/<name>`, canonicalized (the scanner canonicalizes
/// discovered paths too, so comparisons must agree on the real path).
fn make_repo(root: &std::path::Path, name: &str) -> std::path::PathBuf {
let dir = root.join(name);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
dir.canonicalize().unwrap()
}
fn run_git(args: &[&str], cwd: &std::path::Path) {
let out = Command::new("git")
.arg("-C")
+7 -2
View File
@@ -15,8 +15,8 @@ use uuid::Uuid;
use crate::routes::{self, UrlError};
use crate::{
ApiError, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, Project,
RecentDirs, Session,
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
Project, RecentDirs, Session,
};
/// Time budget for establishing a TCP connection to the daemon.
@@ -143,6 +143,11 @@ impl DaemonClient {
self.get(routes::PROJECTS, &[]).await
}
pub async fn list_branches(&self, path: &str) -> Result<Vec<BranchInfo>, ClientError> {
self.get(routes::BRANCHES, &[(routes::PATH_QUERY, path)])
.await
}
// -- plumbing -----------------------------------------------------------
async fn get<T: DeserializeOwned>(
+14
View File
@@ -39,6 +39,9 @@ pub enum Action {
AttachHighlighted,
ToggleAttachHighlighted,
NewDraftPane,
/// Fast-forward spawn (#67): spawn immediately with every default
/// resolved; unresolvable defaults open a pre-filled draft instead.
FastForwardSpawn,
KillHighlighted,
// Tabs
@@ -79,6 +82,7 @@ impl Action {
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
"NewDraftPane" => return Ok(Action::NewDraftPane),
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
"KillHighlighted" => return Ok(Action::KillHighlighted),
"NewTab" => return Ok(Action::NewTab),
"CloseTab" => return Ok(Action::CloseTab),
@@ -197,6 +201,16 @@ mod tests {
assert_eq!(err, ActionParseError::Malformed("GotoTab(3".into()));
}
/// The fast-forward pair sits next to the draft-pane key (#67).
#[test]
fn fast_forward_spawn_round_trips() {
assert_eq!(
Action::parse("FastForwardSpawn").unwrap(),
Action::FastForwardSpawn
);
assert_eq!(Action::FastForwardSpawn.to_string(), "FastForwardSpawn");
}
#[test]
fn display_round_trips() {
for a in [
+172
View File
@@ -87,6 +87,9 @@ pub enum Effect {
},
/// Fetch presets / projects / names for a freshly opened draft (#64).
LoadDraft(usize),
/// Fast-forward spawn (#67): resolve every default from the daemon and
/// spawn with no form; unresolvable defaults open a pre-filled draft.
FastForward,
/// A reroll drained the draft's local name batch (#64).
RefillNames(usize),
/// The draft's target changed: probe `/api/gitroot` so the worktree
@@ -202,6 +205,7 @@ impl App {
Effect::Spawn(req) => self.submit_spawn(req).await,
Effect::Kill(id) => self.submit_kill(id).await,
Effect::LoadDraft(tab_idx) => self.load_draft_data(tab_idx).await,
Effect::FastForward => self.fast_forward_spawn().await,
Effect::RefillNames(tab_idx) => self.refill_names(tab_idx).await,
Effect::ProbeGitRoot { tab, seq, path } => {
self.probe_git_root(tab, seq, path).await
@@ -287,6 +291,7 @@ impl App {
self.state.open_draft_pane();
effects.push(Effect::LoadDraft(self.state.active_tab_idx));
}
Action::FastForwardSpawn => effects.push(Effect::FastForward),
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
if let Some(sid) = self.pick_sidebar_target() {
effects.push(Effect::Attach(sid));
@@ -430,6 +435,46 @@ impl App {
}
}
/// Fast-forward spawn (#67): one keystroke, every default resolved, no
/// form. The most recent project, its last command (or the first
/// non-custom preset), a freshly generated name, worktree on with a new
/// branch named after that name. Anything unresolvable — no projects
/// known yet, no command, no name — downgrades to a draft pane seeded
/// with whatever did resolve.
async fn fast_forward_spawn(&mut self) {
let (info, projects) = tokio::join!(self.daemon.daemon_info(), self.daemon.list_projects());
let names = self.fetch_names(crate::draft::NAME_BATCH_SIZE).await;
let presets = match info {
Ok(info) if !info.presets.is_empty() => info.presets,
_ => Vec::new(),
};
let discovered = projects.unwrap_or_default();
let resolved =
crate::fast_forward::resolve(&discovered, &presets, names.first().cloned(), Utc::now());
if let Some(req) = resolved.build_request() {
// Never eat a pane already showing something: the spawn takes a
// fresh tab, same as opening a draft over a session would.
if self.state.active_tab().pane.is_some() {
self.state.new_tab();
}
self.submit_spawn(req).await;
return;
}
// Unresolved defaults: a draft pre-filled with what did resolve.
self.state.open_draft_pane();
let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() else {
return;
};
if !presets.is_empty() {
draft.set_presets(presets);
}
draft.set_projects(discovered, Utc::now());
resolved.apply_to_draft(draft);
// A missing generated name still arrives here; seeding is a no-op
// once apply_to_draft has set a touched name.
draft.seed_names(names);
}
/// Fetch a fresh name batch after a reroll drained the local queue.
async fn refill_names(&mut self, tab_idx: usize) {
let names = self.fetch_names(crate::draft::NAME_BATCH_SIZE).await;
@@ -846,6 +891,133 @@ mod tests {
);
}
/// The fast-forward keystroke itself is side-effect-free until its
/// daemon fetches come back.
#[test]
fn fast_forward_spawn_dispatches_the_fast_forward_effect() {
let mut app = make_app();
assert_eq!(
app.dispatch(Action::FastForwardSpawn),
vec![Effect::FastForward]
);
}
/// Every default resolves: the POST goes straight out — most recent
/// project, its last command, generated name, worktree branch named
/// after it (#67).
#[tokio::test]
async fn fast_forward_spawns_immediately_when_every_default_resolves() {
use axum::routing::{get, post};
use axum::Json;
use serde_json::json;
use std::sync::{Arc, Mutex};
async fn info() -> Json<serde_json::Value> {
Json(json!({
"hostname": "box", "version": "0.1.0", "home_dir": "/home/u",
"presets": [{"id": "claude", "label": "Claude", "command": "claude"}]
}))
}
async fn projects() -> Json<serde_json::Value> {
Json(json!([
{"path": "/repos/old", "name": "old",
"last_activity": chrono::Utc::now().to_rfc3339(), "last_command": "zsh"},
{"path": "/repos/fresh", "name": "fresh", "base_repo": "/repos/fresh",
"last_activity": chrono::Utc::now().to_rfc3339(), "last_command": "aider"}
]))
}
async fn names() -> Json<serde_json::Value> {
Json(json!({"names": ["crimson-otter"]}))
}
let posted: Arc<Mutex<Vec<serde_json::Value>>> = Arc::default();
let seen = posted.clone();
async fn spawn_session(
axum::extract::State(seen): axum::extract::State<Arc<Mutex<Vec<serde_json::Value>>>>,
Json(body): Json<serde_json::Value>,
) -> Json<serde_json::Value> {
seen.lock().unwrap().push(body);
Json(json!({
"id": uuid::Uuid::from_u128(77).to_string(),
"cwd": "/repos/fresh", "command": "aider",
"status": "Running", "created_at": chrono::Utc::now().to_rfc3339()
}))
}
let app_router = axum::Router::new()
.route(maestro_protocol::routes::INFO, get(info))
.route(maestro_protocol::routes::PROJECTS, get(projects))
.route(maestro_protocol::routes::NAMES, get(names))
.route(
maestro_protocol::routes::SESSIONS,
post(spawn_session).with_state(seen),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() });
let mut app = make_app_at(&base);
let effects = app.dispatch(Action::FastForwardSpawn);
app.perform(effects).await;
let posts = posted.lock().unwrap();
assert_eq!(posts.len(), 1, "one immediate POST, no form shown");
let req = &posts[0];
assert_eq!(req["cwd"], json!("/repos/fresh"));
assert_eq!(req["command"], json!("aider"));
assert_eq!(req["metadata"]["name"], json!("crimson-otter"));
assert_eq!(req["worktree"]["base_repo"], json!("/repos/fresh"));
assert_eq!(req["worktree"]["branch"], json!("crimson-otter"));
assert_eq!(req["worktree"]["create_branch"], json!(true));
}
/// No projects known yet: nothing can resolve, so the keystroke opens a
/// draft pane (focused, Insert mode) instead of guessing (#67).
#[tokio::test]
async fn unresolvable_defaults_open_a_prefilled_draft_instead() {
use axum::routing::get;
use axum::Json;
use serde_json::json;
async fn info() -> Json<serde_json::Value> {
Json(json!({
"hostname": "box", "version": "0.1.0", "home_dir": "/home/u",
"presets": [{"id": "claude", "label": "Claude", "command": "claude"}]
}))
}
async fn projects() -> Json<serde_json::Value> {
Json(json!([]))
}
async fn names() -> Json<serde_json::Value> {
Json(json!({"names": ["amber-falcon"]}))
}
let app_router = axum::Router::new()
.route(maestro_protocol::routes::INFO, get(info))
.route(maestro_protocol::routes::PROJECTS, get(projects))
.route(maestro_protocol::routes::NAMES, get(names));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() });
let mut app = make_app_at(&base);
let effects = app.dispatch(Action::FastForwardSpawn);
app.perform(effects).await;
assert!(
matches!(app.state.active_tab().pane, Some(PaneRef::Draft(_))),
"the fallback is a draft pane"
);
assert_eq!(app.state.focus, Focus::Pane);
assert_eq!(app.state.mode, Mode::Insert);
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
panic!("expected a draft pane");
};
// Whatever did resolve (nothing project-side) is seeded; the
// generated name still lands so the form is ready to go.
assert_eq!(draft.name.value(), "amber-falcon");
assert_eq!(draft.command(), "claude");
}
/// A valid form flows through: pick a project (set directly — the picker
/// itself is covered in `draft.rs`), select the custom tile, type a
/// command, then submit from the Spawn row.
+7
View File
@@ -46,6 +46,9 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
// Catalog actions
("enter", "AttachHighlighted"),
("s", "NewDraftPane"),
// Fast-forward spawn (#67): the draft-pane key's neighbour does the same
// thing with every default resolved and no form.
("S", "FastForwardSpawn"),
("x", "KillHighlighted"),
// Tabs
("ctrl-t", "NewTab"),
@@ -159,6 +162,10 @@ mod tests {
cfg.keymap.resolve(Mode::Normal, "s"),
Some(Action::NewDraftPane),
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "S"),
Some(Action::FastForwardSpawn),
);
}
#[test]
+283
View File
@@ -0,0 +1,283 @@
//! Fast-forward spawn (#67): one keystroke, every default resolved, no form.
//!
//! The defaults mirror what a draft pane would settle on after the operator
//! picked the most recent project and kept their hands off everything else:
//! its last command (or the first non-custom preset), a freshly generated
//! name, and a worktree with a branch named after that name. Any default
//! that cannot be resolved — no projects yet, no command, no generated
//! name — downgrades the keystroke to a draft pane pre-filled with whatever
//! did resolve ([`FastForwardDefaults::apply_to_draft`]).
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use maestro_protocol::{CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
use crate::draft::DraftPane;
/// What one fast-forward keystroke resolved. Every field optional: `None`
/// anywhere means "open a draft instead of spawning".
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FastForwardDefaults {
pub project: Option<Project>,
pub command: Option<String>,
pub name: Option<String>,
}
/// Resolve the fast-forward defaults from the daemon's answers. `name` is
/// `None` when `/api/names` yielded nothing; `presets` may be empty (the
/// caller falls back to the built-ins it already has).
pub fn resolve(
projects: &[Project],
presets: &[SpawnPreset],
name: Option<String>,
now: DateTime<Utc>,
) -> FastForwardDefaults {
let project = crate::projects::sort_projects(projects.to_vec(), now)
.into_iter()
.next();
let command = project
.as_ref()
.and_then(|p| p.last_command.clone())
.filter(|c| !c.trim().is_empty())
.or_else(|| {
presets
.iter()
.find(|p| !p.command.is_empty())
.map(|p| p.command.clone())
});
FastForwardDefaults {
project,
command,
name,
}
}
impl FastForwardDefaults {
/// The spawn request when *every* default resolved: worktree on, new
/// branch named after the generated session name, base repo from the
/// scan (`None` means the project is itself a main checkout).
pub fn build_request(&self) -> Option<CreateSessionRequest> {
let project = self.project.as_ref()?;
let command = self.command.as_deref()?.trim();
let name = self.name.as_deref()?;
if command.is_empty() || name.is_empty() {
return None;
}
let mut metadata = std::collections::HashMap::new();
metadata.insert(
"name".to_string(),
serde_json::Value::String(name.to_string()),
);
Some(CreateSessionRequest {
cwd: project.path.clone(),
command: command.to_string(),
env: Default::default(),
metadata,
worktree: Some(WorktreeSpec {
base_repo: project
.base_repo
.clone()
.unwrap_or_else(|| project.path.clone()),
branch: name.to_string(),
cleanup_on_kill: true,
create_branch: true,
}),
})
}
/// Seed a fresh draft pane with whatever did resolve — the fallback when
/// [`Self::build_request`] is `None`. The operator fills in the rest.
pub fn apply_to_draft(&self, draft: &mut DraftPane) {
if let Some(project) = &self.project {
draft.picked_cwd = Some(PathBuf::from(&project.path));
// The scan already knows this directory's repo (#61): no probe
// round trip needed, exactly like picking the row by hand.
draft.base_repo = Some(
project
.base_repo
.clone()
.unwrap_or_else(|| project.path.clone()),
);
draft.probed_cwd = Some(PathBuf::from(&project.path));
}
if let Some(command) = &self.command {
if let Some(idx) = draft.presets.iter().position(|p| &p.command == command) {
draft.selected_preset = idx;
} else if !command.trim().is_empty() {
// A last_command no preset advertises lands in custom free text.
draft.selected_preset = draft.presets.len().saturating_sub(1);
draft.custom_command = crate::text_input::TextInput::with_value(command);
}
}
if let Some(name) = &self.name {
draft.set_name(name.clone());
// Seeded, not generated: a later refill must not clobber it.
draft.name_touched = true;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeDelta;
fn project(path: &str, days_ago: i64, cmd: Option<&str>) -> Project {
Project {
path: PathBuf::from(path),
name: path.rsplit('/').next().unwrap_or(path).into(),
is_worktree: false,
base_repo: None,
last_activity: Some(Utc::now() - TimeDelta::days(days_ago)),
last_command: cmd.map(String::from),
}
}
fn repo_project(path: &str, base: &str, days_ago: i64, cmd: Option<&str>) -> Project {
Project {
base_repo: Some(PathBuf::from(base)),
..project(path, days_ago, cmd)
}
}
fn preset(id: &str, command: &str) -> SpawnPreset {
SpawnPreset {
id: id.into(),
label: id.into(),
command: command.into(),
}
}
#[test]
fn resolves_every_default_from_the_most_recent_project() {
let projects = vec![
project("/repos/old", 30, Some("zsh")),
repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")),
];
let presets = vec![preset("aider", "aider"), preset("custom", "")];
let r = resolve(&projects, &presets, Some("amber-falcon".into()), Utc::now());
assert_eq!(
r.project.as_ref().unwrap().path,
PathBuf::from("/repos/fresh")
);
assert_eq!(r.command.as_deref(), Some("claude"));
assert_eq!(r.name.as_deref(), Some("amber-falcon"));
let req = r.build_request().expect("all defaults resolved");
assert_eq!(req.cwd, PathBuf::from("/repos/fresh"));
assert_eq!(req.command, "claude");
assert_eq!(
req.metadata.get("name").and_then(|v| v.as_str()),
Some("amber-falcon")
);
let wt = req.worktree.expect("worktree on");
assert_eq!(wt.base_repo, PathBuf::from("/repos/fresh"));
assert_eq!(wt.branch, "amber-falcon");
assert!(wt.create_branch, "fresh branch named after the session");
}
#[test]
fn worktree_of_the_most_recent_project_targets_its_base_repo() {
let projects = vec![repo_project("/repos/main/wt-x", "/repos/main", 0, None)];
let r = resolve(
&projects,
&[preset("zsh", "zsh")],
Some("s".into()),
Utc::now(),
);
let wt = r.build_request().unwrap().worktree.unwrap();
assert_eq!(wt.base_repo, PathBuf::from("/repos/main"));
}
#[test]
fn missing_last_command_falls_back_to_the_first_non_custom_preset() {
let projects = vec![project("/repos/p", 0, None)];
let presets = vec![preset("custom", ""), preset("claude", "claude")];
let r = resolve(&projects, &presets, Some("n".into()), Utc::now());
assert_eq!(r.command.as_deref(), Some("claude"));
}
#[test]
fn an_empty_last_command_is_no_command_at_all() {
let projects = vec![project("/repos/p", 0, Some(""))];
let presets = vec![preset("custom", "")];
let r = resolve(&projects, &presets, Some("n".into()), Utc::now());
assert_eq!(r.command, None);
assert!(r.build_request().is_none());
}
#[test]
fn nothing_resolves_when_no_projects_are_known_yet() {
let r = resolve(
&[],
&[preset("claude", "claude")],
Some("n".into()),
Utc::now(),
);
assert_eq!(r.project, None);
// No project → no directory → no request, even with command + name.
assert!(r.build_request().is_none());
}
#[test]
fn a_missing_generated_name_downgrades_to_a_draft() {
let projects = vec![project("/repos/p", 0, Some("claude"))];
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
assert!(r.build_request().is_none(), "no name → no immediate spawn");
}
#[test]
fn unresolvable_defaults_still_seed_the_draft_with_what_resolved() {
let projects = vec![repo_project("/repos/p", "/repos/p", 0, Some("aider"))];
let r = resolve(&projects, &[preset("aider", "aider")], None, Utc::now());
let mut draft = DraftPane::default();
r.apply_to_draft(&mut draft);
assert_eq!(draft.cwd(), Some(PathBuf::from("/repos/p")));
assert_eq!(draft.base_repo, Some(PathBuf::from("/repos/p")));
assert_eq!(draft.command(), "aider");
assert!(
draft.name.value().is_empty(),
"nothing to seed the name with"
);
assert!(draft.worktree_enabled);
}
#[test]
fn a_resolved_name_survives_late_refills_and_tracks_the_branch() {
let projects = vec![project("/repos/p", 0, Some("claude"))];
let named = resolve(
&projects,
&[preset("claude", "claude")],
Some("amber-falcon".into()),
Utc::now(),
);
let mut draft = DraftPane::default();
named.apply_to_draft(&mut draft);
// A later batch must not overwrite the name the spawn was seeded with.
draft.seed_names(vec!["late-name".into()]);
assert_eq!(draft.name.value(), "amber-falcon");
assert_eq!(draft.branch.value(), "amber-falcon");
}
#[test]
fn an_unresolved_name_still_gets_generated_by_a_later_batch() {
let projects = vec![project("/repos/p", 0, Some("claude"))];
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
let mut draft = DraftPane::default();
r.apply_to_draft(&mut draft);
draft.seed_names(vec!["late-name".into()]);
assert_eq!(draft.name.value(), "late-name");
}
#[test]
fn a_last_command_no_preset_advertises_lands_in_custom_text() {
let projects = vec![project("/repos/p", 0, Some("nvim -d"))];
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
let mut draft = DraftPane::default();
r.apply_to_draft(&mut draft);
assert_eq!(draft.command(), "nvim -d");
assert!(draft.is_custom());
}
}
+1
View File
@@ -9,6 +9,7 @@ pub mod cli;
pub mod config;
pub mod draft;
pub mod emulators;
pub mod fast_forward;
pub mod input;
pub mod keymap;
pub mod overlays;
+2
View File
@@ -196,6 +196,8 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
spawnBtn.type = "button";
spawnBtn.className = "btn btn-primary spawn-btn";
spawnBtn.textContent = "+ Spawn";
// The keyboard pair sits right here in the tooltip (#67).
spawnBtn.title = "Draft pane: n — fast-forward spawn: N";
spawnBtn.addEventListener("click", () => requestOpenSpawn());
actions.appendChild(spawnBtn);
return header;
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import {
buildFastForwardRequest,
fastForwardSeed,
resolveFastForward,
} from "./fastForward.js";
import type { Project, SpawnPreset } from "./types.js";
const presets: SpawnPreset[] = [
{ id: "custom", label: "Custom…", command: "" },
{ id: "claude", label: "Claude", command: "claude" },
];
function project(overrides: Partial<Project>): Project {
return {
path: "/repos/p",
name: "p",
last_activity: new Date().toISOString(),
...overrides,
};
}
describe("resolveFastForward (#67)", () => {
it("resolves every default from the most recent project", () => {
const projects = [
project({ path: "/repos/old", name: "old", last_activity: "2020-01-01T00:00:00Z" }),
project({
path: "/repos/fresh",
name: "fresh",
base_repo: "/repos/fresh",
last_command: "aider",
}),
];
const res = resolveFastForward(projects, presets, "amber-falcon");
expect(res.project?.path).toBe("/repos/fresh");
expect(res.command).toBe("aider");
expect(res.name).toBe("amber-falcon");
const req = buildFastForwardRequest(res)!;
expect(req.cwd).toBe("/repos/fresh");
expect(req.command).toBe("aider");
expect(req.metadata).toEqual({ name: "amber-falcon" });
expect(req.worktree).toEqual({
base_repo: "/repos/fresh",
branch: "amber-falcon",
cleanup_on_kill: true,
create_branch: true,
});
});
it("falls back to the first non-custom preset when there is no last command", () => {
const res = resolveFastForward([project({ last_command: undefined })], presets, "n");
expect(res.command).toBe("claude");
});
it("treats an empty last command as no command", () => {
const res = resolveFastForward([project({ last_command: "" })], [presets[0]], "n");
expect(res.command).toBeUndefined();
expect(buildFastForwardRequest(res)).toBeNull();
});
it("resolves nothing when no projects are known yet", () => {
const res = resolveFastForward([], presets, "n");
expect(res.project).toBeUndefined();
expect(buildFastForwardRequest(res)).toBeNull();
});
it("downgrades to a draft when the generated name is missing", () => {
const res = resolveFastForward([project({ last_command: "claude" })], presets);
expect(res.name).toBeUndefined();
expect(buildFastForwardRequest(res)).toBeNull();
});
it("seeds the fallback draft with whatever did resolve", () => {
const res = resolveFastForward(
[project({ path: "/repos/p", base_repo: "/repos/p", last_command: "aider" })],
presets,
);
expect(fastForwardSeed(res)).toEqual({
cwd: "/repos/p",
command: "aider",
name: "",
worktreeEnabled: true,
worktreeBranch: undefined,
});
});
it("seeds worktree off when not even a project resolved", () => {
const seed = fastForwardSeed(resolveFastForward([], presets));
expect(seed.worktreeEnabled).toBe(false);
expect(seed.cwd).toBe("");
});
});
+62
View File
@@ -0,0 +1,62 @@
// Fast-forward spawn (#67): one keystroke, every default resolved, no form.
// Pure resolution only — the caller (main.ts) owns the fetches and the POST,
// and falls back to a seeded draft pane when anything is missing.
import type { CreateSessionRequest, Project, SpawnPreset } from "./types.js";
import { sortProjects } from "./projectList.js";
/** What one fast-forward keystroke resolved. Anything absent means "open a
* draft pre-filled with the rest instead of spawning". */
export interface FastForwardResolution {
project?: Project;
command?: string;
name?: string;
}
/** The defaults: most recent project (fresh-first, same ordering the picker
* shows), its last command falling back to the first non-custom preset, and
* a freshly generated name when the daemon supplied one. */
export function resolveFastForward(
projects: Project[],
presets: SpawnPreset[],
name?: string,
): FastForwardResolution {
const project = sortProjects(projects, new Date())[0];
const lastCommand = project?.last_command?.trim();
const command =
lastCommand || presets.find((p) => p.command !== "")?.command || undefined;
return { project, command, name: name || undefined };
}
/** The immediate spawn request — worktree on, new branch named after the
* generated session name — or `null` when any default is missing. */
export function buildFastForwardRequest(
res: FastForwardResolution,
): CreateSessionRequest | null {
const { project, command, name } = res;
if (!project || !command?.trim() || !name) return null;
return {
cwd: project.path,
command,
metadata: { name },
worktree: {
base_repo: project.base_repo ?? project.path,
branch: name,
cleanup_on_kill: true,
create_branch: true,
},
};
}
/** Seed for the fallback draft pane: whatever did resolve goes into the
* form; the rest stays with its own defaults. */
export function fastForwardSeed(res: FastForwardResolution) {
return {
cwd: res.project?.path ?? "",
command: res.command ?? "",
name: res.name ?? "",
worktreeEnabled: Boolean(res.project),
worktreeBranch:
res.project && res.name ? res.name : undefined,
};
}
+73 -1
View File
@@ -3,13 +3,25 @@ import { ConnectionPool } from "./connections.js";
import { installHudKeybind } from "./debug.js";
import { CatalogStore } from "./events.js";
import { shouldRetryClose } from "./reconnect.js";
import {
createSession,
fetchNames,
getDaemonInfo,
projects as fetchProjects,
} from "./api.js";
import {
buildFastForwardRequest,
fastForwardSeed,
resolveFastForward,
} from "./fastForward.js";
import { FALLBACK_PRESETS } from "./spawnForm.js";
import { mountHud, type Hud } from "./hud.js";
import { emptyLayout, type Layout, openSessions } from "./layout.js";
import { createMultiView, type MultiView } from "./multiview.js";
import { loadLayout, saveLayout } from "./persistence.js";
import { OPEN_SPAWN_EVENT } from "./catalog.js";
import { initTheme } from "./theme.js";
import { sessionName, type Session } from "./types.js";
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
import { installViewportSizing } from "./viewport.js";
import "./styles.css";
@@ -212,6 +224,7 @@ function renderWelcome(): HTMLElement {
spawn.type = "button";
spawn.className = "btn btn-primary";
spawn.textContent = "+ Spawn session";
spawn.title = "Draft pane: n — fast-forward spawn: N";
spawn.addEventListener("click", () => requestOpenSpawn());
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
return el;
@@ -265,6 +278,65 @@ document.addEventListener("catalog-spawn-similar", (ev) => {
mv.openDraft({ cwd: s.cwd, command: s.command });
});
// Fast-forward spawn (#67): the draft pane's keystroke neighbour spawns
// immediately with every default resolved — most recent project, its last
// command (or the first non-custom preset), a fresh name, worktree on with
// a new branch named after that name. Anything unresolvable (no projects
// known yet, no command, no generated name) opens a draft pre-filled with
// what did resolve instead.
async function requestFastForward(): Promise<void> {
showMultiView();
const [discovered, name] = await Promise.all([
fetchProjects().catch(() => [] as Project[]),
fetchNames(1)
.then((batch) => batch.names[0])
.catch(() => undefined),
]);
let presets: SpawnPreset[] = FALLBACK_PRESETS;
try {
const info = await getDaemonInfo();
if (info.presets?.length) presets = info.presets;
} catch {
/* keep fallbacks */
}
const resolved = resolveFastForward(discovered, presets, name);
const req = buildFastForwardRequest(resolved);
if (req) {
try {
await createSession(req);
return;
} catch {
// A failed POST degrades to the same pre-filled draft as an
// unresolvable default — never a silent dead keystroke.
}
}
mv.openDraft(fastForwardSeed(resolved));
}
// Keyboard pair (#67): `n` opens a draft pane, `N` fast-forwards a spawn.
// Ignored while typing anywhere (form fields, the terminal's hidden
// textarea, search) so an attached session's keys are never hijacked.
document.addEventListener("keydown", (e) => {
if (e.ctrlKey || e.altKey || e.metaKey) return;
const target = e.target as HTMLElement | null;
if (
target &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable)
) {
return;
}
if (e.key === "n") {
e.preventDefault();
requestOpenSpawn();
} else if (e.key === "N") {
e.preventDefault();
void requestFastForward();
}
});
type Route =
| { kind: "catalog" }
| { kind: "attach"; sessionId: string }
+3 -2
View File
@@ -83,8 +83,9 @@ export function buildSpawnRequest(
}
/** Built-ins used when the daemon's `/api/info` presets can't be fetched
* (older daemon, transient error). Mirrors `maestro_config::builtin_presets`. */
const FALLBACK_PRESETS: SpawnPreset[] = [
* (older daemon, transient error). Mirrors `maestro_config::builtin_presets`.
* Exported for the fast-forward spawn's preset fallback (#67). */
export const FALLBACK_PRESETS: SpawnPreset[] = [
{ id: "claude", label: "Claude", command: "claude" },
{ id: "opencode", label: "opencode", command: "opencode" },
{ id: "zsh", label: "Shell", command: "zsh" },