refactor(layout): swap pane width caps for viewport-proportional runtime cap

Replace the hard 350px sidebar / 450px right caps with
max(800, viewport*0.7) so wide displays get a roomy file/terminal
panel and the sidebar can grow with the screen. Pinned minimum is
raised from 50px to 180px so panels don't collapse into unusable slivers.

Caps now live in lib/state/layout-manager/caps.ts; constants module
no longer exports per-column max-px constants.
This commit is contained in:
José Almeida
2026-05-22 00:48:57 +01:00
parent f39e7b06ca
commit e488f23f41
12 changed files with 107 additions and 48 deletions
@@ -11,7 +11,6 @@ import {
CENTER_GROUP,
RIGHT_TOP_GROUP,
RIGHT_BOTTOM_GROUP,
LAYOUT_RIGHT_MAX_PX,
panel,
} from "@/lib/state/layout-manager/constants";
import type { LayoutState } from "@/lib/state/layout-manager/types";
@@ -46,7 +45,6 @@ function officeLayout(): LayoutState {
id: "right",
pinned: true,
width: 350,
maxWidth: LAYOUT_RIGHT_MAX_PX,
groups: [
{ id: RIGHT_TOP_GROUP, panels: [panel("files"), panel("changes")] },
{ id: RIGHT_BOTTOM_GROUP, panels: [panel("terminal-default")] },
@@ -3,10 +3,10 @@ import {
SIDEBAR_LOCK,
SIDEBAR_GROUP,
CENTER_GROUP,
LAYOUT_SIDEBAR_MAX_PX,
LAYOUT_RIGHT_MAX_PX,
RIGHT_TOP_GROUP,
RIGHT_BOTTOM_GROUP,
computePinnedMaxPx,
LAYOUT_PINNED_MIN_PX,
resolveGroupIds,
} from "./layout-manager";
import type { LayoutGroupIds } from "./layout-manager";
@@ -16,11 +16,12 @@ export { getRootSplitview } from "./layout-manager";
/** After fromJSON() restores a session layout, apply fixups and return group IDs. */
export function applyLayoutFixups(api: DockviewApi): LayoutGroupIds {
const cap = computePinnedMaxPx();
const sb = api.getPanel("sidebar");
if (sb) {
sb.group.locked = SIDEBAR_LOCK;
sb.group.header.hidden = false;
sb.group.api.setConstraints({ maximumWidth: LAYOUT_SIDEBAR_MAX_PX });
sb.group.api.setConstraints({ maximumWidth: cap, minimumWidth: LAYOUT_PINNED_MIN_PX });
}
const oldChanges = api.getPanel("diff-files");
@@ -34,7 +35,7 @@ export function applyLayoutFixups(api: DockviewApi): LayoutGroupIds {
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: LAYOUT_RIGHT_MAX_PX });
group.api.setConstraints({ maximumWidth: cap, minimumWidth: LAYOUT_PINNED_MIN_PX });
}
}
+2 -2
View File
@@ -53,8 +53,8 @@ export type { BuiltInPreset } from "./layout-manager";
export {
LAYOUT_SIDEBAR_RATIO,
LAYOUT_RIGHT_RATIO,
LAYOUT_SIDEBAR_MAX_PX,
LAYOUT_RIGHT_MAX_PX,
computePinnedMaxPx,
LAYOUT_PINNED_MIN_PX,
} from "./layout-manager";
export { applyLayoutFixups } from "./dockview-layout-builders";
+17 -6
View File
@@ -8,8 +8,8 @@ import {
RIGHT_TOP_GROUP,
RIGHT_BOTTOM_GROUP,
TERMINAL_DEFAULT_ID,
LAYOUT_SIDEBAR_MAX_PX,
} from "./constants";
import { computePinnedMaxPx, LAYOUT_PINNED_MIN_PX } from "./caps";
export type LayoutGroupIds = {
centerGroupId: string;
@@ -71,23 +71,34 @@ export function applyLayout(
api.fromJSON(serialized);
// Lock sidebar group and enforce max-width constraint.
// Lock sidebar group and enforce max/min-width constraints.
// Constraints are not serialized with layouts, so we must reapply after fromJSON.
const runtimeCap = computePinnedMaxPx();
const sb = api.getPanel("sidebar");
if (sb) {
sb.group.locked = SIDEBAR_LOCK;
sb.group.header.hidden = false;
sb.group.api.setConstraints({ maximumWidth: LAYOUT_SIDEBAR_MAX_PX });
sb.group.api.setConstraints({
maximumWidth: runtimeCap,
minimumWidth: LAYOUT_PINNED_MIN_PX,
});
}
// Enforce max-width on other pinned columns (e.g. right panel group).
// Enforce constraints on other pinned columns (e.g. right panel group).
// Column.maxWidth (when set) wins over the runtime cap so presets like
// compact can pin the sidebar tight; otherwise we inherit the viewport-
// proportional cap so wide screens get a roomy right panel.
for (const col of state.columns) {
if (col.id === "sidebar" || !col.pinned || !col.maxWidth) continue;
if (col.id === "sidebar" || !col.pinned) continue;
const cap = col.maxWidth ?? runtimeCap;
for (const group of col.groups) {
for (const p of group.panels) {
const pnl = api.getPanel(p.id);
if (pnl) {
pnl.group.api.setConstraints({ maximumWidth: col.maxWidth });
pnl.group.api.setConstraints({
maximumWidth: cap,
minimumWidth: LAYOUT_PINNED_MIN_PX,
});
break;
}
}
@@ -0,0 +1,34 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { computePinnedMaxPx, LAYOUT_PINNED_MIN_PX } from "./caps";
describe("computePinnedMaxPx", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("floors at 800px on narrow viewports", () => {
expect(computePinnedMaxPx(1024)).toBe(800);
expect(computePinnedMaxPx(100)).toBe(800);
});
it("scales to viewport * 0.7 above the floor", () => {
expect(computePinnedMaxPx(2000)).toBe(1400);
expect(computePinnedMaxPx(3000)).toBe(2100);
});
it("reads window.innerWidth when no argument passed", () => {
vi.stubGlobal("window", { innerWidth: 1800 } as Window);
expect(computePinnedMaxPx()).toBe(Math.round(1800 * 0.7));
});
it("uses 1440 fallback when window is undefined", () => {
vi.stubGlobal("window", undefined);
expect(computePinnedMaxPx()).toBe(Math.round(1440 * 0.7));
});
});
describe("LAYOUT_PINNED_MIN_PX", () => {
it("keeps pinned panels usable", () => {
expect(LAYOUT_PINNED_MIN_PX).toBeGreaterThanOrEqual(150);
});
});
+27
View File
@@ -0,0 +1,27 @@
/**
* Runtime caps for pinned columns (sidebar / right).
*
* The previous hard caps (350 sidebar, 450 right) were too strict on wide
* displays — users wanted to drag the right panel out to half the screen for
* file review or terminal work. Cap scales with viewport so wider screens get
* more room, while small screens still keep the center column usable.
*/
const MIN_CAP_PX = 800;
const VIEWPORT_RATIO = 0.7;
const FALLBACK_VIEWPORT = 1440;
/**
* Compute the maximum pixel width for a pinned column at the current viewport.
* Returns max(800, viewportWidth * 0.7). Falls back to 1440 when called in an
* SSR/non-browser context.
*/
export function computePinnedMaxPx(viewportWidth?: number): number {
const vw =
viewportWidth ?? (typeof window !== "undefined" ? window.innerWidth : FALLBACK_VIEWPORT);
return Math.max(MIN_CAP_PX, Math.round(vw * VIEWPORT_RATIO));
}
/** Minimum pixel width for any pinned column. Below this the panel becomes
* unusable (icons clipped, scrollbars stacked). */
export const LAYOUT_PINNED_MIN_PX = 180;
@@ -1,10 +1,9 @@
import type { LayoutPanel } from "./types";
// Layout sizing constants (single source of truth)
// Layout sizing constants (single source of truth).
// Pinned column max width is computed at runtime — see ./caps.ts.
export const LAYOUT_SIDEBAR_RATIO = 2.5 / 10;
export const LAYOUT_RIGHT_RATIO = 2.5 / 10;
export const LAYOUT_SIDEBAR_MAX_PX = 350;
export const LAYOUT_RIGHT_MAX_PX = 450;
// Well-known group/panel IDs
export const SIDEBAR_GROUP = "group-sidebar";
+3 -2
View File
@@ -15,8 +15,6 @@ export type {
export {
LAYOUT_SIDEBAR_RATIO,
LAYOUT_RIGHT_RATIO,
LAYOUT_SIDEBAR_MAX_PX,
LAYOUT_RIGHT_MAX_PX,
SIDEBAR_GROUP,
CENTER_GROUP,
RIGHT_TOP_GROUP,
@@ -28,6 +26,9 @@ export {
panel,
} from "./constants";
// Runtime caps for pinned columns
export { computePinnedMaxPx, LAYOUT_PINNED_MIN_PX } from "./caps";
// Presets
export {
defaultLayout,
@@ -1,19 +1,22 @@
import { describe, expect, it } from "vitest";
import { compactLayout, defaultLayout, getPresetSidebarColumn } from "./presets";
import { computePinnedMaxPx } from "./caps";
describe("layout presets", () => {
it("keeps the compact workbench on Dockview while prioritizing the center panel", () => {
const compact = compactLayout();
const compactSidebar = compact.columns.find((column) => column.id === "sidebar");
const defaultSidebarMaxWidth =
// Default sidebar inherits the runtime cap (no per-column maxWidth);
// compact pins itself tighter.
const defaultSidebarCap =
defaultLayout().columns.find((column) => column.id === "sidebar")?.maxWidth ??
Number.POSITIVE_INFINITY;
computePinnedMaxPx();
expect(compact.columns.map((column) => column.id)).toEqual(["sidebar", "center"]);
const compactSidebarWidth = compactSidebar?.width ?? Number.POSITIVE_INFINITY;
const compactSidebarMaxWidth = compactSidebar?.maxWidth ?? Number.POSITIVE_INFINITY;
expect(compactSidebarWidth).toBeLessThan(defaultSidebarMaxWidth);
expect(compactSidebarMaxWidth).toBeLessThan(defaultSidebarMaxWidth);
expect(compactSidebarWidth).toBeLessThan(defaultSidebarCap);
expect(compactSidebarMaxWidth).toBeLessThan(defaultSidebarCap);
expect(compact.columns.find((column) => column.id === "center")?.groups[0].panels[0].id).toBe(
"chat",
);
+2 -8
View File
@@ -1,7 +1,5 @@
import type { LayoutColumn, LayoutState } from "./types";
import {
LAYOUT_SIDEBAR_MAX_PX,
LAYOUT_RIGHT_MAX_PX,
SIDEBAR_GROUP,
CENTER_GROUP,
RIGHT_TOP_GROUP,
@@ -10,6 +8,8 @@ import {
} from "./constants";
const COMPACT_SIDEBAR_WIDTH = 220;
// Compact preset intentionally caps the sidebar tight (small toolbar look),
// so it overrides the runtime cap rather than inheriting it.
const COMPACT_SIDEBAR_MAX_PX = 260;
export function defaultLayout(): LayoutState {
@@ -18,7 +18,6 @@ export function defaultLayout(): LayoutState {
{
id: "sidebar",
pinned: true,
maxWidth: LAYOUT_SIDEBAR_MAX_PX,
groups: [{ id: SIDEBAR_GROUP, panels: [panel("sidebar")] }],
},
{
@@ -29,7 +28,6 @@ export function defaultLayout(): LayoutState {
id: "right",
pinned: true,
width: 350,
maxWidth: LAYOUT_RIGHT_MAX_PX,
groups: [
{ id: RIGHT_TOP_GROUP, panels: [panel("files"), panel("changes")] },
{ id: RIGHT_BOTTOM_GROUP, panels: [panel("terminal-default")] },
@@ -68,7 +66,6 @@ export function planLayout(): LayoutState {
{
id: "sidebar",
pinned: true,
maxWidth: LAYOUT_SIDEBAR_MAX_PX,
groups: [{ id: SIDEBAR_GROUP, panels: [panel("sidebar")] }],
},
{
@@ -89,7 +86,6 @@ export function previewLayout(): LayoutState {
{
id: "sidebar",
pinned: true,
maxWidth: LAYOUT_SIDEBAR_MAX_PX,
groups: [{ id: SIDEBAR_GROUP, panels: [panel("sidebar")] }],
},
{
@@ -110,7 +106,6 @@ export function vscodeLayout(): LayoutState {
{
id: "sidebar",
pinned: true,
maxWidth: LAYOUT_SIDEBAR_MAX_PX,
groups: [{ id: SIDEBAR_GROUP, panels: [panel("sidebar")] }],
},
{
@@ -119,7 +114,6 @@ export function vscodeLayout(): LayoutState {
},
{
id: "right",
maxWidth: LAYOUT_RIGHT_MAX_PX,
groups: [{ panels: [panel("vscode")] }],
},
],
@@ -1,13 +1,7 @@
import type { DockviewApi, SerializedDockview } from "dockview-react";
import type { LayoutState, LayoutColumn, LayoutGroup, LayoutPanel, LayoutNode } from "./types";
import { computeColumnWidths, computeGroupHeights } from "./sizing";
import {
SIDEBAR_LOCK,
KNOWN_PANEL_IDS,
STRUCTURAL_COMPONENTS,
LAYOUT_SIDEBAR_MAX_PX,
LAYOUT_RIGHT_MAX_PX,
} from "./constants";
import { SIDEBAR_LOCK, KNOWN_PANEL_IDS, STRUCTURAL_COMPONENTS } from "./constants";
// Dockview serialized grid node types (internal format)
type SerializedLeafNode = {
@@ -287,12 +281,7 @@ export function fromDockviewApi(api: DockviewApi): LayoutState {
columns.push({
id: columnId,
...(isPinned
? {
pinned: true,
maxWidth: columnId === "right" ? LAYOUT_RIGHT_MAX_PX : LAYOUT_SIDEBAR_MAX_PX,
}
: {}),
...(isPinned ? { pinned: true } : {}),
width,
groups: flatGroups,
// Only store tree for columns with nested structure (branches)
+6 -4
View File
@@ -1,23 +1,25 @@
import type { LayoutColumn, LayoutGroup } from "./types";
import { LAYOUT_SIDEBAR_RATIO } from "./constants";
import { computePinnedMaxPx, LAYOUT_PINNED_MIN_PX } from "./caps";
/**
* Get the effective pinned width for a column,
* considering user overrides, maxWidth cap, and ratio-based default.
*
* The default cap is viewport-proportional (see ./caps.ts) — column.maxWidth
* still wins when explicitly set (e.g. compact preset narrowing the sidebar).
*/
export function getPinnedWidth(
column: LayoutColumn,
totalWidth: number,
override?: number,
): number {
const max = column.maxWidth ?? computePinnedMaxPx();
const min = column.minWidth ?? LAYOUT_PINNED_MIN_PX;
if (override !== undefined) {
const max = column.maxWidth ?? Infinity;
const min = column.minWidth ?? 50;
return Math.max(min, Math.min(override, max));
}
const ratioWidth = Math.round(totalWidth * LAYOUT_SIDEBAR_RATIO);
const max = column.maxWidth ?? Infinity;
const min = column.minWidth ?? 50;
return Math.max(min, Math.min(ratioWidth, max));
}