Просмотр исходного кода

fix(desktop): performance optimization for showing large diff & files (#13460)

Filip 2 месяцев назад
Родитель
Сommit
ebb907d646

+ 16 - 7
packages/app/src/pages/session/file-tabs.tsx

@@ -1,7 +1,7 @@
 import { type ValidComponent, createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
 import { createStore, produce } from "solid-js/store"
 import { Dynamic } from "solid-js/web"
-import { checksum } from "@opencode-ai/util/encode"
+import { sampledChecksum } from "@opencode-ai/util/encode"
 import { decode64 } from "@/utils/base64"
 import { showToast } from "@opencode-ai/ui/toast"
 import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
@@ -49,7 +49,7 @@ export function FileTabContent(props: {
     return props.file.get(p)
   })
   const contents = createMemo(() => state()?.content?.content ?? "")
-  const cacheKey = createMemo(() => checksum(contents()))
+  const cacheKey = createMemo(() => sampledChecksum(contents()))
   const isImage = createMemo(() => {
     const c = state()?.content
     return c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml"
@@ -163,11 +163,20 @@ export function FileTabContent(props: {
       return
     }
 
+    const estimateTop = (range: SelectedLineRange) => {
+      const line = Math.max(range.start, range.end)
+      const height = 24
+      const offset = 2
+      return Math.max(0, (line - 1) * height + offset)
+    }
+
+    const large = contents().length > 500_000
+
     const next: Record<string, number> = {}
     for (const comment of fileComments()) {
       const marker = findMarker(root, comment.selection)
-      if (!marker) continue
-      next[comment.id] = markerTop(el, marker)
+      if (marker) next[comment.id] = markerTop(el, marker)
+      else if (large) next[comment.id] = estimateTop(comment.selection)
     }
 
     const removed = Object.keys(note.positions).filter((id) => next[id] === undefined)
@@ -194,12 +203,12 @@ export function FileTabContent(props: {
     }
 
     const marker = findMarker(root, range)
-    if (!marker) {
-      setNote("draftTop", undefined)
+    if (marker) {
+      setNote("draftTop", markerTop(el, marker))
       return
     }
 
-    setNote("draftTop", markerTop(el, marker))
+    setNote("draftTop", large ? estimateTop(range) : undefined)
   }
 
   const scheduleComments = () => {

+ 93 - 25
packages/ui/src/components/code.tsx

@@ -1,10 +1,27 @@
-import { type FileContents, File, FileOptions, LineAnnotation, type SelectedLineRange } from "@pierre/diffs"
+import {
+  DEFAULT_VIRTUAL_FILE_METRICS,
+  type FileContents,
+  File,
+  FileOptions,
+  LineAnnotation,
+  type SelectedLineRange,
+  type VirtualFileMetrics,
+  VirtualizedFile,
+  Virtualizer,
+} from "@pierre/diffs"
 import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
 import { Portal } from "solid-js/web"
 import { createDefaultOptions, styleVariables } from "../pierre"
 import { getWorkerPool } from "../pierre/worker"
 import { Icon } from "./icon"
 
+const VIRTUALIZE_BYTES = 500_000
+const codeMetrics = {
+  ...DEFAULT_VIRTUAL_FILE_METRICS,
+  lineHeight: 24,
+  fileGap: 0,
+} satisfies Partial<VirtualFileMetrics>
+
 type SelectionSide = "additions" | "deletions"
 
 export type CodeProps<T = {}> = FileOptions<T> & {
@@ -160,16 +177,28 @@ export function Code<T>(props: CodeProps<T>) {
 
   const [findPos, setFindPos] = createSignal<{ top: number; right: number }>({ top: 8, right: 8 })
 
-  const file = createMemo(
-    () =>
-      new File<T>(
-        {
-          ...createDefaultOptions<T>("unified"),
-          ...others,
-        },
-        getWorkerPool("unified"),
-      ),
-  )
+  let instance: File<T> | VirtualizedFile<T> | undefined
+  let virtualizer: Virtualizer | undefined
+  let virtualRoot: Document | HTMLElement | undefined
+
+  const bytes = createMemo(() => {
+    const value = local.file.contents as unknown
+    if (typeof value === "string") return value.length
+    if (Array.isArray(value)) {
+      return value.reduce(
+        (acc, part) => acc + (typeof part === "string" ? part.length + 1 : String(part).length + 1),
+        0,
+      )
+    }
+    if (value == null) return 0
+    return String(value).length
+  })
+  const virtual = createMemo(() => bytes() > VIRTUALIZE_BYTES)
+
+  const options = createMemo(() => ({
+    ...createDefaultOptions<T>("unified"),
+    ...others,
+  }))
 
   const getRoot = () => {
     const host = container.querySelector("diffs-container")
@@ -577,6 +606,14 @@ export function Code<T>(props: CodeProps<T>) {
   }
 
   const applySelection = (range: SelectedLineRange | null) => {
+    const current = instance
+    if (!current) return false
+
+    if (virtual()) {
+      current.setSelectedLines(range)
+      return true
+    }
+
     const root = getRoot()
     if (!root) return false
 
@@ -584,7 +621,7 @@ export function Code<T>(props: CodeProps<T>) {
     if (root.querySelectorAll("[data-line]").length < lines) return false
 
     if (!range) {
-      file().setSelectedLines(null)
+      current.setSelectedLines(null)
       return true
     }
 
@@ -592,12 +629,12 @@ export function Code<T>(props: CodeProps<T>) {
     const end = Math.max(range.start, range.end)
 
     if (start < 1 || end > lines) {
-      file().setSelectedLines(null)
+      current.setSelectedLines(null)
       return true
     }
 
     if (!root.querySelector(`[data-line="${start}"]`) || !root.querySelector(`[data-line="${end}"]`)) {
-      file().setSelectedLines(null)
+      current.setSelectedLines(null)
       return true
     }
 
@@ -608,7 +645,7 @@ export function Code<T>(props: CodeProps<T>) {
       return { start: range.start, end: range.end }
     })()
 
-    file().setSelectedLines(normalized)
+    current.setSelectedLines(normalized)
     return true
   }
 
@@ -619,9 +656,12 @@ export function Code<T>(props: CodeProps<T>) {
 
     const token = renderToken
 
-    const lines = lineCount()
+    const lines = virtual() ? undefined : lineCount()
 
-    const isReady = (root: ShadowRoot) => root.querySelectorAll("[data-line]").length >= lines
+    const isReady = (root: ShadowRoot) =>
+      virtual()
+        ? root.querySelector("[data-line]") != null
+        : root.querySelectorAll("[data-line]").length >= (lines ?? 0)
 
     const notify = () => {
       if (token !== renderToken) return
@@ -844,20 +884,41 @@ export function Code<T>(props: CodeProps<T>) {
   }
 
   createEffect(() => {
-    const current = file()
+    const opts = options()
+    const workerPool = getWorkerPool("unified")
+    const isVirtual = virtual()
 
-    onCleanup(() => {
-      current.cleanUp()
-    })
-  })
-
-  createEffect(() => {
     observer?.disconnect()
     observer = undefined
 
+    instance?.cleanUp()
+    instance = undefined
+
+    if (!isVirtual && virtualizer) {
+      virtualizer.cleanUp()
+      virtualizer = undefined
+      virtualRoot = undefined
+    }
+
+    const v = (() => {
+      if (!isVirtual) return
+      if (typeof document === "undefined") return
+
+      const root = getScrollParent(wrapper) ?? document
+      if (virtualizer && virtualRoot === root) return virtualizer
+
+      virtualizer?.cleanUp()
+      virtualizer = new Virtualizer()
+      virtualRoot = root
+      virtualizer.setup(root, root instanceof Document ? undefined : wrapper)
+      return virtualizer
+    })()
+
+    instance = isVirtual && v ? new VirtualizedFile<T>(opts, v, codeMetrics, workerPool) : new File<T>(opts, workerPool)
+
     container.innerHTML = ""
     const value = text()
-    file().render({
+    instance.render({
       file: typeof local.file.contents === "string" ? local.file : { ...local.file, contents: value },
       lineAnnotations: local.annotations,
       containerWrapper: container,
@@ -910,6 +971,13 @@ export function Code<T>(props: CodeProps<T>) {
   onCleanup(() => {
     observer?.disconnect()
 
+    instance?.cleanUp()
+    instance = undefined
+
+    virtualizer?.cleanUp()
+    virtualizer = undefined
+    virtualRoot = undefined
+
     clearOverlayScroll()
     clearOverlay()
     if (findCurrent === host) {

+ 29 - 9
packages/ui/src/components/diff.tsx

@@ -1,5 +1,5 @@
-import { checksum } from "@opencode-ai/util/encode"
-import { FileDiff, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs"
+import { sampledChecksum } from "@opencode-ai/util/encode"
+import { FileDiff, type FileDiffOptions, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs"
 import { createMediaQuery } from "@solid-primitives/media"
 import { createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js"
 import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre"
@@ -78,14 +78,29 @@ export function Diff<T>(props: DiffProps<T>) {
 
   const mobile = createMediaQuery("(max-width: 640px)")
 
-  const options = createMemo(() => {
-    const opts = {
+  const large = createMemo(() => {
+    const before = typeof local.before?.contents === "string" ? local.before.contents : ""
+    const after = typeof local.after?.contents === "string" ? local.after.contents : ""
+    return Math.max(before.length, after.length) > 500_000
+  })
+
+  const largeOptions = {
+    lineDiffType: "none",
+    maxLineDiffLength: 0,
+    tokenizeMaxLineLength: 1,
+  } satisfies Pick<FileDiffOptions<T>, "lineDiffType" | "maxLineDiffLength" | "tokenizeMaxLineLength">
+
+  const options = createMemo<FileDiffOptions<T>>(() => {
+    const base = {
       ...createDefaultOptions(props.diffStyle),
       ...others,
     }
-    if (!mobile()) return opts
+
+    const perf = large() ? { ...base, ...largeOptions } : base
+    if (!mobile()) return perf
+
     return {
-      ...opts,
+      ...perf,
       disableLineNumbers: true,
     }
   })
@@ -528,12 +543,17 @@ export function Diff<T>(props: DiffProps<T>) {
 
   createEffect(() => {
     const opts = options()
-    const workerPool = getWorkerPool(props.diffStyle)
+    const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
     const virtualizer = getVirtualizer()
     const annotations = local.annotations
     const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
     const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
 
+    const cacheKey = (contents: string) => {
+      if (!large()) return sampledChecksum(contents, contents.length)
+      return sampledChecksum(contents)
+    }
+
     instance?.cleanUp()
     instance = virtualizer
       ? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
@@ -545,12 +565,12 @@ export function Diff<T>(props: DiffProps<T>) {
       oldFile: {
         ...local.before,
         contents: beforeContents,
-        cacheKey: checksum(beforeContents),
+        cacheKey: cacheKey(beforeContents),
       },
       newFile: {
         ...local.after,
         contents: afterContents,
-        cacheKey: checksum(afterContents),
+        cacheKey: cacheKey(afterContents),
       },
       lineAnnotations: annotations,
       containerWrapper: container,

+ 26 - 0
packages/ui/src/components/session-review.css

@@ -222,4 +222,30 @@
     --line-comment-popover-z: 30;
     --line-comment-open-z: 6;
   }
+
+  [data-slot="session-review-large-diff"] {
+    padding: 12px;
+    background: var(--background-stronger);
+  }
+
+  [data-slot="session-review-large-diff-title"] {
+    font-family: var(--font-family-sans);
+    font-size: var(--font-size-small);
+    font-weight: var(--font-weight-medium);
+    color: var(--text-strong);
+    margin-bottom: 4px;
+  }
+
+  [data-slot="session-review-large-diff-meta"] {
+    font-family: var(--font-family-sans);
+    font-size: var(--font-size-small);
+    color: var(--text-weak);
+    word-break: break-word;
+  }
+
+  [data-slot="session-review-large-diff-actions"] {
+    display: flex;
+    gap: 8px;
+    margin-top: 10px;
+  }
 }

+ 142 - 86
packages/ui/src/components/session-review.tsx

@@ -17,6 +17,26 @@ import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
 import { type SelectedLineRange } from "@pierre/diffs"
 import { Dynamic } from "solid-js/web"
 
+const MAX_DIFF_LINES = 20_000
+const MAX_DIFF_BYTES = 2_000_000
+
+function linesOver(text: string, max: number) {
+  let lines = 1
+  for (let i = 0; i < text.length; i++) {
+    if (text.charCodeAt(i) !== 10) continue
+    lines++
+    if (lines > max) return true
+  }
+  return lines > max
+}
+
+function formatBytes(bytes: number) {
+  if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"
+  if (bytes < 1024) return `${bytes} B`
+  if (bytes < 1024 * 1024) return `${Math.round((bytes / 1024) * 10) / 10} KB`
+  return `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MB`
+}
+
 export type SessionReviewDiffStyle = "unified" | "split"
 
 export type SessionReviewComment = {
@@ -326,12 +346,28 @@ export const SessionReview = (props: SessionReviewProps) => {
               {(diff) => {
                 let wrapper: HTMLDivElement | undefined
 
+                const expanded = createMemo(() => open().includes(diff.file))
+                const [force, setForce] = createSignal(false)
+
                 const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === diff.file))
                 const commentedLines = createMemo(() => comments().map((c) => c.selection))
 
                 const beforeText = () => (typeof diff.before === "string" ? diff.before : "")
                 const afterText = () => (typeof diff.after === "string" ? diff.after : "")
 
+                const tooLarge = createMemo(() => {
+                  if (!expanded()) return false
+                  if (force()) return false
+                  if (isImageFile(diff.file)) return false
+
+                  const before = beforeText()
+                  const after = afterText()
+
+                  if (before.length > MAX_DIFF_BYTES || after.length > MAX_DIFF_BYTES) return true
+                  if (linesOver(before, MAX_DIFF_LINES) || linesOver(after, MAX_DIFF_LINES)) return true
+                  return false
+                })
+
                 const isAdded = () => diff.status === "added" || (beforeText().length === 0 && afterText().length > 0)
                 const isDeleted = () =>
                   diff.status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
@@ -571,94 +607,114 @@ export const SessionReview = (props: SessionReviewProps) => {
                           scheduleAnchors()
                         }}
                       >
-                        <Switch>
-                          <Match when={isImage() && imageSrc()}>
-                            <div data-slot="session-review-image-container">
-                              <img data-slot="session-review-image" src={imageSrc()} alt={diff.file} />
-                            </div>
-                          </Match>
-                          <Match when={isImage() && isDeleted()}>
-                            <div data-slot="session-review-image-container" data-removed>
-                              <span data-slot="session-review-image-placeholder">
-                                {i18n.t("ui.sessionReview.change.removed")}
-                              </span>
-                            </div>
-                          </Match>
-                          <Match when={isImage() && !imageSrc()}>
-                            <div data-slot="session-review-image-container">
-                              <span data-slot="session-review-image-placeholder">
-                                {imageStatus() === "loading" ? "Loading..." : "Image"}
-                              </span>
-                            </div>
-                          </Match>
-                          <Match when={!isImage()}>
-                            <Dynamic
-                              component={diffComponent}
-                              preloadedDiff={diff.preloaded}
-                              diffStyle={diffStyle()}
-                              onRendered={() => {
-                                props.onDiffRendered?.()
-                                scheduleAnchors()
-                              }}
-                              enableLineSelection={props.onLineComment != null}
-                              onLineSelected={handleLineSelected}
-                              onLineSelectionEnd={handleLineSelectionEnd}
-                              selectedLines={selectedLines()}
-                              commentedLines={commentedLines()}
-                              before={{
-                                name: diff.file!,
-                                contents: typeof diff.before === "string" ? diff.before : "",
-                              }}
-                              after={{
-                                name: diff.file!,
-                                contents: typeof diff.after === "string" ? diff.after : "",
-                              }}
-                            />
-                          </Match>
-                        </Switch>
-
-                        <For each={comments()}>
-                          {(comment) => (
-                            <LineComment
-                              id={comment.id}
-                              top={positions()[comment.id]}
-                              onMouseEnter={() => setSelection({ file: comment.file, range: comment.selection })}
-                              onClick={() => {
-                                if (isCommentOpen(comment)) {
-                                  setOpened(null)
-                                  return
-                                }
-
-                                openComment(comment)
-                              }}
-                              open={isCommentOpen(comment)}
-                              comment={comment.comment}
-                              selection={selectionLabel(comment.selection)}
-                            />
-                          )}
-                        </For>
-
-                        <Show when={draftRange()}>
-                          {(range) => (
-                            <Show when={draftTop() !== undefined}>
-                              <LineCommentEditor
-                                top={draftTop()}
-                                value={draft()}
-                                selection={selectionLabel(range())}
-                                onInput={setDraft}
-                                onCancel={() => setCommenting(null)}
-                                onSubmit={(comment) => {
-                                  props.onLineComment?.({
-                                    file: diff.file,
-                                    selection: range(),
-                                    comment,
-                                    preview: selectionPreview(diff, range()),
-                                  })
-                                  setCommenting(null)
+                        <Show when={expanded()}>
+                          <Switch>
+                            <Match when={isImage() && imageSrc()}>
+                              <div data-slot="session-review-image-container">
+                                <img data-slot="session-review-image" src={imageSrc()} alt={diff.file} />
+                              </div>
+                            </Match>
+                            <Match when={isImage() && isDeleted()}>
+                              <div data-slot="session-review-image-container" data-removed>
+                                <span data-slot="session-review-image-placeholder">
+                                  {i18n.t("ui.sessionReview.change.removed")}
+                                </span>
+                              </div>
+                            </Match>
+                            <Match when={isImage() && !imageSrc()}>
+                              <div data-slot="session-review-image-container">
+                                <span data-slot="session-review-image-placeholder">
+                                  {imageStatus() === "loading"
+                                    ? i18n.t("ui.sessionReview.image.loading")
+                                    : i18n.t("ui.sessionReview.image.placeholder")}
+                                </span>
+                              </div>
+                            </Match>
+                            <Match when={!isImage() && tooLarge()}>
+                              <div data-slot="session-review-large-diff">
+                                <div data-slot="session-review-large-diff-title">
+                                  {i18n.t("ui.sessionReview.largeDiff.title")}
+                                </div>
+                                <div data-slot="session-review-large-diff-meta">
+                                  Limit: {MAX_DIFF_LINES.toLocaleString()} lines / {formatBytes(MAX_DIFF_BYTES)}.
+                                  Current: {formatBytes(Math.max(beforeText().length, afterText().length))}.
+                                </div>
+                                <div data-slot="session-review-large-diff-actions">
+                                  <Button size="normal" variant="secondary" onClick={() => setForce(true)}>
+                                    {i18n.t("ui.sessionReview.largeDiff.renderAnyway")}
+                                  </Button>
+                                </div>
+                              </div>
+                            </Match>
+                            <Match when={!isImage()}>
+                              <Dynamic
+                                component={diffComponent}
+                                preloadedDiff={diff.preloaded}
+                                diffStyle={diffStyle()}
+                                onRendered={() => {
+                                  props.onDiffRendered?.()
+                                  scheduleAnchors()
+                                }}
+                                enableLineSelection={props.onLineComment != null}
+                                onLineSelected={handleLineSelected}
+                                onLineSelectionEnd={handleLineSelectionEnd}
+                                selectedLines={selectedLines()}
+                                commentedLines={commentedLines()}
+                                before={{
+                                  name: diff.file!,
+                                  contents: typeof diff.before === "string" ? diff.before : "",
                                 }}
+                                after={{
+                                  name: diff.file!,
+                                  contents: typeof diff.after === "string" ? diff.after : "",
+                                }}
+                              />
+                            </Match>
+                          </Switch>
+
+                          <For each={comments()}>
+                            {(comment) => (
+                              <LineComment
+                                id={comment.id}
+                                top={positions()[comment.id]}
+                                onMouseEnter={() => setSelection({ file: comment.file, range: comment.selection })}
+                                onClick={() => {
+                                  if (isCommentOpen(comment)) {
+                                    setOpened(null)
+                                    return
+                                  }
+
+                                  openComment(comment)
+                                }}
+                                open={isCommentOpen(comment)}
+                                comment={comment.comment}
+                                selection={selectionLabel(comment.selection)}
                               />
-                            </Show>
-                          )}
+                            )}
+                          </For>
+
+                          <Show when={draftRange()}>
+                            {(range) => (
+                              <Show when={draftTop() !== undefined}>
+                                <LineCommentEditor
+                                  top={draftTop()}
+                                  value={draft()}
+                                  selection={selectionLabel(range())}
+                                  onInput={setDraft}
+                                  onCancel={() => setCommenting(null)}
+                                  onSubmit={(comment) => {
+                                    props.onLineComment?.({
+                                      file: diff.file,
+                                      selection: range(),
+                                      comment,
+                                      preview: selectionPreview(diff, range()),
+                                    })
+                                    setCommenting(null)
+                                  }}
+                                />
+                              </Show>
+                            )}
+                          </Show>
                         </Show>
                       </div>
                     </Accordion.Content>

+ 5 - 0
packages/ui/src/i18n/ar.ts

@@ -8,6 +8,11 @@ export const dict = {
   "ui.sessionReview.change.added": "مضاف",
   "ui.sessionReview.change.removed": "محذوف",
   "ui.sessionReview.change.modified": "معدل",
+  "ui.sessionReview.image.loading": "جار التحميل...",
+  "ui.sessionReview.image.placeholder": "صورة",
+  "ui.sessionReview.largeDiff.title": "Diff كبير جدا لعرضه",
+  "ui.sessionReview.largeDiff.meta": "الحد: {{lines}} سطر / {{limit}}. الحالي: {{current}}.",
+  "ui.sessionReview.largeDiff.renderAnyway": "اعرض على أي حال",
 
   "ui.lineComment.label.prefix": "تعليق على ",
   "ui.lineComment.label.suffix": "",

+ 5 - 0
packages/ui/src/i18n/br.ts

@@ -8,6 +8,11 @@ export const dict = {
   "ui.sessionReview.change.added": "Adicionado",
   "ui.sessionReview.change.removed": "Removido",
   "ui.sessionReview.change.modified": "Modificado",
+  "ui.sessionReview.image.loading": "Carregando...",
+  "ui.sessionReview.image.placeholder": "Imagem",
+  "ui.sessionReview.largeDiff.title": "Diff grande demais para renderizar",
+  "ui.sessionReview.largeDiff.meta": "Limite: {{lines}} linhas / {{limit}}. Atual: {{current}}.",
+  "ui.sessionReview.largeDiff.renderAnyway": "Renderizar mesmo assim",
 
   "ui.lineComment.label.prefix": "Comentar em ",
   "ui.lineComment.label.suffix": "",

+ 5 - 0
packages/ui/src/i18n/bs.ts

@@ -12,6 +12,11 @@ export const dict = {
   "ui.sessionReview.change.added": "Dodano",
   "ui.sessionReview.change.removed": "Uklonjeno",
   "ui.sessionReview.change.modified": "Izmijenjeno",
+  "ui.sessionReview.image.loading": "Učitavanje...",
+  "ui.sessionReview.image.placeholder": "Slika",
+  "ui.sessionReview.largeDiff.title": "Diff je prevelik za prikaz",
+  "ui.sessionReview.largeDiff.meta": "Limit: {{lines}} linija / {{limit}}. Trenutno: {{current}}.",
+  "ui.sessionReview.largeDiff.renderAnyway": "Prikaži svejedno",
 
   "ui.lineComment.label.prefix": "Komentar na ",
   "ui.lineComment.label.suffix": "",

+ 5 - 0
packages/ui/src/i18n/da.ts

@@ -9,6 +9,11 @@ export const dict = {
   "ui.sessionReview.change.added": "Tilføjet",
   "ui.sessionReview.change.removed": "Fjernet",
   "ui.sessionReview.change.modified": "Ændret",
+  "ui.sessionReview.image.loading": "Indlæser...",
+  "ui.sessionReview.image.placeholder": "Billede",
+  "ui.sessionReview.largeDiff.title": "Diff er for stor til at blive vist",
+  "ui.sessionReview.largeDiff.meta": "Grænse: {{lines}} linjer / {{limit}}. Nuværende: {{current}}.",
+  "ui.sessionReview.largeDiff.renderAnyway": "Vis alligevel",
   "ui.lineComment.label.prefix": "Kommenter på ",
   "ui.lineComment.label.suffix": "",
   "ui.lineComment.editorLabel.prefix": "Kommenterer på ",

+ 5 - 0
packages/ui/src/i18n/de.ts

@@ -13,6 +13,11 @@ export const dict = {
   "ui.sessionReview.change.added": "Hinzugefügt",
   "ui.sessionReview.change.removed": "Entfernt",
   "ui.sessionReview.change.modified": "Geändert",
+  "ui.sessionReview.image.loading": "Wird geladen...",
+  "ui.sessionReview.image.placeholder": "Bild",
+  "ui.sessionReview.largeDiff.title": "Diff zu groß zum Rendern",
+  "ui.sessionReview.largeDiff.meta": "Limit: {{lines}} Zeilen / {{limit}}. Aktuell: {{current}}.",
+  "ui.sessionReview.largeDiff.renderAnyway": "Trotzdem rendern",
   "ui.lineComment.label.prefix": "Kommentar zu ",
   "ui.lineComment.label.suffix": "",
   "ui.lineComment.editorLabel.prefix": "Kommentiere ",

+ 5 - 0
packages/ui/src/i18n/en.ts

@@ -8,6 +8,11 @@ export const dict = {
   "ui.sessionReview.change.added": "Added",
   "ui.sessionReview.change.removed": "Removed",
   "ui.sessionReview.change.modified": "Modified",
+  "ui.sessionReview.image.loading": "Loading...",
+  "ui.sessionReview.image.placeholder": "Image",
+  "ui.sessionReview.largeDiff.title": "Diff too large to render",
+  "ui.sessionReview.largeDiff.meta": "Limit: {{lines}} lines / {{limit}}. Current: {{current}}.",
+  "ui.sessionReview.largeDiff.renderAnyway": "Render anyway",
 
   "ui.lineComment.label.prefix": "Comment on ",
   "ui.lineComment.label.suffix": "",

+ 5 - 0
packages/ui/src/i18n/es.ts

@@ -8,6 +8,11 @@ export const dict = {
   "ui.sessionReview.change.added": "Añadido",
   "ui.sessionReview.change.removed": "Eliminado",
   "ui.sessionReview.change.modified": "Modificado",
+  "ui.sessionReview.image.loading": "Cargando...",
+  "ui.sessionReview.image.placeholder": "Imagen",
+  "ui.sessionReview.largeDiff.title": "Diff demasiado grande para renderizar",
+  "ui.sessionReview.largeDiff.meta": "Límite: {{lines}} líneas / {{limit}}. Actual: {{current}}.",
+  "ui.sessionReview.largeDiff.renderAnyway": "Renderizar de todos modos",
 
   "ui.lineComment.label.prefix": "Comentar en ",
   "ui.lineComment.label.suffix": "",

+ 5 - 0
packages/ui/src/i18n/fr.ts

@@ -8,6 +8,11 @@ export const dict = {
   "ui.sessionReview.change.added": "Ajouté",
   "ui.sessionReview.change.removed": "Supprimé",
   "ui.sessionReview.change.modified": "Modifié",
+  "ui.sessionReview.image.loading": "Chargement...",
+  "ui.sessionReview.image.placeholder": "Image",
+  "ui.sessionReview.largeDiff.title": "Diff trop volumineux pour être affiché",
+  "ui.sessionReview.largeDiff.meta": "Limite : {{lines}} lignes / {{limit}}. Actuel : {{current}}.",
+  "ui.sessionReview.largeDiff.renderAnyway": "Afficher quand même",
 
   "ui.lineComment.label.prefix": "Commenter sur ",
   "ui.lineComment.label.suffix": "",

+ 5 - 0
packages/ui/src/i18n/ja.ts

@@ -9,6 +9,11 @@ export const dict = {
   "ui.sessionReview.change.added": "追加",
   "ui.sessionReview.change.removed": "削除",
   "ui.sessionReview.change.modified": "変更",
+  "ui.sessionReview.image.loading": "読み込み中...",
+  "ui.sessionReview.image.placeholder": "画像",
+  "ui.sessionReview.largeDiff.title": "差分が大きすぎて表示できません",
+  "ui.sessionReview.largeDiff.meta": "上限: {{lines}} 行 / {{limit}}。現在: {{current}}。",
+  "ui.sessionReview.largeDiff.renderAnyway": "それでも表示する",
   "ui.lineComment.label.prefix": "",
   "ui.lineComment.label.suffix": "へのコメント",
   "ui.lineComment.editorLabel.prefix": "",

+ 5 - 0
packages/ui/src/i18n/ko.ts

@@ -8,6 +8,11 @@ export const dict = {
   "ui.sessionReview.change.added": "추가됨",
   "ui.sessionReview.change.removed": "삭제됨",
   "ui.sessionReview.change.modified": "수정됨",
+  "ui.sessionReview.image.loading": "로딩 중...",
+  "ui.sessionReview.image.placeholder": "이미지",
+  "ui.sessionReview.largeDiff.title": "차이가 너무 커서 렌더링할 수 없습니다",
+  "ui.sessionReview.largeDiff.meta": "제한: {{lines}}줄 / {{limit}}. 현재: {{current}}.",
+  "ui.sessionReview.largeDiff.renderAnyway": "그래도 렌더링",
 
   "ui.lineComment.label.prefix": "",
   "ui.lineComment.label.suffix": "에 댓글 달기",

+ 5 - 0
packages/ui/src/i18n/no.ts

@@ -11,6 +11,11 @@ export const dict: Record<Keys, string> = {
   "ui.sessionReview.change.added": "Lagt til",
   "ui.sessionReview.change.removed": "Fjernet",
   "ui.sessionReview.change.modified": "Endret",
+  "ui.sessionReview.image.loading": "Laster...",
+  "ui.sessionReview.image.placeholder": "Bilde",
+  "ui.sessionReview.largeDiff.title": "Diff er for stor til å gjengi",
+  "ui.sessionReview.largeDiff.meta": "Grense: {{lines}} linjer / {{limit}}. Nåværende: {{current}}.",
+  "ui.sessionReview.largeDiff.renderAnyway": "Gjengi likevel",
 
   "ui.lineComment.label.prefix": "Kommenter på ",
   "ui.lineComment.label.suffix": "",

+ 5 - 0
packages/ui/src/i18n/pl.ts

@@ -9,6 +9,11 @@ export const dict = {
   "ui.sessionReview.change.added": "Dodano",
   "ui.sessionReview.change.removed": "Usunięto",
   "ui.sessionReview.change.modified": "Zmodyfikowano",
+  "ui.sessionReview.image.loading": "Ładowanie...",
+  "ui.sessionReview.image.placeholder": "Obraz",
+  "ui.sessionReview.largeDiff.title": "Diff jest zbyt duży, aby go wyrenderować",
+  "ui.sessionReview.largeDiff.meta": "Limit: {{lines}} linii / {{limit}}. Obecnie: {{current}}.",
+  "ui.sessionReview.largeDiff.renderAnyway": "Renderuj mimo to",
   "ui.lineComment.label.prefix": "Komentarz do ",
   "ui.lineComment.label.suffix": "",
   "ui.lineComment.editorLabel.prefix": "Komentowanie: ",

+ 5 - 0
packages/ui/src/i18n/ru.ts

@@ -9,6 +9,11 @@ export const dict = {
   "ui.sessionReview.change.added": "Добавлено",
   "ui.sessionReview.change.removed": "Удалено",
   "ui.sessionReview.change.modified": "Изменено",
+  "ui.sessionReview.image.loading": "Загрузка...",
+  "ui.sessionReview.image.placeholder": "Изображение",
+  "ui.sessionReview.largeDiff.title": "Diff слишком большой для отображения",
+  "ui.sessionReview.largeDiff.meta": "Лимит: {{lines}} строк / {{limit}}. Текущий: {{current}}.",
+  "ui.sessionReview.largeDiff.renderAnyway": "Отобразить всё равно",
   "ui.lineComment.label.prefix": "Комментарий к ",
   "ui.lineComment.label.suffix": "",
   "ui.lineComment.editorLabel.prefix": "Комментирование: ",

+ 5 - 0
packages/ui/src/i18n/th.ts

@@ -8,6 +8,11 @@ export const dict = {
   "ui.sessionReview.change.added": "เพิ่ม",
   "ui.sessionReview.change.removed": "ลบ",
   "ui.sessionReview.change.modified": "แก้ไข",
+  "ui.sessionReview.image.loading": "กำลังโหลด...",
+  "ui.sessionReview.image.placeholder": "รูปภาพ",
+  "ui.sessionReview.largeDiff.title": "Diff มีขนาดใหญ่เกินไปจนไม่สามารถแสดงผลได้",
+  "ui.sessionReview.largeDiff.meta": "ขีดจำกัด: {{lines}} บรรทัด / {{limit}}. ปัจจุบัน: {{current}}.",
+  "ui.sessionReview.largeDiff.renderAnyway": "แสดงผลต่อไป",
 
   "ui.lineComment.label.prefix": "แสดงความคิดเห็นบน ",
   "ui.lineComment.label.suffix": "",

+ 5 - 0
packages/ui/src/i18n/zh.ts

@@ -12,6 +12,11 @@ export const dict = {
   "ui.sessionReview.change.added": "已添加",
   "ui.sessionReview.change.removed": "已移除",
   "ui.sessionReview.change.modified": "已修改",
+  "ui.sessionReview.image.loading": "加载中...",
+  "ui.sessionReview.image.placeholder": "图片",
+  "ui.sessionReview.largeDiff.title": "差异过大,无法渲染",
+  "ui.sessionReview.largeDiff.meta": "限制:{{lines}} 行 / {{limit}}。当前:{{current}}。",
+  "ui.sessionReview.largeDiff.renderAnyway": "仍然渲染",
 
   "ui.lineComment.label.prefix": "评论 ",
   "ui.lineComment.label.suffix": "",

+ 5 - 0
packages/ui/src/i18n/zht.ts

@@ -12,6 +12,11 @@ export const dict = {
   "ui.sessionReview.change.added": "已新增",
   "ui.sessionReview.change.removed": "已移除",
   "ui.sessionReview.change.modified": "已修改",
+  "ui.sessionReview.image.loading": "載入中...",
+  "ui.sessionReview.image.placeholder": "圖片",
+  "ui.sessionReview.largeDiff.title": "差異過大,無法渲染",
+  "ui.sessionReview.largeDiff.meta": "限制:{{lines}} 行 / {{limit}}。目前:{{current}}。",
+  "ui.sessionReview.largeDiff.renderAnyway": "仍然渲染",
 
   "ui.lineComment.label.prefix": "評論 ",
   "ui.lineComment.label.suffix": "",

+ 21 - 0
packages/util/src/encode.ts

@@ -28,3 +28,24 @@ export function checksum(content: string): string | undefined {
   }
   return (hash >>> 0).toString(36)
 }
+
+export function sampledChecksum(content: string, limit = 500_000): string | undefined {
+  if (!content) return undefined
+  if (content.length <= limit) return checksum(content)
+
+  const size = 4096
+  const points = [
+    0,
+    Math.floor(content.length * 0.25),
+    Math.floor(content.length * 0.5),
+    Math.floor(content.length * 0.75),
+    content.length - size,
+  ]
+  const hashes = points
+    .map((point) => {
+      const start = Math.max(0, Math.min(content.length - size, point - Math.floor(size / 2)))
+      return checksum(content.slice(start, start + size)) ?? ""
+    })
+    .join(":")
+  return `${content.length}:${hashes}`
+}