diff --git a/README.md b/README.md index fd5734b5..e7147795 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Hunk is a review-first terminal diff viewer for agent-authored changesets, built - multi-file review stream with sidebar navigation - inline AI and agent annotations beside the code - split, stack, and responsive auto layouts +- watch mode for auto-reloading file and Git-backed reviews - keyboard, mouse, pager, and Git difftool support @@ -52,6 +53,7 @@ Hunk mirrors Git's diff-style commands, but opens the changeset in a review UI i ```bash hunk diff # review current repo changes hunk diff --staged +hunk diff --watch # auto-reload as the working tree changes hunk show # review the latest commit hunk show HEAD~1 # review an earlier commit ``` @@ -59,8 +61,9 @@ hunk show HEAD~1 # review an earlier commit ### Working with raw files and patches ```bash -hunk diff before.ts after.ts # compare two files directly -git diff --no-color | hunk patch - # review a patch from stdin +hunk diff before.ts after.ts # compare two files directly +hunk diff before.ts after.ts --watch # auto-reload when either file changes +git diff --no-color | hunk patch - # review a patch from stdin ``` ### Working with agents diff --git a/src/core/cli.ts b/src/core/cli.ts index c8a29c23..26d80952 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -54,6 +54,7 @@ function buildCommonOptions( theme?: string; agentContext?: string; pager?: boolean; + watch?: boolean; }, argv: string[], ): CommonOptions { @@ -62,6 +63,7 @@ function buildCommonOptions( theme: options.theme, agentContext: options.agentContext, pager: options.pager ? true : undefined, + watch: options.watch ? true : undefined, lineNumbers: resolveBooleanFlag(argv, "--line-numbers", "--no-line-numbers"), wrapLines: resolveBooleanFlag(argv, "--wrap", "--no-wrap"), hunkHeaders: resolveBooleanFlag(argv, "--hunk-headers", "--no-hunk-headers"), @@ -69,7 +71,7 @@ function buildCommonOptions( }; } -/** Attach the shared mode/theme/agent-context flags to a subcommand parser. */ +/** Attach the shared view flags to a subcommand parser. */ function applyCommonOptions(command: Command) { return command .option("--mode ", "layout mode: auto, split, stack", parseLayoutMode) @@ -86,6 +88,11 @@ function applyCommonOptions(command: Command) { .option("--no-agent-notes", "hide agent notes by default"); } +/** Attach auto-refresh support to review commands that can reopen their source input. */ +function applyWatchOption(command: Command) { + return command.option("--watch", "auto-reload when the current diff input changes"); +} + /** Resolve the CLI version from the nearest shipped package manifest. */ function resolveCliVersion() { const candidatePaths = [ @@ -223,7 +230,9 @@ function resolveExplicitSessionSelector( /** Parse the overloaded `hunk diff` command. */ async function parseDiffCommand(tokens: string[], argv: string[]): Promise { const { commandTokens, pathspecs } = splitPathspecArgs(tokens); - const command = createCommand("diff", "review Git diffs or compare two concrete files") + const command = applyWatchOption( + createCommand("diff", "review Git diffs or compare two concrete files"), + ) .option("--staged", "show staged changes instead of the working tree") .option("--cached", "alias for --staged") .argument("[targets...]"); @@ -287,7 +296,9 @@ async function parseDiffCommand(tokens: string[], argv: string[]): Promise { const { commandTokens, pathspecs } = splitPathspecArgs(tokens); - const command = createCommand("show", "review the last commit or a given ref").argument("[ref]"); + const command = applyWatchOption( + createCommand("show", "review the last commit or a given ref"), + ).argument("[ref]"); let parsedRef: string | undefined; let parsedOptions: Record = {}; @@ -313,9 +324,8 @@ async function parseShowCommand(tokens: string[], argv: string[]): Promise { - const command = createCommand( - "patch", - "review a patch file, or read a patch from stdin", + const command = applyWatchOption( + createCommand("patch", "review a patch file, or read a patch from stdin"), ).argument("[file]"); let parsedFile: string | undefined; @@ -365,7 +375,7 @@ async function parsePagerCommand( /** Parse Git difftool-style two-file review commands. */ async function parseDifftoolCommand(tokens: string[], argv: string[]): Promise { - const command = createCommand("difftool", "review Git difftool file pairs") + const command = applyWatchOption(createCommand("difftool", "review Git difftool file pairs")) .argument("") .argument("") .argument("[path]"); @@ -912,9 +922,8 @@ async function parseStashCommand(tokens: string[], argv: string[]): Promise) { + switch (input.kind) { + case "git": + return runGitText({ input, args: buildGitDiffArgs(input) }); + case "show": + return runGitText({ input, args: buildGitShowArgs(input) }); + case "stash-show": + return runGitText({ input, args: buildGitStashShowArgs(input) }); + } +} + +/** Compute a change-detection signature for one watchable input. */ +export function computeWatchSignature(input: CliInput) { + const parts: string[] = [input.kind]; + + switch (input.kind) { + case "git": + case "show": + case "stash-show": + parts.push(gitPatchSignature(input)); + break; + case "diff": + case "difftool": + parts.push(statSignature(input.left), statSignature(input.right)); + break; + case "patch": + if (!input.file || input.file === "-") { + throw new Error("Watch mode requires a patch file path instead of stdin."); + } + parts.push(statSignature(input.file)); + break; + } + + if (input.options.agentContext && input.options.agentContext !== "-") { + parts.push(`agent:${statSignature(input.options.agentContext)}`); + } + + return parts.join("\n---\n"); +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index f213338d..90bde4d4 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -20,6 +20,7 @@ import { resolveConfiguredCliInput } from "../core/config"; import { loadAppBootstrap } from "../core/loaders"; import { resolveRuntimeCliInput } from "../core/terminal"; import type { AppBootstrap, CliInput, LayoutMode } from "../core/types"; +import { canReloadInput, computeWatchSignature } from "../core/watch"; import { HunkHostClient } from "../mcp/client"; import { createInitialSessionSnapshot, @@ -55,11 +56,6 @@ function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } -/** Return whether the current input can be reloaded from disk or Git state. */ -function canRefreshInput(input: CliInput) { - return input.kind !== "patch" || Boolean(input.file && input.file !== "-"); -} - /** Preserve the active shell view settings when rebuilding the current input. */ function withCurrentViewOptions( input: CliInput, @@ -96,7 +92,10 @@ function AppShell({ bootstrap: AppBootstrap; hostClient?: HunkHostClient; onQuit?: () => void; - onReloadSession: (nextInput: CliInput) => Promise; + onReloadSession: ( + nextInput: CliInput, + options?: { resetShell?: boolean }, + ) => Promise; }) { const FILES_MIN_WIDTH = 22; const DIFF_MIN_WIDTH = 48; @@ -352,10 +351,11 @@ function AppShell({ jumpToFile(fileId, hunkIndex); }; - const canRefreshCurrentInput = canRefreshInput(bootstrap.input); + const canRefreshCurrentInput = canReloadInput(bootstrap.input); + const watchEnabled = Boolean(bootstrap.input.options.watch && canRefreshCurrentInput); /** Rebuild the current diff source while preserving the active shell view options. */ - const refreshCurrentInput = useCallback(() => { + const refreshCurrentInput = useCallback(async () => { if (!canRefreshCurrentInput) { return; } @@ -369,9 +369,7 @@ function AppShell({ wrapLines, }); - void onReloadSession(nextInput).catch((error) => { - console.error("Failed to reload the current diff.", error); - }); + await onReloadSession(nextInput, { resetShell: false }); }, [ bootstrap.input, canRefreshCurrentInput, @@ -384,6 +382,63 @@ function AppShell({ wrapLines, ]); + const triggerRefreshCurrentInput = useCallback(() => { + void refreshCurrentInput().catch((error) => { + console.error("Failed to reload the current diff.", error); + }); + }, [refreshCurrentInput]); + + useEffect(() => { + if (!watchEnabled) { + return; + } + + let cancelled = false; + let polling = false; + let refreshing = false; + let lastSignature: string; + + try { + lastSignature = computeWatchSignature(bootstrap.input); + } catch (error) { + console.error("Failed to initialize watch mode.", error); + return; + } + + const pollForChanges = () => { + if (cancelled || polling || refreshing) { + return; + } + + polling = true; + + try { + const nextSignature = computeWatchSignature(bootstrap.input); + if (nextSignature !== lastSignature) { + lastSignature = nextSignature; + refreshing = true; + void refreshCurrentInput() + .catch((error) => { + console.error("Failed to auto-reload the current diff.", error); + }) + .finally(() => { + refreshing = false; + }); + } + } catch (error) { + console.error("Failed to poll watch mode input.", error); + } finally { + polling = false; + } + }; + + const interval = setInterval(pollForChanges, 250); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [bootstrap.input, refreshCurrentInput, watchEnabled]); + /** Leave the app through the shell-owned shutdown path. */ const requestQuit = useCallback(() => { onQuit(); @@ -399,7 +454,7 @@ function AppShell({ layoutMode, moveAnnotatedFile, moveHunk, - refreshCurrentInput, + refreshCurrentInput: triggerRefreshCurrentInput, requestQuit, selectLayoutMode: setLayoutMode, selectThemeId: setThemeId, @@ -422,8 +477,8 @@ function AppShell({ layoutMode, moveAnnotatedFile, moveHunk, - refreshCurrentInput, requestQuit, + triggerRefreshCurrentInput, showAgentNotes, showHelp, showHunkHeaders, @@ -711,7 +766,7 @@ function AppShell({ } if ((key.name === "r" || key.sequence === "r") && canRefreshCurrentInput) { - refreshCurrentInput(); + triggerRefreshCurrentInput(); closeMenu(); return; } @@ -913,7 +968,7 @@ export function App({ const [shellVersion, setShellVersion] = useState(0); const reloadSession = useCallback( - async (nextInput: CliInput) => { + async (nextInput: CliInput, options?: { resetShell?: boolean }) => { const runtimeInput = resolveRuntimeCliInput(nextInput); const configuredInput = resolveConfiguredCliInput(runtimeInput).input; const nextBootstrap = await loadAppBootstrap(configuredInput); @@ -930,7 +985,9 @@ export function App({ } setActiveBootstrap(nextBootstrap); - setShellVersion((current) => current + 1); + if (options?.resetShell !== false) { + setShellVersion((current) => current + 1); + } return { sessionId, diff --git a/src/ui/hooks/useHunkSessionBridge.ts b/src/ui/hooks/useHunkSessionBridge.ts index be9092a4..6d3d69c9 100644 --- a/src/ui/hooks/useHunkSessionBridge.ts +++ b/src/ui/hooks/useHunkSessionBridge.ts @@ -31,7 +31,10 @@ export function useHunkSessionBridge({ hostClient?: HunkHostClient; jumpToFile: (fileId: string, nextHunkIndex?: number) => void; openAgentNotes: () => void; - reloadSession: (nextInput: CliInput) => Promise; + reloadSession: ( + nextInput: CliInput, + options?: { resetShell?: boolean }, + ) => Promise; selectedFile: DiffFile | undefined; selectedHunkIndex: number; showAgentNotes: boolean; diff --git a/test/app-interactions.test.tsx b/test/app-interactions.test.tsx index 457e5d83..cf85adb0 100644 --- a/test/app-interactions.test.tsx +++ b/test/app-interactions.test.tsx @@ -436,6 +436,57 @@ describe("App interactions", () => { } }); + test("watch mode reloads the current file diff from disk", async () => { + const dir = mkdtempSync(join(tmpdir(), "hunk-watch-")); + const left = join(dir, "before.ts"); + const right = join(dir, "after.ts"); + + writeFileSync(left, "export const answer = 41;\n"); + writeFileSync(right, "export const answer = 42;\n"); + + const bootstrap = await loadAppBootstrap({ + kind: "diff", + left, + right, + options: { + mode: "split", + watch: true, + }, + }); + + const setup = await testRender(, { + width: 220, + height: 20, + }); + + try { + await flush(setup); + + let frame = setup.captureCharFrame(); + expect(frame).not.toContain("export const added = true;"); + + writeFileSync(right, "export const answer = 42;\nexport const added = true;\n"); + + let refreshed = false; + for (let attempt = 0; attempt < 40; attempt += 1) { + await flush(setup); + frame = setup.captureCharFrame(); + if (frame.includes("export const added = true;")) { + refreshed = true; + break; + } + await Bun.sleep(25); + } + + expect(refreshed).toBe(true); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + rmSync(dir, { force: true, recursive: true }); + } + }); + test("a shows notes that are visible in the current review viewport", async () => { const bootstrap = createBootstrap(); bootstrap.changeset.files[1]!.agent = { diff --git a/test/cli.test.ts b/test/cli.test.ts index c73cde71..e5ec9abf 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -68,6 +68,7 @@ describe("parseCli", () => { "--wrap", "--no-hunk-headers", "--agent-notes", + "--watch", ]); expect(parsed).toMatchObject({ @@ -78,6 +79,7 @@ describe("parseCli", () => { mode: "split", theme: "paper", agentContext: "notes.json", + watch: true, lineNumbers: false, wrapLines: true, hunkHeaders: false, diff --git a/test/startup.test.ts b/test/startup.test.ts index 0ab6c9d9..dcea9b78 100644 --- a/test/startup.test.ts +++ b/test/startup.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { HunkUserError } from "../src/core/errors"; import { prepareStartupPlan } from "../src/core/startup"; import type { AppBootstrap, CliInput, ParsedCliInput } from "../src/core/types"; @@ -120,6 +121,24 @@ describe("startup planning", () => { expect(seenInputs).toHaveLength(3); }); + test("rejects watch mode for stdin-backed patch inputs", async () => { + const cliInput: CliInput = { + kind: "patch", + file: "-", + options: { + watch: true, + }, + }; + + await expect( + prepareStartupPlan(["bun", "hunk", "patch", "-", "--watch"], { + parseCliImpl: async () => cliInput as ParsedCliInput, + resolveRuntimeCliInputImpl: (input) => input, + resolveConfiguredCliInputImpl: (input) => ({ input }) as never, + }), + ).rejects.toBeInstanceOf(HunkUserError); + }); + test("opens the controlling terminal for piped patch startup", async () => { const cliInput: CliInput = { kind: "patch",