mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-11 18:30:36 +08:00
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:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user