This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/multiview.ts
T
Miguel Palhas 7a3287f231 feat(maestro): colour the previewed pane and its sidebar row
A preview was distinguished only by the chip and a dashed ring, both easy to
miss, and the sidebar's hover reused the neutral surface — so a hovered row
said nothing about what the content pane was showing.

Adds a `--preview` token family on a hue of its own, deliberately not the
accent: the accent means "this is the one you picked", which a preview is not.
The previewed pane's titlebar, the chip, the throwaway tab, the previewed
sidebar row and hover all carry it. Palettes whose accent is already violet
(periwinkle, orchid) move the hue via `--preview-h`.
2026-09-03 13:35:55 +01:00

825 lines
30 KiB
TypeScript

// 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 (freshly split, not yet filled) show a
// placeholder. 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,
freshId,
type Dir,
type Layout,
leaves,
locateSession,
type Node,
openSessions,
removeLeaf,
splitLeaf,
type Tab,
} from "./layout.js";
import type { AttachInfo } from "./attach.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 spawn fills a pane with a new session, 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, opts?: { newTab?: boolean }) => 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<string>;
/** 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;
/** Show `sessionId` in the content pane without committing to it
* (docs/PREVIEW.md): the tab holding it is brought forward, or a throwaway
* tab carries it. `null` puts back whatever was on screen before. */
previewSession: (sessionId: string | null) => void;
/** The session being previewed, or null. */
previewedSessionId: () => string | null;
}
export function createMultiView(opts: MultiViewOptions): MultiView {
const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts;
// A preview must never reach localStorage: it is undone the moment the
// highlight moves on, and the layout it would save is not the operator's.
const persist = () => {
if (preview) return;
opts.onLayoutChange?.();
};
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 layout collapses to exactly one visible
// pane and no tab bar — splits/drag/resize are desktop-only, and opening or
// spawning replaces the pane rather than adding a second thing to navigate.
// The sidebar drawer is the only navigation. The tree is untouched, so
// widening restores whatever splits and tabs a desktop session built.
const mq = window.matchMedia("(max-width: 859px)");
let isMobile = mq.matches;
mq.addEventListener("change", (e) => {
isMobile = e.matches;
render();
});
// ---- preview (docs/PREVIEW.md) ----
// A preview is a temporary *view* change, never a layout change: it records
// exactly what it moved so ending it puts the layout back byte for byte, and
// it neither persists nor reports pane focus to the daemon while it is up.
interface Preview {
sessionId: string;
/** Tab that was active before the preview. */
prevActiveTabId: string;
/** Focused leaf to restore, when the preview stole focus in a real tab. */
prevFocus: { tabId: string; leafId: string | null } | null;
/** Throwaway tab built for a session that no pane was showing. */
tempTabId: string | null;
}
let preview: Preview | null = null;
/// Set while a preview-driven render runs: ending a preview re-mounts the
/// pane that was there, and that mount must not pull the keyboard out of the
/// sidebar the operator is still walking.
let quietRender = false;
/** Render without any mount taking the keyboard. */
function renderQuietly(): void {
quietRender = true;
try {
render();
} finally {
quietRender = false;
}
}
const isTempTab = (tabId: string) => preview?.tempTabId === tabId;
/** Undo the preview's moves. Mutates only — the caller renders. */
function restorePreview(): boolean {
const p = preview;
if (!p) return false;
preview = null;
if (p.tempTabId) {
const i = layout.tabs.findIndex((t) => t.id === p.tempTabId);
if (i >= 0) layout.tabs.splice(i, 1);
} else if (p.prevFocus) {
const t = layout.tabs.find((x) => x.id === p.prevFocus!.tabId);
if (t) t.focusedLeafId = p.prevFocus.leafId;
}
layout.activeTabId = layout.tabs.some((t) => t.id === p.prevActiveTabId)
? p.prevActiveTabId
: layout.tabs[0].id;
return true;
}
/** Keep what the preview is showing: its throwaway tab becomes a real one,
* and the focus it moved stands. Returns true when `sessionId` was the
* session being previewed; any other preview is simply discarded. */
function settlePreview(sessionId: string): boolean {
const p = preview;
if (!p) return false;
if (p.sessionId !== sessionId) {
restorePreview();
return false;
}
preview = null;
if (p.tempTabId) {
const t = layout.tabs.find((x) => x.id === p.tempTabId);
if (t) t.title = `Tab ${layout.tabs.length}`;
}
return true;
}
function previewSession(sessionId: string | null): void {
if (preview?.sessionId === sessionId) return;
if (sessionId === null) {
if (restorePreview()) renderQuietly();
return;
}
restorePreview();
const prevActiveTabId = layout.activeTabId;
const loc = locateSession(layout, sessionId);
if (loc) {
preview = {
sessionId,
prevActiveTabId,
prevFocus: { tabId: loc.tab.id, leafId: loc.tab.focusedLeafId },
tempTabId: null,
};
layout.activeTabId = loc.tab.id;
loc.tab.focusedLeafId = loc.leaf.id;
} else {
// Not on screen anywhere: a throwaway tab carries it, so no existing
// pane is evicted and nothing the operator arranged moves.
const tab: Tab = { id: freshId("t"), title: "Preview", root: null, focusedLeafId: null };
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
layout.tabs.push(tab);
layout.activeTabId = tab.id;
preview = { sessionId, prevActiveTabId, prevFocus: null, tempTabId: tab.id };
}
renderQuietly();
}
/** Any user action that changes the layout for real ends the preview first,
* so it never bakes a throwaway tab into the arrangement. */
function dropPreview(): void {
restorePreview();
}
// ---- highlight helpers ----
/** Sessions the operator actually has open — the preview's throwaway tab
* does not count, or hovering the sidebar would flip the shell's route. */
function realOpenSessions(): Set<string> {
const out = openSessions(layout);
if (preview?.tempTabId) out.delete(preview.sessionId);
return out;
}
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<HTMLElement>(".mv-pane")) {
pane.classList.toggle("is-focused", pane.dataset.leafId === tab.focusedLeafId);
// The previewed pane's titlebar wears the preview colour instead of the
// accent tint a focused pane gets (docs/PREVIEW.md).
pane.classList.toggle(
"is-preview",
preview !== null && pane.dataset.sessionId === preview.sessionId,
);
}
// A preview is not a focused pane: reporting it would clear the session's
// `AwaitingInput` glow just because the operator scrolled past it.
if (!preview) pool.setFocus(focusedLeafSession());
sidebar.setHighlights({
open: realOpenSessions(),
focused: focusedLeafSession(),
});
// The row the preview came from wears the same colour, so the sidebar and
// the pane read as one thing rather than two unrelated highlights.
sidebar.setPreview(preview?.sessionId ?? null);
}
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<Node, { kind: "leaf" }>): {
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) {
const empty = document.createElement("div");
empty.className = "mv-pane-empty";
empty.textContent = "Empty pane — pick a session from the sidebar, or press n to spawn.";
host.appendChild(empty);
}
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) => {
// Clicking into a previewed pane is the operator choosing it.
if (preview && leaf.sessionId === preview.sessionId) {
settlePreview(leaf.sessionId);
persist();
}
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<Node, { kind: "split" }>,
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" : "") +
(isTempTab(t.id) ? " is-preview" : "");
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 (isTempTab(t.id)) return; // a preview tab is not a place to go
dropPreview();
if (layout.activeTabId !== t.id) {
layout.activeTabId = t.id;
}
render();
});
// A preview tab is not the operator's to close: it goes on its own.
if (layout.tabs.length > 1 && !isTempTab(t.id)) {
const close = document.createElement("span");
close.className = "mv-tab-close";
close.textContent = "✕";
close.title = "Close tab";
close.addEventListener("click", (e) => {
e.stopPropagation();
dropPreview();
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", () => {
dropPreview();
addTab(layout);
render();
});
tabBar.appendChild(add);
}
// ---- mutations ----
function splitFocused(leafId: string, dir: Dir): void {
dropPreview();
const tab = activeTab(layout);
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
render();
}
function closePane(leafId: string): void {
dropPreview();
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 {
// Opening what is already previewed keeps exactly what is on screen: the
// throwaway tab becomes a real one instead of the session moving again.
if (settlePreview(sessionId)) {
render();
return;
}
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;
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. Reuses an empty pane, else splits beside the focused one
/// (#67 review). `newTab` opens its own tab instead of halving whatever the
/// operator was reading (#100) — what the Spawn button asks for.
function openSessionBeside(sessionId: string, placement?: { newTab?: boolean }): void {
if (settlePreview(sessionId)) {
render();
opts.onOpenSessionsChange?.();
return;
}
const loc = locateSession(layout, sessionId);
if (loc) {
layout.activeTabId = loc.tab.id;
loc.tab.focusedLeafId = loc.leaf.id;
render();
return;
}
// Mobile keeps a single tab and a single pane, so a new tab is not an
// option there: the session takes over the pane below.
if (placement?.newTab && !isMobile) {
const fresh = addTab(layout);
fresh.focusedLeafId = splitLeaf(fresh, "", sessionId, "row", true);
render();
opts.onOpenSessionsChange?.();
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)!;
// Mobile shows one pane, so there is no "beside": take over the pane. The
// evicted session keeps running and stays a tap away in the sidebar.
if (!target.sessionId || isMobile) {
target.sessionId = sessionId;
} else {
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
}
render();
opts.onOpenSessionsChange?.();
}
// ---- top-level render ----
function render(): void {
renderTabs();
canvas.replaceChildren();
const tab = activeTab(layout);
// Mobile: the tab bar is a second navigation surface we don't want, so CSS
// collapses it; the pane header's ☰ keeps the sidebar drawer reachable.
el.classList.toggle("mv-mobile-solo", isMobile);
// A lone tab has nothing to switch between, so the bar is only a strip of
// chrome; CSS hides it until a second tab exists. Spawning still opens its
// own tab (#100), which brings the bar back with the "+" button on it.
// The preview's throwaway tab must not make the bar appear and vanish as
// the highlight moves, so it does not count towards "one tab only".
el.classList.toggle(
"mv-solo-tab",
layout.tabs.filter((t) => !isTempTab(t.id)).length === 1,
);
el.classList.toggle("mv-previewing", preview !== null);
if (!tab.root) {
pool.setVisible([]);
if (!preview) pool.setFocus(null);
canvas.appendChild(renderWelcome());
sidebar.setHighlights({ open: realOpenSessions(), focused: null });
sidebar.setPreview(preview?.sessionId ?? 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);
persist();
return;
}
const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = [];
canvas.appendChild(buildTree(tab.root, hosts));
// A preview keeps the keyboard in the sidebar: mounting must not focus the
// terminal, or j/k would stop walking the list after the first step.
pool.setVisible(
hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })),
{ focus: !preview && !quietRender },
);
syncFocus();
persist();
}
// Mobile: the focused leaf, full screen, alone. Panes a desktop session left
// behind stay in the tree but are reached only by tapping the session in the
// sidebar drawer — mobile shows no pane switcher of its own.
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";
const pane = buildPane(focused);
pane.el.classList.add("is-focused"); // the only pane — never dimmed
if (preview && focused.sessionId === preview.sessionId) pane.el.classList.add("is-preview");
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) }] : [],
{ focus: !preview && !quietRender },
);
if (!preview) pool.setFocus(focused.sessionId ?? null);
sidebar.setHighlights({ open: realOpenSessions(), focused: focused.sessionId ?? null });
sidebar.setPreview(preview?.sessionId ?? null);
}
function closeSession(sessionId: string): void {
dropPreview();
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<Zone, string> = {
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];
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;
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 {
dropPreview();
runDrag({ kind: "session", sessionId }, e, { deferred: true });
}
return {
el,
openSession,
openSessionBeside,
closeSession,
openSessions: realOpenSessions,
beginSessionDrag,
render,
focusedSessionId: focusedLeafSession,
previewSession,
previewedSessionId: () => preview?.sessionId ?? null,
};
}