fix(web): prevent crowding in narrow changes panel and dockview tabs (#727)

This commit is contained in:
Carlos Florêncio
2026-04-28 22:23:14 +01:00
committed by GitHub
parent 2416b8dd49
commit 8694de20ea
5 changed files with 409 additions and 61 deletions
@@ -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}
/>
);
}