From 9ba8e2c5ae69a76783ab87ec952eb8633a56e77f Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Mon, 23 Mar 2026 09:31:55 -0400 Subject: [PATCH] feat: add reload shortcut for current diff --- src/ui/App.tsx | 75 +++++++++++++++++++++++++ src/ui/components/chrome/HelpDialog.tsx | 5 +- src/ui/components/chrome/StatusBar.tsx | 5 ++ src/ui/lib/appMenus.ts | 60 +++++++++++++------- test/app-interactions.test.tsx | 59 +++++++++++++++++++ test/ui-components.test.tsx | 3 +- test/ui-lib.test.ts | 7 +++ 7 files changed, 191 insertions(+), 23 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index d420f974..b26bf70d 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -55,6 +55,37 @@ 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, + view: { + layoutMode: LayoutMode; + themeId: string; + showAgentNotes: boolean; + showHunkHeaders: boolean; + showLineNumbers: boolean; + wrapLines: boolean; + }, +): CliInput { + return { + ...input, + options: { + ...input.options, + mode: view.layoutMode, + theme: view.themeId, + agentNotes: view.showAgentNotes, + hunkHeaders: view.showHunkHeaders, + lineNumbers: view.showLineNumbers, + wrapLines: view.wrapLines, + }, + }; +} + /** Orchestrate global app state, layout, navigation, and pane coordination. */ function AppShell({ bootstrap, @@ -321,6 +352,38 @@ function AppShell({ jumpToFile(fileId, hunkIndex); }; + const canRefreshCurrentInput = canRefreshInput(bootstrap.input); + + /** Rebuild the current diff source while preserving the active shell view options. */ + const refreshCurrentInput = useCallback(() => { + if (!canRefreshCurrentInput) { + return; + } + + const nextInput = withCurrentViewOptions(bootstrap.input, { + layoutMode, + themeId, + showAgentNotes, + showHunkHeaders, + showLineNumbers, + wrapLines, + }); + + void onReloadSession(nextInput).catch((error) => { + console.error("Failed to reload the current diff.", error); + }); + }, [ + bootstrap.input, + canRefreshCurrentInput, + layoutMode, + onReloadSession, + showAgentNotes, + showHunkHeaders, + showLineNumbers, + themeId, + wrapLines, + ]); + /** Leave the app through the shell-owned shutdown path. */ const requestQuit = useCallback(() => { onQuit(); @@ -330,11 +393,13 @@ function AppShell({ () => buildAppMenus({ activeThemeId: activeTheme.id, + canRefreshCurrentInput, focusFiles: () => setFocusArea("files"), focusFilter: () => setFocusArea("filter"), layoutMode, moveAnnotatedFile, moveHunk, + refreshCurrentInput, requestQuit, selectLayoutMode: setLayoutMode, selectThemeId: setThemeId, @@ -353,9 +418,11 @@ function AppShell({ }), [ activeTheme.id, + canRefreshCurrentInput, layoutMode, moveAnnotatedFile, moveHunk, + refreshCurrentInput, requestQuit, showAgentNotes, showHelp, @@ -645,6 +712,12 @@ function AppShell({ return; } + if ((key.name === "r" || key.sequence === "r") && canRefreshCurrentInput) { + refreshCurrentInput(); + closeMenu(); + return; + } + if (key.name === "t") { const currentIndex = THEMES.findIndex((theme) => theme.id === activeTheme.id); const nextIndex = (currentIndex + 1) % THEMES.length; @@ -785,6 +858,7 @@ function AppShell({ {!pagerMode ? ( Keyboard F10 menus arrows navigate menus Enter select Esc close menu 1 split 2 stack 0 auto t theme a notes l lines w wrap m meta + {canRefresh ? r reload the current diff : null} ↑/↓ line scroll space next page b previous page Home/End jump [ previous hunk ] next hunk diff --git a/src/ui/components/chrome/StatusBar.tsx b/src/ui/components/chrome/StatusBar.tsx index a97d4179..8142e263 100644 --- a/src/ui/components/chrome/StatusBar.tsx +++ b/src/ui/components/chrome/StatusBar.tsx @@ -3,6 +3,7 @@ import { fitText } from "../../lib/text"; /** Render either keyboard hints or the active file filter input. */ export function StatusBar({ + canRefresh = false, canResizeDivider = false, filter, filterFocused, @@ -12,6 +13,7 @@ export function StatusBar({ onFilterInput, onFilterSubmit, }: { + canRefresh?: boolean; canResizeDivider?: boolean; filter: string; filterFocused: boolean; @@ -25,6 +27,9 @@ export function StatusBar({ if (canResizeDivider) { hintParts.push("drag divider resize"); } + if (canRefresh) { + hintParts.push("r reload"); + } hintParts.push( "↑↓ line", "space/b page", diff --git a/src/ui/lib/appMenus.ts b/src/ui/lib/appMenus.ts index e2c47f0b..8c767613 100644 --- a/src/ui/lib/appMenus.ts +++ b/src/ui/lib/appMenus.ts @@ -4,11 +4,13 @@ import { THEMES } from "../themes"; export interface BuildAppMenusOptions { activeThemeId: string; + canRefreshCurrentInput: boolean; focusFiles: () => void; focusFilter: () => void; layoutMode: LayoutMode; moveAnnotatedFile: (delta: number) => void; moveHunk: (delta: number) => void; + refreshCurrentInput: () => void; requestQuit: () => void; selectLayoutMode: (mode: LayoutMode) => void; selectThemeId: (themeId: string) => void; @@ -29,11 +31,13 @@ export interface BuildAppMenusOptions { /** Build the top-level app menus from the current shell state and actions. */ export function buildAppMenus({ activeThemeId, + canRefreshCurrentInput, focusFiles, focusFilter, layoutMode, moveAnnotatedFile, moveHunk, + refreshCurrentInput, requestQuit, selectLayoutMode, selectThemeId, @@ -57,28 +61,42 @@ export function buildAppMenus({ action: () => selectThemeId(theme.id), })); + const fileMenuEntries: MenuEntry[] = [ + { + kind: "item", + label: "Focus files", + hint: "Tab", + action: focusFiles, + }, + { + kind: "item", + label: "Focus filter", + hint: "/", + action: focusFilter, + }, + ]; + + if (canRefreshCurrentInput) { + fileMenuEntries.push({ + kind: "item", + label: "Reload", + hint: "r", + action: refreshCurrentInput, + }); + } + + fileMenuEntries.push( + { kind: "separator" }, + { + kind: "item", + label: "Quit", + hint: "q", + action: requestQuit, + }, + ); + return { - file: [ - { - kind: "item", - label: "Focus files", - hint: "Tab", - action: focusFiles, - }, - { - kind: "item", - label: "Focus filter", - hint: "/", - action: focusFilter, - }, - { kind: "separator" }, - { - kind: "item", - label: "Quit", - hint: "q", - action: requestQuit, - }, - ], + file: fileMenuEntries, view: [ { kind: "item", diff --git a/test/app-interactions.test.tsx b/test/app-interactions.test.tsx index 3ac55c0b..d9b70865 100644 --- a/test/app-interactions.test.tsx +++ b/test/app-interactions.test.tsx @@ -1,9 +1,13 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, mock, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { parseDiffFromFile } from "@pierre/diffs"; import { act } from "react"; import type { AppBootstrap, DiffFile, LayoutMode } from "../src/core/types"; +const { loadAppBootstrap } = await import("../src/core/loaders"); const { App } = await import("../src/ui/App"); function createDiffFile( @@ -352,6 +356,7 @@ describe("App interactions", () => { } expect(frame).toContain("Focus files"); + expect(frame).toContain("Reload"); expect(frame).toContain("Quit"); await act(async () => { @@ -377,6 +382,60 @@ describe("App interactions", () => { } }); + test("reload shortcut reloads the current file diff from disk", async () => { + const dir = mkdtempSync(join(tmpdir(), "hunk-reload-")); + 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", + }, + }); + + 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"); + + await act(async () => { + await setup.mockInput.typeText("r"); + }); + + let refreshed = false; + for (let attempt = 0; attempt < 20; 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/ui-components.test.tsx b/test/ui-components.test.tsx index ca54c690..de7267f8 100644 --- a/test/ui-components.test.tsx +++ b/test/ui-components.test.tsx @@ -636,13 +636,14 @@ describe("UI components", () => { test("HelpDialog renders the keyboard help copy", async () => { const theme = resolveTheme("midnight", null); const frame = await captureFrame( - {}} />, + {}} />, 76, 14, ); expect(frame).toContain("Keyboard"); expect(frame).toContain("F10 menus"); + expect(frame).toContain("r reload"); expect(frame).toContain("drag the Files/Diff divider"); }); diff --git a/test/ui-lib.test.ts b/test/ui-lib.test.ts index 79995d20..76b8062f 100644 --- a/test/ui-lib.test.ts +++ b/test/ui-lib.test.ts @@ -97,11 +97,13 @@ describe("ui helpers", () => { test("buildAppMenus creates checked entries from the current shell state", () => { const menus = buildAppMenus({ activeThemeId: "graphite", + canRefreshCurrentInput: true, focusFiles: () => {}, focusFilter: () => {}, layoutMode: "stack", moveAnnotatedFile: () => {}, moveHunk: () => {}, + refreshCurrentInput: () => {}, requestQuit: () => {}, selectLayoutMode: () => {}, selectThemeId: () => {}, @@ -119,6 +121,11 @@ describe("ui helpers", () => { wrapLines: true, }); + expect( + menus.file + .filter((entry): entry is Extract => entry.kind === "item") + .map((entry) => entry.label), + ).toEqual(["Focus files", "Focus filter", "Reload", "Quit"]); expect( menus.view .filter(