fix(web): stop right pane width from drifting across tasks (#1201)

This commit is contained in:
Carlos Florêncio
2026-05-31 18:48:38 +01:00
committed by GitHub
parent 4f852b7a5c
commit 83c800879a
6 changed files with 135 additions and 62 deletions
@@ -2,10 +2,12 @@ import type { DockviewReadyEvent, SerializedDockview } from "dockview-react";
import type { StoreApi } from "zustand";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { applyLayoutFixups } from "@/lib/state/dockview-layout-builders";
import { savedRightColumnWidth } from "@/lib/state/dockview-env-switch";
import { isLayoutShapeHealthy } from "@/lib/state/dockview-layout-health";
import { measureDockviewContainer } from "@/lib/state/dockview-measure";
import { isEnvScopedDockviewComponent } from "@/lib/state/dockview-env-scoped-components";
import type { LayoutState } from "@/lib/state/layout-manager";
import { setPinnedTarget } from "@/lib/state/layout-manager";
import type { AppState } from "@/lib/state/store";
import { getEnvLayout, getEnvMaximizeState, removeEnvMaximizeState } from "@/lib/local-storage";
import { createDebugLogger, IS_DEBUG } from "@/lib/debug/log";
@@ -152,11 +154,21 @@ type SavedMax = ReturnType<typeof getEnvMaximizeState>;
* maximize state into the store. Single source of truth for both restore
* call sites — keeping `preMaximizeLayout` and `maximizedGroupId` in lockstep.
*/
function applySavedMaximize(api: DockviewReadyEvent["api"], savedMax: NonNullable<SavedMax>): void {
function applySavedMaximize(
api: DockviewReadyEvent["api"],
savedMax: NonNullable<SavedMax>,
savedRightWidth?: number,
): void {
api.fromJSON(savedMax.maximizedDockviewJson as SerializedDockview);
const { width, height } = measureDockviewContainer(api);
api.layout(width, height);
const ids = applyLayoutFixups(api);
// The maximize JSON is 2-column — captureRightTarget skips it (sv.length < 3).
// Seed the right target directly so enforcePinnedTargets can snap the column
// back to the saved width when the user exits maximize mode.
if (savedRightWidth !== undefined && savedRightWidth > 0) {
setPinnedTarget("right", savedRightWidth);
}
useDockviewStore.setState({
...ids,
preMaximizeLayout: savedMax.preMaximizeLayout as unknown as LayoutState,
@@ -164,12 +176,19 @@ function applySavedMaximize(api: DockviewReadyEvent["api"], savedMax: NonNullabl
});
}
function applyFixupsWithMaximize(api: DockviewReadyEvent["api"], envId: string | null): void {
function applyFixupsWithMaximize(
api: DockviewReadyEvent["api"],
envId: string | null,
savedRightWidth?: number,
): void {
const savedMax = envId ? getEnvMaximizeState(envId) : null;
if (savedMax) {
applySavedMaximize(api, savedMax);
applySavedMaximize(api, savedMax, savedRightWidth);
} else {
const ids = applyLayoutFixups(api);
// Anchor the right column to its per-env saved width (see
// `captureRightTarget`) so a page reload restores the task's remembered
// width instead of resetting it to the default.
const ids = applyLayoutFixups(api, savedRightWidth);
useDockviewStore.setState(ids);
}
}
@@ -229,7 +248,7 @@ function tryRestoreEnvLayout(
});
}
api.fromJSON(sanitized as SerializedDockview);
applyFixupsWithMaximize(api, envId);
applyFixupsWithMaximize(api, envId, savedRightColumnWidth(sanitized as SerializedDockview));
return true;
}
@@ -153,36 +153,6 @@ export function setupSashDragCapToggle(api: DockviewReadyEvent["api"]): () => vo
};
}
function trackPinnedWidths(api: DockviewReadyEvent["api"]): void {
const store = useDockviewStore.getState();
if (store.isRestoringLayout) return;
if (api.hasMaximizedGroup() || store.preMaximizeLayout !== null) return;
const sv = getRootSplitview(api);
if (!sv || sv.length < 2) return;
try {
// The sidebar width is NOT tracked here: it is a global pref written only
// by a genuine sash drag (see setupSashDragCapToggle). Capturing the live
// width on every layout change would persist dockview's transient
// proportional-rebalance widths (e.g. 320→213 on a monitor shrink) and
// fight enforcePinnedTargets. Right column is still mirrored below.
//
// Right column is the last grid index when present. Skip when there is
// no right column (compact preset, rightPanelsVisible=false).
if (store.rightPanelsVisible) {
const rightIdx = sv.length - 1;
const rightW = sv.getViewSize(rightIdx);
if (rightW > 50) {
const current = store.pinnedWidths.get("right");
if (current !== rightW) {
store.setPinnedWidth("right", rightW);
}
}
}
} catch {
/* noop */
}
}
/**
* Keep dockview's internal grid width in sync with the live DOM container.
*
@@ -225,11 +195,8 @@ export function setupGroupTracking(api: DockviewReadyEvent["api"]): () => void {
useDockviewStore.setState({ activeGroupId: group?.id ?? null });
});
useDockviewStore.setState({ activeGroupId: api.activeGroup?.id ?? null });
const d2 = api.onDidLayoutChange(() => trackPinnedWidths(api));
trackPinnedWidths(api);
return () => {
d1.dispose();
d2.dispose();
};
}
@@ -43,6 +43,7 @@ vi.mock("./layout-manager", () => {
import { getEnvLayout } from "@/lib/local-storage";
import { getRootSplitview, savedLayoutMatchesLive } from "./layout-manager";
import { applyLayoutFixups } from "./dockview-layout-builders";
function makeMockApi(): EnvSwitchParams["api"] {
return {
@@ -188,5 +189,10 @@ describe("performEnvSwitch — pinned column resize after fast-path", () => {
expect(resizeView).toHaveBeenCalledWith(0, 300);
expect(resizeView).toHaveBeenCalledWith(2, 420);
expect(resizeView).not.toHaveBeenCalledWith(1, expect.anything());
// The saved right width is also forwarded to applyLayoutFixups so the
// fixups pass anchors the right target to the per-env saved width (420)
// rather than re-capturing dockview's transient post-fromJSON live size.
expect(applyLayoutFixups).toHaveBeenCalledWith(expect.anything(), 420);
});
});
+17 -2
View File
@@ -334,7 +334,22 @@ function tryFastEnvSwitch(params: EnvSwitchParams): LayoutGroupIds | null {
applyPinnedColumnSizes(api, saved as SerializedDockview | null, params.safeWidth);
api.layout(params.safeWidth, params.safeHeight);
return applyLayoutFixups(api);
return applyLayoutFixups(api, savedRightColumnWidth(saved as SerializedDockview | null));
}
/**
* The per-env saved width of the right column (the last grid-root child) for a
* default-preset layout, or undefined when the saved layout has no distinct
* right column. Forwarded to `applyLayoutFixups` so the fixups pass anchors the
* pinned right target to this stable saved width instead of dockview's
* transient post-`fromJSON` live size (the dockview-wrong-width drift).
*/
export function savedRightColumnWidth(saved: SerializedDockview | null): number | undefined {
if (!saved) return undefined;
const sizes = extractSavedColumnSizes(saved);
if (!sizes || sizes.length < 3) return undefined;
const w = sizes[sizes.length - 1];
return Number.isFinite(w) && w > 0 ? w : undefined;
}
/** Extract per-column sizes from a saved SerializedDockview grid root. */
@@ -497,7 +512,7 @@ export function performEnvSwitch(params: EnvSwitchParams): LayoutGroupIds {
livePanelIdsAfter: api.panels.map((p) => p.id),
});
}
return applyLayoutFixups(api);
return applyLayoutFixups(api, savedRightColumnWidth(saved as SerializedDockview));
} catch (err) {
console.warn("performEnvSwitch: fromJSON threw", err);
debug("performEnvSwitch: fromJSON threw, falling through to default", { newEnvId, err });
@@ -54,10 +54,14 @@ const SIDEBAR_GROUP = "group-sidebar";
const CENTER_GROUP = "group-center";
const RIGHT_BOTTOM_GROUP = "group-right-bottom";
let mockResizeView: ReturnType<typeof vi.fn>;
function mockSplitview(sizesByIndex: number[]): void {
mockResizeView = vi.fn();
vi.mocked(getRootSplitview).mockReturnValue({
length: sizesByIndex.length,
getViewSize: (idx: number) => sizesByIndex[idx],
resizeView: mockResizeView,
} as unknown as NonNullable<ReturnType<typeof getRootSplitview>>);
}
@@ -117,14 +121,36 @@ describe("applyLayoutFixups — pinned target capture", () => {
expect(setPinnedTarget).toHaveBeenCalledWith("sidebar", SIDEBAR_CAP);
});
it("records both targets in a real 3-column layout", () => {
mockSplitview([350, 720, 400]); // sidebar, center, right
it("uses the column default for the pinned right column when no saved width is given", () => {
// Regression (dockview-wrong-width): the default-preset right column must
// anchor to a STABLE width — the per-env saved width, or the column default
// when none — NOT dockview's post-fromJSON live size (400 here). Capturing
// the live size ratcheted the column wider on every restore. Mirrors
// captureSidebarTarget, which anchors to the pref/default, never the live
// size. getPinnedWidth is mocked to 350 (the default).
mockSplitview([350, 720, 400]); // live right = 400 (transient/rescaled)
const api = makeApi([SIDEBAR_GROUP, CENTER_GROUP, RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP]);
applyLayoutFixups(api);
expect(setPinnedTarget).toHaveBeenCalledWith("sidebar", 350);
expect(setPinnedTarget).toHaveBeenCalledWith("right", 400);
expect(setPinnedTarget).toHaveBeenCalledWith("right", 350); // default, NOT live 400
expect(setPinnedTarget).not.toHaveBeenCalledWith("right", 400);
});
it("anchors the pinned right target to the saved per-env width, not the live size", () => {
// A restore passes the env's saved right width; it wins over both the live
// size (400) and the default (350) so a deliberately-resized task restores
// its own remembered width. Also asserts that the column is physically
// resized to the stable width (not left at the transient live size).
mockSplitview([350, 720, 400]);
const api = makeApi([SIDEBAR_GROUP, CENTER_GROUP, RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP]);
applyLayoutFixups(api, 420);
expect(setPinnedTarget).toHaveBeenCalledWith("right", 420);
expect(setPinnedTarget).not.toHaveBeenCalledWith("right", 400);
expect(mockResizeView).toHaveBeenCalledWith(2, 420);
});
it("derives the caps from api.width, not the window.innerWidth fallback", () => {
@@ -153,11 +179,11 @@ describe("applyLayoutFixups — pinned target capture", () => {
expect(setPinnedTarget).toHaveBeenCalledWith("right", 420);
});
it("clamps an over-cap right target down to the cap", () => {
mockSplitview([350, 200, 1200]); // right (1200) exceeds RIGHT_CAP (1029)
it("clamps an over-cap saved right width down to the cap", () => {
mockSplitview([350, 200, 800]);
const api = makeApi([SIDEBAR_GROUP, CENTER_GROUP, RIGHT_TOP_GROUP]);
applyLayoutFixups(api);
applyLayoutFixups(api, 1200); // saved 1200 exceeds RIGHT_CAP (1029)
expect(setPinnedTarget).toHaveBeenCalledWith("right", RIGHT_CAP);
});
+56 -16
View File
@@ -56,7 +56,7 @@ function captureCallerChain(): string {
* 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): LayoutGroupIds {
export function applyLayoutFixups(api: DockviewApi, savedRightWidth?: number): LayoutGroupIds {
const sv = getRootSplitviewImpl(api);
captureSidebarTarget(api, sv);
@@ -65,7 +65,7 @@ export function applyLayoutFixups(api: DockviewApi): LayoutGroupIds {
const oldFiles = api.getPanel("all-files");
if (oldFiles) oldFiles.api.setTitle("Files");
captureRightTarget(api, sv);
captureRightTarget(api, sv, savedRightWidth);
logFixupsCapture(api, sv);
@@ -121,28 +121,51 @@ function captureSidebarTarget(api: DockviewApi, sv: any): void {
}
}
/** 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.
*
* The target is recorded whenever a distinct last column exists
* (`sv.length >= 3`). It must NOT be gated on the well-known RIGHT_TOP/BOTTOM
* group ids: the vscode/preview/plan presets put their side column in a group
* with a generated id, so gating on those ids would skip the capture and let
* the PREVIOUS task's right target leak in — switching to one of those tasks
* would then snap its side column to whatever width the last task left.
* 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.
*
* `sv.length >= 3` still 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
* and persist that into the env layout. */
* 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): void {
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 rightCap = computeRightMaxPx(layoutWidth(api));
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) {
@@ -150,7 +173,24 @@ function captureRightTarget(api: DockviewApi, sv: any): void {
}
}
if (!sv || sv.length < 3) return;
const liveRight = sv.getViewSize(sv.length - 1);
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));
}