Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Add multi-line code comments
Archived rudolfs opened 1 year ago
17 files changed +1056 -600 0efb4e09 436de162
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;
added src/components/Changes.svelte
@@ -0,0 +1,172 @@
+
<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";
+

+
  import Changeset from "@app/components/Changeset.svelte";
+
  import CobCommitTeaser from "./CobCommitTeaser.svelte";
+
  import CommitsContainer from "@app/components/CommitsContainer.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Id from "./Id.svelte";
+
  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;
+
    showCommits?: boolean;
+
  }
+

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

+
  let hideChanges = $state(false);
+
  let filesExpanded = $state(true);
+

+
  $effect(() => {
+
    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
+
    patchId;
+

+
    hideChanges = false;
+
  });
+

+
  async function loadHighlightedDiff(rid: string, base: string, head: string) {
+
    return invoke<Diff>("get_diff", {
+
      rid,
+
      options: {
+
        base,
+
        head,
+
        unified: 3,
+
        highlight: true,
+
      },
+
    });
+
  }
+

+
  async function loadCommits(rid: string, base: string, head: string) {
+
    return invoke<Commit[]>("list_commits", {
+
      rid,
+
      base,
+
      head,
+
    });
+
  }
+
</script>
+

+
<style>
+
  .commits {
+
    position: relative;
+
    display: flex;
+
    flex-direction: column;
+
    font-size: 0.875rem;
+
    margin-left: 0.5rem;
+
    gap: 0.5rem;
+
    padding: 1rem 0.5rem 0.5rem 1rem;
+
    border-left: 1px solid var(--color-fill-separator);
+
  }
+
  .commit:last-of-type::after {
+
    content: "";
+
    position: absolute;
+
    left: -18.5px;
+
    top: 14px;
+
    bottom: -0.5rem;
+
    border-left: 4px solid var(--color-background-default);
+
  }
+
  .commit-dot {
+
    width: 4px;
+
    height: 4px;
+
    position: absolute;
+
    top: 0.625rem;
+
    left: -18.5px;
+
    background-color: var(--color-fill-separator);
+
  }
+
  .hide {
+
    display: none;
+
  }
+
</style>
+

+
<div
+
  class="txt-semibold global-flex"
+
  style:margin-bottom={hideChanges ? undefined : "1rem"}>
+
  <NakedButton variant="ghost" onclick={() => (hideChanges = !hideChanges)}>
+
    <Icon name={hideChanges ? "chevron-right" : "chevron-down"} />
+
    <div class="txt-semibold global-flex txt-regular">Changes</div>
+
  </NakedButton>
+
  {#if !hideChanges}
+
    <div style:margin-left="auto">
+
      <NakedButton
+
        variant="ghost"
+
        onclick={() => (filesExpanded = !filesExpanded)}>
+
        {#if filesExpanded === true}
+
          <Icon name="collapse" />
+
          Collapse all
+
        {:else}
+
          <Icon name="expand" />
+
          Expand all
+
        {/if}
+
      </NakedButton>
+
    </div>
+
  {/if}
+
</div>
+

+
<div class:hide={hideChanges}>
+
  {#if showCommits}
+
    {#await loadCommits(rid, revision.base, revision.head) then commits}
+
      <div style:margin-bottom="1rem">
+
        <CommitsContainer expanded={filesExpanded}>
+
          {#snippet leftHeader()}
+
            <div class="txt-semibold">Commits</div>
+
          {/snippet}
+
          {#snippet children()}
+
            <div style:padding="0 1rem">
+
              <div
+
                class="global-flex txt-small"
+
                style:color="var(--color-foreground-dim)">
+
                <Icon name="branch" /><Id id={revision.base} variant="commit" />
+
                <div class="global-counter">base</div>
+
              </div>
+
              <div class="commits">
+
                {#each commits.reverse() as commit}
+
                  <div class="commit" style:position="relative">
+
                    <div class="commit-dot"></div>
+
                    <CobCommitTeaser {commit} />
+
                  </div>
+
                {/each}
+
              </div>
+
            </div>
+
          {/snippet}
+
        </CommitsContainer>
+
      </div>
+
    {/await}
+
  {/if}
+

+
  {#await loadHighlightedDiff(rid, revision.base, revision.head)}
+
    <span class="txt-small">Loading…</span>
+
  {:then diff}
+
    <Changeset
+
      {codeCommentThreads}
+
      {diff}
+
      {rid}
+
      expanded={filesExpanded}
+
      {createComment}
+
      head={revision.head} />
+
  {/await}
+
</div>
modified src/components/Changeset.svelte
@@ -1,15 +1,46 @@
<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 { FileDiff } from "@bindings/diff/FileDiff";
+
  import type { Thread } from "@bindings/cob/thread/Thread";

-
  import FileDiff from "./Changeset/FileDiff.svelte";
+
  import FileDiffComponent 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();
+

+
  function filterCommentThreadsByPath(file: FileDiff) {
+
    if (
+
      file.status === "added" ||
+
      file.status === "deleted" ||
+
      file.status === "modified"
+
    ) {
+
      return codeCommentThreads?.filter(t => {
+
        return t.root.location?.path === file.path;
+
      });
+
    }
+
  }
</script>

<style>
@@ -26,12 +57,13 @@
<div class="diff-list">
  {#each diff.files as file}
    <div class="diff">
-
      <FileDiff
+
      <FileDiffComponent
+
        {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={filterCommentThreadsByPath(file)} />
    </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,417 @@
+
<script lang="ts">
+
  type Side = "left" | "right";
+
  type SelectionAnchor = { side: Side; lineNumber: number };
+

+
  interface Selection {
+
    file: string;
+
    start: SelectionAnchor;
+
    end: SelectionAnchor;
+
    lineIdx: number;
+
    hunkIdx: number;
+
    codeLocation: CodeLocation;
+
  }
+

+
  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";
+
  import Icon from "@app/components/Icon.svelte";
+
  import ThreadComponent from "@app/components/Thread.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();
+

+
  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 findLineComment(line: Modification) {
+
    return codeCommentThreads?.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 } // FIXME get rid of type assertion
+
        : { side: "right", lineNumber: lineNumber(line, "right") as number }; //FIXME get rid of type assertion
+
    }
+
  }
+

+
  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
+
    );
+
  }
+
</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);
+
  }
+
  /* 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-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;
+
  }
+
  .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>
+

+
<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 comment = findLineComment(line)}
+
          <div
+
            class="line"
+
            class:addition={line.type === "addition"}
+
            class:deletion={line.type === "deletion"}
+
            class:context={line.type === "context"}
+
            class:selected={!comment &&
+
              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 (createComment && !comment) {
+
                  selectLine(e, 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 (createComment && !comment) {
+
                  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 comment}
+
                <Icon name="comment" />
+
              {/if}
+
            </div>
+
          </div>
+

+
          {#if comment}
+
            <div class="thread">
+
              <ThreadComponent
+
                inline
+
                {rid}
+
                thread={comment}
+
                createReply={async (body, embeds) => {
+
                  if (!createComment) {
+
                    return;
+
                  }
+
                  await createComment(body, embeds, comment.root.id);
+
                }}
+
                canEditComment={() => {
+
                  return undefined;
+
                }} />
+
            </div>
+
          {/if}
+

+
          {#if selection && selection.hunkIdx === hunkIdx && selection.lineIdx === lineIdx && selection.codeLocation && createComment}
+
            <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}
+
              </div>
+
              <CommentToggleInput
+
                disallowEmptyBody
+
                {rid}
+
                onclose={() => {
+
                  selection = undefined;
+
                }}
+
                focus
+
                placeholder="Leave a comment"
+
                submit={async (body, embeds) => {
+
                  if (selection?.codeLocation) {
+
                    await createComment(
+
                      body,
+
                      embeds,
+
                      undefined,
+
                      selection.codeLocation,
+
                    );
+
                  }
+
                }} />
+
            </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,120 @@
+
<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}
+
    {#if codeCommentThreads && codeCommentThreads.length > 0}
+
      <Icon name="comment" />
+
    {/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"
@@ -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";
@@ -18,24 +19,25 @@
  import { nodeRunning } from "@app/lib/events";

  import Border from "@app/components/Border.svelte";
+
  import Changes from "@app/components/Changes.svelte";
  import CommentComponent from "@app/components/Comment.svelte";
+
  import Discussion from "@app/components/Discussion.svelte";
  import Icon from "@app/components/Icon.svelte";
  import Id from "@app/components/Id.svelte";
  import LabelInput from "@app/components/LabelInput.svelte";
  import NakedButton from "@app/components/NakedButton.svelte";
  import NodeId from "@app/components/NodeId.svelte";
+
  import VerdictBadge from "@app/components/VerdictBadge.svelte";
  import VerdictButton from "@app/components/VerdictButton.svelte";
-
  import VerdictBadge from "./VerdictBadge.svelte";
-
  import Discussion from "./Discussion.svelte";

  interface Props {
    config: Config;
    onNavigateBack: () => void;
    patchId: string;
    reload: (reviewId?: string) => Promise<void>;
+
    repo: RepoInfo;
    review: Review;
    revision: Revision;
-
    repo: RepoInfo;
  }

  const {
@@ -43,9 +45,9 @@
    onNavigateBack,
    patchId,
    reload,
+
    repo,
    review,
    revision,
-
    repo,
  }: Props = $props();

  const contributors = $derived(
@@ -65,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 => {
@@ -80,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);

@@ -121,8 +147,8 @@
    body: string,
    embeds: Embed[],
    replyTo?: string,
+
    location?: CodeLocation,
  ) {
-
    console.log({ replyTo });
    try {
      await invoke("edit_patch", {
        rid: repo.rid,
@@ -133,6 +159,7 @@
          body,
          embeds,
          replyTo,
+
          location,
        },
        opts: { announce: $nodeRunning && $announce },
      });
@@ -348,4 +375,12 @@
    {createComment}
    {editComment}
    {reactOnComment} />
+

+
  <Changes
+
    rid={repo.rid}
+
    showCommits={false}
+
    {codeCommentThreads}
+
    {patchId}
+
    {revision}
+
    {createComment} />
</div>
added src/components/Reviews.svelte
@@ -0,0 +1,130 @@
+
<script lang="ts">
+
  import type { Config } from "@bindings/config/Config";
+
  import type { PatchStatus } from "@app/views/repo/router";
+
  import type { Revision } from "@bindings/cob/patch/Revision";
+
  import type { Verdict } from "@bindings/cob/patch/Verdict";
+

+
  import { announce } from "@app/components/AnnounceSwitch.svelte";
+
  import { invoke } from "@app/lib/invoke";
+
  import { nodeRunning } from "@app/lib/events";
+

+
  import Button from "@app/components/Button.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import NakedButton from "@app/components/NakedButton.svelte";
+
  import ReviewTeaser from "@app/components/ReviewTeaser.svelte";
+

+
  import { didFromPublicKey } from "@app/lib/utils";
+

+
  interface Props {
+
    rid: string;
+
    patchId: string;
+
    revision: Revision;
+
    config: Config;
+
    status: PatchStatus | undefined;
+
    reload: () => Promise<void>;
+
  }
+

+
  /* eslint-disable prefer-const */
+
  let { rid, patchId, revision, config, status, reload }: Props = $props();
+
  /* eslint-enable prefer-const */
+

+
  let hideReviews = $state(
+
    revision.reviews === undefined || revision.reviews.length === 0,
+
  );
+

+
  const hasOwnReview = $derived(
+
    Boolean(
+
      revision.reviews &&
+
        revision.reviews.some(
+
          value => value.author.did === didFromPublicKey(config.publicKey),
+
        ),
+
    ),
+
  );
+

+
  $effect(() => {
+
    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
+
    patchId;
+

+
    hideReviews =
+
      revision.reviews === undefined || revision.reviews.length === 0;
+
  });
+

+
  async function createReview(verdict?: Verdict) {
+
    try {
+
      await invoke("edit_patch", {
+
        rid: rid,
+
        cobId: patchId,
+
        action: {
+
          type: "review",
+
          revision: revision.id,
+
          verdict,
+
          // We need to pass an empty string to create a review without a verdict.
+
          summary: "",
+
          labels: [],
+
        },
+
        opts: { announce: $nodeRunning && $announce },
+
      });
+
    } catch (error) {
+
      console.error("Creating a review failed: ", error);
+
    } finally {
+
      await reload();
+
    }
+
  }
+
</script>
+

+
<style>
+
  .hide {
+
    display: none;
+
  }
+
</style>
+

+
<div style:margin={hideReviews ? "1.5rem 0" : "1.5rem 0 2.5rem 0"}>
+
  <div class="global-flex">
+
    <NakedButton
+
      disabled={revision.reviews === undefined || revision.reviews.length === 0}
+
      variant="ghost"
+
      onclick={() => (hideReviews = !hideReviews)}>
+
      <Icon name={hideReviews ? "chevron-right" : "chevron-down"} />
+
      <div class="txt-semibold global-flex txt-regular">
+
        Reviews <span style:font-weight="var(--font-weight-regular)">
+
          {revision.reviews?.length ?? 0}
+
        </span>
+
      </div>
+
    </NakedButton>
+

+
    <div class="global-flex" style:margin-left="auto">
+
      <NakedButton
+
        variant="secondary"
+
        disabled={hasOwnReview}
+
        title={hasOwnReview ? "You already published a review" : undefined}
+
        onclick={() => createReview()}>
+
        <Icon name="plus" />
+
        <span class="txt-small">Write Review</span>
+
      </NakedButton>
+
      <Button
+
        variant="danger"
+
        disabled={hasOwnReview}
+
        title={hasOwnReview ? "You already published a review" : undefined}
+
        onclick={() => createReview("reject")}>
+
        <Icon name="comment-cross" />
+
        <span class="txt-small">Reject</span>
+
      </Button>
+
      <Button
+
        variant="success"
+
        disabled={hasOwnReview}
+
        title={hasOwnReview ? "You already published a review" : undefined}
+
        onclick={() => createReview("accept")}>
+
        <Icon name="comment-checkmark" />
+
        <span class="txt-small">Accept</span>
+
      </Button>
+
    </div>
+
  </div>
+

+
  {#if revision.reviews && revision.reviews.length}
+
    <div class:hide={hideReviews} style:margin-top="1rem">
+
      {#each revision.reviews as review}
+
        <ReviewTeaser {rid} {review} {patchId} {status} />
+
      {/each}
+
    </div>
+
  {/if}
+
</div>
modified src/components/Revision.svelte
@@ -1,13 +1,10 @@
<script lang="ts">
  import type { Author } from "@bindings/cob/Author";
-
  import type { Commit } from "@bindings/repo/Commit";
  import type { Config } from "@bindings/config/Config";
-
  import type { Diff } from "@bindings/diff/Diff";
  import type { Embed } from "@bindings/cob/thread/Embed";
  import type { PatchStatus } from "@app/views/repo/router";
  import type { Revision } from "@bindings/cob/patch/Revision";
  import type { Thread } from "@bindings/cob/thread/Thread";
-
  import type { Verdict } from "@bindings/cob/patch/Verdict";

  import partial from "lodash/partial";

@@ -15,18 +12,12 @@
  import { announce } from "@app/components/AnnounceSwitch.svelte";
  import { invoke } from "@app/lib/invoke";
  import { nodeRunning } from "@app/lib/events";
-
  import { didFromPublicKey, publicKeyFromDid } from "@app/lib/utils";
+
  import { publicKeyFromDid } from "@app/lib/utils";

-
  import Button from "@app/components/Button.svelte";
-
  import Changeset from "@app/components/Changeset.svelte";
-
  import CobCommitTeaser from "./CobCommitTeaser.svelte";
+
  import Changes from "@app/components/Changes.svelte";
  import CommentComponent from "@app/components/Comment.svelte";
-
  import CommitsContainer from "@app/components/CommitsContainer.svelte";
-
  import Discussion from "./Discussion.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Id from "./Id.svelte";
-
  import NakedButton from "./NakedButton.svelte";
-
  import ReviewTeaser from "@app/components/ReviewTeaser.svelte";
+
  import Discussion from "@app/components/Discussion.svelte";
+
  import Reviews from "@app/components/Reviews.svelte";

  interface Props {
    rid: string;
@@ -43,30 +34,6 @@
    $props();
  /* eslint-enable prefer-const */

-
  const hasOwnReview = $derived(
-
    Boolean(
-
      revision.reviews &&
-
        revision.reviews.some(
-
          value => value.author.did === didFromPublicKey(config.publicKey),
-
        ),
-
    ),
-
  );
-

-
  let hideChanges = $state(false);
-
  let hideReviews = $state(
-
    revision.reviews === undefined || revision.reviews.length === 0,
-
  );
-
  let filesExpanded = $state(true);
-

-
  $effect(() => {
-
    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
-
    patchId;
-

-
    hideReviews =
-
      revision.reviews === undefined || revision.reviews.length === 0;
-
    hideChanges = false;
-
  });
-

  const commentThreads = $derived(
    ((revision.discussion &&
      revision.discussion
@@ -138,28 +105,6 @@
    }
  }

-
  async function createReview(verdict?: Verdict) {
-
    try {
-
      await invoke("edit_patch", {
-
        rid: rid,
-
        cobId: patchId,
-
        action: {
-
          type: "review",
-
          revision: revision.id,
-
          verdict,
-
          // We need to pass an empty string to create a review without a verdict.
-
          summary: "",
-
          labels: [],
-
        },
-
        opts: { announce: $nodeRunning && $announce },
-
      });
-
    } catch (error) {
-
      console.error("Creating a review failed: ", error);
-
    } finally {
-
      await reload();
-
    }
-
  }
-

  async function createComment(
    body: string,
    embeds: Embed[],
@@ -226,26 +171,6 @@
      await reload();
    }
  }
-

-
  async function loadHighlightedDiff(rid: string, base: string, head: string) {
-
    return invoke<Diff>("get_diff", {
-
      rid,
-
      options: {
-
        base,
-
        head,
-
        unified: 5,
-
        highlight: true,
-
      },
-
    });
-
  }
-

-
  async function loadCommits(rid: string, base: string, head: string) {
-
    return invoke<Commit[]>("list_commits", {
-
      rid,
-
      base,
-
      head,
-
    });
-
  }
</script>

<style>
@@ -266,35 +191,6 @@
    height: 100%;
    top: 0;
  }
-
  .hide {
-
    display: none;
-
  }
-
  .commits {
-
    position: relative;
-
    display: flex;
-
    flex-direction: column;
-
    font-size: 0.875rem;
-
    margin-left: 0.5rem;
-
    gap: 0.5rem;
-
    padding: 1rem 0.5rem 0.5rem 1rem;
-
    border-left: 1px solid var(--color-fill-separator);
-
  }
-
  .commit:last-of-type::after {
-
    content: "";
-
    position: absolute;
-
    left: -18.5px;
-
    top: 14px;
-
    bottom: -0.5rem;
-
    border-left: 4px solid var(--color-background-default);
-
  }
-
  .commit-dot {
-
    width: 4px;
-
    height: 4px;
-
    position: absolute;
-
    top: 0.625rem;
-
    left: -18.5px;
-
    background-color: var(--color-fill-separator);
-
  }
</style>

<div class="txt-small patch-body">
@@ -318,56 +214,7 @@
  </CommentComponent>
</div>

-
<div style:margin={hideReviews ? "1.5rem 0" : "1.5rem 0 2.5rem 0"}>
-
  <div class="global-flex">
-
    <NakedButton
-
      disabled={revision.reviews === undefined || revision.reviews.length === 0}
-
      variant="ghost"
-
      onclick={() => (hideReviews = !hideReviews)}>
-
      <Icon name={hideReviews ? "chevron-right" : "chevron-down"} />
-
      <div class="txt-semibold global-flex txt-regular">
-
        Reviews <span style:font-weight="var(--font-weight-regular)">
-
          {revision.reviews?.length ?? 0}
-
        </span>
-
      </div>
-
    </NakedButton>
-

-
    <div class="global-flex" style:margin-left="auto">
-
      <NakedButton
-
        variant="secondary"
-
        disabled={hasOwnReview}
-
        title={hasOwnReview ? "You already published a review" : undefined}
-
        onclick={() => createReview()}>
-
        <Icon name="plus" />
-
        <span class="txt-small">Write Review</span>
-
      </NakedButton>
-
      <Button
-
        variant="danger"
-
        disabled={hasOwnReview}
-
        title={hasOwnReview ? "You already published a review" : undefined}
-
        onclick={() => createReview("reject")}>
-
        <Icon name="comment-cross" />
-
        <span class="txt-small">Reject</span>
-
      </Button>
-
      <Button
-
        variant="success"
-
        disabled={hasOwnReview}
-
        title={hasOwnReview ? "You already published a review" : undefined}
-
        onclick={() => createReview("accept")}>
-
        <Icon name="comment-checkmark" />
-
        <span class="txt-small">Accept</span>
-
      </Button>
-
    </div>
-
  </div>
-

-
  {#if revision.reviews && revision.reviews.length}
-
    <div class:hide={hideReviews} style:margin-top="1rem">
-
      {#each revision.reviews as review}
-
        <ReviewTeaser {rid} {review} {patchId} {status} />
-
      {/each}
-
    </div>
-
  {/if}
-
</div>
+
<Reviews {config} {patchId} {reload} {revision} {rid} {status} />

<Discussion
  cobId={patchId}
@@ -379,62 +226,4 @@
  {repoDelegates}
  {rid} />

-
<div
-
  class="txt-semibold global-flex"
-
  style:margin-bottom={hideChanges ? undefined : "1rem"}>
-
  <NakedButton variant="ghost" onclick={() => (hideChanges = !hideChanges)}>
-
    <Icon name={hideChanges ? "chevron-right" : "chevron-down"} />
-
    <div class="txt-semibold global-flex txt-regular">Changes</div>
-
  </NakedButton>
-
  {#if !hideChanges}
-
    <div style:margin-left="auto">
-
      <NakedButton
-
        variant="ghost"
-
        onclick={() => (filesExpanded = !filesExpanded)}>
-
        {#if filesExpanded === true}
-
          <Icon name="collapse" />
-
          Collapse all
-
        {:else}
-
          <Icon name="expand" />
-
          Expand all
-
        {/if}
-
      </NakedButton>
-
    </div>
-
  {/if}
-
</div>
-

-
<div class:hide={hideChanges}>
-
  {#await loadCommits(rid, revision.base, revision.head) then commits}
-
    <div style:margin-bottom="1rem">
-
      <CommitsContainer expanded={filesExpanded}>
-
        {#snippet leftHeader()}
-
          <div class="txt-semibold">Commits</div>
-
        {/snippet}
-
        {#snippet children()}
-
          <div style:padding="0 1rem">
-
            <div
-
              class="global-flex txt-small"
-
              style:color="var(--color-foreground-dim)">
-
              <Icon name="branch" /><Id id={revision.base} variant="commit" />
-
              <div class="global-counter">base</div>
-
            </div>
-
            <div class="commits">
-
              {#each commits.reverse() as commit}
-
                <div class="commit" style:position="relative">
-
                  <div class="commit-dot"></div>
-
                  <CobCommitTeaser {commit} />
-
                </div>
-
              {/each}
-
            </div>
-
          </div>
-
        {/snippet}
-
      </CommitsContainer>
-
    </div>
-
  {/await}
-

-
  {#await loadHighlightedDiff(rid, revision.base, revision.head)}
-
    <span class="txt-small">Loading…</span>
-
  {:then diff}
-
    <Changeset {diff} repoId={rid} expanded={filesExpanded} />
-
  {/await}
-
</div>
+
<Changes {rid} {patchId} {revision} />
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() {
@@ -97,7 +97,7 @@
</style>

<div class="comments" {style}>
-
  <div class="top-level-comment">
+
  <div class:top-level-comment={!inline}>
    <CommentComponent
      disallowEmptyBody
      {rid}
@@ -112,12 +112,14 @@
        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>
+
    <Border variant="float" styleOverflow="hidden" flatTop={!inline}>
      <div style:width="100%">
        {#if replies.length > 0}
          {#each replies as reply}