Radish alpha
r
Radicle desktop app
Radicle
Git (anonymous pull)
Log in to clone via SSH
Implement line comments
Rūdolfs Ošiņš committed 1 year ago
commit 5b8c21d7806a691e99e3baf526cdf32b87f2461c
parent c67678ea154447787b6032d34f7e98c55dcbc656
16 files changed +903 -374
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,7 +1,10 @@
<script lang="ts">
+
  import type { CodeLocation } from "@bindings/cob/thread/CodeLocation";
  import type { Commit } from "@bindings/repo/Commit";
  import type { Diff } from "@bindings/diff/Diff";
+
  import type { Embed } from "@bindings/cob/thread/Embed";
  import type { Revision } from "@bindings/cob/patch/Revision";
+
  import type { Thread } from "@bindings/cob/thread/Thread";

  import { invoke } from "@app/lib/invoke";

@@ -13,6 +16,13 @@
  import NakedButton from "@app/components/NakedButton.svelte";

  interface Props {
+
    createComment?: (
+
      body: string,
+
      embeds: Embed[],
+
      replyTo?: string,
+
      location?: CodeLocation,
+
    ) => Promise<void>;
+
    codeCommentThreads?: Thread<CodeLocation>[];
    patchId: string;
    revision: Revision;
    rid: string;
@@ -20,7 +30,14 @@
  }

  /* eslint-disable prefer-const */
-
  let { patchId, revision, rid, showCommits = true }: Props = $props();
+
  let {
+
    createComment,
+
    codeCommentThreads,
+
    patchId,
+
    revision,
+
    rid,
+
    showCommits = true,
+
  }: Props = $props();
  /* eslint-enable prefer-const */

  let hideChanges = $state(false);
@@ -39,7 +56,7 @@
      options: {
        base,
        head,
-
        unified: 5,
+
        unified: 3,
        highlight: true,
      },
    });
@@ -144,6 +161,12 @@
  {#await loadHighlightedDiff(rid, revision.base, revision.head)}
    <span class="txt-small">Loading…</span>
  {:then diff}
-
    <Changeset {diff} repoId={rid} expanded={filesExpanded} />
+
    <Changeset
+
      {codeCommentThreads}
+
      {diff}
+
      {rid}
+
      expanded={filesExpanded}
+
      {createComment}
+
      head={revision.head} />
  {/await}
</div>
modified src/components/Changeset.svelte
@@ -1,15 +1,33 @@
<script lang="ts">
+
  import type { CodeLocation } from "@bindings/cob/thread/CodeLocation";
  import type { Diff } from "@bindings/diff/Diff";
+
  import type { Embed } from "@bindings/cob/thread/Embed";
+
  import type { Thread } from "@bindings/cob/thread/Thread";

-
  import FileDiff from "./Changeset/FileDiff.svelte";
+
  import FileDiff from "./FileDiff.svelte";

  interface Props {
    diff: Diff;
-
    repoId: string;
    expanded?: boolean;
+
    rid: string;
+
    head: string;
+
    codeCommentThreads?: Thread<CodeLocation>[];
+
    createComment?: (
+
      body: string,
+
      embeds: Embed[],
+
      replyTo?: string,
+
      location?: CodeLocation,
+
    ) => Promise<void>;
  }

-
  const { diff, expanded = true }: Props = $props();
+
  const {
+
    diff,
+
    expanded = true,
+
    rid,
+
    createComment,
+
    head,
+
    codeCommentThreads,
+
  }: Props = $props();
</script>

<style>
@@ -27,11 +45,12 @@
  {#each diff.files as file}
    <div class="diff">
      <FileDiff
+
        {file}
        {expanded}
-
        filePath={"path" in file ? file.path : file.newPath}
-
        oldFilePath={"oldPath" in file ? file.oldPath : undefined}
-
        fileDiff={file.diff}
-
        headerBadgeCaption={file.status} />
+
        {rid}
+
        {createComment}
+
        {head}
+
        {codeCommentThreads} />
    </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,486 @@
+
<script lang="ts">
+
  type Side = "left" | "right";
+
  type SelectionAnchor = { side: Side; lineNumber: number };
+

+
  interface Selection {
+
    file: string;
+
    start: SelectionAnchor;
+
    end: SelectionAnchor;
+
    startHunkIdx: number;
+
    endHunkIdx: number;
+
    startLineIdx: number;
+
    endLineIdx: number;
+
  }
+

+
  import type { CodeLocation } from "@bindings/cob/thread/CodeLocation";
+
  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 escape from "lodash/escape";
+
  import CommentToggleInput from "./CommentToggleInput.svelte";
+

+
  interface Props {
+
    file: FileDiff;
+
    rid: string;
+
    head: string;
+
    createComment?: (
+
      body: string,
+
      embeds: Embed[],
+
      replyTo?: string,
+
      location?: CodeLocation,
+
    ) => Promise<void>;
+
    codeCommentThreads?: Thread<CodeLocation>[];
+
  }
+

+
  const { file, rid, createComment, head, codeCommentThreads }: Props =
+
    $props();
+

+
  console.log({ codeCommentThreads });
+

+
  let dragging = $state(false);
+
  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 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 } // FIXME get rid of type assertion
+
        : { side: "right", lineNumber: lineNumber(line, "right") as number }; //FIXME get rid of type assertion
+
    }
+
  }
+

+
  function deselect() {
+
    selection = undefined;
+
  }
+

+
  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 endDragSelection() {
+
    dragging = false;
+
  }
+

+
  function startDragSelection(
+
    e: MouseEvent,
+
    file: FileDiff,
+
    side: Side,
+
    line: Modification,
+
    hunkIdx: number,
+
    lineIdx: number,
+
  ) {
+
    e.preventDefault();
+
    e.stopPropagation();
+

+
    dragging = true;
+

+
    selection = {
+
      file: filePath(file, side),
+
      start: determineSelectedAnchor(side, line),
+
      end: determineSelectedAnchor(side, line),
+
      startHunkIdx: hunkIdx,
+
      endHunkIdx: hunkIdx,
+
      startLineIdx: lineIdx,
+
      endLineIdx: lineIdx,
+
    };
+
  }
+

+
  function endShiftSelection(
+
    e: MouseEvent,
+
    file: FileDiff,
+
    side: Side,
+
    line: Modification,
+
    hunkIdx: number,
+
    lineIdx: number,
+
  ) {
+
    e.preventDefault();
+
    e.stopPropagation();
+

+
    if (
+
      selection &&
+
      // Prevent inter-hunk selection.
+
      hunkIdx === selection.startHunkIdx &&
+
      // Prevent reverse selection (i.e. end marker before start marker).
+
      lineIdx >= selection.startLineIdx
+
    ) {
+
      // Prevent setting the end marker before the start marker
+
      // on the same line.
+
      if (
+
        selection.startLineIdx === lineIdx &&
+
        selection.start.side === "right" &&
+
        side === "left"
+
      ) {
+
        return;
+
      }
+
      selection = {
+
        file: filePath(file, side),
+
        start: selection.start,
+
        end: determineSelectedAnchor(side, line),
+
        startHunkIdx: selection.startHunkIdx,
+
        endHunkIdx: hunkIdx,
+
        startLineIdx: selection.startLineIdx,
+
        endLineIdx: lineIdx,
+
      };
+
    }
+
  }
+

+
  function addToDragSelection(
+
    file: FileDiff,
+
    side: Side,
+
    line: Modification,
+
    hunkIdx: number,
+
    lineIdx: number,
+
  ) {
+
    if (
+
      dragging &&
+
      selection &&
+
      // Prevent inter-hunk selection.
+
      hunkIdx === selection.startHunkIdx &&
+
      // Prevent reverse selection (i.e. end marker before start marker).
+
      lineIdx >= selection.startLineIdx
+
    ) {
+
      // Prevent setting the end marker before the start marker
+
      // on the same line.
+
      if (
+
        selection.startLineIdx === lineIdx &&
+
        selection.start.side === "right" &&
+
        side === "left"
+
      ) {
+
        return;
+
      }
+

+
      selection = {
+
        file: filePath(file, side),
+
        start: selection.start,
+
        end: determineSelectedAnchor(side, line),
+
        startHunkIdx: selection.startHunkIdx,
+
        endHunkIdx: hunkIdx,
+
        startLineIdx: selection.startLineIdx,
+
        endLineIdx: lineIdx,
+
      };
+
    }
+
  }
+

+
  function isSelected(file: string, hunkIdx: number, lineIdx: number) {
+
    if (!selection || selection.file !== file) {
+
      return false;
+
    }
+
    return (
+
      hunkIdx >= selection.startHunkIdx &&
+
      hunkIdx <= selection.endHunkIdx &&
+
      (hunkIdx === selection.startHunkIdx
+
        ? lineIdx >= selection.startLineIdx
+
        : true) &&
+
      (hunkIdx === selection.endHunkIdx
+
        ? lineIdx <= selection.endLineIdx
+
        : true)
+
    );
+
  }
+
</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);
+
  }
+
  .start-marker::before {
+
    content: "";
+
    position: absolute;
+
    top: 0;
+
    left: 0;
+
    width: 4px;
+
    height: 4px;
+
    background-color: yellow;
+
    z-index: 1;
+
  }
+
  .end-marker::after {
+
    content: "";
+
    position: absolute;
+
    top: 0;
+
    right: 0;
+
    height: 4px;
+
    width: 4px;
+
    background-color: cyan;
+
    z-index: 1;
+
  }
+
  /* Workaround for faking hover color change on end marker while dragging. */
+
  .start-marker,
+
  .end-marker {
+
    color: var(--color-foreground-contrast) !important;
+
  }
+
  /* Draw top border for first line on a multi-line selection. */
+
  .selected:has(.start-marker):not(.end-marker) {
+
    box-shadow: 0 -1px 0 0 var(--color-fill-secondary);
+
    z-index: 1;
+
  }
+
  /* Draw bottom border for last line on a multi-line selection. */
+
  .selected:has(.end-marker):not(.start-marker) {
+
    box-shadow: 0 1px 0 0 var(--color-fill-secondary);
+
    z-index: 1;
+
  }
+
  /* Draw left selection border for every selected line. */
+
  .selected > .left {
+
    box-shadow: -1px 0 0 0 var(--color-fill-secondary);
+
    z-index: 1;
+
  }
+
  /* Draw right selection border for every selected line. */
+
  .selected > .code {
+
    box-shadow: 1px 0 0 0 var(--color-fill-secondary);
+
    z-index: 1;
+
  }
+
  /* Draw all selection borders if a single line is selected regardless of
+
     the start and end markers being on only the left cell or left and right. */
+
  .selected:has(.start-marker.end-marker),
+
  .selected:has(.start-marker + .end-marker),
+
  .selected:has(.end-marker + .start-marker) {
+
    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-form {
+
    background-color: var(--color-fill-float-hover);
+
    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 8px;
+
    width: fit-content;
+
    margin-bottom: 1rem;
+
  }
+
</style>
+

+
<svelte:window onpointerup={endDragSelection} onpointerdown={deselect} />
+

+
<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}
+
          <div
+
            class="line"
+
            class:addition={line.type === "addition"}
+
            class:deletion={line.type === "deletion"}
+
            class:context={line.type === "context"}
+
            class:selected={isSelected(
+
              filePath(file, "left"),
+
              hunkIdx,
+
              lineIdx,
+
            )}>
+
            <div
+
              class="left"
+
              class:start-marker={selection?.start.side === "left" &&
+
                selection.start.lineNumber === lineNumber(line, "left")}
+
              class:end-marker={selection?.end.side === "left" &&
+
                selection.end.lineNumber === lineNumber(line, "left")}
+
              onpointerdown={e => {
+
                if (e.shiftKey) {
+
                  endShiftSelection(e, file, "left", line, hunkIdx, lineIdx);
+
                } else {
+
                  startDragSelection(e, file, "left", line, hunkIdx, lineIdx);
+
                }
+
              }}
+
              onpointerup={endDragSelection}
+
              onpointerenter={() => {
+
                addToDragSelection(file, "left", line, hunkIdx, lineIdx);
+
              }}>
+
              {lineNumber(line, "left")}
+
            </div>
+

+
            <div
+
              class="right"
+
              class:start-marker={selection?.start.side === "right" &&
+
                selection.start.lineNumber === lineNumber(line, "right")}
+
              class:end-marker={selection?.end.side === "right" &&
+
                selection.end.lineNumber === lineNumber(line, "right")}
+
              onpointerdown={e => {
+
                if (e.shiftKey) {
+
                  endShiftSelection(e, file, "right", line, hunkIdx, lineIdx);
+
                } else {
+
                  startDragSelection(e, file, "right", line, hunkIdx, lineIdx);
+
                }
+
              }}
+
              onpointerup={endDragSelection}
+
              onpointerenter={() => {
+
                addToDragSelection(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>
+
          {#if !dragging && selection?.endHunkIdx === hunkIdx && selection.endLineIdx === lineIdx}
+
            <div
+
              class="comment-form"
+
              onpointerdown={e => {
+
                e.stopPropagation();
+
              }}>
+
              <div class="comment-header">
+
                {selection.file.split("/").length > 1
+
                  ? "…/"
+
                  : ""}{selection.file.split("/").slice(-1)}:{selection.start
+
                  .side === "left"
+
                  ? "L"
+
                  : "R"}{selection.start.lineNumber} ->
+
                {selection.end.side === "left" ? "L" : "R"}{selection.end
+
                  .lineNumber}
+
              </div>
+
              <CommentToggleInput
+
                disallowEmptyBody
+
                {rid}
+
                onclose={() => {
+
                  selection = undefined;
+
                }}
+
                focus
+
                placeholder="Leave a comment"
+
                submit={async (body, embeds) => {
+
                  if (selection === undefined || createComment === undefined) {
+
                    return;
+
                  }
+
                  await createComment(body, embeds, undefined, {
+
                    commit: head,
+
                    path: selection.file,
+
                    old: {
+
                      type: "lines",
+
                      range: { start: 0, end: 0 },
+
                    },
+
                    new: {
+
                      type: "lines",
+
                      range: { start: 0, end: 0 },
+
                    },
+
                  });
+
                }} />
+
            </div>
+
          {/if}
+
        {/each}
+
      </div>
+
    {/each}
+
  {/if}
+
</div>
added src/components/DiffViewSwitch.svelte
@@ -0,0 +1,66 @@
+
<script lang="ts" module>
+
  type DiffView = "unified" | "split";
+

+
  export const diffView = writable<DiffView>(loadDiffView());
+

+
  function loadDiffView(): DiffView {
+
    const storedDiffView = localStorage
+
      ? localStorage.getItem("diffView")
+
      : null;
+

+
    if (storedDiffView === null) {
+
      return "unified";
+
    } else {
+
      return storedDiffView as DiffView;
+
    }
+
  }
+

+
  export function storeDiffView(newDiffView: DiffView): void {
+
    diffView.set(newDiffView);
+
    if (localStorage) {
+
      localStorage.setItem("diffView", newDiffView);
+
    } else {
+
      console.warn(
+
        "localStorage isn't available, not able to persist the selected diff view without it.",
+
      );
+
    }
+
  }
+
</script>
+

+
<script lang="ts">
+
  import { writable } from "svelte/store";
+

+
  import Icon from "./Icon.svelte";
+
  import Button from "./Button.svelte";
+
</script>
+

+
<style>
+
  .container {
+
    display: flex;
+
    align-items: center;
+
  }
+
</style>
+

+
<div class="container">
+
  <Button
+
    flatRight
+
    active={$diffView === "unified"}
+
    variant="ghost"
+
    onclick={() => {
+
      storeDiffView("unified");
+
    }}>
+
    <Icon name="unified" />
+
    Unified
+
  </Button>
+

+
  <Button
+
    flatLeft
+
    variant="ghost"
+
    active={$diffView === "split"}
+
    onclick={() => {
+
      storeDiffView("split");
+
    }}>
+
    <Icon name="split" />
+
    Split
+
  </Button>
+
</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,11 @@
    </NakedButton>
    {@render leftHeader()}
  </div>
+
  {#if rightHeader}
+
    <div style:margin-left="auto" style:margin-right="1rem">
+
      {@render rightHeader()}
+
    </div>
+
  {/if}
</div>

{#if expanded}
added src/components/FileDiff.svelte
@@ -0,0 +1,117 @@
+
<script lang="ts">
+
  import type { CodeLocation } from "@bindings/cob/thread/CodeLocation";
+
  import type { Embed } from "@bindings/cob/thread/Embed";
+
  import type { FileDiff } from "@bindings/diff/FileDiff";
+
  import type { Thread } from "@bindings/cob/thread/Thread";
+

+
  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 {
+
    file: FileDiff;
+
    expanded: boolean;
+
    rid: string;
+
    head: string;
+
    createComment?: (
+
      body: string,
+
      embeds: Embed[],
+
      replyTo?: string,
+
      location?: CodeLocation,
+
    ) => Promise<void>;
+
    codeCommentThreads?: Thread<CodeLocation>[];
+
  }
+

+
  const {
+
    file,
+
    expanded,
+
    rid,
+
    head,
+
    createComment,
+
    codeCommentThreads,
+
  }: Props = $props();
+
</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}
+
  {/snippet}
+

+
  {#snippet children()}
+
    {#if file.diff.type === "plain"}
+
      {#if file.diff.hunks.length > 0}
+
        <Diff {file} {rid} {createComment} {head} {codeCommentThreads} />
+
      {: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}
+
  {/snippet}
+
</File>
modified src/components/Icon.svelte
@@ -12,6 +12,7 @@
      | "arrow-right"
      | "arrow-right-hollow"
      | "attachment"
+
      | "binary"
      | "branch"
      | "broom"
      | "broom-double"
@@ -59,7 +60,9 @@
      | "seedling"
      | "seedling-filled"
      | "settings"
+
      | "split"
      | "sun"
+
      | "unified"
      | "user"
      | "warning";
  }
@@ -162,6 +165,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
@@ -1007,6 +1030,19 @@
    <path d="M4 4L5 4L5 7H4V4Z" />
    <path d="M11 9L12 9V12H11V9Z" />
    <path d="M7 4L8 4V7L7 7V4Z" />
+
  {:else if name === "split"}
+
    <path d="M2 3H3L3 13H2L2 3Z" />
+
    <path d="M9 3H10V8H9V3Z" />
+
    <path d="M6 8H7L7 13H6L6 8Z" />
+
    <path d="M3 2L6 2V3L3 3L3 2Z" />
+
    <path d="M10 2L13 2V3L10 3V2Z" />
+
    <path d="M3 13L6 13L6 14H3L3 13Z" />
+
    <path d="M10 13H13V14H10V13Z" />
+
    <path d="M13 3L14 3L14 13H13L13 3Z" />
+
    <path d="M4 6L8 6V7L4 7V6Z" />
+
    <path d="M8 9H12L12 10L8 10V9Z" />
+
    <path d="M9 11L10 11V13L9 13L9 11Z" />
+
    <path d="M6 3L7 3V5H6V3Z" />
  {:else if name === "sun"}
    <path d="M8 2H9V3H8V2Z" />
    <path d="M14 8V9H13V8H14Z" />
@@ -1032,6 +1068,15 @@
    <path d="M6 9L7 9L7 10H6V9Z" />
    <path d="M9 9L10 9L10 10H9L9 9Z" />
    <path d="M9 6H10V7H9V6Z" />
+
  {:else if name === "unified"}
+
    <path d="M3 2.5L11 2.5V3.5L3 3.5V2.5Z" />
+
    <path d="M2 6.5L11 6.5V7.5L2 7.5V6.5Z" />
+
    <path d="M3 10.5H11V11.5L3 11.5V10.5Z" />
+
    <path d="M5 4.5L13 4.5V5.5L5 5.5V4.5Z" />
+
    <path d="M5 8.5L14 8.5V9.5L5 9.5V8.5Z" />
+
    <path d="M5 12.5L13 12.5V13.5L5 13.5V12.5Z" />
+
    <path d="M2 3.5L3 3.5L3 10.5H2L2 3.5Z" />
+
    <path d="M13 5.5L14 5.5L14 12.5H13L13 5.5Z" />
  {:else if name === "user"}
    <path d="M5 3H6V4H5V3Z" />
    <path d="M5 6L5 8H4V6H5Z" />
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 === undefined &&
+
              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 !== undefined &&
+
              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,11 @@
    {editComment}
    {reactOnComment} />

-
  <Changes rid={repo.rid} showCommits={false} {patchId} {revision} />
+
  <Changes
+
    rid={repo.rid}
+
    showCommits={false}
+
    {codeCommentThreads}
+
    {patchId}
+
    {revision}
+
    {createComment} />
</div>
modified src/components/Settings.svelte
@@ -3,6 +3,7 @@

  import AnnounceSwitch from "./AnnounceSwitch.svelte";
  import Border from "./Border.svelte";
+
  import DiffViewSwitch from "./DiffViewSwitch.svelte";
  import Icon from "./Icon.svelte";
  import NakedButton from "./NakedButton.svelte";
  import Popover from "./Popover.svelte";
@@ -42,6 +43,12 @@
          class="global-flex"
          style:justify-content="space-between"
          style:width="100%">
+
          Diff view <DiffViewSwitch />
+
        </div>
+
        <div
+
          class="global-flex"
+
          style:justify-content="space-between"
+
          style:width="100%">
          Theme <ThemeSwitch />
        </div>
        <div