diff --git a/web/src/multiview.test.ts b/web/src/multiview.test.ts index 4040a70..8067d5e 100644 --- a/web/src/multiview.test.ts +++ b/web/src/multiview.test.ts @@ -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); diff --git a/web/src/multiview.ts b/web/src/multiview.ts index f588239..c95daf7 100644 --- a/web/src/multiview.ts +++ b/web/src/multiview.ts @@ -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(); + // 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(); 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();