Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
radicle-explorer src views repos Cob Revision.svelte
<script lang="ts">
  import type {
    Author,
    BaseUrl,
    Comment,
    DiffResponse,
    PatchState,
    Revision,
    Verdict,
  } from "@http-client";
  import type { Timeline } from "@app/views/repos/Patch.svelte";

  import * as utils from "@app/lib/utils";
  import { HttpdClient } from "@http-client";
  import { cachedGetDiff } from "@app/views/repos/router";
  import { onMount } from "svelte";

  import CobCommitTeaser from "@app/views/repos/Cob/CobCommitTeaser.svelte";
  import CommentComponent from "@app/components/Comment.svelte";
  import DiffStatBadge from "@app/components/DiffStatBadge.svelte";
  import DropdownList from "@app/components/DropdownList.svelte";
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
  import ExpandButton from "@app/components/ExpandButton.svelte";
  import IconButton from "@app/components/IconButton.svelte";
  import Icon from "@app/components/Icon.svelte";
  import JobCob from "@app/components/JobCob.svelte";
  import Link from "@app/components/Link.svelte";
  import Loading from "@app/components/Loading.svelte";
  import Markdown from "@app/components/Markdown.svelte";
  import NodeId from "@app/components/NodeId.svelte";
  import Popover from "@app/components/Popover.svelte";
  import Reactions from "@app/components/Reactions.svelte";
  import Thread from "@app/components/Thread.svelte";
  import Id from "@app/components/Id.svelte";

  export let baseUrl: BaseUrl;
  export let initiallyExpanded: boolean = false;
  export let rawPath: (commit?: string) => string;
  export let patchId: string;
  export let patchState: PatchState;
  export let repoId: string;
  export let revisionBase: string;
  export let revisionId: string;
  export let revisionEdits: Revision["edits"];
  export let revisionOid: string;
  export let revisionTimestamp: number;
  export let revisionReactions: Comment["reactions"];
  export let revisionAuthor: Author;
  export let revisionDescription: string;
  export let timelines: Timeline[];
  export let previousRevBase: string | undefined = undefined;
  export let previousRevId: string | undefined = undefined;
  export let previousRevOid: string | undefined = undefined;
  export let first: boolean;

  let expanded = initiallyExpanded;
  const api = new HttpdClient(baseUrl);
  const lastEdit = revisionEdits.at(-1);

  function formatVerdict(verdict?: Verdict | null) {
    switch (verdict) {
      case "accept":
        return "accepted revision";
      case "reject":
        return "rejected revision";
      default:
        return "reviewed revision";
    }
  }

  function verdictIconColor(verdict?: Verdict | null) {
    switch (verdict) {
      case "accept":
        return "var(--color-text-open)";
      case "reject":
        return "var(--color-feedback-error-text)";
      default:
        return "var(--color-text-tertiary)";
    }
  }

  function badgeColor({ status }: PatchState): string | undefined {
    if (status === "draft") {
      return "var(--color-text-tertiary)";
    } else if (status === "open") {
      return "var(--color-text-open)";
    } else if (status === "archived") {
      return "var(--color-text-archived)";
    } else if (status === "merged") {
      return "var(--color-text-merged)";
    } else {
      return "var(--color-text-open)";
    }
  }

  let response: DiffResponse | undefined = undefined;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  let error: any | undefined = undefined;
  let loading: boolean = false;

  $: fromCommit =
    previousRevBase !== revisionBase
      ? revisionBase
      : (previousRevBase ?? revisionBase);
  $: baseMismatch = previousRevBase !== revisionBase;

  onMount(async () => {
    try {
      loading = true;
      response = await cachedGetDiff(
        api.baseUrl,
        repoId,
        fromCommit,
        revisionOid,
      );
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
    } catch (err: any) {
      error = err;
    } finally {
      loading = false;
    }
  });
</script>

<style>
  .action {
    border-radius: var(--border-radius-md);
    min-height: 2.5rem;
    display: flex;
    align-items: center;
  }
  .merge {
    border: 1px solid var(--color-text-merged);
    background-color: var(--color-surface-merged);
  }
  .positive-review {
    border: 1px solid var(--color-feedback-success-border);
    background-color: var(--color-feedback-success-bg);
  }
  .comment-review {
    border: 1px solid var(--color-border-subtle);
    background-color: var(--color-surface-subtle);
  }
  .negative-review {
    border: 1px solid var(--color-feedback-error-border);
    background-color: var(--color-feedback-error-bg);
  }

  .diff-error {
    margin: 1rem 1.5rem;
  }
  .revision {
    display: flex;
    flex-direction: column;
    border-radius: var(--border-radius-sm);
  }
  .revision-box {
    border-radius: var(--border-radius-sm);
  }
  .revision-header {
    display: flex;
    align-items: center;
    justify-content: center;
    background: none;
    padding: 0.5rem;
    font: var(--txt-body-m-regular);
    height: 3rem;
  }
  .revision-name {
    display: flex;
    align-items: center;
    gap: 0.5rem;
  }
  .revision-data {
    gap: 0.5rem;
    display: flex;
    align-items: center;
    margin-left: auto;
    color: var(--color-text-tertiary);
  }
  .revision-description {
    margin-left: 2.75rem;
    padding-right: 0.5rem;
    max-width: fit-content;
  }
  .author-metadata {
    color: var(--color-text-tertiary);
    font: var(--txt-body-m-regular);
  }
  .patch-header {
    background-color: var(--color-surface-subtle);
    border-bottom: 1px solid var(--color-border-subtle);
    border-top: 1px solid var(--color-border-subtle);
    display: flex;
    flex-direction: column;
    justify-content: center;
    min-height: 2.5rem;
    padding: 0.5rem 0;
    font: var(--txt-body-m-regular);
    gap: 0.5rem;
  }
  .authorship-header {
    display: inline-flex;
    white-space: nowrap;
    flex-wrap: wrap;
    align-items: center;
    padding: 0 0.5rem;
    min-height: 1.5rem;
    gap: 0.5rem;
    font: var(--txt-body-m-regular);
  }
  .timestamp {
    font: var(--txt-body-m-regular);
    color: var(--color-text-tertiary);
  }
  .actions {
    display: flex;
    flex-direction: row;
    align-items: center;
    padding-left: 2.5rem;
    gap: 0.5rem;
  }
  .commits {
    position: relative;
    display: flex;
    flex-direction: column;
    font-size: 0.875rem;
    margin-left: 1.25rem;
    gap: 0.5rem;
    padding: 1rem 0.5rem 1rem 1rem;
    border-left: 1px solid var(--color-border-subtle);
  }
  .commit:last-of-type::after {
    content: "";
    position: absolute;
    left: -18.5px;
    top: 14px;
    bottom: -1rem;
    border-left: 4px solid var(--color-surface-base);
  }
  .expanded {
    box-shadow: 0 0 0 1px var(--color-border-subtle);
  }
  .commit-dot {
    border-radius: var(--border-radius-full);
    width: 4px;
    height: 4px;
    position: absolute;
    top: 0.625rem;
    left: -18.5px;
    background-color: var(--color-border-subtle);
  }
  .connector {
    width: 1px;
    height: 1.5rem;
    margin-left: 1.25rem;
    background-color: var(--color-border-subtle);
  }
  @media (max-width: 719.98px) {
    .revision-box {
      border-radius: 0;
    }
    .action {
      border-radius: 0;
    }
  }
</style>

<div class="revision" style:margin-bottom={expanded ? "2rem" : "0.5rem"}>
  <div class="revision-box" class:expanded>
    <div class="revision-header">
      <div class="revision-name">
        <ExpandButton {expanded} on:toggle={() => (expanded = !expanded)} />
        <span>
          Revision
          <Id id={revisionId} />
        </span>
      </div>
      <div class="revision-data">
        <span
          class="global-hide-on-mobile-down"
          title={utils.absoluteTimestamp(revisionTimestamp)}>
          {utils.formatTimestamp(revisionTimestamp)}
        </span>
        {#if loading}
          <Loading small />
        {/if}
        {#if response?.diff.stats}
          <Link
            title="Compare {utils.formatCommit(
              fromCommit,
            )}..{utils.formatCommit(revisionOid)}"
            route={{
              resource: "repo.patch",
              repo: repoId,
              node: baseUrl,
              patch: patchId,
              view: { name: "diff", fromCommit, toCommit: revisionOid },
            }}>
            {@const { insertions, deletions } = response.diff.stats}
            <DiffStatBadge hoverable {insertions} {deletions} />
          </Link>
        {/if}
        <Popover
          popoverPadding="0"
          popoverPositionTop={expanded ? "3rem" : "2.5rem"}
          popoverPositionRight="0"
          popoverBorderRadius="var(--border-radius-md)">
          <IconButton
            slot="toggle"
            let:toggle
            on:click={toggle}
            title="toggle-context-menu">
            <Icon name="ellipsis-vertical" />
          </IconButton>
          <DropdownList
            slot="popover"
            items={previousRevOid && previousRevId
              ? [revisionBase, previousRevOid]
              : [revisionBase]}>
            <Link
              let:item
              disabled={item !== revisionBase && baseMismatch}
              slot="item"
              title="Compare {utils.formatCommit(item)}..{utils.formatCommit(
                revisionOid,
              )}"
              route={{
                resource: "repo.patch",
                repo: repoId,
                node: baseUrl,
                patch: patchId,
                view: {
                  name: "diff",
                  fromCommit: item,
                  toCommit: revisionOid,
                },
              }}>
              {#if item === revisionBase}
                <DropdownListItem selected={false}>
                  <span class="compare-dropdown-item">
                    Compare to base:
                    <span class="txt-id">
                      {utils.formatObjectId(revisionBase)}
                    </span>
                  </span>
                </DropdownListItem>
              {:else if previousRevId}
                <DropdownListItem
                  selected={false}
                  disabled={baseMismatch}
                  title={baseMismatch
                    ? "Previous revision has different base"
                    : `${utils.formatCommit(item)}..${utils.formatCommit(
                        revisionOid,
                      )}`}>
                  <span class="compare-dropdown-item">
                    Compare to previous revision: <span
                      style:color="var(--color-text-brand)"
                      class="txt-id">
                      {utils.formatObjectId(previousRevId)}
                    </span>
                  </span>
                </DropdownListItem>
              {/if}
            </Link>
          </DropdownList>
        </Popover>
      </div>
    </div>
    {#if expanded}
      <div>
        <div class="patch-header">
          <div class="authorship-header">
            <div
              style:color={badgeColor(patchState)}
              style:padding="0 0.375rem">
              <Icon
                name={patchState.status === "draft"
                  ? "patch-draft"
                  : patchState.status === "merged"
                    ? "patch-merged"
                    : patchState.status === "archived"
                      ? "patch-archived"
                      : "patch"} />
            </div>
            <NodeId
              {baseUrl}
              nodeId={revisionAuthor.id}
              alias={revisionAuthor.alias} />
            {#if patchId === revisionId}
              opened this patch on base
              <Id id={revisionBase} />
            {:else}
              updated to
              <Id id={revisionId} />
              {#if previousRevBase && previousRevBase !== revisionBase}
                on base
                <Id id={revisionBase} />
              {/if}
            {/if}
            <span
              class="timestamp"
              title={utils.absoluteTimestamp(revisionTimestamp)}>
              {utils.formatTimestamp(revisionTimestamp)}
            </span>
            {#if revisionEdits.length > 1 && lastEdit}
              <div
                class="author-metadata"
                title={utils.formatEditedCaption(
                  lastEdit.author,
                  lastEdit.timestamp,
                )}>
                • edited
              </div>
            {/if}
          </div>
          {#if revisionDescription && !first}
            <div class="revision-description txt-body-m-regular">
              <Markdown
                breaks
                rawPath={rawPath(revisionBase)}
                content={revisionDescription} />
            </div>
          {/if}
          {#if revisionReactions && revisionReactions.length > 0}
            <div class="actions">
              <Reactions reactions={revisionReactions} />
            </div>
          {/if}
        </div>
        {#if loading}
          <div style:height="3.5rem">
            <Loading small />
          </div>
        {/if}
        {#if response?.commits}
          <div class="commits">
            {#each response.commits.toReversed() as commit, index}
              <div class="commit" style:position="relative">
                <div class="commit-dot"></div>
                <CobCommitTeaser {commit} {baseUrl} {repoId}>
                  {#if response.commits.length - 1 === index}
                    <div class="global-flex-item" style:margin-right="0.25rem">
                      <JobCob
                        {baseUrl}
                        rid={repoId}
                        commit={commit.id}
                        stylePopoverPositionBottom="2rem"
                        stylePopoverPositionLeft="0" />
                    </div>
                  {/if}
                </CobCommitTeaser>
              </div>
            {/each}
          </div>
        {/if}
      </div>
      {#if error}
        <div
          class="diff-error txt-code-regular"
          style:border-radius="var(--border-radius-md)">
          <ErrorMessage
            title="Failed to load diff for this revision"
            description="Make sure you are able to connect to the seed <code>{utils.baseUrlToString(
              api.baseUrl,
            )}</code>"
            {error} />
        </div>
      {/if}
    {/if}
  </div>
  {#if expanded}
    {#if timelines.length > 0}
      {#each timelines as element}
        {#if element.type === "thread"}
          <div class="connector"></div>
          <Thread
            {baseUrl}
            thread={element.inner}
            rawPath={rawPath(revisionBase)} />
        {:else if element.type === "merge"}
          <div class="connector"></div>
          <div class="action merge">
            <div class="authorship-header">
              <div style:color="var(--color-text-merged)">
                <Icon name="patch-merged" />
              </div>

              <NodeId
                {baseUrl}
                nodeId={element.inner.author.id}
                alias={element.inner.author.alias}>
              </NodeId>

              merged revision
              <Id id={element.inner.revision} />
              at commit
              <Id id={element.inner.commit} />
              <span
                class="timestamp"
                title={utils.absoluteTimestamp(element.inner.timestamp)}>
                {utils.formatTimestamp(element.inner.timestamp)}
              </span>
            </div>
          </div>
        {:else if element.type === "review"}
          {@const [author, review] = element.inner}
          <div class="connector"></div>
          <div
            class="action"
            class:comment-review={review.verdict === null}
            class:positive-review={review.verdict === "accept"}
            class:negative-review={review.verdict === "reject"}>
            <CommentComponent
              {baseUrl}
              id={review.id}
              rawPath={rawPath(revisionBase)}
              authorId={author}
              authorAlias={review.author.alias}
              timestamp={review.timestamp}
              body={review.summary ?? ""}>
              <div slot="caption">
                {formatVerdict(review.verdict)}
                <Id id={revisionId} />
              </div>
              <div slot="icon" style:color={verdictIconColor(review.verdict)}>
                {#if review.verdict === "accept"}
                  <Icon name="comment-checkmark" />
                {:else if review.verdict === "reject"}
                  <Icon name="comment-cross" />
                {:else}
                  <Icon name="comment" />
                {/if}
              </div>
            </CommentComponent>
          </div>
          {#if review.threads.length > 0}
            {#each review.threads as thread}
              <div class="connector"></div>
              <Thread {baseUrl} {thread} rawPath={rawPath(revisionBase)} />
            {/each}
          {/if}
        {/if}
      {/each}
    {/if}
    <slot />
  {/if}
</div>