308 lines
13 KiB
TypeScript
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);
|
|
}
|
|
}
|