perf(review): defer offscreen diff mounts (#20469)

pull/20470/head^2
Shoubhit Dash 2026-04-01 19:29:12 +05:30 committed by GitHub
parent 9a1c9ae15a
commit 44f83015cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 96 additions and 9 deletions

View File

@ -13,8 +13,7 @@ import { useFileComponent } from "../context/file"
import { useI18n } from "../context/i18n" import { useI18n } from "../context/i18n"
import { getDirectory, getFilename } from "@opencode-ai/util/path" import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { checksum } from "@opencode-ai/util/encode" import { checksum } from "@opencode-ai/util/encode"
import { createEffect, createMemo, For, Match, Show, Switch, untrack, type JSX } from "solid-js" import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js"
import { onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2" import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
@ -26,6 +25,7 @@ import { createLineCommentController } from "./line-comment-annotations"
import type { LineCommentEditorProps } from "./line-comment" import type { LineCommentEditorProps } from "./line-comment"
const MAX_DIFF_CHANGED_LINES = 500 const MAX_DIFF_CHANGED_LINES = 500
const REVIEW_MOUNT_MARGIN = 300
export type SessionReviewDiffStyle = "unified" | "split" export type SessionReviewDiffStyle = "unified" | "split"
@ -69,7 +69,7 @@ export interface SessionReviewProps {
split?: boolean split?: boolean
diffStyle?: SessionReviewDiffStyle diffStyle?: SessionReviewDiffStyle
onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
onDiffRendered?: () => void onDiffRendered?: VoidFunction
onLineComment?: (comment: SessionReviewLineComment) => void onLineComment?: (comment: SessionReviewLineComment) => void
onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
@ -137,11 +137,14 @@ type SessionReviewSelection = {
export const SessionReview = (props: SessionReviewProps) => { export const SessionReview = (props: SessionReviewProps) => {
let scroll: HTMLDivElement | undefined let scroll: HTMLDivElement | undefined
let focusToken = 0 let focusToken = 0
let frame: number | undefined
const i18n = useI18n() const i18n = useI18n()
const fileComponent = useFileComponent() const fileComponent = useFileComponent()
const anchors = new Map<string, HTMLElement>() const anchors = new Map<string, HTMLElement>()
const nodes = new Map<string, HTMLDivElement>()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
open: [] as string[], open: [] as string[],
visible: {} as Record<string, boolean>,
force: {} as Record<string, boolean>, force: {} as Record<string, boolean>,
selection: null as SessionReviewSelection | null, selection: null as SessionReviewSelection | null,
commenting: null as SessionReviewSelection | null, commenting: null as SessionReviewSelection | null,
@ -154,13 +157,84 @@ export const SessionReview = (props: SessionReviewProps) => {
const open = () => props.open ?? store.open const open = () => props.open ?? store.open
const files = createMemo(() => props.diffs.map((diff) => diff.file)) const files = createMemo(() => props.diffs.map((diff) => diff.file))
const diffs = createMemo(() => new Map(props.diffs.map((diff) => [diff.file, diff] as const))) const diffs = createMemo(() => new Map(props.diffs.map((diff) => [diff.file, diff] as const)))
const grouped = createMemo(() => {
const next = new Map<string, SessionReviewComment[]>()
for (const comment of props.comments ?? []) {
const list = next.get(comment.file)
if (list) {
list.push(comment)
continue
}
next.set(comment.file, [comment])
}
return next
})
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified") const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
const hasDiffs = () => files().length > 0 const hasDiffs = () => files().length > 0
const handleChange = (open: string[]) => { const syncVisible = () => {
props.onOpenChange?.(open) frame = undefined
if (props.open !== undefined) return if (!scroll) return
setStore("open", open)
const root = scroll.getBoundingClientRect()
const top = root.top - REVIEW_MOUNT_MARGIN
const bottom = root.bottom + REVIEW_MOUNT_MARGIN
const openSet = new Set(open())
const next: Record<string, boolean> = {}
for (const [file, el] of nodes) {
if (!openSet.has(file)) continue
const rect = el.getBoundingClientRect()
if (rect.bottom < top || rect.top > bottom) continue
next[file] = true
}
const prev = untrack(() => store.visible)
const prevKeys = Object.keys(prev)
const nextKeys = Object.keys(next)
if (prevKeys.length === nextKeys.length && nextKeys.every((file) => prev[file])) return
setStore("visible", next)
}
const queue = () => {
if (frame !== undefined) return
frame = requestAnimationFrame(syncVisible)
}
const pinned = (file: string) =>
props.focusedComment?.file === file ||
props.focusedFile === file ||
selection()?.file === file ||
commenting()?.file === file ||
opened()?.file === file
const handleScroll: JSX.EventHandler<HTMLDivElement, Event> = (event) => {
queue()
const next = props.onScroll
if (!next) return
if (Array.isArray(next)) {
const [fn, data] = next as [(data: unknown, event: Event) => void, unknown]
fn(data, event)
return
}
;(next as JSX.EventHandler<HTMLDivElement, Event>)(event)
}
onCleanup(() => {
if (frame === undefined) return
cancelAnimationFrame(frame)
})
createEffect(() => {
props.open
files()
queue()
})
const handleChange = (next: string[]) => {
props.onOpenChange?.(next)
if (props.open === undefined) setStore("open", next)
queue()
} }
const handleExpandOrCollapseAll = () => { const handleExpandOrCollapseAll = () => {
@ -274,8 +348,9 @@ export const SessionReview = (props: SessionReviewProps) => {
viewportRef={(el) => { viewportRef={(el) => {
scroll = el scroll = el
props.scrollRef?.(el) props.scrollRef?.(el)
queue()
}} }}
onScroll={props.onScroll as any} onScroll={handleScroll}
classList={{ classList={{
[props.classes?.root ?? ""]: !!props.classes?.root, [props.classes?.root ?? ""]: !!props.classes?.root,
}} }}
@ -291,9 +366,10 @@ export const SessionReview = (props: SessionReviewProps) => {
const item = createMemo(() => diffs().get(file)!) const item = createMemo(() => diffs().get(file)!)
const expanded = createMemo(() => open().includes(file)) const expanded = createMemo(() => open().includes(file))
const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file)))
const force = () => !!store.force[file] const force = () => !!store.force[file]
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === file)) const comments = createMemo(() => grouped().get(file) ?? [])
const commentedLines = createMemo(() => comments().map((c) => c.selection)) const commentedLines = createMemo(() => comments().map((c) => c.selection))
const beforeText = () => (typeof item().before === "string" ? item().before : "") const beforeText = () => (typeof item().before === "string" ? item().before : "")
@ -381,6 +457,8 @@ export const SessionReview = (props: SessionReviewProps) => {
onCleanup(() => { onCleanup(() => {
anchors.delete(file) anchors.delete(file)
nodes.delete(file)
queue()
}) })
const handleLineSelected = (range: SelectedLineRange | null) => { const handleLineSelected = (range: SelectedLineRange | null) => {
@ -465,10 +543,19 @@ export const SessionReview = (props: SessionReviewProps) => {
ref={(el) => { ref={(el) => {
wrapper = el wrapper = el
anchors.set(file, el) anchors.set(file, el)
nodes.set(file, el)
queue()
}} }}
> >
<Show when={expanded()}> <Show when={expanded()}>
<Switch> <Switch>
<Match when={!mounted() && !tooLarge()}>
<div
data-slot="session-review-diff-placeholder"
class="rounded-lg border border-border-weak-base bg-background-stronger/40"
style={{ height: "160px" }}
/>
</Match>
<Match when={tooLarge()}> <Match when={tooLarge()}>
<div data-slot="session-review-large-diff"> <div data-slot="session-review-large-diff">
<div data-slot="session-review-large-diff-title"> <div data-slot="session-review-large-diff-title">