Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Style patch tabs
Merged rudolfs opened 1 year ago
11 files changed +802 -235 6326863f 296c046e
modified src/components/Border.svelte
@@ -10,11 +10,14 @@
    stylePadding?: string;
    styleHeight?: string;
    styleMinHeight?: string;
+
    styleMinWidth?: string;
    styleWidth?: string;
+
    styleDisplay?: string;
    styleCursor?: "default" | "pointer" | "text";
    styleGap?: string;
    styleOverflow?: string;
    flatTop?: boolean;
+
    flatBottom?: boolean;
    styleBackgroundColor?: string;
    styleFlexDirection?: string;
    styleAlignItems?: string;
@@ -31,10 +34,13 @@
    styleMinHeight,
    stylePosition,
    styleWidth,
+
    styleDisplay = "flex",
    styleCursor = "default",
    styleGap = "0.5rem",
+
    styleMinWidth,
    styleOverflow,
    flatTop = false,
+
    flatBottom = false,
    styleBackgroundColor = "var(--color-background-default)",
    styleFlexDirection = "row",
    styleAlignItems = "center",
@@ -50,8 +56,6 @@

<style>
  .container {
-
    white-space: nowrap;
-

    -webkit-touch-callout: none;
    -webkit-user-select: none;
    user-select: none;
@@ -130,7 +134,6 @@
  }
  .p3-3 {
    grid-area: p3-3;
-
    display: flex;
    background-color: var(--local-background-color);
  }
  .p3-4 {
@@ -196,6 +199,21 @@
  .flat-top > .p2-5 {
    background-color: var(--local-button-color-1);
  }
+

+
  .flat-bottom > .p4-2,
+
  .flat-bottom > .p4-4 {
+
    background-color: transparent;
+
  }
+

+
  .flat-bottom > .p4-1,
+
  .flat-bottom > .p4-5,
+
  .flat-bottom > .p5-3,
+
  .flat-bottom > .p5-1,
+
  .flat-bottom > .p5-2,
+
  .flat-bottom > .p5-4,
+
  .flat-bottom > .p5-5 {
+
    background-color: var(--local-button-color-1);
+
  }
</style>

<!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -204,6 +222,7 @@
  style:cursor={styleCursor}
  class="container"
  class:flat-top={flatTop}
+
  class:flat-bottom={flatBottom}
  {onclick}
  role="button"
  tabindex={onclick !== undefined ? 0 : -1}
@@ -226,6 +245,8 @@
  <div class="p3-2"></div>
  <div
    class="p3-3"
+
    style:min-width={styleMinWidth}
+
    style:display={styleDisplay}
    style:position={stylePosition}
    style:padding={stylePadding}
    style:gap={styleGap}
modified src/components/Changeset.svelte
@@ -16,11 +16,15 @@
    display: flex;
    flex-direction: column;
  }
+

+
  .diff:not(:last-of-type) {
+
    margin-bottom: 1rem;
+
  }
</style>

<div class="diff-list">
  {#each diff.files as file}
-
    <div style:margin-bottom="1rem">
+
    <div class="diff">
      <FileDiff
        filePath={"path" in file ? file.path : file.newPath}
        oldFilePath={"oldPath" in file ? file.oldPath : undefined}
modified src/components/File.svelte
@@ -13,9 +13,9 @@
<style>
  .header {
    display: flex;
-
    height: 3rem;
    align-items: center;
-
    padding: 0 0.5rem 0 1rem;
+
    height: 2.5rem;
+
    padding-left: 1rem;
    z-index: 2;
    font-size: var(--font-size-small);
  }
@@ -46,6 +46,7 @@
  .container {
    position: relative;
    overflow-x: auto;
+
    z-index: 1;
  }
  .container::after {
    position: absolute;
modified src/components/Icon.svelte
@@ -15,6 +15,7 @@
      | "checkmark"
      | "chevron-down"
      | "chevron-right"
+
      | "clock"
      | "collapse-panel"
      | "comment"
      | "comment-checkmark"
@@ -185,6 +186,29 @@
    <path d="M7 10L8 10L8 11L7 11L7 10Z" />
    <path d="M9 8L10 8V9L9 9V8Z" />
    <path d="M8 9H9V10H8L8 9Z" />
+
  {:else if name === "clock"}
+
    <path d="M6 13H8V14H6V13Z" />
+
    <path d="M10 13H8V14H10V13Z" />
+
    <path d="M3 6L3 8H2L2 6H3Z" />
+
    <path d="M13 6V8H14V6H13Z" />
+
    <path d="M4 12H6V13H4V12Z" />
+
    <path d="M12 12H10V13H12V12Z" />
+
    <path d="M4 4V6H3L3 4H4Z" />
+
    <path d="M12 4V6L13 6V4L12 4Z" />
+
    <path d="M4 10L4 12H3L3 10H4Z" />
+
    <path d="M12 10V12H13V10H12Z" />
+
    <path d="M6 4L4 4L4 3L6 3V4Z" />
+
    <path d="M10 4L12 4V3L10 3V4Z" />
+
    <path d="M3 8L3 10H2L2 8H3Z" />
+
    <path d="M13 8V10H14V8H13Z" />
+
    <path d="M8 3L6 3V2L8 2V3Z" />
+
    <path d="M8 3L10 3L10 2L8 2V3Z" />
+
    <path d="M8 8H9V9H8V8Z" />
+
    <path d="M9 7H10V8H9V7Z" />
+
    <path d="M10 6H11V7L10 7V6Z" />
+
    <path d="M7 7H8V8L7 8V7Z" />
+
    <path d="M6 6H7V7L6 7V6Z" />
+
    <path d="M5 5H6V6H5V5Z" />
  {:else if name === "collapse-panel"}
    <path d="M2 3.00002H3V13H2V3.00002Z" />
    <path d="M13 3.00002H14V6.00002H13V3.00002Z" />
modified src/components/PatchTeaser.svelte
@@ -105,8 +105,8 @@
    </div>
  </div>

-
  {#if !compact}
-
    <div class="global-flex">
+
  <div class="global-flex">
+
    {#if !compact}
      {#await invoke<Stats>( "diff_stats", { rid, base: patch.base, head: patch.head }, ) then stats}
        <DiffStatBadge {stats} />
      {/await}
@@ -114,11 +114,13 @@
      {#each patch.labels as label}
        <div class="global-counter txt-small">{label}</div>
      {/each}
-

-
      <div class="txt-small global-flex" style:gap="0.25rem">
-
        <Icon name="revision" />
-
        {patch.revisionCount}
-
      </div>
+
    {/if}
+
    <div
+
      class="txt-small global-flex"
+
      style:gap="0.25rem"
+
      style:white-space="nowrap">
+
      <Icon name="revision" />
+
      {patch.revisionCount}
    </div>
-
  {/if}
+
  </div>
</div>
added src/components/Revision.svelte
@@ -0,0 +1,337 @@
+
<script lang="ts">
+
  import type { Author } from "@bindings/cob/Author";
+
  import type { Config } from "@bindings/config/Config";
+
  import type { Diff } from "@bindings/diff/Diff";
+
  import type { Embed } from "@bindings/cob/thread/Embed";
+
  import type { Revision } from "@bindings/cob/patch/Revision";
+
  import type { Thread } from "@bindings/cob/thread/Thread";
+

+
  import partial from "lodash/partial";
+
  import { tick } from "svelte";
+

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

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

+
  interface Props {
+
    rid: string;
+
    repoDelegates: Author[];
+
    patchId: string;
+
    revision: Revision;
+
    config: Config;
+
    reload: () => Promise<void>;
+
  }
+

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

+
  let focusReply: boolean = $state(false);
+
  let hideChanges = $state(false);
+
  let hideDiscussion = $state(false);
+
  let topLevelReplyOpen = $state(false);
+

+
  const threads = $derived(
+
    ((revision.discussion &&
+
      revision.discussion
+
        .filter(
+
          comment =>
+
            (comment.id !== revision.id && !comment.replyTo) ||
+
            comment.replyTo === revision.id,
+
        )
+
        .map(thread => {
+
          return {
+
            root: thread,
+
            replies:
+
              revision.discussion &&
+
              revision.discussion
+
                .filter(comment => comment.replyTo === thread.id)
+
                .sort((a, b) => a.edits[0].timestamp - b.edits[0].timestamp),
+
          };
+
        }, [])) as Thread[]) || [],
+
  );
+

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

+
    hideDiscussion = false;
+
    hideChanges = false;
+
  });
+

+
  async function editRevision(
+
    revisionId: string,
+
    description: string,
+
    embeds: Embed[],
+
  ) {
+
    try {
+
      await invoke("edit_patch", {
+
        rid: rid,
+
        cobId: patchId,
+
        action: {
+
          type: "revision.edit",
+
          revision: revisionId,
+
          description,
+
          embeds,
+
        },
+
        opts: { announce: $nodeRunning && $announce },
+
      });
+
    } catch (error) {
+
      console.error("Editing revision failed: ", error);
+
    } finally {
+
      await reload();
+
    }
+
  }
+

+
  async function reactOnRevision(
+
    publicKey: string,
+
    revisionId: string,
+
    authors: Author[],
+
    reaction: string,
+
  ) {
+
    try {
+
      await invoke("edit_patch", {
+
        rid: rid,
+
        cobId: patchId,
+
        action: {
+
          type: "revision.react",
+
          revision: revisionId,
+
          reaction,
+
          active: !authors.find(
+
            ({ did }) => publicKeyFromDid(did) === publicKey,
+
          ),
+
        },
+
        opts: { announce: $nodeRunning && $announce },
+
      });
+
    } catch (error) {
+
      console.error("Editing reactions failed", error);
+
    } finally {
+
      await reload();
+
    }
+
  }
+

+
  async function editComment(commentId: string, body: string, embeds: Embed[]) {
+
    try {
+
      await invoke("edit_patch", {
+
        rid: rid,
+
        cobId: patchId,
+
        action: {
+
          type: "revision.comment.edit",
+
          comment: commentId,
+
          body,
+
          revision: revision.id,
+
          embeds,
+
        },
+
        opts: { announce: $nodeRunning && $announce },
+
      });
+
    } catch (error) {
+
      console.error("Eediting comment failed: ", error);
+
    } finally {
+
      await reload();
+
    }
+
  }
+

+
  async function createReply(replyTo: string, body: string, embeds: Embed[]) {
+
    try {
+
      await invoke("create_patch_comment", {
+
        rid: rid,
+
        new: { id: patchId, body, embeds, replyTo, revision: revision.id },
+
        opts: { announce: $nodeRunning && $announce },
+
      });
+
    } catch (error) {
+
      console.error("Creating reply failed", error);
+
    } finally {
+
      await reload();
+
    }
+
  }
+

+
  async function reactOnComment(
+
    publicKey: string,
+
    commentId: string,
+
    authors: Author[],
+
    reaction: string,
+
  ) {
+
    try {
+
      await invoke("edit_patch", {
+
        rid: rid,
+
        cobId: patchId,
+
        action: {
+
          type: "revision.comment.react",
+
          comment: commentId,
+
          reaction,
+
          revision: revision.id,
+
          active: !authors.find(
+
            ({ did }) => publicKeyFromDid(did) === publicKey,
+
          ),
+
        },
+
        opts: { announce: $nodeRunning && $announce },
+
      });
+
    } catch (error) {
+
      console.error("Editing comment reactions failed", error);
+
    } finally {
+
      await reload();
+
    }
+
  }
+

+
  async function createComment(body: string, embeds: Embed[]) {
+
    try {
+
      await invoke("create_patch_comment", {
+
        rid: rid,
+
        new: { id: patchId, body, embeds, revision: revision.id },
+
        opts: { announce: $nodeRunning && $announce },
+
      });
+
    } catch (error) {
+
      console.error("Creating comment failed: ", error);
+
    } finally {
+
      await reload();
+
    }
+
  }
+

+
  async function toggleReply() {
+
    topLevelReplyOpen = !topLevelReplyOpen;
+
    if (!topLevelReplyOpen) {
+
      return;
+
    }
+

+
    await tick();
+
    scrollIntoView(`reply-${patchId}`, {
+
      behavior: "smooth",
+
      block: "center",
+
    });
+
    focusReply = true;
+
  }
+

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

+
<style>
+
  .patch-body {
+
    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. */
+
  .patch-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;
+
  }
+
  .hide {
+
    display: none;
+
  }
+
  .connector {
+
    width: 2px;
+
    height: 1rem;
+
    margin-left: 1.25rem;
+
    background-color: var(--color-background-float);
+
  }
+
</style>
+

+
<div class="txt-small patch-body">
+
  <CommentComponent
+
    caption="opened"
+
    {rid}
+
    id={patchId}
+
    lastEdit={revision.description.length > 1
+
      ? revision.description.at(-1)
+
      : undefined}
+
    author={revision.author}
+
    reactions={revision.reactions}
+
    timestamp={revision.timestamp}
+
    body={revision.description.slice(-1)[0].body}
+
    reactOnComment={partial(reactOnRevision, config.publicKey, revision.id)}
+
    editComment={roles.isDelegateOrAuthor(
+
      config.publicKey,
+
      repoDelegates.map(delegate => delegate.did),
+
      revision.author.did,
+
    ) && partial(editRevision, revision.id)}>
+
    {#snippet actions()}
+
      <Icon name="reply" onclick={toggleReply} />
+
    {/snippet}
+
  </CommentComponent>
+
</div>
+

+
<div style:margin="1rem 0">
+
  <!-- svelte-ignore a11y_click_events_have_key_events -->
+
  <div
+
    role="button"
+
    tabindex="0"
+
    class="txt-semibold global-flex"
+
    style:margin-bottom="1rem"
+
    style:cursor="pointer"
+
    onclick={() => (hideDiscussion = !hideDiscussion)}>
+
    <Icon name={hideDiscussion ? "chevron-right" : "chevron-down"} />Discussion
+
  </div>
+
  <div class:hide={hideDiscussion}>
+
    {#each threads as thread}
+
      <ThreadComponent
+
        {thread}
+
        {rid}
+
        canEditComment={partial(
+
          roles.isDelegateOrAuthor,
+
          config.publicKey,
+
          repoDelegates.map(delegate => delegate.did),
+
        )}
+
        editComment={partial(editComment)}
+
        createReply={partial(createReply)}
+
        reactOnComment={partial(reactOnComment, config.publicKey)} />
+
      <div class="connector"></div>
+
    {/each}
+

+
    <div id={`reply-${patchId}`}>
+
      <CommentToggleInput
+
        disallowEmptyBody
+
        {rid}
+
        focus={focusReply}
+
        onexpand={toggleReply}
+
        onclose={topLevelReplyOpen
+
          ? () => (topLevelReplyOpen = false)
+
          : undefined}
+
        placeholder="Leave a comment"
+
        submit={partial(createComment)} />
+
    </div>
+
  </div>
+
</div>
+

+
<!-- svelte-ignore a11y_click_events_have_key_events -->
+
<div
+
  role="button"
+
  tabindex="0"
+
  class="txt-semibold global-flex"
+
  style:margin-bottom={hideChanges ? undefined : "1rem"}
+
  style:cursor="pointer"
+
  onclick={() => (hideChanges = !hideChanges)}>
+
  <Icon name={hideChanges ? "chevron-right" : "chevron-down"} />Changes
+
</div>
+
<div class:hide={hideChanges}>
+
  {#await loadHighlightedDiff(rid, revision.base, revision.head)}
+
    <span class="txt-small">Loading…</span>
+
  {:then diff}
+
    <Changeset {diff} repoId={rid} />
+
  {/await}
+
</div>
added src/components/RevisionBadges.svelte
@@ -0,0 +1,29 @@
+
<script lang="ts">
+
  import type { Revision } from "@bindings/cob/patch/Revision";
+

+
  interface Props {
+
    revision: Revision;
+
    revisions: Revision[];
+
  }
+

+
  /* eslint-disable prefer-const */
+
  let { revision, revisions }: Props = $props();
+
  /* eslint-enable prefer-const */
+
</script>
+

+
{#if revision.id === revisions.slice(-1)[0].id}
+
  <span
+
    class="global-counter"
+
    style:height="22px"
+
    style:color="var(--color-foreground-contrast)">
+
    Latest
+
  </span>
+
{/if}
+
{#if revision.id === revisions[0].id}
+
  <span
+
    class="global-counter"
+
    style:height="22px"
+
    style:color="var(--color-foreground-contrast)">
+
    Initial
+
  </span>
+
{/if}
added src/components/RevisionSelector.svelte
@@ -0,0 +1,127 @@
+
<script lang="ts">
+
  import type { Patch } from "@bindings/cob/patch/Patch";
+
  import type { Revision } from "@bindings/cob/patch/Revision";
+

+
  import uniqBy from "lodash/uniqBy";
+
  import orderBy from "lodash/orderBy";
+

+
  import { authorForNodeId, formatOid } from "@app/lib/utils";
+

+
  import Border from "./Border.svelte";
+
  import DropdownList from "./DropdownList.svelte";
+
  import DropdownListItem from "./DropdownListItem.svelte";
+
  import Icon from "./Icon.svelte";
+
  import NakedButton from "./NakedButton.svelte";
+
  import NodeId from "./NodeId.svelte";
+
  import Popover, { closeFocused } from "./Popover.svelte";
+
  import RevisionBadges from "./RevisionBadges.svelte";
+

+
  interface Props {
+
    patch: Patch;
+
    revisions: Revision[];
+
    selectedRevision: Revision;
+
    selectRevision: (revision: Revision) => void;
+
  }
+

+
  /* eslint-disable prefer-const */
+
  let { patch, revisions, selectedRevision, selectRevision }: Props = $props();
+
  /* eslint-enable prefer-const */
+

+
  const revisionAuthors = $derived(
+
    orderBy(
+
      uniqBy(
+
        revisions.map(r => {
+
          return r.author;
+
        }),
+
        "did",
+
      ),
+
      [
+
        o => {
+
          return o.did === patch.author.did;
+
        },
+
      ],
+
      ["desc"],
+
    ),
+
  );
+
</script>
+

+
<style>
+
  .dropdown-group:not(:last-of-type) {
+
    margin-bottom: 1rem;
+
  }
+
  .icon {
+
    min-width: 16px;
+
  }
+
</style>
+

+
<Popover popoverPadding="0" popoverPositionTop="2.5rem" popoverPositionLeft="0">
+
  {#snippet toggle(onclick)}
+
    <NakedButton variant="ghost" {onclick}>
+
      <Icon name="chevron-down" />
+
    </NakedButton>
+
  {/snippet}
+

+
  {#snippet popover()}
+
    <Border variant="ghost">
+
      <div style:max-width="20rem" style:padding-top="0.5rem">
+
        {#each revisionAuthors as author}
+
          <div class="dropdown-group">
+
            <div style:padding-left="0.5rem" style:padding-bottom="0.5rem">
+
              <NodeId {...authorForNodeId(author)} />
+
            </div>
+
            <DropdownList
+
              items={orderBy(
+
                revisions.filter(r => {
+
                  return r.author.did === author.did;
+
                }),
+
                "timestamp",
+
                ["desc"],
+
              )}>
+
              {#snippet item(revision)}
+
                <DropdownListItem
+
                  selected={revision.id === selectedRevision.id}
+
                  onclick={() => {
+
                    closeFocused();
+
                    selectRevision(revision);
+
                  }}>
+
                  <div class="global-flex txt-overflow">
+
                    <div class="icon">
+
                      {#if patch.state.status === "merged" && patch.state.revision === revision.id}
+
                        <div style:color="var(--color-fill-primary)">
+
                          <Icon name="merge" />
+
                        </div>
+
                      {:else if revision.reviews && revision.reviews.length > 0 && revision.reviews.every( r => {
+
                            return r.verdict === "accept";
+
                          }, )}
+
                        <div style:color="var(--color-fill-success)">
+
                          <Icon name="comment-checkmark" />
+
                        </div>
+
                      {:else if revision.reviews && revision.reviews.length > 0 && revision.reviews.every( r => {
+
                            return r.verdict === "reject";
+
                          }, )}
+
                        <div style:color="var(--color-foreground-red)">
+
                          <Icon name="comment-cross" />
+
                        </div>
+
                      {:else if revision.reviews && revision.reviews.length}
+
                        <div style:color="var(--color-foreground-dim)">
+
                          <Icon name="none" />
+
                        </div>
+
                      {/if}
+
                    </div>
+
                    <span class="global-oid">
+
                      {formatOid(revision.id)}
+
                    </span>
+
                    <RevisionBadges {revision} {revisions} />
+
                    <span class="txt-overflow">
+
                      {revision.description[0].body}
+
                    </span>
+
                  </div>
+
                </DropdownListItem>
+
              {/snippet}
+
            </DropdownList>
+
          </div>
+
        {/each}
+
      </div>
+
    </Border>
+
  {/snippet}
+
</Popover>
added src/components/Tab.svelte
@@ -0,0 +1,76 @@
+
<script lang="ts">
+
  import type { Snippet } from "svelte";
+

+
  interface Props {
+
    children: Snippet;
+
    onclick?: () => void;
+
    disabled?: boolean;
+
    active?: boolean;
+
    flatLeft?: boolean;
+
    flatRight?: boolean;
+
  }
+

+
  const {
+
    children,
+
    onclick = undefined,
+
    disabled = false,
+
    active = false,
+
    flatLeft = false,
+
    flatRight = false,
+
  }: Props = $props();
+
</script>
+

+
<style>
+
  .container {
+
    white-space: nowrap;
+

+
    -webkit-touch-callout: none;
+
    -webkit-user-select: none;
+
    user-select: none;
+
    display: flex;
+
    flex-direction: row;
+
    font-size: var(--font-size-small);
+
  }
+

+
  .wrapper {
+
    position: relative;
+
    display: flex;
+
    gap: 0.5rem;
+
    padding: 5px 0;
+
    align-items: center;
+
  }
+

+
  .active {
+
    font-weight: var(--font-weight-semibold);
+
    color: var(--color-foreground-emphasized);
+
  }
+

+
  .wrapper.active::after {
+
    position: absolute;
+
    z-index: 1;
+
    content: " ";
+
    background-color: var(--color-fill-secondary);
+
    height: 2px;
+
    bottom: -4px;
+
    width: 100%;
+
  }
+

+
  .container.disabled {
+
    color: var(--color-foreground-disabled);
+
  }
+
</style>
+

+
<!-- svelte-ignore a11y_click_events_have_key_events -->
+
<div
+
  class="container"
+
  style:cursor={!disabled ? "pointer" : "default"}
+
  class:disabled
+
  class:flat-right={flatRight}
+
  class:flat-left={flatLeft}
+
  onclick={!disabled ? onclick : undefined}
+
  role="button"
+
  tabindex="0">
+
  <div class="wrapper" class:active>
+
    {@render children()}
+
  </div>
+
</div>
modified src/components/Thread.svelte
@@ -80,6 +80,7 @@

  .top-level-comment {
    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. */
modified src/views/repo/Patch.svelte
@@ -2,8 +2,6 @@
  import type { Action } from "@bindings/cob/patch/Action";
  import type { Author } from "@bindings/cob/Author";
  import type { Config } from "@bindings/config/Config";
-
  import type { Diff } from "@bindings/diff/Diff";
-
  import type { Embed } from "@bindings/cob/thread/Embed";
  import type { Operation } from "@bindings/cob/Operation";
  import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery";
  import type { Patch } from "@bindings/cob/patch/Patch";
@@ -11,25 +9,18 @@
  import type { RepoInfo } from "@bindings/repo/RepoInfo";
  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 {
    formatOid,
    patchStatusBackgroundColor,
    patchStatusColor,
-
    publicKeyFromDid,
  } from "@app/lib/utils";
  import { invoke } from "@app/lib/invoke";
  import { nodeRunning } from "@app/lib/events";

-
  import { announce } from "@app/components/AnnounceSwitch.svelte";
-

  import AssigneeInput from "@app/components/AssigneeInput.svelte";
  import Border from "@app/components/Border.svelte";
-
  import Button from "@app/components/Button.svelte";
-
  import Changeset from "@app/components/Changeset.svelte";
-
  import CommentComponent from "@app/components/Comment.svelte";
  import CopyableId from "@app/components/CopyableId.svelte";
  import Icon from "@app/components/Icon.svelte";
  import InlineTitle from "@app/components/InlineTitle.svelte";
@@ -39,7 +30,11 @@
  import PatchStateButton from "@app/components/PatchStateButton.svelte";
  import PatchTeaser from "@app/components/PatchTeaser.svelte";
  import PatchTimeline from "@app/components/PatchTimeline.svelte";
+
  import RevisionBadges from "@app/components/RevisionBadges.svelte";
+
  import RevisionComponent from "@app/components/Revision.svelte";
+
  import RevisionSelector from "@app/components/RevisionSelector.svelte";
  import Sidebar from "@app/components/Sidebar.svelte";
+
  import Tab from "@app/components/Tab.svelte";
  import TextInput from "@app/components/TextInput.svelte";

  interface Props {
@@ -72,9 +67,9 @@
  let updatedTitle = $state("");
  let labelSaveInProgress: boolean = $state(false);
  let assigneesSaveInProgress: boolean = $state(false);
-
  let tab: "patch" | "revisions" = $state("patch");
-

+
  let tab: "patch" | "revisions" | "timeline" = $state("patch");
  let hideTimeline = $state(false);
+
  let selectedRevision: Revision = $state(revisions.slice(-1)[0]);

  $effect(() => {
    items = patches.content;
@@ -89,23 +84,12 @@
    tab = "patch";
    editingTitle = false;
    updatedTitle = patch.title;
+
    selectedRevision = revisions.slice(-1)[0];
    hideTimeline = false;
  });

  const project = $derived(repo.payloads["xyz.radicle.project"]!);

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

  async function loadPatch(rid: string, patchId: string) {
    patch = await invoke<Patch>("patch_by_id", {
      rid: rid,
@@ -121,17 +105,29 @@
    });
  }

-
  async function loadMoreSecondColumn() {
-
    if (more) {
-
      const p = await invoke<PaginatedQuery<Patch[]>>("list_patches", {
-
        rid: repo.rid,
-
        skip: cursor + 20,
-
        take: 20,
-
      });
+
  async function editTitle(rid: string, patchId: string, title: string) {
+
    if (patch.title === updatedTitle) {
+
      editingTitle = false;
+
      return;
+
    }

-
      cursor = p.cursor;
-
      more = p.more;
-
      items = [...items, ...p.content];
+
    try {
+
      await invoke("edit_patch", {
+
        rid,
+
        cobId: patchId,
+
        action: {
+
          id: patchId,
+
          type: "edit",
+
          title,
+
          target: "delegates",
+
        },
+
        opts: { announce: $nodeRunning && $announce },
+
      });
+
      editingTitle = false;
+
    } catch (error) {
+
      console.error("Editing title failed: ", error);
+
    } finally {
+
      await reload();
    }
  }

@@ -175,82 +171,6 @@
    }
  }

-
  async function reload() {
-
    [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,
-
      }),
-
      invoke<Patch>("patch_by_id", {
-
        rid: repo.rid,
-
        id: patch.id,
-
      }),
-
      invoke<Revision[]>("revisions_by_patch", {
-
        rid: repo.rid,
-
        id: patch.id,
-
      }),
-
      invoke<Operation<Action>[]>("activity_by_patch", {
-
        rid: repo.rid,
-
        id: patch.id,
-
      }),
-
    ]);
-
  }
-

-
  async function editRevision(
-
    revisionId: string,
-
    description: string,
-
    embeds: Embed[],
-
  ) {
-
    try {
-
      await invoke("edit_patch", {
-
        rid: repo.rid,
-
        cobId: patch.id,
-
        action: {
-
          type: "revision.edit",
-
          revision: revisionId,
-
          description,
-
          embeds,
-
        },
-
        opts: { announce: $nodeRunning && $announce },
-
      });
-
    } catch (error) {
-
      console.error("Patch revision editing failed: ", error);
-
    } finally {
-
      await reload();
-
    }
-
  }
-

-
  async function reactOnRevision(
-
    publicKey: string,
-
    revisionId: string,
-
    authors: Author[],
-
    reaction: string,
-
  ) {
-
    try {
-
      await invoke("edit_patch", {
-
        rid: repo.rid,
-
        cobId: patch.id,
-
        action: {
-
          type: "revision.react",
-
          revision: revisionId,
-
          reaction,
-
          active: !authors.find(
-
            ({ did }) => publicKeyFromDid(did) === publicKey,
-
          ),
-
        },
-
        opts: { announce: $nodeRunning && $announce },
-
      });
-
    } catch (error) {
-
      console.error("Editing reactions failed", error);
-
    } finally {
-
      await reload();
-
    }
-
  }
-

  async function saveState(state: Patch["state"]) {
    try {
      await invoke("edit_patch", {
@@ -266,37 +186,50 @@
        status = state["status"];
      }
    } catch (error) {
-
      console.error("Changing patch state failed", error);
+
      console.error("Changing state failed", error);
    } finally {
      await reload();
    }
  }

-
  async function editTitle(id: string, title: string) {
-
    if (patch.title === updatedTitle) {
-
      editingTitle = false;
-
      return;
-
    }
-

-
    try {
-
      await invoke("edit_patch", {
+
  async function loadMoreSecondColumn() {
+
    if (more) {
+
      const p = await invoke<PaginatedQuery<Patch[]>>("list_patches", {
        rid: repo.rid,
-
        cobId: patch.id,
-
        action: {
-
          id,
-
          type: "edit",
-
          title,
-
          target: "delegates",
-
        },
-
        opts: { announce: $nodeRunning && $announce },
+
        skip: cursor + 20,
+
        take: 20,
      });
-
      editingTitle = false;
-
    } catch (error) {
-
      console.error("Patch title editing failed: ", error);
-
    } finally {
-
      await reload();
+

+
      cursor = p.cursor;
+
      more = p.more;
+
      items = [...items, ...p.content];
    }
  }
+

+
  async function reload() {
+
    [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,
+
      }),
+
      invoke<Patch>("patch_by_id", {
+
        rid: repo.rid,
+
        id: patch.id,
+
      }),
+
      invoke<Revision[]>("revisions_by_patch", {
+
        rid: repo.rid,
+
        id: patch.id,
+
      }),
+
      invoke<Operation<Action>[]>("activity_by_patch", {
+
        rid: repo.rid,
+
        id: patch.id,
+
      }),
+
    ]);
+
  }
</script>

<style>
@@ -334,22 +267,6 @@
    padding: 1rem 1rem 1rem 0;
  }

-
  .patch-body {
-
    margin: 1rem 0;
-
    position: relative;
-
  }
-
  /* We put the background and clip-path in a separate element to prevent
-
     popovers being clipped in the main element. */
-
  .patch-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;
-
  }
  .metadata-divider {
    width: 2px;
    background-color: var(--color-fill-ghost);
@@ -369,7 +286,7 @@
    margin-bottom: 0.5rem;
    color: var(--color-foreground-dim);
  }
-
  .hide-timeline {
+
  .hide {
    display: none;
  }
</style>
@@ -424,7 +341,7 @@
            autofocus
            onSubmit={async () => {
              if (updatedTitle.trim().length > 0) {
-
                await editTitle(patch.id, updatedTitle);
+
                await editTitle(repo.rid, patch.id, updatedTitle);
              }
            }}
            onDismiss={() => {
@@ -436,7 +353,7 @@
              name="checkmark"
              onclick={async () => {
                if (updatedTitle.trim().length > 0) {
-
                  await editTitle(patch.id, updatedTitle);
+
                  await editTitle(repo.rid, patch.id, updatedTitle);
                }
              }} />
            <Icon
@@ -470,7 +387,6 @@
        </div>
      {/if}
    </div>
-

    <Border variant="ghost" styleGap="0">
      <div class="metadata-section" style:min-width="8rem">
        <div class="metadata-section-title">Status</div>
@@ -506,78 +422,107 @@
      </div>
    </Border>

-
    <div class="global-flex" style:gap="0" style:margin-top="1rem">
-
      <Button
-
        flatRight
-
        active={tab === "patch"}
-
        variant="ghost"
-
        onclick={() => {
-
          tab = "patch";
-
        }}>
-
        Patch
-
      </Button>
-

-
      <Button
-
        flatLeft
-
        variant="ghost"
-
        active={tab === "revisions"}
-
        onclick={() => {
-
          tab = "revisions";
-
        }}>
-
        Revision: {formatOid(revisions.slice(-1)[0].id)}
-
        <span class="global-counter" style:height="22px">latest</span>
-
      </Button>
+
    <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="22px"
+
              style:color="var(--color-foreground-contrast)">
+
              Initial
+
            </span>
+
          </Tab>
+
          {#if revisions.length > 1}
+
            <Tab
+
              active={tab === "revisions"}
+
              onclick={() => {
+
                tab = "revisions";
+
              }}>
+
              {formatOid(selectedRevision.id)}
+
              <RevisionBadges revision={selectedRevision} {revisions} />
+
            </Tab>
+

+
            <RevisionSelector
+
              {patch}
+
              {revisions}
+
              {selectedRevision}
+
              selectRevision={rev => {
+
                selectedRevision = rev;
+
                tab = "revisions";
+
              }} />
+
          {/if}
+

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

-
    {#if tab === "patch"}
-
      <div class="txt-small patch-body">
-
        <CommentComponent
-
          caption="opened"
+
    <Border
+
      variant="ghost"
+
      flatTop
+
      styleWidth="100%"
+
      stylePadding="1rem"
+
      styleMinWidth="0"
+
      styleDisplay="block"
+
      styleFlexDirection="column"
+
      styleAlignItems="flex-start">
+
      {#if tab === "patch"}
+
        <RevisionComponent
          rid={repo.rid}
-
          id={patch.id}
-
          lastEdit={revisions[0].description.length > 1
-
            ? revisions[0].description.at(-1)
-
            : undefined}
-
          author={revisions[0].author}
-
          reactions={revisions[0].reactions}
-
          timestamp={revisions[0].timestamp}
-
          body={revisions[0].description.slice(-1)[0].body}
-
          reactOnComment={partial(
-
            reactOnRevision,
-
            config.publicKey,
-
            revisions[0].id,
-
          )}
-
          editComment={roles.isDelegateOrAuthor(
-
            config.publicKey,
-
            repo.delegates.map(delegate => delegate.did),
-
            revisions[0].author.did,
-
          ) && partial(editRevision, revisions[0].id)}>
-
        </CommentComponent>
-
      </div>
-

-
      <div>
-
        <!-- svelte-ignore a11y_click_events_have_key_events -->
-
        <div
-
          role="button"
-
          tabindex="0"
-
          class="txt-semibold global-flex"
-
          style:margin-bottom="1rem"
-
          style:cursor="pointer"
-
          onclick={() => (hideTimeline = !hideTimeline)}>
-
          <Icon
-
            name={hideTimeline ? "chevron-right" : "chevron-down"} />Timeline
-
        </div>
-
        <div class:hide-timeline={hideTimeline}>
-
          <PatchTimeline {activity} patchId={patch.id} />
-
        </div>
-
      </div>
-
    {:else}
-
      {@const revision = revisions.slice(-1)[0]}
-
      {#await loadHighlightedDiff(repo.rid, revision.base, revision.head) then diff}
-
        <div style:margin-top="1rem">
-
          <Changeset {diff} repoId={repo.rid} />
+
          repoDelegates={repo.delegates}
+
          patchId={patch.id}
+
          {reload}
+
          revision={revisions[0]}
+
          {config} />
+
      {:else if tab === "timeline"}
+
        <div>
+
          <!-- svelte-ignore a11y_click_events_have_key_events -->
+
          <div
+
            role="button"
+
            tabindex="0"
+
            class="txt-semibold global-flex"
+
            style:margin-bottom={hideTimeline ? undefined : "1rem"}
+
            style:cursor="pointer"
+
            onclick={() => (hideTimeline = !hideTimeline)}>
+
            <Icon
+
              name={hideTimeline ? "chevron-right" : "chevron-down"} />Timeline
+
          </div>
+
          <div class:hide={hideTimeline}>
+
            <PatchTimeline {activity} patchId={patch.id} />
+
          </div>
        </div>
-
      {/await}
-
    {/if}
+
      {:else}
+
        <RevisionComponent
+
          rid={repo.rid}
+
          repoDelegates={repo.delegates}
+
          patchId={patch.id}
+
          {reload}
+
          revision={selectedRevision}
+
          {config} />
+
      {/if}
+
    </Border>
  </div>
</Layout>