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:
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user