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); });