Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
Show canonical branches and tags in peer selector
Rūdolfs Ošiņš committed 8 days ago
commit ecbea99f564615ae6524efa1678dc18d2fadc32e
parent 42bc27c
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.