Compare commits

..
Author SHA1 Message Date
Miguel Palhas 74967e45b4 Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/66-e2e 2026-08-23 22:56:01 +01:00
Miguel Palhas 7a2cc30bf9 test(maestro): cover spawn-v2 discovery, worktrees and cleanup e2e (#66) 2026-08-23 22:55:52 +01:00
Miguel Palhas 8ed44e785d test(maestro): add branch/project clients and config seeding to e2e harness 2026-08-23 22:55:52 +01:00
Miguel Palhas 63da845614 merge: tui draft worktree section (#65) 2026-08-23 22:49:57 +01:00
Miguel Palhas d336bd802d Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/65-tui-worktree 2026-08-23 22:45:58 +01:00
Miguel Palhas 67d17f48d0 feat(maestro): app loop serves the draft's worktree fetches (#65)
Two new effects: ProbeGitRoot resolves the draft target's base repo via
/api/gitroot (seq-guarded, a miss unticks the toggle) and LoadBranches
fills the existing-branch picker from /api/branches, both applied back
to the tab that requested them. Branch loads triggered by a successful
probe are served inline so perform() stays recursion-free.
2026-08-23 22:45:49 +01:00
Miguel Palhas ec45312400 merge: reuse draft forms across renders (#68) 2026-08-23 22:45:20 +01:00
Miguel Palhas 525dc9a04a fix(maestro): reuse draft forms across renders (#68)
render() destroyed and rebuilt every draft form, so an unrelated
session exiting tore down a draft mid-edit: focus, caret and
selection were lost and /api/info, /api/projects, /api/names were
refetched for a form already on screen.

Key draft forms by leaf id; rebuild only the pane DOM around them
and destroy a form when its leaf is removed or filled by a session.
2026-08-23 22:43:42 +01:00
Miguel Palhas 264332266c feat(maestro): tui draft form gains a worktree section (#65)
Worktree on by default over a git repository with Enter/Space to spawn
in the checkout itself; base repo inferred from picked projects or a
guarded /api/gitroot probe for raw paths (a miss unticks, web parity);
new-branch name tracking the generated session name until touched;
Left/Right switches to picking an existing branch from GET /api/branches,
with held branches skipped and rendered dimmed beside their holder.
No cleanup option: cleanup is unconditional daemon-side (#56).
2026-08-23 22:42:46 +01:00
Miguel Palhas 37338def71 merge: delete the spawn modal, amend DESIGN.md (#62) 2026-08-23 22:38:53 +01:00
9 changed files with 1406 additions and 44 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>(
+141 -2
View File
@@ -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
+25
View File
@@ -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
View File
@@ -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();