7771155c8f
* feat(task): add per-group minimize state to dockview store Adds `minimizedGroupIds: Set<string>` plus `toggleGroupMinimized` and `clearMinimizedGroups` actions. The set is cleared on maximize, exit-maximize, preset apply, custom layout apply, env switch, default-layout rebuild, and release-to-default so minimize state never leaks across task environments and never overlaps with maximize. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(task): add minimize button to dockview group header Adds an IconMinus toggle button alongside Maximize/Split/Close in each non-chat dockview group header. Clicking it adds the group ID to the store's `minimizedGroupIds`; a new `useMinimizedGroupSync` hook walks `api.groups`, stamps a `data-kandev-minimized` attribute on each group element, and calls `group.api.setSize` to shrink the group to the splitview minimum (the freed space redistributes to siblings). A CSS rule hides the content container so only the tab strip remains visible. The hook also auto-restores a minimized group when a new panel is added to it (e.g. via the `+` menu) and evicts removed groups from the set. Clicking Minimize while maximized first exits maximize so the user doesn't end up with a header-only view. In wide mode the button is inline; in narrow mode it folds into the existing overflow dropdown as its first item. Chat group never sees the button (same exclusion as Close). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(task): tighten minimize-icon test, drop unused TID The previous "renders Restore tooltip and icon swap" case had two weak assertions: `getAttribute(...) !== null || btn !== null` is tautologically true, and `btn.innerHTML.toContain("svg")` matches both icon variants. Replaced with two focused tests that assert the Tabler class differs between the minimized and non-minimized state. Also dropped the unused `minimizeMenuItem` entry from the TID map. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(task): minimize-from-maximize used stale group ID `handleMinimize` previously called `toggleGroupMinimized(group.id)` after `exitMaximizedLayout()`. During maximize, `group.id` is the ephemeral overlay group's ID — once the original layout is restored that ID is gone from `api.groups`, and the minimize was silently lost. Read `maximizedGroupId` (the pre-maximize group ID stored at maximize time) from the store and target that instead. Reported on PR #1039 by claude-review, CodeRabbit, and Greptile. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * perf(task): skip redundant data-attribute writes in minimize sync The sync hook re-runs on every `onDidAddGroup` / `onDidAddPanel`, which previously rewrote `data-kandev-minimized` on every group regardless of whether the value actually changed. Guard with a getAttribute-before-set check. Reported on PR #1039 by claude-review. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(task): cover useMinimizedGroupSync hook Adds 11 unit tests for the hook: mount-time attribute stamping, null-api no-op, minimize/restore round-trip with prior-size replay, auto-restore on panel add into a minimized group, no auto-restore for unrelated groups, group-removal eviction from the store and prior-size map, fresh size on re-added group with the same ID, sync of newly added groups, the perf guard on redundant attribute writes, and clean unmount. Reported on PR #1039 by claude-review as a blocker (CLAUDE.md mandates tests for new hooks). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(task): defer minimize toggle to next RAF when exiting maximize `exitMaximizedLayout` schedules `api.layout(w, h)` in a requestAnimation Frame after the synchronous fromJSON rebuild. Calling `toggleGroup Minimized` immediately afterwards races with that layout pass, and the resulting `setSize({ width: 0, height: 0 })` could be overridden — the content was still hidden by CSS but the group kept its full splitview footprint. Defer the toggle one frame so the maximize-exit settles first. Reported on PR #1039 by claude-review, Greptile, and cubic. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(task): guard deferred minimize against env-switch + redundant setSize Two small polish fixes from PR #1039 review: - The RAF callback in `handleMinimize` now verifies the target group still exists in `api.groups` before toggling. Within the frame, an env-switch (or another maximize) could clear `minimizedGroupIds`; blindly re-adding a stale ID would leave it in the set, and if the next env happens to reuse the same well-known constant ID (e.g. "right-top"), the wrong group would render as minimized. (cubic P2 finding.) - `applyGroupMinimized` now early-returns when re-syncing an already- minimized group (`priorSizes.has(groupId)`), instead of re-emitting setSize(0,0) and its size-change events. New test covers this. (claude-review suggestion.) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
290 lines
9.4 KiB
TypeScript
290 lines
9.4 KiB
TypeScript
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);
|
|
});
|
|
});
|