Compare commits

..
Author SHA1 Message Date
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 37338def71 merge: delete the spawn modal, amend DESIGN.md (#62) 2026-08-23 22:38:53 +01:00
2 changed files with 80 additions and 29 deletions
+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();