Compare commits

...
Author SHA1 Message Date
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
4 changed files with 1058 additions and 40 deletions
+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();