import { describe, it, expect, vi } from "vitest";
import { render, fireEvent } 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(
,
);
}
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
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();
});
it("passes diff source + repository context on open diff", () => {
const onOpenDiff = vi.fn();
const { container } = render(
,
);
const row = container.querySelector("[data-testid='file-row-README.md']") as HTMLElement | null;
expect(row).not.toBeNull();
fireEvent.click(row!);
expect(onOpenDiff).toHaveBeenCalledWith("README.md", {
source: "uncommitted",
repositoryName: "frontend",
});
});
});
describe("FileRow hover swap (stats <-> actions occupy same cell)", () => {
// Goal: hovering must not shift the file name. The stats group and the
// hover-actions group live in the same CSS grid cell — stats fade out and
// actions fade in, so the right cluster's width is stable.
it("stats container fades out on hover and never intercepts pointer events", () => {
const { container } = renderRow("file.go");
const li = container.querySelector("li") as HTMLElement;
const gridWrapper = li.children[1] as HTMLElement;
const statsLayer = gridWrapper.children[0] as HTMLElement;
expect(statsLayer.className).toContain("group-hover:opacity-0");
expect(statsLayer.className).toContain("transition-opacity");
// Defensive: stats has no interactive children today, but blocking pointer
// events on this always-non-interactive layer prevents a future regression
// if someone adds a clickable element here.
expect(statsLayer.className).toContain("pointer-events-none");
});
it("actions container fades in on hover and only accepts pointer/keyboard events while hovered", () => {
const { container } = renderRow("file.go");
const li = container.querySelector("li") as HTMLElement;
const gridWrapper = li.children[1] as HTMLElement;
const actionsLayer = gridWrapper.children[1] as HTMLElement;
expect(actionsLayer.className).toContain("opacity-0");
expect(actionsLayer.className).toContain("group-hover:opacity-100");
expect(actionsLayer.className).toContain("transition-opacity");
// Critical: the actions div is the top child in the same grid cell, so
// without pointer-events gating it would eat clicks aimed at the
// visually-shown stats. (Keyboard Tab focus is a separate concern — a
// pre-existing gap not closed by `pointer-events: none`, which only
// blocks mouse/touch.)
expect(actionsLayer.className).toContain("pointer-events-none");
expect(actionsLayer.className).toContain("group-hover:pointer-events-auto");
});
it("wrapper stacks both layers into one grid cell so width is max(stats, actions)", () => {
const { container } = renderRow("file.go");
const li = container.querySelector("li") as HTMLElement;
const gridWrapper = li.children[1] as HTMLElement;
expect(gridWrapper.className).toContain("grid");
// Arbitrary child selector pins every direct child to row 1 / col 1 so
// they overlap instead of laying out side-by-side.
expect(gridWrapper.className).toContain("col-start-1");
expect(gridWrapper.className).toContain("row-start-1");
});
});
describe("FileRow active-tab highlight", () => {
it("renders data-active='true' and bg-accent/60 when isActive", () => {
const { container } = render(
,
);
const row = container.querySelector("[data-changes-file='active.ts']") as HTMLElement | null;
expect(row).not.toBeNull();
expect(row!.getAttribute("data-active")).toBe("true");
expect(row!.className).toContain("bg-accent/60");
});
it("keeps highlight when isActive and isSelected are both true", () => {
const { container } = render(
,
);
const row = container.querySelector("[data-changes-file='both.ts']") as HTMLElement | null;
expect(row).not.toBeNull();
expect(row!.getAttribute("data-active")).toBe("true");
expect(row!.getAttribute("data-selected")).toBe("true");
expect(row!.className).toContain("bg-accent/60");
});
});