Integration review (#168): the content pane opened a pane for whatever the sidebar highlighted with no status check. After a host reboot every session is dormant, so arrowing down the sidebar opened a pane per session that immediately died with WS 1006 (the daemon answers a bridgeless attach with 404/409, never a completed handshake). Gate every path that fills a pane (preview, open, mobile's single pane) on the session's own status: a session that isn't Running gets the same metadata-and-resume view its sidebar card shows (docs/SIDEBAR.md, docs/PREVIEW.md) instead of an attach attempt. The shell also reacts to onNeverAttached (previous commit) by dropping the dead connection and re-rendering, and to an open session's Running-ness flipping — most commonly a resume completing — so a placeholder and a live pane swap in step with the catalog.
898 lines
33 KiB
TypeScript
898 lines
33 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 { activityDurationLabel, activityHeldMs } from "./activity.js";
|
|
import { escapeHtml } from "./html.js";
|
|
import { sessionName } from "./types.js";
|
|
import type { Session } from "./types.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;
|
|
/** The session behind a leaf, when known — used to gate a live attach on
|
|
* `status === "Running"` (#168 review) and to fill in the dormant pane's
|
|
* resume affordance. A leaf whose session this returns `undefined` for
|
|
* (unknown to the caller) attaches as before, so callers that don't wire
|
|
* this up see no change in behaviour. */
|
|
sessionFor?: (sessionId: string) => Session | undefined;
|
|
/** Resume a dormant session (#166/#168), from its pane's own affordance. */
|
|
onResume?: (sessionId: string) => Promise<unknown>;
|
|
/** 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;
|
|
}
|
|
|
|
/** The session the operator actually picked — what `focusedLeafSession()`
|
|
* would return with the preview undone. A preview moves the active tab and
|
|
* its focus, but it is a view: the accent stays on the row the operator
|
|
* chose until they choose another (docs/PREVIEW.md). */
|
|
function realFocusedSession(): string | null {
|
|
const p = preview;
|
|
if (!p) return focusedLeafSession();
|
|
const tab = layout.tabs.find((t) => t.id === p.prevActiveTabId) ?? activeTab(layout);
|
|
const leafId =
|
|
p.prevFocus && p.prevFocus.tabId === tab.id ? p.prevFocus.leafId : tab.focusedLeafId;
|
|
const fl = leafId ? findLeaf(tab.root, leafId) : 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: realFocusedSession(),
|
|
});
|
|
// 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();
|
|
}
|
|
|
|
// ---- dormant panes (#168 review) ----
|
|
|
|
/** The session behind `sessionId` when it is known and not `Running` —
|
|
* what gates a leaf away from a live attach and towards the resume
|
|
* affordance instead. */
|
|
function dormantSessionFor(sessionId: string): Session | undefined {
|
|
const s = opts.sessionFor?.(sessionId);
|
|
return s && s.status !== "Running" ? s : undefined;
|
|
}
|
|
|
|
/** docs/PREVIEW.md / docs/SIDEBAR.md: what the content pane can honestly
|
|
* show for a session with no pane behind it — its metadata and a resume
|
|
* affordance, never a live terminal that would just die with WS 1006. */
|
|
function renderDormantPane(host: HTMLElement, session: Session): void {
|
|
const heldMs = activityHeldMs(session.last_activity_at ?? session.created_at, Date.now());
|
|
const age = heldMs !== null ? activityDurationLabel(heldMs) : null;
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "mv-pane-dormant";
|
|
wrap.innerHTML = `
|
|
<div class="mv-dormant-glyph" aria-hidden="true">◌</div>
|
|
<div class="mv-dormant-name">${escapeHtml(sessionName(session))}</div>
|
|
<div class="mv-dormant-cwd" title="${escapeHtml(session.cwd)}">${escapeHtml(session.cwd)}</div>
|
|
<div class="mv-dormant-detail">dormant${age ? ` for ${escapeHtml(age)}` : ""} — nothing running behind it</div>
|
|
<button type="button" class="btn btn-primary mv-dormant-resume">Resume</button>
|
|
`;
|
|
const btn = wrap.querySelector(".mv-dormant-resume") as HTMLButtonElement;
|
|
btn.addEventListener("click", () => {
|
|
btn.disabled = true;
|
|
btn.textContent = "Resuming…";
|
|
opts.onResume?.(session.id)?.catch((e: Error) => {
|
|
btn.disabled = false;
|
|
btn.textContent = "Resume";
|
|
alert(`Failed to resume: ${e.message}`);
|
|
});
|
|
});
|
|
host.replaceChildren(wrap);
|
|
}
|
|
|
|
// ---- 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) {
|
|
const dormant = dormantSessionFor(node.sessionId);
|
|
if (dormant) {
|
|
renderDormantPane(pane.host, dormant);
|
|
} else {
|
|
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);
|
|
|
|
const dormant = focused.sessionId ? dormantSessionFor(focused.sessionId) : undefined;
|
|
if (dormant) renderDormantPane(pane.host, dormant);
|
|
// Only the focused leaf's session is live on mobile; the rest park.
|
|
pool.setVisible(
|
|
focused.sessionId && !dormant
|
|
? [{ 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: realFocusedSession() });
|
|
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: realFocusedSession,
|
|
previewSession,
|
|
previewedSessionId: () => preview?.sessionId ?? null,
|
|
};
|
|
}
|