Files
kandev/apps/web/lib/state/dockview-layout-builders.ts
T
2026-07-28 23:29:32 +01:00

337 lines
15 KiB
TypeScript

import type { DockviewApi, AddPanelOptions } from "dockview-react";
import {
SIDEBAR_LOCK,
CENTER_GROUP,
RIGHT_TOP_GROUP,
RIGHT_BOTTOM_GROUP,
LAYOUT_PINNED_MIN_PX,
computeSidebarMaxPx,
computeRightMaxPx,
getPinnedWidth,
isCenterCandidateGroupId,
getRootSplitview as getRootSplitviewImpl,
resolveGroupIds,
setPinnedTarget,
} from "./layout-manager";
import type { LayoutGroupIds } from "./layout-manager";
import { getGlobalSidebarWidth } from "@/lib/local-storage";
import { resolveResponsiveRightWidth } from "./layout-manager/right-width";
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;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function visibleSidebarWidth(sv: any): number {
return sv?.length >= 3 ? sv.getViewSize(0) : 0;
}
/** 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,
/** Retained for callers restoring legacy layouts; manual width is authoritative. */
_savedRightWidth?: number,
manualRightWidth?: number | null,
): 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, manualRightWidth);
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 MANUAL width when the user has dragged the sash, else the responsive
* column default for the current measured layout. Never the live splitview size — see
* `captureRightTarget`. */
function resolveRightTarget(
cap: number,
totalWidth: number | undefined,
manualRightWidth: number | null | undefined,
): number {
return Math.min(resolveResponsiveRightWidth(totalWidth, 0, manualRightWidth ?? null, cap), 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 manual width passed by the restore, or the responsive 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. Identified by
* `sv.length >= 3` (sidebar + center + side), since we have no stable group id
* to key off.
*
* The default-preset branch runs regardless of column count: a task with the
* sidebar hidden has sv.length=2 but the LAST child IS the pinned right
* column. Without per-env capture there, the stale global right target from a
* previous task's drag leaks through `enforcePinnedTargets` and snaps the
* restored column to the wrong width (per-task width persistence bug).
*
* The 2-column global-fallback restore (sidebar + center, right panels
* stripped) is excluded because neither right group exists in `api.groups`
* and `sv.length < 3` — its last splitview child is the CENTER column. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function captureRightTarget(
api: DockviewApi,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sv: any,
manualRightWidth?: number | null,
): 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, visibleSidebarWidth(sv));
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 < 2) 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. Works for
// both 3-column (sidebar+center+right) and 2-column (center+right, sidebar
// hidden) layouts since the right column is always at sv.length-1.
if (api.groups.some((g) => g.id === RIGHT_TOP_GROUP || g.id === RIGHT_BOTTOM_GROUP)) {
const target = resolveRightTarget(rightCap, measuredWidth, manualRightWidth);
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 (vscode/preview/plan): side column has a generated
// group id, so we need `sv.length >= 3` to know the last splitview child is
// the side column (not the center in a 2-column fallback).
if (sv.length < 3) return;
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);
// A 2-column layout's last child is the right column only when the pinned
// right groups exist (default preset, sidebar hidden); otherwise it's the
// center (global-fallback restore). Mirrors captureRightTarget.
const hasPinnedRight = api.groups.some(
(g) => g.id === RIGHT_TOP_GROUP || g.id === RIGHT_BOTTOM_GROUP,
);
const rightIdxIsRight = sv && (sv.length >= 3 || (sv.length === 2 && hasPinnedRight));
const liveRight = rightIdxIsRight ? 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, visibleSidebarWidth(sv)))} 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 && isCenterCandidateGroupId(chatGroupId)) return { referenceGroup: chatGroupId };
const sessionGroupId = api.panels.find((p) => p.id.startsWith("session:"))?.group?.id;
if (sessionGroupId && isCenterCandidateGroupId(sessionGroupId)) {
return { referenceGroup: sessionGroupId };
}
const centerish = api.groups.find((g) => isCenterCandidateGroupId(g.id));
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);
}
}