fix(web): prevent crowding in narrow changes panel and dockview tabs (#727)
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
import { FileRow } from "./changes-panel-file-row";
|
||||
|
||||
const noop = () => {};
|
||||
const noopSelect = () => false;
|
||||
|
||||
const baseFile = {
|
||||
status: "modified" as const,
|
||||
staged: false,
|
||||
plus: 18,
|
||||
minus: 3,
|
||||
oldPath: undefined,
|
||||
};
|
||||
|
||||
function renderRow(path: string) {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<ul>
|
||||
<FileRow
|
||||
file={{ ...baseFile, path }}
|
||||
isPending={false}
|
||||
onSelect={noopSelect}
|
||||
onOpenDiff={noop}
|
||||
onStage={noop}
|
||||
onUnstage={noop}
|
||||
onDiscard={noop}
|
||||
onEditFile={noop}
|
||||
/>
|
||||
</ul>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("FileRow truncation (regression: path overlaps diff stats in narrow panel)", () => {
|
||||
it("file name span allows truncation so a long name does not overflow visually", () => {
|
||||
// Bug: when the panel is narrow, a long file name renders past its container
|
||||
// and overlaps the diff counts (+/-) on the right. Fix: file name span must be
|
||||
// truncatable (truncate + min-w-0), not whitespace-nowrap+shrink-0.
|
||||
const { container } = renderRow("deeply/nested/folder/render_text_to_image_controller_test.go");
|
||||
const fileNameSpan = container.querySelector(
|
||||
"span.font-medium.text-foreground",
|
||||
) as HTMLElement | null;
|
||||
|
||||
expect(fileNameSpan).not.toBeNull();
|
||||
expect(fileNameSpan!.className).toContain("truncate");
|
||||
expect(fileNameSpan!.className).toContain("min-w-0");
|
||||
// Must NOT prevent shrinking — that's what causes the overflow.
|
||||
expect(fileNameSpan!.className).not.toContain("shrink-0");
|
||||
});
|
||||
|
||||
it("folder span keeps truncate so the folder portion is shortened first", () => {
|
||||
const { container } = renderRow("a/b/c/d/file.go");
|
||||
const folderSpan = container.querySelector("span.text-foreground\\/60") as HTMLElement | null;
|
||||
|
||||
expect(folderSpan).not.toBeNull();
|
||||
expect(folderSpan!.className).toContain("truncate");
|
||||
});
|
||||
|
||||
it("right-side stats container has shrink-0 so it stays in place when path is long", () => {
|
||||
// Without shrink-0 on the stats column, flex layout could squeeze the
|
||||
// diff counts. Anchoring it ensures the file name truncates instead.
|
||||
const { container } = renderRow("file.go");
|
||||
// The stats container is the second direct child of the <li>
|
||||
const li = container.querySelector("li") as HTMLElement | null;
|
||||
expect(li).not.toBeNull();
|
||||
const statsContainer = li!.children[1] as HTMLElement;
|
||||
expect(statsContainer.className).toContain("shrink-0");
|
||||
});
|
||||
|
||||
it("renders a file with no folder without crashing and exposes the file name", () => {
|
||||
const { container } = renderRow("README.md");
|
||||
const fileNameSpan = container.querySelector(
|
||||
"span.font-medium.text-foreground",
|
||||
) as HTMLElement | null;
|
||||
expect(fileNameSpan?.textContent).toBe("README.md");
|
||||
// No folder element when there's no slash in the path.
|
||||
const folderSpan = container.querySelector("span.text-foreground\\/60");
|
||||
expect(folderSpan).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -88,12 +88,16 @@ export function FileRow({
|
||||
/>
|
||||
<button type="button" className="min-w-0 text-left cursor-pointer" title={file.path}>
|
||||
<p className="flex text-foreground text-xs min-w-0">
|
||||
{folder && <span className="text-foreground/60 truncate shrink">{folder}/</span>}
|
||||
<span className="font-medium text-foreground whitespace-nowrap shrink-0">{name}</span>
|
||||
{folder && (
|
||||
<span className="text-foreground/60 truncate min-w-0 [flex-shrink:9999]">
|
||||
{folder}/
|
||||
</span>
|
||||
)}
|
||||
<span className="font-medium text-foreground truncate min-w-0">{name}</span>
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<FileRowActions path={file.path} onDiscard={onDiscard} onEditFile={onEditFile} />
|
||||
<LineStat added={file.plus} removed={file.minus} />
|
||||
<FileStatusIcon status={file.status} />
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
import { GroupSplitCloseActionsView } from "./dockview-group-actions";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const TID = {
|
||||
maximize: "dockview-maximize-btn",
|
||||
splitRight: "dockview-split-right-btn",
|
||||
splitDown: "dockview-split-down-btn",
|
||||
close: "dockview-close-group-btn",
|
||||
menu: "dockview-group-actions-menu",
|
||||
} as const;
|
||||
|
||||
const baseProps = {
|
||||
isChatGroup: false,
|
||||
isMaximized: false,
|
||||
onMaximize: () => {},
|
||||
onSplitRight: () => {},
|
||||
onSplitDown: () => {},
|
||||
onCloseGroup: () => {},
|
||||
};
|
||||
|
||||
function renderView(width: number, override: Partial<typeof baseProps> = {}) {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<GroupSplitCloseActionsView width={width} {...baseProps} {...override} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("GroupSplitCloseActionsView", () => {
|
||||
it("wide width: renders 4 inline buttons and no overflow dropdown", () => {
|
||||
renderView(600);
|
||||
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)).not.toBeNull();
|
||||
expect(screen.queryByTestId(TID.menu)).toBeNull();
|
||||
});
|
||||
|
||||
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.splitRight)).toBeNull();
|
||||
expect(screen.queryByTestId(TID.splitDown)).toBeNull();
|
||||
expect(screen.queryByTestId(TID.close)).toBeNull();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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.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} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
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} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.queryByTestId(TID.menu)).toBeNull();
|
||||
expect(screen.queryByTestId(TID.splitRight)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("Maximize button click fires onMaximize handler in both modes", () => {
|
||||
const onMaximize = vi.fn();
|
||||
const { rerender } = render(
|
||||
<TooltipProvider>
|
||||
<GroupSplitCloseActionsView width={600} {...baseProps} onMaximize={onMaximize} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
screen.getByTestId(TID.maximize).click();
|
||||
expect(onMaximize).toHaveBeenCalledTimes(1);
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<GroupSplitCloseActionsView width={200} {...baseProps} onMaximize={onMaximize} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
screen.getByTestId(TID.maximize).click();
|
||||
expect(onMaximize).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState, useSyncExternalStore } from "react";
|
||||
import { type IDockviewHeaderActionsProps } from "dockview-react";
|
||||
import {
|
||||
IconArrowsMaximize,
|
||||
IconArrowsMinimize,
|
||||
IconDots,
|
||||
IconLayoutColumns,
|
||||
IconLayoutRows,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@kandev/ui/dropdown-menu";
|
||||
|
||||
const ACTION_BTN =
|
||||
"h-5 w-5 inline-flex items-center justify-center text-muted-foreground/50 hover:text-foreground transition-colors cursor-pointer";
|
||||
|
||||
/** Width thresholds for collapsing split/close into a dropdown. Hysteresis avoids toggle flicker. */
|
||||
const COLLAPSE_WIDTH = 320;
|
||||
const EXPAND_WIDTH = 340;
|
||||
|
||||
/** Subscribe to a dockview group's width via its panel api. */
|
||||
export function useDockviewGroupWidth(group: IDockviewHeaderActionsProps["group"]): number {
|
||||
// Stable subscribe/getSnapshot tied to the group identity so useSyncExternalStore
|
||||
// does not resubscribe on every render.
|
||||
const subscribe = useCallback(
|
||||
(cb: () => void) => {
|
||||
const d = group.api.onDidDimensionsChange(cb);
|
||||
return () => d.dispose();
|
||||
},
|
||||
[group],
|
||||
);
|
||||
const getSnapshot = useCallback(() => group.api.width, [group]);
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sticky collapsed state with hysteresis. Uses the documented React pattern of
|
||||
* storing previous-derived state and conditionally updating during render —
|
||||
* https://react.dev/reference/react/useState#storing-information-from-previous-renders
|
||||
*/
|
||||
function useCollapsedWithHysteresis(width: number): boolean {
|
||||
const [collapsed, setCollapsed] = useState<boolean>(width < COLLAPSE_WIDTH);
|
||||
const next = collapsed ? width < EXPAND_WIDTH : width < COLLAPSE_WIDTH;
|
||||
if (next !== collapsed) {
|
||||
setCollapsed(next);
|
||||
return next;
|
||||
}
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
type SplitCloseHandlers = {
|
||||
isChatGroup: boolean;
|
||||
onSplitRight: () => void;
|
||||
onSplitDown: () => void;
|
||||
onCloseGroup: () => void;
|
||||
};
|
||||
|
||||
function MaximizeButton({
|
||||
isMaximized,
|
||||
onMaximize,
|
||||
}: {
|
||||
isMaximized: boolean;
|
||||
onMaximize: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={ACTION_BTN}
|
||||
onClick={onMaximize}
|
||||
data-testid="dockview-maximize-btn"
|
||||
>
|
||||
{isMaximized ? (
|
||||
<IconArrowsMinimize className="h-3 w-3" />
|
||||
) : (
|
||||
<IconArrowsMaximize className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{isMaximized ? "Restore" : "Maximize"}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineSplitClose({
|
||||
isChatGroup,
|
||||
onSplitRight,
|
||||
onSplitDown,
|
||||
onCloseGroup,
|
||||
}: SplitCloseHandlers) {
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={ACTION_BTN}
|
||||
onClick={onSplitRight}
|
||||
data-testid="dockview-split-right-btn"
|
||||
>
|
||||
<IconLayoutColumns className="h-3 w-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split right</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={ACTION_BTN}
|
||||
onClick={onSplitDown}
|
||||
data-testid="dockview-split-down-btn"
|
||||
>
|
||||
<IconLayoutRows className="h-3 w-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split down</TooltipContent>
|
||||
</Tooltip>
|
||||
{!isChatGroup && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={ACTION_BTN}
|
||||
onClick={onCloseGroup}
|
||||
data-testid="dockview-close-group-btn"
|
||||
>
|
||||
<IconX className="h-3 w-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Close group</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SplitCloseDropdown({
|
||||
isChatGroup,
|
||||
onSplitRight,
|
||||
onSplitDown,
|
||||
onCloseGroup,
|
||||
}: SplitCloseHandlers) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={ACTION_BTN} data-testid="dockview-group-actions-menu">
|
||||
<IconDots className="h-3 w-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onSplitRight} className="cursor-pointer text-xs">
|
||||
<IconLayoutColumns className="h-3.5 w-3.5 mr-1.5" />
|
||||
Split right
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onSplitDown} className="cursor-pointer text-xs">
|
||||
<IconLayoutRows className="h-3.5 w-3.5 mr-1.5" />
|
||||
Split down
|
||||
</DropdownMenuItem>
|
||||
{!isChatGroup && (
|
||||
<DropdownMenuItem onClick={onCloseGroup} className="cursor-pointer text-xs">
|
||||
<IconX className="h-3.5 w-3.5 mr-1.5" />
|
||||
Close group
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
type GroupSplitCloseActionsViewProps = SplitCloseHandlers & {
|
||||
width: number;
|
||||
isMaximized: boolean;
|
||||
onMaximize: () => void;
|
||||
};
|
||||
|
||||
/** Presentational view: maximize stays inline; split/close collapse into a dropdown when narrow. */
|
||||
export function GroupSplitCloseActionsView({
|
||||
width,
|
||||
isChatGroup,
|
||||
isMaximized,
|
||||
onMaximize,
|
||||
onSplitRight,
|
||||
onSplitDown,
|
||||
onCloseGroup,
|
||||
}: GroupSplitCloseActionsViewProps) {
|
||||
const collapsed = useCollapsedWithHysteresis(width);
|
||||
const handlers = { isChatGroup, onSplitRight, onSplitDown, onCloseGroup };
|
||||
return (
|
||||
<>
|
||||
<MaximizeButton isMaximized={isMaximized} onMaximize={onMaximize} />
|
||||
{collapsed ? <SplitCloseDropdown {...handlers} /> : <InlineSplitClose {...handlers} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,12 +13,7 @@ import {
|
||||
IconGitPullRequest,
|
||||
IconPlayerPlay,
|
||||
IconLayoutSidebarRightCollapse,
|
||||
IconLayoutColumns,
|
||||
IconLayoutRows,
|
||||
IconX,
|
||||
IconBrandVscode,
|
||||
IconArrowsMaximize,
|
||||
IconArrowsMinimize,
|
||||
} from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
@@ -43,6 +38,7 @@ import { NewSessionDialog } from "./new-session-dialog";
|
||||
import { NewTaskDropdown } from "./new-task-dropdown";
|
||||
import { RepositoryScriptsMenuItems, useActiveSessionDevScript } from "./repository-scripts-menu";
|
||||
import { SessionReopenMenuItems } from "./session-reopen-menu";
|
||||
import { GroupSplitCloseActionsView, useDockviewGroupWidth } from "./dockview-group-actions";
|
||||
|
||||
/** Map a ProcessInfo response to a ProcessStatusEntry for the store. */
|
||||
function mapProcessToStatus(process: ProcessInfo): ProcessStatusEntry {
|
||||
@@ -258,9 +254,6 @@ export function LeftHeaderActions(props: IDockviewHeaderActionsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const ACTION_BTN =
|
||||
"h-5 w-5 inline-flex items-center justify-center text-muted-foreground/50 hover:text-foreground transition-colors cursor-pointer";
|
||||
|
||||
/** Faded maximize, split, and close buttons for any non-sidebar group. */
|
||||
function GroupSplitCloseActions({ group, containerApi }: IDockviewHeaderActionsProps) {
|
||||
const centerGroupId = useDockviewStore((s) => s.centerGroupId);
|
||||
@@ -268,6 +261,7 @@ function GroupSplitCloseActions({ group, containerApi }: IDockviewHeaderActionsP
|
||||
const isMaximized = useDockviewStore((s) => s.preMaximizeLayout !== null);
|
||||
const storeMaximize = useDockviewStore((s) => s.maximizeGroup);
|
||||
const storeExitMaximize = useDockviewStore((s) => s.exitMaximizedLayout);
|
||||
const width = useDockviewGroupWidth(group);
|
||||
|
||||
const handleMaximize = useCallback(() => {
|
||||
if (isMaximized) {
|
||||
@@ -305,56 +299,15 @@ function GroupSplitCloseActions({ group, containerApi }: IDockviewHeaderActionsP
|
||||
}, [group, containerApi]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={ACTION_BTN}
|
||||
onClick={handleMaximize}
|
||||
data-testid="dockview-maximize-btn"
|
||||
>
|
||||
{isMaximized ? (
|
||||
<IconArrowsMinimize className="h-3 w-3" />
|
||||
) : (
|
||||
<IconArrowsMaximize className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{isMaximized ? "Restore" : "Maximize"}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" className={ACTION_BTN} onClick={handleSplitRight}>
|
||||
<IconLayoutColumns className="h-3 w-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split right</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" className={ACTION_BTN} onClick={handleSplitDown}>
|
||||
<IconLayoutRows className="h-3 w-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split down</TooltipContent>
|
||||
</Tooltip>
|
||||
{!isChatGroup && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={ACTION_BTN}
|
||||
onClick={handleCloseGroup}
|
||||
data-testid="dockview-close-group-btn"
|
||||
>
|
||||
<IconX className="h-3 w-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Close group</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
<GroupSplitCloseActionsView
|
||||
width={width}
|
||||
isChatGroup={isChatGroup}
|
||||
isMaximized={isMaximized}
|
||||
onMaximize={handleMaximize}
|
||||
onSplitRight={handleSplitRight}
|
||||
onSplitDown={handleSplitDown}
|
||||
onCloseGroup={handleCloseGroup}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user