Virtualize the patch-review diff renderer

Route the comment-capable diff through the same single virtualizer as
the commit diff instead of rendering every file and line at once.
This commit is contained in:
Rūdolfs Ošiņš
2026-07-03 11:28:36 +02:00
parent 41535bf32c
commit 737703422e
8 changed files with 742 additions and 773 deletions
+6 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import type { CodeComments } from "@app/components/Diff.svelte";
import type { Revision } from "@bindings/cob/patch/Revision";
import type { CodeComments } from "@app/lib/diffComments";
import { cachedGetDiff, cachedListCommits } from "@app/lib/invoke";
import { pluralize } from "@app/lib/utils";
@@ -209,6 +209,10 @@
{#await cachedGetDiff(rid, { base, head, unified: 3, highlight: true })}
<span class="txt-body-m-regular">Loading…</span>
{:then diff}
<Changeset expanded={filesExpanded} {head} {diff} {codeComments} />
<!-- The patch layout already pads its content, so the diff cards don't add
their own side inset and align with the rest of the patch. -->
<div style:--diff-inset="0">
<Changeset expanded={filesExpanded} {head} {diff} {codeComments} />
</div>
{/await}
</div>
+116 -51
View File
@@ -1,15 +1,26 @@
<script lang="ts">
import type { CodeComments } from "@app/components/Diff.svelte";
import type { Diff } from "@bindings/diff/Diff";
import type { FileDiff } from "@bindings/diff/FileDiff";
import type { Modification } from "@bindings/diff/Modification";
import type { Snippet } from "svelte";
import { SvelteSet } from "svelte/reactivity";
import type {
CodeComments,
CommentContext,
Selection,
Side,
} from "@app/lib/diffComments";
import {
buildSelection,
buildThreadByLine,
buildThreadCountsByFile,
} from "@app/lib/diffComments";
import type { DiffRow } from "@app/lib/diffRows";
import { diffRowKey, flattenDiff } from "@app/lib/diffRows";
import DiffRowView from "@app/components/DiffRow.svelte";
import FileDiffComponent from "@app/components/FileDiff.svelte";
import VirtualList from "@app/components/VirtualList.svelte";
interface Props {
@@ -24,10 +35,21 @@
const { codeComments, diff, expanded = true, head, header }: Props = $props();
// Files the user collapsed (virtualized read-only path only).
// Per-file collapse state. The `expanded` prop is the "expand all"/"collapse
// all" command: when it flips, re-seed every file; individual chevrons then
// deviate from that until the next flip.
const collapsed = new SvelteSet<number>();
$effect(() => {
if (expanded) {
collapsed.clear();
} else {
for (let i = 0; i < diff.files.length; i++) {
collapsed.add(i);
}
}
});
function isFileExpanded(fileIndex: number): boolean {
return expanded && !collapsed.has(fileIndex);
return !collapsed.has(fileIndex);
}
function toggleFile(fileIndex: number) {
if (collapsed.has(fileIndex)) {
@@ -37,6 +59,64 @@
}
}
// Patch-review comment state (lifted from the old per-file Diff.svelte so a
// single virtualizer can own the whole changeset).
let selection = $state<Selection | undefined>(undefined);
const threadByLine = $derived(buildThreadByLine(codeComments?.threads ?? []));
const threadCountsByFile = $derived(
buildThreadCountsByFile(codeComments?.threads ?? []),
);
// eslint-disable-next-line svelte/prefer-writable-derived -- needs a $state proxy so toggleCommentExpand's property mutation triggers reactivity
let threadExpandedStates: Record<string, boolean> = $state({});
$effect(() => {
threadExpandedStates = codeComments
? Object.fromEntries(
codeComments.threads.map(t => [t.root.id, t.root.resolved]),
)
: {};
});
function toggleCommentExpand(commentId: string) {
threadExpandedStates[commentId] = !threadExpandedStates[commentId];
}
function selectLine(
fileIdx: number,
side: Side,
line: Modification,
hunkIdx: number,
lineIdx: number,
file: FileDiff,
) {
selection = buildSelection(
head,
file,
fileIdx,
side,
line,
hunkIdx,
lineIdx,
);
}
const comments = $derived<CommentContext | undefined>(
codeComments
? {
codeComments,
threadByLine,
threadCountsByFile,
selection,
expandedStates: threadExpandedStates,
onSelectLine: selectLine,
onClearSelection: () => {
selection = undefined;
},
onToggleThread: toggleCommentExpand,
}
: undefined,
);
type Row = { type: "header" } | DiffRow;
const rows = $derived<Row[]>(
@@ -73,6 +153,7 @@
if (
r.type === "file-header" ||
r.type === "hunk-header" ||
r.type === "file-note" ||
r.type === "line"
) {
return r.fileIndex;
@@ -103,13 +184,6 @@
</script>
<style>
.diff-list {
display: flex;
flex-direction: column;
}
.diff:not(:last-of-type) {
margin-bottom: 1rem;
}
/* Zero-height sticky anchor pinned to the viewport top; the bar overflows
from it (absolute) so it overlays the rows without taking flow space. */
.sticky-anchor {
@@ -126,44 +200,35 @@
}
</style>
{#if codeComments}
<!-- Patch review keeps the comment-capable path until it is virtualized. -->
<div class="diff-list">
{#each diff.files as file}
<div class="diff">
<FileDiffComponent {codeComments} {expanded} {file} {head} />
</div>
{/each}
</div>
{:else}
<div class="sticky-anchor">
{#if stickyFileIndex !== undefined}
<div
class="sticky-bar"
bind:clientHeight={stickyBarHeight}
style:transform="translateY({-pushOffset}px)">
<DiffRowView
row={{
type: "file-header",
fileIndex: stickyFileIndex,
file: diff.files[stickyFileIndex],
standalone: false,
}}
expanded={isFileExpanded(stickyFileIndex)}
onToggleFile={toggleFile} />
</div>
<div class="sticky-anchor">
{#if stickyFileIndex !== undefined}
<div
class="sticky-bar"
bind:clientHeight={stickyBarHeight}
style:transform="translateY({-pushOffset}px)">
<DiffRowView
row={{
type: "file-header",
fileIndex: stickyFileIndex,
file: diff.files[stickyFileIndex],
standalone: false,
}}
expanded={isFileExpanded(stickyFileIndex)}
onToggleFile={toggleFile}
{comments} />
</div>
{/if}
</div>
<VirtualList items={rows} getKey={rowKey} autoStartMargin {onScrollState}>
{#snippet row(r)}
{#if r.type === "header"}
{@render header?.()}
{:else}
<DiffRowView
row={r}
expanded={isFileExpanded(r.fileIndex)}
onToggleFile={toggleFile}
{comments} />
{/if}
</div>
<VirtualList items={rows} getKey={rowKey} {onScrollState}>
{#snippet row(r)}
{#if r.type === "header"}
{@render header?.()}
{:else}
<DiffRowView
row={r}
expanded={isFileExpanded(r.fileIndex)}
onToggleFile={toggleFile} />
{/if}
{/snippet}
</VirtualList>
{/if}
{/snippet}
</VirtualList>
-526
View File
@@ -1,526 +0,0 @@
<script lang="ts" module>
export interface CodeComments {
changeCommentStatus?: (
commentId: string,
resolved: boolean,
) => Promise<void>;
config: Config;
createComment: (
body: string,
embeds: Embed[],
replyTo?: string,
location?: CodeLocation,
) => Promise<void>;
editComment: (
commentId: string,
body: string,
embeds: Embed[],
) => Promise<void>;
reactOnComment?: (
commentId: string,
authors: Author[],
reaction: string,
) => Promise<void>;
deleteComment?: (commentId: string) => Promise<void>;
// Defaults to `true`.
canReply?: boolean;
// See `ExtendedTextarea`.
disableAttachments?: boolean | string;
repoDelegates: Author[];
rid: string;
threads: Thread<CodeLocation>[];
}
</script>
<script lang="ts">
type Side = "left" | "right";
type SelectionAnchor = { side: Side; lineNumber: number };
type SelectionRange = { start: SelectionAnchor; end?: SelectionAnchor };
interface Selection {
file: string;
start: SelectionAnchor;
end: SelectionAnchor;
lineIdx: number;
hunkIdx: number;
codeLocation: CodeLocation;
}
import type { Author } from "@bindings/cob/Author";
import type { CodeLocation } from "@bindings/cob/thread/CodeLocation";
import type { Embed } from "@bindings/cob/thread/Embed";
import type { Thread } from "@bindings/cob/thread/Thread";
import type { Config } from "@bindings/config/Config";
import type { FileDiff } from "@bindings/diff/FileDiff";
import type { Modification } from "@bindings/diff/Modification";
import escape from "lodash/escape";
import partial from "lodash/partial";
import * as roles from "@app/lib/roles";
import CommentToggleInput from "@app/components/CommentToggleInput.svelte";
import Icon from "@app/components/Icon.svelte";
import ThreadComponent from "@app/components/Thread.svelte";
interface Props {
codeComments?: CodeComments;
file: FileDiff;
head: string;
}
const { file, head, codeComments }: Props = $props();
let selection: Selection | undefined = $state(undefined);
function lineNumber(line: Modification, side: Side): number | undefined {
if (side === "left") {
if (line.type === "context") {
return line.lineNoOld;
}
if (line.type === "deletion") {
return line.lineNo;
}
} else {
if (line.type === "context") {
return line.lineNoNew;
}
if (line.type === "addition") {
return line.lineNo;
}
}
}
function findLineThread(line: Modification) {
return codeComments?.threads.find(t => {
if (line.type === "addition") {
return t.root.location?.new?.range.end === line.lineNo + 1;
} else if (line.type === "deletion") {
return t.root.location?.old?.range.end === line.lineNo + 1;
} else if (line.type === "context") {
return (
t.root.location?.new?.range.end === line.lineNoNew + 1 ||
t.root.location?.old?.range.end === line.lineNoOld + 1
);
}
});
}
function determineSelectedAnchor(
side: Side,
line: Modification,
): SelectionAnchor {
// When a user tries to select a side with no changes, use opposite side.
if (side === "left" && line.type === "addition") {
return { side: "right", lineNumber: line.lineNo };
} else if (side === "right" && line.type === "deletion") {
return { side: "left", lineNumber: line.lineNo };
} else {
return side === "left"
? { side: "left", lineNumber: lineNumber(line, "left") as number }
: { side: "right", lineNumber: lineNumber(line, "right") as number };
}
}
function filePath(file: FileDiff, side: Side): string {
if (file.status === "moved" || file.status === "copied") {
if (side === "left") {
return file.oldPath;
} else {
return file.newPath;
}
} else {
return file.path;
}
}
function selectLine(
e: MouseEvent,
file: FileDiff,
side: Side,
line: Modification,
hunkIdx: number,
lineIdx: number,
) {
e.preventDefault();
e.stopPropagation();
const commentAnchor = determineSelectedAnchor(side, line);
selection = {
file: filePath(file, side),
start: commentAnchor,
end: commentAnchor,
hunkIdx: hunkIdx,
lineIdx: lineIdx,
codeLocation: {
commit: head,
path: filePath(file, side),
old:
commentAnchor.side === "left"
? {
type: "lines",
range: {
start: commentAnchor.lineNumber,
end: commentAnchor.lineNumber + 1,
},
}
: null,
new:
commentAnchor.side === "right"
? {
type: "lines",
range: {
start: commentAnchor.lineNumber,
end: commentAnchor.lineNumber + 1,
},
}
: null,
},
};
}
function isSelected(file: string, hunkIdx: number, lineIdx: number) {
return (
selection &&
selection.file === file &&
selection.hunkIdx === hunkIdx &&
selection.lineIdx === lineIdx
);
}
function rangeAnchorsFromCodeLocation(
location: CodeLocation | null,
): SelectionRange | undefined {
if (location?.old?.type === "lines") {
return {
start: { side: "left", lineNumber: location.old.range.start },
};
} else if (location?.new?.type === "lines") {
return {
start: { side: "right", lineNumber: location.new.range.start },
};
}
}
// eslint-disable-next-line svelte/prefer-writable-derived -- needs a $state proxy so toggleCommentExpand's property mutation triggers reactivity
let threadExpandedStates: Record<string, boolean> = $state({});
$effect(() => {
threadExpandedStates = codeComments
? Object.fromEntries(
codeComments.threads.map(t => [t.root.id, t.root.resolved]),
)
: {};
});
function toggleCommentExpand(commentId: string) {
threadExpandedStates[commentId] = !threadExpandedStates[commentId];
}
</script>
<style>
.container {
/* Make space for the box-shadow border, otherwise it gets cut off due to
overflow: hide on the container. */
padding: 0.5rem 0.0625rem;
font: var(--txt-code-regular);
}
.line {
display: flex;
position: relative;
white-space: pre-wrap;
}
.hunk-header {
color: var(--color-text-secondary);
}
.hunk-header > .left,
.hunk-header > .right {
cursor: default;
}
.addition {
background-color: var(--color-feedback-success-bg);
}
.deletion {
background-color: var(--color-feedback-error-bg);
}
.addition > .left,
.addition > .right,
.addition > .sign {
color: var(--color-feedback-success-text);
}
.deletion > .left,
.deletion > .right,
.deletion > .sign {
color: var(--color-feedback-error-text);
}
.context > .left,
.context > .right,
.context > .sign {
color: var(--color-text-disabled);
}
.marker {
color: var(--color-text-primary) !important;
}
.selected {
box-shadow: 0 0 0 1px var(--color-border-brand);
z-index: 1;
}
.left,
.right {
min-width: 3rem;
text-align: center;
position: relative;
cursor: cell;
}
.selection-disabled {
cursor: default;
}
.left:hover:not(.selection-disabled),
.right:hover:not(.selection-disabled),
.left:active:not(.selection-disabled),
.right:active:not(.selection-disabled) {
color: var(--color-text-primary);
}
.sign {
min-width: 1.5rem;
}
.code {
-webkit-touch-callout: initial;
-webkit-user-select: text;
user-select: text;
width: 100%;
word-break: break-word;
}
.comment-icon {
margin-left: auto;
margin-right: 1rem;
margin-top: 3px;
align-self: flex-start;
}
.thread {
background-color: var(--color-surface-base);
font: var(--txt-body-m-regular);
padding: 0.5rem;
}
.comment-form {
background-color: var(--color-surface-base);
display: flex;
flex-direction: column;
font: var(--txt-body-m-regular);
padding: 1rem;
}
.comment-header {
display: flex;
background-color: var(--color-surface-subtle);
border-radius: var(--border-radius-sm);
padding: 0 0.5rem;
width: fit-content;
}
</style>
{#snippet lineDiff(line: Modification, lineIdx: number, hunkIdx: number)}
{@const thread = findLineThread(line)}
<div
class="line"
class:addition={line.type === "addition"}
class:deletion={line.type === "deletion"}
class:context={line.type === "context"}
class:selected={!thread &&
isSelected(filePath(file, "left"), hunkIdx, lineIdx)}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="left"
class:selection-disabled={!codeComments || thread}
class:marker={selection?.start.side === "left" &&
selection.start.lineNumber === lineNumber(line, "left")}
onpointerdown={e => {
if (codeComments?.createComment && !thread) {
selectLine(e, file, "left", line, hunkIdx, lineIdx);
}
}}>
{lineNumber(line, "left")}
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="right"
class:selection-disabled={!codeComments || thread}
class:marker={selection?.start.side === "right" &&
selection.start.lineNumber === lineNumber(line, "right")}
onpointerdown={e => {
if (codeComments?.createComment && !thread) {
selectLine(e, file, "right", line, hunkIdx, lineIdx);
}
}}>
{lineNumber(line, "right")}
</div>
<div class="sign">
{#if line.type === "addition"}
+
{:else if line.type === "deletion"}
-
{/if}
</div>
{#if line.highlight && line.highlight.items.length > 0}
<div class="code">
{@html line.highlight.items
.map(
paint =>
`<span class="global-syntax ${paint.style}">${escape(paint.item)}</span>`,
)
.join("")}
</div>
{:else if line.line !== ""}
<div class="code">{line.line}</div>
{:else}
<div class="code"><br /></div>
{/if}
<div class="global-flex comment-icon">
{#if thread}
{#if thread.root.resolved}
<Icon
name="comment-checkmark"
onclick={() => toggleCommentExpand(thread.root.id)} />
{:else}
<Icon
name="comment-cross"
onclick={() => toggleCommentExpand(thread.root.id)} />
{/if}
{/if}
</div>
</div>
{#if codeComments && thread && !threadExpandedStates[thread.root.id]}
<div class="thread">
<div class="global-flex" style:padding="0.5rem">
{@render commentHeader(
thread.root.location?.path,
rangeAnchorsFromCodeLocation(thread.root.location),
)}
{#if codeComments.changeCommentStatus && roles.isDelegateOrAuthor( codeComments.config.publicKey, codeComments.repoDelegates.map(delegate => delegate.did), thread.root.author.did, )}
<div style:margin-left="auto">
{#if thread.root.resolved}
<div title="Unresolve comment thread">
<Icon
name="close"
onclick={partial(
codeComments.changeCommentStatus,
thread.root.id,
false,
)} />
</div>
{:else}
<div title="Resolve comment thread">
<Icon
name="checkmark"
onclick={partial(
codeComments.changeCommentStatus,
thread.root.id,
true,
)} />
</div>
{/if}
</div>
{/if}
</div>
<ThreadComponent
inline
rid={codeComments.rid}
currentUserNid={codeComments.config.publicKey}
{thread}
reactOnComment={codeComments.reactOnComment}
createReply={(codeComments.canReply ?? true)
? async (body, embeds) => {
await codeComments.createComment(body, embeds, thread.root.id);
}
: undefined}
editComment={codeComments.editComment}
canEditComment={partial(
roles.isDelegateOrAuthor,
codeComments.config.publicKey,
codeComments.repoDelegates.map(delegate => delegate.did),
)}
deleteComment={codeComments.deleteComment} />
</div>
{/if}
{#if codeComments && selection?.hunkIdx === hunkIdx && selection?.lineIdx === lineIdx}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="comment-form"
onpointerdown={e => {
e.stopPropagation();
}}>
<div style:margin-bottom="1rem">
{@render commentHeader(selection.file, {
start: selection.start,
})}
</div>
<CommentToggleInput
disallowEmptyBody
rid={codeComments.rid}
onclose={() => {
selection = undefined;
}}
focus
placeholder="Leave a comment"
disableAttachments={codeComments.disableAttachments}
submit={async (body, embeds) => {
if (selection?.codeLocation) {
try {
await codeComments.createComment(
body,
embeds,
undefined,
selection.codeLocation,
);
} catch (e) {
console.error("Comment creation failed", e);
} finally {
selection = undefined;
}
}
}} />
</div>
{/if}
{/snippet}
{#snippet commentHeader(filePath?: string, selectionRange?: SelectionRange)}
{#if filePath && selectionRange}
<div class="comment-header">
{filePath.split("/").length > 1 ? "…/" : ""}{filePath
.split("/")
.slice(-1)}:{selectionRange.start.side === "left"
? "L"
: "R"}{selectionRange.start.lineNumber}
{#if selectionRange.end}
->
{selectionRange.end.side === "left" ? "L" : "R"}{selectionRange.end
.lineNumber}
{/if}
</div>
{/if}
{/snippet}
<div class="container">
{#if file.diff.type === "plain"}
{#each file.diff.hunks as hunk, hunkIdx}
<div class="line hunk-header">
<div class="left"></div>
<div class="right"></div>
<div class="sign"></div>
<div class="code">{hunk.header}</div>
</div>
<div>
{#each hunk.lines as modification, lineIdx}
{@render lineDiff(modification, lineIdx, hunkIdx)}
{/each}
</div>
{/each}
{/if}
</div>
+301 -39
View File
@@ -2,32 +2,36 @@
import type { Modification } from "@bindings/diff/Modification";
import escape from "lodash/escape";
import partial from "lodash/partial";
import type { CommentContext, SelectionRange } from "@app/lib/diffComments";
import {
findLineThread,
lineNumber,
rangeAnchorsFromCodeLocation,
} from "@app/lib/diffComments";
import type { DiffRow } from "@app/lib/diffRows";
import { fileDiffToText } from "@app/lib/diffText";
import * as roles from "@app/lib/roles";
import CommentToggleInput from "@app/components/CommentToggleInput.svelte";
import CopyButton from "@app/components/CopyButton.svelte";
import Icon from "@app/components/Icon.svelte";
import Path from "@app/components/Path.svelte";
import ThreadComponent from "@app/components/Thread.svelte";
interface Props {
row: DiffRow;
// Whether the row's file is expanded (only meaningful for file-header rows).
expanded?: boolean;
onToggleFile?: (fileIndex: number) => void;
// Present only on the patch-review path; enables inline comments/selection.
comments?: CommentContext;
}
const { row, expanded = true, onToggleFile }: Props = $props();
const { row, expanded = true, onToggleFile, comments }: Props = $props();
function lineNumber(line: Modification, side: "left" | "right") {
if (side === "left") {
if (line.type === "context") return line.lineNoOld;
if (line.type === "deletion") return line.lineNo;
} else {
if (line.type === "context") return line.lineNoNew;
if (line.type === "addition") return line.lineNo;
}
}
type LineRow = Extract<DiffRow, { type: "line" }>;
</script>
<style>
@@ -39,7 +43,10 @@
align-items: center;
gap: 0.5rem;
height: 2.5rem;
margin: 0 1rem;
/* Horizontal inset of the diff cards. The container sets `--diff-inset`
(e.g. 0 when it already provides its own side padding); falls back to 1rem
when the diff is the full-width scroll content (commit view). */
margin: 0 var(--diff-inset, 1rem);
padding: 0 0.5rem;
background-color: var(--color-surface-canvas);
/* Bottom border present in both states: it's the separator above the lines
@@ -103,7 +110,7 @@
display: flex;
position: relative;
white-space: pre-wrap;
margin: 0 1rem;
margin: 0 var(--diff-inset, 1rem);
font: var(--txt-code-regular);
border-left: 1px solid var(--color-border-subtle);
border-right: 1px solid var(--color-border-subtle);
@@ -114,6 +121,10 @@
border-bottom-left-radius: var(--border-radius-md);
border-bottom-right-radius: var(--border-radius-md);
}
.line.selected {
box-shadow: 0 0 0 1px var(--color-border-brand);
z-index: 1;
}
.hunk-header {
color: var(--color-text-secondary);
}
@@ -147,6 +158,15 @@
text-align: center;
flex-shrink: 0;
}
.num.gutter:not(.selection-disabled) {
cursor: cell;
}
.num.gutter:not(.selection-disabled):hover {
color: var(--color-text-primary);
}
.marker {
color: var(--color-text-primary) !important;
}
.sign {
min-width: 1.5rem;
flex-shrink: 0;
@@ -158,8 +178,259 @@
word-break: break-word;
padding-right: 1rem;
}
.comment-icon {
margin-left: auto;
margin-right: 1rem;
margin-top: 3px;
align-self: flex-start;
}
.thread,
.comment-form {
margin: 0 var(--diff-inset, 1rem);
border-left: 1px solid var(--color-border-subtle);
border-right: 1px solid var(--color-border-subtle);
background-color: var(--color-surface-base);
font: var(--txt-body-m-regular);
}
.thread {
padding: 0.5rem;
}
.comment-form {
display: flex;
flex-direction: column;
padding: 1rem;
}
.thread.last,
.comment-form.last {
border-bottom: 1px solid var(--color-border-subtle);
border-bottom-left-radius: var(--border-radius-md);
border-bottom-right-radius: var(--border-radius-md);
}
.comment-header {
display: flex;
background-color: var(--color-surface-subtle);
border-radius: var(--border-radius-sm);
padding: 0 0.5rem;
width: fit-content;
}
</style>
{#snippet code(line: Modification)}
{#if line.highlight && line.highlight.items.length > 0}
<div class="code">
{@html line.highlight.items
.map(
paint =>
`<span class="global-syntax ${paint.style}">${escape(paint.item)}</span>`,
)
.join("")}
</div>
{:else if line.line !== ""}
<div class="code">{line.line}</div>
{:else}
<div class="code"><br /></div>
{/if}
{/snippet}
{#snippet commentHeader(filePath?: string, selectionRange?: SelectionRange)}
{#if filePath && selectionRange}
<div class="comment-header">
{filePath.split("/").length > 1 ? "…/" : ""}{filePath
.split("/")
.slice(-1)}:{selectionRange.start.side === "left"
? "L"
: "R"}{selectionRange.start.lineNumber}
{#if selectionRange.end}
->
{selectionRange.end.side === "left" ? "L" : "R"}{selectionRange.end
.lineNumber}
{/if}
</div>
{/if}
{/snippet}
{#snippet readonlyLine(r: LineRow)}
{@const line = r.modification}
<div
class="line"
class:addition={line.type === "addition"}
class:deletion={line.type === "deletion"}
class:context={line.type === "context"}
class:last={r.last}>
<div class="num">{lineNumber(line, "left") ?? ""}</div>
<div class="num">{lineNumber(line, "right") ?? ""}</div>
<div class="sign">
{#if line.type === "addition"}+{:else if line.type === "deletion"}-{/if}
</div>
{@render code(line)}
</div>
{/snippet}
{#snippet interactiveLine(r: LineRow, ctx: CommentContext)}
{@const line = r.modification}
{@const cc = ctx.codeComments}
{@const thread = findLineThread(ctx.threadByLine, r.file, line)}
{@const showThread =
thread !== undefined && !ctx.expandedStates[thread.root.id]}
{@const sel = ctx.selection}
{@const inFile = sel !== undefined && sel.fileIdx === r.fileIndex}
{@const isSel =
inFile && sel.hunkIdx === r.hunkIndex && sel.lineIdx === r.lineIndex}
{@const closeOnLine = r.last && !showThread && !isSel}
{@const closeOnThread = r.last && showThread && !isSel}
{@const closeOnForm = r.last && isSel}
{@const selectable = cc.createComment !== undefined && thread === undefined}
<div
class="line"
class:addition={line.type === "addition"}
class:deletion={line.type === "deletion"}
class:context={line.type === "context"}
class:selected={thread === undefined && isSel}
class:last={closeOnLine}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="num gutter"
class:selection-disabled={!selectable}
class:marker={inFile &&
sel.start.side === "left" &&
sel.start.lineNumber === lineNumber(line, "left")}
onpointerdown={e => {
if (selectable) {
e.preventDefault();
e.stopPropagation();
ctx.onSelectLine(
r.fileIndex,
"left",
line,
r.hunkIndex,
r.lineIndex,
r.file,
);
}
}}>
{lineNumber(line, "left") ?? ""}
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="num gutter"
class:selection-disabled={!selectable}
class:marker={inFile &&
sel.start.side === "right" &&
sel.start.lineNumber === lineNumber(line, "right")}
onpointerdown={e => {
if (selectable) {
e.preventDefault();
e.stopPropagation();
ctx.onSelectLine(
r.fileIndex,
"right",
line,
r.hunkIndex,
r.lineIndex,
r.file,
);
}
}}>
{lineNumber(line, "right") ?? ""}
</div>
<div class="sign">
{#if line.type === "addition"}+{:else if line.type === "deletion"}-{/if}
</div>
{@render code(line)}
{#if thread}
<div class="global-flex comment-icon">
<Icon
name={thread.root.resolved ? "comment-checkmark" : "comment-cross"}
onclick={() => ctx.onToggleThread(thread.root.id)} />
</div>
{/if}
</div>
{#if showThread && thread}
<div class="thread" class:last={closeOnThread}>
<div class="global-flex" style:padding="0.5rem">
{@render commentHeader(
thread.root.location?.path,
rangeAnchorsFromCodeLocation(thread.root.location),
)}
{#if cc.changeCommentStatus && roles.isDelegateOrAuthor( cc.config.publicKey, cc.repoDelegates.map(delegate => delegate.did), thread.root.author.did, )}
<div style:margin-left="auto">
{#if thread.root.resolved}
<div title="Unresolve comment thread">
<Icon
name="close"
onclick={partial(
cc.changeCommentStatus,
thread.root.id,
false,
)} />
</div>
{:else}
<div title="Resolve comment thread">
<Icon
name="checkmark"
onclick={partial(
cc.changeCommentStatus,
thread.root.id,
true,
)} />
</div>
{/if}
</div>
{/if}
</div>
<ThreadComponent
inline
rid={cc.rid}
currentUserNid={cc.config.publicKey}
{thread}
reactOnComment={cc.reactOnComment}
createReply={(cc.canReply ?? true)
? async (body, embeds) => {
await cc.createComment(body, embeds, thread.root.id);
}
: undefined}
editComment={cc.editComment}
canEditComment={partial(
roles.isDelegateOrAuthor,
cc.config.publicKey,
cc.repoDelegates.map(delegate => delegate.did),
)}
deleteComment={cc.deleteComment} />
</div>
{/if}
{#if isSel && sel}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="comment-form"
class:last={closeOnForm}
onpointerdown={e => {
e.stopPropagation();
}}>
<div style:margin-bottom="1rem">
{@render commentHeader(sel.file, { start: sel.start })}
</div>
<CommentToggleInput
disallowEmptyBody
rid={cc.rid}
onclose={() => ctx.onClearSelection()}
focus
placeholder="Leave a comment"
disableAttachments={cc.disableAttachments}
submit={async (body, embeds) => {
try {
await cc.createComment(body, embeds, undefined, sel.codeLocation);
} catch (e) {
console.error("Comment creation failed", e);
} finally {
ctx.onClearSelection();
}
}} />
</div>
{/if}
{/snippet}
{#if row.type === "file-gap"}
<div class="file-gap"></div>
{:else if row.type === "file-header"}
@@ -193,6 +464,21 @@
<span class="del">-{row.file.diff.stats.deletions}</span>
{/if}
</span>
{#if comments && (row.file.status === "added" || row.file.status === "deleted" || row.file.status === "modified")}
{@const counts = comments.threadCountsByFile.get(row.file.path)}
{#if counts !== undefined && counts.unresolved > 0}
<div class="global-flex">
<Icon name="comment-cross" />
{counts.unresolved}
</div>
{/if}
{#if counts !== undefined && counts.resolved > 0}
<div class="global-flex">
<Icon name="comment-checkmark" />
{counts.resolved}
</div>
{/if}
{/if}
<CopyButton text={() => fileDiffToText(row.file)} title="Copy file diff" />
</div>
{:else if row.type === "hunk-header"}
@@ -211,32 +497,8 @@
Empty file
{/if}
</div>
{:else if comments}
{@render interactiveLine(row, comments)}
{:else}
{@const line = row.modification}
<div
class="line"
class:addition={line.type === "addition"}
class:deletion={line.type === "deletion"}
class:context={line.type === "context"}
class:last={row.last}>
<div class="num">{lineNumber(line, "left") ?? ""}</div>
<div class="num">{lineNumber(line, "right") ?? ""}</div>
<div class="sign">
{#if line.type === "addition"}+{:else if line.type === "deletion"}-{/if}
</div>
{#if line.highlight && line.highlight.items.length > 0}
<div class="code">
{@html line.highlight.items
.map(
paint =>
`<span class="global-syntax ${paint.style}">${escape(paint.item)}</span>`,
)
.join("")}
</div>
{:else if line.line !== ""}
<div class="code">{line.line}</div>
{:else}
<div class="code"><br /></div>
{/if}
</div>
{@render readonlyLine(row)}
{/if}
-150
View File
@@ -1,150 +0,0 @@
<script lang="ts">
import type { CodeComments } from "@app/components/Diff.svelte";
import type { FileDiff } from "@bindings/diff/FileDiff";
import Diff from "@app/components/Diff.svelte";
import FileBlock from "@app/components/FileBlock.svelte";
import Icon from "@app/components/Icon.svelte";
import Path from "@app/components/Path.svelte";
interface Props {
expanded: boolean;
file: FileDiff;
head: string;
codeComments?: CodeComments;
}
const { expanded, file, head, codeComments }: Props = $props();
// Pass down only the comments that apply to the given diff.
function filterThreadsByFilePath() {
if (codeComments) {
if (
file.status === "added" ||
file.status === "deleted" ||
file.status === "modified"
) {
return {
...codeComments,
threads:
codeComments.threads.filter(t => {
return t.root.location?.path === file.path;
}) ?? [],
};
} else {
return { ...codeComments, threads: [] };
}
} else {
return undefined;
}
}
const commentsOfThisFile = $derived(filterThreadsByFilePath());
const resolvedThreads = $derived(
commentsOfThisFile
? commentsOfThisFile.threads.filter(t => {
return t.root.resolved === true;
}).length
: 0,
);
const unresolvedThreads = $derived(
commentsOfThisFile
? commentsOfThisFile.threads.filter(t => {
return t.root.resolved === false;
}).length
: 0,
);
</script>
<style>
.added {
color: var(--color-feedback-success-text);
background-color: var(--color-feedback-success-bg);
}
.deleted {
color: var(--color-feedback-error-text);
background-color: var(--color-feedback-error-bg);
}
.moved,
.copied {
color: var(--color-text-secondary);
background: var(--color-surface-subtle);
}
.stats {
font: var(--txt-code-regular);
}
</style>
{#snippet emptyPlaceholder()}
<div class="global-flex" style:margin="1rem 0" style:justify-content="center">
Empty file
</div>
{/snippet}
<FileBlock {expanded}>
{#snippet leftHeader()}
{#if file.status === "moved" || file.status === "copied"}
<span style="display: flex; align-items: center; flex-wrap: wrap;">
<Path fullPath={file.oldPath} />
<span style:padding="0 0.5rem">→</span>
<Path fullPath={file.newPath} />
</span>
{:else}
<Path fullPath={file.path} />
{/if}
{#if file.status === "added"}
<span class="global-chip added">Added</span>
{:else if file.status === "deleted"}
<span class="global-chip deleted">Deleted</span>
{:else if file.status === "moved"}
<span class="global-chip moved">Moved</span>
{:else if file.status === "copied"}
<span class="global-chip copied">Copied</span>
{/if}
{/snippet}
{#snippet rightHeader()}
{#if file.diff.type === "plain" && file.diff.hunks.length > 0}
<div class="stats">
<span style:color="var(--color-feedback-success-text)">
+{file.diff.stats.additions}
</span>
<span style:color="var(--color-feedback-error-text)">
-{file.diff.stats.deletions}
</span>
</div>
{/if}
{#if commentsOfThisFile && commentsOfThisFile.threads.length > 0}
{#if unresolvedThreads > 0}
<div class="global-flex">
<Icon name="comment-cross" />
{unresolvedThreads}
</div>
{/if}
{#if resolvedThreads > 0}
<div class="global-flex">
<Icon name="comment-checkmark" />
{resolvedThreads}
</div>
{/if}
{/if}
{/snippet}
{#if file.diff.type === "plain"}
{#if file.diff.hunks.length > 0}
<Diff {file} {head} codeComments={commentsOfThisFile} />
{:else}
{@render emptyPlaceholder()}
{/if}
{:else if file.diff.type === "binary"}
<div
class="global-flex"
style:margin="1rem 0"
style:justify-content="center">
<Icon name="binary" />Binary file
</div>
{:else}
{@render emptyPlaceholder()}
{/if}
</FileBlock>
+1 -1
View File
@@ -1,5 +1,4 @@
<script lang="ts">
import type { CodeComments } from "@app/components/Diff.svelte";
import type { Author } from "@bindings/cob/Author";
import type { Revision } from "@bindings/cob/patch/Revision";
import type { CodeLocation } from "@bindings/cob/thread/CodeLocation";
@@ -7,6 +6,7 @@
import type { Thread } from "@bindings/cob/thread/Thread";
import type { Config } from "@bindings/config/Config";
import type { CodeComments } from "@app/lib/diffComments";
import { draftReviewStorage } from "@app/lib/draftReviewStorage";
import { nodeRunning } from "@app/lib/events";
import { invoke } from "@app/lib/invoke";
+70 -4
View File
@@ -21,6 +21,11 @@
bufferSize?: number;
// Height of any content rendered before this list inside the same scroller.
startMargin?: number;
// Measure the list's own offset within the scroll viewport and use it as
// `startMargin`, kept up to date as content above the list resizes. Use when
// the list is not the first thing in the scroller (e.g. the patch diff sits
// below the patch metadata and commit list).
autoStartMargin?: boolean;
// Distance from the bottom (in pixels) at which to request the next page.
prefetchPx?: number;
// Restore a previously-captured scroll position (used on history nav).
@@ -50,6 +55,7 @@
estimatedItemSize = undefined,
bufferSize = undefined,
startMargin = undefined,
autoStartMargin = false,
prefetchPx = 800,
initialCache = undefined,
initialScrollOffset = undefined,
@@ -65,6 +71,61 @@
let handle = $state<VirtualizerHandle | undefined>(undefined);
let restored = false;
// A zero-height marker placed right before the virtualizer; its position
// tells us how far down the list starts inside the scroll viewport.
let anchorEl = $state<HTMLElement | undefined>(undefined);
let measuredStartMargin = $state(0);
const effectiveStartMargin = $derived(
autoStartMargin ? measuredStartMargin : startMargin,
);
function measureStartMargin() {
if (!autoStartMargin || !anchorEl || !viewport) return;
// Offset within the scroll content (scroll-invariant: the moving rect top is
// cancelled by scrollTop), so it only changes when content above resizes.
const offset =
anchorEl.getBoundingClientRect().top -
viewport.getBoundingClientRect().top +
viewport.scrollTop;
const next = Math.max(0, Math.round(offset));
if (next !== measuredStartMargin) {
measuredStartMargin = next;
}
}
$effect(() => {
const vp = viewport;
if (!autoStartMargin || !vp) return;
// The content above the list lives in sibling/ancestor elements, not in this
// component, so observe the viewport's direct children: any height change
// upstream resizes the child that contains it. A MutationObserver keeps the
// observed set current if those children are added/removed.
const refresh = () => {
measureStartMargin();
// The list may not have scrolled, so re-report so sticky-header math picks
// up the new margin immediately.
reportScrollState();
};
const ro = new ResizeObserver(refresh);
const observeChildren = () => {
ro.disconnect();
for (const child of Array.from(vp.children)) {
ro.observe(child);
}
};
const mo = new MutationObserver(() => {
observeChildren();
refresh();
});
observeChildren();
mo.observe(vp, { childList: true });
measureStartMargin();
return () => {
ro.disconnect();
mo.disconnect();
};
});
$effect(() => {
// Restore the saved scroll offset once, after the virtualizer has mounted
// with the restored size cache (so the offset maps to the right item).
@@ -86,10 +147,13 @@
function reportScrollState() {
const h = handle;
if (!h || !onScrollState) return;
const scrollOffset = h.getScrollOffset();
// `getScrollOffset` is the raw viewport scrollTop; `findItemIndex` expects it
// raw (it subtracts the start margin internally). `getItemOffset` is
// list-relative, so the reported offset is too, letting callers mix the two.
const raw = h.getScrollOffset();
onScrollState({
topIndex: h.findItemIndex(scrollOffset),
scrollOffset,
topIndex: h.findItemIndex(raw),
scrollOffset: raw - (effectiveStartMargin ?? 0),
itemOffset: index => h.getItemOffset(index),
});
}
@@ -117,12 +181,14 @@
const id = requestAnimationFrame(() => {
maybeLoadMore();
reportScrollState();
measureStartMargin();
});
return () => cancelAnimationFrame(id);
});
</script>
{#if viewport}
<div bind:this={anchorEl} style:height="0"></div>
<Virtualizer
bind:this={handle}
data={items}
@@ -130,7 +196,7 @@
{getKey}
itemSize={estimatedItemSize}
{bufferSize}
{startMargin}
startMargin={effectiveStartMargin}
cache={initialCache}
onscroll={onScroll}
onscrollend={reportState}>
+248
View File
@@ -0,0 +1,248 @@
import type { Author } from "@bindings/cob/Author";
import type { CodeLocation } from "@bindings/cob/thread/CodeLocation";
import type { Embed } from "@bindings/cob/thread/Embed";
import type { Thread } from "@bindings/cob/thread/Thread";
import type { Config } from "@bindings/config/Config";
import type { FileDiff } from "@bindings/diff/FileDiff";
import type { Modification } from "@bindings/diff/Modification";
export interface CodeComments {
changeCommentStatus?: (commentId: string, resolved: boolean) => Promise<void>;
config: Config;
createComment: (
body: string,
embeds: Embed[],
replyTo?: string,
location?: CodeLocation,
) => Promise<void>;
editComment: (
commentId: string,
body: string,
embeds: Embed[],
) => Promise<void>;
reactOnComment?: (
commentId: string,
authors: Author[],
reaction: string,
) => Promise<void>;
deleteComment?: (commentId: string) => Promise<void>;
// Defaults to `true`.
canReply?: boolean;
// See `ExtendedTextarea`.
disableAttachments?: boolean | string;
repoDelegates: Author[];
rid: string;
threads: Thread<CodeLocation>[];
}
// Everything the interactive (patch-review) diff path needs, bundled so it can
// be threaded through the single changeset-level virtualizer as one prop.
export interface CommentContext {
codeComments: CodeComments;
threadByLine: Map<string, Thread<CodeLocation>>;
// Per-file resolved/unresolved thread tallies for the file-header badges.
threadCountsByFile: Map<string, { resolved: number; unresolved: number }>;
selection: Selection | undefined;
expandedStates: Record<string, boolean>;
onSelectLine: (
fileIdx: number,
side: Side,
line: Modification,
hunkIdx: number,
lineIdx: number,
file: FileDiff,
) => void;
onClearSelection: () => void;
onToggleThread: (threadId: string) => void;
}
export type Side = "left" | "right";
export type SelectionAnchor = { side: Side; lineNumber: number };
export type SelectionRange = { start: SelectionAnchor; end?: SelectionAnchor };
export interface Selection {
file: string;
// Index of the file in the changeset; disambiguates lines across files, since
// hunkIdx/lineIdx are only unique within a single file.
fileIdx: number;
start: SelectionAnchor;
end: SelectionAnchor;
lineIdx: number;
hunkIdx: number;
codeLocation: CodeLocation;
}
export function lineNumber(line: Modification, side: Side): number | undefined {
if (side === "left") {
if (line.type === "context") {
return line.lineNoOld;
}
if (line.type === "deletion") {
return line.lineNo;
}
} else {
if (line.type === "context") {
return line.lineNoNew;
}
if (line.type === "addition") {
return line.lineNo;
}
}
}
export function determineSelectedAnchor(
side: Side,
line: Modification,
): SelectionAnchor {
// When a user tries to select a side with no changes, use opposite side.
if (side === "left" && line.type === "addition") {
return { side: "right", lineNumber: line.lineNo };
} else if (side === "right" && line.type === "deletion") {
return { side: "left", lineNumber: line.lineNo };
} else {
return side === "left"
? { side: "left", lineNumber: lineNumber(line, "left") as number }
: { side: "right", lineNumber: lineNumber(line, "right") as number };
}
}
export function filePath(file: FileDiff, side: Side): string {
if (file.status === "moved" || file.status === "copied") {
if (side === "left") {
return file.oldPath;
} else {
return file.newPath;
}
} else {
return file.path;
}
}
export function buildSelection(
head: string,
file: FileDiff,
fileIdx: number,
side: Side,
line: Modification,
hunkIdx: number,
lineIdx: number,
): Selection {
const commentAnchor = determineSelectedAnchor(side, line);
return {
file: filePath(file, side),
fileIdx,
start: commentAnchor,
end: commentAnchor,
hunkIdx,
lineIdx,
codeLocation: {
commit: head,
path: filePath(file, side),
old:
commentAnchor.side === "left"
? {
type: "lines",
range: {
start: commentAnchor.lineNumber,
end: commentAnchor.lineNumber + 1,
},
}
: null,
new:
commentAnchor.side === "right"
? {
type: "lines",
range: {
start: commentAnchor.lineNumber,
end: commentAnchor.lineNumber + 1,
},
}
: null,
},
};
}
export function rangeAnchorsFromCodeLocation(
location: CodeLocation | null,
): SelectionRange | undefined {
if (location?.old?.type === "lines") {
return {
start: { side: "left", lineNumber: location.old.range.start },
};
} else if (location?.new?.type === "lines") {
return {
start: { side: "right", lineNumber: location.new.range.start },
};
}
}
// Index threads by the file and line they anchor to, so each rendered line
// does an O(1) lookup instead of scanning every thread. Rebuilt wholesale by
// callers and only read, so a plain Map is correct here. Keys are qualified
// by the thread's file path — line numbers alone repeat across files.
export function buildThreadByLine(
threads: Thread<CodeLocation>[],
): Map<string, Thread<CodeLocation>> {
const map = new Map<string, Thread<CodeLocation>>();
for (const t of threads) {
const path = t.root.location?.path;
if (path === undefined) {
continue;
}
const newEnd = t.root.location?.new?.range.end;
const oldEnd = t.root.location?.old?.range.end;
if (newEnd !== undefined && !map.has(`${path}:new:${newEnd}`)) {
map.set(`${path}:new:${newEnd}`, t);
}
if (oldEnd !== undefined && !map.has(`${path}:old:${oldEnd}`)) {
map.set(`${path}:old:${oldEnd}`, t);
}
}
return map;
}
// Per-file resolved/unresolved thread tallies for the file-header badges.
// Like buildThreadByLine, rebuilt wholesale and only read.
export function buildThreadCountsByFile(
threads: Thread<CodeLocation>[],
): Map<string, { resolved: number; unresolved: number }> {
const counts = new Map<string, { resolved: number; unresolved: number }>();
for (const t of threads) {
const path = t.root.location?.path;
if (path === undefined) {
continue;
}
const c = counts.get(path) ?? { resolved: 0, unresolved: 0 };
if (t.root.resolved) {
c.resolved += 1;
} else {
c.unresolved += 1;
}
counts.set(path, c);
}
return counts;
}
export function findLineThread(
map: Map<string, Thread<CodeLocation>>,
file: FileDiff,
line: Modification,
): Thread<CodeLocation> | undefined {
// Comment locations carry a single path, which is ambiguous for renames
// and copies; like the pre-virtualization per-file diff, those files
// don't get inline threads.
if (file.status === "moved" || file.status === "copied") {
return undefined;
}
if (line.type === "addition") {
return map.get(`${file.path}:new:${line.lineNo + 1}`);
} else if (line.type === "deletion") {
return map.get(`${file.path}:old:${line.lineNo + 1}`);
} else if (line.type === "context") {
return (
map.get(`${file.path}:new:${line.lineNoNew + 1}`) ??
map.get(`${file.path}:old:${line.lineNoOld + 1}`)
);
}
}