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

This commit is contained in:
Filip
2026-02-13 12:08:13 +01:00
committed by GitHub
parent b8ee882126
commit ebb907d646
22 changed files with 407 additions and 127 deletions

View File

@@ -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 = () => {

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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 : "",
}}
/>
</Show>
)}
</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>
)}
</Show>
</Show>
</div>
</Accordion.Content>

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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å ",

View File

@@ -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 ",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "에 댓글 달기",

View File

@@ -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": "",

View File

@@ -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: ",

View File

@@ -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": "Комментирование: ",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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}`
}