Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
Overhaul for peer and branch selector
Merged did:key:z6MkkfM3...sVz5 opened 1 year ago
23 files changed +531 -409 173ede84 a4e0d21b
modified http-client/lib/project.ts
@@ -1,6 +1,7 @@
-
import type { Commit, Commits } from "./project/commit.js";
-
import type { Embed } from "./project/comment.js";
+
import type { ZodSchema } from "zod";
import type { Fetcher, RequestOptions } from "./fetcher.js";
+
import type { Embed } from "./project/comment.js";
+
import type { Commit, Commits } from "./project/commit.js";
import type {
  Issue,
  IssueCreated,
@@ -13,27 +14,26 @@ import type {
  PatchUpdateAction,
} from "./project/patch.js";
import type { SuccessResponse } from "./shared.js";
-
import type { ZodSchema } from "zod";

-
import { successResponseSchema } from "./shared.js";
import {
  array,
  boolean,
  literal,
  number,
+
  object,
  optional,
  record,
-
  object,
  string,
  union,
  z,
} from "zod";
+
import { successResponseSchema } from "./shared.js";

import {
-
  diffBlobSchema,
  commitHeaderSchema,
  commitSchema,
  commitsSchema,
+
  diffBlobSchema,
  diffSchema,
} from "./project/commit.js";

@@ -44,9 +44,9 @@ import {
} from "./project/issue.js";

import {
+
  patchCreatedSchema,
  patchSchema,
  patchesSchema,
-
  patchCreatedSchema,
} from "./project/patch.js";

const projectSchema = object({
@@ -121,7 +121,7 @@ const treeSchema = object({

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

-
const remoteSchema = object({
+
export const remoteSchema = object({
  id: string(),
  alias: string().optional(),
  heads: record(string(), string()),
modified package-lock.json
@@ -18,6 +18,7 @@
        "buffer": "^6.0.3",
        "compare-versions": "^6.1.0",
        "dompurify": "^3.0.11",
+
        "fuzzysort": "^3.0.2",
        "hast-util-to-dom": "^4.0.0",
        "hast-util-to-html": "^9.0.0",
        "lodash": "^4.17.21",
@@ -2707,6 +2708,11 @@
        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
      }
    },
+
    "node_modules/fuzzysort": {
+
      "version": "3.0.2",
+
      "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.0.2.tgz",
+
      "integrity": "sha512-ZyahVgxvckB1Qosn7YGWLDJJp2XlyaQ2WmZeI+d0AzW0AMqVYnz5N89G6KAKa6m/LOtv+kzJn4lhDF/yVg11Cg=="
+
    },
    "node_modules/get-func-name": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
modified package.json
@@ -64,6 +64,7 @@
    "buffer": "^6.0.3",
    "compare-versions": "^6.1.0",
    "dompurify": "^3.0.11",
+
    "fuzzysort": "^3.0.2",
    "hast-util-to-dom": "^4.0.0",
    "hast-util-to-html": "^9.0.0",
    "lodash": "^4.17.21",
modified public/index.css
@@ -96,6 +96,11 @@ pre {
  height: 100%;
  background-color: var(--color-fill-ghost);
}
+
.global-flex-item {
+
  display: flex;
+
  gap: 0.5rem;
+
  align-items: center;
+
}

/*
  Breakpoints
modified src/components/DropdownList/DropdownListItem.svelte
@@ -2,6 +2,7 @@
  export let selected: boolean;
  export let disabled: boolean = false;
  export let title: string | undefined = undefined;
+
  export let style: string | undefined = undefined;
</script>

<style>
@@ -49,6 +50,7 @@
  class="item"
  class:selected
  class:disabled
+
  {style}
  {title}
  on:click>
  <slot />
modified src/components/Id.svelte
@@ -54,7 +54,7 @@
    flex-direction: row;
    gap: 0.5rem;
    justify-content: center;
-
    z-index: 10;
+
    z-index: 20;
    background: var(--color-background-float);
    color: var(--color-foreground-default);
    border: 1px solid var(--color-border-hint);
modified src/components/Link.svelte
@@ -7,6 +7,7 @@
  export let route: Route;
  export let disabled: boolean = false;
  export let styleHoverState: boolean = false;
+
  export let styleTextOverflow: boolean = false;
  export let title: string | undefined = undefined;
  export let style: string | undefined = undefined;

@@ -39,6 +40,7 @@
</style>

<a
+
  class:txt-overflow={styleTextOverflow}
  class:hover-style={styleHoverState}
  on:click={navigateToRoute}
  href={routeToPath(route)}
modified src/components/NodeId.svelte
@@ -1,11 +1,12 @@
<script lang="ts">
-
  import { formatNodeId } from "@app/lib/utils";
+
  import { formatNodeId, parseNodeId, truncateId } from "@app/lib/utils";

  import Avatar from "./Avatar.svelte";
  import Id from "./Id.svelte";

  export let nodeId: string;
  export let alias: string | undefined = undefined;
+
  export let subject: string = formatNodeId(nodeId);
</script>

<style>
@@ -19,15 +20,25 @@
    font-weight: var(--font-weight-semibold);
    font-size: var(--font-size-small);
  }
+
  .no-alias {
+
    color: var(--color-foreground-dim);
+
  }
</style>

-
<Id id={nodeId} subject={formatNodeId(nodeId)} style="none">
+
<Id id={nodeId} {subject} style="none">
  <div class="avatar-alias">
    <Avatar {nodeId} />
    {#if alias}
-
      {alias}
+
      <span class="txt-overflow">
+
        {alias}
+
      </span>
    {:else}
-
      {formatNodeId(nodeId)}
+
      <span class="no-alias global-hide-on-mobile-down">
+
        {formatNodeId(nodeId)}
+
      </span>
+
      <span class="no-alias global-hide-on-small-desktop-up">
+
        {truncateId(parseNodeId(nodeId)?.pubkey || "")}
+
      </span>
    {/if}
  </div>
</Id>
modified src/components/Popover.svelte
@@ -20,7 +20,7 @@
  let thisComponent: HTMLDivElement;

  function clickOutside(ev: MouseEvent | TouchEvent) {
-
    if (!$focused?.contains(ev.target as HTMLDivElement)) {
+
    if ($focused && !ev.composedPath().includes($focused)) {
      closeFocused();
    }
  }
modified src/views/projects/Header/SeedButton.svelte
@@ -8,7 +8,7 @@
  import ErrorModal from "@app/modals/ErrorModal.svelte";
  import ExternalLink from "@app/components/ExternalLink.svelte";
  import IconSmall from "@app/components/IconSmall.svelte";
-
  import Popover from "@app/components/Popover.svelte";
+
  import Popover, { closeFocused } from "@app/components/Popover.svelte";

  export let projectId: string;
  export let seedCount: number;
@@ -100,6 +100,7 @@
    on:click={async () => {
      if ($experimental && !seeding && canEditSeeding) {
        await editSeeding();
+
        closeFocused();
      } else {
        toggle();
      }
@@ -121,6 +122,7 @@

  <div
    slot="popover"
+
    let:toggle
    style:width={$experimental ? (seeding ? "19.5rem" : "30.5rem") : "auto"}>
    {#if $experimental && canEditSeeding && seeding}
      <div class="seed-label txt-bold">Stop seeding</div>
@@ -134,6 +136,7 @@
        disabled={editSeedingInProgress}
        on:click={async () => {
          await editSeeding();
+
          toggle();
        }}>
        <IconSmall name="seedling" />
        Stop seeding
modified src/views/projects/History.svelte
@@ -7,7 +7,7 @@
    Node,
    Tree,
  } from "@http-client";
-
  import type { Route } from "@app/lib/router";
+
  import type { ProjectRoute } from "./router";

  import { COMMITS_PER_PAGE } from "./router";
  import { HttpdClient } from "@http-client";
@@ -26,7 +26,6 @@
  export let baseUrl: BaseUrl;
  export let node: Node;
  export let commit: string;
-
  export let branches: string[];
  export let commitHeaders: CommitHeader[];
  export let peer: string | undefined;
  export let peers: Remote[];
@@ -43,6 +42,11 @@
  let loading = false;
  let allCommitHeaders: CommitHeader[];

+
  $: baseRoute = {
+
    resource: "project.history",
+
    node: baseUrl,
+
    project: project.id,
+
  } as Extract<ProjectRoute, { resource: "project.history" }>;
  $: {
    allCommitHeaders = commitHeaders;
    page = 0;
@@ -63,28 +67,6 @@
    }
    loading = false;
  }
-

-
  $: peersWithRoute = peers.map(remote => ({
-
    remote,
-
    selected: remote.id === peer,
-
    route: {
-
      resource: "project.history",
-
      node: baseUrl,
-
      project: project.id,
-
      peer: remote.id,
-
    } as Route,
-
  }));
-

-
  $: branchesWithRoute = branches.map(name => ({
-
    name,
-
    route: {
-
      resource: "project.history",
-
      node: baseUrl,
-
      project: project.id,
-
      peer,
-
      revision: name,
-
    } as Route,
-
  }));
</script>

<style>
@@ -111,15 +93,16 @@
<Layout {node} {baseUrl} {project} activeTab="source">
  <ProjectNameHeader {project} {baseUrl} {seeding} slot="header" />

-
  <div style:margin="1rem 0 1rem 1rem" slot="subheader">
+
  <div style:margin="1rem" slot="subheader">
    <Header
-
      node={baseUrl}
+
      {baseRoute}
      {commit}
+
      {peers}
+
      {peer}
      {project}
-
      peers={peersWithRoute}
-
      branches={branchesWithRoute}
      {revision}
      {tree}
+
      node={baseUrl}
      filesLinkActive={false}
      historyLinkActive={true} />
  </div>
modified src/views/projects/Source.svelte
@@ -1,7 +1,6 @@
<script lang="ts">
  import type { BaseUrl, Node, Project, Remote, Tree } from "@http-client";
-
  import type { BlobResult } from "./router";
-
  import type { Route } from "@app/lib/router";
+
  import type { BlobResult, ProjectRoute } from "./router";

  import { HttpdClient } from "@http-client";

@@ -15,18 +14,17 @@
  import ProjectNameHeader from "./Source/ProjectNameHeader.svelte";

  export let baseUrl: BaseUrl;
-
  export let node: Node;
-
  export let commit: string;
-
  export let rawPath: (commit?: string) => string;
  export let blobResult: BlobResult;
-
  export let branches: string[];
+
  export let commit: string;
+
  export let node: Node;
  export let path: string;
  export let peer: string | undefined;
  export let peers: Remote[];
  export let project: Project;
+
  export let rawPath: (commit?: string) => string;
  export let revision: string | undefined;
-
  export let tree: Tree;
  export let seeding: boolean;
+
  export let tree: Tree;

  let mobileFileTree = false;

@@ -47,30 +45,12 @@
      });
  };

-
  $: peersWithRoute = peers.map(remote => ({
-
    remote,
-
    selected: remote.id === peer,
-
    route: {
-
      resource: "project.source",
-
      node: baseUrl,
-
      project: project.id,
-
      peer: remote.id,
-
      revision: remote.heads[project.defaultBranch]
-
        ? undefined
-
        : Object.keys(remote.heads)[0],
-
    } as Route,
-
  }));
-

-
  $: branchesWithRoute = branches.map(name => ({
-
    name,
-
    route: {
-
      resource: "project.source",
-
      node: baseUrl,
-
      project: project.id,
-
      peer,
-
      revision: name,
-
    } as Route,
-
  }));
+
  $: baseRoute = {
+
    resource: "project.source",
+
    node: baseUrl,
+
    project: project.id,
+
    path: "/",
+
  } as Extract<ProjectRoute, { resource: "project.source" }>;
</script>

<style>
@@ -134,17 +114,18 @@
<Layout {node} {baseUrl} {project} activeTab="source" stylePaddingBottom="0">
  <ProjectNameHeader {project} {baseUrl} {seeding} slot="header" />

-
  <div style:margin="1rem 0 1rem 1rem" slot="subheader">
+
  <div style:margin="1rem" slot="subheader">
    <Header
+
      filesLinkActive={true}
+
      historyLinkActive={false}
      node={baseUrl}
      {commit}
+
      {baseRoute}
+
      {peers}
+
      {peer}
      {project}
-
      peers={peersWithRoute}
-
      branches={branchesWithRoute}
      {revision}
-
      {tree}
-
      filesLinkActive={true}
-
      historyLinkActive={false} />
+
      {tree} />
  </div>
  <div class="global-hide-on-medium-desktop-up">
    {#if tree.entries.length > 0}
deleted src/views/projects/Source/BranchSelector.svelte
@@ -1,100 +0,0 @@
-
<script lang="ts">
-
  import type { BaseUrl, Commit, Project } from "@http-client";
-
  import type { Route } from "@app/lib/router";
-

-
  import { activeUnloadedRouteStore } from "@app/lib/router";
-
  import { closeFocused } from "@app/components/Popover.svelte";
-

-
  import Badge from "@app/components/Badge.svelte";
-
  import Button from "@app/components/Button.svelte";
-
  import CommitButton from "@app/views/projects/components/CommitButton.svelte";
-
  import DropdownList from "@app/components/DropdownList.svelte";
-
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
-
  import IconSmall from "@app/components/IconSmall.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import Popover from "@app/components/Popover.svelte";
-

-
  export let onCanonical: boolean;
-
  export let branches: Array<{ name: string; route: Route }>;
-
  export let node: BaseUrl;
-
  export let project: Project;
-
  export let selectedBranch: string | undefined;
-
  export let selectedCommit: Commit["commit"];
-
</script>
-

-
<style>
-
  .branch {
-
    display: flex;
-
    align-items: center;
-
    justify-content: center;
-
  }
-

-
  .identifier {
-
    display: flex;
-
    align-items: center;
-
    gap: 0.5rem;
-
  }
-
</style>
-

-
<div class="branch">
-
  {#if selectedBranch}
-
    <Popover
-
      popoverPadding="0"
-
      popoverPositionTop="2.5rem"
-
      popoverBorderRadius="var(--border-radius-tiny)">
-
      <Button
-
        variant="gray-white"
-
        let:expanded
-
        let:toggle
-
        on:click={toggle}
-
        slot="toggle"
-
        styleBorderRadius="var(--border-radius-tiny) 0 0 var(--border-radius-tiny)"
-
        title="Change branch">
-
        <IconSmall name="branch" />
-
        <div class="identifier">{selectedBranch}</div>
-
        {#if onCanonical}
-
          <Badge title="Canonical branch" variant="foreground-emphasized">
-
            Canonical
-
          </Badge>
-
        {/if}
-
        <IconSmall name={expanded ? "chevron-up" : "chevron-down"} />
-
      </Button>
-

-
      <DropdownList
-
        slot="popover"
-
        styleDropdownMinWidth="7.5rem"
-
        items={branches}>
-
        <svelte:fragment slot="item" let:item>
-
          <Link route={item.route} on:afterNavigate={() => closeFocused()}>
-
            <DropdownListItem selected={item.name === selectedBranch}>
-
              <IconSmall name="branch" />
-
              <div class="identifier">
-
                {item.name}
-
              </div>
-
              {#if onCanonical}
-
                <Badge title="Canonical branch" variant="foreground-emphasized">
-
                  Canonical
-
                </Badge>
-
              {/if}
-
            </DropdownListItem>
-
          </Link>
-
        </svelte:fragment>
-
        <svelte:fragment slot="empty">
-
          <Link
-
            route={$activeUnloadedRouteStore}
-
            on:afterNavigate={() => closeFocused()}>
-
            <DropdownListItem selected>
-
              <IconSmall name="branch" />
-
              <div class="identifier">
-
                {project.defaultBranch}
-
              </div>
-
            </DropdownListItem>
-
          </Link>
-
        </svelte:fragment>
-
      </DropdownList>
-
    </Popover>
-
  {/if}
-

-
  <div class="global-spacer" />
-
  <CommitButton projectId={project.id} commit={selectedCommit} baseUrl={node} />
-
</div>
modified src/views/projects/Source/Header.svelte
@@ -1,26 +1,29 @@
<script lang="ts">
+
  import type { ProjectRoute } from "../router";
  import type { BaseUrl, Project, Remote, Tree } from "@http-client";
-
  import type { Route } from "@app/lib/router";

  import { HttpdClient } from "@http-client";

-
  import BranchSelector from "./BranchSelector.svelte";
-
  import PeerSelector from "./PeerSelector.svelte";
-

  import Button from "@app/components/Button.svelte";
+
  import CommitButton from "../components/CommitButton.svelte";
  import IconSmall from "@app/components/IconSmall.svelte";
  import Link from "@app/components/Link.svelte";
  import Loading from "@app/components/Loading.svelte";
+
  import PeerBranchSelector from "./PeerBranchSelector.svelte";

-
  export let node: BaseUrl;
  export let commit: string;
-
  export let branches: Array<{ name: string; route: Route }>;
-
  export let peers: Array<{ remote: Remote; selected: boolean; route: Route }>;
  export let filesLinkActive: boolean;
  export let historyLinkActive: boolean;
+
  export let node: BaseUrl;
+
  export let peer: string | undefined;
+
  export let peers: Remote[];
+
  export let project: Project;
+
  export let baseRoute: Extract<
+
    ProjectRoute,
+
    { resource: "project.source" } | { resource: "project.history" }
+
  >;
  export let revision: string | undefined;
  export let tree: Tree;
-
  export let project: Project;

  const api = new HttpdClient(node);
  let selectedBranch: string | undefined;
@@ -34,7 +37,6 @@
  }

  $: lastCommit = tree.lastCommit;
-
  $: peer = peers.find(p => p.selected)?.remote.id;
</script>

<style>
@@ -42,8 +44,9 @@
    display: flex;
    align-items: center;
    justify-content: left;
-
    flex-wrap: wrap;
+
    row-gap: 0.5rem;
    gap: 1rem;
+
    flex-wrap: wrap;
    margin-bottom: 2rem;
  }

@@ -86,17 +89,21 @@
</style>

<div class="top-header">
-
  {#if peers.length > 0}
-
    <PeerSelector {peers} {project} {node} />
-
  {/if}
-

-
  <BranchSelector
-
    {branches}
-
    {project}
-
    {node}
+
  <PeerBranchSelector
+
    {peers}
+
    {peer}
+
    {baseRoute}
    onCanonical={Boolean(!peer && selectedBranch === project.defaultBranch)}
-
    selectedCommit={lastCommit}
+
    {project}
    {selectedBranch} />
+
  <CommitButton
+
    styleMinWidth="0"
+
    styleWidth="100%"
+
    hideSummaryOnMobile={false}
+
    projectId={project.id}
+
    commit={lastCommit}
+
    baseUrl={node}
+
    styleRoundBorders />
</div>

<div class="header">
added src/views/projects/Source/PeerBranchSelector.svelte
@@ -0,0 +1,286 @@
+
<script lang="ts">
+
  import type { ProjectRoute } from "@app/views/projects/router";
+
  import type { Project, Remote } from "@http-client";
+

+
  import fuzzysort from "fuzzysort";
+
  import { formatCommit, formatNodeId } from "@app/lib/utils";
+
  import { orderBy } from "lodash";
+

+
  import Badge from "@app/components/Badge.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import Peer from "./PeerBranchSelector/Peer.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";
+
  import Avatar from "@app/components/Avatar.svelte";
+

+
  export let baseRoute: Extract<
+
    ProjectRoute,
+
    { resource: "project.source" } | { resource: "project.history" }
+
  >;
+
  export let onCanonical: boolean;
+
  export let peer: string | undefined;
+
  export let peers: Remote[];
+
  export let project: Project;
+
  export let selectedBranch: string | undefined;
+

+
  const subgridStyle =
+
    "display: grid; grid-template-columns: subgrid; grid-column: span 2;";
+
  const highlightSearchStyle = [
+
    '<span style="background: var(--color-fill-yellow-iconic); color: var(--color-foreground-black);">',
+
    "</span>",
+
  ];
+
  let searchInput = "";
+

+
  const searchElements = [
+
    {
+
      peer: undefined,
+
      revision: project.defaultBranch,
+
      head: project.head,
+
    },
+
    ...peers.flatMap(peer =>
+
      Object.entries(peer.heads).map(([name, head]) => ({
+
        peer: { id: peer.id, alias: peer.alias, delegate: peer.delegate },
+
        revision: name,
+
        head,
+
      })),
+
    ),
+
  ];
+

+
  $: selectedPeer = peers.find(p => p.id === peer);
+
  $: searchResults = fuzzysort.go(searchInput, searchElements, {
+
    keys: ["peer.alias", "revision"],
+
    scoreFn: r =>
+
      r.score *
+
      (r.obj.peer?.delegate ? 2 : 1) *
+
      (r.obj.peer === undefined ? 10 : 1) *
+
      (r.obj.peer?.alias ? 2 : 1),
+
  });
+
</script>
+

+
<style>
+
  .dropdown {
+
    border-radius: var(--border-radius-small);
+
    width: 40rem;
+
    max-height: 60vh;
+
    overflow-y: auto;
+
    padding: 0.25rem;
+
  }
+
  .subgrid-item {
+
    display: grid;
+
    grid-template-columns: subgrid;
+
    grid-column: span 2;
+
  }
+
  .dropdown-grid {
+
    display: grid;
+
    column-gap: 2rem;
+
    grid-template-columns: [branch] minmax(20ch, 1fr) [commit] 7ch;
+
  }
+
  .dropdown-header {
+
    display: grid;
+
    grid-template-columns: subgrid;
+
    font-size: var(--font-size-tiny);
+
    padding: 0.5rem;
+
    color: var(--color-foreground-dim);
+
  }
+
  .container {
+
    display: flex;
+
    gap: 1px;
+
    min-width: 0;
+
    flex-wrap: nowrap;
+
  }
+
  .node-id {
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
+
    gap: 0.375rem;
+
    height: 1rem;
+
    font-family: var(--font-family-monospace);
+
    font-weight: var(--font-weight-semibold);
+
    font-size: var(--font-size-small);
+
  }
+
  @media (max-width: 719.98px) {
+
    .dropdown {
+
      width: 100%;
+
    }
+
  }
+
</style>
+

+
<div class="container">
+
  <Popover
+
    popoverContainerMinWidth="0"
+
    popoverPadding="0"
+
    popoverPositionTop="2.5rem"
+
    popoverBorderRadius="var(--border-radius-small)">
+
    <Button
+
      slot="toggle"
+
      let:expanded
+
      let:toggle
+
      styleBorderRadius="var(--border-radius-tiny) 0 0 var(--border-radius-tiny)"
+
      styleWidth="100%"
+
      on:click={toggle}
+
      title="Change branch"
+
      disabled={!peers}>
+
      {#if selectedPeer}
+
        <div class="global-flex-item">
+
          <div class="node-id">
+
            <Avatar nodeId={selectedPeer.id} inline />
+
            {selectedPeer.alias || formatNodeId(selectedPeer.id)}
+
          </div>
+

+
          {#if selectedPeer.delegate}
+
            <Badge size="tiny" variant="delegate">
+
              <IconSmall name="badge" />
+
              <span class="global-hide-on-small-desktop-down">Delegate</span>
+
            </Badge>
+
          {/if}
+
        </div>
+
      {/if}
+
      {#if selectedPeer && selectedBranch}
+
        <span>/</span>
+
      {/if}
+
      {#if selectedBranch}
+
        <IconSmall name="branch" />
+
        <span class="txt-overflow">
+
          {selectedBranch}
+
        </span>
+
        {#if onCanonical}
+
          <Badge title="Canonical branch" variant="foreground-emphasized">
+
            Canonical
+
          </Badge>
+
        {/if}
+
      {/if}
+
      <IconSmall name={expanded ? "chevron-up" : "chevron-down"} />
+
    </Button>
+

+
    <div slot="popover" class="dropdown" let:toggle>
+
      <TextInput
+
        showKeyHint={false}
+
        placeholder="Search"
+
        bind:value={searchInput} />
+
      <div class="dropdown-grid">
+
        <div class="dropdown-header">Branch</div>
+
        <div class="dropdown-header" style="padding-left: 0;">Head</div>
+

+
        {#if searchInput}
+
          {#each searchResults as result}
+
            {@const { revision, peer, head } = result.obj}
+
            <Link
+
              style={subgridStyle}
+
              route={{
+
                ...baseRoute,
+
                peer: peer?.id,
+
                revision: peer ? revision : undefined,
+
              }}
+
              on:afterNavigate={() => {
+
                searchInput = "";
+
                toggle();
+
              }}>
+
              <DropdownListItem
+
                selected={selectedPeer?.id === peer?.id &&
+
                  selectedBranch === revision}
+
                style={`${subgridStyle} gap: inherit;`}>
+
                <div class="global-flex-item">
+
                  <IconSmall name="branch" />
+
                  <span class="txt-overflow">
+
                    {#if peer?.id}
+
                      <span class="global-flex-item">
+
                        {#if result[0].target}
+
                          <span>
+
                            {@html result[0].highlight(...highlightSearchStyle)}
+
                          </span>
+
                        {:else if peer.alias}
+
                          {peer.alias}
+
                        {:else}
+
                          {formatNodeId(peer.id)}
+
                        {/if}
+
                        {#if peer.delegate}
+
                          <Badge variant="delegate" round>
+
                            <IconSmall name="badge" />
+
                          </Badge>
+
                        {/if} /
+
                        <span class="txt-overflow">
+
                          {#if result[1].target}
+
                            <span>
+
                              {@html result[1].highlight(
+
                                ...highlightSearchStyle,
+
                              )}
+
                            </span>
+
                          {:else}
+
                            {revision}
+
                          {/if}
+
                        </span>
+
                      </span>
+
                    {:else}
+
                      <div class="global-flex-item">
+
                        {revision}
+
                        <Badge
+
                          title="Canonical branch"
+
                          variant="foreground-emphasized">
+
                          Canonical
+
                        </Badge>
+
                      </div>
+
                    {/if}
+
                  </span>
+
                </div>
+
                <div
+
                  class="txt-monospace"
+
                  style="color: var(--color-foreground-dim);">
+
                  {formatCommit(head)}
+
                </div>
+
              </DropdownListItem>
+
            </Link>
+
          {:else}
+
            <div
+
              style="gap: inherit; padding: 0.5rem 0.375rem;"
+
              class="subgrid-item txt-missing txt-small">
+
              No entries found
+
            </div>
+
          {/each}
+
        {:else}
+
          <Link
+
            style={subgridStyle}
+
            route={{ ...baseRoute, revision: undefined }}
+
            on:afterNavigate={() => {
+
              searchInput = "";
+
              toggle();
+
            }}>
+
            <DropdownListItem
+
              selected={onCanonical}
+
              style={`${subgridStyle} gap: inherit;`}>
+
              <div class="global-flex-item">
+
                <IconSmall name="branch" />
+
                {project.defaultBranch}
+
                <Badge title="Canonical branch" variant="foreground-emphasized">
+
                  Canonical
+
                </Badge>
+
              </div>
+
              <div
+
                class="txt-monospace"
+
                style="color: var(--color-foreground-dim);">
+
                {formatCommit(project.head)}
+
              </div>
+
            </DropdownListItem>
+
          </Link>
+
          {#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}
+
        {/if}
+
      </div>
+
    </div>
+
  </Popover>
+
  {#if selectedPeer}
+
    <Link route={baseRoute}>
+
      <Button
+
        variant="not-selected"
+
        styleBorderRadius="0 var(--border-radius-tiny) var(--border-radius-tiny) 0">
+
        <IconSmall name="cross" />
+
      </Button>
+
    </Link>
+
  {/if}
+
</div>
added src/views/projects/Source/PeerBranchSelector/Peer.svelte
@@ -0,0 +1,88 @@
+
<script lang="ts">
+
  import type { ProjectRoute } from "@app/views/projects/router";
+
  import type { Remote } from "@http-client";
+

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

+
  import Badge from "@app/components/Badge.svelte";
+
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
+

+
  export let baseRoute: Extract<
+
    ProjectRoute,
+
    { resource: "project.source" } | { resource: "project.history" }
+
  >;
+
  export let peer: { remote: Remote; selected: boolean };
+
  export let revision: string | undefined = undefined;
+

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

+
<style>
+
  .subgrid-item {
+
    display: grid;
+
    grid-template-columns: subgrid;
+
    grid-column: span 2;
+
  }
+
</style>
+

+
<div class="subgrid-item" aria-label="peer-item">
+
  <div class="global-flex-item" style="padding: 0.5rem 0">
+
    <IconButton title="Expand peer" on:click={() => (expanded = !expanded)}>
+
      <IconSmall name={expanded ? "chevron-down" : "chevron-right"} />
+
    </IconButton>
+
    <NodeId
+
      subject="Node Id"
+
      nodeId={peer.remote.id}
+
      alias={peer.remote.alias} />
+
    {#if peer.remote.delegate}
+
      <Badge size="tiny" variant="delegate">
+
        <IconSmall name="badge" />
+
        <span class="global-hide-on-small-desktop-down">Delegate</span>
+
      </Badge>
+
    {/if}
+
  </div>
+
</div>
+
{#if expanded}
+
  {#each Object.entries(peer.remote.heads) as [name, head]}
+
    <Link
+
      style={subgridStyle}
+
      route={{
+
        ...baseRoute,
+
        peer: peer.remote.id,
+
        revision: name,
+
      }}
+
      on:afterNavigate={() => closeFocused()}>
+
      <DropdownListItem
+
        selected={peer.selected && revision === name}
+
        on:click={() =>
+
          replace({
+
            ...baseRoute,
+
            peer: peer.remote.id,
+
            revision: name,
+
          })}
+
        style={`${subgridStyle} padding-left: 2.3rem; gap: inherit;`}>
+
        <div class="global-flex-item">
+
          <IconSmall name="branch" />
+
          <span class="txt-overflow">
+
            {name}
+
          </span>
+
        </div>
+
        <div class="global-flex-item">
+
          <span
+
            class="txt-monospace"
+
            style="color: var(--color-foreground-dim);">
+
            {formatCommit(head)}
+
          </span>
+
        </div>
+
      </DropdownListItem>
+
    </Link>
+
  {/each}
+
{/if}
deleted src/views/projects/Source/PeerSelector.svelte
@@ -1,134 +0,0 @@
-
<script lang="ts">
-
  import type { BaseUrl, Project, Remote } from "@http-client";
-
  import type { Route } from "@app/lib/router";
-

-
  import { closeFocused } from "@app/components/Popover.svelte";
-
  import { formatNodeId } from "@app/lib/utils";
-
  import { httpdStore } from "@app/lib/httpd";
-

-
  import Avatar from "@app/components/Avatar.svelte";
-
  import Badge from "@app/components/Badge.svelte";
-
  import Button from "@app/components/Button.svelte";
-
  import DropdownList from "@app/components/DropdownList.svelte";
-
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
-
  import IconSmall from "@app/components/IconSmall.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import Popover from "@app/components/Popover.svelte";
-

-
  export let peers: Array<{ remote: Remote; selected: boolean; route: Route }>;
-
  export let project: Project;
-
  export let node: BaseUrl;
-

-
  $: selectedPeer = peers.find(p => p.selected)?.remote;
-

-
  function createTitle(p: Remote): string {
-
    const nodeId = formatNodeId(p.id);
-
    return p.delegate
-
      ? `${nodeId} is a delegate of this project`
-
      : `${nodeId} is a peer followed by this node`;
-
  }
-
</script>
-

-
<style>
-
  .counter {
-
    border-radius: var(--border-radius-tiny);
-
    background-color: var(--color-fill-counter);
-
    color: var(--color-foreground-contrast);
-
    padding: 0 0.25rem;
-
  }
-
  .no-alias {
-
    color: var(--color-foreground-dim);
-
  }
-
</style>
-

-
<div style="display: flex; gap: 1px;">
-
  <Popover
-
    popoverPadding="0"
-
    popoverPositionTop="2.5rem"
-
    popoverBorderRadius="var(--border-radius-small)">
-
    <Button
-
      ariaLabel="Change peer"
-
      slot="toggle"
-
      let:expanded
-
      let:toggle
-
      styleBorderRadius="var(--border-radius-tiny) 0 0 var(--border-radius-tiny)"
-
      on:click={toggle}
-
      title={selectedPeer ? formatNodeId(selectedPeer.id) : "Change peer"}
-
      disabled={!peers}>
-
      {#if !selectedPeer}
-
        <IconSmall name="delegate" />
-
      {/if}
-

-
      {#if selectedPeer}
-
        <div style:height="1rem">
-
          <Avatar nodeId={selectedPeer.id} />
-
        </div>
-
        <span
-
          style:font-family="var(--font-family-monospace)"
-
          class:no-alias={!selectedPeer.alias}>
-
          {selectedPeer.alias || formatNodeId(selectedPeer.id)}
-
        </span>
-
        {#if selectedPeer.delegate}
-
          <Badge size="tiny" variant="delegate">
-
            <IconSmall name="badge" />
-
            Delegate
-
          </Badge>
-
        {/if}
-
      {:else}
-
        Remotes
-
        <div class="counter">
-
          {peers.length}
-
        </div>
-
      {/if}
-
      <IconSmall name={expanded ? "chevron-up" : "chevron-down"} />
-
    </Button>
-

-
    <DropdownList slot="popover" items={peers}>
-
      <svelte:fragment slot="item" let:item>
-
        <Link on:afterNavigate={() => closeFocused()} route={item.route}>
-
          <DropdownListItem
-
            selected={item.selected}
-
            title={createTitle(item.remote)}>
-
            <div style:height="1rem">
-
              <Avatar nodeId={item.remote.id} />
-
            </div>
-
            <span
-
              style:font-family="var(--font-family-monospace)"
-
              class:no-alias={!item.remote.alias}>
-
              {item.remote.alias || formatNodeId(item.remote.id)}
-
            </span>
-
            {#if $httpdStore.state !== "stopped" && item.remote.id === $httpdStore.node.id}
-
              <Badge
-
                style="background-color: var(--color-fill-ghost-hover)"
-
                variant="neutral"
-
                size="tiny">
-
                You
-
              </Badge>
-
            {/if}
-
            {#if item.remote.delegate}
-
              <Badge size="tiny" variant="delegate">
-
                <IconSmall name="badge" />
-
                Delegate
-
              </Badge>
-
            {/if}
-
          </DropdownListItem>
-
        </Link>
-
      </svelte:fragment>
-
    </DropdownList>
-
  </Popover>
-
  {#if selectedPeer}
-
    <Link
-
      route={{
-
        resource: "project.source",
-
        project: project.id,
-
        node,
-
        path: "/",
-
      }}>
-
      <Button
-
        variant="not-selected"
-
        styleBorderRadius="0 var(--border-radius-tiny) var(--border-radius-tiny) 0">
-
        <IconSmall name="cross" />
-
      </Button>
-
    </Link>
-
  {/if}
-
</div>
modified src/views/projects/components/CommitButton.svelte
@@ -5,9 +5,12 @@
  import Link from "@app/components/Link.svelte";
  import { formatCommit } from "@app/lib/utils";

+
  export let styleMinWidth: string | undefined = undefined;
+
  export let styleWidth: "100%" | undefined = undefined;
  export let styleRoundBorders: boolean = false;
  export let projectId: string;
  export let baseUrl: BaseUrl;
+
  export let hideSummaryOnMobile: boolean = true;
  export let commit: Commit["commit"];

  $: commitShortId = formatCommit(commit.id);
@@ -27,6 +30,7 @@
</style>

<Link
+
  styleTextOverflow
  route={{
    resource: "project.commit",
    project: projectId,
@@ -36,14 +40,18 @@
  <Button
    title="Current HEAD"
    variant="not-selected"
+
    {styleWidth}
+
    {styleMinWidth}
    styleBorderRadius={styleRoundBorders
      ? "var(--border-radius-tiny)"
      : "0 var(--border-radius-tiny) var(--border-radius-tiny) 0"}>
-
    <div class="commit">
+
    <div class="txt-overflow commit">
      <div class="identifier global-commit">
        {commitShortId}
      </div>
-
      <span class="global-hide-on-small-desktop-down">
+
      <span
+
        class="txt-overflow"
+
        class:global-hide-on-small-desktop-down={hideSummaryOnMobile}>
        {commit.summary}
      </span>
    </div>
modified src/views/projects/router.ts
@@ -21,15 +21,15 @@ import type {
  Tree,
} from "@http-client";

-
import * as Syntax from "@app/lib/syntax";
+
import { experimental } from "@app/lib/appearance";
import * as httpd from "@app/lib/httpd";
+
import * as Syntax from "@app/lib/syntax";
+
import { unreachable } from "@app/lib/utils";
+
import { nodePath } from "@app/views/nodes/router";
+
import { handleError, unreachableError } from "@app/views/projects/error";
import { HttpdClient } from "@http-client";
import { ResponseError, ResponseParseError } from "@http-client/lib/fetcher";
-
import { experimental } from "@app/lib/appearance";
import { get } from "svelte/store";
-
import { handleError, unreachableError } from "@app/views/projects/error";
-
import { nodePath } from "@app/views/nodes/router";
-
import { unreachable } from "@app/lib/utils";

export const COMMITS_PER_PAGE = 30;
export const PATCHES_PER_PAGE = 10;
@@ -119,7 +119,6 @@ export type ProjectLoadedRoute =
        project: Project;
        peers: Remote[];
        peer: string | undefined;
-
        branches: string[];
        revision: string | undefined;
        tree: Tree;
        path: string;
@@ -137,7 +136,6 @@ export type ProjectLoadedRoute =
        project: Project;
        peers: Remote[];
        peer: string | undefined;
-
        branches: string[];
        revision: string | undefined;
        tree: Tree;
        commitHeaders: CommitHeader[];
@@ -438,7 +436,9 @@ async function loadTreeView(
    isLocalNodeSeeding(route),
  ]);

-
  let branchMap: Record<string, string>;
+
  let branchMap: Record<string, string> = {
+
    [project.defaultBranch]: project.head,
+
  };
  if (route.peer) {
    const peer = peers.find(peer => peer.id === route.peer);
    if (!peer) {
@@ -449,15 +449,10 @@ async function loadTreeView(
    } else {
      branchMap = peer.heads;
    }
-
  } else {
-
    branchMap = { [project.defaultBranch]: project.head };
  }

  if (route.route) {
-
    const { revision, path } = detectRevision(
-
      route.route,
-
      branchMap || { [project.defaultBranch]: project.head },
-
    );
+
    const { revision, path } = detectRevision(route.route, branchMap);
    route.revision = revision;
    route.path = path;
  }
@@ -481,7 +476,6 @@ async function loadTreeView(
      project,
      peers: peers.filter(remote => Object.keys(remote.heads).length > 0),
      peer: route.peer,
-
      branches: Object.keys(branchMap),
      rawPath,
      revision: route.revision,
      tree,
@@ -587,7 +581,6 @@ async function loadHistoryView(
      project,
      peers: peers.filter(remote => Object.keys(remote.heads).length > 0),
      peer: route.peer,
-
      branches: Object.keys(branchMap || {}),
      revision: route.revision,
      tree,
      commitHeaders,
modified tests/e2e/project.spec.ts
@@ -12,6 +12,7 @@ import {
  sourceBrowsingUrl,
  test,
} from "@tests/support/fixtures.js";
+
import { changeBranch } from "@tests/support/project";
import { expectUrlPersistsReload } from "@tests/support/router";

test("navigate to project", async ({ page }) => {
@@ -288,17 +289,12 @@ test("peer and branch switching", async ({ page }) => {

  // Alice's peer.
  {
-
    await page.getByLabel("Change peer").click();
-
    await page
-
      .getByRole("link", {
-
        name: "alice delegate",
-
      })
-
      .click();
-
    await expect(page.getByLabel("Change peer")).toHaveText("alice Delegate");
+
    await changeBranch("alice", `main ${shortAliceHead}`, page);
+
    await expect(page.getByTitle("Change branch")).toHaveText(/alice/);

    // Default `main` branch.
    {
-
      await expect(page.getByTitle("Change branch")).toHaveText("main");
+
      await expect(page.getByTitle("Change branch")).toHaveText(/main/);
      await expect(
        page
          .getByRole("button", {
@@ -315,6 +311,7 @@ test("peer and branch switching", async ({ page }) => {

    // Feature branch with a slash in the name.
    {
+
      await changeBranch("alice", "feature/branch", page);
      await page.getByTitle("Change branch").click();
      await page.getByText("feature/branch").click();

@@ -333,8 +330,7 @@ test("peer and branch switching", async ({ page }) => {

    // Branch without a history or files in it.
    {
-
      await page.getByTitle("Change branch").click();
-
      await page.getByText("orphaned-branch").click();
+
      await changeBranch("alice", "orphaned-branch", page);

      await expect(
        page.getByRole("button", { name: "orphaned-branch" }),
@@ -356,8 +352,8 @@ test("peer and branch switching", async ({ page }) => {
  {
    await page.getByRole("link", { name: "source-browsing" }).nth(1).click();

-
    await expect(page.getByLabel("Change peer")).not.toContainText("alice");
-
    await expect(page.getByLabel("Change peer")).not.toContainText("bob");
+
    await expect(page.getByTitle("Change branch")).not.toContainText("alice");
+
    await expect(page.getByTitle("Change branch")).not.toContainText("bob");

    await expect(page.getByTitle("Change branch")).toBeVisible();
    await expect(
@@ -372,10 +368,10 @@ test("peer and branch switching", async ({ page }) => {

  // Bob's peer.
  {
-
    await page.getByLabel("Change peer").click();
-
    await page.getByRole("link", { name: "bob" }).click();
-
    await expect(page.getByLabel("Change peer")).toContainText("bob");
-
    await expect(page.getByLabel("Change peer")).not.toHaveText("delegate");
+
    await changeBranch("bob", `main ${shortBobHead}`, page);
+
    await expect(
+
      page.getByRole("button", { name: "avatar bob / main" }),
+
    ).toBeVisible();

    // Default `main` branch.
    {
@@ -402,36 +398,22 @@ test("peer and branch switching", async ({ page }) => {
test("only one modal can be open at a time", async ({ page }) => {
  await page.goto(sourceBrowsingUrl);

-
  await page.getByLabel("Change peer").click();
-
  await page
-
    .getByRole("link", {
-
      name: "alice delegate",
-
    })
-
    .click();
+
  await changeBranch("alice", `main ${shortAliceHead}`, page);

  await page.getByText("Clone").click();
  await expect(page.getByText("Code font")).not.toBeVisible();
  await expect(page.getByText("Use the Radicle CLI")).toBeVisible();
  await expect(page.getByText("bob")).not.toBeVisible();
-
  await expect(page.getByText("feature/branch")).not.toBeVisible();

  await page.getByRole("button", { name: "Settings" }).click();
  await expect(page.getByText("Code font")).toBeVisible();
  await expect(page.getByText("Use the Radicle CLI")).not.toBeVisible();
  await expect(page.getByText("bob")).not.toBeVisible();
-
  await expect(page.getByText("feature/branch")).not.toBeVisible();

  await page.getByTitle("Change branch").click();
  await expect(page.getByText("Code font")).not.toBeVisible();
  await expect(page.getByText("Use the Radicle CLI")).not.toBeVisible();
-
  await expect(page.getByText("bob")).not.toBeVisible();
-
  await expect(page.getByText("feature/branch")).toBeVisible();
-

-
  await page.getByLabel("Change peer").click();
-
  await expect(page.getByText("Code font")).not.toBeVisible();
-
  await expect(page.getByText("Use the Radicle CLI")).not.toBeVisible();
  await expect(page.getByText("bob")).toBeVisible();
-
  await expect(page.getByText("feature/branch")).not.toBeVisible();
});

test.describe("browser error handling", () => {
modified tests/e2e/project/commit.spec.ts
@@ -1,23 +1,19 @@
import {
  aliceRemote,
  bobHead,
-
  bobMainCommitCount,
  expect,
  shortBobHead,
  sourceBrowsingUrl,
  test,
} from "@tests/support/fixtures.js";
+
import { changeBranch } from "@tests/support/project";
import sinon from "sinon";

const commitUrl = `${sourceBrowsingUrl}/commits/${bobHead}`;

test("navigation from commit list", async ({ page }) => {
  await page.goto(sourceBrowsingUrl);
-
  await page.getByLabel("Change peer").click();
-
  await page.getByRole("link", { name: "bob" }).click();
-
  await page
-
    .getByRole("link", { name: `Commits ${bobMainCommitCount}` })
-
    .click();
+
  await changeBranch("bob", `main ${shortBobHead}`, page);

  await page.getByText("Update readme").first().click();
  await expect(page).toHaveURL(commitUrl);
modified tests/e2e/project/commits.spec.ts
@@ -1,15 +1,15 @@
import {
  aliceMainCommitCount,
  aliceMainCommitMessage,
-
  aliceMainHead,
  bobMainCommitCount,
  expect,
  gitOptions,
+
  shortAliceHead,
  shortBobHead,
  sourceBrowsingUrl,
  test,
} from "@tests/support/fixtures.js";
-
import { createProject } from "@tests/support/project";
+
import { changeBranch, createProject } from "@tests/support/project";
import sinon from "sinon";

test("peer and branch switching", async ({ page }) => {
@@ -17,17 +17,15 @@ test("peer and branch switching", async ({ page }) => {
  await page
    .getByRole("link", { name: `Commits ${aliceMainCommitCount}` })
    .click();
+
  await expect(page.getByText("Thursday, December 15,")).toBeVisible();

  // Alice's peer.
  {
-
    await page.getByLabel("Change peer").click();
-
    await page
-
      .getByRole("link", {
-
        name: "alice delegate",
-
      })
-
      .click();
+
    await changeBranch("alice", `main ${shortAliceHead}`, page);

-
    await expect(page.getByLabel("Change peer")).toHaveText("alice Delegate");
+
    await expect(page.getByTitle("Change branch")).toHaveText(
+
      "alice Delegate / main",
+
    );

    await expect(page.getByText("Thursday, November 17, 2022")).toBeVisible();
    await expect(page.locator(".list .teaser")).toHaveCount(
@@ -36,7 +34,7 @@ test("peer and branch switching", async ({ page }) => {

    const latestCommit = page.locator(".teaser").first();
    await expect(latestCommit).toContainText(aliceMainCommitMessage);
-
    await expect(latestCommit).toContainText(aliceMainHead.substring(0, 7));
+
    await expect(latestCommit).toContainText(shortAliceHead);

    const earliestCommit = page.locator(".teaser").last();
    await expect(earliestCommit).toContainText(
@@ -44,8 +42,7 @@ test("peer and branch switching", async ({ page }) => {
    );
    await expect(earliestCommit).toContainText("36d5bbe");

-
    await page.getByTitle("Change branch").click();
-
    await page.getByText("feature/branch").click();
+
    await changeBranch("alice", "feature/branch", page);

    await expect(
      page.getByRole("button", { name: "feature/branch" }),
@@ -53,8 +50,7 @@ test("peer and branch switching", async ({ page }) => {
    await expect(page.getByText("Thursday, November 17, 2022")).toBeVisible();
    await expect(page.locator(".list .teaser")).toHaveCount(bobMainCommitCount);

-
    await page.getByTitle("Change branch").click();
-
    await page.getByText("orphaned-branch").click();
+
    await changeBranch("alice", "orphaned-branch", page);

    await expect(
      page.getByRole("button", { name: "orphaned-branch" }),
@@ -65,10 +61,9 @@ test("peer and branch switching", async ({ page }) => {

  // Bob's peer.
  {
-
    await page.getByLabel("Change peer").click();
-
    await page.getByRole("link", { name: "bob" }).click();
+
    await changeBranch("bob", `main ${shortBobHead}`, page);

-
    await expect(page.getByLabel("Change peer")).toContainText("bob");
+
    await expect(page.getByTitle("Change branch")).toContainText("bob");

    await expect(page.getByText("Wednesday, December 21, 2022")).toBeVisible();
    await expect(page.locator(".list").first().locator(".teaser")).toHaveCount(
@@ -120,6 +115,7 @@ test("commit messages with double colon not converted into single colon", async
      exact: true,
    })
    .click();
+
  await page.waitForLoadState("networkidle");
  await expect(page.getByText(commitMessage, { exact: true })).toBeVisible();
});

@@ -155,9 +151,8 @@ test("relative timestamps", async ({ page }) => {
    .getByRole("link", { name: `Commits ${aliceMainCommitCount}` })
    .click();

-
  await page.getByLabel("Change peer").click();
-
  await page.getByRole("link", { name: "bob" }).click();
-
  await expect(page.getByLabel("Change peer")).toHaveText("bob");
+
  await changeBranch("bob", `main ${shortBobHead}`, page);
+
  await expect(page.getByTitle("Change branch")).toHaveText(/bob/);
  const latestCommit = page.locator(".teaser").first();
  await expect(latestCommit).toContainText(
    `Bob Belcher committed ${shortBobHead} now`,
modified tests/support/project.ts
@@ -1,9 +1,16 @@
import type { Locator, Page } from "@playwright/test";
import type { RadiclePeer } from "@tests/support/peerManager";

-
import * as Path from "node:path";
import { expect } from "@playwright/test";
import { readFileSync } from "node:fs";
+
import * as Path from "node:path";
+

+
export async function changeBranch(peer: string, branch: string, page: Page) {
+
  await page.getByTitle("Change branch").click();
+
  const peerLocator = page.getByLabel("peer-item").filter({ hasText: peer });
+
  await peerLocator.getByTitle("Expand peer").click();
+
  await page.getByRole("button", { name: branch }).click();
+
}

// Create a project using the rad CLI.
export async function createProject(