Skip to content

Commit ebb907d

Browse files
authored
fix(desktop): performance optimization for showing large diff & files (#13460)
1 parent b8ee882 commit ebb907d

File tree

22 files changed

+407
-127
lines changed

22 files changed

+407
-127
lines changed

packages/app/src/pages/session/file-tabs.tsx

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { type ValidComponent, createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
22
import { createStore, produce } from "solid-js/store"
33
import { Dynamic } from "solid-js/web"
4-
import { checksum } from "@opencode-ai/util/encode"
4+
import { sampledChecksum } from "@opencode-ai/util/encode"
55
import { decode64 } from "@/utils/base64"
66
import { showToast } from "@opencode-ai/ui/toast"
77
import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
@@ -49,7 +49,7 @@ export function FileTabContent(props: {
4949
return props.file.get(p)
5050
})
5151
const contents = createMemo(() => state()?.content?.content ?? "")
52-
const cacheKey = createMemo(() => checksum(contents()))
52+
const cacheKey = createMemo(() => sampledChecksum(contents()))
5353
const isImage = createMemo(() => {
5454
const c = state()?.content
5555
return c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml"
@@ -163,11 +163,20 @@ export function FileTabContent(props: {
163163
return
164164
}
165165

166+
const estimateTop = (range: SelectedLineRange) => {
167+
const line = Math.max(range.start, range.end)
168+
const height = 24
169+
const offset = 2
170+
return Math.max(0, (line - 1) * height + offset)
171+
}
172+
173+
const large = contents().length > 500_000
174+
166175
const next: Record<string, number> = {}
167176
for (const comment of fileComments()) {
168177
const marker = findMarker(root, comment.selection)
169-
if (!marker) continue
170-
next[comment.id] = markerTop(el, marker)
178+
if (marker) next[comment.id] = markerTop(el, marker)
179+
else if (large) next[comment.id] = estimateTop(comment.selection)
171180
}
172181

173182
const removed = Object.keys(note.positions).filter((id) => next[id] === undefined)
@@ -194,12 +203,12 @@ export function FileTabContent(props: {
194203
}
195204

196205
const marker = findMarker(root, range)
197-
if (!marker) {
198-
setNote("draftTop", undefined)
206+
if (marker) {
207+
setNote("draftTop", markerTop(el, marker))
199208
return
200209
}
201210

202-
setNote("draftTop", markerTop(el, marker))
211+
setNote("draftTop", large ? estimateTop(range) : undefined)
203212
}
204213

205214
const scheduleComments = () => {

packages/ui/src/components/code.tsx

Lines changed: 93 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,27 @@
1-
import { type FileContents, File, FileOptions, LineAnnotation, type SelectedLineRange } from "@pierre/diffs"
1+
import {
2+
DEFAULT_VIRTUAL_FILE_METRICS,
3+
type FileContents,
4+
File,
5+
FileOptions,
6+
LineAnnotation,
7+
type SelectedLineRange,
8+
type VirtualFileMetrics,
9+
VirtualizedFile,
10+
Virtualizer,
11+
} from "@pierre/diffs"
212
import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
313
import { Portal } from "solid-js/web"
414
import { createDefaultOptions, styleVariables } from "../pierre"
515
import { getWorkerPool } from "../pierre/worker"
616
import { Icon } from "./icon"
717

18+
const VIRTUALIZE_BYTES = 500_000
19+
const codeMetrics = {
20+
...DEFAULT_VIRTUAL_FILE_METRICS,
21+
lineHeight: 24,
22+
fileGap: 0,
23+
} satisfies Partial<VirtualFileMetrics>
24+
825
type SelectionSide = "additions" | "deletions"
926

1027
export type CodeProps<T = {}> = FileOptions<T> & {
@@ -160,16 +177,28 @@ export function Code<T>(props: CodeProps<T>) {
160177

161178
const [findPos, setFindPos] = createSignal<{ top: number; right: number }>({ top: 8, right: 8 })
162179

163-
const file = createMemo(
164-
() =>
165-
new File<T>(
166-
{
167-
...createDefaultOptions<T>("unified"),
168-
...others,
169-
},
170-
getWorkerPool("unified"),
171-
),
172-
)
180+
let instance: File<T> | VirtualizedFile<T> | undefined
181+
let virtualizer: Virtualizer | undefined
182+
let virtualRoot: Document | HTMLElement | undefined
183+
184+
const bytes = createMemo(() => {
185+
const value = local.file.contents as unknown
186+
if (typeof value === "string") return value.length
187+
if (Array.isArray(value)) {
188+
return value.reduce(
189+
(acc, part) => acc + (typeof part === "string" ? part.length + 1 : String(part).length + 1),
190+
0,
191+
)
192+
}
193+
if (value == null) return 0
194+
return String(value).length
195+
})
196+
const virtual = createMemo(() => bytes() > VIRTUALIZE_BYTES)
197+
198+
const options = createMemo(() => ({
199+
...createDefaultOptions<T>("unified"),
200+
...others,
201+
}))
173202

174203
const getRoot = () => {
175204
const host = container.querySelector("diffs-container")
@@ -577,27 +606,35 @@ export function Code<T>(props: CodeProps<T>) {
577606
}
578607

579608
const applySelection = (range: SelectedLineRange | null) => {
609+
const current = instance
610+
if (!current) return false
611+
612+
if (virtual()) {
613+
current.setSelectedLines(range)
614+
return true
615+
}
616+
580617
const root = getRoot()
581618
if (!root) return false
582619

583620
const lines = lineCount()
584621
if (root.querySelectorAll("[data-line]").length < lines) return false
585622

586623
if (!range) {
587-
file().setSelectedLines(null)
624+
current.setSelectedLines(null)
588625
return true
589626
}
590627

591628
const start = Math.min(range.start, range.end)
592629
const end = Math.max(range.start, range.end)
593630

594631
if (start < 1 || end > lines) {
595-
file().setSelectedLines(null)
632+
current.setSelectedLines(null)
596633
return true
597634
}
598635

599636
if (!root.querySelector(`[data-line="${start}"]`) || !root.querySelector(`[data-line="${end}"]`)) {
600-
file().setSelectedLines(null)
637+
current.setSelectedLines(null)
601638
return true
602639
}
603640

@@ -608,7 +645,7 @@ export function Code<T>(props: CodeProps<T>) {
608645
return { start: range.start, end: range.end }
609646
})()
610647

611-
file().setSelectedLines(normalized)
648+
current.setSelectedLines(normalized)
612649
return true
613650
}
614651

@@ -619,9 +656,12 @@ export function Code<T>(props: CodeProps<T>) {
619656

620657
const token = renderToken
621658

622-
const lines = lineCount()
659+
const lines = virtual() ? undefined : lineCount()
623660

624-
const isReady = (root: ShadowRoot) => root.querySelectorAll("[data-line]").length >= lines
661+
const isReady = (root: ShadowRoot) =>
662+
virtual()
663+
? root.querySelector("[data-line]") != null
664+
: root.querySelectorAll("[data-line]").length >= (lines ?? 0)
625665

626666
const notify = () => {
627667
if (token !== renderToken) return
@@ -844,20 +884,41 @@ export function Code<T>(props: CodeProps<T>) {
844884
}
845885

846886
createEffect(() => {
847-
const current = file()
887+
const opts = options()
888+
const workerPool = getWorkerPool("unified")
889+
const isVirtual = virtual()
848890

849-
onCleanup(() => {
850-
current.cleanUp()
851-
})
852-
})
853-
854-
createEffect(() => {
855891
observer?.disconnect()
856892
observer = undefined
857893

894+
instance?.cleanUp()
895+
instance = undefined
896+
897+
if (!isVirtual && virtualizer) {
898+
virtualizer.cleanUp()
899+
virtualizer = undefined
900+
virtualRoot = undefined
901+
}
902+
903+
const v = (() => {
904+
if (!isVirtual) return
905+
if (typeof document === "undefined") return
906+
907+
const root = getScrollParent(wrapper) ?? document
908+
if (virtualizer && virtualRoot === root) return virtualizer
909+
910+
virtualizer?.cleanUp()
911+
virtualizer = new Virtualizer()
912+
virtualRoot = root
913+
virtualizer.setup(root, root instanceof Document ? undefined : wrapper)
914+
return virtualizer
915+
})()
916+
917+
instance = isVirtual && v ? new VirtualizedFile<T>(opts, v, codeMetrics, workerPool) : new File<T>(opts, workerPool)
918+
858919
container.innerHTML = ""
859920
const value = text()
860-
file().render({
921+
instance.render({
861922
file: typeof local.file.contents === "string" ? local.file : { ...local.file, contents: value },
862923
lineAnnotations: local.annotations,
863924
containerWrapper: container,
@@ -910,6 +971,13 @@ export function Code<T>(props: CodeProps<T>) {
910971
onCleanup(() => {
911972
observer?.disconnect()
912973

974+
instance?.cleanUp()
975+
instance = undefined
976+
977+
virtualizer?.cleanUp()
978+
virtualizer = undefined
979+
virtualRoot = undefined
980+
913981
clearOverlayScroll()
914982
clearOverlay()
915983
if (findCurrent === host) {

packages/ui/src/components/diff.tsx

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { checksum } from "@opencode-ai/util/encode"
2-
import { FileDiff, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs"
1+
import { sampledChecksum } from "@opencode-ai/util/encode"
2+
import { FileDiff, type FileDiffOptions, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs"
33
import { createMediaQuery } from "@solid-primitives/media"
44
import { createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js"
55
import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre"
@@ -78,14 +78,29 @@ export function Diff<T>(props: DiffProps<T>) {
7878

7979
const mobile = createMediaQuery("(max-width: 640px)")
8080

81-
const options = createMemo(() => {
82-
const opts = {
81+
const large = createMemo(() => {
82+
const before = typeof local.before?.contents === "string" ? local.before.contents : ""
83+
const after = typeof local.after?.contents === "string" ? local.after.contents : ""
84+
return Math.max(before.length, after.length) > 500_000
85+
})
86+
87+
const largeOptions = {
88+
lineDiffType: "none",
89+
maxLineDiffLength: 0,
90+
tokenizeMaxLineLength: 1,
91+
} satisfies Pick<FileDiffOptions<T>, "lineDiffType" | "maxLineDiffLength" | "tokenizeMaxLineLength">
92+
93+
const options = createMemo<FileDiffOptions<T>>(() => {
94+
const base = {
8395
...createDefaultOptions(props.diffStyle),
8496
...others,
8597
}
86-
if (!mobile()) return opts
98+
99+
const perf = large() ? { ...base, ...largeOptions } : base
100+
if (!mobile()) return perf
101+
87102
return {
88-
...opts,
103+
...perf,
89104
disableLineNumbers: true,
90105
}
91106
})
@@ -528,12 +543,17 @@ export function Diff<T>(props: DiffProps<T>) {
528543

529544
createEffect(() => {
530545
const opts = options()
531-
const workerPool = getWorkerPool(props.diffStyle)
546+
const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
532547
const virtualizer = getVirtualizer()
533548
const annotations = local.annotations
534549
const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
535550
const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
536551

552+
const cacheKey = (contents: string) => {
553+
if (!large()) return sampledChecksum(contents, contents.length)
554+
return sampledChecksum(contents)
555+
}
556+
537557
instance?.cleanUp()
538558
instance = virtualizer
539559
? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
@@ -545,12 +565,12 @@ export function Diff<T>(props: DiffProps<T>) {
545565
oldFile: {
546566
...local.before,
547567
contents: beforeContents,
548-
cacheKey: checksum(beforeContents),
568+
cacheKey: cacheKey(beforeContents),
549569
},
550570
newFile: {
551571
...local.after,
552572
contents: afterContents,
553-
cacheKey: checksum(afterContents),
573+
cacheKey: cacheKey(afterContents),
554574
},
555575
lineAnnotations: annotations,
556576
containerWrapper: container,

packages/ui/src/components/session-review.css

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,4 +222,30 @@
222222
--line-comment-popover-z: 30;
223223
--line-comment-open-z: 6;
224224
}
225+
226+
[data-slot="session-review-large-diff"] {
227+
padding: 12px;
228+
background: var(--background-stronger);
229+
}
230+
231+
[data-slot="session-review-large-diff-title"] {
232+
font-family: var(--font-family-sans);
233+
font-size: var(--font-size-small);
234+
font-weight: var(--font-weight-medium);
235+
color: var(--text-strong);
236+
margin-bottom: 4px;
237+
}
238+
239+
[data-slot="session-review-large-diff-meta"] {
240+
font-family: var(--font-family-sans);
241+
font-size: var(--font-size-small);
242+
color: var(--text-weak);
243+
word-break: break-word;
244+
}
245+
246+
[data-slot="session-review-large-diff-actions"] {
247+
display: flex;
248+
gap: 8px;
249+
margin-top: 10px;
250+
}
225251
}

0 commit comments

Comments
 (0)