Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Review page
Merged rudolfs opened 1 year ago
20 files changed +729 -264 a918981e 4add00d6
modified Cargo.lock
@@ -3975,8 +3975,7 @@ dependencies = [
[[package]]
name = "radicle"
version = "0.14.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "fd823aeed3ffe73eb82a213e62cb3811f9bdf453844d6e0b14684e0757fb389b"
+
source = "git+https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?rev=7c902b6905724345ba850eb6cca8f8becc9a9c72#7c902b6905724345ba850eb6cca8f8becc9a9c72"
dependencies = [
 "amplify",
 "base64 0.21.7",
@@ -4007,8 +4006,7 @@ dependencies = [
[[package]]
name = "radicle-cob"
version = "0.13.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "90581a9508ccc310998e991d7acf139d2991297d3fb37d30de07536e10256afb"
+
source = "git+https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?rev=7c902b6905724345ba850eb6cca8f8becc9a9c72#7c902b6905724345ba850eb6cca8f8becc9a9c72"
dependencies = [
 "fastrand",
 "git2",
@@ -4026,8 +4024,7 @@ dependencies = [
[[package]]
name = "radicle-crypto"
version = "0.11.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "d1d6a67969719841ad06049597006368eb4238ca63a02d20207654dfd1d2d6ad"
+
source = "git+https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?rev=7c902b6905724345ba850eb6cca8f8becc9a9c72#7c902b6905724345ba850eb6cca8f8becc9a9c72"
dependencies = [
 "amplify",
 "cyphernet",
@@ -4047,8 +4044,7 @@ dependencies = [
[[package]]
name = "radicle-dag"
version = "0.10.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "cb41c7e10ada3a4df960190a96bfb4af56d33ada890f917acc8e3b122b614875"
+
source = "git+https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?rev=7c902b6905724345ba850eb6cca8f8becc9a9c72#7c902b6905724345ba850eb6cca8f8becc9a9c72"
dependencies = [
 "fastrand",
]
@@ -4070,8 +4066,7 @@ dependencies = [
[[package]]
name = "radicle-ssh"
version = "0.9.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "fbee758010fb64482be4b18591fbeb3cbc15b16450d143edf4edb5484c7366c6"
+
source = "git+https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?rev=7c902b6905724345ba850eb6cca8f8becc9a9c72#7c902b6905724345ba850eb6cca8f8becc9a9c72"
dependencies = [
 "byteorder",
 "log",
modified crates/radicle-tauri/Cargo.toml
@@ -18,7 +18,7 @@ tauri-build = { version = "2.0.1", features = ["isolation"] }
anyhow = { version = "1.0.90" }
base64 = { version = "0.22.1" }
log = { version = "0.4.22" }
-
radicle = { version = "0.14.0" }
+
radicle = { git = "https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git", package = "radicle", rev = "7c902b6905724345ba850eb6cca8f8becc9a9c72" }
radicle-types = { version = "0.1.0", path = "../radicle-types" }
radicle-surf = { version = "0.22.1", features = ["serde"] }
serde = { version = "1.0.210", features = ["derive"] }
modified crates/radicle-types/Cargo.toml
@@ -11,7 +11,7 @@ localtime = { version = "1.3.1" }
log = { version = "0.4.22" }
infer = { version = "0.3" }
mime-infer = { version = "3.0.0" }
-
radicle = { version = "0.14.0", features = ["test"] }
+
radicle = { git = "https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git", package = "radicle", rev = "7c902b6905724345ba850eb6cca8f8becc9a9c72", features = ["test"] }
radicle-surf = { version = "0.22.1", features = ["serde"] }
serde = { version = "1.0.210", features = ["derive"] }
serde_json = { version = "1.0.132" }
modified crates/radicle-types/bindings/cob/patch/Review.ts
@@ -11,4 +11,5 @@ export type Review = {
  summary?: string;
  comments: Array<Comment<CodeLocation>>;
  timestamp: number;
+
  labels: Array<string>;
};
modified crates/radicle-types/src/domain/patch/models/patch.rs
@@ -253,6 +253,8 @@ pub struct Review {
    comments: Vec<cobs::thread::Comment<cobs::thread::CodeLocation>>,
    #[ts(type = "number")]
    timestamp: cob::common::Timestamp,
+
    #[ts(as = "Vec<String>")]
+
    labels: Vec<cob::Label>,
}

impl Review {
@@ -262,6 +264,7 @@ impl Review {
            author: cobs::Author::new(&review.author().id, aliases),
            verdict: review.verdict().map(|v| v.into()),
            summary: review.summary().map(|s| s.to_string()),
+
            labels: review.labels().cloned().collect::<Vec<_>>(),
            comments: review
                .comments()
                .map(|(id, c)| {
modified crates/test-http-api/Cargo.toml
@@ -10,7 +10,7 @@ anyhow = { version = "1.0.90" }
axum = { version = "0.7.5", default-features = false, features = ["json", "query", "tokio", "http1"] }
hyper = { version = "1.4", default-features = false }
lexopt = { version = "0.3.0" }
-
radicle = { version = "0.14.0" }
+
radicle = { git = "https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git", package = "radicle", rev = "7c902b6905724345ba850eb6cca8f8becc9a9c72" }
radicle-surf = { version = "0.22.1", default-features = false, features = ["serde"] }
radicle-types = { path = "../radicle-types" }
serde = { version = "1", features = ["derive"] }
modified src/components/Border.svelte
@@ -3,7 +3,7 @@

  interface Props {
    children: Snippet;
-
    variant: "primary" | "secondary" | "ghost" | "float" | "danger";
+
    variant: "primary" | "secondary" | "ghost" | "float" | "danger" | "success";
    hoverable?: boolean;
    onclick?: () => void;
    stylePosition?: string;
modified src/components/Comment.svelte
@@ -20,23 +20,27 @@

  interface Props {
    actions?: Snippet;
+
    beforeTimestamp?: Snippet;
    id?: string;
    rid: string;
    author: Author;
-
    body: string;
+
    body?: string;
    reactions?: Reaction[];
    embeds?: Map<string, Embed>;
    caption?: string;
    timestamp: number;
    lastEdit?: Edit;
    disallowEmptyBody?: boolean;
+
    emptyBodyTooltip?: string;
    editComment?: (body: string, embeds: Embed[]) => Promise<void>;
    reactOnComment?: (authors: Author[], reaction: string) => Promise<void>;
+
    styleWidth?: string;
  }

  /* eslint-disable prefer-const */
  let {
    actions,
+
    beforeTimestamp,
    id,
    rid,
    author,
@@ -49,6 +53,8 @@
    disallowEmptyBody = false,
    editComment,
    reactOnComment,
+
    styleWidth,
+
    emptyBodyTooltip,
  }: Props = $props();
  /* eslint-enable prefer-const */

@@ -120,7 +126,7 @@
  }
</style>

-
<div class="card" {id}>
+
<div class="card" {id} style:width={styleWidth}>
  <div style:position="relative">
    <div class="card-header">
      <NodeId {...utils.authorForNodeId(author)} />
@@ -128,6 +134,9 @@
      {#if id}
        <Id {id} variant="oid" />
      {/if}
+
      {#if beforeTimestamp}
+
        {@render beforeTimestamp()}
+
      {/if}
      <span class="timestamp" title={utils.absoluteTimestamp(timestamp)}>
        {utils.formatTimestamp(timestamp)}
      </span>
@@ -165,7 +174,7 @@
    </div>
  </div>

-
  {#if body.trim() === "" && state === "read"}
+
  {#if (body === undefined || body?.trim() === "") && state === "read"}
    <div class="card-body">
      <span class="txt-missing txt-small" style:line-height="1.625rem">
        No description.
@@ -181,6 +190,7 @@
            {rid}
            {embeds}
            {disallowEmptyBody}
+
            {emptyBodyTooltip}
            borderVariant="ghost"
            submitInProgress={state === "submit"}
            submitCaption="Save"
@@ -202,7 +212,7 @@
      {:else}
        <div style:width="100%">
          <div style:overflow="hidden">
-
            <Markdown {rid} breaks content={body} />
+
            <Markdown {rid} breaks content={body ?? ""} />
          </div>
        </div>
      {/if}
modified src/components/DropdownListItem.svelte
@@ -67,6 +67,11 @@
  class:disabled
  {style}
  {title}
-
  {onclick}>
+
  onclick={() => {
+
    if (disabled) {
+
      return;
+
    }
+
    onclick();
+
  }}>
  {@render children()}
</div>
modified src/components/ExtendedTextarea.svelte
@@ -29,6 +29,7 @@
    submitInProgress?: boolean;
    disableSubmit?: boolean;
    disallowEmptyBody?: boolean;
+
    emptyBodyTooltip?: string;
    isValid?: () => boolean;
    preview?: boolean;
    stylePadding?: string;
@@ -55,6 +56,7 @@
    submitInProgress = false,
    disableSubmit = false,
    disallowEmptyBody = false,
+
    emptyBodyTooltip,
    isValid = () => true,
    stylePadding,
    borderVariant = "float",
@@ -293,6 +295,7 @@
      </OutlineButton>
      <Button
        variant="ghost"
+
        title={emptyBodyTooltip}
        disabled={!isValid() ||
          submitInProgress ||
          disableSubmit ||
modified src/components/NodeId.svelte
@@ -7,14 +7,14 @@
    publicKey: string;
    alias?: string;
    styleFontSize?: string;
-
    styleFontFamily?: string;
+
    styleFontWeight?: string;
  }

  const {
    publicKey,
    alias,
    styleFontSize = "var(--font-size-small)",
-
    styleFontFamily = "var(--font-family-monospace)",
+
    styleFontWeight = "var(--font-weight-semibold)",
  }: Props = $props();
</script>

@@ -23,7 +23,6 @@
    display: flex;
    align-items: center;
    gap: 0.375rem;
-
    font-weight: var(--font-weight-semibold);
  }
  .no-alias {
    color: var(--color-foreground-dim);
@@ -33,7 +32,7 @@
<div
  class="avatar-alias"
  style:font-size={styleFontSize}
-
  style:font-family={styleFontFamily}>
+
  style:font-weight={styleFontWeight}>
  <Avatar {publicKey} />
  {#if alias}
    <span class="txt-overflow">
modified src/components/PatchTeaser.svelte
@@ -1,7 +1,7 @@
<script lang="ts">
  import type { Patch } from "@bindings/cob/patch/Patch";
-
  import type { Stats } from "@bindings/cob/Stats";
  import type { PatchStatus } from "@app/views/repo/router";
+
  import type { Stats } from "@bindings/cob/Stats";

  import {
    authorForNodeId,
@@ -12,27 +12,28 @@
  import { invoke } from "@app/lib/invoke";
  import { push } from "@app/lib/router";

-
  import DiffStatBadge from "./DiffStatBadge.svelte";
-
  import Icon from "./Icon.svelte";
-
  import Id from "./Id.svelte";
-
  import InlineTitle from "./InlineTitle.svelte";
-
  import NodeId from "./NodeId.svelte";
+
  import DiffStatBadge from "@app/components/DiffStatBadge.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Id from "@app/components/Id.svelte";
+
  import InlineTitle from "@app/components/InlineTitle.svelte";
+
  import Label from "@app/components/Label.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";

  interface Props {
+
    compact?: boolean;
+
    loadPatch?: (rid: string, patchId: string) => void;
    patch: Patch;
    rid: string;
    selected?: boolean;
-
    compact?: boolean;
-
    loadPatch?: (rid: string, patchId: string) => void;
    status: PatchStatus | undefined;
  }

  const {
+
    compact = false,
+
    loadPatch,
    patch,
    rid,
    selected = false,
-
    compact = false,
-
    loadPatch,
    status,
  }: Props = $props();
</script>
@@ -82,7 +83,13 @@
    if (loadPatch) {
      loadPatch(rid, patch.id);
    } else {
-
      void push({ resource: "repo.patch", rid, patch: patch.id, status });
+
      void push({
+
        resource: "repo.patch",
+
        rid,
+
        patch: patch.id,
+
        status,
+
        reviewId: undefined,
+
      });
    }
  }}>
  <div class="global-flex" style:align-items="flex-start">
@@ -116,7 +123,7 @@
      {/await}

      {#each patch.labels as label}
-
        <div class="global-counter txt-small">{label}</div>
+
        <Label {label} />
      {/each}
    {/if}
    <div
added src/components/Review.svelte
@@ -0,0 +1,234 @@
+
<script lang="ts">
+
  import type { Config } from "@bindings/config/Config";
+
  import type { RepoInfo } from "@bindings/repo/RepoInfo";
+
  import type { Review } from "@bindings/cob/patch/Review";
+
  import type { Revision } from "@bindings/cob/patch/Revision";
+

+
  import partial from "lodash/partial";
+

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

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

+
  import Border from "@app/components/Border.svelte";
+
  import CommentComponent from "@app/components/Comment.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 VerdictButton from "@app/components/VerdictButton.svelte";
+
  import VerdictBadge from "./VerdictBadge.svelte";
+

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

+
  const {
+
    config,
+
    onNavigateBack,
+
    patchId,
+
    reload,
+
    review,
+
    revision,
+
    repo,
+
  }: Props = $props();
+

+
  const contributors = [
+
    review.author,
+
    ...review.comments.map(c => {
+
      return c.author;
+
    }),
+
  ];
+

+
  let verdict: Review["verdict"] = $state(review.verdict);
+
  let labelSaveInProgress: boolean = $state(false);
+

+
  async function editReview(
+
    reviewId: string,
+
    verdict: Review["verdict"],
+
    labels: string[],
+
    summary?: string,
+
  ) {
+
    if (summary?.trim() === "") {
+
      summary = undefined;
+
    } else {
+
      summary = summary?.trim();
+
    }
+

+
    try {
+
      labelSaveInProgress = true;
+
      await invoke("edit_patch", {
+
        rid: repo.rid,
+
        cobId: patchId,
+
        action: {
+
          type: "review.edit",
+
          review: reviewId,
+
          summary,
+
          verdict,
+
          labels,
+
        },
+
        opts: { announce: $nodeRunning && $announce },
+
      });
+
    } catch (error) {
+
      console.error("Editing review failed: ", error);
+
    } finally {
+
      labelSaveInProgress = false;
+
      await reload(reviewId);
+
    }
+
  }
+
</script>
+

+
<style>
+
  .content {
+
    padding: 1rem 1rem 1rem 0;
+
  }
+
  .title {
+
    font-size: var(--font-size-medium);
+
    font-weight: var(--font-weight-medium);
+
    -webkit-user-select: text;
+
    user-select: text;
+
    display: flex;
+
    align-items: center;
+
    white-space: nowrap;
+
    min-height: 40px;
+
    gap: 0.5rem;
+
    margin-bottom: 0.5rem;
+
  }
+
  .metadata-divider {
+
    width: 2px;
+
    background-color: var(--color-fill-ghost);
+
    height: calc(100% + 4px);
+
    top: 0;
+
    position: relative;
+
  }
+
  .metadata-section {
+
    padding: 0.5rem;
+
    font-size: var(--font-size-small);
+
    display: flex;
+
    flex-direction: column;
+
    align-items: flex-start;
+
    height: 100%;
+
  }
+
  .metadata-section-title {
+
    margin-bottom: 0.5rem;
+
    color: var(--color-foreground-dim);
+
  }
+
  .review-body {
+
    margin-top: 1rem;
+
    margin-bottom: 1rem;
+
    position: relative;
+
    z-index: 1;
+
  }
+
  /* We put the background and clip-path in a separate element to prevent
+
     popovers being clipped in the main element. */
+
  .review-body::after {
+
    position: absolute;
+
    z-index: -1;
+
    content: " ";
+
    background-color: var(--color-background-float);
+
    clip-path: var(--2px-corner-fill);
+
    width: 100%;
+
    height: 100%;
+
    top: 0;
+
  }
+
</style>
+

+
<div class="content">
+
  <div style:margin-bottom="0.5rem">
+
    <div class="title">
+
      <NakedButton
+
        variant="ghost"
+
        onclick={onNavigateBack}
+
        stylePadding="0 4px">
+
        <Icon name="arrow-left" />
+
      </NakedButton>
+
      <span class="global-flex" style:gap="0">
+
        <NodeId
+
          {...authorForNodeId(review.author)}
+
          styleFontSize="var(--font-size-medium)"
+
          styleFontWeight="var(--font-weight-medium)" />'s review
+
      </span>
+
    </div>
+

+
    <Border variant="ghost" styleGap="0">
+
      <div class="metadata-section" style:min-width="8rem">
+
        <div class="metadata-section-title">Verdict</div>
+
        {#if !!roles.isDelegateOrAuthor( config.publicKey, repo.delegates.map(delegate => delegate.did), review.author.did, )}
+
          <VerdictButton
+
            {verdict}
+
            summaryMissing={review.summary === undefined ||
+
              review.summary.trim() === ""}
+
            onSelect={async newVerdict => {
+
              verdict = newVerdict;
+
              await editReview(
+
                review.id,
+
                verdict,
+
                review.labels,
+
                review.summary,
+
              );
+
            }} />
+
        {:else}
+
          <VerdictBadge {verdict} />
+
        {/if}
+
      </div>
+

+
      <div class="metadata-divider"></div>
+

+
      <div class="metadata-section" style:flex="1">
+
        <LabelInput
+
          allowedToEdit={!!roles.isDelegateOrAuthor(
+
            config.publicKey,
+
            repo.delegates.map(delegate => delegate.did),
+
            review.author.did,
+
          )}
+
          labels={review.labels}
+
          submitInProgress={labelSaveInProgress}
+
          save={async labels => {
+
            await editReview(review.id, verdict, labels, review.summary);
+
          }} />
+
      </div>
+

+
      <div class="metadata-divider"></div>
+

+
      <div class="metadata-section" style:flex="1">
+
        <div class="metadata-section-title">Participants</div>
+
        {#each contributors as contributor}
+
          <NodeId {...authorForNodeId(contributor)} />
+
        {/each}
+
      </div>
+
    </Border>
+

+
    <div class="review-body">
+
      <CommentComponent
+
        rid={repo.rid}
+
        disallowEmptyBody={review.verdict === undefined}
+
        emptyBodyTooltip="Summary is mandatory when verdict is None"
+
        styleWidth="100%"
+
        caption="published review"
+
        id={review.id}
+
        author={review.author}
+
        timestamp={review.timestamp}
+
        editComment={(publicKeyFromDid(review.author.did) ===
+
          config.publicKey ||
+
          undefined) &&
+
          partial(async (id: string, summary: string) => {
+
            await editReview(id, verdict, review.labels, summary);
+
          }, review.id)}
+
        body={review.summary}>
+
        {#snippet beforeTimestamp()}
+
          on revision <Id id={revision.id} variant="oid" />
+
        {/snippet}
+
      </CommentComponent>
+
    </div>
+
  </div>
+
</div>
modified src/components/ReviewTeaser.svelte
@@ -1,71 +1,56 @@
<script lang="ts">
+
  import type { PatchStatus } from "@app/views/repo/router";
  import type { Review } from "@bindings/cob/patch/Review";

  import {
    absoluteTimestamp,
    authorForNodeId,
    formatTimestamp,
+
    verdictIcon,
  } from "@app/lib/utils";
+
  import { push } from "@app/lib/router";

-
  import Icon from "./Icon.svelte";
-
  import Markdown from "./Markdown.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Id from "@app/components/Id.svelte";
+
  import Label from "@app/components/Label.svelte";
+
  import Markdown from "@app/components/Markdown.svelte";
  import NodeId from "@app/components/NodeId.svelte";

  interface Props {
-
    rid: string;
+
    patchId: string;
    review: Review;
+
    rid: string;
+
    status: PatchStatus | undefined;
  }

-
  const { rid, review }: Props = $props();
-

-
  const header = $derived.by(() => {
-
    if (!review.verdict) {
-
      return "published a review";
-
    }
-

-
    return `${review.verdict ? `${review.verdict}ed` : "reviewed"} revision`;
-
  });
-

-
  function icon(verdict: Review["verdict"]) {
-
    if (verdict === "accept") {
-
      return "comment-checkmark";
-
    } else if (verdict === "reject") {
-
      return "comment-cross";
-
    } else {
-
      return "comment";
-
    }
-
  }
-

-
  function backgroundColor(verdict: Review["verdict"]) {
-
    if (verdict === undefined) {
-
      return "var(--color-fill-float)";
-
    } else if (verdict === "accept") {
-
      return "var(--color-fill-diff-green-light)";
-
    } else if (verdict === "reject") {
-
      return "var(--color-fill-diff-red-light)";
-
    }
-
  }
-

-
  function color(verdict: Review["verdict"]) {
-
    if (verdict === undefined) {
-
      return "var(--color-foreground-dim)";
-
    } else if (verdict === "accept") {
-
      return "var(--color-foreground-success)";
-
    } else if (verdict === "reject") {
-
      return "var(--color-foreground-red)";
-
    }
-
  }
+
  const { patchId, review, rid, status }: Props = $props();
</script>

<style>
  .review {
-
    clip-path: var(--2px-corner-fill);
    display: flex;
    align-items: flex-start;
-
    padding: 0.5rem 0.75rem;
    gap: 0.75rem;
+
    z-index: 1;
+
    position: relative;
+
  }
+
  /* We put the background and clip-path in a separate element to prevent
+
     popovers being clipped in the main element. */
+
  .review::after {
+
    position: absolute;
+
    z-index: -1;
+
    content: " ";
+
    background-color: var(--color-fill-float);
+
    clip-path: var(--2px-corner-fill);
+
    width: 100%;
+
    height: 100%;
+
    top: 0;
+
  }
+
  .review:hover::after {
+
    background-color: var(--color-fill-float-hover);
  }
  .review-content {
+
    padding: 10px 0.75rem 0.5rem 0;
    width: 100%;
    font-size: var(--font-size-small);
    display: flex;
@@ -81,29 +66,68 @@
    align-items: center;
    width: 100%;
  }
-
  .icon {
-
    padding-top: 0.25rem;
+
  .status {
+
    padding: 0;
+
    margin: 0.5rem 0 0 0.5rem;
+
  }
+

+
  .accepted {
+
    background-color: var(--color-fill-diff-green-light);
+
  }
+

+
  .rejected {
+
    background-color: var(--color-fill-diff-red-light);
+
  }
+

+
  .no-verdict {
+
    background-color: var(--color-fill-ghost);
  }
</style>

-
<div class="review" style:background-color={backgroundColor(review.verdict)}>
-
  <div class="icon" style:color={color(review.verdict)}>
-
    <Icon name={icon(review.verdict)} />
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
+
<div
+
  tabindex="0"
+
  role="button"
+
  class="review"
+
  style:cursor="pointer"
+
  onclick={() => {
+
    void push({
+
      resource: "repo.patch",
+
      rid,
+
      patch: patchId,
+
      status,
+
      reviewId: review.id,
+
    });
+
  }}>
+
  <div
+
    class:accepted={review.verdict === "accept"}
+
    class:rejected={review.verdict === "reject"}
+
    class:no-verdict={review.verdict === undefined}
+
    class="global-counter status">
+
    <Icon name={verdictIcon(review.verdict)} />
  </div>
  <div class="review-content">
    <div class="review-header">
      <div class="global-flex">
        <NodeId {...authorForNodeId(review.author)} />
-
        <span>{header}</span>
+
        <span>published review</span>
+
        <Id id={review.id} variant="oid" />
        <div class="timestamp" title={absoluteTimestamp(review.timestamp)}>
          {formatTimestamp(review.timestamp)}
        </div>
-
        {#if review.comments.length > 0}
-
          <div class="global-flex" style:gap="0.25rem" style:margin-left="auto">
-
            <Icon name="comment" />{review.comments.length}
-
          </div>
-
        {/if}
      </div>
+
      {#if review.comments.length > 0}
+
        <div class="global-flex" style:gap="0.25rem" style:margin-left="auto">
+
          <Icon name="comment" />{review.comments.length}
+
        </div>
+
      {/if}
+
      {#if review.labels.length > 0}
+
        <div class="global-flex" style:margin-left="auto">
+
          {#each review.labels as label}
+
            <Label {label} />
+
          {/each}
+
        </div>
+
      {/if}
    </div>
    {#if review.summary?.trim()}
      <div>
modified src/components/Revision.svelte
@@ -4,6 +4,7 @@
  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";
@@ -39,11 +40,12 @@
    patchId: string;
    revision: Revision;
    config: Config;
+
    status: PatchStatus | undefined;
    reload: () => Promise<void>;
  }

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

@@ -407,7 +409,7 @@
  {#if revision.reviews && revision.reviews.length}
    <div class:hide={hideReviews} style:margin-top="1rem">
      {#each revision.reviews as review}
-
        <ReviewTeaser {rid} {review} />
+
        <ReviewTeaser {rid} {review} {patchId} {status} />
      {/each}
    </div>
  {/if}
added src/components/VerdictBadge.svelte
@@ -0,0 +1,56 @@
+
<script lang="ts">
+
  import type { Review } from "@bindings/cob/patch/Review";
+
  import type { Snippet } from "svelte";
+

+
  import capitalize from "lodash/capitalize.js";
+
  import { verdictIcon, verdictIconColor } from "@app/lib/utils";
+

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

+
  interface Props {
+
    children?: Snippet;
+
    verdict: Review["verdict"];
+
    hoverable?: boolean;
+
  }
+

+
  const { children, verdict, hoverable = false }: Props = $props();
+
</script>
+

+
<style>
+
  .badge {
+
    gap: 6px;
+
    padding-right: 10px;
+
  }
+
  .no-verdict {
+
    background-color: var(--color-fill-ghost);
+
  }
+
  .no-verdict.hoverable:hover {
+
    background-color: var(--color-fill-ghost-hover);
+
  }
+

+
  .accepted {
+
    background-color: var(--color-fill-diff-green-light);
+
  }
+
  .accepted.hoverable:hover {
+
    background-color: var(--color-fill-diff-green);
+
  }
+

+
  .rejected {
+
    background-color: var(--color-fill-diff-red-light);
+
  }
+
  .rejected.hoverable:hover {
+
    background-color: var(--color-fill-diff-red);
+
  }
+
</style>
+

+
<span
+
  class="global-counter badge"
+
  style:color={verdictIconColor(verdict)}
+
  class:hoverable
+
  class:no-verdict={verdict === undefined}
+
  class:accepted={verdict === "accept"}
+
  class:rejected={verdict === "reject"}>
+
  <Icon name={verdictIcon(verdict)} />
+
  {verdict ? capitalize(`${verdict}ed`) : "None"}
+
  {@render children?.()}
+
</span>
added src/components/VerdictButton.svelte
@@ -0,0 +1,70 @@
+
<script lang="ts">
+
  import type { Review } from "@bindings/cob/patch/Review";
+

+
  import capitalize from "lodash/capitalize.js";
+

+
  import { closeFocused } from "./Popover.svelte";
+
  import { verdictIcon, verdictIconColor } from "@app/lib/utils";
+

+
  import Border from "@app/components/Border.svelte";
+
  import DropdownList from "@app/components/DropdownList.svelte";
+
  import DropdownListItem from "@app/components/DropdownListItem.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+
  import VerdictBadge from "@app/components/VerdictBadge.svelte";
+

+
  interface Props {
+
    onSelect: (verdict: Review["verdict"]) => Promise<void>;
+
    summaryMissing: boolean;
+
    verdict: Review["verdict"];
+
  }
+

+
  const { onSelect, summaryMissing, verdict }: Props = $props();
+
</script>
+

+
<style>
+
  button {
+
    cursor: pointer;
+
    border: 0;
+
    background: none;
+
    margin: 0;
+
    padding: 0;
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
+
    font-size: var(--font-size-small);
+
  }
+
</style>
+

+
<Popover popoverPadding="0" popoverPositionLeft="0" popoverPositionTop="2rem">
+
  {#snippet toggle(onclick)}
+
    <button {onclick}>
+
      <VerdictBadge {verdict} hoverable>
+
        <Icon name="chevron-down" />
+
      </VerdictBadge>
+
    </button>
+
  {/snippet}
+
  {#snippet popover()}
+
    <Border variant="ghost">
+
      <DropdownList items={[undefined, "accept", "reject"] as const}>
+
        {#snippet item(action)}
+
          <DropdownListItem
+
            title={action === undefined && summaryMissing
+
              ? "Set a summary to select verdict None"
+
              : undefined}
+
            disabled={action === undefined && summaryMissing}
+
            selected={verdict === action}
+
            onclick={async () => {
+
              await onSelect(action);
+
              closeFocused();
+
            }}>
+
            <span class="global-flex" style:color={verdictIconColor(action)}>
+
              <Icon name={verdictIcon(action)} />
+
              {action ? capitalize(`${action}ed`) : "None"}
+
            </span>
+
          </DropdownListItem>
+
        {/snippet}
+
      </DropdownList>
+
    </Border>
+
  {/snippet}
+
</Popover>
modified src/lib/utils.ts
@@ -3,6 +3,7 @@ import type { ComponentProps } from "svelte";
import type { Author } from "@bindings/cob/Author";
import type { Issue } from "@bindings/cob/issue/Issue";
import type { Patch } from "@bindings/cob/patch/Patch";
+
import type { Review } from "@bindings/cob/patch/Review";

import bs58 from "bs58";
import twemojiModule from "twemoji";
@@ -237,3 +238,23 @@ export function gravatarURL(email: string): string {

  return `https://www.gravatar.com/avatar/${hash}`;
}
+

+
export function verdictIcon(verdict: Review["verdict"]) {
+
  if (verdict === "accept") {
+
    return "comment-checkmark";
+
  } else if (verdict === "reject") {
+
    return "comment-cross";
+
  } else {
+
    return "comment";
+
  }
+
}
+

+
export function verdictIconColor(verdict: Review["verdict"]) {
+
  if (verdict === "accept") {
+
    return "var(--color-foreground-success)";
+
  } else if (verdict === "reject") {
+
    return "var(--color-foreground-red)";
+
  } else {
+
    return "var(--color-foreground-dim)";
+
  }
+
}
modified src/views/repo/Patch.svelte
@@ -7,6 +7,7 @@
  import type { Patch } from "@bindings/cob/patch/Patch";
  import type { PatchStatus } from "./router";
  import type { RepoInfo } from "@bindings/repo/RepoInfo";
+
  import type { Review } from "@bindings/cob/patch/Review";
  import type { Revision } from "@bindings/cob/patch/Revision";

  import capitalize from "lodash/capitalize";
@@ -37,6 +38,7 @@
  import PatchTeaser from "@app/components/PatchTeaser.svelte";
  import PatchTimeline from "@app/components/PatchTimeline.svelte";
  import Popover, { closeFocused } from "@app/components/Popover.svelte";
+
  import ReviewComponent from "@app/components/Review.svelte";
  import RevisionBadges from "@app/components/RevisionBadges.svelte";
  import RevisionComponent from "@app/components/Revision.svelte";
  import RevisionSelector from "@app/components/RevisionSelector.svelte";
@@ -52,6 +54,7 @@
    config: Config;
    activity: Operation<Action>[];
    status: PatchStatus | undefined;
+
    review: Review | undefined;
  }

  /* eslint-disable prefer-const */
@@ -63,6 +66,7 @@
    config,
    status: initialStatus,
    activity,
+
    review,
  }: Props = $props();
  /* eslint-enable prefer-const */

@@ -110,6 +114,7 @@
      rid: repo.rid,
      id: patch.id,
    });
+
    review = undefined;
  }

  async function editTitle(rid: string, patchId: string, title: string) {
@@ -213,7 +218,7 @@
    }
  }

-
  async function reload() {
+
  async function reload(reviewId?: string) {
    [config, repo, patches, patch, revisions, activity] = await Promise.all([
      invoke<Config>("config"),
      invoke<RepoInfo>("repo_by_id", {
@@ -236,6 +241,9 @@
        id: patch.id,
      }),
    ]);
+
    review = revisions
+
      .flatMap(r => r.reviews || [])
+
      .find(review => review.id === reviewId);
  }

  async function loadPatches(filter: PatchStatus | undefined) {
@@ -430,55 +438,22 @@
    </div>
  {/snippet}

-
  <div class="content">
-
    <div style:margin-bottom="0.5rem">
-
      {#if editingTitle}
-
        <div class="title">
-
          <div
-
            class="global-counter status"
-
            style:color={patchStatusColor[patch.state.status]}
-
            style:background-color={patchStatusBackgroundColor[
-
              patch.state.status
-
            ]}>
-
            <Icon
-
              name={patch.state.status === "open"
-
                ? "patch"
-
                : `patch-${patch.state.status}`} />
-
          </div>
-

-
          <TextInput
-
            valid={updatedTitle.trim().length > 0}
-
            bind:value={updatedTitle}
-
            autofocus
-
            onSubmit={async () => {
-
              if (updatedTitle.trim().length > 0) {
-
                await editTitle(repo.rid, patch.id, updatedTitle);
-
              }
-
            }}
-
            onDismiss={() => {
-
              updatedTitle = patch.title;
-
              editingTitle = !editingTitle;
-
            }} />
-
          <div class="title-icons">
-
            <Icon
-
              name="checkmark"
-
              onclick={async () => {
-
                if (updatedTitle.trim().length > 0) {
-
                  await editTitle(repo.rid, patch.id, updatedTitle);
-
                }
-
              }} />
-
            <Icon
-
              name="cross"
-
              onclick={() => {
-
                updatedTitle = patch.title;
-
                editingTitle = !editingTitle;
-
              }} />
-
            <PatchStateButton patchState={patch.state} save={saveState} />
-
          </div>
-
        </div>
-
      {:else}
-
        <div class="title">
-
          <div class="global-flex" style:gap="0">
+
  {#if review}
+
    <ReviewComponent
+
      {config}
+
      patchId={patch.id}
+
      {repo}
+
      {reload}
+
      {review}
+
      revision={selectedRevision}
+
      onNavigateBack={() => {
+
        review = undefined;
+
      }} />
+
  {:else}
+
    <div class="content">
+
      <div style:margin-bottom="0.5rem">
+
        {#if editingTitle}
+
          <div class="title">
            <div
              class="global-counter status"
              style:color={patchStatusColor[patch.state.status]}
@@ -490,139 +465,189 @@
                  ? "patch"
                  : `patch-${patch.state.status}`} />
            </div>
-
            <InlineTitle content={patch.title} fontSize="medium" />
-
          </div>
-
          {#if roles.isDelegateOrAuthor( config.publicKey, repo.delegates.map(delegate => delegate.did), patch.author.did, )}
+

+
            <TextInput
+
              valid={updatedTitle.trim().length > 0}
+
              bind:value={updatedTitle}
+
              autofocus
+
              onSubmit={async () => {
+
                if (updatedTitle.trim().length > 0) {
+
                  await editTitle(repo.rid, patch.id, updatedTitle);
+
                }
+
              }}
+
              onDismiss={() => {
+
                updatedTitle = patch.title;
+
                editingTitle = !editingTitle;
+
              }} />
            <div class="title-icons">
-
              <Icon name="pen" onclick={() => (editingTitle = !editingTitle)} />
+
              <Icon
+
                name="checkmark"
+
                onclick={async () => {
+
                  if (updatedTitle.trim().length > 0) {
+
                    await editTitle(repo.rid, patch.id, updatedTitle);
+
                  }
+
                }} />
+
              <Icon
+
                name="cross"
+
                onclick={() => {
+
                  updatedTitle = patch.title;
+
                  editingTitle = !editingTitle;
+
                }} />
              <PatchStateButton patchState={patch.state} save={saveState} />
            </div>
-
          {/if}
-
        </div>
-
      {/if}
-
    </div>
-
    <Border variant="ghost" styleGap="0">
-
      <div class="metadata-section" style:min-width="8rem">
-
        <div class="metadata-section-title">Status</div>
-
        <PatchStateBadge state={patch.state} />
+
          </div>
+
        {:else}
+
          <div class="title">
+
            <div class="global-flex" style:gap="0">
+
              <div
+
                class="global-counter status"
+
                style:color={patchStatusColor[patch.state.status]}
+
                style:background-color={patchStatusBackgroundColor[
+
                  patch.state.status
+
                ]}>
+
                <Icon
+
                  name={patch.state.status === "open"
+
                    ? "patch"
+
                    : `patch-${patch.state.status}`} />
+
              </div>
+
              <InlineTitle content={patch.title} fontSize="medium" />
+
            </div>
+
            {#if roles.isDelegateOrAuthor( config.publicKey, repo.delegates.map(delegate => delegate.did), patch.author.did, )}
+
              <div class="title-icons">
+
                <Icon
+
                  name="pen"
+
                  onclick={() => (editingTitle = !editingTitle)} />
+
                <PatchStateButton patchState={patch.state} save={saveState} />
+
              </div>
+
            {/if}
+
          </div>
+
        {/if}
      </div>
+
      <Border variant="ghost" styleGap="0">
+
        <div class="metadata-section" style:min-width="8rem">
+
          <div class="metadata-section-title">Status</div>
+
          <PatchStateBadge state={patch.state} />
+
        </div>

-
      <div class="metadata-divider"></div>
-

-
      <div class="metadata-section" style:flex="1">
-
        <LabelInput
-
          allowedToEdit={!!roles.isDelegateOrAuthor(
-
            config.publicKey,
-
            repo.delegates.map(delegate => delegate.did),
-
            patch.author.did,
-
          )}
-
          labels={patch.labels}
-
          submitInProgress={labelSaveInProgress}
-
          save={saveLabels} />
-
      </div>
+
        <div class="metadata-divider"></div>
+

+
        <div class="metadata-section" style:flex="1">
+
          <LabelInput
+
            allowedToEdit={!!roles.isDelegateOrAuthor(
+
              config.publicKey,
+
              repo.delegates.map(delegate => delegate.did),
+
              patch.author.did,
+
            )}
+
            labels={patch.labels}
+
            submitInProgress={labelSaveInProgress}
+
            save={saveLabels} />
+
        </div>

-
      <div class="metadata-divider"></div>
-

-
      <div class="metadata-section" style:flex="1">
-
        <AssigneeInput
-
          allowedToEdit={!!roles.isDelegateOrAuthor(
-
            config.publicKey,
-
            repo.delegates.map(delegate => delegate.did),
-
            patch.author.did,
-
          )}
-
          assignees={patch.assignees}
-
          submitInProgress={assigneesSaveInProgress}
-
          save={saveAssignees} />
-
      </div>
-
    </Border>
-

-
    <div class="global-flex" style:gap="0.5rem" style:margin-top="1rem">
-
      <Border stylePosition="relative" variant="ghost" flatBottom>
-
        <div
-
          class="global-flex"
-
          style:z-index="10"
-
          style:gap="1rem"
-
          style:padding="0 1rem"
-
          style:width="100%">
-
          <span class="txt-small" style:color="var(--color-foreground-dim)">
-
            Revisions
-
          </span>
-
          <Tab
-
            active={tab === "patch"}
-
            onclick={() => {
-
              tab = "patch";
-
            }}>
-
            {formatOid(patch.id)}
-
            <span
-
              class="global-counter"
-
              style:height="24px"
-
              style:color="var(--color-foreground-contrast)">
-
              Initial
-
            </span>
-
          </Tab>
-
          {#if revisions.length > 1}
-
            <Tab
-
              active={tab === "revisions"}
-
              onclick={() => {
-
                tab = "revisions";
-
              }}>
-
              {formatOid(selectedRevision.id)}
-
              <div class="global-flex" style:gap="0.25rem">
-
                <RevisionBadges revision={selectedRevision} {revisions} />
-
                <RevisionSelector
-
                  {patch}
-
                  {revisions}
-
                  {selectedRevision}
-
                  selectRevision={rev => {
-
                    selectedRevision = rev;
-
                    tab = "revisions";
-
                  }} />
-
              </div>
-
            </Tab>
-
          {/if}
+
        <div class="metadata-divider"></div>
+

+
        <div class="metadata-section" style:flex="1">
+
          <AssigneeInput
+
            allowedToEdit={!!roles.isDelegateOrAuthor(
+
              config.publicKey,
+
              repo.delegates.map(delegate => delegate.did),
+
              patch.author.did,
+
            )}
+
            assignees={patch.assignees}
+
            submitInProgress={assigneesSaveInProgress}
+
            save={saveAssignees} />
+
        </div>
+
      </Border>

-
          <div style:margin-left="auto">
+
      <div class="global-flex" style:gap="0.5rem" style:margin-top="1rem">
+
        <Border stylePosition="relative" variant="ghost" flatBottom>
+
          <div
+
            class="global-flex"
+
            style:z-index="10"
+
            style:gap="1rem"
+
            style:padding="0 1rem"
+
            style:width="100%">
+
            <span class="txt-small" style:color="var(--color-foreground-dim)">
+
              Revisions
+
            </span>
            <Tab
-
              active={tab === "timeline"}
+
              active={tab === "patch"}
              onclick={() => {
-
                tab = "timeline";
+
                tab = "patch";
              }}>
-
              <Icon name="clock" />
-
              Timeline
+
              {formatOid(patch.id)}
+
              <span
+
                class="global-counter"
+
                style:height="24px"
+
                style:color="var(--color-foreground-contrast)">
+
                Initial
+
              </span>
            </Tab>
+
            {#if revisions.length > 1}
+
              <Tab
+
                active={tab === "revisions"}
+
                onclick={() => {
+
                  tab = "revisions";
+
                }}>
+
                {formatOid(selectedRevision.id)}
+
                <div class="global-flex" style:gap="0.25rem">
+
                  <RevisionBadges revision={selectedRevision} {revisions} />
+
                  <RevisionSelector
+
                    {patch}
+
                    {revisions}
+
                    {selectedRevision}
+
                    selectRevision={rev => {
+
                      selectedRevision = rev;
+
                      tab = "revisions";
+
                    }} />
+
                </div>
+
              </Tab>
+
            {/if}
+

+
            <div style:margin-left="auto">
+
              <Tab
+
                active={tab === "timeline"}
+
                onclick={() => {
+
                  tab = "timeline";
+
                }}>
+
                <Icon name="clock" />
+
                Timeline
+
              </Tab>
+
            </div>
          </div>
-
        </div>
+
        </Border>
+
      </div>
+

+
      <Border
+
        variant="ghost"
+
        flatTop
+
        styleWidth="100%"
+
        stylePadding="1rem"
+
        styleMinWidth="0"
+
        styleDisplay="block"
+
        styleFlexDirection="column"
+
        styleAlignItems="flex-start">
+
        {#if tab === "patch"}
+
          <RevisionComponent
+
            rid={repo.rid}
+
            repoDelegates={repo.delegates}
+
            patchId={patch.id}
+
            {reload}
+
            {status}
+
            revision={revisions[0]}
+
            {config} />
+
        {:else if tab === "timeline"}
+
          <PatchTimeline {activity} patchId={patch.id} />
+
        {:else}
+
          <RevisionComponent
+
            rid={repo.rid}
+
            repoDelegates={repo.delegates}
+
            patchId={patch.id}
+
            {reload}
+
            {status}
+
            revision={selectedRevision}
+
            {config} />
+
        {/if}
      </Border>
    </div>
-

-
    <Border
-
      variant="ghost"
-
      flatTop
-
      styleWidth="100%"
-
      stylePadding="1rem"
-
      styleMinWidth="0"
-
      styleDisplay="block"
-
      styleFlexDirection="column"
-
      styleAlignItems="flex-start">
-
      {#if tab === "patch"}
-
        <RevisionComponent
-
          rid={repo.rid}
-
          repoDelegates={repo.delegates}
-
          patchId={patch.id}
-
          {reload}
-
          revision={revisions[0]}
-
          {config} />
-
      {:else if tab === "timeline"}
-
        <PatchTimeline {activity} patchId={patch.id} />
-
      {:else}
-
        <RevisionComponent
-
          rid={repo.rid}
-
          repoDelegates={repo.delegates}
-
          patchId={patch.id}
-
          {reload}
-
          revision={selectedRevision}
-
          {config} />
-
      {/if}
-
    </Border>
-
  </div>
+
  {/if}
</Layout>
modified src/views/repo/router.ts
@@ -1,13 +1,14 @@
import type { Action as IssueAction } from "@bindings/cob/issue/Action";
import type { Action as PatchAction } from "@bindings/cob/patch/Action";
import type { Config } from "@bindings/config/Config";
-
import type { Thread } from "@bindings/cob/thread/Thread";
import type { Issue } from "@bindings/cob/issue/Issue";
import type { Operation } from "@bindings/cob/Operation";
import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery";
import type { Patch } from "@bindings/cob/patch/Patch";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
+
import type { Review } from "@bindings/cob/patch/Review";
import type { Revision } from "@bindings/cob/patch/Revision";
+
import type { Thread } from "@bindings/cob/thread/Thread";

import { invoke } from "@app/lib/invoke";
import { unreachable } from "@app/lib/utils";
@@ -73,6 +74,7 @@ export interface RepoPatchRoute {
  rid: string;
  patch: string;
  status: PatchStatus | undefined;
+
  reviewId: string | undefined;
}

export interface LoadedRepoPatchRoute {
@@ -83,6 +85,7 @@ export interface LoadedRepoPatchRoute {
    patch: Patch;
    patches: PaginatedQuery<Patch[]>;
    status: PatchStatus | undefined;
+
    review: Review | undefined;
    revisions: Revision[];
    activity: Operation<PatchAction>[];
  };
@@ -145,6 +148,10 @@ export async function loadPatch(
    ],
  );

+
  const review = revisions
+
    .flatMap(r => r.reviews || [])
+
    .find(review => review.id === route.reviewId);
+

  return {
    resource: "repo.patch",
    params: {
@@ -154,6 +161,7 @@ export async function loadPatch(
      patches,
      revisions,
      status: route.status,
+
      review,
      activity,
    },
  };
@@ -332,12 +340,14 @@ export function repoUrlToRoute(
      const status = (searchParams.get("status") ?? undefined) as
        | PatchStatus
        | undefined;
+
      const reviewId = searchParams.get("reviewId") ?? undefined;
      if (id) {
        return {
          resource: "repo.patch",
          rid,
          patch: id,
          status,
+
          reviewId,
        };
      } else {
        return { resource: "repo.patches", rid, status };