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
75 changes: 75 additions & 0 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand All @@ -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,
Expand All @@ -353,9 +418,11 @@ function AppShell({
}),
[
activeTheme.id,
canRefreshCurrentInput,
layoutMode,
moveAnnotatedFile,
moveHunk,
refreshCurrentInput,
requestQuit,
showAgentNotes,
showHelp,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -785,6 +858,7 @@ function AppShell({

{!pagerMode ? (
<StatusBar
canRefresh={canRefreshCurrentInput}
canResizeDivider={showFilesPane}
filter={filter}
filterFocused={focusArea === "filter"}
Expand Down Expand Up @@ -817,6 +891,7 @@ function AppShell({
{!pagerMode && showHelp ? (
<Suspense fallback={null}>
<LazyHelpDialog
canRefresh={canRefreshCurrentInput}
left={helpLeft}
theme={activeTheme}
width={helpWidth}
Expand Down
5 changes: 4 additions & 1 deletion src/ui/components/chrome/HelpDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import type { AppTheme } from "../../themes";

/** Render the compact keyboard help overlay. */
export function HelpDialog({
canRefresh = false,
left,
theme,
width,
onClose,
}: {
canRefresh?: boolean;
left: number;
theme: AppTheme;
width: number;
Expand All @@ -19,7 +21,7 @@ export function HelpDialog({
top: 3,
left,
width,
height: 9,
height: canRefresh ? 10 : 9,
zIndex: 60,
border: true,
borderColor: theme.accent,
Expand All @@ -33,6 +35,7 @@ export function HelpDialog({
<text fg={theme.text}>Keyboard</text>
<text fg={theme.muted}>F10 menus arrows navigate menus Enter select Esc close menu</text>
<text fg={theme.muted}>1 split 2 stack 0 auto t theme a notes l lines w wrap m meta</text>
{canRefresh ? <text fg={theme.muted}>r reload the current diff</text> : null}
<text fg={theme.muted}>
↑/↓ line scroll space next page b previous page Home/End jump [ previous hunk ] next hunk
</text>
Expand Down
5 changes: 5 additions & 0 deletions src/ui/components/chrome/StatusBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -12,6 +13,7 @@ export function StatusBar({
onFilterInput,
onFilterSubmit,
}: {
canRefresh?: boolean;
canResizeDivider?: boolean;
filter: string;
filterFocused: boolean;
Expand All @@ -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",
Expand Down
60 changes: 39 additions & 21 deletions src/ui/lib/appMenus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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",
Expand Down
59 changes: 59 additions & 0 deletions test/app-interactions.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -352,6 +356,7 @@ describe("App interactions", () => {
}

expect(frame).toContain("Focus files");
expect(frame).toContain("Reload");
expect(frame).toContain("Quit");

await act(async () => {
Expand All @@ -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(<App bootstrap={bootstrap} />, {
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 = {
Expand Down
3 changes: 2 additions & 1 deletion test/ui-components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -636,13 +636,14 @@ describe("UI components", () => {
test("HelpDialog renders the keyboard help copy", async () => {
const theme = resolveTheme("midnight", null);
const frame = await captureFrame(
<HelpDialog left={2} theme={theme} width={68} onClose={() => {}} />,
<HelpDialog canRefresh={true} left={2} theme={theme} width={68} onClose={() => {}} />,
76,
14,
);

expect(frame).toContain("Keyboard");
expect(frame).toContain("F10 menus");
expect(frame).toContain("r reload");
expect(frame).toContain("drag the Files/Diff divider");
});

Expand Down
Loading
Loading