فهرست منبع

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

Shoubhit Dash 2 هفته پیش
والد
کامیت
44f83015cd
1فایلهای تغییر یافته به همراه96 افزوده شده و 9 حذف شده
  1. 96 9
      packages/ui/src/components/session-review.tsx

+ 96 - 9
packages/ui/src/components/session-review.tsx

@@ -13,8 +13,7 @@ import { useFileComponent } from "../context/file"
 import { useI18n } from "../context/i18n"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
 import { checksum } from "@opencode-ai/util/encode"
-import { createEffect, createMemo, For, Match, Show, Switch, untrack, type JSX } from "solid-js"
-import { onCleanup } from "solid-js"
+import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js"
 import { createStore } from "solid-js/store"
 import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
 import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
@@ -26,6 +25,7 @@ import { createLineCommentController } from "./line-comment-annotations"
 import type { LineCommentEditorProps } from "./line-comment"
 
 const MAX_DIFF_CHANGED_LINES = 500
+const REVIEW_MOUNT_MARGIN = 300
 
 export type SessionReviewDiffStyle = "unified" | "split"
 
@@ -69,7 +69,7 @@ export interface SessionReviewProps {
   split?: boolean
   diffStyle?: SessionReviewDiffStyle
   onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
-  onDiffRendered?: () => void
+  onDiffRendered?: VoidFunction
   onLineComment?: (comment: SessionReviewLineComment) => void
   onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
   onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
@@ -137,11 +137,14 @@ type SessionReviewSelection = {
 export const SessionReview = (props: SessionReviewProps) => {
   let scroll: HTMLDivElement | undefined
   let focusToken = 0
+  let frame: number | undefined
   const i18n = useI18n()
   const fileComponent = useFileComponent()
   const anchors = new Map<string, HTMLElement>()
+  const nodes = new Map<string, HTMLDivElement>()
   const [store, setStore] = createStore({
     open: [] as string[],
+    visible: {} as Record<string, boolean>,
     force: {} as Record<string, boolean>,
     selection: 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 files = createMemo(() => props.diffs.map((diff) => diff.file))
   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 hasDiffs = () => files().length > 0
 
-  const handleChange = (open: string[]) => {
-    props.onOpenChange?.(open)
-    if (props.open !== undefined) return
-    setStore("open", open)
+  const syncVisible = () => {
+    frame = undefined
+    if (!scroll) return
+
+    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 = () => {
@@ -274,8 +348,9 @@ export const SessionReview = (props: SessionReviewProps) => {
         viewportRef={(el) => {
           scroll = el
           props.scrollRef?.(el)
+          queue()
         }}
-        onScroll={props.onScroll as any}
+        onScroll={handleScroll}
         classList={{
           [props.classes?.root ?? ""]: !!props.classes?.root,
         }}
@@ -291,9 +366,10 @@ export const SessionReview = (props: SessionReviewProps) => {
                     const item = createMemo(() => diffs().get(file)!)
 
                     const expanded = createMemo(() => open().includes(file))
+                    const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(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 beforeText = () => (typeof item().before === "string" ? item().before : "")
@@ -381,6 +457,8 @@ export const SessionReview = (props: SessionReviewProps) => {
 
                     onCleanup(() => {
                       anchors.delete(file)
+                      nodes.delete(file)
+                      queue()
                     })
 
                     const handleLineSelected = (range: SelectedLineRange | null) => {
@@ -465,10 +543,19 @@ export const SessionReview = (props: SessionReviewProps) => {
                             ref={(el) => {
                               wrapper = el
                               anchors.set(file, el)
+                              nodes.set(file, el)
+                              queue()
                             }}
                           >
                             <Show when={expanded()}>
                               <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()}>
                                   <div data-slot="session-review-large-diff">
                                     <div data-slot="session-review-large-diff-title">