chore: revert dockview minimize button (#1039) (#1048)

Revert "feat(task): add minimize button to dockview group header (#1039)"

This reverts commit 7771155c8f.

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-05-23 22:23:36 +01:00
committed by GitHub
parent 2c15cf83a9
commit 5a55b06d4d
9 changed files with 16 additions and 665 deletions
-7
View File
@@ -137,13 +137,6 @@
box-shadow: none !important;
}
/* Per-group minimize: hide the content area while keeping the tab strip visible.
The splitview size is shrunk programmatically by useMinimizedGroupSync; this
rule ensures the body is invisible during the transition and at rest. */
.dockview-theme-kandev .dv-groupview[data-kandev-minimized="true"] .dv-content-container {
display: none;
}
.dockview-theme-kandev .dv-content-container .session-panel-wrapper {
border: none !important;
border-radius: 0 !important;
@@ -56,7 +56,6 @@ import {
useCompactDockviewDefault,
useDockviewUnmountCleanup,
} from "./dockview-desktop-layout-hooks";
import { useMinimizedGroupSync } from "./dockview-minimize-sync";
import { TerminalPanel } from "./terminal-panel";
import { BrowserPanel } from "./browser-panel";
import { VscodePanel } from "./vscode-panel";
@@ -499,8 +498,6 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
useEditorKeybinds();
usePlanPanelAutoOpen();
useCompactDockviewDefault(compact);
const apiForMinimize = useDockviewStore((s) => s.api);
useMinimizedGroupSync(apiForMinimize);
useEffect(() => {
envIdRef.current = effectiveEnvId;
@@ -7,7 +7,6 @@ afterEach(() => cleanup());
const TID = {
maximize: "dockview-maximize-btn",
minimize: "dockview-minimize-btn",
splitRight: "dockview-split-right-btn",
splitDown: "dockview-split-down-btn",
close: "dockview-close-group-btn",
@@ -18,8 +17,6 @@ const baseProps = {
isChatGroup: false,
isMaximized: false,
onMaximize: () => {},
isMinimized: false,
onMinimize: () => {},
onSplitRight: () => {},
onSplitDown: () => {},
onCloseGroup: () => {},
@@ -34,47 +31,44 @@ function renderView(width: number, override: Partial<typeof baseProps> = {}) {
}
describe("GroupSplitCloseActionsView", () => {
it("wide width: renders 5 inline buttons and no overflow dropdown", () => {
it("wide width: renders 4 inline buttons and no overflow dropdown", () => {
renderView(600);
expect(screen.queryByTestId(TID.maximize)).not.toBeNull();
expect(screen.queryByTestId(TID.minimize)).not.toBeNull();
expect(screen.queryByTestId(TID.splitRight)).not.toBeNull();
expect(screen.queryByTestId(TID.splitDown)).not.toBeNull();
expect(screen.queryByTestId(TID.close)).not.toBeNull();
expect(screen.queryByTestId(TID.menu)).toBeNull();
});
it("narrow width: keeps Maximize inline, folds Minimize/split/close into dropdown", () => {
it("narrow width: keeps Maximize inline, collapses split/close into dropdown", () => {
renderView(200);
expect(screen.queryByTestId(TID.maximize)).not.toBeNull();
expect(screen.queryByTestId(TID.menu)).not.toBeNull();
expect(screen.queryByTestId(TID.minimize)).toBeNull();
expect(screen.queryByTestId(TID.splitRight)).toBeNull();
expect(screen.queryByTestId(TID.splitDown)).toBeNull();
expect(screen.queryByTestId(TID.close)).toBeNull();
});
it("narrow + chat group: no Close, no Minimize, dropdown still present for splits", () => {
it("narrow + chat group: no Close anywhere, dropdown still present for splits", () => {
renderView(200, { isChatGroup: true });
expect(screen.queryByTestId(TID.maximize)).not.toBeNull();
expect(screen.queryByTestId(TID.menu)).not.toBeNull();
expect(screen.queryByTestId(TID.close)).toBeNull();
expect(screen.queryByTestId(TID.minimize)).toBeNull();
});
it("wide + chat group: Maximize + splits inline, no Close, no Minimize", () => {
it("wide + chat group: 3 inline buttons, no Close button", () => {
renderView(600, { isChatGroup: true });
expect(screen.queryByTestId(TID.maximize)).not.toBeNull();
expect(screen.queryByTestId(TID.splitRight)).not.toBeNull();
expect(screen.queryByTestId(TID.splitDown)).not.toBeNull();
expect(screen.queryByTestId(TID.close)).toBeNull();
expect(screen.queryByTestId(TID.minimize)).toBeNull();
expect(screen.queryByTestId(TID.menu)).toBeNull();
});
it("hysteresis: stays collapsed between 320 and 340 once collapsed", () => {
const { rerender } = renderView(200);
expect(screen.queryByTestId(TID.menu)).not.toBeNull();
// Resize up to 330 — still inside hysteresis band, should remain collapsed
rerender(
<TooltipProvider>
<GroupSplitCloseActionsView width={330} {...baseProps} />
@@ -82,6 +76,7 @@ describe("GroupSplitCloseActionsView", () => {
);
expect(screen.queryByTestId(TID.menu)).not.toBeNull();
expect(screen.queryByTestId(TID.splitRight)).toBeNull();
// Resize up to 360 — past expand threshold, switches back to inline
rerender(
<TooltipProvider>
<GroupSplitCloseActionsView width={360} {...baseProps} />
@@ -108,37 +103,4 @@ describe("GroupSplitCloseActionsView", () => {
screen.getByTestId(TID.maximize).click();
expect(onMaximize).toHaveBeenCalledTimes(2);
});
it("Minimize button fires onMinimize in wide mode", () => {
const onMinimize = vi.fn();
render(
<TooltipProvider>
<GroupSplitCloseActionsView width={600} {...baseProps} onMinimize={onMinimize} />
</TooltipProvider>,
);
screen.getByTestId(TID.minimize).click();
expect(onMinimize).toHaveBeenCalledTimes(1);
});
it("narrow mode renders Minimize as a dropdown menu item (not inline)", () => {
renderView(200);
expect(screen.queryByTestId(TID.minimize)).toBeNull();
expect(screen.queryByTestId(TID.menu)).not.toBeNull();
});
});
describe("GroupSplitCloseActionsView — minimize icon swap", () => {
function minimizeBtnIconClass(): string | null {
return screen.getByTestId(TID.minimize).querySelector("svg")?.getAttribute("class") ?? null;
}
it("renders IconMinus when not minimized", () => {
renderView(600, { isMinimized: false });
expect(minimizeBtnIconClass()).toContain("tabler-icon-minus");
});
it("renders IconWindowMaximize when minimized", () => {
renderView(600, { isMinimized: true });
expect(minimizeBtnIconClass()).toContain("tabler-icon-window-maximize");
});
});
@@ -8,8 +8,6 @@ import {
IconDots,
IconLayoutColumns,
IconLayoutRows,
IconMinus,
IconWindowMaximize,
IconX,
} from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
@@ -64,40 +62,6 @@ type SplitCloseHandlers = {
onCloseGroup: () => void;
};
type MinimizeHandlers = {
isChatGroup: boolean;
isMinimized: boolean;
onMinimize: () => void;
};
function MinimizeButton({
isMinimized,
onMinimize,
}: {
isMinimized: boolean;
onMinimize: () => void;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={ACTION_BTN}
onClick={onMinimize}
data-testid="dockview-minimize-btn"
>
{isMinimized ? (
<IconWindowMaximize className="h-3 w-3" />
) : (
<IconMinus className="h-3 w-3" />
)}
</button>
</TooltipTrigger>
<TooltipContent>{isMinimized ? "Restore" : "Minimize"}</TooltipContent>
</Tooltip>
);
}
function MaximizeButton({
isMaximized,
onMaximize,
@@ -181,12 +145,10 @@ function InlineSplitClose({
function SplitCloseDropdown({
isChatGroup,
isMinimized,
onMinimize,
onSplitRight,
onSplitDown,
onCloseGroup,
}: SplitCloseHandlers & MinimizeHandlers) {
}: SplitCloseHandlers) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -195,20 +157,6 @@ function SplitCloseDropdown({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!isChatGroup && (
<DropdownMenuItem
onClick={onMinimize}
className="cursor-pointer text-xs"
data-testid="dockview-minimize-menuitem"
>
{isMinimized ? (
<IconWindowMaximize className="h-3.5 w-3.5 mr-1.5" />
) : (
<IconMinus className="h-3.5 w-3.5 mr-1.5" />
)}
{isMinimized ? "Restore" : "Minimize"}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={onSplitRight} className="cursor-pointer text-xs">
<IconLayoutColumns className="h-3.5 w-3.5 mr-1.5" />
Split right
@@ -232,38 +180,24 @@ type GroupSplitCloseActionsViewProps = SplitCloseHandlers & {
width: number;
isMaximized: boolean;
onMaximize: () => void;
isMinimized: boolean;
onMinimize: () => void;
};
/** Presentational view: maximize stays inline; split/close collapse into a dropdown when narrow.
* Minimize sits between Maximize and the split/close cluster in wide mode and folds into the
* dropdown as its first item in narrow mode. Chat group never sees Minimize (same exclusion as Close). */
/** Presentational view: maximize stays inline; split/close collapse into a dropdown when narrow. */
export function GroupSplitCloseActionsView({
width,
isChatGroup,
isMaximized,
onMaximize,
isMinimized,
onMinimize,
onSplitRight,
onSplitDown,
onCloseGroup,
}: GroupSplitCloseActionsViewProps) {
const collapsed = useCollapsedWithHysteresis(width);
const splitCloseHandlers = { isChatGroup, onSplitRight, onSplitDown, onCloseGroup };
const minimizeHandlers = { isChatGroup, isMinimized, onMinimize };
const handlers = { isChatGroup, onSplitRight, onSplitDown, onCloseGroup };
return (
<>
<MaximizeButton isMaximized={isMaximized} onMaximize={onMaximize} />
{!isChatGroup && !collapsed && (
<MinimizeButton isMinimized={isMinimized} onMinimize={onMinimize} />
)}
{collapsed ? (
<SplitCloseDropdown {...splitCloseHandlers} {...minimizeHandlers} />
) : (
<InlineSplitClose {...splitCloseHandlers} />
)}
{collapsed ? <SplitCloseDropdown {...handlers} /> : <InlineSplitClose {...handlers} />}
</>
);
}
@@ -168,16 +168,13 @@ export function LeftHeaderActions(props: IDockviewHeaderActionsProps) {
);
}
/** Faded maximize, minimize, split, and close buttons for any non-sidebar group. */
/** Faded maximize, split, and close buttons for any non-sidebar group. */
function GroupSplitCloseActions({ group, containerApi }: IDockviewHeaderActionsProps) {
const centerGroupId = useDockviewStore((s) => s.centerGroupId);
const isChatGroup = group.id === centerGroupId;
const isMaximized = useDockviewStore((s) => s.preMaximizeLayout !== null);
const isMinimized = useDockviewStore((s) => s.minimizedGroupIds.has(group.id));
const preMaximizeGroupId = useDockviewStore((s) => s.maximizedGroupId);
const storeMaximize = useDockviewStore((s) => s.maximizeGroup);
const storeExitMaximize = useDockviewStore((s) => s.exitMaximizedLayout);
const toggleGroupMinimized = useDockviewStore((s) => s.toggleGroupMinimized);
const width = useDockviewGroupWidth(group);
const handleMaximize = useCallback(() => {
@@ -188,38 +185,6 @@ function GroupSplitCloseActions({ group, containerApi }: IDockviewHeaderActionsP
}
}, [group.id, isMaximized, storeMaximize, storeExitMaximize]);
const handleMinimize = useCallback(() => {
// Minimize and maximize are orthogonal — maximize is a global single-group
// overlay, minimize is per-group in-place collapse. Clicking Minimize while
// maximized would minimize the lone visible group and leave the user with a
// header-only screen; exit maximize first so they see the regular layout.
//
// During maximize, `group.id` is the ephemeral overlay group's ID — once
// exitMaximizedLayout rebuilds the original layout, that ID no longer
// exists in api.groups, and toggling minimize on it would silently no-op.
// Use the pre-maximize group ID (stored at maximize time) instead.
//
// exitMaximizedLayout schedules `api.layout(w, h)` in a requestAnimationFrame
// after rebuilding the layout; calling toggleGroupMinimized synchronously
// would race with that layout pass and the resulting setSize(0,0) could be
// overridden. Defer the toggle to the next frame so the maximize-exit
// settles first.
if (isMaximized) {
const target = preMaximizeGroupId ?? group.id;
storeExitMaximize();
requestAnimationFrame(() => {
// The world may have changed across the frame (env switch, another
// maximize, the target group removed). Only toggle if the target still
// exists as a real group in the current dockview api.
const live = useDockviewStore.getState();
const stillThere = live.api?.groups.some((g) => g.id === target) ?? false;
if (stillThere) live.toggleGroupMinimized(target);
});
return;
}
toggleGroupMinimized(group.id);
}, [group.id, isMaximized, preMaximizeGroupId, storeExitMaximize, toggleGroupMinimized]);
const handleSplitRight = useCallback(() => {
containerApi.addGroup({ referenceGroup: group, direction: "right" });
}, [group, containerApi]);
@@ -253,8 +218,6 @@ function GroupSplitCloseActions({ group, containerApi }: IDockviewHeaderActionsP
isChatGroup={isChatGroup}
isMaximized={isMaximized}
onMaximize={handleMaximize}
isMinimized={isMinimized}
onMinimize={handleMinimize}
onSplitRight={handleSplitRight}
onSplitDown={handleSplitDown}
onCloseGroup={handleCloseGroup}
@@ -1,289 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import type { DockviewApi } from "dockview-react";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { useMinimizedGroupSync } from "./dockview-minimize-sync";
const MINIMIZED_ATTR = "data-kandev-minimized";
type FakeListener<T> = (event: T) => void;
type FakeGroup = {
id: string;
element: HTMLElement;
api: {
width: number;
height: number;
setSize: ReturnType<typeof vi.fn>;
};
};
type FakePanel = { group: FakeGroup };
type FakeApi = {
groups: FakeGroup[];
emit: {
addGroup: (g: FakeGroup) => void;
removeGroup: (g: FakeGroup) => void;
addPanel: (panel: FakePanel) => void;
};
};
function makeFakeApi(initialGroups: FakeGroup[] = []): { api: DockviewApi; fake: FakeApi } {
const addGroupListeners: Array<FakeListener<FakeGroup>> = [];
const removeGroupListeners: Array<FakeListener<FakeGroup>> = [];
const addPanelListeners: Array<FakeListener<FakePanel>> = [];
const api = {
groups: [...initialGroups],
onDidAddGroup: (cb: FakeListener<FakeGroup>) => {
addGroupListeners.push(cb);
return { dispose: () => addGroupListeners.splice(addGroupListeners.indexOf(cb), 1) };
},
onDidRemoveGroup: (cb: FakeListener<FakeGroup>) => {
removeGroupListeners.push(cb);
return { dispose: () => removeGroupListeners.splice(removeGroupListeners.indexOf(cb), 1) };
},
onDidAddPanel: (cb: FakeListener<FakePanel>) => {
addPanelListeners.push(cb);
return { dispose: () => addPanelListeners.splice(addPanelListeners.indexOf(cb), 1) };
},
} as unknown as DockviewApi & { groups: FakeGroup[] };
const fake: FakeApi = {
groups: (api as unknown as { groups: FakeGroup[] }).groups,
emit: {
addGroup: (g) => {
fake.groups.push(g);
addGroupListeners.slice().forEach((cb) => cb(g));
},
removeGroup: (g) => {
const idx = fake.groups.indexOf(g);
if (idx >= 0) fake.groups.splice(idx, 1);
removeGroupListeners.slice().forEach((cb) => cb(g));
},
addPanel: (p) => addPanelListeners.slice().forEach((cb) => cb(p)),
},
};
return { api: api as DockviewApi, fake };
}
function makeGroup(id: string, opts: { width?: number; height?: number } = {}): FakeGroup {
return {
id,
element: document.createElement("div"),
api: {
width: opts.width ?? 800,
height: opts.height ?? 600,
setSize: vi.fn(),
},
};
}
describe("useMinimizedGroupSync", () => {
beforeEach(() => {
useDockviewStore.setState({ minimizedGroupIds: new Set<string>() });
});
afterEach(() => {
useDockviewStore.setState({ minimizedGroupIds: new Set<string>() });
});
it("stamps data-kandev-minimized=false on every group at mount", () => {
const groupA = makeGroup("A");
const groupB = makeGroup("B");
const { api } = makeFakeApi([groupA, groupB]);
renderHook(() => useMinimizedGroupSync(api));
expect(groupA.element.getAttribute(MINIMIZED_ATTR)).toBe("false");
expect(groupB.element.getAttribute(MINIMIZED_ATTR)).toBe("false");
});
it("does nothing when api is null", () => {
const { result } = renderHook(() => useMinimizedGroupSync(null));
expect(result.current).toBeUndefined();
});
it("on minimize: sets data-attribute=true, records prior size, calls setSize(0,0)", () => {
const groupA = makeGroup("A", { width: 800, height: 600 });
const { api } = makeFakeApi([groupA]);
renderHook(() => useMinimizedGroupSync(api));
act(() => {
useDockviewStore.getState().toggleGroupMinimized("A");
});
expect(groupA.element.getAttribute(MINIMIZED_ATTR)).toBe("true");
expect(groupA.api.setSize).toHaveBeenCalledWith({ width: 0, height: 0 });
});
it("skips a redundant setSize when sync re-fires while already minimized", () => {
const groupA = makeGroup("A");
const { api, fake } = makeFakeApi([groupA]);
renderHook(() => useMinimizedGroupSync(api));
act(() => {
useDockviewStore.getState().toggleGroupMinimized("A");
});
expect(groupA.api.setSize).toHaveBeenCalledTimes(1);
// A subsequent sync (triggered here by an addGroup event) should not re-fire
// setSize on the already-minimized group.
act(() => {
fake.emit.addGroup(makeGroup("B"));
});
expect(groupA.api.setSize).toHaveBeenCalledTimes(1);
});
it("on restore: replays the recorded prior size and resets data-attribute", () => {
const groupA = makeGroup("A", { width: 800, height: 600 });
const { api } = makeFakeApi([groupA]);
renderHook(() => useMinimizedGroupSync(api));
act(() => {
useDockviewStore.getState().toggleGroupMinimized("A");
});
expect(groupA.api.setSize).toHaveBeenLastCalledWith({ width: 0, height: 0 });
act(() => {
useDockviewStore.getState().toggleGroupMinimized("A");
});
expect(groupA.element.getAttribute(MINIMIZED_ATTR)).toBe("false");
expect(groupA.api.setSize).toHaveBeenLastCalledWith({ width: 800, height: 600 });
});
it("auto-restores a minimized group when a new panel is added to it", () => {
const groupA = makeGroup("A");
const { api, fake } = makeFakeApi([groupA]);
renderHook(() => useMinimizedGroupSync(api));
act(() => {
useDockviewStore.getState().toggleGroupMinimized("A");
});
expect(useDockviewStore.getState().minimizedGroupIds.has("A")).toBe(true);
act(() => {
fake.emit.addPanel({ group: groupA });
});
expect(useDockviewStore.getState().minimizedGroupIds.has("A")).toBe(false);
});
it("ignores panel-add events for groups that are not minimized", () => {
const groupA = makeGroup("A");
const groupB = makeGroup("B");
const { api, fake } = makeFakeApi([groupA, groupB]);
renderHook(() => useMinimizedGroupSync(api));
act(() => {
useDockviewStore.getState().toggleGroupMinimized("A");
});
act(() => {
fake.emit.addPanel({ group: groupB });
});
expect(useDockviewStore.getState().minimizedGroupIds.has("A")).toBe(true);
});
});
describe("useMinimizedGroupSync — group lifecycle", () => {
beforeEach(() => {
useDockviewStore.setState({ minimizedGroupIds: new Set<string>() });
});
afterEach(() => {
useDockviewStore.setState({ minimizedGroupIds: new Set<string>() });
});
it("evicts a removed group from minimizedGroupIds", () => {
const groupA = makeGroup("A");
const { api, fake } = makeFakeApi([groupA]);
renderHook(() => useMinimizedGroupSync(api));
act(() => {
useDockviewStore.getState().toggleGroupMinimized("A");
});
expect(useDockviewStore.getState().minimizedGroupIds.has("A")).toBe(true);
act(() => {
fake.emit.removeGroup(groupA);
});
expect(useDockviewStore.getState().minimizedGroupIds.has("A")).toBe(false);
});
it("clears recorded prior size when the group is removed (no stale restore on a re-added ID)", () => {
const groupA = makeGroup("A", { width: 800, height: 600 });
const { api, fake } = makeFakeApi([groupA]);
renderHook(() => useMinimizedGroupSync(api));
act(() => {
useDockviewStore.getState().toggleGroupMinimized("A");
});
act(() => {
fake.emit.removeGroup(groupA);
});
// A new group with the same ID later — its size should NOT be reset to the
// old prior. We re-add and then minimize+restore; restore should use the
// new group's current width/height, not the deleted one.
const groupA2 = makeGroup("A", { width: 400, height: 300 });
act(() => {
fake.emit.addGroup(groupA2);
});
act(() => {
useDockviewStore.getState().toggleGroupMinimized("A");
});
act(() => {
useDockviewStore.getState().toggleGroupMinimized("A");
});
expect(groupA2.api.setSize).toHaveBeenLastCalledWith({ width: 400, height: 300 });
});
it("syncs newly added groups", () => {
const groupA = makeGroup("A");
const { api, fake } = makeFakeApi([groupA]);
renderHook(() => useMinimizedGroupSync(api));
const groupB = makeGroup("B");
act(() => {
fake.emit.addGroup(groupB);
});
expect(groupB.element.getAttribute(MINIMIZED_ATTR)).toBe("false");
});
it("does not rewrite data-attribute when unchanged (perf guard)", () => {
const groupA = makeGroup("A");
const { api, fake } = makeFakeApi([groupA]);
renderHook(() => useMinimizedGroupSync(api));
const setAttrSpy = vi.spyOn(groupA.element, "setAttribute");
// Trigger a sync that wouldn't change the existing attribute.
act(() => {
fake.emit.addGroup(makeGroup("B"));
});
expect(setAttrSpy).not.toHaveBeenCalledWith(MINIMIZED_ATTR, "false");
});
it("disposes all subscribers on unmount", () => {
const groupA = makeGroup("A");
const { api, fake } = makeFakeApi([groupA]);
const { unmount } = renderHook(() => useMinimizedGroupSync(api));
unmount();
// After unmount, store changes must not touch the group element or call setSize.
const setAttrSpy = vi.spyOn(groupA.element, "setAttribute");
act(() => {
useDockviewStore.getState().toggleGroupMinimized("A");
});
expect(setAttrSpy).not.toHaveBeenCalled();
expect(groupA.api.setSize).not.toHaveBeenCalled();
// And dockview events should no longer mutate the store.
act(() => {
fake.emit.removeGroup(groupA);
});
// The store still has "A" because the onDidRemoveGroup listener was disposed.
expect(useDockviewStore.getState().minimizedGroupIds.has("A")).toBe(true);
});
});
@@ -1,109 +0,0 @@
import { useEffect, useRef } from "react";
import type { DockviewApi } from "dockview-react";
import { useDockviewStore } from "@/lib/state/dockview-store";
const MINIMIZED_ATTR = "data-kandev-minimized";
/** Minimum splitview size we request when minimizing — the splitview clamps to the
* group's actual minimum (tab-strip height / minimum group width) so this can be 0. */
const MIN_SIZE = 0;
type PriorSize = { width: number; height: number };
/** Apply a single group's minimized state: data-attribute + splitview size shrink/restore. */
function applyGroupMinimized(
api: DockviewApi,
groupId: string,
minimized: boolean,
priorSizes: Map<string, PriorSize>,
): void {
const group = api.groups.find((g) => g.id === groupId);
if (!group) return;
const next = minimized ? "true" : "false";
if (group.element.getAttribute(MINIMIZED_ATTR) !== next) {
group.element.setAttribute(MINIMIZED_ATTR, next);
}
if (minimized) {
// Skip the setSize when the group is already minimized (priorSizes already
// has the entry). Re-firing setSize on every sync would re-emit dockview's
// size-change events for no benefit.
if (priorSizes.has(groupId)) return;
priorSizes.set(groupId, { width: group.api.width, height: group.api.height });
try {
group.api.setSize({ width: MIN_SIZE, height: MIN_SIZE });
} catch {
/* setSize may throw if the group is being torn down */
}
} else {
const prior = priorSizes.get(groupId);
if (prior) {
try {
group.api.setSize({ width: prior.width, height: prior.height });
} catch {
/* same */
}
priorSizes.delete(groupId);
}
}
}
/**
* Keep the DOM and splitview sizes in sync with the store's `minimizedGroupIds`.
*
* - On set change: walk every group, set the `data-kandev-minimized` attribute,
* and call `group.api.setSize` to either shrink (clamps to splitview minimum)
* or restore the prior recorded size.
* - On `onDidAddPanel` into a minimized group: auto-restore (user is trying to
* add something they want to see).
* - On `onDidRemoveGroup`: evict the group from `minimizedGroupIds` and prior-sizes.
* - On `onDidAddGroup`: new groups default to not-minimized (no-op — they don't
* appear in the set).
*/
export function useMinimizedGroupSync(api: DockviewApi | null): void {
const priorSizesRef = useRef<Map<string, PriorSize>>(new Map());
useEffect(() => {
if (!api) return;
const priorSizes = priorSizesRef.current;
const sync = () => {
const ids = useDockviewStore.getState().minimizedGroupIds;
for (const group of api.groups) {
applyGroupMinimized(api, group.id, ids.has(group.id), priorSizes);
}
};
sync();
const unsubscribe = useDockviewStore.subscribe((state, prev) => {
if (state.minimizedGroupIds === prev.minimizedGroupIds) return;
sync();
});
const d1 = api.onDidAddGroup(() => sync());
const d2 = api.onDidRemoveGroup((group) => {
const ids = useDockviewStore.getState().minimizedGroupIds;
priorSizes.delete(group.id);
if (ids.has(group.id)) {
const next = new Set(ids);
next.delete(group.id);
useDockviewStore.setState({ minimizedGroupIds: next });
}
});
const d3 = api.onDidAddPanel((panel) => {
const groupId = panel.group?.id;
if (!groupId) return;
const ids = useDockviewStore.getState().minimizedGroupIds;
if (!ids.has(groupId)) return;
const next = new Set(ids);
next.delete(groupId);
useDockviewStore.setState({ minimizedGroupIds: next });
});
return () => {
unsubscribe();
d1.dispose();
d2.dispose();
d3.dispose();
};
}, [api]);
}
-49
View File
@@ -91,52 +91,3 @@ describe("dockview-store resolveFilePath (via onDidActivePanelChange)", () => {
expect(useDockviewStore.getState().activeFilePath).toBeNull();
});
});
describe("dockview-store minimize state", () => {
beforeEach(() => {
useDockviewStore.setState({ minimizedGroupIds: new Set<string>() });
});
it("toggleGroupMinimized adds then removes a group ID", () => {
const { toggleGroupMinimized } = useDockviewStore.getState();
toggleGroupMinimized("g1");
expect(useDockviewStore.getState().minimizedGroupIds.has("g1")).toBe(true);
toggleGroupMinimized("g1");
expect(useDockviewStore.getState().minimizedGroupIds.has("g1")).toBe(false);
});
it("toggleGroupMinimized swaps the Set instance so selectors re-fire", () => {
const before = useDockviewStore.getState().minimizedGroupIds;
useDockviewStore.getState().toggleGroupMinimized("g1");
const after = useDockviewStore.getState().minimizedGroupIds;
expect(after).not.toBe(before);
});
it("toggleGroupMinimized treats distinct IDs independently", () => {
const { toggleGroupMinimized } = useDockviewStore.getState();
toggleGroupMinimized("g1");
toggleGroupMinimized("g2");
const ids = useDockviewStore.getState().minimizedGroupIds;
expect(ids.has("g1")).toBe(true);
expect(ids.has("g2")).toBe(true);
toggleGroupMinimized("g1");
const ids2 = useDockviewStore.getState().minimizedGroupIds;
expect(ids2.has("g1")).toBe(false);
expect(ids2.has("g2")).toBe(true);
});
it("clearMinimizedGroups empties the Set", () => {
useDockviewStore.getState().toggleGroupMinimized("g1");
useDockviewStore.getState().toggleGroupMinimized("g2");
expect(useDockviewStore.getState().minimizedGroupIds.size).toBe(2);
useDockviewStore.getState().clearMinimizedGroups();
expect(useDockviewStore.getState().minimizedGroupIds.size).toBe(0);
});
it("clearMinimizedGroups is a no-op when already empty (preserves identity)", () => {
const before = useDockviewStore.getState().minimizedGroupIds;
useDockviewStore.getState().clearMinimizedGroups();
const after = useDockviewStore.getState().minimizedGroupIds;
expect(after).toBe(before);
});
});
+5 -56
View File
@@ -178,10 +178,6 @@ type DockviewStore = {
maximizedGroupId: string | null;
maximizeGroup: (groupId: string) => void;
exitMaximizedLayout: () => void;
/** Per-group minimized state — content hidden, tab strip visible. Not persisted. */
minimizedGroupIds: Set<string>;
toggleGroupMinimized: (groupId: string) => void;
clearMinimizedGroups: () => void;
};
type StoreGet = () => DockviewStore;
@@ -391,7 +387,6 @@ function buildPresetActions(set: StoreSet, get: StoreGet) {
sidebarVisible: true,
rightPanelsVisible: preset === "default",
pinnedWidths: cleanedWidths,
minimizedGroupIds: new Set<string>(),
});
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
@@ -422,11 +417,7 @@ function buildPresetActions(set: StoreSet, get: StoreGet) {
const colCount = state?.columns?.length ?? api.groups.length;
const sidebarCols = hasSidebar ? 1 : 0;
const hasRight = colCount > sidebarCols + 1;
set({
sidebarVisible: hasSidebar,
rightPanelsVisible: hasRight,
minimizedGroupIds: new Set<string>(),
});
set({ sidebarVisible: hasSidebar, rightPanelsVisible: hasRight });
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
syncPinnedWidthsFromApi(api, set);
@@ -574,11 +565,7 @@ function buildEnvSwitchAction(set: StoreSet, get: StoreGet) {
// outgoing env rather than silently skipping it.
const effectiveOld = oldEnvId ?? currentLayoutEnvId;
saveOutgoingEnv(api, effectiveOld, preMaximizeLayout, get().pinnedWidths);
set({
preMaximizeLayout: null,
maximizedGroupId: null,
minimizedGroupIds: new Set<string>(),
});
set({ preMaximizeLayout: null, maximizedGroupId: null });
set({ isRestoringLayout: true, currentLayoutEnvId: newEnvId });
try {
if (restoreMaximizeFromStorage(api, newEnvId, set)) return;
@@ -602,26 +589,6 @@ function buildEnvSwitchAction(set: StoreSet, get: StoreGet) {
};
}
function buildMinimizeActions(set: StoreSet, get: StoreGet) {
return {
toggleGroupMinimized: (groupId: string) => {
set((prev) => {
const next = new Set(prev.minimizedGroupIds);
if (next.has(groupId)) {
next.delete(groupId);
} else {
next.add(groupId);
}
return { minimizedGroupIds: next };
});
},
clearMinimizedGroups: () => {
if (get().minimizedGroupIds.size === 0) return;
set({ minimizedGroupIds: new Set<string>() });
},
};
}
function buildMaximizeActions(set: StoreSet, get: StoreGet) {
return {
maximizeGroup: (groupId: string) => {
@@ -656,12 +623,7 @@ function buildMaximizeActions(set: StoreSet, get: StoreGet) {
groups: [{ panels: targetGroup.panels, activePanel: targetGroup.activePanel }],
});
const maximizedLayout: LayoutState = { columns };
set({
isRestoringLayout: true,
preMaximizeLayout: current,
maximizedGroupId: groupId,
minimizedGroupIds: new Set<string>(),
});
set({ isRestoringLayout: true, preMaximizeLayout: current, maximizedGroupId: groupId });
const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api);
applyLayoutAndSet(api, maximizedLayout, liveWidths, set);
requestAnimationFrame(() => {
@@ -683,12 +645,7 @@ function buildMaximizeActions(set: StoreSet, get: StoreGet) {
const safeWidth = measured.width;
const safeHeight = measured.height;
const liveWidths = get().pinnedWidths;
set({
isRestoringLayout: true,
preMaximizeLayout: null,
maximizedGroupId: null,
minimizedGroupIds: new Set<string>(),
});
set({ isRestoringLayout: true, preMaximizeLayout: null, maximizedGroupId: null });
if (currentLayoutEnvId) {
removeEnvMaximizeState(currentLayoutEnvId);
}
@@ -731,12 +688,7 @@ function performBuildDefault(
const ids = applyLayout(api, state, freshPinned);
const hasSidebar = state.columns.some((c) => c.id === "sidebar");
const hasRight = state.columns.length > (hasSidebar ? 2 : 1);
set({
...ids,
sidebarVisible: hasSidebar,
rightPanelsVisible: hasRight,
minimizedGroupIds: new Set<string>(),
});
set({ ...ids, sidebarVisible: hasSidebar, rightPanelsVisible: hasRight });
const pending = get().deferredPanelActions;
if (pending.length > 0) {
@@ -845,8 +797,6 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({
setPendingChatScrollTop: (value) => set({ pendingChatScrollTop: value }),
preMaximizeLayout: null,
maximizedGroupId: null,
minimizedGroupIds: new Set<string>(),
...buildMinimizeActions(set, get),
...buildMaximizeActions(set, get),
...buildPanelActions(set, get),
...buildExtraPanelActions(get),
@@ -884,7 +834,6 @@ export function releaseLayoutToDefault(oldEnvId: string | null): void {
preMaximizeLayout: null,
maximizedGroupId: null,
currentLayoutEnvId: null,
minimizedGroupIds: new Set<string>(),
});
buildDefaultLayout(api);
}