fix(web): stop right pane width from drifting across tasks (#1201)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user