fix(web): make left sidebar width global + steady monitor-switch settling (#1140)
This commit is contained in:
@@ -11,7 +11,7 @@ import {
|
||||
RIGHT_BOTTOM_GROUP,
|
||||
setPinnedTarget,
|
||||
} from "@/lib/state/layout-manager";
|
||||
import { setEnvLayout } from "@/lib/local-storage";
|
||||
import { setEnvLayout, setGlobalSidebarWidth } from "@/lib/local-storage";
|
||||
import { panelPortalManager } from "@/lib/layout/panel-portal-manager";
|
||||
import { stopVscode } from "@/lib/api/domains/vscode-api";
|
||||
import { parkUserShell, stopUserShell } from "@/lib/api/domains/user-shell-api";
|
||||
@@ -123,7 +123,15 @@ export function setupSashDragCapToggle(api: DockviewReadyEvent["api"]): () => vo
|
||||
const sv = getRootSplitview(api);
|
||||
if (!sv) return;
|
||||
const store = useDockviewStore.getState();
|
||||
if (store.sidebarVisible) setPinnedTarget("sidebar", sv.getViewSize(0));
|
||||
if (store.sidebarVisible) {
|
||||
// A genuine drag is the ONLY writer of the global sidebar width pref.
|
||||
// Persist it globally and mirror into the store's pinnedWidths so the
|
||||
// override path (resolvePresetPinnedWidths / classifyColumns) agrees.
|
||||
const sidebarW = sv.getViewSize(0);
|
||||
setPinnedTarget("sidebar", sidebarW);
|
||||
setGlobalSidebarWidth(sidebarW);
|
||||
store.setPinnedWidth("sidebar", sidebarW);
|
||||
}
|
||||
if (store.rightPanelsVisible) setPinnedTarget("right", sv.getViewSize(sv.length - 1));
|
||||
if (IS_DEBUG) {
|
||||
debugWidths(`sash-drag-end ${formatWidthsSnapshot(snapshotColumnWidths(api))}`);
|
||||
@@ -152,18 +160,12 @@ function trackPinnedWidths(api: DockviewReadyEvent["api"]): void {
|
||||
const sv = getRootSplitview(api);
|
||||
if (!sv || sv.length < 2) return;
|
||||
try {
|
||||
// Sidebar is grid index 0 *only when sidebar is visible*. Without the
|
||||
// visibility guard, hiding the sidebar makes index 0 the center column,
|
||||
// and we'd persist the center width as the sidebar's preferred width.
|
||||
if (store.sidebarVisible) {
|
||||
const sidebarW = sv.getViewSize(0);
|
||||
if (sidebarW > 50) {
|
||||
const current = store.pinnedWidths.get("sidebar");
|
||||
if (current !== sidebarW) {
|
||||
store.setPinnedWidth("sidebar", sidebarW);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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) {
|
||||
|
||||
@@ -208,6 +208,29 @@ function SavedLayoutItems({
|
||||
));
|
||||
}
|
||||
|
||||
/** The built-in preset menu items. Applying any preset resets widths
|
||||
* (resetWidths=true), which is how a user clears a custom sidebar width. */
|
||||
function BuiltInPresetItems({ onApply }: { onApply: (presetId: BuiltInPreset) => void }) {
|
||||
return BUILT_IN_PRESETS.map((preset) => {
|
||||
const Icon = preset.icon;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={preset.id}
|
||||
data-testid="layout-preset-item"
|
||||
data-preset-id={preset.id}
|
||||
onClick={() => onApply(preset.id)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Icon className="h-4 w-4 mr-2 shrink-0" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-xs font-medium">{preset.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate">{preset.description}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function LayoutPresetSelector() {
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
@@ -261,7 +284,12 @@ export function LayoutPresetSelector() {
|
||||
<Tooltip open={tooltipOpen} onOpenChange={handleTooltipOpenChange}>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="outline" className="cursor-pointer px-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="cursor-pointer px-2"
|
||||
data-testid="layout-preset-trigger"
|
||||
>
|
||||
<IconLayout className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -271,24 +299,7 @@ export function LayoutPresetSelector() {
|
||||
<DropdownMenuContent align="end" className="w-60">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="text-xs">Presets</DropdownMenuLabel>
|
||||
{BUILT_IN_PRESETS.map((preset) => {
|
||||
const Icon = preset.icon;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={preset.id}
|
||||
onClick={() => applyBuiltInPreset(preset.id, true)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Icon className="h-4 w-4 mr-2 shrink-0" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-xs font-medium">{preset.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{preset.description}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<BuiltInPresetItems onApply={(id) => applyBuiltInPreset(id, true)} />
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
|
||||
@@ -184,6 +184,11 @@ export async function resizeColumnViaSplitview(
|
||||
if (typeof w.__setPinnedTarget__ === "function") {
|
||||
w.__setPinnedTarget__(col, actual);
|
||||
}
|
||||
// The sidebar width is a global pref persisted on a real drag's mouseup.
|
||||
// Mirror that so cross-task / reload assertions see the same width.
|
||||
if (col === "sidebar" && typeof w.__setGlobalSidebarWidth__ === "function") {
|
||||
w.__setGlobalSidebarWidth__(actual);
|
||||
}
|
||||
// `sv.resizeView` alone does not fire `onDidLayoutChange` in dockview
|
||||
// 4.x — without that, the debounced layout-persistence handler never
|
||||
// saves the new width. Call the exposed test helper to force-flush the
|
||||
|
||||
@@ -58,3 +58,84 @@ test.describe("Sidebar resize — viewport-proportional cap", () => {
|
||||
expectApproxWidth(after, before, 20);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Sidebar width — global across tasks", () => {
|
||||
test("a width set in one task applies to a different task", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
// Task A: drag the sidebar to a distinctive width.
|
||||
const sessionA = await openWideTask(testPage, apiClient, seedData, "Sidebar global A");
|
||||
const widthA = await resizeColumnViaSplitview(testPage, "sidebar", 440);
|
||||
|
||||
// Task B is a separate task (separate env). It must open at task A's width
|
||||
// because the left sidebar width is now a single global preference.
|
||||
const taskB = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Sidebar global B",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
await testPage.goto(`/t/${taskB.id}`);
|
||||
await sessionA.waitForLoad();
|
||||
await sessionA.waitForDockviewReady();
|
||||
|
||||
const widthB = await getDockviewGroupWidth(testPage, "sidebar");
|
||||
expectApproxWidth(widthB, widthA, 12);
|
||||
});
|
||||
|
||||
test("clamps to fit a smaller screen, then restores on a wider one", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
// Set a wide sidebar on a 1600px screen.
|
||||
const session = await openWideTask(testPage, apiClient, seedData, "Sidebar clamp");
|
||||
const wide = await resizeColumnViaSplitview(testPage, "sidebar", 520);
|
||||
expect(wide).toBeGreaterThan(450);
|
||||
|
||||
// Shrink the viewport. The sidebar must settle within the smaller screen's
|
||||
// cap (max(350, 30% of 1100) = 350, bounded by viewport-300) — not stay at
|
||||
// 520 and not drift.
|
||||
await testPage.setViewportSize({ width: 1100, height: 800 });
|
||||
await session.waitForDockviewReady();
|
||||
await testPage.waitForTimeout(400);
|
||||
const small = await getDockviewGroupWidth(testPage, "sidebar");
|
||||
const smallCap = Math.max(350, Math.round(1100 * 0.3));
|
||||
expect(small).toBeLessThanOrEqual(smallCap + 12);
|
||||
|
||||
// Back to a wide screen: the raw 520 preference is restored (clamp does not
|
||||
// overwrite stored width).
|
||||
await testPage.setViewportSize({ width: 1600, height: 900 });
|
||||
await session.waitForDockviewReady();
|
||||
await testPage.waitForTimeout(400);
|
||||
const restored = await getDockviewGroupWidth(testPage, "sidebar");
|
||||
expectApproxWidth(restored, wide, 16);
|
||||
});
|
||||
|
||||
test("Default layout preset resets the custom width", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const session = await openWideTask(testPage, apiClient, seedData, "Sidebar reset");
|
||||
const custom = await resizeColumnViaSplitview(testPage, "sidebar", 480);
|
||||
expect(custom).toBeGreaterThan(420);
|
||||
|
||||
// Apply the "Default" layout preset (resetWidths=true).
|
||||
await testPage.getByTestId("layout-preset-trigger").click();
|
||||
await testPage.locator('[data-testid="layout-preset-item"][data-preset-id="default"]').click();
|
||||
await session.waitForDockviewReady();
|
||||
await testPage.waitForTimeout(400);
|
||||
|
||||
// Sidebar returns to the ratio default (1600*0.25=400, clamped to 350).
|
||||
const afterReset = await getDockviewGroupWidth(testPage, "sidebar");
|
||||
expectApproxWidth(afterReset, 350, 16);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
cleanupTaskStorage,
|
||||
clearGlobalSidebarWidth,
|
||||
getGlobalSidebarWidth,
|
||||
markPRClosedBannerDismissed,
|
||||
markPRMergedBannerDismissed,
|
||||
setGlobalSidebarWidth,
|
||||
wasPRClosedBannerDismissed,
|
||||
wasPRMergedBannerDismissed,
|
||||
} from "./local-storage";
|
||||
@@ -66,3 +69,37 @@ describe("PR closed banner dismissal storage", () => {
|
||||
expect(wasPRClosedBannerDismissed("task-b")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("global sidebar width storage", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("returns null when no width has been stored", () => {
|
||||
expect(getGlobalSidebarWidth()).toBeNull();
|
||||
});
|
||||
|
||||
it("persists a width globally and reads it back (rounded)", () => {
|
||||
setGlobalSidebarWidth(412.6);
|
||||
expect(getGlobalSidebarWidth()).toBe(413);
|
||||
});
|
||||
|
||||
it("ignores non-positive or non-finite widths", () => {
|
||||
setGlobalSidebarWidth(0);
|
||||
setGlobalSidebarWidth(-100);
|
||||
setGlobalSidebarWidth(Number.NaN);
|
||||
expect(getGlobalSidebarWidth()).toBeNull();
|
||||
});
|
||||
|
||||
it("clears the stored width", () => {
|
||||
setGlobalSidebarWidth(320);
|
||||
clearGlobalSidebarWidth();
|
||||
expect(getGlobalSidebarWidth()).toBeNull();
|
||||
});
|
||||
|
||||
it("is NOT removed by cleanupTaskStorage (it is global, not task-scoped)", () => {
|
||||
setGlobalSidebarWidth(320);
|
||||
cleanupTaskStorage("task-a", []);
|
||||
expect(getGlobalSidebarWidth()).toBe(320);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -288,6 +288,28 @@ export function setEnvLayout(envId: string, layout: object): void {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dockview global left-sidebar width (localStorage) ---
|
||||
// The LEFT sidebar width is a single GLOBAL preference shared across every
|
||||
// task env (unlike the per-env layout above, which keys widths by envId).
|
||||
// Stores the user's raw, unclamped width — clamping to the current screen
|
||||
// happens at apply time. Written only by a genuine sash drag; read by every
|
||||
// layout build/restore/switch via getPinnedWidth.
|
||||
const DOCKVIEW_GLOBAL_SIDEBAR_WIDTH_KEY = "kandev.dockview.sidebar-width";
|
||||
|
||||
export function getGlobalSidebarWidth(): number | null {
|
||||
const v = getLocalStorage<number | null>(DOCKVIEW_GLOBAL_SIDEBAR_WIDTH_KEY, null);
|
||||
return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : null;
|
||||
}
|
||||
|
||||
export function setGlobalSidebarWidth(width: number): void {
|
||||
if (!Number.isFinite(width) || width <= 0) return;
|
||||
setLocalStorage(DOCKVIEW_GLOBAL_SIDEBAR_WIDTH_KEY, Math.round(width));
|
||||
}
|
||||
|
||||
export function clearGlobalSidebarWidth(): void {
|
||||
removeLocalStorage(DOCKVIEW_GLOBAL_SIDEBAR_WIDTH_KEY);
|
||||
}
|
||||
|
||||
// --- Dockview per-env maximize state (sessionStorage) ---
|
||||
// v2: bumped alongside DOCKVIEW_ENV_LAYOUT_PREFIX. The maximize blob
|
||||
// references the pre-maximize layout, which can carry the same oversized
|
||||
|
||||
@@ -8,6 +8,9 @@ vi.mock("@/lib/local-storage", () => ({
|
||||
getEnvMaximizeState: vi.fn(() => null),
|
||||
setEnvMaximizeState: vi.fn(),
|
||||
removeEnvMaximizeState: vi.fn(),
|
||||
getGlobalSidebarWidth: vi.fn(() => null),
|
||||
setGlobalSidebarWidth: vi.fn(),
|
||||
clearGlobalSidebarWidth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/layout/panel-portal-manager", () => ({
|
||||
|
||||
@@ -30,7 +30,10 @@ vi.mock("./layout-manager", () => {
|
||||
savedLayoutMatchesLive: vi.fn(() => false),
|
||||
layoutStructuresMatch: vi.fn(() => true),
|
||||
getRootSplitview: vi.fn(),
|
||||
getPinnedWidth: vi.fn(() => 350),
|
||||
// Distinct per-column defaults so we can prove the sidebar branch ignores
|
||||
// saved per-env sizes (it routes through getPinnedWidth, which honors the
|
||||
// global pref) while the right branch keeps using saved sizes.
|
||||
getPinnedWidth: vi.fn((col: { id: string }) => (col.id === "sidebar" ? 300 : 350)),
|
||||
// Used by applyPinnedColumnSizes and (indirectly) by the
|
||||
// formatWidthsSnapshot debug log it now emits.
|
||||
setPinnedTarget: vi.fn(),
|
||||
@@ -87,15 +90,16 @@ describe("performEnvSwitch — pinned column resize after fast-path", () => {
|
||||
|
||||
performEnvSwitch(makeParams());
|
||||
|
||||
expect(resizeView).toHaveBeenCalledWith(0, 350);
|
||||
expect(resizeView).toHaveBeenCalledWith(2, 350);
|
||||
expect(resizeView).toHaveBeenCalledWith(0, 300); // sidebar default (global)
|
||||
expect(resizeView).toHaveBeenCalledWith(2, 350); // right default
|
||||
// center column is at index 1 and is not pinned — must not be resized.
|
||||
expect(resizeView).not.toHaveBeenCalledWith(1, expect.anything());
|
||||
});
|
||||
|
||||
it("uses the saved layout's per-column sizes when present", () => {
|
||||
// When a saved layout exists for the incoming env, its serialized
|
||||
// grid.root.data[i].size wins over the ratio-based default.
|
||||
it("ignores the saved sidebar size and uses the global width instead", () => {
|
||||
// The sidebar is a GLOBAL pref shared across tasks, so the incoming env's
|
||||
// saved sidebar size (420 below) must NOT win — it routes through
|
||||
// getPinnedWidth (mocked to 300 for the sidebar).
|
||||
const savedLayout = {
|
||||
grid: {
|
||||
root: {
|
||||
@@ -131,15 +135,12 @@ describe("performEnvSwitch — pinned column resize after fast-path", () => {
|
||||
|
||||
performEnvSwitch(makeParams());
|
||||
|
||||
expect(resizeView).toHaveBeenCalledWith(0, 420);
|
||||
// Only sidebar/right are resized; with 2 sv slots and 3 columns, the
|
||||
// loop bound is min(3, 2) = 2, so center (index 1) is the last iterated
|
||||
// but since columns[1] = "center" (not pinned), no resize fires. Right
|
||||
// is at index 2 in the column list which exceeds sv.length so isn't
|
||||
// hit; this scenario covers the saved-sidebar restoration path only.
|
||||
// Saved sidebar size (420) is ignored; the global default (300) wins.
|
||||
expect(resizeView).toHaveBeenCalledWith(0, 300);
|
||||
expect(resizeView).not.toHaveBeenCalledWith(0, 420);
|
||||
});
|
||||
|
||||
it("applies saved widths to both sidebar AND right when sv.length matches", () => {
|
||||
it("uses the saved size for RIGHT but the global width for the sidebar", () => {
|
||||
// Cover the 3-column saved-layout path so the right column's
|
||||
// saved-size branch is exercised. The previous test exits the loop
|
||||
// before reaching the right column because sv.length = 2.
|
||||
@@ -181,9 +182,10 @@ describe("performEnvSwitch — pinned column resize after fast-path", () => {
|
||||
|
||||
performEnvSwitch(makeParams());
|
||||
|
||||
// Both pinned columns get their saved size; center (index 1) is not
|
||||
// Sidebar ignores its saved size (420) → global default 300; the right
|
||||
// column still restores its saved size (420). Center (index 1) is not
|
||||
// pinned and is skipped.
|
||||
expect(resizeView).toHaveBeenCalledWith(0, 420);
|
||||
expect(resizeView).toHaveBeenCalledWith(0, 300);
|
||||
expect(resizeView).toHaveBeenCalledWith(2, 420);
|
||||
expect(resizeView).not.toHaveBeenCalledWith(1, expect.anything());
|
||||
});
|
||||
|
||||
@@ -386,7 +386,12 @@ function applyPinnedColumnSizes(
|
||||
for (let i = 0; i < liveLayout.columns.length && i < sv.length; i++) {
|
||||
const col = liveLayout.columns[i];
|
||||
if (col.id !== "sidebar" && col.id !== "right") continue;
|
||||
const target = targetPinnedWidth(col, i, savedSizes, totalWidth);
|
||||
// Sidebar uses the GLOBAL width pref (single source of truth across tasks),
|
||||
// so it ignores this env's saved size. Right keeps per-env saved sizes.
|
||||
const target =
|
||||
col.id === "sidebar"
|
||||
? getPinnedWidth(col, totalWidth, undefined)
|
||||
: targetPinnedWidth(col, i, savedSizes, totalWidth);
|
||||
if (typeof target !== "number" || target <= 0) continue;
|
||||
try {
|
||||
sv.resizeView(i, target);
|
||||
|
||||
@@ -15,6 +15,10 @@ vi.mock("@/lib/debug/log", () => ({
|
||||
IS_DEBUG: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/local-storage", () => ({
|
||||
getGlobalSidebarWidth: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("./layout-manager", () => ({
|
||||
SIDEBAR_LOCK: "no-drop-target",
|
||||
SIDEBAR_GROUP: "group-sidebar",
|
||||
@@ -24,6 +28,7 @@ vi.mock("./layout-manager", () => ({
|
||||
LAYOUT_PINNED_MIN_PX: 180,
|
||||
computeSidebarMaxPx: vi.fn(() => SIDEBAR_CAP),
|
||||
computeRightMaxPx: vi.fn(() => RIGHT_CAP),
|
||||
getPinnedWidth: vi.fn(() => 350),
|
||||
getRootSplitview: vi.fn(),
|
||||
resolveGroupIds: vi.fn(() => ({
|
||||
sidebarGroupId: "group-sidebar",
|
||||
@@ -35,7 +40,15 @@ vi.mock("./layout-manager", () => ({
|
||||
}));
|
||||
|
||||
import { applyLayoutFixups } from "./dockview-layout-builders";
|
||||
import { getRootSplitview, setPinnedTarget, RIGHT_TOP_GROUP } from "./layout-manager";
|
||||
import {
|
||||
getRootSplitview,
|
||||
setPinnedTarget,
|
||||
computeSidebarMaxPx,
|
||||
computeRightMaxPx,
|
||||
getPinnedWidth,
|
||||
RIGHT_TOP_GROUP,
|
||||
} from "./layout-manager";
|
||||
import { getGlobalSidebarWidth } from "@/lib/local-storage";
|
||||
|
||||
const SIDEBAR_GROUP = "group-sidebar";
|
||||
const CENTER_GROUP = "group-center";
|
||||
@@ -65,6 +78,8 @@ function makeApi(groupIds: string[]): DockviewApi {
|
||||
describe("applyLayoutFixups — pinned target capture", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getGlobalSidebarWidth).mockReturnValue(null);
|
||||
vi.mocked(getPinnedWidth).mockReturnValue(350);
|
||||
});
|
||||
|
||||
it("does NOT record a right target in the 2-column fallback (center is last)", () => {
|
||||
@@ -80,9 +95,20 @@ describe("applyLayoutFixups — pinned target capture", () => {
|
||||
expect(setPinnedTarget).not.toHaveBeenCalledWith("right", expect.anything());
|
||||
});
|
||||
|
||||
it("clamps an over-cap sidebar target down to the cap", () => {
|
||||
// sidebar live (474) exceeds the cap (441); the constraint pins it at the
|
||||
// cap, so the target must be clamped or enforcePinnedTargets spins forever.
|
||||
it("uses the default sidebar target instead of an env-saved live width when no global pref exists", () => {
|
||||
// Slow fromJSON restore can bring back an env-specific sidebar width. With
|
||||
// sidebar now a global pref, no pref means "use the default", not the
|
||||
// env-saved live width.
|
||||
mockSplitview([420, 1050]);
|
||||
const api = makeApi([SIDEBAR_GROUP, CENTER_GROUP]);
|
||||
|
||||
applyLayoutFixups(api);
|
||||
|
||||
expect(setPinnedTarget).toHaveBeenCalledWith("sidebar", 350);
|
||||
});
|
||||
|
||||
it("clamps a global sidebar pref down to the cap", () => {
|
||||
vi.mocked(getGlobalSidebarWidth).mockReturnValue(900);
|
||||
mockSplitview([474, 996]);
|
||||
const api = makeApi([SIDEBAR_GROUP, CENTER_GROUP]);
|
||||
|
||||
@@ -101,6 +127,19 @@ describe("applyLayoutFixups — pinned target capture", () => {
|
||||
expect(setPinnedTarget).toHaveBeenCalledWith("right", 400);
|
||||
});
|
||||
|
||||
it("derives the caps from api.width, not the window.innerWidth fallback", () => {
|
||||
// Regression: caps computed without the measured width fall back to
|
||||
// window.innerWidth, which can be transiently stale and clamp the captured
|
||||
// target too narrow. They must be derived from api.width (1470 here).
|
||||
mockSplitview([350, 720, 400]);
|
||||
const api = makeApi([SIDEBAR_GROUP, CENTER_GROUP, RIGHT_TOP_GROUP]);
|
||||
|
||||
applyLayoutFixups(api);
|
||||
|
||||
expect(computeSidebarMaxPx).toHaveBeenCalledWith(1470);
|
||||
expect(computeRightMaxPx).toHaveBeenCalledWith(1470);
|
||||
});
|
||||
|
||||
it("records the side-column target for a 3-column preset without RIGHT_TOP_GROUP", () => {
|
||||
// Regression: vscode/preview/plan presets put their side column in a group
|
||||
// with a generated id (not RIGHT_TOP_GROUP). The target must still be
|
||||
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
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, IS_DEBUG } from "@/lib/debug/log";
|
||||
|
||||
// Re-export for consumers that import from this module
|
||||
@@ -20,6 +22,13 @@ 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.
|
||||
@@ -63,6 +72,20 @@ export function applyLayoutFixups(api: DockviewApi): LayoutGroupIds {
|
||||
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. */
|
||||
@@ -70,13 +93,31 @@ export function applyLayoutFixups(api: DockviewApi): LayoutGroupIds {
|
||||
function captureSidebarTarget(api: DockviewApi, sv: any): void {
|
||||
const sb = api.getPanel("sidebar");
|
||||
if (!sb) return;
|
||||
const sidebarCap = computeSidebarMaxPx();
|
||||
// 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 });
|
||||
const live = sv?.getViewSize?.(0) ?? sb.group.width;
|
||||
if (typeof live === "number" && live > 0) {
|
||||
setPinnedTarget("sidebar", Math.min(live, sidebarCap));
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +140,9 @@ function captureSidebarTarget(api: DockviewApi, sv: any): void {
|
||||
function captureRightTarget(api: DockviewApi, sv: any): 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.
|
||||
const rightCap = computeRightMaxPx();
|
||||
// Use the measured grid width, not the window.innerWidth fallback (see
|
||||
// captureSidebarTarget).
|
||||
const rightCap = computeRightMaxPx(layoutWidth(api));
|
||||
for (const gid of [RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP]) {
|
||||
const group = api.groups.find((g) => g.id === gid);
|
||||
if (group) {
|
||||
@@ -123,7 +166,8 @@ function logFixupsCapture(api: DockviewApi, sv: any): void {
|
||||
// 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 sidebarCap = computeSidebarMaxPx();
|
||||
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
|
||||
@@ -134,7 +178,7 @@ function logFixupsCapture(api: DockviewApi, sv: any): void {
|
||||
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())} liveRight=${r(liveRight)} ` +
|
||||
`rightCap=${Math.round(computeRightMaxPx(w))} liveRight=${r(liveRight)} ` +
|
||||
`sidebarOverCap=${typeof liveSidebar === "number" && liveSidebar > sidebarCap + 1}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
* `sv.resizeView`.
|
||||
*/
|
||||
import type { DockviewApi } from "dockview-react";
|
||||
import { getRootSplitview, getPinnedTarget } from "./layout-manager";
|
||||
import { getRootSplitview, getPinnedTarget, computeSidebarMaxPx } from "./layout-manager";
|
||||
import { createDebugLogger, IS_DEBUG } from "@/lib/debug/log";
|
||||
|
||||
const debugWidths = createDebugLogger("dockview:widths");
|
||||
@@ -97,7 +97,18 @@ export function enforcePinnedTargets(api: DockviewApi, ctx: EnforcePinnedTargets
|
||||
if (!sv || sv.length < 2) return;
|
||||
enforcing = true;
|
||||
try {
|
||||
if (ctx.sidebarVisible) restoreColumnToTarget(sv, 0, getPinnedTarget("sidebar"));
|
||||
if (ctx.sidebarVisible) {
|
||||
// Clamp the (global, raw) sidebar target to the current screen so a width
|
||||
// set on a wide monitor fits a narrower one. Storage keeps the raw value,
|
||||
// so returning to the wide monitor restores it.
|
||||
const raw = getPinnedTarget("sidebar");
|
||||
// Guard against an unmeasured container (api.width === 0): passing 0 to
|
||||
// computeSidebarMaxPx bypasses its window.innerWidth fallback and clamps
|
||||
// the target down to LAYOUT_PINNED_MIN_PX. undefined lets it fall back.
|
||||
const safeWidth = api.width > 0 ? api.width : undefined;
|
||||
const clamped = raw === undefined ? undefined : Math.min(raw, computeSidebarMaxPx(safeWidth));
|
||||
restoreColumnToTarget(sv, 0, clamped);
|
||||
}
|
||||
if (ctx.rightPanelsVisible) {
|
||||
restoreColumnToTarget(sv, sv.length - 1, getPinnedTarget("right"));
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
resolvePresetPinnedWidths,
|
||||
collectPinnedWidthUpdates,
|
||||
} from "./dockview-store";
|
||||
import { getGlobalSidebarWidth, setGlobalSidebarWidth } from "@/lib/local-storage";
|
||||
import { getPinnedTarget, setPinnedTarget, clearPinnedTarget } from "./layout-manager";
|
||||
|
||||
type ActivePanelEvent = { id: string };
|
||||
type CapturedHandlers = {
|
||||
@@ -122,7 +124,7 @@ describe("resolvePresetPinnedWidths", () => {
|
||||
expect(result.has("center")).toBe(false); // not pinned
|
||||
});
|
||||
|
||||
it("keeps live widths for columns in the target layout when not resetting", () => {
|
||||
it("keeps right live widths for columns in the target layout when not resetting", () => {
|
||||
const live = new Map([
|
||||
["sidebar", 519],
|
||||
["right", 900],
|
||||
@@ -130,10 +132,24 @@ describe("resolvePresetPinnedWidths", () => {
|
||||
|
||||
const result = resolvePresetPinnedWidths(live, cols, 1600, false);
|
||||
|
||||
expect(result.get("sidebar")).toBe(519);
|
||||
expect(result.has("sidebar")).toBe(false);
|
||||
expect(result.get("right")).toBe(900);
|
||||
});
|
||||
|
||||
it("does not carry a live sidebar override on non-reset switches", () => {
|
||||
setGlobalSidebarWidth(520);
|
||||
const live = new Map([
|
||||
["sidebar", 350],
|
||||
["right", 900],
|
||||
]);
|
||||
|
||||
const result = resolvePresetPinnedWidths(live, cols, 1600, false);
|
||||
|
||||
expect(result.has("sidebar")).toBe(false);
|
||||
expect(result.get("right")).toBe(900);
|
||||
expect(getGlobalSidebarWidth()).toBe(520);
|
||||
});
|
||||
|
||||
it("drops live overrides for columns absent from the target layout", () => {
|
||||
// e.g. switching to a layout without a "right" column must not leak the
|
||||
// old right width into the new layout.
|
||||
@@ -148,7 +164,7 @@ describe("resolvePresetPinnedWidths", () => {
|
||||
|
||||
const result = resolvePresetPinnedWidths(live, noRight, 1600, false);
|
||||
|
||||
expect(result.get("sidebar")).toBe(300);
|
||||
expect(result.has("sidebar")).toBe(false);
|
||||
expect(result.has("right")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -157,20 +173,48 @@ describe("resolvePresetPinnedWidths", () => {
|
||||
resolvePresetPinnedWidths(live, cols, 1600, false);
|
||||
expect(live.get("sidebar")).toBe(300);
|
||||
});
|
||||
|
||||
describe("Default layout reset clears the global sidebar width", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
clearPinnedTarget("sidebar");
|
||||
});
|
||||
|
||||
it("clears the global pref + runtime target and uses the ratio default", () => {
|
||||
setGlobalSidebarWidth(520);
|
||||
setPinnedTarget("sidebar", 520);
|
||||
|
||||
const result = resolvePresetPinnedWidths(new Map(), cols, 1600, true);
|
||||
|
||||
// pref cleared → ratio default (1600*0.25=400, clamped to sidebar cap 350)
|
||||
expect(result.get("sidebar")).toBe(350);
|
||||
expect(getGlobalSidebarWidth()).toBeNull();
|
||||
expect(getPinnedTarget("sidebar")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT clear the pref/target on a non-reset (programmatic) switch", () => {
|
||||
setGlobalSidebarWidth(520);
|
||||
setPinnedTarget("sidebar", 520);
|
||||
|
||||
resolvePresetPinnedWidths(new Map([["sidebar", 480]]), cols, 1600, false);
|
||||
|
||||
expect(getGlobalSidebarWidth()).toBe(520);
|
||||
expect(getPinnedTarget("sidebar")).toBe(520);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectPinnedWidthUpdates", () => {
|
||||
const size = (i: number) => [350, 560, 560][i]; // sidebar, center, last
|
||||
|
||||
it("tracks sidebar + right when both are visible", () => {
|
||||
it("tracks right but not sidebar when both are visible", () => {
|
||||
const columns = [{ id: "sidebar" }, { id: "center" }, { id: "right" }];
|
||||
|
||||
const updates = collectPinnedWidthUpdates(columns, size, {
|
||||
sidebarVisible: true,
|
||||
rightPanelsVisible: true,
|
||||
});
|
||||
|
||||
expect(updates.get("sidebar")).toBe(350);
|
||||
expect(updates.has("sidebar")).toBe(false);
|
||||
expect(updates.get("right")).toBe(560);
|
||||
});
|
||||
|
||||
@@ -182,19 +226,17 @@ describe("collectPinnedWidthUpdates", () => {
|
||||
const columns = [{ id: "sidebar" }, { id: "center" }, { id: "right" }];
|
||||
|
||||
const updates = collectPinnedWidthUpdates(columns, size, {
|
||||
sidebarVisible: true,
|
||||
rightPanelsVisible: false,
|
||||
});
|
||||
|
||||
expect(updates.has("right")).toBe(false);
|
||||
expect(updates.get("sidebar")).toBe(350);
|
||||
expect(updates.has("sidebar")).toBe(false);
|
||||
});
|
||||
|
||||
it("skips collapsed/transient widths <= 50px", () => {
|
||||
const columns = [{ id: "sidebar" }, { id: "right" }];
|
||||
|
||||
const updates = collectPinnedWidthUpdates(columns, () => 40, {
|
||||
sidebarVisible: true,
|
||||
rightPanelsVisible: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
getEnvMaximizeState,
|
||||
setEnvMaximizeState,
|
||||
removeEnvMaximizeState,
|
||||
clearGlobalSidebarWidth,
|
||||
setGlobalSidebarWidth,
|
||||
} from "@/lib/local-storage";
|
||||
import { setPinnedTarget } from "./layout-manager";
|
||||
import { setPinnedTarget, clearPinnedTarget } from "./layout-manager";
|
||||
import { applyLayoutFixups, focusOrAddPanel } from "./dockview-layout-builders";
|
||||
import {
|
||||
SIDEBAR_GROUP,
|
||||
@@ -217,37 +219,36 @@ function applyDeferredPanelActions(api: DockviewApi, actions: DeferredPanelActio
|
||||
}
|
||||
}
|
||||
|
||||
/** Read live column widths from dockview's splitview and persist them as pinned overrides.
|
||||
* Only syncs widths for columns identified as "sidebar" or "right" to avoid
|
||||
* capturing plan/preview/vscode column widths as stale "right" overrides. */
|
||||
/**
|
||||
* Build the pinnedWidths updates for a width sync, tracking a column only when
|
||||
* it is the VISIBLE default sidebar/right.
|
||||
* Build the pinnedWidths updates for a width sync, tracking only the VISIBLE
|
||||
* default right column.
|
||||
*
|
||||
* In plan/preview/vscode layouts the side column inherits merged files/changes
|
||||
* panels and `fromDockviewApi` mislabels it "right"; storing its width as the
|
||||
* right override would then leak into the default layout when toggling back
|
||||
* (e.g. plan-mode off snapping the right column to the plan column's width).
|
||||
* Mirrors the visibility guards in `trackPinnedWidths`. Widths <= 50px are
|
||||
* treated as transient/collapsed and skipped.
|
||||
*
|
||||
* Sidebar is intentionally excluded: its persisted width is a global pref
|
||||
* written only by explicit sash drag, and syncing live layout-change widths
|
||||
* would overwrite the raw pref with viewport-clamped transient widths.
|
||||
* Widths <= 50px are treated as transient/collapsed and skipped.
|
||||
*/
|
||||
export function collectPinnedWidthUpdates(
|
||||
columns: { id: string }[],
|
||||
getSize: (index: number) => number,
|
||||
visibility: { sidebarVisible: boolean; rightPanelsVisible: boolean },
|
||||
visibility: { rightPanelsVisible: boolean },
|
||||
): Map<string, number> {
|
||||
const updates = new Map<string, number>();
|
||||
columns.forEach((col, i) => {
|
||||
const tracked =
|
||||
(col.id === "sidebar" && visibility.sidebarVisible) ||
|
||||
(col.id === "right" && visibility.rightPanelsVisible);
|
||||
if (!tracked) return;
|
||||
if (col.id !== "right" || !visibility.rightPanelsVisible) return;
|
||||
const w = getSize(i);
|
||||
if (w > 50) updates.set(col.id, w);
|
||||
});
|
||||
return updates;
|
||||
}
|
||||
|
||||
/** Read live column widths from dockview's splitview and persist the visible
|
||||
* right width as a pinned override (see `collectPinnedWidthUpdates`). */
|
||||
function syncPinnedWidthsFromApi(api: DockviewApi, set: StoreSet): void {
|
||||
if (api.hasMaximizedGroup()) return;
|
||||
const sv = getRootSplitview(api);
|
||||
@@ -255,9 +256,8 @@ function syncPinnedWidthsFromApi(api: DockviewApi, set: StoreSet): void {
|
||||
try {
|
||||
const state = fromDockviewApi(api);
|
||||
if (state.columns.length !== sv.length) return;
|
||||
const { sidebarVisible, rightPanelsVisible } = useDockviewStore.getState();
|
||||
const { rightPanelsVisible } = useDockviewStore.getState();
|
||||
const updates = collectPinnedWidthUpdates(state.columns, (i) => sv.getViewSize(i), {
|
||||
sidebarVisible,
|
||||
rightPanelsVisible,
|
||||
});
|
||||
if (updates.size > 0) {
|
||||
@@ -278,7 +278,6 @@ function syncPinnedWidthsFromApi(api: DockviewApi, set: StoreSet): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Capture the live sidebar/right pixel widths into pinnedWidths before a layout rebuild. */
|
||||
/**
|
||||
* Decide which pinned-width overrides to apply when switching to a preset.
|
||||
*
|
||||
@@ -290,9 +289,10 @@ function syncPinnedWidthsFromApi(api: DockviewApi, set: StoreSet): void {
|
||||
* `fromJSON` out at a transient narrower width, and that shrunken size would
|
||||
* be captured as the pinned target and then enforced, leaving the columns
|
||||
* too narrow.
|
||||
* - otherwise (programmatic switch, e.g. plan-mode toggle): keep the live
|
||||
* widths, minus overrides for columns absent in the target layout, so the
|
||||
* user's current sidebar/right widths carry across the switch.
|
||||
* - otherwise (programmatic switch, e.g. plan-mode toggle): keep the live right
|
||||
* width, minus overrides for columns absent in the target layout. Sidebar is
|
||||
* always resolved from the global pref/default instead of an in-memory
|
||||
* override.
|
||||
*/
|
||||
export function resolvePresetPinnedWidths(
|
||||
liveWidths: Map<string, number>,
|
||||
@@ -301,6 +301,11 @@ export function resolvePresetPinnedWidths(
|
||||
resetWidths: boolean,
|
||||
): Map<string, number> {
|
||||
if (resetWidths) {
|
||||
// "Default layout" is the reset gesture: drop any custom global sidebar
|
||||
// width (and its runtime target) so getPinnedWidth returns the fresh
|
||||
// ratio default for the current screen instead of re-reading the pref.
|
||||
clearGlobalSidebarWidth();
|
||||
clearPinnedTarget("sidebar");
|
||||
const defaults = new Map<string, number>();
|
||||
for (const col of columns) {
|
||||
if (col.pinned) defaults.set(col.id, getPinnedWidth(col, totalWidth, undefined));
|
||||
@@ -310,11 +315,12 @@ export function resolvePresetPinnedWidths(
|
||||
const targetColumnIds = new Set(columns.map((c) => c.id));
|
||||
const cleaned = new Map(liveWidths);
|
||||
for (const key of cleaned.keys()) {
|
||||
if (!targetColumnIds.has(key)) cleaned.delete(key);
|
||||
if (key === "sidebar" || !targetColumnIds.has(key)) cleaned.delete(key);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/** Capture the live right pixel width into pinnedWidths before a layout rebuild. */
|
||||
function captureLiveWidths(api: DockviewApi, set: StoreSet): Map<string, number> {
|
||||
if (api.hasMaximizedGroup()) {
|
||||
api.exitMaximizedGroup();
|
||||
@@ -868,12 +874,15 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({
|
||||
type TestWindow = {
|
||||
__dockviewApi__: DockviewApi | null;
|
||||
__setPinnedTarget__?: typeof setPinnedTarget;
|
||||
__setGlobalSidebarWidth__?: typeof setGlobalSidebarWidth;
|
||||
};
|
||||
const w = window as unknown as TestWindow;
|
||||
w.__dockviewApi__ = api;
|
||||
// E2E test helpers: let `resizeColumnViaSplitview` update the target
|
||||
// width after a programmatic resize (mirroring the sash-drag mouseup).
|
||||
// width after a programmatic resize (mirroring the sash-drag mouseup),
|
||||
// including persisting the global sidebar-width pref like a real drag.
|
||||
w.__setPinnedTarget__ = setPinnedTarget;
|
||||
w.__setGlobalSidebarWidth__ = setGlobalSidebarWidth;
|
||||
}
|
||||
if (api) {
|
||||
const resolveFilePath = (panelId: string | undefined): string | null => {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { getPinnedWidth } from "./sizing";
|
||||
import { computeSidebarMaxPx } from "./caps";
|
||||
import type { LayoutColumn } from "./types";
|
||||
import { getGlobalSidebarWidth, setGlobalSidebarWidth } from "@/lib/local-storage";
|
||||
|
||||
const sidebar = (extra: Partial<LayoutColumn> = {}): LayoutColumn =>
|
||||
({ id: "sidebar", pinned: true, groups: [], ...extra }) as LayoutColumn;
|
||||
const right = (extra: Partial<LayoutColumn> = {}): LayoutColumn =>
|
||||
({ id: "right", pinned: true, groups: [], ...extra }) as LayoutColumn;
|
||||
|
||||
const TOTAL = 1600; // computeSidebarMaxPx(1600) = max(350, 480) bounded = 480
|
||||
|
||||
describe("getPinnedWidth — global sidebar width pref", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("uses the stored pref for the sidebar when it fits the screen", () => {
|
||||
setGlobalSidebarWidth(400); // < cap 480
|
||||
expect(getPinnedWidth(sidebar(), TOTAL, undefined)).toBe(400);
|
||||
});
|
||||
|
||||
it("clamps the sidebar pref to the screen cap without mutating storage", () => {
|
||||
const cap = computeSidebarMaxPx(TOTAL);
|
||||
setGlobalSidebarWidth(900); // > cap → clamp to fit
|
||||
expect(getPinnedWidth(sidebar(), TOTAL, undefined)).toBe(cap);
|
||||
// Raw value preserved so a wider monitor restores it.
|
||||
expect(getGlobalSidebarWidth()).toBe(900);
|
||||
});
|
||||
|
||||
it("falls back to the legacy ratio default when no pref is set", () => {
|
||||
// ratio 0.25 * 1600 = 400, clamped to the legacy sidebar initial cap (350).
|
||||
expect(getGlobalSidebarWidth()).toBeNull();
|
||||
expect(getPinnedWidth(sidebar(), TOTAL, undefined)).toBe(350);
|
||||
});
|
||||
|
||||
it("lets an explicit override win over the pref", () => {
|
||||
setGlobalSidebarWidth(400);
|
||||
// maxWidth pins the runtime cap so the override isn't clamped by innerWidth.
|
||||
expect(getPinnedWidth(sidebar({ maxWidth: 600 }), TOTAL, 500)).toBe(500);
|
||||
});
|
||||
|
||||
it("does NOT apply the sidebar pref to the right column", () => {
|
||||
setGlobalSidebarWidth(200);
|
||||
// right: ratio 0.25 * 1600 = 400, clamped to legacy right cap (450) → 400.
|
||||
expect(getPinnedWidth(right(), TOTAL, undefined)).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { LayoutColumn, LayoutGroup } from "./types";
|
||||
import { LAYOUT_INITIAL_RATIO } from "./constants";
|
||||
import { computePinnedMaxPxFor, LAYOUT_PINNED_MIN_PX } from "./caps";
|
||||
import { computePinnedMaxPxFor, computeSidebarMaxPx, LAYOUT_PINNED_MIN_PX } from "./caps";
|
||||
import { getGlobalSidebarWidth } from "@/lib/local-storage";
|
||||
|
||||
// Legacy hard caps used to clamp the *initial* default width. Users can still
|
||||
// drag past these via setConstraints (which uses the larger runtime cap),
|
||||
@@ -27,6 +28,16 @@ export function getPinnedWidth(
|
||||
if (override !== undefined) {
|
||||
return Math.max(min, Math.min(override, runtimeMax));
|
||||
}
|
||||
// No override: the left sidebar honors the persisted GLOBAL width pref so it
|
||||
// opens identically across every task. Clamp-to-fit against the current
|
||||
// screen (the raw value stays in storage, so a wider monitor restores it).
|
||||
if (column.id === "sidebar") {
|
||||
const pref = getGlobalSidebarWidth();
|
||||
if (pref !== null) {
|
||||
const max = column.maxWidth ?? computeSidebarMaxPx(totalWidth);
|
||||
return Math.max(min, Math.min(pref, max));
|
||||
}
|
||||
}
|
||||
const ratioWidth = Math.round(totalWidth * LAYOUT_INITIAL_RATIO);
|
||||
const initialCap =
|
||||
column.maxWidth ??
|
||||
|
||||
Reference in New Issue
Block a user