Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Implement code comments for single line selections
Open rudolfs opened 1 year ago
23 files changed +1230 -717 7cf21972 2c8b00ca
modified crates/radicle-tauri/src/commands/cob/patch.rs
@@ -1,6 +1,5 @@
-
use radicle::git;
-
use radicle::identity;
use radicle::patch::{Action, TYPENAME};
+
use radicle::{cob, git, identity};

use radicle_types as types;
use radicle_types::cobs;
@@ -80,6 +79,17 @@ pub fn revision_by_patch_and_id(
}

#[tauri::command]
+
pub fn review_by_patch_and_revision_and_id(
+
    ctx: tauri::State<AppState>,
+
    rid: identity::RepoId,
+
    id: git::Oid,
+
    revision_id: git::Oid,
+
    review_id: cob::patch::ReviewId,
+
) -> Result<Option<models::patch::Review>, Error> {
+
    ctx.review_by_id(rid, id, revision_id, review_id)
+
}
+

+
#[tauri::command]
pub fn edit_patch(
    ctx: tauri::State<AppState>,
    rid: identity::RepoId,
modified crates/radicle-tauri/src/lib.rs
@@ -47,6 +47,7 @@ pub fn run() {
            cob::patch::list_patches,
            cob::patch::patch_by_id,
            cob::patch::edit_patch,
+
            cob::patch::review_by_patch_and_revision_and_id,
            cob::patch::revisions_by_patch,
            cob::patch::revision_by_patch_and_id,
            thread::create_issue_comment,
modified crates/radicle-types/src/traits/patch.rs
@@ -1,7 +1,7 @@
use radicle::node::Handle;
use radicle::patch::cache::Patches as _;
use radicle::storage::ReadStorage;
-
use radicle::{git, identity, Node};
+
use radicle::{cob, git, identity, Node};

use crate::cobs;
use crate::domain::patch::models;
@@ -63,6 +63,28 @@ pub trait Patches: Profile {

        Ok::<_, Error>(revision)
    }
+

+
    fn review_by_id(
+
        &self,
+
        rid: identity::RepoId,
+
        id: git::Oid,
+
        revision_id: git::Oid,
+
        review_id: cob::patch::ReviewId,
+
    ) -> Result<Option<models::patch::Review>, Error> {
+
        let profile = self.profile();
+
        let repo = profile.storage.repository(rid)?;
+
        let patches = profile.patches(&repo)?;
+
        let review = patches.get(&id.into())?.and_then(|patch| {
+
            let aliases = &profile.aliases();
+

+
            patch
+
                .reviews_of(revision_id.into())
+
                .find(|(id, _)| *id == &review_id)
+
                .map(|(_, review)| models::patch::Review::new(review.clone(), aliases))
+
        });
+

+
        Ok::<_, Error>(review)
+
    }
}

pub trait PatchesMut: Profile {
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,151 @@
+
<script lang="ts">
+
  import type { Commit } from "@bindings/repo/Commit";
+
  import type { Diff } from "@bindings/diff/Diff";
+
  import type { CodeComments } from "./Diff.svelte";
+
  import type { Revision } from "@bindings/cob/patch/Revision";
+

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

+
  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 {
+
    patchId: string;
+
    revision: Revision;
+
    rid: string;
+
    codeComments?: CodeComments;
+
  }
+

+
  const { patchId, revision, rid, codeComments }: Props = $props();
+

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

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

+
    hideChanges = false;
+
    filesExpanded = true;
+
  });
+

+
  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}>
+
  {#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
+
      expanded={filesExpanded}
+
      head={revision.head}
+
      {diff}
+
      {codeComments} />
+
  {/await}
+
</div>
modified src/components/Changeset.svelte
@@ -1,15 +1,17 @@
<script lang="ts">
  import type { Diff } from "@bindings/diff/Diff";
+
  import type { CodeComments } from "./Diff.svelte";

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

  interface Props {
+
    codeComments?: CodeComments;
    diff: Diff;
-
    repoId: string;
    expanded?: boolean;
+
    head: string;
  }

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

<style>
@@ -26,12 +28,7 @@
<div class="diff-list">
  {#each diff.files as file}
    <div class="diff">
-
      <FileDiff
-
        {expanded}
-
        filePath={"path" in file ? file.path : file.newPath}
-
        oldFilePath={"oldPath" in file ? file.oldPath : undefined}
-
        fileDiff={file.diff}
-
        headerBadgeCaption={file.status} />
+
      <FileDiffComponent {codeComments} {expanded} {file} {head} />
    </div>
  {/each}
</div>
deleted src/components/Changeset/FileDiff.svelte
@@ -1,323 +0,0 @@
-
<script lang="ts">
-
  import type { DiffContent } from "@bindings/diff/DiffContent";
-
  import type { FileDiff } from "@bindings/diff/FileDiff";
-
  import type { Modification } from "@bindings/diff/Modification";
-

-
  import escape from "lodash/escape";
-

-
  import File from "@app/components/File.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-

-
  interface Props {
-
    filePath: string;
-
    oldFilePath?: string | undefined;
-
    fileDiff: DiffContent;
-
    headerBadgeCaption: FileDiff["status"];
-
    expanded: boolean;
-
  }
-

-
  const {
-
    filePath,
-
    oldFilePath = undefined,
-
    fileDiff,
-
    headerBadgeCaption,
-
    expanded,
-
  }: Props = $props();
-

-
  function lineNumberR(line: Modification): string | number {
-
    switch (line.type) {
-
      case "addition": {
-
        return line.lineNo;
-
      }
-
      case "context": {
-
        return line.lineNoNew;
-
      }
-
      case "deletion": {
-
        return " ";
-
      }
-
    }
-
  }
-

-
  function lineNumberL(line: Modification): string | number {
-
    switch (line.type) {
-
      case "addition": {
-
        return " ";
-
      }
-
      case "context": {
-
        return line.lineNoOld;
-
      }
-
      case "deletion": {
-
        return line.lineNo;
-
      }
-
    }
-
  }
-

-
  function lineSign(line: Modification): string {
-
    switch (line.type) {
-
      case "addition": {
-
        return "+";
-
      }
-
      case "context": {
-
        return " ";
-
      }
-
      case "deletion": {
-
        return "-";
-
      }
-
    }
-
  }
-
</script>
-

-
<style>
-
  .container {
-
    font-size: var(--font-size-small);
-
    overflow-x: auto;
-
  }
-
  .actions {
-
    display: flex;
-
    flex-direction: row;
-
    align-items: center;
-
    gap: 1rem;
-
  }
-
  .browse {
-
    margin-left: auto;
-
  }
-
  .expand-button {
-
    cursor: pointer;
-
    user-select: none;
-
    margin-right: 0.5rem;
-
  }
-
  .diff {
-
    font-family: var(--font-family-monospace);
-
    table-layout: fixed;
-
    border-collapse: collapse;
-
    margin: 0.5rem 0;
-
    -webkit-touch-callout: initial;
-
    -webkit-user-select: text;
-
    user-select: text;
-
  }
-
  .diff-line {
-
    vertical-align: top;
-
  }
-
  .diff-line.type-addition > * {
-
    background-color: var(--color-fill-diff-green-light);
-
  }
-
  .diff-line.type-deletion > * {
-
    background-color: var(--color-fill-diff-red-light);
-
  }
-

-
  .diff-line.selected > * {
-
    background-color: var(--color-fill-float-hover);
-
  }
-
  .diff-line.selected.type-addition > * {
-
    background-color: var(--color-fill-diff-green);
-
  }
-
  .diff-line.selected.type-deletion > * {
-
    background-color: var(--color-fill-diff-red);
-
  }
-

-
  .type-addition > .diff-line-number,
-
  .type-addition > .diff-line-type {
-
    color: var(--color-foreground-success);
-
  }
-
  .type-deletion > .diff-line-number,
-
  .type-deletion > .diff-line-type {
-
    color: var(--color-foreground-red);
-
  }
-

-
  .diff-line.selected .selection-indicator-left {
-
    background-color: var(--color-fill-secondary);
-
  }
-
  .type-addition.diff-line.selected .selection-indicator-left {
-
    background-color: var(--color-fill-secondary);
-
  }
-
  .type-deletion.diff-line.selected .selection-indicator-left {
-
    background-color: var(--color-fill-secondary);
-
  }
-

-
  .diff-line.selected .selection-indicator-right {
-
    background-color: var(--color-fill-secondary);
-
  }
-
  .type-addition.diff-line.selected .selection-indicator-right {
-
    background-color: var(--color-fill-secondary);
-
  }
-
  .type-deletion.diff-line.selected .selection-indicator-right {
-
    background-color: var(--color-fill-secondary);
-
  }
-

-
  .selection-start {
-
    box-shadow: 0 -1px 0 0 var(--color-fill-secondary);
-
    z-index: 1;
-
  }
-
  .selection-end {
-
    box-shadow: 0 1px 0 0 var(--color-fill-secondary);
-
    z-index: 1;
-
  }
-

-
  .selection-start.selection-end {
-
    box-shadow: 0 0 0 1px var(--color-fill-secondary);
-
    z-index: 1;
-
  }
-

-
  .diff-line-number {
-
    font-family: var(--font-family-monospace);
-
    text-align: right;
-
    user-select: none;
-
    line-height: 1.5rem;
-
    min-width: 3rem;
-
    color: var(--color-foreground-disabled);
-
    -webkit-user-select: none;
-
    user-select: none;
-
  }
-
  .diff-line-number.left {
-
    position: relative;
-
    padding: 0 0.5rem 0 0.75rem;
-
  }
-
  .selection-indicator-left {
-
    position: absolute;
-
    left: 0;
-
    top: 0;
-
    bottom: 0;
-
    width: 1px;
-
  }
-
  .selection-indicator-right {
-
    display: none; /* FIXME: fix the selection indicator */
-
    position: absolute;
-
    right: 0;
-
    top: 0;
-
    bottom: 0;
-
    width: 1px;
-
  }
-
  .diff-line-number.right {
-
    padding: 0 0.75rem 0 0.5rem;
-
  }
-
  .diff-line-content {
-
    color: unset !important;
-
    white-space: pre-wrap;
-
    overflow-wrap: anywhere;
-
    width: 100%;
-
    padding-right: 0.5rem;
-
  }
-
  .diff-line-type {
-
    text-align: center;
-
    padding-left: 0.75rem;
-
    padding-right: 0.75rem;
-
    -webkit-user-select: none;
-
    user-select: none;
-
  }
-
  .diff-expand-header {
-
    padding-left: 0.5rem;
-
    color: var(--color-foreground-dim);
-
  }
-
  .added {
-
    color: var(--color-foreground-success);
-
    background-color: var(--color-fill-diff-green-light);
-
  }
-
  .deleted {
-
    color: var(--color-foreground-red);
-
    background-color: var(--color-fill-diff-red-light);
-
  }
-
  .moved,
-
  .copied {
-
    color: var(--color-foreground-dim);
-
    background: var(--color-fill-ghost);
-
  }
-
</style>
-

-
{#snippet styledPath(fullPath: string)}
-
  <!-- prettier-ignore -->
-
  <span class="txt-small" style:white-space="nowrap" style:-webkit-touch-callout="initial" style:-webkit-user-select="text" style:user-select="text"><span style:color="var(--color-fill-gray)" style:font-weight="var(--font-weight-regular)">{fullPath.match(/^.*\/|/)?.values().next().value}</span><span style:font-weight="var(--font-weight-semibold)">{fullPath.split("/").slice(-1)}</span></span>
-
{/snippet}
-

-
{#snippet emptyPlaceholder()}
-
  <div class="global-flex" style:margin="1rem 0" style:justify-content="center">
-
    <Icon name="none" />Empty file
-
  </div>
-
{/snippet}
-

-
<File {expanded}>
-
  {#snippet leftHeader()}
-
    {#if (headerBadgeCaption === "moved" || headerBadgeCaption === "copied") && oldFilePath}
-
      <span style="display: flex; align-items: center; flex-wrap: wrap;">
-
        {@render styledPath(filePath)}
-
        <span style:padding="0 0.5rem">→</span>
-
        {@render styledPath(filePath)}
-
      </span>
-
    {:else}
-
      {@render styledPath(filePath)}
-
    {/if}
-

-
    {#if headerBadgeCaption === "added"}
-
      <span class="global-counter added">added</span>
-
    {:else if headerBadgeCaption === "deleted"}
-
      <span class="global-counter deleted">deleted</span>
-
    {:else if headerBadgeCaption === "moved"}
-
      <span class="global-counter moved">moved</span>
-
    {:else if headerBadgeCaption === "copied"}
-
      <span class="global-counter copied">copied</span>
-
    {/if}
-
  {/snippet}
-

-
  {#snippet children()}
-
    <div class="container">
-
      {#if fileDiff.type === "plain"}
-
        {#if fileDiff.hunks.length > 0}
-
          <table class="diff" data-file-diff-select>
-
            {#each fileDiff.hunks as hunk, hunkIdx}
-
              <!-- svelte-ignore node_invalid_placement_ssr -->
-
              <tr class="diff-line hunk-header">
-
                <td colspan={2} style:position="relative">
-
                  <div class="selection-indicator-left"></div>
-
                </td>
-
                <td
-
                  colspan={6}
-
                  class="diff-expand-header"
-
                  style:position="relative">
-
                  {hunk.header}
-
                  <div class="selection-indicator-right"></div>
-
                </td>
-
              </tr>
-
              {#each hunk.lines as line, lineIdx}
-
                <!-- svelte-ignore node_invalid_placement_ssr -->
-
                <tr
-
                  style="position: relative;"
-
                  class={`diff-line type-${line.type}`}>
-
                  <td
-
                    id={[filePath, "H" + hunkIdx, "L" + lineIdx].join("-")}
-
                    class="diff-line-number left">
-
                    <div class="selection-indicator-left"></div>
-
                    {lineNumberL(line)}
-
                  </td>
-
                  <td class="diff-line-number right">
-
                    {lineNumberR(line)}
-
                  </td>
-
                  <td class="diff-line-type" data-line-type={line.type}>
-
                    {lineSign(line)}
-
                  </td>
-
                  <td class="diff-line-content">
-
                    {#if line.highlight?.items.length === 0 || line.line === ""}
-
                      <br />
-
                    {:else if line.highlight}
-
                      {@html line.highlight.items
-
                        .map(
-
                          s =>
-
                            `<span class="syntax ${s.style}">${escape(s.item)}</span>`,
-
                        )
-
                        .join("")}
-
                    {:else}
-
                      {line.line}
-
                    {/if}
-
                  </td>
-
                  <td class="selection-indicator-right"></td>
-
                </tr>
-
              {/each}
-
            {/each}
-
          </table>
-
        {:else}
-
          {@render emptyPlaceholder()}
-
        {/if}
-
      {:else}
-
        {@render emptyPlaceholder()}
-
      {/if}
-
    </div>
-
  {/snippet}
-
</File>
modified src/components/CommentToggleInput.svelte
@@ -38,6 +38,7 @@
    padding: 0 0.75rem;
    font-size: var(--font-size-small);
    color: var(--color-fill-gray);
+
    font-family: var(--font-family-sans-serif);
  }
</style>

@@ -71,7 +72,10 @@
    variant="float"
    styleHeight="40px"
    styleWidth="100%"
-
    onclick={() => {
+
    onclick={e => {
+
      e.preventDefault();
+
      e.stopPropagation();
+

      state = "expanded";
      if (onexpand !== undefined) {
        onexpand();
added src/components/Diff.svelte
@@ -0,0 +1,465 @@
+
<script lang="ts" module>
+
  export interface CodeComments {
+
    threads: Thread<CodeLocation>[];
+
    config: Config;
+
    createComment: (
+
      body: string,
+
      embeds: Embed[],
+
      replyTo?: string,
+
      location?: CodeLocation,
+
    ) => Promise<void>;
+
    editComment: (
+
      commentId: string,
+
      body: string,
+
      embeds: Embed[],
+
    ) => Promise<void>;
+
    reactOnComment: (
+
      publicKey: string,
+
      commentId: string,
+
      authors: Author[],
+
      reaction: string,
+
    ) => Promise<void>;
+
    repoDelegates: Author[];
+
    rid: string;
+
  }
+
</script>
+

+
<script lang="ts">
+
  type Side = "left" | "right";
+
  type SelectionAnchor = { side: Side; lineNumber: number };
+
  type SelectionRange = { start: SelectionAnchor; end?: SelectionAnchor };
+

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

+
  import type { Author } from "@bindings/cob/Author";
+
  import type { CodeLocation } from "@bindings/cob/thread/CodeLocation";
+
  import type { Config } from "@bindings/config/Config";
+
  import type { Embed } from "@bindings/cob/thread/Embed";
+
  import type { FileDiff } from "@bindings/diff/FileDiff";
+
  import type { Modification } from "@bindings/diff/Modification";
+
  import type { Thread } from "@bindings/cob/thread/Thread";
+

+
  import * as roles from "@app/lib/roles";
+

+
  import escape from "lodash/escape";
+
  import partial from "lodash/partial";
+

+
  import CommentToggleInput from "./CommentToggleInput.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import ThreadComponent from "@app/components/Thread.svelte";
+

+
  interface Props {
+
    codeComments?: CodeComments;
+
    file: FileDiff;
+
    head: string;
+
  }
+

+
  const { file, head, codeComments }: Props = $props();
+

+
  let selection: Selection | undefined = $state(undefined);
+

+
  function lineNumber(line: Modification, side: Side): number | undefined {
+
    if (side === "left") {
+
      if (line.type === "context") {
+
        return line.lineNoOld;
+
      }
+
      if (line.type === "deletion") {
+
        return line.lineNo;
+
      }
+
    } else {
+
      if (line.type === "context") {
+
        return line.lineNoNew;
+
      }
+
      if (line.type === "addition") {
+
        return line.lineNo;
+
      }
+
    }
+
  }
+

+
  function findLineThread(line: Modification) {
+
    return codeComments?.threads.find(t => {
+
      if (line.type === "addition") {
+
        return t.root.location?.new?.range.end === line.lineNo + 1;
+
      } else if (line.type === "deletion") {
+
        return t.root.location?.old?.range.end === line.lineNo + 1;
+
      } else if (line.type === "context") {
+
        return (
+
          t.root.location?.new?.range.end === line.lineNoNew + 1 ||
+
          t.root.location?.old?.range.end === line.lineNoOld + 1
+
        );
+
      }
+
    });
+
  }
+

+
  function determineSelectedAnchor(
+
    side: Side,
+
    line: Modification,
+
  ): SelectionAnchor {
+
    // When a user tries to select a side with no changes, use opposite side.
+
    if (side === "left" && line.type === "addition") {
+
      return { side: "right", lineNumber: line.lineNo };
+
    } else if (side === "right" && line.type === "deletion") {
+
      return { side: "left", lineNumber: line.lineNo };
+
    } else {
+
      return side === "left"
+
        ? { side: "left", lineNumber: lineNumber(line, "left") as number }
+
        : { side: "right", lineNumber: lineNumber(line, "right") as number };
+
    }
+
  }
+

+
  function filePath(file: FileDiff, side: Side): string {
+
    if (file.status === "moved" || file.status === "copied") {
+
      if (side === "left") {
+
        return file.oldPath;
+
      } else {
+
        return file.newPath;
+
      }
+
    } else {
+
      return file.path;
+
    }
+
  }
+

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

+
    selection = {
+
      file: filePath(file, side),
+
      start: determineSelectedAnchor(side, line),
+
      end: determineSelectedAnchor(side, line),
+
      hunkIdx: hunkIdx,
+
      lineIdx: lineIdx,
+
      codeLocation: {
+
        commit: head,
+
        path: filePath(file, side),
+
        old:
+
          side === "left"
+
            ? {
+
                type: "lines",
+
                range: {
+
                  start: lineNumber(line, "left") as number,
+
                  end: (lineNumber(line, "left") as number) + 1,
+
                },
+
              }
+
            : null,
+
        new:
+
          side === "right"
+
            ? {
+
                type: "lines",
+
                range: {
+
                  start: lineNumber(line, "right") as number,
+
                  end: (lineNumber(line, "right") as number) + 1,
+
                },
+
              }
+
            : null,
+
      },
+
    };
+
  }
+

+
  function isSelected(file: string, hunkIdx: number, lineIdx: number) {
+
    return (
+
      selection &&
+
      selection.file === file &&
+
      selection.hunkIdx === hunkIdx &&
+
      selection.lineIdx === lineIdx
+
    );
+
  }
+

+
  function rangeAnchorsFromCodeLocation(
+
    location: CodeLocation | null,
+
  ): SelectionRange | undefined {
+
    if (location?.old?.type === "lines") {
+
      return {
+
        start: { side: "left", lineNumber: location.old.range.start },
+
      };
+
    } else if (location?.new?.type === "lines") {
+
      return {
+
        start: { side: "right", lineNumber: location.new.range.start },
+
      };
+
    }
+
  }
+
</script>
+

+
<style>
+
  .container {
+
    /* Make space for the box-shadow border, otherwise it gets cut off due to
+
       overflow: hide on the container. */
+
    padding: 8px 1px;
+
    font-size: var(--font-size-small);
+
    font-family: var(--font-family-monospace);
+
  }
+
  .line {
+
    display: flex;
+
    position: relative;
+
    white-space: pre-wrap;
+
  }
+
  .hunk-header {
+
    color: var(--color-foreground-dim);
+
  }
+
  .hunk-header > .left,
+
  .hunk-header > .right {
+
    cursor: default;
+
  }
+
  .addition {
+
    background-color: var(--color-fill-diff-green-light);
+
  }
+
  .deletion {
+
    background-color: var(--color-fill-diff-red-light);
+
  }
+
  .addition > .left,
+
  .addition > .right,
+
  .addition > .sign {
+
    color: var(--color-foreground-success);
+
  }
+
  .deletion > .left,
+
  .deletion > .right,
+
  .deletion > .sign {
+
    color: var(--color-foreground-red);
+
  }
+
  .context > .left,
+
  .context > .right,
+
  .context > .sign {
+
    color: var(--color-foreground-disabled);
+
  }
+
  .marker {
+
    color: var(--color-foreground-contrast) !important;
+
  }
+
  .selected {
+
    box-shadow: 0 0 0 1px var(--color-fill-secondary);
+
    z-index: 1;
+
  }
+
  .left,
+
  .right {
+
    min-width: 3rem;
+
    text-align: center;
+
    position: relative;
+
    cursor: cell;
+
  }
+
  .left:hover,
+
  .right:hover,
+
  .left:active,
+
  .right:active {
+
    color: var(--color-foreground-contrast);
+
  }
+
  .sign {
+
    min-width: 1.5rem;
+
  }
+
  .code {
+
    -webkit-touch-callout: initial;
+
    -webkit-user-select: text;
+
    user-select: text;
+
    width: 100%;
+
    word-break: break-word;
+
  }
+
  .comment-icon {
+
    margin-left: auto;
+
    margin-right: 1rem;
+
    margin-top: 3px;
+
    align-self: flex-start;
+
  }
+
  .thread {
+
    background-color: var(--color-fill-float-hover);
+
    padding: 0.5rem;
+
    margin-bottom: 1rem;
+
  }
+
  .comment-form {
+
    background-color: var(--color-fill-float-hover);
+
    font-family: var(--font-family-sans-serif);
+
    display: flex;
+
    flex-direction: column;
+
    padding: 1rem;
+
    margin-bottom: 1rem;
+
  }
+
  .comment-header {
+
    display: flex;
+
    background-color: var(--color-fill-ghost);
+
    clip-path: var(--1px-corner-fill);
+
    padding: 0 8px;
+
    width: fit-content;
+
  }
+
</style>
+

+
{#snippet commentHeader(filePath?: string, selectionRange?: SelectionRange)}
+
  {#if filePath && selectionRange}
+
    <div class="comment-header">
+
      {filePath.split("/").length > 1 ? "…/" : ""}{filePath
+
        .split("/")
+
        .slice(-1)}:{selectionRange.start.side === "left"
+
        ? "L"
+
        : "R"}{selectionRange.start.lineNumber}
+
      {#if selectionRange.end}
+
        ->
+
        {selectionRange.end.side === "left" ? "L" : "R"}{selectionRange.end
+
          .lineNumber}
+
      {/if}
+
    </div>
+
  {/if}
+
{/snippet}
+

+
<div class="container">
+
  {#if file.diff.type === "plain"}
+
    {#each file.diff.hunks as hunk, hunkIdx}
+
      <div class="line hunk-header">
+
        <div class="left"></div>
+
        <div class="right"></div>
+
        <div class="sign"></div>
+
        <div class="code">{hunk.header}</div>
+
      </div>
+

+
      <div>
+
        {#each hunk.lines as line, lineIdx}
+
          {@const thread = findLineThread(line)}
+
          <div
+
            class="line"
+
            class:addition={line.type === "addition"}
+
            class:deletion={line.type === "deletion"}
+
            class:context={line.type === "context"}
+
            class:selected={!thread &&
+
              isSelected(filePath(file, "left"), hunkIdx, lineIdx)}>
+
            <div
+
              class="left"
+
              class:marker={selection?.start.side === "left" &&
+
                selection.start.lineNumber === lineNumber(line, "left")}
+
              onpointerdown={e => {
+
                if (codeComments?.createComment && !thread) {
+
                  selectLine(e, file, "left", line, hunkIdx, lineIdx);
+
                }
+
              }}>
+
              {lineNumber(line, "left")}
+
            </div>
+

+
            <div
+
              class="right"
+
              class:marker={selection?.start.side === "right" &&
+
                selection.start.lineNumber === lineNumber(line, "right")}
+
              onpointerdown={e => {
+
                if (codeComments?.createComment && !thread) {
+
                  selectLine(e, file, "right", line, hunkIdx, lineIdx);
+
                }
+
              }}>
+
              {lineNumber(line, "right")}
+
            </div>
+

+
            <div class="sign">
+
              {#if line.type === "addition"}
+
                +
+
              {:else if line.type === "deletion"}
+
                -
+
              {/if}
+
            </div>
+

+
            {#if line.highlight && line.highlight.items.length > 0}
+
              <div class="code">
+
                {@html line.highlight.items
+
                  .map(
+
                    paint =>
+
                      `<span class="global-syntax ${paint.style}">${escape(paint.item)}</span>`,
+
                  )
+
                  .join("")}
+
              </div>
+
            {:else if line.line !== ""}
+
              <div class="code">{line.line}</div>
+
            {:else}
+
              <div class="code"><br /></div>
+
            {/if}
+

+
            <div class="global-flex comment-icon">
+
              {#if thread}
+
                {#if thread.root.resolved && thread.replies.every(r => r.resolved)}
+
                  <Icon name="comment-checkmark" />
+
                {:else}
+
                  <Icon name="comment" />
+
                {/if}
+
              {/if}
+
            </div>
+
          </div>
+

+
          {#if codeComments && thread}
+
            <div class="thread">
+
              <div style:padding="0.5rem">
+
                {@render commentHeader(
+
                  thread.root.location?.path,
+
                  rangeAnchorsFromCodeLocation(thread.root.location),
+
                )}
+
              </div>
+
              <ThreadComponent
+
                inline
+
                rid={codeComments.rid}
+
                {thread}
+
                reactOnComment={codeComments.config &&
+
                  partial(
+
                    codeComments.reactOnComment,
+
                    codeComments.config.publicKey,
+
                  )}
+
                createReply={async (body, embeds) => {
+
                  await codeComments.createComment(
+
                    body,
+
                    embeds,
+
                    thread.root.id,
+
                  );
+
                }}
+
                editComment={codeComments.editComment}
+
                canEditComment={partial(
+
                  roles.isDelegateOrAuthor,
+
                  codeComments.config.publicKey,
+
                  codeComments.repoDelegates.map(delegate => delegate.did),
+
                )} />
+
            </div>
+
          {/if}
+

+
          {#if codeComments && selection && selection.hunkIdx === hunkIdx && selection.lineIdx === lineIdx && selection.codeLocation}
+
            <div
+
              class="comment-form"
+
              onpointerdown={e => {
+
                e.stopPropagation();
+
              }}>
+
              <div style:margin-bottom="1rem">
+
                {@render commentHeader(selection.file, {
+
                  start: selection.start,
+
                })}
+
              </div>
+
              <CommentToggleInput
+
                disallowEmptyBody
+
                rid={codeComments.rid}
+
                onclose={() => {
+
                  selection = undefined;
+
                }}
+
                focus
+
                placeholder="Leave a comment"
+
                submit={async (body, embeds) => {
+
                  if (selection?.codeLocation) {
+
                    try {
+
                      await codeComments.createComment(
+
                        body,
+
                        embeds,
+
                        undefined,
+
                        selection.codeLocation,
+
                      );
+
                    } catch (e) {
+
                      console.error("Comment creation failed", e);
+
                    } finally {
+
                      selection = undefined;
+
                    }
+
                  }
+
                }} />
+
            </div>
+
          {/if}
+
        {/each}
+
      </div>
+
    {/each}
+
  {/if}
+
</div>
modified src/components/ExtendedTextarea.svelte
@@ -207,6 +207,7 @@
    gap: 1rem;
    width: 100%;
    flex: 1;
+
    font-family: var(--font-family-sans-serif);
  }
  .inline {
    border: 0;
modified src/components/File.svelte
@@ -7,14 +7,21 @@
  import NakedButton from "./NakedButton.svelte";

  interface Props {
-
    sticky?: boolean;
-
    leftHeader: Snippet;
    children: Snippet;
    expanded: boolean;
+
    leftHeader: Snippet;
+
    rightHeader?: Snippet;
+
    sticky?: boolean;
  }

  /* eslint-disable prefer-const */
-
  let { sticky = true, leftHeader, children, expanded }: Props = $props();
+
  let {
+
    children,
+
    expanded,
+
    leftHeader,
+
    rightHeader,
+
    sticky = true,
+
  }: Props = $props();
  /* eslint-enable prefer-const */

  let header: HTMLElement | undefined = $state();
@@ -91,6 +98,15 @@
    </NakedButton>
    {@render leftHeader()}
  </div>
+
  {#if rightHeader}
+
    <div
+
      class="global-flex"
+
      style:gap="1rem"
+
      style:margin-left="auto"
+
      style:margin-right="1rem">
+
      {@render rightHeader()}
+
    </div>
+
  {/if}
</div>

{#if expanded}
added src/components/FileDiff.svelte
@@ -0,0 +1,127 @@
+
<script lang="ts">
+
  import type { FileDiff } from "@bindings/diff/FileDiff";
+
  import type { CodeComments } from "./Diff.svelte";
+

+
  import Diff from "@app/components/Diff.svelte";
+
  import File from "@app/components/File.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Path from "@app/components/Path.svelte";
+

+
  interface Props {
+
    expanded: boolean;
+
    file: FileDiff;
+
    head: string;
+
    codeComments?: CodeComments;
+
  }
+

+
  const { expanded, file, head, codeComments }: Props = $props();
+

+
  // Pass down only the comments that apply to the given diff.
+
  function filterThreadsByFilePath() {
+
    if (codeComments) {
+
      if (
+
        file.status === "added" ||
+
        file.status === "deleted" ||
+
        file.status === "modified"
+
      ) {
+
        return {
+
          ...codeComments,
+
          threads:
+
            codeComments.threads.filter(t => {
+
              return t.root.location?.path === file.path;
+
            }) ?? [],
+
        };
+
      } else {
+
        return { ...codeComments, threads: [] };
+
      }
+
    } else {
+
      return undefined;
+
    }
+
  }
+

+
  const commentsOfThisFile = $derived(filterThreadsByFilePath());
+
</script>
+

+
<style>
+
  .added {
+
    color: var(--color-foreground-success);
+
    background-color: var(--color-fill-diff-green-light);
+
  }
+
  .deleted {
+
    color: var(--color-foreground-red);
+
    background-color: var(--color-fill-diff-red-light);
+
  }
+
  .moved,
+
  .copied {
+
    color: var(--color-foreground-dim);
+
    background: var(--color-fill-ghost);
+
  }
+
  .stats {
+
    font-size: var(--font-size-tiny);
+
    font-family: var(--font-family-monospace);
+
    font-weight: var(--font-weight-semibold);
+
  }
+
</style>
+

+
{#snippet emptyPlaceholder()}
+
  <div class="global-flex" style:margin="1rem 0" style:justify-content="center">
+
    <Icon name="none" />Empty file
+
  </div>
+
{/snippet}
+

+
<File {expanded}>
+
  {#snippet leftHeader()}
+
    {#if file.status === "moved" || file.status === "copied"}
+
      <span style="display: flex; align-items: center; flex-wrap: wrap;">
+
        <Path fullPath={file.oldPath} />
+
        <span style:padding="0 0.5rem">→</span>
+
        <Path fullPath={file.newPath} />
+
      </span>
+
    {:else}
+
      <Path fullPath={file.path} />
+
    {/if}
+

+
    {#if file.status === "added"}
+
      <span class="global-counter added">added</span>
+
    {:else if file.status === "deleted"}
+
      <span class="global-counter deleted">deleted</span>
+
    {:else if file.status === "moved"}
+
      <span class="global-counter moved">moved</span>
+
    {:else if file.status === "copied"}
+
      <span class="global-counter copied">copied</span>
+
    {/if}
+
  {/snippet}
+

+
  {#snippet rightHeader()}
+
    {#if file.diff.type === "plain" && file.diff.hunks.length > 0}
+
      <div class="stats">
+
        <span style:color="var(--color-foreground-success)">
+
          +{file.diff.stats.additions}
+
        </span>
+
        <span style:color="var(--color-foreground-red)">
+
          -{file.diff.stats.deletions}
+
        </span>
+
      </div>
+
    {/if}
+
    {#if commentsOfThisFile && commentsOfThisFile.threads.length > 0}
+
      <Icon name="comment" />
+
    {/if}
+
  {/snippet}
+

+
  {#if file.diff.type === "plain"}
+
    {#if file.diff.hunks.length > 0}
+
      <Diff {file} {head} codeComments={commentsOfThisFile} />
+
    {:else}
+
      {@render emptyPlaceholder()}
+
    {/if}
+
  {:else if file.diff.type === "binary"}
+
    <div
+
      class="global-flex"
+
      style:margin="1rem 0"
+
      style:justify-content="center">
+
      <Icon name="binary" />Binary file
+
    </div>
+
  {:else}
+
    {@render emptyPlaceholder()}
+
  {/if}
+
</File>
modified src/components/Icon.svelte
@@ -12,6 +12,7 @@
      | "arrow-right"
      | "arrow-right-hollow"
      | "attachment"
+
      | "binary"
      | "branch"
      | "broom"
      | "broom-double"
@@ -162,6 +163,26 @@
    <path d="M4 8H5V9L4 9V8Z" />
    <path d="M5 6H11V7H5V6Z" />
    <path d="M4 7H5L5 8H4L4 7Z" />
+
  {:else if name === "binary"}
+
    <path d="M10 3.5H11V4.5H10V3.5Z" />
+
    <path d="M11 4.5L12 4.5V5.5H11V4.5Z" />
+
    <path d="M10 5.5L12 5.5V6.5H10V5.5Z" />
+
    <path d="M9 5.5H10V6.5H9V5.5Z" />
+
    <path d="M8 4.5H9V5.5L8 5.5V4.5Z" />
+
    <path d="M8 2.5H9L9 4.5H8L8 2.5Z" />
+
    <path d="M9 2.5L10 2.5V3.5H9V2.5Z" />
+
    <path d="M4 13.5H12V14.5H4V13.5Z" />
+
    <path d="M4 1.5H9V2.5L4 2.5V1.5Z" />
+
    <path d="M13 5.5V13.5L12 13.5L12 5.5L13 5.5Z" />
+
    <path d="M4 2.5L4 13.5H3L3 2.5L4 2.5Z" />
+
    <path d="M5 9.5H6V11.5H5V9.5Z" />
+
    <path d="M7 9.5H8V11.5H7V9.5Z" />
+
    <path d="M6 11.5H7L7 12.5H6L6 11.5Z" />
+
    <path d="M6 8.5H7V9.5L6 9.5L6 8.5Z" />
+
    <path d="M9 9.5H10V10.5H9V9.5Z" />
+
    <path d="M10 8.5H11V12.5H10V8.5Z" />
+
    <path d="M5 4.5H6V5.5H5V4.5Z" />
+
    <path d="M6 3.5H7V7.5H6V3.5Z" />
  {:else if name === "branch"}
    <path d="M11 5L10 5V2L13 2V5L12 5V8L11 8V5ZM11 3H12V4H11V3Z" />
    <path
modified src/components/PatchTeaser.svelte
@@ -21,7 +21,7 @@

  interface Props {
    compact?: boolean;
-
    loadPatch?: (rid: string, patchId: string) => void;
+
    loadPatch?: (patchId: string) => Promise<void>;
    patch: Patch;
    rid: string;
    selected?: boolean;
@@ -79,9 +79,9 @@
  class:selected
  class="patch-teaser"
  style:align-items="flex-start"
-
  onclick={() => {
+
  onclick={async () => {
    if (loadPatch) {
-
      loadPatch(rid, patch.id);
+
      await loadPatch(patch.id);
    } else {
      void push({
        resource: "repo.patch",
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,34 +19,35 @@
  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>;
+
    loadReview: () => Promise<void>;
+
    repo: RepoInfo;
    review: Review;
    revision: Revision;
-
    repo: RepoInfo;
  }

  const {
    config,
    onNavigateBack,
    patchId,
-
    reload,
+
    loadReview,
+
    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 &&
+
              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 &&
+
              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);

@@ -113,7 +139,7 @@
      console.error("Editing review failed: ", error);
    } finally {
      labelSaveInProgress = false;
-
      await reload(reviewId);
+
      await loadReview();
    }
  }

@@ -121,8 +147,8 @@
    body: string,
    embeds: Embed[],
    replyTo?: string,
+
    location?: CodeLocation,
  ) {
-
    console.log({ replyTo });
    try {
      await invoke("edit_patch", {
        rid: repo.rid,
@@ -133,13 +159,14 @@
          body,
          embeds,
          replyTo,
+
          location,
        },
        opts: { announce: $nodeRunning && $announce },
      });
    } catch (error) {
      console.error("Creating comment failed", error);
    } finally {
-
      await reload(review.id);
+
      await loadReview();
    }
  }

@@ -160,7 +187,7 @@
    } catch (error) {
      console.error("Editing comment failed: ", error);
    } finally {
-
      await reload(review.id);
+
      await loadReview();
    }
  }

@@ -188,7 +215,7 @@
    } catch (error) {
      console.error("Editing comment reactions failed", error);
    } finally {
-
      await reload(review.id);
+
      await loadReview();
    }
  }
</script>
@@ -348,4 +375,18 @@
    {createComment}
    {editComment}
    {reactOnComment} />
+

+
  <Changes
+
    codeComments={{
+
      config,
+
      createComment,
+
      editComment,
+
      reactOnComment,
+
      repoDelegates: repo.delegates,
+
      rid: repo.rid,
+
      threads: codeCommentThreads,
+
    }}
+
    rid={repo.rid}
+
    {patchId}
+
    {revision} />
</div>
added src/components/Reviews.svelte
@@ -0,0 +1,128 @@
+
<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;
+
    loadPatch: () => Promise<void>;
+
  }
+

+
  const { rid, patchId, revision, config, status, loadPatch }: Props = $props();
+

+
  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 loadPatch();
+
    }
+
  }
+
</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;
@@ -35,37 +26,18 @@
    revision: Revision;
    config: Config;
    status: PatchStatus | undefined;
-
    reload: () => Promise<void>;
+
    loadPatch: () => Promise<void>;
  }

-
  /* eslint-disable prefer-const */
-
  let { rid, repoDelegates, patchId, revision, config, status, reload }: Props =
-
    $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 {
+
    rid,
+
    repoDelegates,
+
    patchId,
+
    revision,
+
    config,
+
    status,
+
    loadPatch,
+
  }: Props = $props();

  const commentThreads = $derived(
    ((revision.discussion &&
@@ -107,7 +79,7 @@
    } catch (error) {
      console.error("Editing revision failed: ", error);
    } finally {
-
      await reload();
+
      await loadPatch();
    }
  }

@@ -134,29 +106,7 @@
    } catch (error) {
      console.error("Editing reactions failed", error);
    } finally {
-
      await reload();
-
    }
-
  }
-

-
  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();
+
      await loadPatch();
    }
  }

@@ -174,7 +124,7 @@
    } catch (error) {
      console.error("Creating comment failed", error);
    } finally {
-
      await reload();
+
      await loadPatch();
    }
  }

@@ -195,7 +145,7 @@
    } catch (error) {
      console.error("Editing comment failed: ", error);
    } finally {
-
      await reload();
+
      await loadPatch();
    }
  }

@@ -223,29 +173,9 @@
    } catch (error) {
      console.error("Editing comment reactions failed", error);
    } finally {
-
      await reload();
+
      await loadPatch();
    }
  }
-

-
  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 +196,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 +219,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} {loadPatch} {revision} {rid} {status} />

<Discussion
  cobId={patchId}
@@ -379,62 +231,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() {
@@ -76,6 +76,7 @@
    display: flex;
    flex-direction: column;
    width: 100%;
+
    font-family: var(--font-family-sans-serif);
  }

  .top-level-comment {
@@ -96,8 +97,53 @@
  }
</style>

+
{#snippet repliesSnippet()}
+
  <div style:width="100%">
+
    {#if replies.length > 0}
+
      {#each replies as reply}
+
        <CommentComponent
+
          disallowEmptyBody
+
          {rid}
+
          lastEdit={reply.edits.length > 1 ? reply.edits.at(-1) : undefined}
+
          id={reply.id}
+
          author={reply.author}
+
          caption="replied"
+
          reactions={reply.reactions}
+
          timestamp={reply.edits[0].timestamp}
+
          body={reply.edits.slice(-1)[0].body}
+
          editComment={editComment &&
+
            canEditComment(reply.author.did) &&
+
            partial(editComment, reply.id)}
+
          reactOnComment={reactOnComment &&
+
            partial(reactOnComment, reply.id)} />
+
      {/each}
+
    {/if}
+
    {#if createReply && showReplyForm}
+
      <div id={`reply-${root.id}`} style:padding="1rem">
+
        <ExtendedTextarea
+
          disallowEmptyBody
+
          {submitInProgress}
+
          {rid}
+
          placeholder="Reply to comment"
+
          submitCaption="Reply"
+
          focus
+
          close={() => (showReplyForm = false)}
+
          submit={async ({ comment, embeds }) => {
+
            try {
+
              submitInProgress = true;
+
              await createReply(comment, Array.from(embeds.values()), root.id);
+
            } finally {
+
              showReplyForm = false;
+
              submitInProgress = false;
+
            }
+
          }} />
+
      </div>
+
    {/if}
+
  </div>
+
{/snippet}
+

<div class="comments" {style}>
-
  <div class="top-level-comment">
+
  <div class:top-level-comment={!inline}>
    <CommentComponent
      disallowEmptyBody
      {rid}
@@ -112,58 +158,21 @@
        partial(editComment, root.id)}
      reactOnComment={reactOnComment && partial(reactOnComment, root.id)}>
      {#snippet actions()}
-
        <Icon name="reply" onclick={toggleReply} />
+
        {#if createReply}
+
          <Icon name="reply" onclick={toggleReply} />
+
        {/if}
      {/snippet}
    </CommentComponent>
  </div>
  {#if replies.length > 0 || (createReply && showReplyForm)}
-
    <Border variant="float" styleOverflow="hidden" flatTop>
-
      <div style:width="100%">
-
        {#if replies.length > 0}
-
          {#each replies as reply}
-
            <CommentComponent
-
              disallowEmptyBody
-
              {rid}
-
              lastEdit={reply.edits.length > 1 ? reply.edits.at(-1) : undefined}
-
              id={reply.id}
-
              author={reply.author}
-
              caption="replied"
-
              reactions={reply.reactions}
-
              timestamp={reply.edits[0].timestamp}
-
              body={reply.edits.slice(-1)[0].body}
-
              editComment={editComment &&
-
                canEditComment(reply.author.did) &&
-
                partial(editComment, reply.id)}
-
              reactOnComment={reactOnComment &&
-
                partial(reactOnComment, reply.id)} />
-
          {/each}
-
        {/if}
-
        {#if createReply && showReplyForm}
-
          <div id={`reply-${root.id}`} style:padding="1rem">
-
            <ExtendedTextarea
-
              disallowEmptyBody
-
              {submitInProgress}
-
              {rid}
-
              placeholder="Reply to comment"
-
              submitCaption="Reply"
-
              focus
-
              close={() => (showReplyForm = false)}
-
              submit={async ({ comment, embeds }) => {
-
                try {
-
                  submitInProgress = true;
-
                  await createReply(
-
                    comment,
-
                    Array.from(embeds.values()),
-
                    root.id,
-
                  );
-
                } finally {
-
                  showReplyForm = false;
-
                  submitInProgress = false;
-
                }
-
              }} />
-
          </div>
-
        {/if}
+
    {#if inline}
+
      <div style:background-color="var(--color-background-float)">
+
        {@render repliesSnippet()}
      </div>
-
    </Border>
+
    {:else}
+
      <Border variant="float" styleOverflow="hidden" flatTop={!inline}>
+
        {@render repliesSnippet()}
+
      </Border>
+
    {/if}
  {/if}
</div>
modified src/views/repo/Patch.svelte
@@ -86,12 +86,6 @@
  let selectedRevision: Revision = $state(revisions.slice(-1)[0]);

  $effect(() => {
-
    patchTeasers = patches.content;
-
    cursor = patches.cursor;
-
    more = patches.more;
-
  });
-

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

@@ -101,23 +95,13 @@
    selectedRevision = revisions.slice(-1)[0];
  });

-
  const project = $derived(repo.payloads["xyz.radicle.project"]!);
+
  $effect(() => {
+
    patchTeasers = patches.content;
+
    cursor = patches.cursor;
+
    more = patches.more;
+
  });

-
  async function loadPatch(rid: string, patchId: string) {
-
    patch = await invoke<Patch>("patch_by_id", {
-
      rid: rid,
-
      id: patchId,
-
    });
-
    revisions = await invoke<Revision[]>("revisions_by_patch", {
-
      rid: rid,
-
      id: patchId,
-
    });
-
    activity = await invoke<Operation<Action>[]>("activity_by_patch", {
-
      rid: repo.rid,
-
      id: patch.id,
-
    });
-
    review = undefined;
-
  }
+
  const project = $derived(repo.payloads["xyz.radicle.project"]!);

  async function editTitle(rid: string, patchId: string, title: string) {
    if (patch.title === updatedTitle) {
@@ -141,7 +125,7 @@
    } catch (error) {
      console.error("Editing title failed: ", error);
    } finally {
-
      await reload();
+
      await loadPatch();
    }
  }

@@ -161,7 +145,7 @@
      console.error("Editing labels failed", error);
    } finally {
      labelSaveInProgress = false;
-
      await reload();
+
      await loadPatch();
    }
  }

@@ -181,7 +165,7 @@
      console.error("Editing assignees failed", error);
    } finally {
      assigneesSaveInProgress = false;
-
      await reload();
+
      await loadPatch();
    }
  }

@@ -202,7 +186,7 @@
    } catch (error) {
      console.error("Changing state failed", error);
    } finally {
-
      await reload();
+
      await loadPatch();
    }
  }

@@ -210,6 +194,7 @@
    if (more) {
      const p = await invoke<PaginatedQuery<Patch[]>>("list_patches", {
        rid: repo.rid,
+
        status,
        skip: cursor + 20,
        take: 20,
      });
@@ -220,32 +205,38 @@
    }
  }

-
  async function reload(reviewId?: string) {
-
    [config, repo, patches, patch, revisions, activity] = await Promise.all([
-
      invoke<Config>("config"),
-
      invoke<RepoInfo>("repo_by_id", {
-
        rid: repo.rid,
-
      }),
-
      invoke<PaginatedQuery<Patch[]>>("list_patches", {
-
        rid: repo.rid,
-
        status,
-
      }),
+
  async function loadPatch(patchId: string = patch.id) {
+
    [patch, revisions, activity, patches] = await Promise.all([
      invoke<Patch>("patch_by_id", {
        rid: repo.rid,
-
        id: patch.id,
+
        id: patchId,
      }),
      invoke<Revision[]>("revisions_by_patch", {
        rid: repo.rid,
-
        id: patch.id,
+
        id: patchId,
      }),
      invoke<Operation<Action>[]>("activity_by_patch", {
        rid: repo.rid,
-
        id: patch.id,
+
        id: patchId,
+
      }),
+
      invoke<PaginatedQuery<Patch[]>>("list_patches", {
+
        rid: repo.rid,
+
        status,
      }),
    ]);
-
    review = revisions
-
      .flatMap(r => r.reviews || [])
-
      .find(review => review.id === reviewId);
+
  }
+

+
  async function loadReview(reviewId: string | undefined = review?.id) {
+
    if (!reviewId) {
+
      return;
+
    }
+

+
    review = await invoke<Review>("review_by_patch_and_revision_and_id", {
+
      rid: repo.rid,
+
      id: patch.id,
+
      revisionId: findReviewRevision(reviewId).id,
+
      reviewId,
+
    });
  }

  async function loadPatches(filter: PatchStatus | undefined) {
@@ -259,6 +250,16 @@
      console.error("Loading patch list failed", error);
    }
  }
+

+
  function findReviewRevision(reviewId: string): Revision {
+
    // Every review is guaranteed to have a revision according to the protocol
+
    // model, so using type assertions here is safe.
+
    return revisions.find(revision => {
+
      return revision.reviews!.find(rev => {
+
        return rev.id === reviewId;
+
      });
+
    }) as Revision;
+
  }
</script>

<style>
@@ -409,7 +410,10 @@
      {#each patchTeasers as teaser}
        <PatchTeaser
          compact
-
          {loadPatch}
+
          loadPatch={async (id: string) => {
+
            review = undefined;
+
            await loadPatch(id);
+
          }}
          patch={teaser}
          rid={repo.rid}
          {status}
@@ -445,9 +449,9 @@
      {config}
      patchId={patch.id}
      {repo}
-
      {reload}
+
      {loadReview}
      {review}
-
      revision={selectedRevision}
+
      revision={findReviewRevision(review.id)}
      onNavigateBack={() => {
        review = undefined;
      }} />
@@ -633,7 +637,7 @@
            rid={repo.rid}
            repoDelegates={repo.delegates}
            patchId={patch.id}
-
            {reload}
+
            {loadPatch}
            {status}
            revision={revisions[0]}
            {config} />
@@ -644,7 +648,7 @@
            rid={repo.rid}
            repoDelegates={repo.delegates}
            patchId={patch.id}
-
            {reload}
+
            {loadPatch}
            {status}
            revision={selectedRevision}
            {config} />
modified src/views/repo/router.ts
@@ -290,6 +290,11 @@ export function repoRouteToPath(route: RepoRoute): string {
    let url = [...pathSegments, "patches", route.patch].join("/");
    if (route.status) {
      searchParams.set("status", route.status);
+
    }
+
    if (route.reviewId) {
+
      searchParams.set("review", route.reviewId);
+
    }
+
    if (searchParams.size > 0) {
      url += `?${searchParams}`;
    }
    return url;
@@ -340,7 +345,7 @@ export function repoUrlToRoute(
      const status = (searchParams.get("status") ?? undefined) as
        | PatchStatus
        | undefined;
-
      const reviewId = searchParams.get("reviewId") ?? undefined;
+
      const reviewId = searchParams.get("review") ?? undefined;
      if (id) {
        return {
          resource: "repo.patch",