Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Show canonical branches and tags in peer selector
Rūdolfs Ošiņš committed 28 days ago
commit ecbea99f564615ae6524efa1678dc18d2fadc32e
parent 42bc27c1f8a5c449382d7157271203c03e76d09c
14 files changed +631 -83
modified http-client/index.ts
@@ -3,9 +3,11 @@ import type {
  Blob,
  DiffResponse,
  Job,
+
  PeerRefs,
  Remote,
  Repo,
  RepoListQuery,
+
  TagInfo,
  Tree,
  TreeStats,
} from "./lib/repo.js";
@@ -77,6 +79,7 @@ export type {
  Merge,
  Patch,
  PatchState,
+
  PeerRefs,
  Reaction,
  Remote,
  Repo,
@@ -84,6 +87,7 @@ export type {
  Review,
  Revision,
  SeedingPolicy,
+
  TagInfo,
  Tree,
  TreeStats,
  Verdict,
modified http-client/lib/repo.ts
@@ -1,4 +1,3 @@
-
import type { ZodSchema } from "zod";
import type { Fetcher, RequestOptions } from "./fetcher.js";
import type { Commit, Commits } from "./repo/commit.js";
import type { Issue } from "./repo/issue.js";
@@ -15,6 +14,7 @@ import {
  string,
  union,
  z,
+
  ZodSchema,
} from "zod";

import {
@@ -28,6 +28,25 @@ import { issueSchema, issuesSchema } from "./repo/issue.js";
import { patchSchema, patchesSchema } from "./repo/patch.js";
import { authorSchema } from "./shared.js";

+
export type PeerRefs = {
+
  id: string;
+
  alias?: string;
+
  delegate: boolean;
+
  refs: Record<string, string>;
+
};
+

+
const tagInfoSchema = object({
+
  commit: string(),
+
  tagger: object({
+
    name: string(),
+
    email: string(),
+
    timestamp: number(),
+
  }).optional(),
+
  message: string().optional(),
+
});
+

+
export type TagInfo = z.infer<typeof tagInfoSchema>;
+

const repoSchema = object({
  rid: string(),
  payloads: object({
@@ -59,6 +78,10 @@ const repoSchema = object({
    object({ type: literal("private"), allow: optional(array(string())) }),
  ]),
  seeding: number(),
+
  refs: object({
+
    tags: record(string(), tagInfoSchema),
+
    refs: record(string(), string()),
+
  }).optional(),
});
const reposSchema = array(repoSchema);

@@ -106,16 +129,17 @@ const treeSchema = object({
  path: string(),
});

-
export type Remote = z.infer<typeof remoteSchema>;
-

-
export const remoteSchema = object({
+
const remoteSchema = object({
  id: string(),
  alias: string().optional(),
-
  heads: record(string(), string()),
  delegate: boolean(),
+
  heads: record(string(), string()),
+
  refs: record(string(), string()).optional(),
});

-
const remotesSchema = array(remoteSchema) satisfies ZodSchema<Remote[]>;
+
export type Remote = z.infer<typeof remoteSchema>;
+

+
const remotesSchema = array(remoteSchema);

export type DiffResponse = z.infer<typeof diffResponseSchema>;

modified http-client/tests/repo.test.ts
@@ -52,10 +52,6 @@ describe("repo", () => {
    await api.repo.getTree(sourceBrowsingRid, aliceMainHead, "src");
  });

-
  test("#getAllRemotes(rid)", async () => {
-
    await api.repo.getAllRemotes(sourceBrowsingRid);
-
  });
-

  test("#getRemoteByPeer(rid, peer)", async () => {
    await api.repo.getRemoteByPeer(sourceBrowsingRid, aliceRemote.substring(8));
  });
modified public/typography.css
@@ -73,11 +73,13 @@
}

[data-codefont="system"] {
+
  --txt-code-small: 400 0.75rem/1rem monospace;
  --txt-code-regular: 400 0.875rem/1.25rem monospace;
  --txt-code-semibold: 600 0.875rem/1.25rem monospace;
}

[data-codefont="jetbrains"] {
+
  --txt-code-small: 400 0.75rem/1rem "JetBrains Mono";
  --txt-code-regular: 400 0.875rem/1.25rem "JetBrains Mono";
  --txt-code-semibold: 600 0.875rem/1.25rem "JetBrains Mono";
}
@@ -158,6 +160,10 @@ html {
  font: var(--txt-body-l-semibold);
}

+
.txt-code-small {
+
  font: var(--txt-code-small);
+
}
+

.txt-code-regular {
  font: var(--txt-code-regular);
}
modified src/components/TextInput.svelte
@@ -11,6 +11,8 @@
  export let placeholder: string | undefined = undefined;
  export let value: string | undefined = undefined;

+
  export let size: "small" | "regular" = "regular";
+

  export let autofocus: boolean = false;
  export let autoselect: boolean = false;
  export let disabled: boolean = false;
@@ -76,9 +78,14 @@
    position: relative;
    flex: 1;
    align-items: center;
-
    height: var(--button-regular-height);
    background: var(--color-surface-base);
  }
+
  .wrapper.small {
+
    height: var(--button-small-height);
+
  }
+
  .wrapper.regular {
+
    height: var(--button-regular-height);
+
  }
  input {
    background: var(--color-surface-base);
    font-family: inherit;
@@ -139,7 +146,7 @@
  }
</style>

-
<div class="wrapper">
+
<div class="wrapper {size}">
  <input
    class:invalid={!valid && value}
    style:padding-right={rightContainerWidth
modified src/lib/utils.ts
@@ -266,3 +266,27 @@ export function formatQualifiedRefname(
): string {
  return peer ? `refs/namespaces/${peer}/refs/heads/${refname}` : refname;
}
+

+
export function getBranchesFromRefs(
+
  refs: Record<string, string>,
+
): Record<string, string> {
+
  const branches: Record<string, string> = {};
+
  for (const [name, oid] of Object.entries(refs)) {
+
    if (name.startsWith("refs/heads/")) {
+
      branches[name.slice("refs/heads/".length)] = oid;
+
    }
+
  }
+
  return branches;
+
}
+

+
export function getTagsFromRefs(
+
  refs: Record<string, string>,
+
): Record<string, string> {
+
  const tags: Record<string, string> = {};
+
  for (const [name, oid] of Object.entries(refs)) {
+
    if (name.startsWith("refs/tags/")) {
+
      tags[name.slice("refs/tags/".length)] = oid;
+
    }
+
  }
+
  return tags;
+
}
modified src/views/repos/History.svelte
@@ -2,8 +2,8 @@
  import type {
    BaseUrl,
    CommitHeader,
+
    PeerRefs,
    Repo,
-
    Remote,
    SeedingPolicy,
    Tree,
  } from "@http-client";
@@ -31,7 +31,7 @@
  export let commit: string;
  export let commitHeaders: CommitHeader[];
  export let peer: string | undefined;
-
  export let peers: Remote[];
+
  export let peers: PeerRefs[];
  export let repo: Repo;
  export let revision: string | undefined;
  export let tree: Tree;
modified src/views/repos/Source.svelte
@@ -1,8 +1,8 @@
<script lang="ts">
  import type {
    BaseUrl,
+
    PeerRefs,
    Repo,
-
    Remote,
    SeedingPolicy,
    Tree,
  } from "@http-client";
@@ -28,7 +28,7 @@
  export let commit: string;
  export let path: string;
  export let peer: string | undefined;
-
  export let peers: Remote[];
+
  export let peers: PeerRefs[];
  export let repo: Repo;
  export let rawPath: (commit?: string) => string;
  export let revision: string | undefined;
modified src/views/repos/Source/Header.svelte
@@ -5,7 +5,7 @@

<script lang="ts">
  import type { RepoRoute } from "../router";
-
  import type { BaseUrl, Repo, Remote, Tree } from "@http-client";
+
  import type { BaseUrl, PeerRefs, Repo, Tree } from "@http-client";
  import type { ComponentProps } from "svelte";

  import { HttpdClient } from "@http-client";
@@ -23,7 +23,7 @@
  export let historyLinkActive: boolean;
  export let node: BaseUrl;
  export let peer: string | undefined;
-
  export let peers: Remote[];
+
  export let peers: PeerRefs[];
  export let repo: Repo;
  export let baseRoute: Extract<
    RepoRoute,
modified src/views/repos/Source/PeerBranchSelector.svelte
@@ -1,14 +1,23 @@
<script lang="ts">
  import type { RepoRoute } from "@app/views/repos/router";
-
  import type { Repo, Remote } from "@http-client";
+
  import type { Repo, PeerRefs } from "@http-client";

  import fuzzysort from "fuzzysort";
  import orderBy from "lodash/orderBy";
-
  import { formatCommit, formatNodeId } from "@app/lib/utils";
+
  import {
+
    absoluteTimestamp,
+
    formatCommit,
+
    formatNodeId,
+
    formatTimestamp,
+
    getBranchesFromRefs,
+
    getTagsFromRefs,
+
    gravatarURL,
+
  } from "@app/lib/utils";

  import Badge from "@app/components/Badge.svelte";
  import Button from "@app/components/Button.svelte";
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
+
  import HoverPopover from "@app/components/HoverPopover.svelte";
  import Icon from "@app/components/Icon.svelte";
  import Link from "@app/components/Link.svelte";
  import Peer from "./PeerBranchSelector/Peer.svelte";
@@ -22,7 +31,7 @@
  >;
  export let onCanonical: boolean;
  export let peer: string | undefined;
-
  export let peers: Remote[];
+
  export let peers: PeerRefs[];
  export let repo: Repo;
  export let selectedBranch: string | undefined;

@@ -33,22 +42,72 @@
    "</span>",
  ];
  let searchInput = "";
+
  let selectedTab: "branches" | "tags" = "branches";

-
  const searchElements = [
+
  type SearchElement = {
+
    peer?: { id: string; alias?: string; delegate: boolean };
+
    revision: string;
+
    head: string;
+
    type: "branch" | "tag";
+
  };
+

+
  $: canonicalBranchesMap = getBranchesFromRefs(repo.refs?.refs ?? {});
+
  $: canonicalTagsInfo = Object.fromEntries(
+
    Object.entries(repo.refs?.tags ?? {}).map(([name, info]) => [
+
      name.slice("refs/tags/".length),
+
      info,
+
    ]),
+
  );
+

+
  $: branchElements = [
    {
      peer: undefined,
      revision: repo.payloads["xyz.radicle.project"].data.defaultBranch,
      head: repo.payloads["xyz.radicle.project"].meta.head,
+
      type: "branch",
    },
-
    ...peers.flatMap(peer =>
-
      Object.entries(peer.heads).map(([name, head]) => ({
-
        peer: { id: peer.id, alias: peer.alias, delegate: peer.delegate },
+
    ...Object.entries(canonicalBranchesMap)
+
      .filter(
+
        ([branchName]) =>
+
          branchName !==
+
          repo.payloads["xyz.radicle.project"].data.defaultBranch,
+
      )
+
      .map(([name, head]) => ({
+
        peer: undefined,
        revision: name,
        head,
+
        type: "branch",
      })),
-
    ),
-
  ];
+
    ...peers.flatMap(peer => {
+
      const peerBranches = getBranchesFromRefs(peer.refs);
+
      return Object.entries(peerBranches).map(([name, head]) => ({
+
        peer: { id: peer.id, alias: peer.alias, delegate: peer.delegate },
+
        revision: name,
+
        head,
+
        type: "branch",
+
      }));
+
    }),
+
  ] as SearchElement[];
+

+
  $: tagElements = [
+
    ...Object.entries(canonicalTagsInfo).map(([name, info]) => ({
+
      peer: undefined,
+
      revision: name,
+
      head: info.commit,
+
      type: "tag",
+
    })),
+
    ...peers.flatMap(peer => {
+
      const peerTags = getTagsFromRefs(peer.refs);
+
      return Object.entries(peerTags).map(([name, head]) => ({
+
        peer: { id: peer.id, alias: peer.alias, delegate: peer.delegate },
+
        revision: name,
+
        head,
+
        type: "tag",
+
      }));
+
    }),
+
  ] as SearchElement[];

+
  $: searchElements = selectedTab === "branches" ? branchElements : tagElements;
  $: selectedPeer = peers.find(p => p.id === peer);
  $: searchResults = fuzzysort.go(searchInput, searchElements, {
    keys: ["peer.alias", "revision"],
@@ -58,6 +117,81 @@
      (r.obj.peer === undefined ? 10 : 1) *
      (r.obj.peer?.alias ? 2 : 1),
  });
+
  $: canonicalTags = Object.entries(canonicalTagsInfo).sort(
+
    ([nameA, infoA], [nameB, infoB]) => {
+
      const tsA = infoA.tagger?.timestamp ?? 0;
+
      const tsB = infoB.tagger?.timestamp ?? 0;
+
      if (tsA !== tsB) return tsB - tsA;
+
      return nameB.localeCompare(nameA);
+
    },
+
  );
+
  $: canonicalBranches = Object.entries(canonicalBranchesMap).filter(
+
    ([branchName]) =>
+
      branchName !== repo.payloads["xyz.radicle.project"].data.defaultBranch,
+
  );
+
  $: hasTags =
+
    Object.keys(canonicalTagsInfo).length > 0 ||
+
    peers.some(p => Object.keys(getTagsFromRefs(p.refs)).length > 0);
+

+
  $: selectedTag = (() => {
+
    if (!selectedBranch) return undefined;
+

+
    if (peer) {
+
      const p = peers.find(x => x.id === peer);
+
      if (!p) return undefined;
+
      const peerTags = getTagsFromRefs(p.refs);
+
      for (const [tagName, oid] of Object.entries(peerTags)) {
+
        if (
+
          oid === selectedBranch ||
+
          encodeURIComponent(tagName) === selectedBranch
+
        ) {
+
          return { name: tagName, peer: p };
+
        }
+
      }
+
      return undefined;
+
    }
+

+
    for (const [tagName, info] of Object.entries(canonicalTagsInfo)) {
+
      if (
+
        info.commit === selectedBranch ||
+
        encodeURIComponent(tagName) === selectedBranch
+
      ) {
+
        return { name: tagName, peer: undefined };
+
      }
+
    }
+

+
    return undefined;
+
  })();
+

+
  $: selectedTagName = selectedTag?.name;
+
  $: selectedTagPeer = selectedTag?.peer;
+

+
  let lastSelectedBranch: string | undefined;
+
  $: {
+
    if (selectedBranch !== lastSelectedBranch) {
+
      if (selectedTagName) {
+
        selectedTab = "tags";
+
      } else if (!selectedBranch) {
+
        selectedTab = "branches";
+
      }
+
      lastSelectedBranch = selectedBranch;
+
    }
+
  }
+

+
  $: if (!hasTags && selectedTab === "tags") {
+
    selectedTab = "branches";
+
  }
+

+
  $: isSelectedBranchCanonical = (() => {
+
    if (onCanonical) return true;
+
    if (!selectedBranch || peer) return false;
+

+
    const branchNames = Object.keys(canonicalBranchesMap);
+
    return (
+
      branchNames.includes(selectedBranch) ||
+
      branchNames.includes(decodeURIComponent(selectedBranch))
+
    );
+
  })();
</script>

<style>
@@ -68,6 +202,7 @@
    overflow-y: auto;
    overscroll-behavior: contain;
    padding: 0.25rem;
+
    background-color: var(--color-background-default);
  }
  .subgrid-item {
    display: grid;
@@ -100,6 +235,44 @@
    height: 1rem;
    font: var(--txt-body-m-regular);
  }
+
  .tabs {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.5rem;
+
  }
+
  .sticky-header {
+
    position: sticky;
+
    top: -0.25rem;
+
    margin: -0.25rem -0.25rem 0;
+
    padding: 0.25rem;
+
    background-color: var(--color-surface-canvas);
+
    z-index: 1;
+
  }
+
  .tag-details {
+
    display: flex;
+
    flex-direction: column;
+
    width: 32rem;
+
    grid-column: span 2;
+
    color: var(--color-text-secondary);
+
    min-width: 0;
+
    font: var(--txt-body-m-regular);
+
  }
+
  .tag-tagger {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.375rem;
+
    margin-bottom: 1rem;
+
  }
+
  .tag-avatar {
+
    width: 1rem;
+
    height: 1rem;
+
  }
+
  .tag-message {
+
    margin: 0;
+
    white-space: pre-wrap;
+
    overflow-wrap: anywhere;
+
    font: var(--txt-code-small);
+
  }
  @media (max-width: 719.98px) {
    .dropdown {
      width: 100%;
@@ -120,16 +293,17 @@
      styleBorderRadius="var(--border-radius-sm) 0 0 var(--border-radius-sm)"
      styleWidth="100%"
      on:click={toggle}
-
      title="Change branch"
+
      title={hasTags ? "Change branch or tag" : "Change branch"}
      disabled={!peers}>
-
      {#if selectedPeer}
+
      {@const displayPeer = selectedPeer || selectedTagPeer}
+
      {#if displayPeer}
        <div class="global-flex-item">
          <div class="node-id">
-
            <UserAvatar nodeId={selectedPeer.id} styleWidth="1rem" />
-
            {selectedPeer.alias || formatNodeId(selectedPeer.id)}
+
            <UserAvatar nodeId={displayPeer.id} styleWidth="1rem" />
+
            {displayPeer.alias || formatNodeId(displayPeer.id)}
          </div>

-
          {#if selectedPeer.delegate}
+
          {#if displayPeer.delegate}
            <Badge size="tiny" variant="delegate">
              <Icon name="badge" />
              <span class="global-hide-on-small-desktop-down">Delegate</span>
@@ -137,15 +311,25 @@
          {/if}
        </div>
      {/if}
-
      {#if selectedPeer && selectedBranch}
+
      {#if displayPeer && (selectedBranch || selectedTagName)}
        <span>/</span>
      {/if}
-
      {#if selectedBranch}
+
      {#if selectedTagName}
+
        <Icon name="label" />
+
        <span class="txt-overflow">
+
          {selectedTagName}
+
        </span>
+
        {#if Object.keys(canonicalTagsInfo).includes(selectedTagName)}
+
          <Badge title="Canonical tag" variant="foreground-emphasized">
+
            Canonical
+
          </Badge>
+
        {/if}
+
      {:else if selectedBranch}
        <Icon name="branch" />
        <span class="txt-overflow">
          {selectedBranch}
        </span>
-
        {#if onCanonical}
+
        {#if isSelectedBranchCanonical}
          <Badge title="Canonical branch" variant="foreground-emphasized">
            Canonical
          </Badge>
@@ -155,34 +339,88 @@
    </Button>

    <div slot="popover" class="dropdown" let:toggle>
-
      <TextInput
-
        showKeyHint={false}
-
        placeholder="Search"
-
        bind:value={searchInput} />
+
      <div class="sticky-header">
+
        {#if hasTags}
+
          <div class="tabs">
+
            <Button
+
              variant={selectedTab === "branches" ? "selected" : "background"}
+
              on:click={() => {
+
                selectedTab = "branches";
+
                searchInput = "";
+
              }}>
+
              <Icon name="branch" />
+
              Branches
+
            </Button>
+
            <Button
+
              variant={selectedTab === "tags" ? "selected" : "background"}
+
              on:click={() => {
+
                selectedTab = "tags";
+
                searchInput = "";
+
              }}>
+
              <Icon name="label" />
+
              Tags
+
            </Button>
+
            <div class="global-hide-on-mobile-down" style:flex="1">
+
              <TextInput
+
                size="small"
+
                showKeyHint={false}
+
                placeholder={selectedTab === "branches"
+
                  ? "Filter branches"
+
                  : "Filter tags"}
+
                bind:value={searchInput} />
+
            </div>
+
          </div>
+
        {:else}
+
          <div style="margin-bottom: 0.5rem;">
+
            <TextInput
+
              showKeyHint={false}
+
              placeholder="Filter branches"
+
              bind:value={searchInput} />
+
          </div>
+
        {/if}
+
        {#if hasTags}
+
          <div
+
            class="global-hide-on-small-desktop-up"
+
            style="margin-bottom: 0.5rem;">
+
            <TextInput
+
              showKeyHint={false}
+
              placeholder={selectedTab === "branches"
+
                ? "Filter branches"
+
                : "Filter tags"}
+
              bind:value={searchInput} />
+
          </div>
+
        {/if}
+
      </div>
      <div class="dropdown-grid">
-
        <div class="dropdown-header">Branch</div>
+
        <div class="dropdown-header">
+
          {selectedTab === "branches" ? "Branch" : "Tag"}
+
        </div>
        <div class="dropdown-header" style="padding-left: 0;">Head</div>

        {#if searchInput}
          {#each searchResults as result}
-
            {@const { revision, peer, head } = result.obj}
+
            {@const { revision, peer, head, type } = result.obj}
            <Link
              style={subgridStyle}
              route={{
                ...baseRoute,
-
                peer: peer?.id,
-
                revision: peer ? revision : undefined,
+
                peer: type === "branch" ? peer?.id : undefined,
+
                revision:
+
                  type === "tag" ? encodeURIComponent(revision) : revision,
              }}
              on:afterNavigate={() => {
                searchInput = "";
                toggle();
              }}>
              <DropdownListItem
-
                selected={selectedPeer?.id === peer?.id &&
-
                  selectedBranch === revision}
-
                style={subgridStyle}>
+
                selected={type === "tag"
+
                  ? selectedTagName === revision ||
+
                    selectedBranch === encodeURIComponent(revision)
+
                  : selectedPeer?.id === peer?.id &&
+
                    selectedBranch === revision}
+
                style={`${subgridStyle} gap: inherit;`}>
                <div class="global-flex-item">
-
                  <Icon name="branch" />
+
                  <Icon name={type === "tag" ? "label" : "branch"} />
                  <span class="txt-overflow">
                    {#if peer?.id}
                      <span class="global-flex-item">
@@ -216,7 +454,9 @@
                      <div class="global-flex-item">
                        {revision}
                        <Badge
-
                          title="Canonical branch"
+
                          title={type === "tag"
+
                            ? "Canonical tag"
+
                            : "Canonical branch"}
                          variant="foreground-emphasized">
                          Canonical
                        </Badge>
@@ -237,7 +477,7 @@
              No entries found
            </div>
          {/each}
-
        {:else}
+
        {:else if selectedTab === "branches"}
          <Link
            style={subgridStyle}
            route={{ ...baseRoute, revision: undefined }}
@@ -258,12 +498,123 @@
              </div>
            </DropdownListItem>
          </Link>
+
          {#each canonicalBranches as [branchName, branchHead]}
+
            <Link
+
              style={subgridStyle}
+
              route={{
+
                ...baseRoute,
+
                peer: undefined,
+
                revision: encodeURIComponent(branchName),
+
              }}
+
              on:afterNavigate={() => {
+
                searchInput = "";
+
                toggle();
+
              }}>
+
              <DropdownListItem
+
                selected={!peer &&
+
                  (selectedBranch === branchName ||
+
                    selectedBranch === encodeURIComponent(branchName))}
+
                style={subgridStyle}>
+
                <div class="global-flex-item">
+
                  <Icon name="branch" />
+
                  <span class="txt-overflow">{branchName}</span>
+
                  <Badge
+
                    title="Canonical branch"
+
                    variant="foreground-emphasized">
+
                    Canonical
+
                  </Badge>
+
                </div>
+
                <div class="txt-id">
+
                  {formatCommit(branchHead)}
+
                </div>
+
              </DropdownListItem>
+
            </Link>
+
          {/each}
          {#each orderBy(peers, ["delegate", o => o.alias?.toLowerCase()], ["desc", "asc"]) as peer}
            <Peer
              {baseRoute}
              revision={selectedBranch}
              peer={{ remote: peer, selected: selectedPeer?.id === peer.id }} />
          {/each}
+
        {:else if selectedTab === "tags"}
+
          {#if canonicalTags.length > 0}
+
            {#each canonicalTags as [tagName, info]}
+
              {@const annotated = info.tagger || info.message}
+
              <Link
+
                style={subgridStyle}
+
                route={{
+
                  ...baseRoute,
+
                  peer: undefined,
+
                  revision: encodeURIComponent(tagName),
+
                }}
+
                on:afterNavigate={() => {
+
                  searchInput = "";
+
                  toggle();
+
                }}>
+
                <DropdownListItem
+
                  selected={!peer &&
+
                    (selectedBranch === tagName ||
+
                      selectedBranch === encodeURIComponent(tagName) ||
+
                      selectedTagName === tagName)}
+
                  style={subgridStyle}>
+
                  <div class="global-flex-item">
+
                    {#if annotated}
+
                      <HoverPopover>
+
                        <svelte:fragment slot="toggle">
+
                          <Icon name="label" />
+
                        </svelte:fragment>
+
                        <div slot="popover" class="tag-details">
+
                          {#if info.tagger}
+
                            <div
+
                              class="tag-tagger"
+
                              title={`${info.tagger.name} <${info.tagger.email}>`}>
+
                              <img
+
                                class="tag-avatar"
+
                                alt="avatar"
+
                                src={gravatarURL(info.tagger.email)} />
+
                              {info.tagger.name}
+
                              tagged
+
                              <span
+
                                title={absoluteTimestamp(
+
                                  info.tagger.timestamp,
+
                                )}>
+
                                {formatTimestamp(info.tagger.timestamp)}
+
                              </span>
+
                            </div>
+
                          {/if}
+
                          {#if info.message}
+
                            <pre class="tag-message">{info.message}</pre>
+
                          {/if}
+
                        </div>
+
                      </HoverPopover>
+
                    {:else}
+
                      <Icon name="label" />
+
                    {/if}
+
                    <span class="txt-overflow">{tagName}</span>
+
                    <Badge
+
                      title="Canonical tag"
+
                      variant="foreground-emphasized">
+
                      Canonical
+
                    </Badge>
+
                  </div>
+
                  <div class="txt-id">
+
                    {formatCommit(info.commit)}
+
                  </div>
+
                </DropdownListItem>
+
              </Link>
+
            {/each}
+
          {/if}
+
          {#each orderBy(peers, ["delegate", o => o.alias?.toLowerCase()], ["desc", "asc"]).filter(p => Object.keys(getTagsFromRefs(p.refs)).length > 0) as peer}
+
            <Peer
+
              {baseRoute}
+
              revision={selectedBranch}
+
              type="tags"
+
              {selectedTagName}
+
              peer={{
+
                remote: peer,
+
                selected: selectedTagPeer?.id === peer.id,
+
              }} />
+
          {/each}
        {/if}
      </div>
    </div>
modified src/views/repos/Source/PeerBranchSelector/Peer.svelte
@@ -1,9 +1,13 @@
<script lang="ts">
  import type { RepoRoute } from "@app/views/repos/router";
-
  import type { Remote } from "@http-client";
+
  import type { PeerRefs } from "@http-client";

  import { closeFocused } from "@app/components/Popover.svelte";
-
  import { formatCommit } from "@app/lib/utils";
+
  import {
+
    formatCommit,
+
    getBranchesFromRefs,
+
    getTagsFromRefs,
+
  } from "@app/lib/utils";
  import { replace } from "@app/lib/router";

  import Badge from "@app/components/Badge.svelte";
@@ -17,12 +21,23 @@
    RepoRoute,
    { resource: "repo.source" } | { resource: "repo.history" }
  >;
-
  export let peer: { remote: Remote; selected: boolean };
+
  export let peer: { remote: PeerRefs; selected: boolean };
  export let revision: string | undefined = undefined;
+
  export let type: "branches" | "tags" = "branches";
+
  export let selectedTagName: string | undefined = undefined;

  const subgridStyle =
    "display: grid; grid-template-columns: subgrid; grid-column: span 2;";
  let expanded = false;
+

+
  $: refs =
+
    type === "branches"
+
      ? getBranchesFromRefs(peer.remote.refs)
+
      : getTagsFromRefs(peer.remote.refs);
+

+
  $: if (peer.selected) {
+
    expanded = true;
+
  }
</script>

<style>
@@ -51,26 +66,29 @@
  </div>
</div>
{#if expanded}
-
  {#each Object.entries(peer.remote.heads) as [name, head]}
+
  {#each Object.entries(refs) as [name, head]}
    <Link
      style={subgridStyle}
      route={{
        ...baseRoute,
        peer: peer.remote.id,
-
        revision: name,
+
        revision: type === "tags" ? encodeURIComponent(name) : name,
      }}
      on:afterNavigate={() => closeFocused()}>
      <DropdownListItem
-
        selected={peer.selected && revision === name}
+
        selected={type === "tags"
+
          ? peer.selected &&
+
            (selectedTagName === name || revision === encodeURIComponent(name))
+
          : peer.selected && revision === name}
        on:click={() =>
          replace({
            ...baseRoute,
            peer: peer.remote.id,
-
            revision: name,
+
            revision: type === "tags" ? encodeURIComponent(name) : name,
          })}
        style={`${subgridStyle} padding-left: 2.3rem;`}>
        <div class="global-flex-item">
-
          <Icon name="branch" />
+
          <Icon name={type === "branches" ? "branch" : "label"} />
          <span class="txt-overflow">
            {name}
          </span>
modified src/views/repos/router.ts
@@ -16,8 +16,9 @@ import type {
  Node,
  Patch,
  PatchState,
-
  Repo,
+
  PeerRefs,
  Remote,
+
  Repo,
  Revision,
  SeedingPolicy,
  Tree,
@@ -29,12 +30,55 @@ import { HttpdClient } from "@http-client";
import { ResponseError, ResponseParseError } from "@http-client/lib/fetcher";
import { cached } from "@app/lib/cache";
import { handleError, unreachableError } from "@app/views/repos/error";
-
import { unreachable } from "@app/lib/utils";
+
import {
+
  getBranchesFromRefs,
+
  getTagsFromRefs,
+
  unreachable,
+
} from "@app/lib/utils";
import { nodePath } from "@app/views/nodes/router";

export const PATCHES_PER_PAGE = 10;
export const ISSUES_PER_PAGE = 10;

+
function peerHasBranches(peer: PeerRefs): boolean {
+
  return Object.keys(peer.refs).some(name => name.startsWith("refs/heads/"));
+
}
+

+
function canonicalOids(
+
  refs: Repo["refs"] | undefined,
+
): Array<[string, string]> {
+
  return [
+
    ...Object.entries(refs?.refs ?? {}),
+
    ...Object.entries(refs?.tags ?? {}).map(
+
      ([name, info]): [string, string] => [name, info.commit],
+
    ),
+
  ];
+
}
+

+
function remoteToPeerRefs(remote: Remote): PeerRefs {
+
  if (remote.refs) {
+
    return {
+
      id: remote.id,
+
      alias: remote.alias,
+
      delegate: remote.delegate,
+
      refs: remote.refs,
+
    };
+
  }
+

+
  const refs: Record<string, string> = {};
+

+
  for (const [name, oid] of Object.entries(remote.heads)) {
+
    refs[`refs/heads/${name}`] = oid;
+
  }
+

+
  return {
+
    id: remote.id,
+
    alias: remote.alias,
+
    delegate: remote.delegate,
+
    refs,
+
  };
+
}
+

export type RepoRoute =
  | RepoTreeRoute
  | RepoHistoryRoute
@@ -116,7 +160,7 @@ export type RepoLoadedRoute =
        seedingPolicy: SeedingPolicy;
        commit: string;
        repo: Repo;
-
        peers: Remote[];
+
        peers: PeerRefs[];
        peer: string | undefined;
        revision: string | undefined;
        tree: Tree;
@@ -133,7 +177,7 @@ export type RepoLoadedRoute =
        seedingPolicy: SeedingPolicy;
        commit: string;
        repo: Repo;
-
        peers: Remote[];
+
        peers: PeerRefs[];
        peer: string | undefined;
        revision: string | undefined;
        tree: Tree;
@@ -373,7 +417,6 @@ async function loadTreeView(

  let repoPromise: Promise<Repo>;
  let seedingPolicyPromise: Promise<SeedingPolicy>;
-
  let peersPromise: Promise<Remote[]>;
  let nodePromise: Promise<Partial<Node>>;
  if (
    (previousLoaded.resource === "repo.source" ||
@@ -382,25 +425,25 @@ async function loadTreeView(
    previousLoaded.params.peer === route.peer
  ) {
    repoPromise = Promise.resolve(previousLoaded.params.repo);
-
    peersPromise = Promise.resolve(previousLoaded.params.peers);
    seedingPolicyPromise = Promise.resolve(previousLoaded.params.seedingPolicy);
    nodePromise = Promise.resolve({
      avatarUrl: previousLoaded.params.nodeAvatarUrl,
    });
  } else {
    repoPromise = api.repo.getByRid(route.repo);
-
    peersPromise = api.repo.getAllRemotes(route.repo);
    seedingPolicyPromise = api.getPolicyByRid(route.repo);
    nodePromise = api.getNode();
  }

-
  const [repo, peers, seedingPolicy, node] = await Promise.all([
+
  const [repo, seedingPolicy, node] = await Promise.all([
    repoPromise,
-
    peersPromise,
    seedingPolicyPromise,
    nodePromise,
  ]);

+
  const remotes = await api.repo.getAllRemotes(route.repo);
+
  const peers: PeerRefs[] = remotes.map(remoteToPeerRefs);
+

  if (!repo["payloads"]["xyz.radicle.project"]) {
    throw new Error(
      `Repository ${repo.rid} does not have a xyz.radicle.project payload.`,
@@ -411,6 +454,25 @@ async function loadTreeView(
  let branchMap: Record<string, string> = {
    [project.data.defaultBranch]: project.meta.head,
  };
+

+
  for (const [refName, oid] of canonicalOids(repo.refs)) {
+
    const shortName = refName.startsWith("refs/heads/")
+
      ? refName.slice("refs/heads/".length)
+
      : refName.startsWith("refs/tags/")
+
        ? refName.slice("refs/tags/".length)
+
        : refName;
+
    branchMap[shortName] = oid;
+
    branchMap[encodeURIComponent(shortName)] = oid;
+
  }
+

+
  for (const peer of peers) {
+
    const tags = getTagsFromRefs(peer.refs);
+
    for (const [tagName, oid] of Object.entries(tags)) {
+
      branchMap[tagName] = oid;
+
      branchMap[encodeURIComponent(tagName)] = oid;
+
    }
+
  }
+

  if (route.peer) {
    const peer = peers.find(peer => peer.id === route.peer);
    if (!peer) {
@@ -419,7 +481,11 @@ async function loadTreeView(
        params: { title: `Peer ${route.peer} could not be found` },
      };
    } else {
-
      branchMap = peer.heads;
+
      branchMap = { ...getBranchesFromRefs(peer.refs) };
+
      for (const [tagName, oid] of Object.entries(getTagsFromRefs(peer.refs))) {
+
        branchMap[tagName] = oid;
+
        branchMap[encodeURIComponent(tagName)] = oid;
+
      }
    }
  }

@@ -446,7 +512,7 @@ async function loadTreeView(
      seedingPolicy,
      commit,
      repo,
-
      peers: peers.filter(remote => Object.keys(remote.heads).length > 0),
+
      peers: peers.filter(peerHasBranches),
      peer: route.peer,
      rawPath,
      revision: route.revision,
@@ -515,7 +581,6 @@ async function loadHistoryView(

  let repoPromise: Promise<Repo>;
  let seedingPolicyPromise: Promise<SeedingPolicy>;
-
  let peersPromise: Promise<Remote[]>;
  let nodePromise: Promise<Partial<Node>>;
  if (
    (previousLoaded.resource === "repo.source" ||
@@ -524,26 +589,33 @@ async function loadHistoryView(
    previousLoaded.params.peer === route.peer
  ) {
    repoPromise = Promise.resolve(previousLoaded.params.repo);
-
    peersPromise = Promise.resolve(previousLoaded.params.peers);
    seedingPolicyPromise = Promise.resolve(previousLoaded.params.seedingPolicy);
    nodePromise = Promise.resolve({
      avatarUrl: previousLoaded.params.nodeAvatarUrl,
    });
  } else {
    repoPromise = api.repo.getByRid(route.repo);
-
    peersPromise = api.repo.getAllRemotes(route.repo);
    seedingPolicyPromise = api.getPolicyByRid(route.repo);
    nodePromise = api.getNode();
  }

-
  const [repo, peers, seedingPolicy, branchMap, node] = await Promise.all([
+
  const [repo, seedingPolicy, node] = await Promise.all([
    repoPromise,
-
    peersPromise,
    seedingPolicyPromise,
-
    getPeerBranches(api, route.repo, route.peer),
    nodePromise,
  ]);

+
  const remotes = await api.repo.getAllRemotes(route.repo);
+
  const peers: PeerRefs[] = remotes.map(remoteToPeerRefs);
+

+
  const branchMap = await getPeerBranches(
+
    api,
+
    route.repo,
+
    route.peer,
+
    repo,
+
    peers,
+
  );
+

  if (!repo["payloads"]["xyz.radicle.project"]) {
    throw new Error(
      `Repository ${repo.rid} does not have a xyz.radicle.project payload.`,
@@ -556,8 +628,6 @@ async function loadHistoryView(
    commitId = route.revision;
  } else if (branchMap) {
    commitId = branchMap[route.revision || project.data.defaultBranch];
-
  } else if (!route.revision) {
-
    commitId = project.meta.head;
  }

  if (!commitId) {
@@ -595,7 +665,7 @@ async function loadHistoryView(
      seedingPolicy,
      commit: commitId,
      repo,
-
      peers: peers.filter(remote => Object.keys(remote.heads).length > 0),
+
      peers: peers.filter(peerHasBranches),
      peer: route.peer,
      revision: route.revision,
      tree,
@@ -733,9 +803,51 @@ async function loadPatchView(
  };
}

-
async function getPeerBranches(api: HttpdClient, repo: string, peer?: string) {
+
async function getPeerBranches(
+
  api: HttpdClient,
+
  repoId: string,
+
  peer?: string,
+
  repo?: Repo,
+
  loadedPeers?: PeerRefs[],
+
) {
  if (peer) {
-
    return (await api.repo.getRemoteByPeer(repo, peer)).heads;
+
    const remote = await api.repo.getRemoteByPeer(repoId, peer);
+
    const refs = remoteToPeerRefs(remote).refs;
+
    const map: Record<string, string> = { ...getBranchesFromRefs(refs) };
+
    for (const [tagName, oid] of Object.entries(getTagsFromRefs(refs))) {
+
      map[tagName] = oid;
+
      map[encodeURIComponent(tagName)] = oid;
+
    }
+
    return map;
+
  } else if (repo) {
+
    const branchMap: Record<string, string> = {};
+
    const peers = loadedPeers ?? [];
+

+
    const project = repo.payloads["xyz.radicle.project"];
+
    if (project) {
+
      branchMap[project.data.defaultBranch] = project.meta.head;
+
      branchMap[encodeURIComponent(project.data.defaultBranch)] =
+
        project.meta.head;
+
    }
+

+
    for (const [refName, oid] of canonicalOids(repo.refs)) {
+
      const shortName = refName.startsWith("refs/heads/")
+
        ? refName.slice("refs/heads/".length)
+
        : refName.startsWith("refs/tags/")
+
          ? refName.slice("refs/tags/".length)
+
          : refName;
+
      branchMap[shortName] = oid;
+
      branchMap[encodeURIComponent(shortName)] = oid;
+
    }
+

+
    for (const p of peers) {
+
      const tags = getTagsFromRefs(p.refs);
+
      for (const [tagName, oid] of Object.entries(tags)) {
+
        branchMap[tagName] = oid;
+
        branchMap[encodeURIComponent(tagName)] = oid;
+
      }
+
    }
+
    return branchMap;
  } else {
    return undefined;
  }
modified tests/e2e/repo.spec.ts
@@ -349,8 +349,7 @@ test("peer and branch switching", async ({ page }) => {
      await changeBranch("alice", "feature/branch", page);
      await page.locator('[title="Change branch"]:visible').first().click();
      await page
-
        .getByRole("button", { name: "feature/branch" })
-
        .first()
+
        .getByRole("button", { name: "feature/branch 1aded56" })
        .click();

      await expect(
modified tests/support/repo.ts
@@ -6,8 +6,15 @@ import * as Path from "node:path";
export async function changeBranch(peer: string, branch: string, page: Page) {
  await page.locator('[title="Change branch"]:visible').first().click();
  const peerLocator = page.getByLabel("peer-item").filter({ hasText: peer });
-
  await peerLocator.getByTitle("Expand peer").click();
-
  await page.getByRole("button", { name: branch }).click();
+

+
  const branchButton = page.getByRole("button", { name: branch });
+
  const isVisible = await branchButton.isVisible().catch(() => false);
+

+
  if (!isVisible) {
+
    await peerLocator.getByTitle("Expand peer").click();
+
  }
+

+
  await branchButton.click();
}

// Create a repo using the rad CLI.