Radish alpha
r
Radicle desktop app
Radicle
Git (anonymous pull)
Log in to clone via SSH
Implement code comments for single line selections
Rūdolfs Ošiņš committed 1 year ago
commit 87a455d2a4873a93d1b7dce28a7a56018e2153ad
parent a28aa0d6abfa3bda148ff3188893ed0153c4e2b0
16 files changed +849 -431
modified public/index.css
@@ -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);
modified public/syntax.css
@@ -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);
}
modified src/components/Border.svelte
@@ -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;
modified src/components/Changes.svelte
@@ -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>
modified src/components/Changeset.svelte
@@ -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>
deleted src/components/Changeset/FileDiff.svelte
@@ -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>
modified src/components/CommentToggleInput.svelte
@@ -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();
added src/components/Diff.svelte
@@ -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>
modified src/components/ExtendedTextarea.svelte
@@ -207,6 +207,7 @@
    gap: 1rem;
    width: 100%;
    flex: 1;
+
    font-family: var(--font-family-sans-serif);
  }
  .inline {
    border: 0;
modified src/components/File.svelte
@@ -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}
added src/components/FileDiff.svelte
@@ -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>
modified src/components/Icon.svelte
@@ -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
added src/components/Path.svelte
@@ -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>
modified src/components/Review.svelte
@@ -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>
modified src/components/Thread.svelte
@@ -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>
modified src/views/repo/Patch.svelte
@@ -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;
      }} />