Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/beta-ci-smoke-coverage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
5 changes: 5 additions & 0 deletions .changeset/clear-empty-reviews.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Show an empty-review message instead of claiming an active filter hid files when there are no changes to review.
5 changes: 4 additions & 1 deletion packages/hunk-git/src/status.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, test } from "bun:test";
import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test";
import {
mkdtempSync,
mkdirSync,
Expand All @@ -13,6 +13,9 @@ import { resolveCanonicalPath } from "@hunk/vcs/path";
import { join } from "node:path";
import { createGitStatusCapability, parseGitStatus, parseGitStatusWorktrees } from "./status";

// Real Git fixture setup and multi-step status reads can exceed five seconds on Windows runners.
setDefaultTimeout(30_000);

const dirs: string[] = [];
/** Create a portable Git fixture with local identity and a deterministic branch. */
function createTestRepo(commit = true) {
Expand Down
3 changes: 2 additions & 1 deletion packages/hunk/src/app/documents/filesystemSource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ async function createTestRoot() {
return root;
}

// The bounded-enumeration fixture creates 10,001 directories; Windows cleanup needs its own budget.
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
}, 30_000);

describe("filesystem documents", () => {
test("lists only immediate children, directories first, and reads complete unchanged text", async () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/hunk/src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1731,6 +1731,11 @@ export function App({
expandedGapsByFileId={review.expandedGapsByFileId}
fileViews={fileViewLayouts}
files={filteredFiles}
emptyStateMessage={
reviewFiles.length === 0
? "No changes to review."
: "No files match the current filter."
}
visibleHunkIndexesByFileId={review.visibleHunkIndexesByFileId}
semanticFileIdentities={semanticFileIdentities}
lineHighlights={paintedLineHighlights}
Expand Down
19 changes: 19 additions & 0 deletions packages/hunk/src/ui/AppHost.interactions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2714,6 +2714,25 @@ describe("App interactions", () => {
}
});

test("an empty loaded review does not claim that a filter hid its files", async () => {
const bootstrap = createBootstrap();
bootstrap.changeset = { ...bootstrap.changeset, files: [] };
const setup = await testRender(<AppHost bootstrap={bootstrap} />, {
width: 120,
height: 24,
});

try {
await flush(setup);
expect(setup.captureCharFrame()).toContain("No changes to review.");
expect(setup.captureCharFrame()).not.toContain("current filter");
} finally {
await act(async () => {
setup.renderer.destroy();
});
}
});

test("filter focus accepts typed input and narrows the visible file set", async () => {
const setup = await testRender(<AppHost bootstrap={createBootstrap()} />, {
width: 240,
Expand Down
5 changes: 4 additions & 1 deletion packages/hunk/src/ui/components/panes/DiffPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ export function DiffPane({
expandedGapsByFileId = EMPTY_EXPANDED_GAPS_BY_FILE_ID,
fileViews = EMPTY_FILE_VIEWS,
files,
emptyStateMessage = "No changes to review.",
visibleHunkIndexesByFileId,
semanticFileIdentities,
lineHighlights = EMPTY_LINE_HIGHLIGHTS,
Expand Down Expand Up @@ -375,6 +376,8 @@ export function DiffPane({
/** Validated alternate layouts, keyed by file id; raw Pierre remains the fallback. */
fileViews?: ReadonlyMap<string, ResolvedFileViewLayout>;
files: DiffFile[];
/** Distinguish empty input from files hidden by the host's review projection. */
emptyStateMessage?: string;
/** Optional extension-owned hunk projection keyed by runtime file id. */
visibleHunkIndexesByFileId?: ReadonlyMap<string, ReadonlySet<number>> | null;
/** Already-projected semantic identities for selection invalidation. */
Expand Down Expand Up @@ -2688,7 +2691,7 @@ export function DiffPane({
</box>
) : (
<box style={{ flexGrow: 1, alignItems: "center", justifyContent: "center" }}>
<text fg={theme.muted}>No files match the current filter.</text>
<text fg={theme.muted}>{emptyStateMessage}</text>
</box>
)}
</box>
Expand Down
61 changes: 34 additions & 27 deletions packages/hunk/src/ui/components/ui-components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3905,34 +3905,41 @@ describe("UI components", () => {
expect(frame).not.toMatch(/q\s+quit/);
});

test("DiffPane renders an empty-state message when no files are visible", async () => {
const theme = resolveTheme("github-dark-default", null);
const frame = await captureFrame(
<DiffPane
diffContentWidth={72}
files={[]}
headerLabelWidth={40}
headerStatsWidth={16}
layout="split"
scrollRef={createRef()}
selectedFileId={undefined}
selectedHunkIndex={0}
separatorWidth={68}
showAgentNotes={false}
showLineNumbers={true}
showHunkHeaders={true}
wrapLines={false}
wrapToggleScrollTop={null}
theme={theme}
width={76}
onSelectFile={() => {}}
/>,
80,
10,
);
test.each([
[undefined, "No changes to review."],
["No files match the current filter.", "No files match the current filter."],
])(
"DiffPane distinguishes empty reviews from filtered reviews (%s)",
async (message, expected) => {
const theme = resolveTheme("github-dark-default", null);
const frame = await captureFrame(
<DiffPane
diffContentWidth={72}
files={[]}
emptyStateMessage={message}
headerLabelWidth={40}
headerStatsWidth={16}
layout="split"
scrollRef={createRef()}
selectedFileId={undefined}
selectedHunkIndex={0}
separatorWidth={68}
showAgentNotes={false}
showLineNumbers={true}
showHunkHeaders={true}
wrapLines={false}
wrapToggleScrollTop={null}
theme={theme}
width={76}
onSelectFile={() => {}}
/>,
80,
10,
);

expect(frame).toContain("No files match the current filter.");
});
expect(frame).toContain(expected);
},
);

test("DiffPane can hide line numbers while keeping diff signs visible", async () => {
const bootstrap = createBootstrap();
Expand Down
92 changes: 92 additions & 0 deletions test/pty/empty-review-integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test";
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { createPtyHarness } from "./harness";

const harness = createPtyHarness();

setDefaultTimeout(20_000);

afterEach(() => {
harness.cleanup();
});

/** Restore a disposable harness repository to its committed, clean state. */
function createTestCleanRepoFixture() {
const fixture = harness.createSidebarJumpRepoFixture();
const reset = Bun.spawnSync(["git", "reset", "--hard", "HEAD"], {
cwd: fixture.dir,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
});
expect(reset.exitCode).toBe(0);
return fixture;
}

describe("empty review messages (PTY)", () => {
test("a clean working tree reports no changes without suggesting an active filter", async () => {
const fixture = createTestCleanRepoFixture();
const session = await harness.launchHunk({
args: ["diff", "--no-extensions"],
cwd: fixture.dir,
cols: 120,
rows: 24,
});

try {
const frame = await session.waitForText(/No changes to review\./);
expect(frame).not.toContain("No files match");
session.resize({ cols: 60, rows: 12 });
await harness.waitForSnapshot(session, (text) => text.includes("No changes to review."));
} finally {
session.close();
}
});

test("empty patch stdin reports no changes without suggesting an active filter", async () => {
const fixture = createTestCleanRepoFixture();
const stdinFile = join(fixture.dir, "empty.patch");
writeFileSync(stdinFile, "");
const session = await harness.launchHunkWithFileBackedStdin({
stdinFile,
args: ["patch", "--no-extensions"],
cwd: fixture.dir,
cols: 120,
rows: 24,
});

try {
const frame = await session.waitForText(/No changes to review\./);
expect(frame).not.toContain("No files match");
} finally {
session.close();
}
});

test("a filter hiding an existing review still reports no matching files", async () => {
const fixture = harness.createSidebarJumpRepoFixture();
const session = await harness.launchHunk({
args: ["diff", "--no-extensions"],
cwd: fixture.dir,
cols: 120,
rows: 24,
});

try {
await session.waitForText(/alphaOnly = true/);
await session.press("tab");
await session.waitForText(/filter: type to filter files/);
await session.type("no-such-file");
const filtered = await session.waitForText(/No files match the current filter\./);
expect(filtered).not.toContain("No changes to review.");
await session.press("escape");
await harness.waitForSnapshot(
session,
(text) => text.includes("alphaOnly = true") && !text.includes("No files match"),
);
} finally {
session.close();
}
});
});
10 changes: 9 additions & 1 deletion website/tests/changelog-smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { expect, test } from "@playwright/test";
import { readFileSync } from "node:fs";

// Release channels change independently of this test; verify the browser preserves published labels.
const changelogIndex = readFileSync(
new URL("../src/content/docs/changelog/index.md", import.meta.url),
"utf8",
);
const prereleaseSeriesCount = changelogIndex.match(/^Prerelease ·/gm)?.length ?? 0;

test("the changelog is reachable from the main navigation", async ({ page }, testInfo) => {
await page.goto("/");
Expand Down Expand Up @@ -110,7 +118,7 @@ test("the index lists every series newest first and links each one", async ({ pa
expect(major > nextMajor || (major === nextMajor && minor > nextMinor)).toBe(true);
}

await expect(page.getByText(/^Prerelease ·/)).toHaveCount(0);
await expect(page.getByText(/^Prerelease ·/)).toHaveCount(prereleaseSeriesCount);
await expect(page.getByText(/^Latest ·/)).toHaveCount(1);
});

Expand Down
Loading