// The multi-view renderer/controller (issues #30 splits, #31 tabs, #33 // highlights). Owns the tab bar + canvas inside the content pane, builds the // active tab's split tree into DOM, and mounts a live AttachConnection into each // session leaf via the pool. Empty leaves are draft panes (#57): they render // the spawn form inline, and submitting replaces the draft with that session's // terminal in place. Drag-to-dock is issue #32; mobile projection is #35. import type { SidebarHandle } from "./catalog.js"; import type { ConnectionPool } from "./connections.js"; import { activeTab, addTab, closeTab, findLeaf, type Dir, type Draft, type Layout, leaves, locateSession, type Node, openSessions, removeLeaf, splitLeaf, } from "./layout.js"; import type { AttachInfo } from "./attach.js"; import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js"; export interface MultiViewOptions { pool: ConnectionPool; layout: Layout; sidebar: SidebarHandle; /** Header text for a session's pane / attach view. */ infoFor: (sessionId: string) => AttachInfo | undefined; /** Disconnect a session (the ⏻ tool): tear down its socket and close the pane. */ onDisconnect: (sessionId: string) => void; /** Welcome content for an empty tab. */ renderWelcome: () => HTMLElement; /** Called when a draft-pane submit turns an empty pane into a session pane, * so the shell can re-evaluate its route (sidebar vs attach layout). */ onOpenSessionsChange?: () => void; /** Called after any layout change (structure, focus, or divider resize) so the * shell can persist it (issue #34). */ onLayoutChange?: () => void; } export interface MultiView { el: HTMLElement; /** Bring a session into the layout: focus it if already open, else fill the * focused pane (or open the first pane in an empty tab). */ openSession: (sessionId: string) => void; openSessionBeside: (sessionId: string) => void; /** Drop the pane showing `sessionId` (the session was disconnected/killed). */ closeSession: (sessionId: string) => void; /** Every session currently shown in some pane, across all tabs. */ openSessions: () => Set; /** Open a draft pane (issue #62): reuse the focused pane if it is already * empty, otherwise split the focused pane. `seed` pre-fills the form * ("Spawn similar" clones a session's cwd + command). */ openDraft: (seed?: Partial, opts?: { newTab?: boolean }) => void; /** Start dragging a sidebar session into the canvas (issue #32). The * controller owns drag-vs-tap: a tap lets the row's normal click open it, a * drag shows drop zones to place it. */ beginSessionDrag: (sessionId: string, e: PointerEvent) => void; /** Rebuild the view from the current layout. */ render: () => void; focusedSessionId: () => string | null; } 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 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"; const tabBar = document.createElement("div"); tabBar.className = "mv-tabs"; const canvas = document.createElement("div"); canvas.className = "mv-canvas"; el.append(tabBar, canvas); // Mobile (issue #35): below 860px the tree projects to a one-pane-at-a-time // strip of pills — splits/drag/resize are desktop-only. The tree is untouched, // so widening restores the splits. const mq = window.matchMedia("(max-width: 859px)"); let isMobile = mq.matches; mq.addEventListener("change", (e) => { isMobile = e.matches; render(); }); // ---- highlight helpers ---- function focusedLeafSession(): string | null { const tab = activeTab(layout); const fl = tab.focusedLeafId ? findLeaf(tab.root, tab.focusedLeafId) : null; return fl?.sessionId ?? null; } /** Update focus styling + pool focus + sidebar highlights without rebuilding * the DOM (cheap path for click-to-focus). */ function syncFocus(): void { const tab = activeTab(layout); for (const pane of canvas.querySelectorAll(".mv-pane")) { pane.classList.toggle("is-focused", pane.dataset.leafId === tab.focusedLeafId); } pool.setFocus(focusedLeafSession()); sidebar.setHighlights({ open: openSessions(layout), focused: focusedLeafSession(), }); } function focusLeaf(leafId: string): void { const tab = activeTab(layout); if (tab.focusedLeafId === leafId) return; tab.focusedLeafId = leafId; syncFocus(); persist(); } // ---- pane + split DOM ---- function buildPane(leaf: Extract): { el: HTMLElement; host: HTMLElement; sessionId: string | null; } { const pane = document.createElement("div"); pane.className = "mv-pane"; pane.dataset.leafId = leaf.id; if (leaf.sessionId) pane.dataset.sessionId = leaf.sessionId; const host = document.createElement("div"); host.className = "mv-pane-host"; if (!leaf.sessionId) { // 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 = 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"); tools.className = "mv-pane-tools"; const mkBtn = (label: string, title: string, fn: () => void) => { const b = document.createElement("button"); b.type = "button"; b.className = "mv-tool"; b.title = title; b.textContent = label; b.addEventListener("pointerdown", (e) => e.stopPropagation()); b.addEventListener("click", (e) => { e.stopPropagation(); fn(); }); return b; }; // Order: disconnect (⏻) leftmost, then split-right / split-down / close. // Splits are desktop-only; mobile keeps just disconnect + close. if (leaf.sessionId) { const off = mkBtn("⏻", "Disconnect", () => onDisconnect(leaf.sessionId!)); off.classList.add("mv-tool-off"); tools.appendChild(off); } if (!isMobile) { tools.append( mkBtn("▥", "Split right", () => splitFocused(leaf.id, "row")), mkBtn("▤", "Split down", () => splitFocused(leaf.id, "col")), ); } tools.appendChild(mkBtn("✕", "Close pane", () => closePane(leaf.id))); pane.append(host, tools); pane.addEventListener( "pointerdown", (e) => { focusLeaf(leaf.id); // Drag a pane by its title bar (desktop only — no docking on mobile). if (isMobile) return; const t = e.target as HTMLElement; if (t.closest(".attach-header") && !t.closest("button, a, .mv-tool")) { runDrag({ kind: "pane", leafId: leaf.id }, e, { deferred: true }); } }, true, ); return { el: pane, host, sessionId: leaf.sessionId }; } function wireDivider( divider: HTMLElement, split: Extract, slotA: HTMLElement, slotB: HTMLElement, container: HTMLElement, ): void { divider.addEventListener("pointerdown", (e) => { e.preventDefault(); e.stopPropagation(); divider.classList.add("is-dragging"); divider.setPointerCapture(e.pointerId); const rect = container.getBoundingClientRect(); const move = (ev: PointerEvent) => { const frac = split.dir === "row" ? (ev.clientX - rect.left) / rect.width : (ev.clientY - rect.top) / rect.height; split.ratio = Math.max(0.1, Math.min(0.9, frac)); slotA.style.flexGrow = String(split.ratio); slotB.style.flexGrow = String(1 - split.ratio); }; const up = (ev: PointerEvent) => { divider.classList.remove("is-dragging"); try { divider.releasePointerCapture(ev.pointerId); } catch { /* already released */ } divider.removeEventListener("pointermove", move); divider.removeEventListener("pointerup", up); persist(); // save the new split ratio }; divider.addEventListener("pointermove", move); divider.addEventListener("pointerup", up); }); } /** Build the tree DOM, collecting each session leaf's mount host. */ function buildTree(node: Node, hosts: { id: string; host: HTMLElement; info?: AttachInfo }[]): HTMLElement { if (node.kind === "leaf") { const pane = buildPane(node); if (node.sessionId) { hosts.push({ id: node.sessionId, host: pane.host, info: infoFor(node.sessionId) }); } return pane.el; } const container = document.createElement("div"); container.className = `mv-split ${node.dir}`; const slotA = document.createElement("div"); slotA.className = "mv-slot"; slotA.style.cssText = `flex:${node.ratio} 1 0`; const divider = document.createElement("div"); divider.className = "mv-divider"; const slotB = document.createElement("div"); slotB.className = "mv-slot"; slotB.style.cssText = `flex:${1 - node.ratio} 1 0`; slotA.appendChild(buildTree(node.a, hosts)); slotB.appendChild(buildTree(node.b, hosts)); container.append(slotA, divider, slotB); wireDivider(divider, node, slotA, slotB, container); return container; } // ---- tab bar ---- function renderTabs(): void { tabBar.replaceChildren(); for (const t of layout.tabs) { const tab = document.createElement("button"); tab.type = "button"; tab.className = "mv-tab" + (t.id === layout.activeTabId ? " is-active" : ""); const n = leaves(t.root).filter((l) => l.sessionId).length; const label = document.createElement("span"); label.textContent = t.title + (n ? ` · ${n}` : ""); tab.appendChild(label); tab.addEventListener("click", () => { if (layout.activeTabId !== t.id) { layout.activeTabId = t.id; render(); } }); if (layout.tabs.length > 1) { const close = document.createElement("span"); close.className = "mv-tab-close"; close.textContent = "✕"; close.title = "Close tab"; close.addEventListener("click", (e) => { e.stopPropagation(); closeTab(layout, t.id); render(); }); tab.appendChild(close); } tabBar.appendChild(tab); } const add = document.createElement("button"); add.type = "button"; add.className = "mv-tab-add"; add.textContent = "+"; add.title = "New tab"; add.addEventListener("click", () => { addTab(layout); render(); }); tabBar.appendChild(add); } // ---- mutations ---- function splitFocused(leafId: string, dir: Dir): void { const tab = activeTab(layout); tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true); render(); } function closePane(leafId: string): void { const tab = activeTab(layout); removeLeaf(tab, leafId); if (tab.focusedLeafId === leafId) { tab.focusedLeafId = leaves(tab.root)[0]?.id ?? null; } render(); } function openSession(sessionId: string): void { const loc = locateSession(layout, sessionId); if (loc) { layout.activeTabId = loc.tab.id; loc.tab.focusedLeafId = loc.leaf.id; render(); return; } const tab = activeTab(layout); if (!tab.root) { const id = splitLeaf(tab, "", sessionId, "row", true); // root from empty tab.focusedLeafId = id; render(); return; } // Fill the focused pane (VS Code style: open replaces the active group). const target = (tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)) || leaves(tab.root)[0]; target.sessionId = sessionId; target.draft = null; tab.focusedLeafId = target.id; render(); } /// Mount a session without evicting one. /// /// `openSession` deliberately replaces the focused pane (VS Code style) for /// a sidebar click, but the fast path must never do that: pressing the key /// while attached would drop the running session out of the layout /// entirely. Follows `openDraft`'s rule instead — reuse an empty pane, else /// split beside the focused one (#67 review). function openSessionBeside(sessionId: string): void { const loc = locateSession(layout, sessionId); if (loc) { layout.activeTabId = loc.tab.id; loc.tab.focusedLeafId = loc.leaf.id; render(); return; } const tab = activeTab(layout); if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) { tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true); render(); opts.onOpenSessionsChange?.(); return; } const target = findLeaf(tab.root, tab.focusedLeafId)!; if (!target.sessionId) { target.sessionId = sessionId; target.draft = null; } else { tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true); } render(); opts.onOpenSessionsChange?.(); } function openDraft(seed?: Partial, opts?: { newTab?: boolean }): void { const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed }); if (opts?.newTab) { // Spawn opens its own tab rather than halving whatever you were reading // (#100). The empty-pane reuse below still applies within a tab. const fresh = addTab(layout); fresh.focusedLeafId = splitLeaf(fresh, "", null, "row", true); findLeaf(fresh.root!, fresh.focusedLeafId)!.draft = draft(); render(); return; } const tab = activeTab(layout); if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) { // Empty tab (or stale focus): the draft becomes the first pane. tab.focusedLeafId = splitLeaf(tab, "", null, "row", true); findLeaf(tab.root!, tab.focusedLeafId)!.draft = draft(); render(); return; } const target = findLeaf(tab.root, tab.focusedLeafId)!; if (!target.sessionId) { // Empty = no session, not `!draft` (#72): the mounted form auto-syncs // picked defaults into `draft` once ready, so that's never reliably null. target.draft = draft(); const stale = draftForms.get(target.id); if (stale) { stale.destroy(); draftForms.delete(target.id); } } else { // Split a fresh draft beside the focused pane. tab.focusedLeafId = splitLeaf(tab, target.id, null, "row", true); findLeaf(tab.root, tab.focusedLeafId)!.draft = draft(); } render(); } // ---- 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 { renderTabs(); canvas.replaceChildren(); const tab = activeTab(layout); // Mobile single-tab: the tab bar carries no information (one tab, one pane), // so CSS collapses it and we float a ☰ instead (rendered in renderMobile). el.classList.toggle("mv-mobile-solo", isMobile && layout.tabs.length === 1); if (!tab.root) { pruneForms(tab); pool.setVisible([]); pool.setFocus(null); canvas.appendChild(renderWelcome()); sidebar.setHighlights({ open: openSessions(layout), focused: null }); persist(); return; } // Keep focus pointing at a real leaf. if (!tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) { tab.focusedLeafId = leaves(tab.root)[0]?.id ?? null; } 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(); } // Mobile: a pill strip of the active tab's leaves + one full-screen pane. function renderMobile(tab: { root: Node | null; focusedLeafId: string | null }): void { const ls = leaves(tab.root); const focused = (tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)) || ls[0]; const wrap = document.createElement("div"); wrap.className = "mv-mobile"; if (ls.length > 1) { const bar = document.createElement("div"); bar.className = "mv-panebar"; for (const l of ls) { const pill = document.createElement("button"); pill.type = "button"; pill.className = "mv-pill" + (l.id === focused.id ? " is-active" : ""); pill.textContent = l.sessionId ? (infoFor(l.sessionId)?.name ?? l.sessionId.slice(0, 8)) : "empty"; pill.addEventListener("click", () => { tab.focusedLeafId = l.id; render(); }); bar.appendChild(pill); } wrap.appendChild(bar); } const pane = buildPane(focused); pane.el.classList.add("is-focused"); // the only pane — never dimmed wrap.appendChild(pane.el); canvas.appendChild(wrap); // Only the focused leaf's session is live on mobile; the rest park. pool.setVisible( focused.sessionId ? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }] : [], ); pool.setFocus(focused.sessionId ?? null); sidebar.setHighlights({ open: openSessions(layout), focused: focused.sessionId ?? null }); } function closeSession(sessionId: string): void { const loc = locateSession(layout, sessionId); if (!loc) return; removeLeaf(loc.tab, loc.leaf.id); if (loc.tab.focusedLeafId === loc.leaf.id) { loc.tab.focusedLeafId = leaves(loc.tab.root)[0]?.id ?? null; } // A tab that held nothing but the dead session goes with it (#137): a killed // session should not leave an empty tab behind for the operator to close by // hand. `closeTab` keeps the last tab (emptied) so there is always one. if (!loc.tab.root) closeTab(layout, loc.tab.id); render(); } // ---- drag-to-dock (issue #32) ---- // Drag a pane's title bar, or a session from the sidebar, onto another pane: // edge zones split that pane, centre swaps (pane source) or replaces (session // source). Sidebar drags move the session if it's already open elsewhere. type Zone = "left" | "right" | "top" | "bottom" | "center"; type DragSource = { kind: "pane"; leafId: string } | { kind: "session"; sessionId: string }; let drag: { source: DragSource; ghost: HTMLElement; overlay: HTMLElement | null; targetLeafId: string | null; zone: Zone | null; } | null = null; function dragSessionId(src: DragSource): string | null { if (src.kind === "session") return src.sessionId; return findLeaf(activeTab(layout).root, src.leafId)?.sessionId ?? null; } /** The leaf in the ACTIVE tab the source occupies (skip it as a drop target). */ function dragSelfLeaf(src: DragSource): string | null { if (src.kind === "pane") return src.leafId; const loc = locateSession(layout, src.sessionId); return loc && loc.tab.id === layout.activeTabId ? loc.leaf.id : null; } function dragLabel(src: DragSource): string { const sid = dragSessionId(src); if (!sid) return "empty pane"; return infoFor(sid)?.name ?? sid; } function makeGhost(src: DragSource): HTMLElement { const g = document.createElement("div"); g.className = "mv-drag-ghost"; g.textContent = `⠿ ${dragLabel(src)}`; document.body.appendChild(g); return g; } function runDrag(src: DragSource, e: PointerEvent, opts: { deferred: boolean }): void { const sx = e.clientX; const sy = e.clientY; let started = !opts.deferred; if (started) drag = { source: src, ghost: makeGhost(src), overlay: null, targetLeafId: null, zone: null }; const place = (ev: PointerEvent) => { if (drag) { drag.ghost.style.left = `${ev.clientX + 12}px`; drag.ghost.style.top = `${ev.clientY + 12}px`; } }; if (started) place(e); const move = (ev: PointerEvent) => { if (!started) { if (Math.hypot(ev.clientX - sx, ev.clientY - sy) < 5) return; started = true; document.body.style.userSelect = "none"; drag = { source: src, ghost: makeGhost(src), overlay: null, targetLeafId: null, zone: null }; } place(ev); updateDock(ev); }; const up = () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); if (!started) return; document.body.style.userSelect = ""; suppressNextClick(); finishDrag(); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); } /** Eat the click that follows a real drag (so a dragged sidebar row doesn't * also navigate, and a dragged pane title doesn't trigger anything). */ function suppressNextClick(): void { const kill = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); window.removeEventListener("click", kill, true); }; window.addEventListener("click", kill, true); } function paneUnder(x: number, y: number): HTMLElement | null { for (const node of document.elementsFromPoint(x, y)) { const pane = (node as HTMLElement).closest?.(".mv-pane") as HTMLElement | null; if (pane && canvas.contains(pane)) return pane; } return null; } function clearOverlay(): void { drag?.overlay?.remove(); if (drag) drag.overlay = null; } function updateDock(ev: PointerEvent): void { if (!drag) return; clearOverlay(); const pane = paneUnder(ev.clientX, ev.clientY); const self = dragSelfLeaf(drag.source); if (!pane || pane.dataset.leafId === self) { drag.targetLeafId = null; drag.zone = null; return; } const r = pane.getBoundingClientRect(); const fx = (ev.clientX - r.left) / r.width; const fy = (ev.clientY - r.top) / r.height; const edges = { left: fx, right: 1 - fx, top: fy, bottom: 1 - fy }; const nearest = (Object.keys(edges) as (keyof typeof edges)[]).reduce((m, k) => edges[k] < edges[m] ? k : m, ); const zone: Zone = edges[nearest] < 0.28 ? nearest : "center"; drag.targetLeafId = pane.dataset.leafId!; drag.zone = zone; const ov = document.createElement("div"); ov.className = "mv-dock-overlay"; const hot = document.createElement("div"); hot.className = "mv-dock-zone"; const css: Record = { left: "left:0;top:0;width:50%;height:100%", right: "left:50%;top:0;width:50%;height:100%", top: "left:0;top:0;width:100%;height:50%", bottom: "left:0;top:50%;width:100%;height:50%", center: "inset:14%", }; hot.style.cssText = "position:absolute;" + css[zone]; ov.appendChild(hot); pane.appendChild(ov); drag.overlay = ov; } function finishDrag(): void { if (!drag) return; const { source, targetLeafId, zone, ghost } = drag; ghost.remove(); clearOverlay(); drag = null; if (!targetLeafId || !zone) return; const tab = activeTab(layout); const self = dragSelfLeaf(source); if (targetLeafId === self) return; // dropped on its own pane const target = findLeaf(tab.root, targetLeafId); if (!target) return; const sessionId = dragSessionId(source); if (zone === "center") { if (source.kind === "pane" && self) { const dragged = findLeaf(tab.root, self)!; [dragged.sessionId, target.sessionId] = [target.sessionId, dragged.sessionId]; [dragged.draft, target.draft] = [target.draft, dragged.draft]; // The leaf data moved but each pane's mounted form is still keyed to // its old leaf (#73) — tear both down like openDraft's reuse case // (#72) so render() remounts fresh forms seeded from the swapped data. for (const id of [dragged.id, target.id]) { const stale = draftForms.get(id); if (stale) { stale.destroy(); draftForms.delete(id); } } tab.focusedLeafId = target.id; } else if (sessionId) { const loc = locateSession(layout, sessionId); if (loc) removeLeaf(loc.tab, loc.leaf.id); const t2 = findLeaf(tab.root, targetLeafId); if (t2) { t2.sessionId = sessionId; t2.draft = null; tab.focusedLeafId = t2.id; } } render(); return; } // edge zone → split the target, placing the source beside it const dir: Dir = zone === "left" || zone === "right" ? "row" : "col"; const after = zone === "right" || zone === "bottom"; const loc = sessionId ? locateSession(layout, sessionId) : null; if (source.kind === "pane" && self) removeLeaf(tab, self); else if (loc) removeLeaf(loc.tab, loc.leaf.id); tab.focusedLeafId = splitLeaf(tab, targetLeafId, sessionId, dir, after); render(); } function beginSessionDrag(sessionId: string, e: PointerEvent): void { runDrag({ kind: "session", sessionId }, e, { deferred: true }); } return { el, openSession, openSessionBeside, openDraft, closeSession, openSessions: () => openSessions(layout), beginSessionDrag, render, focusedSessionId: focusedLeafSession, }; }