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
56 changes: 31 additions & 25 deletions src/ui/components/panes/DiffPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ import {
type DiffSectionRowMetric,
} from "../../lib/sectionHeights";
import {
buildSectionLayoutMetrics,
findHeaderOwningSection,
resolveFileHeaderTop,
} from "../../lib/sectionLayout";
buildFileSectionLayoutMetrics,
findHeaderOwningFileSection,
resolveFileSectionHeaderTop,
} from "../../lib/fileSectionLayout";
import { diffHunkId, diffSectionId } from "../../lib/ids";
import type { AppTheme } from "../../themes";
import { DiffSection } from "./DiffSection";
Expand Down Expand Up @@ -64,13 +64,13 @@ function findViewportRowAnchor(
sectionMetrics: DiffSectionMetrics[],
scrollTop: number,
) {
const sectionLayoutMetrics = buildSectionLayoutMetrics(
const fileSectionLayoutMetrics = buildFileSectionLayoutMetrics(
files,
sectionMetrics.map((metrics) => metrics?.bodyHeight ?? 0),
);

for (let index = 0; index < files.length; index += 1) {
const layoutMetric = sectionLayoutMetrics[index];
const layoutMetric = fileSectionLayoutMetrics[index];
const bodyTop = layoutMetric?.bodyTop ?? 0;
const metrics = sectionMetrics[index];
const bodyHeight = metrics?.bodyHeight ?? 0;
Expand All @@ -97,13 +97,13 @@ function resolveViewportRowAnchorTop(
sectionMetrics: DiffSectionMetrics[],
anchor: ViewportRowAnchor,
) {
const sectionLayoutMetrics = buildSectionLayoutMetrics(
const fileSectionLayoutMetrics = buildFileSectionLayoutMetrics(
files,
sectionMetrics.map((metrics) => metrics?.bodyHeight ?? 0),
);

for (let index = 0; index < files.length; index += 1) {
const layoutMetric = sectionLayoutMetrics[index];
const layoutMetric = fileSectionLayoutMetrics[index];
const bodyTop = layoutMetric?.bodyTop ?? 0;
const file = files[index];
const metrics = sectionMetrics[index];
Expand Down Expand Up @@ -287,8 +287,8 @@ export function DiffPane({
() => baseSectionMetrics.map((metrics) => metrics.bodyHeight),
[baseSectionMetrics],
);
const baseSectionLayoutMetrics = useMemo(
() => buildSectionLayoutMetrics(files, baseEstimatedBodyHeights),
const baseFileSectionLayoutMetrics = useMemo(
() => buildFileSectionLayoutMetrics(files, baseEstimatedBodyHeights),
[baseEstimatedBodyHeights, files],
);

Expand All @@ -297,11 +297,11 @@ export function DiffPane({
const minVisibleY = Math.max(0, scrollViewport.top - overscanRows);
const maxVisibleY = scrollViewport.top + scrollViewport.height + overscanRows;
return new Set(
baseSectionLayoutMetrics
baseFileSectionLayoutMetrics
.filter((metric) => metric.sectionBottom >= minVisibleY && metric.sectionTop <= maxVisibleY)
.map((metric) => metric.fileId),
);
}, [baseSectionLayoutMetrics, scrollViewport.height, scrollViewport.top]);
}, [baseFileSectionLayoutMetrics, scrollViewport.height, scrollViewport.top]);

const visibleAgentNotesByFile = useMemo(() => {
const next = new Map<string, VisibleAgentNote[]>();
Expand Down Expand Up @@ -362,29 +362,29 @@ export function DiffPane({
() => sectionMetrics.map((metrics) => metrics.bodyHeight),
[sectionMetrics],
);
const sectionLayoutMetrics = useMemo(
() => buildSectionLayoutMetrics(files, estimatedBodyHeights),
const fileSectionLayoutMetrics = useMemo(
() => buildFileSectionLayoutMetrics(files, estimatedBodyHeights),
[estimatedBodyHeights, files],
);
// Read the live scroll box position during render so sticky-header ownership flips
// immediately after imperative scrolls instead of waiting for the polled viewport snapshot.
const effectiveScrollTop = scrollRef.current?.scrollTop ?? scrollViewport.top;

const totalContentHeight =
sectionLayoutMetrics[sectionLayoutMetrics.length - 1]?.sectionBottom ?? 0;
fileSectionLayoutMetrics[fileSectionLayoutMetrics.length - 1]?.sectionBottom ?? 0;

const stickyHeaderFile = useMemo(() => {
if (files.length < 2) {
return null;
}

const owner = findHeaderOwningSection(sectionLayoutMetrics, effectiveScrollTop);
const owner = findHeaderOwningFileSection(fileSectionLayoutMetrics, effectiveScrollTop);
if (!owner || effectiveScrollTop <= owner.headerTop) {
return null;
}

return files[owner.sectionIndex] ?? null;
}, [effectiveScrollTop, files, sectionLayoutMetrics]);
}, [effectiveScrollTop, fileSectionLayoutMetrics, files]);

const visibleWindowedFileIds = useMemo(() => {
if (!windowingEnabled) {
Expand Down Expand Up @@ -418,8 +418,8 @@ export function DiffPane({
return null;
}

const selectedSectionLayout = sectionLayoutMetrics[selectedFileIndex];
if (!selectedSectionLayout) {
const selectedFileSectionLayout = fileSectionLayoutMetrics[selectedFileIndex];
if (!selectedFileSectionLayout) {
return null;
}

Expand All @@ -433,13 +433,19 @@ export function DiffPane({
}

return {
top: selectedSectionLayout.bodyTop + hunkBounds.top,
top: selectedFileSectionLayout.bodyTop + hunkBounds.top,
height: hunkBounds.height,
startRowId: hunkBounds.startRowId,
endRowId: hunkBounds.endRowId,
sectionTop: selectedSectionLayout.sectionTop,
sectionTop: selectedFileSectionLayout.sectionTop,
};
}, [sectionLayoutMetrics, sectionMetrics, selectedFile, selectedFileIndex, selectedHunkIndex]);
}, [
fileSectionLayoutMetrics,
sectionMetrics,
selectedFile,
selectedFileIndex,
selectedHunkIndex,
]);

/** Absolute scroll offset and height of the first inline note in the selected hunk, if any. */
const selectedNoteBounds = useMemo(() => {
Expand Down Expand Up @@ -483,7 +489,7 @@ export function DiffPane({
/** Scroll one file header to the top using the latest planned section geometry. */
const scrollFileHeaderToTop = useCallback(
(fileId: string) => {
const headerTop = resolveFileHeaderTop(sectionLayoutMetrics, fileId);
const headerTop = resolveFileSectionHeaderTop(fileSectionLayoutMetrics, fileId);
if (headerTop == null) {
return false;
}
Expand All @@ -496,7 +502,7 @@ export function DiffPane({
scrollBox.scrollTo(headerTop);
return true;
},
[scrollRef, sectionLayoutMetrics],
[fileSectionLayoutMetrics, scrollRef],
);

useLayoutEffect(() => {
Expand Down Expand Up @@ -585,7 +591,7 @@ export function DiffPane({
if (scrollFileHeaderToTop(pendingFileId)) {
clearPendingFileTopAlign();
}
}, [clearPendingFileTopAlign, files, scrollFileHeaderToTop, sectionLayoutMetrics]);
}, [clearPendingFileTopAlign, fileSectionLayoutMetrics, files, scrollFileHeaderToTop]);

useLayoutEffect(() => {
if (suppressNextSelectionAutoScrollRef.current) {
Expand Down
16 changes: 11 additions & 5 deletions src/ui/lib/sectionLayout.ts → src/ui/lib/fileSectionLayout.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { DiffFile } from "../../core/types";

/** Stream geometry for one file section in the main review pane. */
export interface SectionLayoutMetric {
export interface FileSectionLayoutMetric {
fileId: string;
sectionIndex: number;
sectionTop: number;
Expand All @@ -12,8 +12,8 @@ export interface SectionLayoutMetric {
}

/** Build absolute section offsets from file order and measured body heights. */
export function buildSectionLayoutMetrics(files: DiffFile[], bodyHeights: number[]) {
const metrics: SectionLayoutMetric[] = [];
export function buildFileSectionLayoutMetrics(files: DiffFile[], bodyHeights: number[]) {
const metrics: FileSectionLayoutMetric[] = [];
let cursor = 0;

files.forEach((file, index) => {
Expand Down Expand Up @@ -41,7 +41,10 @@ export function buildSectionLayoutMetrics(files: DiffFile[], bodyHeights: number
}

/** Return the file section that owns the viewport top, switching at each next header row. */
export function findHeaderOwningSection(sectionMetrics: SectionLayoutMetric[], scrollTop: number) {
export function findHeaderOwningFileSection(
sectionMetrics: FileSectionLayoutMetric[],
scrollTop: number,
) {
if (sectionMetrics.length === 0) {
return null;
}
Expand All @@ -68,7 +71,10 @@ export function findHeaderOwningSection(sectionMetrics: SectionLayoutMetric[], s
}

/** Resolve the scroll target needed to make one file header own the viewport top. */
export function resolveFileHeaderTop(sectionMetrics: SectionLayoutMetric[], fileId: string) {
export function resolveFileSectionHeaderTop(
sectionMetrics: FileSectionLayoutMetric[],
fileId: string,
) {
const targetSection = sectionMetrics.find((metric) => metric.fileId === fileId);
if (!targetSection) {
return null;
Expand Down
Loading