perf(review): defer offscreen diff mounts (#20469)
parent
9a1c9ae15a
commit
44f83015cd
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue