Implement code comments for single line selections

This commit is contained in:
Rūdolfs Ošiņš
2025-03-17 21:17:25 -06:00
parent a28aa0d6ab
commit 87a455d2a4
16 changed files with 849 additions and 431 deletions
+5
View File
@@ -22,6 +22,11 @@ body {
background-color: var(--color-background-default);
}
::selection {
background: var(--color-fill-yellow);
color: var(--color-foreground-black);
}
.global-oid {
color: var(--color-foreground-emphasized);
font-size: var(--font-size-small);
+33 -33
View File
@@ -1,67 +1,67 @@
.syntax.operator,
.syntax.keyword\.repeat,
.syntax.keyword {
.global-syntax.operator,
.global-syntax.keyword\.repeat,
.global-syntax.keyword {
color: var(--color-prettylights-syntax-keyword);
}
.syntax.type {
.global-syntax.type {
color: var(--color-prettylights-syntax-entity);
}
.syntax.property,
.syntax.variable\.parameter {
.global-syntax.property,
.global-syntax.variable\.parameter {
color: var(--color-prettylights-syntax-variable);
}
.syntax.punctuation\.delimiter {
.global-syntax.punctuation\.delimiter {
}
.syntax.punctuation\.bracket {
.global-syntax.punctuation\.bracket {
}
.syntax.attribute {
.global-syntax.attribute {
}
.syntax.number,
.syntax.constant,
.syntax.type\.builtin,
.syntax.constant\.builtin,
.syntax.variable\.builtin,
.syntax.function {
.global-syntax.number,
.global-syntax.constant,
.global-syntax.type\.builtin,
.global-syntax.constant\.builtin,
.global-syntax.variable\.builtin,
.global-syntax.function {
color: var(--color-prettylights-syntax-constant);
}
.syntax.comment,
.syntax.comment\.documentation {
.global-syntax.comment,
.global-syntax.comment\.documentation {
color: var(--color-prettylights-syntax-comment);
}
.syntax.string {
.global-syntax.string {
color: var(--color-prettylights-syntax-string);
}
.syntax.string.special {
.global-syntax.string.special {
}
.syntax.function\.method {
.global-syntax.function\.method {
color: var(--color-prettylights-syntax-entity);
}
.syntax.type.builtin {
.global-syntax.type.builtin {
}
.syntax.punctuation.bracket {
.global-syntax.punctuation.bracket {
}
.syntax.punctuation.delimiter {
.global-syntax.punctuation.delimiter {
}
.syntax.punctuation.special {
.global-syntax.punctuation.special {
}
.syntax.text.literal {
.global-syntax.text.literal {
}
.syntax.text.title {
.global-syntax.text.title {
}
.syntax.variable {
.global-syntax.variable {
color: var(--color-prettylights-syntax-storage-modifier-import);
}
.syntax.attribute {
.global-syntax.attribute {
}
.syntax.label {
.global-syntax.label {
}
.syntax.type {
.global-syntax.type {
}
.syntax.variable.parameter {
.global-syntax.variable.parameter {
}
.syntax.constructor {
.global-syntax.constructor {
color: var(--color-prettylights-syntax-entity);
}
.syntax.tag\.delimiter {
.global-syntax.tag\.delimiter {
color: var(--color-prettylights-syntax-keyword);
}
+1 -1
View File
@@ -5,7 +5,7 @@
children: Snippet;
variant: "primary" | "secondary" | "ghost" | "float" | "danger" | "success";
hoverable?: boolean;
onclick?: () => void;
onclick?: (e: MouseEvent) => void;
stylePosition?: string;
stylePadding?: string;
styleHeight?: string;
+15 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import type { Commit } from "@bindings/repo/Commit";
import type { Diff } from "@bindings/diff/Diff";
import type { CodeComments } from "./Diff.svelte";
import type { Revision } from "@bindings/cob/patch/Revision";
import { invoke } from "@app/lib/invoke";
@@ -17,10 +18,17 @@
revision: Revision;
rid: string;
showCommits?: boolean;
codeComments?: CodeComments;
}
/* eslint-disable prefer-const */
let { patchId, revision, rid, showCommits = true }: Props = $props();
let {
patchId,
revision,
rid,
showCommits = true,
codeComments,
}: Props = $props();
/* eslint-enable prefer-const */
let hideChanges = $state(false);
@@ -39,7 +47,7 @@
options: {
base,
head,
unified: 5,
unified: 3,
highlight: true,
},
});
@@ -144,6 +152,10 @@
{#await loadHighlightedDiff(rid, revision.base, revision.head)}
<span class="txt-small">Loading…</span>
{:then diff}
<Changeset {diff} repoId={rid} expanded={filesExpanded} />
<Changeset
expanded={filesExpanded}
head={revision.head}
{diff}
{codeComments} />
{/await}
</div>
+6 -9
View File
@@ -1,15 +1,17 @@
<script lang="ts">
import type { Diff } from "@bindings/diff/Diff";
import type { CodeComments } from "./Diff.svelte";
import FileDiff from "./Changeset/FileDiff.svelte";
import FileDiffComponent from "./FileDiff.svelte";
interface Props {
codeComments?: CodeComments;
diff: Diff;
repoId: string;
expanded?: boolean;
head: string;
}
const { diff, expanded = true }: Props = $props();
const { codeComments, diff, expanded = true, head }: Props = $props();
</script>
<style>
@@ -26,12 +28,7 @@
<div class="diff-list">
{#each diff.files as file}
<div class="diff">
<FileDiff
{expanded}
filePath={"path" in file ? file.path : file.newPath}
oldFilePath={"oldPath" in file ? file.oldPath : undefined}
fileDiff={file.diff}
headerBadgeCaption={file.status} />
<FileDiffComponent {codeComments} {expanded} {file} {head} />
</div>
{/each}
</div>
-323
View File
@@ -1,323 +0,0 @@
<script lang="ts">
import type { DiffContent } from "@bindings/diff/DiffContent";
import type { FileDiff } from "@bindings/diff/FileDiff";
import type { Modification } from "@bindings/diff/Modification";
import escape from "lodash/escape";
import File from "@app/components/File.svelte";
import Icon from "@app/components/Icon.svelte";
interface Props {
filePath: string;
oldFilePath?: string | undefined;
fileDiff: DiffContent;
headerBadgeCaption: FileDiff["status"];
expanded: boolean;
}
const {
filePath,
oldFilePath = undefined,
fileDiff,
headerBadgeCaption,
expanded,
}: Props = $props();
function lineNumberR(line: Modification): string | number {
switch (line.type) {
case "addition": {
return line.lineNo;
}
case "context": {
return line.lineNoNew;
}
case "deletion": {
return " ";
}
}
}
function lineNumberL(line: Modification): string | number {
switch (line.type) {
case "addition": {
return " ";
}
case "context": {
return line.lineNoOld;
}
case "deletion": {
return line.lineNo;
}
}
}
function lineSign(line: Modification): string {
switch (line.type) {
case "addition": {
return "+";
}
case "context": {
return " ";
}
case "deletion": {
return "-";
}
}
}
</script>
<style>
.container {
font-size: var(--font-size-small);
overflow-x: auto;
}
.actions {
display: flex;
flex-direction: row;
align-items: center;
gap: 1rem;
}
.browse {
margin-left: auto;
}
.expand-button {
cursor: pointer;
user-select: none;
margin-right: 0.5rem;
}
.diff {
font-family: var(--font-family-monospace);
table-layout: fixed;
border-collapse: collapse;
margin: 0.5rem 0;
-webkit-touch-callout: initial;
-webkit-user-select: text;
user-select: text;
}
.diff-line {
vertical-align: top;
}
.diff-line.type-addition > * {
background-color: var(--color-fill-diff-green-light);
}
.diff-line.type-deletion > * {
background-color: var(--color-fill-diff-red-light);
}
.diff-line.selected > * {
background-color: var(--color-fill-float-hover);
}
.diff-line.selected.type-addition > * {
background-color: var(--color-fill-diff-green);
}
.diff-line.selected.type-deletion > * {
background-color: var(--color-fill-diff-red);
}
.type-addition > .diff-line-number,
.type-addition > .diff-line-type {
color: var(--color-foreground-success);
}
.type-deletion > .diff-line-number,
.type-deletion > .diff-line-type {
color: var(--color-foreground-red);
}
.diff-line.selected .selection-indicator-left {
background-color: var(--color-fill-secondary);
}
.type-addition.diff-line.selected .selection-indicator-left {
background-color: var(--color-fill-secondary);
}
.type-deletion.diff-line.selected .selection-indicator-left {
background-color: var(--color-fill-secondary);
}
.diff-line.selected .selection-indicator-right {
background-color: var(--color-fill-secondary);
}
.type-addition.diff-line.selected .selection-indicator-right {
background-color: var(--color-fill-secondary);
}
.type-deletion.diff-line.selected .selection-indicator-right {
background-color: var(--color-fill-secondary);
}
.selection-start {
box-shadow: 0 -1px 0 0 var(--color-fill-secondary);
z-index: 1;
}
.selection-end {
box-shadow: 0 1px 0 0 var(--color-fill-secondary);
z-index: 1;
}
.selection-start.selection-end {
box-shadow: 0 0 0 1px var(--color-fill-secondary);
z-index: 1;
}
.diff-line-number {
font-family: var(--font-family-monospace);
text-align: right;
user-select: none;
line-height: 1.5rem;
min-width: 3rem;
color: var(--color-foreground-disabled);
-webkit-user-select: none;
user-select: none;
}
.diff-line-number.left {
position: relative;
padding: 0 0.5rem 0 0.75rem;
}
.selection-indicator-left {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 1px;
}
.selection-indicator-right {
display: none; /* FIXME: fix the selection indicator */
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 1px;
}
.diff-line-number.right {
padding: 0 0.75rem 0 0.5rem;
}
.diff-line-content {
color: unset !important;
white-space: pre-wrap;
overflow-wrap: anywhere;
width: 100%;
padding-right: 0.5rem;
}
.diff-line-type {
text-align: center;
padding-left: 0.75rem;
padding-right: 0.75rem;
-webkit-user-select: none;
user-select: none;
}
.diff-expand-header {
padding-left: 0.5rem;
color: var(--color-foreground-dim);
}
.added {
color: var(--color-foreground-success);
background-color: var(--color-fill-diff-green-light);
}
.deleted {
color: var(--color-foreground-red);
background-color: var(--color-fill-diff-red-light);
}
.moved,
.copied {
color: var(--color-foreground-dim);
background: var(--color-fill-ghost);
}
</style>
{#snippet styledPath(fullPath: string)}
<!-- prettier-ignore -->
<span class="txt-small" style:white-space="nowrap" style:-webkit-touch-callout="initial" style:-webkit-user-select="text" style:user-select="text"><span style:color="var(--color-fill-gray)" style:font-weight="var(--font-weight-regular)">{fullPath.match(/^.*\/|/)?.values().next().value}</span><span style:font-weight="var(--font-weight-semibold)">{fullPath.split("/").slice(-1)}</span></span>
{/snippet}
{#snippet emptyPlaceholder()}
<div class="global-flex" style:margin="1rem 0" style:justify-content="center">
<Icon name="none" />Empty file
</div>
{/snippet}
<File {expanded}>
{#snippet leftHeader()}
{#if (headerBadgeCaption === "moved" || headerBadgeCaption === "copied") && oldFilePath}
<span style="display: flex; align-items: center; flex-wrap: wrap;">
{@render styledPath(filePath)}
<span style:padding="0 0.5rem">→</span>
{@render styledPath(filePath)}
</span>
{:else}
{@render styledPath(filePath)}
{/if}
{#if headerBadgeCaption === "added"}
<span class="global-counter added">added</span>
{:else if headerBadgeCaption === "deleted"}
<span class="global-counter deleted">deleted</span>
{:else if headerBadgeCaption === "moved"}
<span class="global-counter moved">moved</span>
{:else if headerBadgeCaption === "copied"}
<span class="global-counter copied">copied</span>
{/if}
{/snippet}
{#snippet children()}
<div class="container">
{#if fileDiff.type === "plain"}
{#if fileDiff.hunks.length > 0}
<table class="diff" data-file-diff-select>
{#each fileDiff.hunks as hunk, hunkIdx}
<!-- svelte-ignore node_invalid_placement_ssr -->
<tr class="diff-line hunk-header">
<td colspan={2} style:position="relative">
<div class="selection-indicator-left"></div>
</td>
<td
colspan={6}
class="diff-expand-header"
style:position="relative">
{hunk.header}
<div class="selection-indicator-right"></div>
</td>
</tr>
{#each hunk.lines as line, lineIdx}
<!-- svelte-ignore node_invalid_placement_ssr -->
<tr
style="position: relative;"
class={`diff-line type-${line.type}`}>
<td
id={[filePath, "H" + hunkIdx, "L" + lineIdx].join("-")}
class="diff-line-number left">
<div class="selection-indicator-left"></div>
{lineNumberL(line)}
</td>
<td class="diff-line-number right">
{lineNumberR(line)}
</td>
<td class="diff-line-type" data-line-type={line.type}>
{lineSign(line)}
</td>
<td class="diff-line-content">
{#if line.highlight?.items.length === 0 || line.line === ""}
<br />
{:else if line.highlight}
{@html line.highlight.items
.map(
s =>
`<span class="syntax ${s.style}">${escape(s.item)}</span>`,
)
.join("")}
{:else}
{line.line}
{/if}
</td>
<td class="selection-indicator-right"></td>
</tr>
{/each}
{/each}
</table>
{:else}
{@render emptyPlaceholder()}
{/if}
{:else}
{@render emptyPlaceholder()}
{/if}
</div>
{/snippet}
</File>
+5 -1
View File
@@ -38,6 +38,7 @@
padding: 0 0.75rem;
font-size: var(--font-size-small);
color: var(--color-fill-gray);
font-family: var(--font-family-sans-serif);
}
</style>
@@ -71,7 +72,10 @@
variant="float"
styleHeight="40px"
styleWidth="100%"
onclick={() => {
onclick={e => {
e.preventDefault();
e.stopPropagation();
state = "expanded";
if (onexpand !== undefined) {
onexpand();
+465
View File
@@ -0,0 +1,465 @@
<script lang="ts" module>
export interface CodeComments {
threads: Thread<CodeLocation>[];
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>;
repoDelegates: Author[];
rid: string;
}
</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();
selection = {
file: filePath(file, side),
start: determineSelectedAnchor(side, line),
end: determineSelectedAnchor(side, line),
hunkIdx: hunkIdx,
lineIdx: lineIdx,
codeLocation: {
commit: head,
path: filePath(file, side),
old:
side === "left"
? {
type: "lines",
range: {
start: lineNumber(line, "left") as number,
end: (lineNumber(line, "left") as number) + 1,
},
}
: null,
new:
side === "right"
? {
type: "lines",
range: {
start: lineNumber(line, "right") as number,
end: (lineNumber(line, "right") as number) + 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 },
};
}
}
</script>
<style>
.container {
/* Make space for the box-shadow border, otherwise it gets cut off due to
overflow: hide on the container. */
padding: 8px 1px;
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-light);
}
.deletion {
background-color: var(--color-fill-diff-red-light);
}
.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;
}
.left:hover,
.right:hover,
.left:active,
.right:active {
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-fill-float-hover);
padding: 0.5rem;
margin-bottom: 1rem;
}
.comment-form {
background-color: var(--color-fill-float-hover);
font-family: var(--font-family-sans-serif);
display: flex;
flex-direction: column;
padding: 1rem;
margin-bottom: 1rem;
}
.comment-header {
display: flex;
background-color: var(--color-fill-ghost);
clip-path: var(--1px-corner-fill);
padding: 0 8px;
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: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: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 && thread.replies.every(r => r.resolved)}
<Icon name="comment-checkmark" />
{:else}
<Icon name="comment" />
{/if}
{/if}
</div>
</div>
{#if codeComments && thread}
<div class="thread">
<div style:padding="0.5rem">
{@render commentHeader(
thread.root.location?.path,
rangeAnchorsFromCodeLocation(thread.root.location),
)}
</div>
<ThreadComponent
inline
rid={codeComments.rid}
{thread}
reactOnComment={codeComments.config &&
partial(
codeComments.reactOnComment,
codeComments.config.publicKey,
)}
createReply={async (body, embeds) => {
await codeComments.createComment(
body,
embeds,
thread.root.id,
);
}}
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"
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>
+1
View File
@@ -207,6 +207,7 @@
gap: 1rem;
width: 100%;
flex: 1;
font-family: var(--font-family-sans-serif);
}
.inline {
border: 0;
+19 -3
View File
@@ -7,14 +7,21 @@
import NakedButton from "./NakedButton.svelte";
interface Props {
sticky?: boolean;
leftHeader: Snippet;
children: Snippet;
expanded: boolean;
leftHeader: Snippet;
rightHeader?: Snippet;
sticky?: boolean;
}
/* eslint-disable prefer-const */
let { sticky = true, leftHeader, children, expanded }: Props = $props();
let {
children,
expanded,
leftHeader,
rightHeader,
sticky = true,
}: Props = $props();
/* eslint-enable prefer-const */
let header: HTMLElement | undefined = $state();
@@ -91,6 +98,15 @@
</NakedButton>
{@render leftHeader()}
</div>
{#if rightHeader}
<div
class="global-flex"
style:gap="1rem"
style:margin-left="auto"
style:margin-right="1rem">
{@render rightHeader()}
</div>
{/if}
</div>
{#if expanded}
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import type { FileDiff } from "@bindings/diff/FileDiff";
import type { CodeComments } from "./Diff.svelte";
import Diff from "@app/components/Diff.svelte";
import File from "@app/components/File.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());
</script>
<style>
.added {
color: var(--color-foreground-success);
background-color: var(--color-fill-diff-green-light);
}
.deleted {
color: var(--color-foreground-red);
background-color: var(--color-fill-diff-red-light);
}
.moved,
.copied {
color: var(--color-foreground-dim);
background: var(--color-fill-ghost);
}
.stats {
font-size: var(--font-size-tiny);
font-family: var(--font-family-monospace);
font-weight: var(--font-weight-semibold);
}
</style>
{#snippet emptyPlaceholder()}
<div class="global-flex" style:margin="1rem 0" style:justify-content="center">
<Icon name="none" />Empty file
</div>
{/snippet}
<File {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-counter added">added</span>
{:else if file.status === "deleted"}
<span class="global-counter deleted">deleted</span>
{:else if file.status === "moved"}
<span class="global-counter moved">moved</span>
{:else if file.status === "copied"}
<span class="global-counter 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-foreground-success)">
+{file.diff.stats.additions}
</span>
<span style:color="var(--color-foreground-red)">
-{file.diff.stats.deletions}
</span>
</div>
{/if}
{#if commentsOfThisFile && commentsOfThisFile.threads.length > 0}
<Icon name="comment" />
{/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}
</File>
+21
View File
@@ -12,6 +12,7 @@
| "arrow-right"
| "arrow-right-hollow"
| "attachment"
| "binary"
| "branch"
| "broom"
| "broom-double"
@@ -162,6 +163,26 @@
<path d="M4 8H5V9L4 9V8Z" />
<path d="M5 6H11V7H5V6Z" />
<path d="M4 7H5L5 8H4L4 7Z" />
{:else if name === "binary"}
<path d="M10 3.5H11V4.5H10V3.5Z" />
<path d="M11 4.5L12 4.5V5.5H11V4.5Z" />
<path d="M10 5.5L12 5.5V6.5H10V5.5Z" />
<path d="M9 5.5H10V6.5H9V5.5Z" />
<path d="M8 4.5H9V5.5L8 5.5V4.5Z" />
<path d="M8 2.5H9L9 4.5H8L8 2.5Z" />
<path d="M9 2.5L10 2.5V3.5H9V2.5Z" />
<path d="M4 13.5H12V14.5H4V13.5Z" />
<path d="M4 1.5H9V2.5L4 2.5V1.5Z" />
<path d="M13 5.5V13.5L12 13.5L12 5.5L13 5.5Z" />
<path d="M4 2.5L4 13.5H3L3 2.5L4 2.5Z" />
<path d="M5 9.5H6V11.5H5V9.5Z" />
<path d="M7 9.5H8V11.5H7V9.5Z" />
<path d="M6 11.5H7L7 12.5H6L6 11.5Z" />
<path d="M6 8.5H7V9.5L6 9.5L6 8.5Z" />
<path d="M9 9.5H10V10.5H9V9.5Z" />
<path d="M10 8.5H11V12.5H10V8.5Z" />
<path d="M5 4.5H6V5.5H5V4.5Z" />
<path d="M6 3.5H7V7.5H6V3.5Z" />
{:else if name === "branch"}
<path d="M11 5L10 5V2L13 2V5L12 5V8L11 8V5ZM11 3H12V4H11V3Z" />
<path
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
interface Props {
fullPath: string;
}
const { fullPath }: Props = $props();
</script>
<style>
.container {
white-space: nowrap;
-webkit-touch-callout: initial;
-webkit-user-select: text;
user-select: text;
}
.path {
color: var(--color-fill-gray);
font-weight: var(--font-weight-regular);
}
.filename {
font-weight: var(--font-weight-semibold);
}
</style>
<!-- prettier-ignore -->
<span class="txt-small container">
<span class="path">
{fullPath
.match(/^.*\/|/)
?.values()
.next().value}
</span><span class="filename">
{fullPath.split("/").slice(-1)}
</span>
</span>
+42 -3
View File
@@ -1,5 +1,6 @@
<script lang="ts">
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 { RepoInfo } from "@bindings/repo/RepoInfo";
@@ -66,7 +67,9 @@
review.comments
.filter(
comment =>
(comment.id !== review.id && !comment.replyTo) ||
(!comment.location &&
comment.id !== review.id &&
!comment.replyTo) ||
comment.replyTo === review.id,
)
.map(thread => {
@@ -81,6 +84,28 @@
}, [])) as Thread[]) || [],
);
const codeCommentThreads = $derived(
((review.comments &&
review.comments
.filter(
comment =>
(comment.location &&
comment.id !== review.id &&
!comment.replyTo) ||
comment.replyTo === review.id,
)
.map(thread => {
return {
root: thread,
replies:
review.comments &&
review.comments
.filter(comment => comment.replyTo === thread.id)
.sort((a, b) => a.edits[0].timestamp - b.edits[0].timestamp),
};
}, [])) as Thread<CodeLocation>[]) || [],
);
let verdict: Review["verdict"] = $state(review.verdict);
let labelSaveInProgress: boolean = $state(false);
@@ -122,8 +147,8 @@
body: string,
embeds: Embed[],
replyTo?: string,
location?: CodeLocation,
) {
console.log({ replyTo });
try {
await invoke("edit_patch", {
rid: repo.rid,
@@ -134,6 +159,7 @@
body,
embeds,
replyTo,
location,
},
opts: { announce: $nodeRunning && $announce },
});
@@ -350,5 +376,18 @@
{editComment}
{reactOnComment} />
<Changes rid={repo.rid} showCommits={false} {patchId} {revision} />
<Changes
codeComments={{
config,
createComment,
editComment,
reactOnComment,
repoDelegates: repo.delegates,
rid: repo.rid,
threads: codeCommentThreads,
}}
rid={repo.rid}
showCommits={false}
{patchId}
{revision} />
</div>
+63 -54
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import type { Author } from "@bindings/cob/Author";
import type { Comment } from "@bindings/cob/thread/Comment";
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 { tick } from "svelte";
import partial from "lodash/partial";
@@ -14,10 +15,7 @@
import Icon from "@app/components/Icon.svelte";
interface Props {
thread: {
root: Comment;
replies: Comment[];
};
thread: Thread<CodeLocation>;
rid: string;
canEditComment: (author: string) => true | undefined;
editComment?: (
@@ -35,6 +33,7 @@
authors: Author[],
reaction: string,
) => Promise<void>;
inline?: boolean;
}
const {
@@ -44,6 +43,7 @@
editComment,
createReply,
reactOnComment,
inline = false,
}: Props = $props();
async function toggleReply() {
@@ -76,6 +76,7 @@
display: flex;
flex-direction: column;
width: 100%;
font-family: var(--font-family-sans-serif);
}
.top-level-comment {
@@ -96,8 +97,53 @@
}
</style>
{#snippet repliesSnippet()}
<div style:width="100%">
{#if replies.length > 0}
{#each replies as reply}
<CommentComponent
disallowEmptyBody
{rid}
lastEdit={reply.edits.length > 1 ? reply.edits.at(-1) : undefined}
id={reply.id}
author={reply.author}
caption="replied"
reactions={reply.reactions}
timestamp={reply.edits[0].timestamp}
body={reply.edits.slice(-1)[0].body}
editComment={editComment &&
canEditComment(reply.author.did) &&
partial(editComment, reply.id)}
reactOnComment={reactOnComment &&
partial(reactOnComment, reply.id)} />
{/each}
{/if}
{#if createReply && showReplyForm}
<div id={`reply-${root.id}`} style:padding="1rem">
<ExtendedTextarea
disallowEmptyBody
{submitInProgress}
{rid}
placeholder="Reply to comment"
submitCaption="Reply"
focus
close={() => (showReplyForm = false)}
submit={async ({ comment, embeds }) => {
try {
submitInProgress = true;
await createReply(comment, Array.from(embeds.values()), root.id);
} finally {
showReplyForm = false;
submitInProgress = false;
}
}} />
</div>
{/if}
</div>
{/snippet}
<div class="comments" {style}>
<div class="top-level-comment">
<div class:top-level-comment={!inline}>
<CommentComponent
disallowEmptyBody
{rid}
@@ -112,58 +158,21 @@
partial(editComment, root.id)}
reactOnComment={reactOnComment && partial(reactOnComment, root.id)}>
{#snippet actions()}
<Icon name="reply" onclick={toggleReply} />
{#if createReply}
<Icon name="reply" onclick={toggleReply} />
{/if}
{/snippet}
</CommentComponent>
</div>
{#if replies.length > 0 || (createReply && showReplyForm)}
<Border variant="float" styleOverflow="hidden" flatTop>
<div style:width="100%">
{#if replies.length > 0}
{#each replies as reply}
<CommentComponent
disallowEmptyBody
{rid}
lastEdit={reply.edits.length > 1 ? reply.edits.at(-1) : undefined}
id={reply.id}
author={reply.author}
caption="replied"
reactions={reply.reactions}
timestamp={reply.edits[0].timestamp}
body={reply.edits.slice(-1)[0].body}
editComment={editComment &&
canEditComment(reply.author.did) &&
partial(editComment, reply.id)}
reactOnComment={reactOnComment &&
partial(reactOnComment, reply.id)} />
{/each}
{/if}
{#if createReply && showReplyForm}
<div id={`reply-${root.id}`} style:padding="1rem">
<ExtendedTextarea
disallowEmptyBody
{submitInProgress}
{rid}
placeholder="Reply to comment"
submitCaption="Reply"
focus
close={() => (showReplyForm = false)}
submit={async ({ comment, embeds }) => {
try {
submitInProgress = true;
await createReply(
comment,
Array.from(embeds.values()),
root.id,
);
} finally {
showReplyForm = false;
submitInProgress = false;
}
}} />
</div>
{/if}
{#if inline}
<div style:background-color="var(--color-background-float)">
{@render repliesSnippet()}
</div>
</Border>
{:else}
<Border variant="float" styleOverflow="hidden" flatTop={!inline}>
{@render repliesSnippet()}
</Border>
{/if}
{/if}
</div>
+11 -1
View File
@@ -259,6 +259,16 @@
console.error("Loading patch list failed", error);
}
}
function findReviewRevision(review: Review): Revision {
// Every review is guaranteed to have a revision according to the protocol
// model, so using type assertions here is safe.
return revisions.find(revision => {
return revision.reviews!.find(rev => {
return rev.id === review.id;
});
}) as Revision;
}
</script>
<style>
@@ -447,7 +457,7 @@
{repo}
{reload}
{review}
revision={selectedRevision}
revision={findReviewRevision(review)}
onNavigateBack={() => {
review = undefined;
}} />