Skip to content
59 changes: 56 additions & 3 deletions src/ui/components/panes/DiffPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { DiffSection } from "./DiffSection";
import { DiffFileHeaderRow } from "./DiffFileHeaderRow";
import { DiffSectionPlaceholder } from "./DiffSectionPlaceholder";
import { VerticalScrollbar, type VerticalScrollbarHandle } from "../scrollbar/VerticalScrollbar";
import type { VisibleBodyBounds } from "../../diff/rowWindowing";
import { prefetchHighlightedDiff } from "../../diff/useHighlightedDiff";

const EMPTY_VISIBLE_AGENT_NOTES: VisibleAgentNote[] = [];
Expand Down Expand Up @@ -385,9 +386,9 @@ export function DiffPane({
);

const visibleViewportFileIds = useMemo(() => {
const overscanRows = 8;
const minVisibleY = Math.max(0, scrollViewport.top - overscanRows);
const maxVisibleY = scrollViewport.top + scrollViewport.height + overscanRows;
const overscanTerminalRows = 8;
const minVisibleY = Math.max(0, scrollViewport.top - overscanTerminalRows);
const maxVisibleY = scrollViewport.top + scrollViewport.height + overscanTerminalRows;
return collectIntersectingFileSectionIds(baseFileSectionLayouts, minVisibleY, maxVisibleY);
}, [baseFileSectionLayouts, scrollViewport.height, scrollViewport.top]);

Expand Down Expand Up @@ -589,6 +590,56 @@ export function DiffPane({

return next;
}, [adjacentPrefetchFileIds, selectedFileId, visibleViewportFileIds, windowingEnabled]);
const visibleBodyBoundsByFile = useMemo(() => {
const next = new Map<string, VisibleBodyBounds>();
if (scrollViewport.height <= 0) {
return next;
}

const overscanTerminalRows = Math.max(24, scrollViewport.height * 2);

files.forEach((file, index) => {
const sectionLayout = fileSectionLayouts[index];
const geometry = sectionGeometry[index];
if (!sectionLayout || !geometry) {
return;
}

const shouldRenderSection = visibleWindowedFileIds?.has(file.id) ?? true;
if (!shouldRenderSection) {
return;
}

// Convert the absolute review-stream viewport into file-body-local coordinates.
// Example: if the viewport starts at row 2_000 globally and this file body starts at row
// 1_940, then the file-local visible top is 60 rows into this file.
const minTop = scrollViewport.top - sectionLayout.bodyTop - overscanTerminalRows;
const maxBottom =
scrollViewport.top + scrollViewport.height - sectionLayout.bodyTop + overscanTerminalRows;

// Keep the mounted rows bounded to the viewport slice. Selection reveal uses planned hunk
// geometry as its fallback, so mounting an offscreen selected hunk is not necessary and would
// remount very large hunks in full.

// Clamp the requested file-local interval back into the real body extent, then store it as
// { top, height } so the row slicer can rebuild the matching [top, bottom) window later.
const clampedTop = Math.min(geometry.bodyHeight, Math.max(0, minTop));
const clampedBottom = Math.min(geometry.bodyHeight, Math.max(clampedTop, maxBottom));
next.set(file.id, {
top: clampedTop,
height: clampedBottom - clampedTop,
});
});

return next;
}, [
fileSectionLayouts,
files,
scrollViewport.height,
scrollViewport.top,
sectionGeometry,
visibleWindowedFileIds,
]);

const selectedFileIndex = selectedFileId
? files.findIndex((file) => file.id === selectedFileId)
Expand Down Expand Up @@ -1085,6 +1136,7 @@ export function DiffPane({
layout={layout}
selectedHunkIndex={file.id === selectedFileId ? selectedHunkIndex : -1}
shouldLoadHighlight={highlightPrefetchFileIds.has(file.id)}
sectionGeometry={sectionGeometry[index]}
separatorWidth={separatorWidth}
showHeader={shouldRenderInStreamFileHeader(index)}
showSeparator={index > 0}
Expand All @@ -1096,6 +1148,7 @@ export function DiffPane({
visibleAgentNotes={
visibleAgentNotesByFile.get(file.id) ?? EMPTY_VISIBLE_AGENT_NOTES
}
visibleBodyBounds={visibleBodyBoundsByFile.get(file.id)}
onOpenAgentNotesAtHunk={(hunkIndex) =>
onOpenAgentNotesAtHunk(file.id, hunkIndex)
}
Expand Down
10 changes: 10 additions & 0 deletions src/ui/components/panes/DiffSection.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { memo } from "react";
import type { DiffFile, LayoutMode } from "../../../core/types";
import { PierreDiffView } from "../../diff/PierreDiffView";
import type { VisibleBodyBounds } from "../../diff/rowWindowing";
import type { DiffSectionGeometry } from "../../lib/diffSectionGeometry";
import { getAnnotatedHunkIndices, type VisibleAgentNote } from "../../lib/agentAnnotations";
import { diffSectionId } from "../../lib/ids";
import { fitText } from "../../lib/text";
Expand All @@ -15,6 +17,7 @@ interface DiffSectionProps {
layout: Exclude<LayoutMode, "auto">;
selectedHunkIndex: number;
shouldLoadHighlight: boolean;
sectionGeometry?: DiffSectionGeometry;
separatorWidth: number;
showLineNumbers: boolean;
showHunkHeaders: boolean;
Expand All @@ -23,6 +26,7 @@ interface DiffSectionProps {
showSeparator: boolean;
theme: AppTheme;
visibleAgentNotes: VisibleAgentNote[];
visibleBodyBounds?: VisibleBodyBounds;
viewWidth: number;
onOpenAgentNotesAtHunk: (hunkIndex: number) => void;
onSelect: () => void;
Expand All @@ -37,6 +41,7 @@ function DiffSectionComponent({
layout,
selectedHunkIndex,
shouldLoadHighlight,
sectionGeometry,
separatorWidth,
showLineNumbers,
showHunkHeaders,
Expand All @@ -45,6 +50,7 @@ function DiffSectionComponent({
showSeparator,
theme,
visibleAgentNotes,
visibleBodyBounds,
viewWidth,
onOpenAgentNotesAtHunk,
onSelect,
Expand Down Expand Up @@ -98,9 +104,11 @@ function DiffSectionComponent({
visibleAgentNotes={visibleAgentNotes}
onOpenAgentNotesAtHunk={onOpenAgentNotesAtHunk}
selectedHunkIndex={selectedHunkIndex}
sectionGeometry={sectionGeometry}
shouldLoadHighlight={shouldLoadHighlight}
// The parent review stream owns scrolling across files.
scrollable={false}
visibleBodyBounds={visibleBodyBounds}
/>
</box>
);
Expand All @@ -117,6 +125,7 @@ export const DiffSection = memo(DiffSectionComponent, (previous, next) => {
previous.layout === next.layout &&
previous.selectedHunkIndex === next.selectedHunkIndex &&
previous.shouldLoadHighlight === next.shouldLoadHighlight &&
previous.sectionGeometry === next.sectionGeometry &&
previous.separatorWidth === next.separatorWidth &&
previous.showLineNumbers === next.showLineNumbers &&
previous.showHunkHeaders === next.showHunkHeaders &&
Expand All @@ -125,6 +134,7 @@ export const DiffSection = memo(DiffSectionComponent, (previous, next) => {
previous.showSeparator === next.showSeparator &&
previous.theme === next.theme &&
previous.visibleAgentNotes === next.visibleAgentNotes &&
previous.visibleBodyBounds === next.visibleBodyBounds &&
previous.viewWidth === next.viewWidth
);
});
28 changes: 28 additions & 0 deletions src/ui/components/ui-components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1001,6 +1001,34 @@ describe("UI components", () => {
}
});

test("DiffPane keeps a distant selected hunk visible when row windowing narrows one file body", async () => {
const theme = resolveTheme("midnight", null);
const props = createDiffPaneProps([createWideTwoHunkDiffFile("target", "target.ts")], theme, {
diffContentWidth: 96,
headerLabelWidth: 48,
selectedFileId: "target",
selectedHunkIndex: 1,
separatorWidth: 92,
width: 100,
});
const setup = await testRender(<DiffPane {...props} />, {
width: 104,
height: 12,
});

try {
await settleDiffPane(setup);
const frame = await waitForFrame(setup, (nextFrame) => nextFrame.includes("line60 = 5901"));

expect(frame).toContain("line60 = 5901");
expect(frame).not.toContain("line1 = 1001");
} finally {
await act(async () => {
setup.renderer.destroy();
});
}
});

test("DiffPane keeps a selected hunk with inline notes fully visible when it fits", async () => {
const theme = resolveTheme("midnight", null);
const file = createViewportSizedBottomHunkDiffFile("target", "target.ts");
Expand Down
58 changes: 57 additions & 1 deletion src/ui/diff/PierreDiffView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import { useMemo } from "react";
import type { DiffFile, LayoutMode } from "../../core/types";
import { AgentInlineNote, AgentInlineNoteGuideCap } from "../components/panes/AgentInlineNote";
import type { VisibleAgentNote } from "../lib/agentAnnotations";
import type { DiffSectionGeometry } from "../lib/diffSectionGeometry";
import { reviewRowId } from "../lib/ids";
import type { AppTheme } from "../themes";
import { findMaxLineNumber } from "./codeColumns";
import { buildSplitRows, buildStackRows } from "./pierre";
import { plannedReviewRowVisible } from "./plannedReviewRows";
import { buildReviewRenderPlan } from "./reviewRenderPlan";
import { resolveVisiblePlannedRowWindow, type VisibleBodyBounds } from "./rowWindowing";
import { diffMessage, DiffRowView, fitText } from "./renderRows";
import { useHighlightedDiff } from "./useHighlightedDiff";

Expand All @@ -28,8 +30,10 @@ export function PierreDiffView({
visibleAgentNotes = EMPTY_VISIBLE_AGENT_NOTES,
width,
selectedHunkIndex,
sectionGeometry,
shouldLoadHighlight = true,
scrollable = true,
visibleBodyBounds,
}: {
annotatedHunkIndices?: Set<number>;
codeHorizontalOffset?: number;
Expand All @@ -43,8 +47,10 @@ export function PierreDiffView({
visibleAgentNotes?: VisibleAgentNote[];
width: number;
selectedHunkIndex: number;
sectionGeometry?: DiffSectionGeometry;
shouldLoadHighlight?: boolean;
scrollable?: boolean;
visibleBodyBounds?: VisibleBodyBounds;
}) {
const resolvedHighlighted = useHighlightedDiff({
file,
Expand Down Expand Up @@ -74,6 +80,35 @@ export function PierreDiffView({
[file, rows, showHunkHeaders, visibleAgentNotes],
);
const lineNumberDigits = useMemo(() => String(file ? findMaxLineNumber(file) : 1).length, [file]);
const visiblePlannedRowWindow = useMemo(() => {
// Fall back to the full row list unless all three row-windowing inputs are ready:
// - the complete planned row stream for this file
// - measured per-row geometry for that same stream
// - one file-local visible body slice from DiffPane
// The helper relies on those structures staying in lockstep, so any missing input means
// "render everything" instead of risking a mismatched partial slice.
if (!sectionGeometry || !visibleBodyBounds) {
return {
bottomSpacerHeight: 0,
plannedRows,
topSpacerHeight: 0,
};
}

// `visibleBodyBounds` is already relative to this file body, not the whole review stream.
// Example: if DiffPane says "mount rows 120..260 within package-lock.json", this helper keeps
// only the planned rows whose measured bounds overlap that interval.
//
// The return value is not just the sliced rows. It also includes spacer heights for the skipped
// region above and below so the file still occupies its original total body height inside the
// scroll stream. That lets navigation, sticky headers, and reveal math keep using the same
// absolute geometry even though most rows are temporarily unmounted.
return resolveVisiblePlannedRowWindow({
plannedRows,
sectionGeometry,
visibleBodyBounds,
});
}, [plannedRows, sectionGeometry, visibleBodyBounds]);

if (!file) {
return (
Expand All @@ -93,7 +128,18 @@ export function PierreDiffView({

const content = (
<box style={{ width: "100%", flexDirection: "column" }}>
{plannedRows.map((plannedRow) => {
{visiblePlannedRowWindow.topSpacerHeight > 0 ? (
// Reserve the skipped height above the mounted slice so the file body keeps its original
// absolute row positions inside the larger review stream.
<box
style={{
width: "100%",
height: visiblePlannedRowWindow.topSpacerHeight,
backgroundColor: theme.panel,
}}
/>
) : null}
{visiblePlannedRowWindow.plannedRows.map((plannedRow) => {
// Mirror the same visibility/id decisions used by the scroll-bound helpers so the mounted
// tree can be measured by hunk later.
const rowId = reviewRowId(plannedRow.key);
Expand Down Expand Up @@ -154,6 +200,16 @@ export function PierreDiffView({
</box>
);
})}
{visiblePlannedRowWindow.bottomSpacerHeight > 0 ? (
// Mirror that reservation below the mounted slice so total file-body height stays stable.
<box
style={{
width: "100%",
height: visiblePlannedRowWindow.bottomSpacerHeight,
backgroundColor: theme.panel,
}}
/>
) : null}
</box>
);

Expand Down
Loading