Skip to content
Open
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
14 changes: 9 additions & 5 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,12 @@ import { useHunkSessionBridge } from "./hooks/useHunkSessionBridge";
import { useMenuController } from "./hooks/useMenuController";
import { useReviewController } from "./hooks/useReviewController";
import { buildAppMenus } from "./lib/appMenus";
import type { FocusArea } from "./lib/focus";
import { fileRowId } from "./lib/ids";
import { resolveResponsiveLayout } from "./lib/responsive";
import { resizeSidebarWidth } from "./lib/sidebar";
import { resolveTheme, THEMES } from "./themes";

type FocusArea = "files" | "filter";

const FAST_CODE_HORIZONTAL_SCROLL_COLUMNS = 8;

const LazyHelpDialog = lazy(async () => ({
Expand Down Expand Up @@ -113,7 +112,7 @@ export function App({
const [sidebarVisible, setSidebarVisible] = useState(() => !pagerMode);
const [forceSidebarOpen, setForceSidebarOpen] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [focusArea, setFocusArea] = useState<FocusArea>("files");
const [focusArea, setFocusArea] = useState<FocusArea>("diff");
const [sidebarWidth, setSidebarWidth] = useState(34);
const [resizeDragOriginX, setResizeDragOriginX] = useState<number | null>(null);
const [resizeStartWidth, setResizeStartWidth] = useState<number | null>(null);
Expand Down Expand Up @@ -455,9 +454,11 @@ export function App({
setFocusArea("filter");
}, []);

/** Toggle keyboard focus between the file list and the file filter. */
// Tab cycles keyboard focus between the sidebar and the diff stream. The filter
// input is reached with `/`; Tab inside that input stays in the input so users
// can type the literal character without being kicked out.
const toggleFocusArea = useCallback(() => {
setFocusArea((current) => (current === "files" ? "filter" : "files"));
setFocusArea((current) => (current === "diff" ? "files" : "diff"));
}, []);

/** Cycle through the available built-in themes. */
Expand Down Expand Up @@ -548,6 +549,7 @@ export function App({
focusArea,
focusFilter,
moveToAnnotatedHunk,
moveToFile: review.moveToFile,
moveToHunk: review.moveToHunk,
moveMenuItem,
openMenu,
Expand Down Expand Up @@ -673,6 +675,7 @@ export function App({
<>
<SidebarPane
entries={review.sidebarEntries}
focused={focusArea === "files"}
scrollRef={sidebarScrollRef}
selectedFileId={selectedFile?.id}
textWidth={sidebarTextWidth}
Expand All @@ -687,6 +690,7 @@ export function App({
<PaneDivider
dividerHitLeft={dividerHitLeft}
dividerHitWidth={DIVIDER_HIT_WIDTH}
highlighted={focusArea === "files"}
isResizing={isResizingSidebar}
theme={activeTheme}
onMouseDown={beginSidebarResize}
Expand Down
73 changes: 62 additions & 11 deletions src/ui/AppHost.interactions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1018,18 +1018,18 @@ describe("App interactions", () => {
await setup.mockInput.pressKey("F10");
});
let frame = await waitForFrame(setup, (currentFrame) =>
currentFrame.includes("Toggle files/filter focus"),
currentFrame.includes("Toggle files/diff focus"),
);
if (!frame.includes("Toggle files/filter focus")) {
if (!frame.includes("Toggle files/diff focus")) {
await act(async () => {
await setup.mockInput.pressKey("F10");
});
frame = await waitForFrame(setup, (currentFrame) =>
currentFrame.includes("Toggle files/filter focus"),
currentFrame.includes("Toggle files/diff focus"),
);
}

expect(frame).toContain("Toggle files/filter focus");
expect(frame).toContain("Toggle files/diff focus");
expect(frame).toContain("Reload");
expect(frame).toContain("Quit");

Expand Down Expand Up @@ -1690,6 +1690,57 @@ describe("App interactions", () => {
}
});

test("Tab cycles focus between sidebar and diff, and ↓ steps the file selection while sidebar holds focus", async () => {
const { getLatestSnapshot, hostClient } = createMockHostClient();
const setup = await testRender(
<AppHost bootstrap={createBootstrap()} hostClient={hostClient} />,
{ width: 240, height: 24 },
);

try {
await flush(setup);

// Default focus is "diff": pressing ↓ scrolls the diff and leaves the
// file selection on alpha.ts.
expect(getLatestSnapshot()).toMatchObject({ selectedFilePath: "alpha.ts" });
await act(async () => {
await setup.mockInput.pressArrow("down");
});
await flush(setup);
expect(getLatestSnapshot()).toMatchObject({ selectedFilePath: "alpha.ts" });

// Tab once → sidebar holds focus, ↓ steps to the next visible file.
await act(async () => {
await setup.mockInput.pressTab();
});
await flush(setup);
await act(async () => {
await setup.mockInput.pressArrow("down");
});
const snapshotOnFiles = await waitForSnapshot(
setup,
getLatestSnapshot,
(snapshot) => snapshot.selectedFilePath === "beta.ts",
);
expect(snapshotOnFiles).toMatchObject({ selectedFilePath: "beta.ts" });

// Tab again → focus returns to the diff; ↓ no longer moves files.
await act(async () => {
await setup.mockInput.pressTab();
});
await flush(setup);
await act(async () => {
await setup.mockInput.pressArrow("down");
});
await flush(setup);
expect(getLatestSnapshot()).toMatchObject({ selectedFilePath: "beta.ts" });
} finally {
await act(async () => {
setup.renderer.destroy();
});
}
});

test("filter focus accepts typed input and narrows the visible file set", async () => {
const setup = await testRender(<AppHost bootstrap={createBootstrap()} />, {
width: 240,
Expand All @@ -1700,7 +1751,7 @@ describe("App interactions", () => {
await flush(setup);

await act(async () => {
await setup.mockInput.pressTab();
await setup.mockInput.typeText("/");
});
await flush(setup);
await act(async () => {
Expand Down Expand Up @@ -1729,7 +1780,7 @@ describe("App interactions", () => {
await flush(setup);

await act(async () => {
await setup.mockInput.pressTab();
await setup.mockInput.typeText("/");
});
await flush(setup);
await act(async () => {
Expand All @@ -1744,7 +1795,7 @@ describe("App interactions", () => {
expect(frame).not.toContain("Annotation for alpha.ts");

await act(async () => {
await setup.mockInput.pressTab();
await setup.mockInput.pressEnter();
});
await flush(setup);

Expand Down Expand Up @@ -1772,7 +1823,7 @@ describe("App interactions", () => {
await flush(setup);

await act(async () => {
await setup.mockInput.pressTab();
await setup.mockInput.typeText("/");
});
await flush(setup);
await act(async () => {
Expand Down Expand Up @@ -1863,7 +1914,7 @@ describe("App interactions", () => {
await flush(setup);

let frame = setup.captureCharFrame();
expect(frame).toContain("Toggle files/filter focus");
expect(frame).toContain("Toggle files/diff focus");
expect(frame).not.toContain("Controls help");

await act(async () => {
Expand All @@ -1873,15 +1924,15 @@ describe("App interactions", () => {

frame = setup.captureCharFrame();
expect(frame).toContain("Controls help");
expect(frame).not.toContain("Toggle files/filter focus");
expect(frame).not.toContain("Toggle files/diff focus");

await act(async () => {
await setup.mockInput.pressArrow("right");
});
await flush(setup);

frame = setup.captureCharFrame();
expect(frame).toContain("Toggle files/filter focus");
expect(frame).toContain("Toggle files/diff focus");
expect(frame).not.toContain("Controls help");
} finally {
await act(async () => {
Expand Down
2 changes: 1 addition & 1 deletion src/ui/AppHost.responsive.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ describe("responsive app", () => {
try {
await act(async () => {
await setup.renderOnce();
await setup.mockInput.pressTab();
await setup.mockInput.typeText("/");
await setup.renderOnce();
});

Expand Down
2 changes: 1 addition & 1 deletion src/ui/components/chrome/HelpDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export function HelpDialog({
title: "Review",
items: [
["/", "focus file filter"],
["Tab", "toggle files/filter focus"],
["Tab", "toggle files/diff focus"],
["F10", "open menus"],
[canRefresh ? "r / q" : "q", canRefresh ? "reload / quit" : "quit"],
],
Expand Down
5 changes: 4 additions & 1 deletion src/ui/components/panes/PaneDivider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { AppTheme } from "../../themes";
export function PaneDivider({
dividerHitLeft,
dividerHitWidth,
highlighted = false,
isResizing,
theme,
onMouseDown,
Expand All @@ -14,20 +15,22 @@ export function PaneDivider({
}: {
dividerHitLeft: number;
dividerHitWidth: number;
highlighted?: boolean;
isResizing: boolean;
theme: AppTheme;
onMouseDown: (event: TuiMouseEvent) => void;
onMouseDrag: (event: TuiMouseEvent) => void;
onMouseDragEnd: (event: TuiMouseEvent) => void;
onMouseUp: (event: TuiMouseEvent) => void;
}) {
const accent = isResizing || highlighted;
return (
<>
<box
style={{
width: 1,
border: ["top", "left"],
borderColor: isResizing ? theme.accent : theme.border,
borderColor: accent ? theme.accent : theme.border,
backgroundColor: isResizing ? theme.accentMuted : theme.panel,
}}
customBorderChars={{
Expand Down
4 changes: 3 additions & 1 deletion src/ui/components/panes/SidebarPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { FileGroupHeader, FileListItem } from "./FileListItem";
/** Render the file navigation sidebar. */
export function SidebarPane({
entries,
focused,
scrollRef,
selectedFileId,
textWidth,
Expand All @@ -15,6 +16,7 @@ export function SidebarPane({
onSelectFile,
}: {
entries: SidebarEntry[];
focused: boolean;
scrollRef: RefObject<ScrollBoxRenderable | null>;
selectedFileId?: string;
textWidth: number;
Expand All @@ -30,7 +32,7 @@ export function SidebarPane({
style={{
width,
border: ["top"],
borderColor: theme.border,
borderColor: focused ? theme.accent : theme.border,
backgroundColor: theme.panel,
paddingY: 1,
paddingX: 0,
Expand Down
81 changes: 80 additions & 1 deletion src/ui/components/ui-components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,37 @@ async function captureFrame(node: ReactNode, width = 120, height = 24) {
}
}

async function captureSpansFrame(node: ReactNode, width = 120, height = 24) {
const setup = await testRender(node, { width, height });

try {
await act(async () => {
await setup.renderOnce();
});

return setup.captureSpans();
} finally {
await act(async () => {
setup.renderer.destroy();
});
}
}

/** Pull the foreground colour of the first horizontal-border character on the
* top row of the rendered output. */
function topBorderColor(frame: {
lines: Array<{ spans: Array<{ text: string; fg?: { buffer?: ArrayLike<number> } }> }>;
}) {
const topLine = frame.lines[0];
if (!topLine) return null;
for (const span of topLine.spans) {
if (/[─━]/.test(span.text)) {
return capturedColorToHex(span.fg);
}
}
return null;
}

function frameHasHighlightedMarker(
frame: { lines: Array<{ spans: Array<{ text: string; fg?: unknown; bg?: unknown }> }> },
marker: string,
Expand Down Expand Up @@ -439,6 +470,7 @@ describe("UI components", () => {
const frame = await captureFrame(
<SidebarPane
entries={buildSidebarEntries(files)}
focused={false}
scrollRef={createRef()}
selectedFileId="app"
textWidth={28}
Expand All @@ -463,6 +495,53 @@ describe("UI components", () => {
expect(frame).not.toContain("M +2 -1 AI");
});

test("SidebarPane top-border colour shifts to theme.accent when focused", async () => {
const theme = resolveTheme("midnight", null);
const files = [
createTestDiffFile(
"app",
"src/ui/App.tsx",
"export const app = 1;\n",
"export const app = 2;\n",
),
];

const blurred = await captureSpansFrame(
<SidebarPane
entries={buildSidebarEntries(files)}
focused={false}
scrollRef={createRef()}
selectedFileId="app"
textWidth={28}
theme={theme}
width={32}
onSelectFile={() => {}}
/>,
36,
6,
);
const focused = await captureSpansFrame(
<SidebarPane
entries={buildSidebarEntries(files)}
focused={true}
scrollRef={createRef()}
selectedFileId="app"
textWidth={28}
theme={theme}
width={32}
onSelectFile={() => {}}
/>,
36,
6,
);

const blurredColor = topBorderColor(blurred);
const focusedColor = topBorderColor(focused);
expect(blurredColor).not.toBeNull();
expect(focusedColor).not.toBeNull();
expect(focusedColor).not.toBe(blurredColor);
});

test("DiffPane renders all diff sections in file order", async () => {
const bootstrap = createBootstrap();
const theme = resolveTheme("midnight", null);
Expand Down Expand Up @@ -1618,7 +1697,7 @@ describe("UI components", () => {
"l / w / m lines / wrap / metadata",
"Review",
"/ focus file filter",
"Tab toggle files/filter focus",
"Tab toggle files/diff focus",
"F10 open menus",
"r / q reload / quit",
] as const;
Expand Down
Loading
Loading