Files
radicle-explorer/src/components/Diff.svelte
T
Rūdolfs Ošiņš a55a138a7f Improve code review usability
This patch moves the revision list from the tab bar drop-down to a sidebar
on wide screens and above the current revision on narrow screens. This
way all revisions are visible at all times.

We also move and nest the revision reviews into that list so that one
can see whether there's anything actionable at a glance.

Other tweaks:

  - added comment counts to revisions and reviews
  - displayed unresolved comment thread counts per review
  - swapped icons for accepted/rejected and neutral reviews to avoid clashing
    with resolved/unresolved comment icons
  - made label and assignee sections more compact with hover-triggered "+" buttons
  - added author and status sections to the sidebar
  - enabled patch state changes from the title bar
  - made "Review revision" the primary action and "Checkout patch" secondary
  - added "Draft" label to draft patches
  - capitalized all badges for consistent UI text
2025-07-03 11:46:39 +02:00

533 lines
16 KiB
Svelte

<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?: (
publicKey: string,
commentId: string,
authors: Author[],
reaction: 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 { Config } from "@bindings/config/Config";
import type { Embed } from "@bindings/cob/thread/Embed";
import type { FileDiff } from "@bindings/diff/FileDiff";
import type { Modification } from "@bindings/diff/Modification";
import type { Thread } from "@bindings/cob/thread/Thread";
import * as roles from "@app/lib/roles";
import escape from "lodash/escape";
import partial from "lodash/partial";
import CommentToggleInput from "./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 },
};
}
}
let threadExpandedStates: Record<string, boolean> = $state(
codeComments
? Object.fromEntries(
codeComments.threads.map(t => [t.root.id, t.root.resolved]),
)
: {},
);
$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-size: var(--font-size-small);
font-family: var(--font-family-monospace);
}
.line {
display: flex;
position: relative;
white-space: pre-wrap;
}
.hunk-header {
color: var(--color-foreground-dim);
}
.hunk-header > .left,
.hunk-header > .right {
cursor: default;
}
.addition {
background-color: var(--color-fill-diff-green);
}
.deletion {
background-color: var(--color-fill-diff-red);
}
.addition > .left,
.addition > .right,
.addition > .sign {
color: var(--color-foreground-success);
}
.deletion > .left,
.deletion > .right,
.deletion > .sign {
color: var(--color-foreground-red);
}
.context > .left,
.context > .right,
.context > .sign {
color: var(--color-foreground-disabled);
}
.marker {
color: var(--color-foreground-contrast) !important;
}
.selected {
box-shadow: 0 0 0 1px var(--color-fill-secondary);
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-foreground-contrast);
}
.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-background-default);
box-shadow: inset 0 0 0 2px var(--color-border-hint);
padding: 0.5rem;
}
.comment-form {
background-color: var(--color-background-default);
box-shadow: inset 0 0 0 2px var(--color-border-default);
font-family: var(--font-family-sans-serif);
display: flex;
flex-direction: column;
padding: 1rem;
}
.comment-header {
display: flex;
background-color: var(--color-fill-ghost);
clip-path: var(--1px-corner-fill);
padding: 0 0.5rem;
width: fit-content;
}
</style>
{#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 line, lineIdx}
{@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)}>
<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>
<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="unresolve"
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}
{thread}
reactOnComment={codeComments.reactOnComment &&
partial(
codeComments.reactOnComment,
codeComments.config.publicKey,
)}
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),
)} />
</div>
{/if}
{#if codeComments && selection && selection.hunkIdx === hunkIdx && selection.lineIdx === lineIdx && selection.codeLocation}
<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}
{/each}
</div>
{/each}
{/if}
</div>