Files
kandev/apps/web/lib/state/dockview-layout-builders.ts
T
2026-06-02 21:48:23 +01:00

308 lines
13 KiB
TypeScript

import type { DockviewApi, AddPanelOptions } from "dockview-react";
import {
SIDEBAR_LOCK,
SIDEBAR_GROUP,
CENTER_GROUP,
RIGHT_TOP_GROUP,
RIGHT_BOTTOM_GROUP,
LAYOUT_PINNED_MIN_PX,
computeSidebarMaxPx,
computeRightMaxPx,
getPinnedWidth,
getRootSplitview as getRootSplitviewImpl,
resolveGroupIds,
setPinnedTarget,
} from "./layout-manager";
import type { LayoutGroupIds } from "./layout-manager";
import { getGlobalSidebarWidth } from "@/lib/local-storage";
import { createDebugLogger, isDebug } from "@/lib/debug/log";
// Re-export for consumers that import from this module
export { getRootSplitview } from "./layout-manager";
const debugWidths = createDebugLogger("dockview:widths");
/** Dockview's measured grid width, or undefined when not yet laid out — passed
* to the cap helpers so they don't fall back to a possibly-stale
* `window.innerWidth`. */
function layoutWidth(api: DockviewApi): number | undefined {
return api.width > 0 ? api.width : undefined;
}
/** Best-effort caller chain for the fixups-capture debug log: pull the first
* few stack frames above `applyLayoutFixups` so we can see WHICH layout path
* (env-switch / restore / custom-layout / maximize) recorded a given target.
* Debug-only — never called when isDebug() is false. */
const CALLER_CHAIN_SKIP = new Set(["captureCallerChain", "applyLayoutFixups", "logFixupsCapture"]);
function captureCallerChain(): string {
const stack = new Error().stack;
if (!stack) return "-";
const lines = stack.split("\n").slice(1);
const frames: string[] = [];
for (const line of lines) {
const m = /at (\S+)/.exec(line);
const name = m?.[1] ?? "";
if (!name || CALLER_CHAIN_SKIP.has(name)) continue;
const short = name.split(".").pop() ?? name;
frames.push(short);
if (frames.length >= 3) break;
}
return frames.join("<") || "-";
}
/** After fromJSON() restores a session layout, apply fixups and return group IDs.
*
* Apply loose runtime caps so the user can drag freely; the just-restored
* widths become the new pinned targets, and `enforcePinnedTargets` restores
* the column to that target on every subsequent rebalance. */
export function applyLayoutFixups(api: DockviewApi, savedRightWidth?: number): LayoutGroupIds {
const sv = getRootSplitviewImpl(api);
captureSidebarTarget(api, sv);
const oldChanges = api.getPanel("diff-files");
if (oldChanges) oldChanges.api.setTitle("Changes");
const oldFiles = api.getPanel("all-files");
if (oldFiles) oldFiles.api.setTitle("Files");
captureRightTarget(api, sv, savedRightWidth);
logFixupsCapture(api, sv);
return resolveGroupIds(api);
}
/** Resolve the sidebar's target width (clamped to the cap): the GLOBAL width
* pref when set, else the default width for the current measured layout. */
function resolveSidebarTarget(cap: number, totalWidth: number | undefined): number | undefined {
const pref = getGlobalSidebarWidth();
if (pref !== null) return Math.min(pref, cap);
const width =
totalWidth ??
(typeof window !== "undefined" && window.innerWidth > 0 ? window.innerWidth : cap);
return Math.min(
getPinnedWidth({ id: "sidebar", pinned: true, groups: [] }, width, undefined),
cap,
);
}
/** Lock + constrain the sidebar group and record its target width, clamped to
* the cap. The constraint pins the column at `sidebarCap`, so a target above
* it is unreachable and makes `enforcePinnedTargets` spin forever. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function captureSidebarTarget(api: DockviewApi, sv: any): void {
const sb = api.getPanel("sidebar");
if (!sb) return;
// Derive the cap from dockview's measured grid width, not the implicit
// window.innerWidth fallback: innerWidth can read transiently stale during
// route transitions / devtools toggles, yielding a too-small cap that would
// clamp the captured target too narrow and persist it — the width drift this
// pipeline exists to prevent.
const measuredWidth = layoutWidth(api);
const sidebarCap = computeSidebarMaxPx(measuredWidth);
sb.group.locked = SIDEBAR_LOCK;
sb.group.header.hidden = false;
sb.group.api.setConstraints({ maximumWidth: sidebarCap, minimumWidth: LAYOUT_PINNED_MIN_PX });
// Slow-path env restore: fromJSON brought back this env's saved sidebar
// pixel width, but the sidebar is a GLOBAL pref. Seed the target from the
// pref (clamped to fit) and resize the column so the restore honors it; fall
// back to the default sidebar width when no pref exists.
const target = resolveSidebarTarget(sidebarCap, measuredWidth);
if (target !== undefined) {
const cur = sv?.getViewSize?.(0);
if (typeof cur === "number" && cur > 0 && Math.abs(cur - target) > 1) {
try {
sv?.resizeView?.(0, target);
} catch {
/* dockview rejects unreachable sizes — ignore */
}
}
setPinnedTarget("sidebar", target);
}
}
/** Resolve the pinned right column's target width (clamped to the cap): the
* per-env SAVED width when the restore supplies one, else the column default
* for the current measured layout. Never the live splitview size — see
* `captureRightTarget`. */
function resolveRightTarget(
cap: number,
totalWidth: number | undefined,
savedRightWidth: number | undefined,
): number {
if (savedRightWidth !== undefined && savedRightWidth > 0) return Math.min(savedRightWidth, cap);
const width =
totalWidth ??
(typeof window !== "undefined" && window.innerWidth > 0 ? window.innerWidth : cap);
return Math.min(getPinnedWidth({ id: "right", pinned: true, groups: [] }, width, undefined), cap);
}
/** Constrain the default layout's right column groups and record the side
* column's target width, clamped to the cap.
*
* For the DEFAULT preset (the right column is pinned — identified by the
* well-known RIGHT_TOP_GROUP), the target is anchored to a STABLE width: the
* per-env saved width passed by the restore, or the column default when none.
* It is deliberately NOT read from the live splitview: dockview's
* post-`fromJSON` proportional rebalance reports a transient/rescaled size,
* and persisting that as the target ratcheted the right column wider on every
* restore (the dockview-wrong-width drift). The sidebar is handled the same
* way in `captureSidebarTarget` — neither pinned column captures its live size.
*
* For the vscode/preview/plan presets the side column has a generated group id
* and is NOT pinned, so there is no saved/default width to fall back on; there
* we keep recording the just-restored live width so switching to one of those
* tasks doesn't leak the previous task's right target.
*
* `sv.length >= 3` excludes the 2-column global-fallback restore (sidebar +
* center, right panels stripped as env-scoped), where the last splitview child
* is the CENTER column — recording its width as the "right" target would
* inflate the real right column once the full layout materializes. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function captureRightTarget(api: DockviewApi, sv: any, savedRightWidth?: number): void {
// Constrain the default preset's right column groups (stable well-known IDs).
// Other presets' side columns aren't pinned and carry no max-width cap.
// Use the measured grid width, not the window.innerWidth fallback (see
// captureSidebarTarget).
const measuredWidth = layoutWidth(api);
const rightCap = computeRightMaxPx(measuredWidth);
for (const gid of [RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP]) {
const group = api.groups.find((g) => g.id === gid);
if (group) {
group.api.setConstraints({ maximumWidth: rightCap, minimumWidth: LAYOUT_PINNED_MIN_PX });
}
}
if (!sv || sv.length < 3) return;
const idx = sv.length - 1;
// Default preset (pinned right column): anchor to saved-or-default, resize the
// column to it, and record it as the target. Never the live size.
if (api.groups.some((g) => g.id === RIGHT_TOP_GROUP || g.id === RIGHT_BOTTOM_GROUP)) {
const target = resolveRightTarget(rightCap, measuredWidth, savedRightWidth);
const cur = sv.getViewSize(idx);
if (typeof cur === "number" && cur > 0 && Math.abs(cur - target) > 1) {
try {
sv?.resizeView?.(idx, target);
} catch {
/* dockview rejects unreachable sizes — ignore */
}
}
setPinnedTarget("right", target);
return;
}
// Non-default presets: side column is not pinned — keep the live width.
const liveRight = sv.getViewSize(idx);
if (typeof liveRight === "number" && liveRight > 0) {
setPinnedTarget("right", Math.min(liveRight, rightCap));
}
}
/** Emit the `fixups-capture` width snapshot. Pulled out of `applyLayoutFixups`
* to keep that function under the complexity limit; no-op in prod. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function logFixupsCapture(api: DockviewApi, sv: any): void {
if (!isDebug()) return;
// Decisive fields: `sidebarOverCap=true` means the recorded target exceeds
// the cap that enforcement clamps the column to — i.e. an unreachable target
// that makes `enforcePinnedTargets` spin forever. `cols` shows whether the
// layout was complete at capture (cols<3 → no real right column). api.width
// vs window.innerWidth surfaces the window-fallback cap divergence.
const w = layoutWidth(api);
const sidebarCap = computeSidebarMaxPx(w);
const innerW = typeof window !== "undefined" ? window.innerWidth : -1;
const liveSidebar = sv?.getViewSize?.(0);
// Threshold matches captureRightTarget (>= 3): in a 2-column layout the last
// child is the center, not a right column, so we don't mislabel it here.
const liveRight = sv && sv.length >= 3 ? sv.getViewSize(sv.length - 1) : undefined;
const r = (n: number | undefined): string =>
typeof n === "number" ? String(Math.round(n)) : "-";
debugWidths(
`fixups-capture caller=${captureCallerChain()} apiW=${api.width} innerW=${innerW} ` +
`cols=${sv?.length ?? 0} sidebarCap=${Math.round(sidebarCap)} liveSidebar=${r(liveSidebar)} ` +
`rightCap=${Math.round(computeRightMaxPx(w))} liveRight=${r(liveRight)} ` +
`sidebarOverCap=${typeof liveSidebar === "number" && liveSidebar > sidebarCap + 1}`,
);
}
/**
* Resolve a fallback group position when the intended reference is stale.
*
* Tries to land in the center column, in this order:
* 1. Well-known CENTER_GROUP id.
* 2. Group containing the `chat` panel (post-drag, the well-known id may be
* gone but the chat panel still marks the center column).
* 3. Group containing any `session:*` panel (active session: chat is removed
* and replaced with per-session tabs).
* 4. Any group that is NOT the sidebar AND NOT a right-column group
* (Changes/Files/Terminal). Returning a right-column group would leak the
* panel into the narrow tools column — same UX bug as the sidebar leak.
*
* Returns undefined if no center-like group exists. The caller drops the
* position so dockview picks a default. Never returns the sidebar.
*/
export function fallbackGroupPosition(api: DockviewApi): { referenceGroup: string } | undefined {
const centerGroup = api.groups.find((g) => g.id === CENTER_GROUP);
if (centerGroup) return { referenceGroup: centerGroup.id };
const chatGroupId = api.getPanel("chat")?.group?.id;
if (chatGroupId) return { referenceGroup: chatGroupId };
const sessionGroupId = api.panels.find((p) => p.id.startsWith("session:"))?.group?.id;
if (sessionGroupId) return { referenceGroup: sessionGroupId };
const centerish = api.groups.find(
(g) => g.id !== SIDEBAR_GROUP && g.id !== RIGHT_TOP_GROUP && g.id !== RIGHT_BOTTOM_GROUP,
);
if (centerish) return { referenceGroup: centerish.id };
return undefined;
}
export function focusOrAddPanel(
api: DockviewApi,
options: AddPanelOptions & { id: string },
quiet = false,
): void {
const existing = api.getPanel(options.id);
if (existing) {
if (!quiet) existing.api.setActive();
return;
}
// Guard: if the referenced group or panel no longer exists (stale ID after
// layout transition), fall back to a known group. Avoid the active panel's
// group because the user may have just clicked in the sidebar.
const pos = options.position;
if (pos && "referenceGroup" in pos) {
const groupExists = api.groups.some((g) => g.id === pos.referenceGroup);
if (!groupExists) {
const fallback = fallbackGroupPosition(api);
options = fallback
? { ...options, position: { ...pos, ...fallback } }
: (Object.fromEntries(
Object.entries(options).filter(([k]) => k !== "position"),
) as typeof options);
}
}
if (pos && "referencePanel" in pos) {
const refPanel = api.getPanel(pos.referencePanel as string);
if (!refPanel) {
const fallback = fallbackGroupPosition(api);
options = fallback
? { ...options, position: fallback }
: (Object.fromEntries(
Object.entries(options).filter(([k]) => k !== "position"),
) as typeof options);
}
}
// For quiet adds use dockview's `inactive` flag so the new panel is never
// briefly activated. The save-active / restore-active dance flips the new
// panel's `isActive` to true and then back, which fires spurious
// onDidActiveChange events on listeners (e.g. PlanTab's seen-mark).
if (quiet) {
api.addPanel({ ...options, inactive: true });
} else {
api.addPanel(options);
}
}