// 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 Layout, leaves, locateSession, type Node, openSessions, removeLeaf, splitLeaf, } from "./layout.js"; import { sessionColor } from "./sessioncolor.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; /** 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; /** 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 each // render tears down the previous generation before rebuilding the canvas. const draftForms = new Set(); 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 colorMap(): Map { const m = new Map(); for (const id of openSessions(layout)) m.set(id, sessionColor(id)); return m; } 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(), colors: colorMap(), }); } 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; pane.style.setProperty("--mv-color", sessionColor(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 = 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. const t = activeTab(layout); const attached = leaves(t.root).includes(leaf); if (attached) { leaf.sessionId = session.id; leaf.draft = null; t.focusedLeafId = leaf.id; } render(); opts.onOpenSessionsChange?.(); }, }); draftForms.add(form); host.appendChild(form.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(); } // ---- top-level render ---- function render(): void { for (const f of draftForms) f.destroy(); draftForms.clear(); 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) { pool.setVisible([]); pool.setFocus(null); canvas.appendChild(renderWelcome()); sidebar.setHighlights({ open: openSessions(layout), focused: null, colors: colorMap() }); 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); persist(); return; } const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = []; canvas.appendChild(buildTree(tab.root, hosts)); 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"; if (l.sessionId) pill.style.setProperty("--mv-color", sessionColor(l.sessionId)); 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, colors: colorMap() }); } 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; } 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]; 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, closeSession, openSessions: () => openSessions(layout), beginSessionDrag, render, focusedSessionId: focusedLeafSession, }; }