Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74967e45b4 | ||
|
|
7a2cc30bf9 | ||
|
|
8ed44e785d | ||
|
|
63da845614 | ||
|
|
d336bd802d | ||
|
|
67d17f48d0 | ||
|
|
ec45312400 | ||
|
|
525dc9a04a | ||
|
|
264332266c | ||
|
|
37338def71 |
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -12,12 +12,13 @@
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::Stdout;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use crossterm::event::{Event as CtEvent, EventStream};
|
||||
use futures_util::StreamExt;
|
||||
use maestro_protocol::{routes, CreateSessionRequest, DaemonClient, NameBatch};
|
||||
use maestro_protocol::{routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Terminal;
|
||||
use uuid::Uuid;
|
||||
@@ -88,6 +89,15 @@ pub enum Effect {
|
||||
LoadDraft(usize),
|
||||
/// A reroll drained the draft's local name batch (#64).
|
||||
RefillNames(usize),
|
||||
/// The draft's target changed: probe `/api/gitroot` so the worktree
|
||||
/// section knows its base repo (#65). `seq` guards stale answers.
|
||||
ProbeGitRoot {
|
||||
tab: usize,
|
||||
seq: u32,
|
||||
path: PathBuf,
|
||||
},
|
||||
/// Fetch the draft target's branches from `/api/branches` (#65).
|
||||
LoadBranches(usize),
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -193,6 +203,10 @@ impl App {
|
||||
Effect::Kill(id) => self.submit_kill(id).await,
|
||||
Effect::LoadDraft(tab_idx) => self.load_draft_data(tab_idx).await,
|
||||
Effect::RefillNames(tab_idx) => self.refill_names(tab_idx).await,
|
||||
Effect::ProbeGitRoot { tab, seq, path } => {
|
||||
self.probe_git_root(tab, seq, path).await
|
||||
}
|
||||
Effect::LoadBranches(tab) => self.load_branches(tab).await,
|
||||
Effect::Forward { id, bytes } => {
|
||||
if let Some(handle) = self.emulators.get(id) {
|
||||
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
||||
@@ -349,6 +363,16 @@ impl App {
|
||||
if draft.take_name_refill() {
|
||||
effects.push(Effect::RefillNames(tab_idx));
|
||||
}
|
||||
if let Some((seq, path)) = draft.take_gitroot_probe() {
|
||||
effects.push(Effect::ProbeGitRoot {
|
||||
tab: tab_idx,
|
||||
seq,
|
||||
path,
|
||||
});
|
||||
}
|
||||
if draft.take_branch_load().is_some() {
|
||||
effects.push(Effect::LoadBranches(tab_idx));
|
||||
}
|
||||
effects
|
||||
}
|
||||
|
||||
@@ -422,6 +446,72 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the draft target's enclosing repository via `/api/gitroot`
|
||||
/// (#65). A miss unticks the worktree toggle inside `resolve_gitroot`;
|
||||
/// a hit may schedule a branch load (Existing mode), served inline.
|
||||
async fn probe_git_root(&mut self, tab_idx: usize, seq: u32, path: PathBuf) {
|
||||
let root = self
|
||||
.daemon
|
||||
.git_root(&path.to_string_lossy())
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|g| g.root);
|
||||
let Some(PaneRef::Draft(draft)) = self
|
||||
.state
|
||||
.tabs
|
||||
.get_mut(tab_idx)
|
||||
.and_then(|t| t.pane.as_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
draft.resolve_gitroot(seq, root);
|
||||
if draft.take_branch_load().is_some() {
|
||||
self.load_branches(tab_idx).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// One `/api/branches` batch for the draft's current cwd (#65). Same
|
||||
/// reqwest route as [`Self::fetch_names`]: no typed client call exists.
|
||||
async fn load_branches(&mut self, tab_idx: usize) {
|
||||
let Some(path) = self
|
||||
.state
|
||||
.tabs
|
||||
.get(tab_idx)
|
||||
.and_then(|t| t.pane.as_ref())
|
||||
.and_then(|p| match p {
|
||||
PaneRef::Draft(d) => d.cwd(),
|
||||
_ => None,
|
||||
})
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let url = format!(
|
||||
"{}{}",
|
||||
maestro_protocol::routes::normalize_base(self.daemon.base()),
|
||||
routes::BRANCHES
|
||||
);
|
||||
let resp = self
|
||||
.http
|
||||
.get(url)
|
||||
.query(&[(routes::PATH_QUERY, path.to_string_lossy().as_ref())])
|
||||
.send()
|
||||
.await;
|
||||
// On failure the picker stays empty; "new" mode still works.
|
||||
let branches = match resp {
|
||||
Ok(resp) => resp.json::<Vec<BranchInfo>>().await.unwrap_or_default(),
|
||||
Err(_) => return,
|
||||
};
|
||||
let Some(PaneRef::Draft(draft)) = self
|
||||
.state
|
||||
.tabs
|
||||
.get_mut(tab_idx)
|
||||
.and_then(|t| t.pane.as_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
draft.set_branches(&path, branches);
|
||||
}
|
||||
|
||||
async fn submit_spawn(&mut self, req: CreateSessionRequest) {
|
||||
match self.daemon.create_session(&req).await {
|
||||
Ok(session) => {
|
||||
@@ -613,6 +703,7 @@ fn render_help_lines(cfg: &TuiConfig) -> Vec<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::action::Action;
|
||||
use crate::draft::DraftField;
|
||||
use crate::keymap::Mode;
|
||||
use maestro_config::TuiSection;
|
||||
|
||||
@@ -767,6 +858,8 @@ mod tests {
|
||||
};
|
||||
|
||||
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work"));
|
||||
draft.base_repo = Some(std::path::PathBuf::from("/srv/work"));
|
||||
draft.probed_cwd = Some(std::path::PathBuf::from("/srv/work"));
|
||||
draft.selected_preset = draft.presets.len() - 1; // custom
|
||||
app.handle_draft_input(&InputKey::Tab); // preset → custom command
|
||||
for c in "claude".chars() {
|
||||
@@ -777,6 +870,9 @@ mod tests {
|
||||
for c in "crimson-otter".chars() {
|
||||
app.handle_draft_input(&InputKey::char(c));
|
||||
}
|
||||
app.handle_draft_input(&InputKey::Tab); // → worktree toggle
|
||||
app.handle_draft_input(&InputKey::Tab); // → branch mode
|
||||
app.handle_draft_input(&InputKey::Tab); // → new-branch name
|
||||
app.handle_draft_input(&InputKey::Tab); // → submit
|
||||
|
||||
match app.handle_draft_input(&InputKey::Enter).as_slice() {
|
||||
@@ -793,6 +889,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Editing the raw path with the worktree toggle on schedules a
|
||||
/// `/api/gitroot` probe so the section learns its base repo (#65).
|
||||
#[test]
|
||||
fn typing_a_raw_path_schedules_a_gitroot_probe() {
|
||||
let mut app = make_app();
|
||||
app.dispatch(Action::NewDraftPane);
|
||||
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
|
||||
panic!("expected a draft pane");
|
||||
};
|
||||
draft.cwd_raw_visible = true;
|
||||
draft.focused_field = DraftField::CwdRaw;
|
||||
|
||||
let effects = app.handle_draft_input(&InputKey::char('/'));
|
||||
assert!(
|
||||
matches!(
|
||||
effects.as_slice(),
|
||||
[Effect::ProbeGitRoot { tab: 0, seq: 2, .. }]
|
||||
),
|
||||
"{effects:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Switching the worktree branch source to Existing schedules one
|
||||
/// `/api/branches` load for the current target (#65).
|
||||
#[test]
|
||||
fn switching_to_existing_branch_mode_schedules_a_branch_load() {
|
||||
let mut app = make_app();
|
||||
app.dispatch(Action::NewDraftPane);
|
||||
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
|
||||
panic!("expected a draft pane");
|
||||
};
|
||||
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/repo"));
|
||||
draft.base_repo = Some(std::path::PathBuf::from("/srv/repo"));
|
||||
draft.probed_cwd = Some(std::path::PathBuf::from("/srv/repo"));
|
||||
draft.focused_field = DraftField::BranchMode;
|
||||
|
||||
let effects = app.handle_draft_input(&InputKey::Right);
|
||||
assert!(
|
||||
matches!(effects.as_slice(), [Effect::LoadBranches(0)]),
|
||||
"{effects:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Submitting with no directory names the missing field inside the form
|
||||
/// and posts nothing (#64).
|
||||
#[test]
|
||||
@@ -800,7 +939,7 @@ mod tests {
|
||||
let mut app = make_app();
|
||||
app.dispatch(Action::NewDraftPane);
|
||||
// Walk to Submit without picking anything.
|
||||
for _ in 0..3 {
|
||||
for _ in 0..4 {
|
||||
app.handle_draft_input(&InputKey::Tab);
|
||||
}
|
||||
assert!(app.handle_draft_input(&InputKey::Enter).is_empty());
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -122,6 +122,31 @@ describe("multiview openDraft (#62)", () => {
|
||||
expect(tab.focusedLeafId).toBe(empty);
|
||||
});
|
||||
|
||||
it("keeps the draft form instance when an unrelated session closes (#68)", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
const s1 = splitLeaf(tab, "", "s1", "row", true);
|
||||
const draftLeaf = splitLeaf(tab, s1, null, "row", true);
|
||||
tab.focusedLeafId = draftLeaf;
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
await flush();
|
||||
|
||||
const nameInput = mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement;
|
||||
nameInput.value = "wip";
|
||||
nameInput.dispatchEvent(new Event("input"));
|
||||
await flush();
|
||||
expect(leaves(tab.root).find((l) => l.id === draftLeaf)?.draft?.name).toBe("wip");
|
||||
|
||||
const formBefore = mv.el.querySelector(".mv-draft");
|
||||
mv.closeSession("s1");
|
||||
await flush();
|
||||
|
||||
expect(mv.el.querySelector(".mv-draft")).toBe(formBefore);
|
||||
expect((mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement).value).toBe("wip");
|
||||
});
|
||||
|
||||
it("seeds a clone's cwd and command into the draft", async () => {
|
||||
const layout = emptyLayout();
|
||||
const mv = makeMv(layout);
|
||||
|
||||
+55
-29
@@ -70,9 +70,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts;
|
||||
const persist = () => opts.onLayoutChange?.();
|
||||
|
||||
// Draft-pane forms (#57) carry global listeners (dir autocomplete), so each
|
||||
// render tears down the previous generation before rebuilding the canvas.
|
||||
const draftForms = new Set<SpawnFormHandle>();
|
||||
// Draft-pane forms (#57) carry global listeners (dir autocomplete), so forms
|
||||
// whose leaves are gone are destroyed on each render. Forms for draft leaves
|
||||
// that survive the render are reused in place (#68): rebuilding one mid-edit
|
||||
// would drop focus, caret and selection, and re-fetch info/projects/names.
|
||||
const draftForms = new Map<string, SpawnFormHandle>();
|
||||
|
||||
const el = document.createElement("div");
|
||||
el.className = "mv";
|
||||
@@ -150,30 +152,35 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
// Draft pane (#57): an empty pane is a spawn form. Values hang off the
|
||||
// leaf (never persisted) so each draft keeps its own half-filled state;
|
||||
// submitting fills this leaf with the new session in place.
|
||||
const form = createSpawnForm({
|
||||
seed: leaf.draft ?? undefined,
|
||||
class: "mv-draft",
|
||||
onChange: (values) => {
|
||||
leaf.draft = values;
|
||||
},
|
||||
onSuccess: (session) => {
|
||||
// The pane can be closed while the POST is in flight. Mounting the
|
||||
// session onto a leaf no longer in the tree would strand it in no
|
||||
// pane at all, so only claim the leaf while it is still there —
|
||||
// searching every tab, since the operator may have switched tabs
|
||||
// while the request was in flight.
|
||||
const host = layout.tabs.find((t) => leaves(t.root).includes(leaf));
|
||||
if (host) {
|
||||
leaf.sessionId = session.id;
|
||||
leaf.draft = null;
|
||||
host.focusedLeafId = leaf.id;
|
||||
}
|
||||
render();
|
||||
opts.onOpenSessionsChange?.();
|
||||
},
|
||||
});
|
||||
draftForms.add(form);
|
||||
host.appendChild(form.el);
|
||||
const form = draftForms.get(leaf.id);
|
||||
if (form) {
|
||||
host.appendChild(form.el);
|
||||
} else {
|
||||
const created = createSpawnForm({
|
||||
seed: leaf.draft ?? undefined,
|
||||
class: "mv-draft",
|
||||
onChange: (values) => {
|
||||
leaf.draft = values;
|
||||
},
|
||||
onSuccess: (session) => {
|
||||
// The pane can be closed while the POST is in flight. Mounting the
|
||||
// session onto a leaf no longer in the tree would strand it in no
|
||||
// pane at all, so only claim the leaf while it is still there —
|
||||
// searching every tab, since the operator may have switched tabs
|
||||
// while the request was in flight.
|
||||
const host = layout.tabs.find((t) => leaves(t.root).includes(leaf));
|
||||
if (host) {
|
||||
leaf.sessionId = session.id;
|
||||
leaf.draft = null;
|
||||
host.focusedLeafId = leaf.id;
|
||||
}
|
||||
render();
|
||||
opts.onOpenSessionsChange?.();
|
||||
},
|
||||
});
|
||||
draftForms.set(leaf.id, created);
|
||||
host.appendChild(created.el);
|
||||
}
|
||||
}
|
||||
|
||||
const tools = document.createElement("div");
|
||||
@@ -395,9 +402,25 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
|
||||
// ---- top-level render ----
|
||||
|
||||
/** Destroy forms whose leaf is no longer a draft (leaf removed or filled by
|
||||
* a session). Surviving draft leaves keep their form instance (#68). */
|
||||
function pruneForms(tab: { root: Node | null }): void {
|
||||
const alive = new Set(
|
||||
tab.root
|
||||
? leaves(tab.root)
|
||||
.filter((l) => !l.sessionId)
|
||||
.map((l) => l.id)
|
||||
: [],
|
||||
);
|
||||
for (const [id, f] of draftForms) {
|
||||
if (!alive.has(id)) {
|
||||
f.destroy();
|
||||
draftForms.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
for (const f of draftForms) f.destroy();
|
||||
draftForms.clear();
|
||||
renderTabs();
|
||||
canvas.replaceChildren();
|
||||
const tab = activeTab(layout);
|
||||
@@ -406,6 +429,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
el.classList.toggle("mv-mobile-solo", isMobile && layout.tabs.length === 1);
|
||||
|
||||
if (!tab.root) {
|
||||
pruneForms(tab);
|
||||
pool.setVisible([]);
|
||||
pool.setFocus(null);
|
||||
canvas.appendChild(renderWelcome());
|
||||
@@ -421,12 +445,14 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
|
||||
if (isMobile) {
|
||||
renderMobile(tab);
|
||||
pruneForms(tab);
|
||||
persist();
|
||||
return;
|
||||
}
|
||||
|
||||
const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = [];
|
||||
canvas.appendChild(buildTree(tab.root, hosts));
|
||||
pruneForms(tab);
|
||||
pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })));
|
||||
syncFocus();
|
||||
persist();
|
||||
|
||||
Reference in New Issue
Block a user