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
5 changes: 5 additions & 0 deletions .changeset/active-pane-dividers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Highlight active built-in and extension panes with accent-weighted separators.
2 changes: 1 addition & 1 deletion src/extensions/default/ui/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ describe("bundled UI registry", () => {
expect(reviewInfo).toMatchObject({
placement: "top",
defaultOpen: true,
height: { preferred: 3, min: 3, max: 3 },
height: { preferred: 2, min: 2, max: 2 },
});
expect(
reviewInfo.available?.({
Expand Down
20 changes: 7 additions & 13 deletions src/extensions/default/ui/reviewInfo/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,73 +36,67 @@ function backgroundsAtColumn(
}

describe("ReviewInfoPane", () => {
test("separates review chrome with an accent rail and panel background", async () => {
test("paints review metadata with an accent rail and panel background", async () => {
const theme = resolveTheme("github-dark-default", null);
const width = 30;
const setup = await testRender(
<ReviewInfoPane
{...({
review,
width,
height: 3,
height: 2,
theme,
} as unknown as ExtensionPaneProps)}
/>,
{ width, height: 3 },
{ width, height: 2 },
);

try {
await act(async () => {
await setup.renderOnce();
});
expect(backgroundsAtColumn(setup, 0)).toEqual([
theme.panel.toLowerCase(),
theme.accent.toLowerCase(),
theme.accent.toLowerCase(),
]);
expect(backgroundsAtColumn(setup, 1)).toEqual([
theme.panel.toLowerCase(),
theme.panel.toLowerCase(),
theme.panel.toLowerCase(),
]);
expect(backgroundsAtColumn(setup, width - 1)).toEqual([
theme.panel.toLowerCase(),
theme.panel.toLowerCase(),
theme.panel.toLowerCase(),
]);
expect(backgroundsAtColumn(setup, 1)).not.toContain(theme.panelAlt.toLowerCase());

const [primary, secondary] = reviewInfoLines(review, width - 3);
const frame = setup.captureCharFrame();
expect(frame.split("\n")[0]).toBe("─".repeat(width));
const borderSpan = setup.captureSpans().lines[0]?.spans.find((span) => span.width > 0);
expect(capturedTestColorToHex(borderSpan?.fg)).toBe(theme.border.toLowerCase());
expect(frame).toContain(` ${primary}`);
expect(frame).toContain(` ${secondary}`);
} finally {
setup.renderer.destroy();
}
});

test("keeps the border deterministic when no metadata text fits", async () => {
test("keeps the rail deterministic when no metadata text fits", async () => {
const theme = resolveTheme("github-dark-default", null);
const setup = await testRender(
<ReviewInfoPane
{...({
review,
width: 1,
height: 3,
height: 2,
theme,
} as unknown as ExtensionPaneProps)}
/>,
{ width: 1, height: 3 },
{ width: 1, height: 2 },
);

try {
await act(async () => {
await setup.renderOnce();
});
expect(setup.captureCharFrame().split("\n").slice(0, 3)).toEqual(["─", " ", " "]);
expect(setup.captureCharFrame().split("\n").slice(0, 2)).toEqual([" ", " "]);
} finally {
setup.renderer.destroy();
}
Expand Down
7 changes: 2 additions & 5 deletions src/extensions/default/ui/reviewInfo/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,11 @@ export function ReviewInfoPane({ review, theme, width }: ExtensionPaneProps): Re
<box
style={{
width: "100%",
height: 3,
height: 2,
flexDirection: "column",
backgroundColor: theme.panel,
}}
>
<text fg={theme.border} bg={theme.panel}>
{"─".repeat(Math.max(0, width))}
</text>
<box style={{ width: "100%", height: 2, flexDirection: "row" }}>
<box style={{ width: 1, height: 2, backgroundColor: theme.accent }} />
<box
Expand All @@ -47,7 +44,7 @@ const registerBundledReviewInfo: ExtensionFactory = (hunk) => {
id: BUNDLED_REVIEW_INFO_VIEW_ID,
title: "Review info",
placement: "top",
height: { preferred: 3, min: 3, max: 3 },
height: { preferred: 2, min: 2, max: 2 },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Architecture documentation is stale

The bundled review-info pane now has two content rows, with its separator drawn by the host. However, docs/extension-architecture.md still describes a fixed three-row pane that draws its own border. This mismatch could lead extension authors to rely on the obsolete ownership and geometry model.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/extensions/default/ui/reviewInfo/index.tsx
Line: 47

Comment:
**Architecture documentation is stale**

The bundled review-info pane now has two content rows, with its separator drawn by the host. However, `docs/extension-architecture.md` still describes a fixed three-row pane that draws its own border. This mismatch could lead extension authors to rely on the obsolete ownership and geometry model.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

defaultOpen: true,
available: ({ review }) => review?.kind === "change-request",
component: ReviewInfoPane,
Expand Down
67 changes: 42 additions & 25 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
MouseEvent as TuiMouseEvent,
ScrollBoxRenderable,
} from "@opentui/core";
import { MouseButton } from "@opentui/core";
import { useRenderer, useTerminalDimensions } from "@opentui/react";
import {
Suspense,
Expand Down Expand Up @@ -108,6 +109,7 @@ import { setMouseCapture } from "./lib/mouseCapture";
import { openSelectedFileInEditor } from "./lib/openInEditor";
import { resolveResponsiveLayout } from "./lib/responsive";
import type { WorkspaceRefreshRequest } from "./currentReviewRefresh";
import { useActivePaneController } from "./hooks/useActivePaneController";

type FocusArea = "files" | "filter" | "note";

Expand Down Expand Up @@ -480,6 +482,12 @@ export function App({
pagerMode,
responsiveShowsSidebar: responsiveLayout.showSidebar,
});
const visiblePaneKeys = useMemo(
() => paneLayout.panes.map(({ pane }) => pane.key),
[paneLayout.panes],
);
const { activePaneKey, activatePane, activateReview, paneSurfaceRef, reviewSurfaceRef } =
useActivePaneController({ renderer, visiblePaneKeys });

useEffect(() => {
if (resizingPaneKey === null) {
Expand Down Expand Up @@ -1199,6 +1207,7 @@ export function App({
return (
<box
key={pane.key}
ref={paneSurfaceRef(pane.key)}
style={{
position: "absolute",
left: bodyPadding / 2 + bounds.x,
Expand Down Expand Up @@ -1234,6 +1243,7 @@ export function App({
focusFiles();
return review.revealLine(fileId, side, line);
}}
onActivateSurface={activatePane}
onRenderFailure={
pane.key === HUNK_FILES_PANE_KEY ? undefined : () => reportPaneRenderFailure(pane)
}
Expand All @@ -1246,37 +1256,39 @@ export function App({
// so a fast motion or a sidebar projection swap cannot transfer the gesture to a transient row.
const beginCapturedPaneResize = (planned: PlannedPane, event: TuiMouseEvent) => {
if (!beginPaneResize(planned, event)) return;
activatePane(planned.pane.key);
if (paneResizeCaptureRef.current) {
setMouseCapture(renderer, paneResizeCaptureRef.current);
}
closeMenu();
};

const renderDivider = (planned: PlannedPane) =>
planned.divider ? (
<box
key={`${planned.pane.key}:divider`}
style={{
position: "absolute",
left: bodyPadding / 2 + planned.divider.x,
top: planned.divider.y,
width: planned.divider.width,
height: planned.divider.height,
}}
>
<PaneDivider
orientation={planned.divider.width === 1 ? "vertical" : "horizontal"}
width={planned.divider.width}
height={planned.divider.height}
isResizing={resizingPaneKey === planned.pane.key}
theme={activeTheme}
onMouseDown={(event) => beginCapturedPaneResize(planned, event)}
onMouseDrag={updatePaneResize}
onMouseDragEnd={endPaneResize}
onMouseUp={endPaneResize}
/>
</box>
) : null;
const renderDivider = (planned: PlannedPane) => (
<box
key={`${planned.pane.key}:divider`}
style={{
position: "absolute",
left: bodyPadding / 2 + planned.divider.x,
top: planned.divider.y,
width: planned.divider.width,
height: planned.divider.height,
}}
>
<PaneDivider
orientation={planned.divider.width === 1 ? "vertical" : "horizontal"}
width={planned.divider.width}
height={planned.divider.height}
isActive={activePaneKey === planned.pane.key}
isResizing={resizingPaneKey === planned.pane.key}
resizable={planned.resizable}
theme={activeTheme}
onMouseDown={(event) => beginCapturedPaneResize(planned, event)}
onMouseDrag={updatePaneResize}
onMouseDragEnd={endPaneResize}
onMouseUp={endPaneResize}
/>
</box>
);

return (
<box
Expand Down Expand Up @@ -1327,13 +1339,17 @@ export function App({
{paneLayout.panes.map(renderPane)}
{paneLayout.panes.map(renderDivider)}
<box
ref={reviewSurfaceRef}
style={{
position: "absolute",
left: bodyPadding / 2 + paneLayout.reviewBounds.x,
top: paneLayout.reviewBounds.y,
width: diffPaneWidth,
height: diffPaneHeight,
}}
onMouseDown={(event) => {
if (event.button === MouseButton.LEFT) activateReview();
}}
>
<DiffPane
cancelCopySelectionRef={cancelCopySelectionRef}
Expand Down Expand Up @@ -1394,6 +1410,7 @@ export function App({
scrollCodeHorizontally(delta * FAST_CODE_HORIZONTAL_SCROLL_COLUMNS);
}}
onCopyFeedback={showTransientNotice}
onActivateSurface={activateReview}
onFileViewRowFailure={reportFileViewRowFailure}
onSelectFile={jumpToFile}
onToggleGap={review.toggleGap}
Expand Down
12 changes: 11 additions & 1 deletion src/ui/AppHost.key-routing.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -295,16 +295,22 @@ describe("UI key routing with a focused scroll box", () => {
join(extension, "index.tsx"),
`import { createElement, useState } from "react";
export default function (hunk) {
let showPrompt = () => {};
hunk.registerPane({
id: "prompt",
placement: "bottom",
defaultOpen: true,
height: { preferred: 3, min: 3, max: 3 },
component: () => {
const [visible, setVisible] = useState(false);
const [value, setValue] = useState("");
return createElement("input", { value, focused: true, onInput: setValue });
showPrompt = () => setVisible(true);
return visible
? createElement("input", { value, focused: true, onInput: setValue })
: createElement("text", { content: "PROMPT CLOSED" });
},
});
hunk.registerCommand({ id: "focus", title: "Focus prompt", key: "o" }, () => showPrompt());
hunk.registerCommand({ id: "letter", title: "Letter command", key: "j" }, (ctx) => {
ctx.notify("COMMAND FIRED");
});
Expand All @@ -325,6 +331,9 @@ export default function (hunk) {
});

try {
await waitForFrame(setup, () => setup.captureCharFrame().includes("PROMPT CLOSED"), 12);
expect(setup.renderer.currentFocusedEditor).toBeNull();
await act(async () => setup.mockInput.typeText("o"));
await waitForFrame(setup, () => setup.renderer.currentFocusedEditor !== null, 12);
expect(setup.renderer.currentFocusedEditor).not.toBeNull();

Expand All @@ -333,6 +342,7 @@ export default function (hunk) {

const frame = setup.captureCharFrame();
expect(frame).toContain("j?");
expect(frame).toContain("━");
expect(frame).not.toContain("COMMAND FIRED");
expect(frame).not.toContain("Controls help");
} finally {
Expand Down
26 changes: 26 additions & 0 deletions src/ui/AppHost.review-metadata.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,32 @@ async function flushUntil(
}

describe("delegated review metadata reloads", () => {
test("gives the fixed built-in review pane an active host separator", async () => {
const fixture = await createTestBootstrap();
const setup = await testRender(<AppHost bootstrap={fixture.bootstrap} />, {
width: 100,
height: 12,
});
try {
await flushUntil(
setup,
() => setup.captureCharFrame().includes("OPEN · #123 · Metadata pane"),
"the delegated review pane to mount",
);
expect(setup.captureCharFrame().split("\n")[3]).toContain("─");

await act(async () => setup.mockMouse.click(50, 1));
await flushUntil(
setup,
() => setup.captureCharFrame().split("\n")[3]?.includes("━") === true,
"the fixed pane separator to activate",
);
} finally {
await act(async () => setup.renderer.destroy());
rmSync(fixture.directory, { recursive: true, force: true });
}
});

test("the bundled review pane occupies exactly three rows only for delegated change requests", async () => {
const delegated = await createTestBootstrap();
const ordinary = await createTestBootstrap();
Expand Down
27 changes: 26 additions & 1 deletion src/ui/AppHost.sidebar-resize.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,17 @@ async function flush(setup: Awaited<ReturnType<typeof testRender>>) {
/** Column of the vertical sidebar/diff divider on the probe row, or -1 when absent. */
function dividerColumn(setup: Awaited<ReturnType<typeof testRender>>) {
const row = setup.captureCharFrame().split("\n")[PROBE_ROW] ?? "";
return row.indexOf("│");
const normal = row.indexOf("│");
const active = row.indexOf("┃");
if (normal < 0) return active;
if (active < 0) return normal;
return Math.min(normal, active);
}

/** Read the sidebar divider's normal or emphasized vertical glyph. */
function dividerGlyph(setup: Awaited<ReturnType<typeof testRender>>) {
const row = setup.captureCharFrame().split("\n")[PROBE_ROW] ?? "";
return row[dividerColumn(setup)] ?? "";
}

/** Return only the file-sidebar columns so diff headers cannot satisfy sidebar assertions. */
Expand Down Expand Up @@ -139,6 +149,20 @@ afterEach(() => {
});

describe("AppHost sidebar resize", () => {
test("moves active emphasis between the built-in files pane and review", async () => {
setup = await testRender(<AppHost bootstrap={createResizeBootstrap()} />, WIDE);
await flush(setup);
expect(dividerGlyph(setup)).toBe("│");

await act(async () => setup!.mockMouse.click(8, PROBE_ROW));
await flush(setup);
expect(dividerGlyph(setup)).toBe("┃");

await act(async () => setup!.mockMouse.click(INITIAL_DIVIDER_COLUMN + 8, PROBE_ROW));
await flush(setup);
expect(dividerGlyph(setup)).toBe("│");
});

test("resizes the default sidebar with the terminal until the user drags it", async () => {
setup = await testRender(<AppHost bootstrap={createResizeBootstrap()} />, WIDE);
await flush(setup);
Expand Down Expand Up @@ -166,6 +190,7 @@ describe("AppHost sidebar resize", () => {

// The divider follows the new width: startWidth + (currentX - originX).
expect(dividerColumn(setup)).toBeGreaterThan(INITIAL_DIVIDER_COLUMN);
expect(dividerGlyph(setup)).toBe("┃");
});

test("resizing across the content-width threshold switches the file projection", async () => {
Expand Down
Loading
Loading