From c92e4e46d271575e29c405084fd6e98bc260668e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Apr 2026 12:26:36 +0700 Subject: [PATCH] Stabilize git diff list heights --- packages/app/src/components/git-diff-pane.tsx | 145 ++++++++++++++++-- 1 file changed, 130 insertions(+), 15 deletions(-) diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index cdd81007f..e78bdaba7 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -20,6 +20,7 @@ import { type NativeSyntheticEvent, type NativeScrollEvent, type PressableStateCallbackType, + type FlatListProps, type StyleProp, type ViewStyle, TextStyle, @@ -616,7 +617,7 @@ function DiffFileBody({ file: ParsedDiffFile; layout: "unified" | "split"; wrapLines: boolean; - onBodyHeightChange?: (path: string, height: number) => void; + onBodyHeightChange?: (file: ParsedDiffFile, height: number) => void; testID?: string; }) { const [scrollViewWidth, setScrollViewWidth] = useState(0); @@ -625,9 +626,9 @@ function DiffFileBody({ const handleLayout = useCallback( (event: LayoutChangeEvent) => { setBodyWidth(event.nativeEvent.layout.width); - onBodyHeightChange?.(file.path, event.nativeEvent.layout.height); + onBodyHeightChange?.(file, event.nativeEvent.layout.height); }, - [file.path, onBodyHeightChange], + [file, onBodyHeightChange], ); const availableWidth = bodyWidth > 0 ? bodyWidth : scrollViewWidth; @@ -899,6 +900,25 @@ function DiffFilesToolbar({ type DiffFlatItem = | { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean } | { type: "body"; file: ParsedDiffFile; fileIndex: number }; +type DiffFlatItemLayoutGetter = NonNullable["getItemLayout"]>; + +function getUnifiedDiffLineCount(file: ParsedDiffFile): number { + let lineCount = 0; + for (const hunk of file.hunks) { + lineCount += hunk.lines.length; + } + return lineCount; +} + +function getDiffContentLength(file: ParsedDiffFile): number { + let contentLength = 0; + for (const hunk of file.hunks) { + for (const line of hunk.lines) { + contentLength += line.content.length; + } + } + return contentLength; +} function computeBranchLabel(currentBranch: string | null | undefined, notGit: boolean): string { if (currentBranch && currentBranch !== "HEAD") { @@ -936,6 +956,7 @@ interface DiffBodyContentProps { stickyHeaderIndices: number[]; renderFlatItem: ({ item }: { item: DiffFlatItem }) => ReactElement; flatKeyExtractor: (item: DiffFlatItem) => string; + getFlatItemLayout: DiffFlatItemLayoutGetter; flatExtraData: unknown; diffListRef: RefObject | null>; handleDiffListLayout: (event: LayoutChangeEvent) => void; @@ -957,6 +978,7 @@ function DiffBodyContent({ stickyHeaderIndices, renderFlatItem, flatKeyExtractor, + getFlatItemLayout, flatExtraData, diffListRef, handleDiffListLayout, @@ -1014,6 +1036,7 @@ function DiffBodyContent({ data={flatItems} renderItem={renderFlatItem} keyExtractor={flatKeyExtractor} + getItemLayout={getFlatItemLayout} stickyHeaderIndices={stickyHeaderIndices} extraData={flatExtraData} style={styles.scrollView} @@ -1596,8 +1619,12 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi const diffListScrollOffsetRef = useRef(0); const diffListViewportHeightRef = useRef(0); const headerHeightByPathRef = useRef>({}); - const bodyHeightByPathRef = useRef>({}); + const bodyHeightByKeyRef = useRef>({}); const defaultHeaderHeightRef = useRef(44); + const [heightVersion, setHeightVersion] = useState(0); + const diffBodyLineHeight = theme.lineHeight.diff; + const diffBodyChromeHeight = theme.borderWidth[1] * 2; + const statusBodyHeightEstimate = diffBodyChromeHeight + theme.spacing[4] * 2 + diffBodyLineHeight; const shipDefaultStorageKey = useMemo(() => { if (!gitStatus?.repoRoot) { return null; @@ -1654,21 +1681,77 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi return { flatItems: items, stickyHeaderIndices: stickyIndices }; }, [expandedPaths, files]); + const getBodyHeightKey = useCallback( + (file: ParsedDiffFile): string => { + if (file.status === "too_large" || file.status === "binary") { + return `${effectiveLayout}:${wrapLines ? "wrap" : "scroll"}:${file.path}:${file.status}`; + } + + return [ + effectiveLayout, + wrapLines ? "wrap" : "scroll", + file.path, + file.status ?? "ok", + file.additions, + file.deletions, + file.hunks.length, + getUnifiedDiffLineCount(file), + getDiffContentLength(file), + ].join(":"); + }, + [effectiveLayout, wrapLines], + ); + + const estimateBodyHeight = useCallback( + (file: ParsedDiffFile): number => { + if (file.status === "too_large" || file.status === "binary") { + return statusBodyHeightEstimate; + } + + const lineCount = + effectiveLayout === "split" + ? buildSplitDiffRows(file).length + : getUnifiedDiffLineCount(file); + return diffBodyChromeHeight + lineCount * diffBodyLineHeight; + }, + [diffBodyChromeHeight, diffBodyLineHeight, effectiveLayout, statusBodyHeightEstimate], + ); + const handleHeaderHeightChange = useCallback((path: string, height: number) => { if (!Number.isFinite(height) || height <= 0) { return; } - headerHeightByPathRef.current[path] = height; - defaultHeaderHeightRef.current = height; - }, []); - - const handleBodyHeightChange = useCallback((path: string, height: number) => { - if (!Number.isFinite(height) || height < 0) { + const previousHeight = headerHeightByPathRef.current[path]; + if ( + previousHeight !== undefined && + Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON + ) { return; } - bodyHeightByPathRef.current[path] = height; + headerHeightByPathRef.current[path] = height; + defaultHeaderHeightRef.current = height; + setHeightVersion((version) => version + 1); }, []); + const handleBodyHeightChange = useCallback( + (file: ParsedDiffFile, height: number) => { + if (!Number.isFinite(height) || height < 0) { + return; + } + const heightKey = getBodyHeightKey(file); + const previousHeight = bodyHeightByKeyRef.current[heightKey]; + if ( + previousHeight !== undefined && + Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON + ) { + return; + } + bodyHeightByKeyRef.current[heightKey] = height; + setHeightVersion((version) => version + 1); + }, + [getBodyHeightKey], + ); + const handleDiffListScroll = useCallback( (event: NativeSyntheticEvent) => { diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; @@ -1699,12 +1782,13 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi } offset += headerHeightByPathRef.current[file.path] ?? defaultHeaderHeight; if (expandedPaths.has(file.path)) { - offset += bodyHeightByPathRef.current[file.path] ?? 0; + const bodyHeightKey = getBodyHeightKey(file); + offset += bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(file); } } return Math.max(0, offset); }, - [expandedPaths, files], + [estimateBodyHeight, expandedPaths, files, getBodyHeightKey], ); const handleToggleExpanded = useCallback( @@ -1909,9 +1993,38 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi [], ); + const getFlatItemHeight = useCallback( + (item: DiffFlatItem): number => { + if (item.type === "header") { + return headerHeightByPathRef.current[item.file.path] ?? defaultHeaderHeightRef.current; + } + + const bodyHeightKey = getBodyHeightKey(item.file); + return bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(item.file); + }, + [estimateBodyHeight, getBodyHeightKey], + ); + + const getFlatItemLayout = useCallback( + (_data, index) => { + let offset = 0; + for (let itemIndex = 0; itemIndex < index; itemIndex += 1) { + const item = flatItems[itemIndex]; + if (item) { + offset += getFlatItemHeight(item); + } + } + + const item = flatItems[index]; + const length = item ? getFlatItemHeight(item) : 0; + return { length, offset, index }; + }, + [flatItems, getFlatItemHeight], + ); + const flatExtraData = useMemo( - () => ({ expandedPathsArray, effectiveLayout, wrapLines }), - [expandedPathsArray, effectiveLayout, wrapLines], + () => ({ expandedPathsArray, effectiveLayout, heightVersion, wrapLines }), + [expandedPathsArray, effectiveLayout, heightVersion, wrapLines], ); const hasChanges = files.length > 0; @@ -1988,6 +2101,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi stickyHeaderIndices={stickyHeaderIndices} renderFlatItem={renderFlatItem} flatKeyExtractor={flatKeyExtractor} + getFlatItemLayout={getFlatItemLayout} flatExtraData={flatExtraData} diffListRef={diffListRef} handleDiffListLayout={handleDiffListLayout} @@ -2599,3 +2713,4 @@ const HEADER_LINE_TEXT_STYLE = [styles.diffLineText, styles.headerLineText]; const FILE_SECTION_BODY_STYLE = [styles.fileSectionBodyContainer, styles.fileSectionBorder]; const DIFF_CONTENT_SPLIT_ROW_STYLE = [styles.diffContent, styles.splitRow]; const DIFF_CONTENT_ROW_STYLE = [styles.diffContent, styles.diffContentRow]; +const DIFF_HEIGHT_CHANGE_EPSILON = 0.5;