The one that loses data: the project picker lists linked worktrees as pickable rows, so `base_repo` was easily a worktree rather than the main checkout, and the new placement then nested the tree inside it. Git hides a registered worktree's contents from the outer `git status`, so the outer tree read clean and removing it deleted the inner one's uncommitted work — while that session could still be running. Placement now resolves to the main checkout via `--git-common-dir`, and removal refuses outright when `worktree list` reports anything registered underneath. Also: - The `worktree_root` override dropped the old UUID without replacing it, so two repos spawning the same branch name targeted one directory and the second failed. The repo basename now sits between root and slug. - Project staleness read `.git/HEAD`'s mtime, which only moves on checkout. A repo committed to daily from one long-lived branch reported its last branch switch and got dimmed. Prefer `logs/HEAD`. - The projects cache TTL (5s) was shorter than its own background refresh (60s), so nearly every request rescanned all of $HOME under the scanner lock — and draft panes re-request on unrelated re-renders. Now 180s. - onSuccess resolved the leaf against the active tab only, so switching tabs mid-POST orphaned the session. Searches every tab.
650 lines
22 KiB
TypeScript
650 lines
22 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 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<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;
|
|
}
|
|
|
|
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<SpawnFormHandle>();
|
|
|
|
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<string, string> {
|
|
const m = new Map<string, string>();
|
|
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<HTMLElement>(".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<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;
|
|
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 —
|
|
// searching every tab, since the operator may have switched tabs
|
|
// while the request was in flight.
|
|
const host = layout.tabs.find((t) => leaves(t.root).includes(leaf));
|
|
if (host) {
|
|
leaf.sessionId = session.id;
|
|
leaf.draft = null;
|
|
host.focusedLeafId = leaf.id;
|
|
}
|
|
render();
|
|
opts.onOpenSessionsChange?.();
|
|
},
|
|
});
|
|
draftForms.add(form);
|
|
host.appendChild(form.el);
|
|
}
|
|
|
|
const 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<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" : "");
|
|
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<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];
|
|
[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,
|
|
};
|
|
}
|