diff --git a/.changeset/beta-ci-smoke-coverage.md b/.changeset/beta-ci-smoke-coverage.md
new file mode 100644
index 000000000..a845151cc
--- /dev/null
+++ b/.changeset/beta-ci-smoke-coverage.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/.changeset/clear-empty-reviews.md b/.changeset/clear-empty-reviews.md
new file mode 100644
index 000000000..f94de30ca
--- /dev/null
+++ b/.changeset/clear-empty-reviews.md
@@ -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.
diff --git a/packages/hunk-git/src/status.test.ts b/packages/hunk-git/src/status.test.ts
index 86e190bc7..acdbe0055 100644
--- a/packages/hunk-git/src/status.test.ts
+++ b/packages/hunk-git/src/status.test.ts
@@ -1,4 +1,4 @@
-import { afterEach, describe, expect, test } from "bun:test";
+import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test";
import {
mkdtempSync,
mkdirSync,
@@ -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) {
diff --git a/packages/hunk/src/app/documents/filesystemSource.test.ts b/packages/hunk/src/app/documents/filesystemSource.test.ts
index 21adce9ae..2e7613f50 100644
--- a/packages/hunk/src/app/documents/filesystemSource.test.ts
+++ b/packages/hunk/src/app/documents/filesystemSource.test.ts
@@ -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 () => {
diff --git a/packages/hunk/src/ui/App.tsx b/packages/hunk/src/ui/App.tsx
index b17b8cf13..296dfe3b3 100644
--- a/packages/hunk/src/ui/App.tsx
+++ b/packages/hunk/src/ui/App.tsx
@@ -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}
diff --git a/packages/hunk/src/ui/AppHost.interactions.test.tsx b/packages/hunk/src/ui/AppHost.interactions.test.tsx
index db78100ce..cb4ed7bbb 100644
--- a/packages/hunk/src/ui/AppHost.interactions.test.tsx
+++ b/packages/hunk/src/ui/AppHost.interactions.test.tsx
@@ -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(, {
+ 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(, {
width: 240,
diff --git a/packages/hunk/src/ui/components/panes/DiffPane.tsx b/packages/hunk/src/ui/components/panes/DiffPane.tsx
index d4bd9f424..b7b5dee38 100644
--- a/packages/hunk/src/ui/components/panes/DiffPane.tsx
+++ b/packages/hunk/src/ui/components/panes/DiffPane.tsx
@@ -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,
@@ -375,6 +376,8 @@ export function DiffPane({
/** Validated alternate layouts, keyed by file id; raw Pierre remains the fallback. */
fileViews?: ReadonlyMap;
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> | null;
/** Already-projected semantic identities for selection invalidation. */
@@ -2688,7 +2691,7 @@ export function DiffPane({
) : (
- No files match the current filter.
+ {emptyStateMessage}
)}
diff --git a/packages/hunk/src/ui/components/ui-components.test.tsx b/packages/hunk/src/ui/components/ui-components.test.tsx
index 9c1b6d1fc..51bd77d51 100644
--- a/packages/hunk/src/ui/components/ui-components.test.tsx
+++ b/packages/hunk/src/ui/components/ui-components.test.tsx
@@ -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(
- {}}
- />,
- 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(
+ {}}
+ />,
+ 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();
diff --git a/test/pty/empty-review-integration.test.ts b/test/pty/empty-review-integration.test.ts
new file mode 100644
index 000000000..244e0f08c
--- /dev/null
+++ b/test/pty/empty-review-integration.test.ts
@@ -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();
+ }
+ });
+});
diff --git a/website/tests/changelog-smoke.spec.ts b/website/tests/changelog-smoke.spec.ts
index a1c7d14bf..25d31af8a 100644
--- a/website/tests/changelog-smoke.spec.ts
+++ b/website/tests/changelog-smoke.spec.ts
@@ -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("/");
@@ -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);
});