Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Implement breadcrumbs
Rūdolfs Ošiņš committed 11 months ago
commit f539a2a3507c9bb4481894d55aaaa8099b7616f2
parent fba5020
20 files changed +563 -133
modified src/components/DropdownListItem.svelte
@@ -4,11 +4,12 @@
  interface Props {
    children: Snippet;
    selected: boolean;
-
    onclick: () => void;
+
    onclick?: () => void;
    disabled?: boolean;
    title?: string;
    styleGap?: string;
    styleMinHeight?: string;
+
    styleWidth?: string;
  }

  const {
@@ -19,6 +20,7 @@
    title,
    styleGap,
    styleMinHeight,
+
    styleWidth,
  }: Props = $props();
</script>

@@ -67,11 +69,12 @@
  class="item"
  class:selected
  class:disabled
+
  style:width={styleWidth}
  style:gap={styleGap}
  style:min-height={styleMinHeight}
  {title}
  onclick={() => {
-
    if (disabled) {
+
    if (disabled || !onclick) {
      return;
    }
    onclick();
modified src/components/Header.svelte
@@ -5,21 +5,18 @@
  import { boolean } from "zod";
  import { onMount } from "svelte";

-
  import * as router from "@app/lib/router";
  import useLocalStorage from "@app/lib/useLocalStorage.svelte";
+

  import { checkRadicleCLI } from "@app/lib/checkRadicleCLI.svelte";
  import { dynamicInterval } from "@app/lib/interval";
  import { setFocused } from "@app/components/Popover.svelte";

-
  import Avatar from "@app/components/Avatar.svelte";
  import Icon from "@app/components/Icon.svelte";
  import InboxPopover from "@app/components/InboxPopover.svelte";
  import InfoButton from "@app/components/InfoButton.svelte";
  import NakedButton from "@app/components/NakedButton.svelte";
  import NodeStatusButton from "@app/components/NodeStatusButton.svelte";

-
  const activeRouteStore = router.activeRouteStore;
-

  const firstLaunchStorage = useLocalStorage(
    "appFirstLaunch",
    boolean(),
@@ -28,11 +25,13 @@
  );

  interface Props {
+
    breadcrumbs?: Snippet;
    config: Config;
-
    center?: Snippet;
    notificationCount: number;
  }

+
  const { breadcrumbs, config, notificationCount }: Props = $props();
+

  onMount(async () => {
    try {
      await checkRadicleCLI();
@@ -46,8 +45,6 @@
      firstLaunchStorage.value = false;
    }
  });
-

-
  const { center, notificationCount, config }: Props = $props();
</script>

<style>
@@ -88,15 +85,6 @@
      <div class="global-flex" style:gap="0.25rem">
        <NakedButton
          variant="ghost"
-
          active={$activeRouteStore.resource === "home"}
-
          onclick={() => {
-
            void router.push({ resource: "home", activeTab: "all" });
-
          }}
-
          stylePadding="0 4px">
-
          <Avatar publicKey={config.publicKey} />
-
        </NakedButton>
-
        <NakedButton
-
          variant="ghost"
          onclick={() => {
            window.history.back();
          }}
@@ -111,10 +99,14 @@
          stylePadding="0 4px">
          <Icon name="arrow-right" />
        </NakedButton>
+
        <div
+
          class="global-flex txt-small txt-semibold"
+
          style:gap="0.25rem"
+
          style:margin-left="0.5rem">
+
          {@render breadcrumbs?.()}
+
        </div>
      </div>

-
      {@render center?.()}
-

      <div class="global-flex">
        <InfoButton {config} />
        <NodeStatusButton />
modified src/components/Icon.svelte
@@ -49,6 +49,7 @@
      | "issue"
      | "issue-closed"
      | "label"
+
      | "link"
      | "lock"
      | "markdown"
      | "minus"
@@ -57,6 +58,7 @@
      | "none"
      | "offline"
      | "online"
+
      | "open-external"
      | "patch"
      | "patch-archived"
      | "patch-draft"
@@ -779,6 +781,30 @@
    <path d="M12.5 10.5H13.5V11.5H12.5V10.5Z" />
    <path d="M10.5 5.50003L11.5 5.50003V6.50003L10.5 6.50003L10.5 5.50003Z" />
    <path d="M11.5 11.5H12.5L12.5 12.5H11.5L11.5 11.5Z" />
+
  {:else if name === "link"}
+
    <path d="M8 2L11 2V3H8V2Z" />
+
    <path d="M7 9H9V10H7V9Z" />
+
    <path d="M13 4V6H12V4L13 4Z" />
+
    <path d="M5 6L5 8L4 8L4 6L5 6Z" />
+
    <path d="M11 3L12 3V4L11 4L11 3Z" />
+
    <path d="M5 8H6V9H5L5 8Z" />
+
    <path d="M7 3L8 3L8 4L7 4V3Z" />
+
    <path d="M6 4L7 4L7 5H6V4Z" />
+
    <path d="M5 5H6L6 6H5V5Z" />
+
    <path d="M11 6H12V7H11V6Z" />
+
    <path d="M10 7L11 7L11 8H10V7Z" />
+
    <path d="M9 8H10L10 9H9L9 8Z" />
+
    <path d="M7 6H9V7H7V6Z" />
+
    <path d="M5 13H8V14H5V13Z" />
+
    <path d="M12 8L12 10H11L11 8L12 8Z" />
+
    <path d="M4 10L4 12H3L3 10H4Z" />
+
    <path d="M10 7L11 7L11 8H10V7Z" />
+
    <path d="M4 12H5V13H4L4 12Z" />
+
    <path d="M6 7L7 7L7 8H6L6 7Z" />
+
    <path d="M4 9H5L5 10H4L4 9Z" />
+
    <path d="M10 10L11 10V11L10 11V10Z" />
+
    <path d="M9 11L10 11V12H9V11Z" />
+
    <path d="M8 12L9 12L9 13H8L8 12Z" />
  {:else if name === "lock"}
    <path d="M6 2H10V3H6V2Z" />
    <path d="M10 3L11 3V4H10V3Z" />
@@ -935,6 +961,30 @@
    <path d="M11 14H10V13H11V14Z" />
    <path d="M5 13H6V14H5V13Z" />
    <path d="M11 2.99998L10 2.99998V1.99998L11 1.99998V2.99998Z" />
+
  {:else if name === "open-external"}
+
    <path d="M3 2L6 2V3L3 3V2Z" />
+
    <path d="M3 13L13 13V14L3 14V13Z" />
+
    <path d="M2 3L3 3L3 13H2L2 3Z" />
+
    <path d="M13 10H14V13H13V10Z" />
+
    <path d="M13 2H14V3L13 3V2Z" />
+
    <path d="M12 3L13 3V4H12V3Z" />
+
    <path d="M12 4H13V5H12V4Z" />
+
    <path d="M11 3L12 3V4L11 4V3Z" />
+
    <path d="M11 4L12 4V5L11 5V4Z" />
+
    <path d="M10 5L11 5V6H10V5Z" />
+
    <path d="M9 6H10V7H9V6Z" />
+
    <path d="M8 7L9 7V8H8V7Z" />
+
    <path d="M7 8H8V9H7V8Z" />
+
    <path d="M12 2L13 2V3L12 3V2Z" />
+
    <path d="M11 2L12 2V3L11 3V2Z" />
+
    <path d="M9 2H10V3H9V2Z" />
+
    <path d="M8 2L9 2V3L8 3V2Z" />
+
    <path d="M10 2L11 2V3L10 3V2Z" />
+
    <path d="M13 3L14 3V4H13V3Z" />
+
    <path d="M13 4H14V5H13V4Z" />
+
    <path d="M13 5H14V6H13V5Z" />
+
    <path d="M13 6H14V7H13V6Z" />
+
    <path d="M13 7H14V8H13V7Z" />
  {:else if name === "patch"}
    <path d="M13 11H14V14H11L11 11H12V7H13V11ZM12 13L12 12H13L13 13H12Z" />
    <path d="M12 7L9 7L9 9H8L8 8L7 8V7H6V6L7 6V5H8L8 4L9 4V6L12 6L12 7Z" />
modified src/components/Id.svelte
@@ -33,6 +33,8 @@
    variant: "oid" | "commit" | "none";
    ariaLabel?: string;
    debounceTimeout?: number;
+
    styleBottom?: string;
+
    styleLeft?: string;
  }

  const {
@@ -43,6 +45,8 @@
    variant,
    ariaLabel,
    debounceTimeout = 50,
+
    styleBottom = "1.5rem",
+
    styleLeft = "1rem",
  }: Props = $props();

  const setVisible = debounce((value: boolean) => {
@@ -57,14 +61,12 @@
  }
  .popover {
    position: absolute;
-
    left: 1rem;
    display: flex;
    align-items: center;
    flex-direction: row;
    gap: 0.5rem;
    justify-content: center;
    z-index: 20;
-
    bottom: 1.5rem;
    background: var(--color-fill-counter);
    color: var(--color-foreground-contrast);
    box-shadow: var(--elevation-low);
@@ -113,7 +115,7 @@

  {#if visible}
    <div style:position="absolute">
-
      <div class="popover">
+
      <div class="popover" style:bottom={styleBottom} style:left={styleLeft}>
        <Icon name={icon} />
        {tooltip}
      </div>
modified src/components/IssueSecondColumn.svelte
@@ -12,7 +12,6 @@
  import Icon from "./Icon.svelte";
  import IssueStateFilterButton from "./IssueStateFilterButton.svelte";
  import IssueTeaser from "@app/components/IssueTeaser.svelte";
-
  import Link from "./Link.svelte";
  import NakedButton from "./NakedButton.svelte";
  import OutlineButton from "./OutlineButton.svelte";
  import TextInput from "./TextInput.svelte";
@@ -25,10 +24,9 @@
    repo: RepoInfo;
    selectedIssueId?: string;
    status: IssueStatus;
-
    title: string;
  }

-
  const { changeFilter, issues, repo, selectedIssueId, status, title }: Props =
+
  const { changeFilter, issues, repo, selectedIssueId, status }: Props =
    $props();

  const project = $derived(repo.payloads["xyz.radicle.project"]!);
@@ -77,25 +75,11 @@
</style>

<div class="container">
-
  <div
-
    class="txt-medium global-flex"
-
    style:font-weight="var(--font-weight-medium)"
-
    style:gap="4px"
-
    style:white-space="nowrap">
-
    {title}
-
    <Icon name="chevron-right" />
-
    <Link
-
      underline={false}
-
      route={{
-
        resource: "repo.issues",
-
        rid: repo.rid,
-
        status: "open",
-
      }}>
-
      Issues
-
    </Link>
-
  </div>
-

-
  <div class="global-flex" style:margin-left="auto">
+
  <div class="global-flex">
+
    <IssueStateFilterButton
+
      {status}
+
      counters={project.meta.issues}
+
      {changeFilter} />
    <NakedButton
      styleHeight="2.5rem"
      keyShortcuts="ctrl+f"
@@ -111,7 +95,8 @@
      }}>
      <Icon name="filter" />
    </NakedButton>
-

+
  </div>
+
  <div class="global-flex" style:margin-left="auto">
    <OutlineButton
      variant="ghost"
      styleHeight="2.5rem"
@@ -134,10 +119,6 @@

{#if showFilters}
  <div class="global-flex" style:margin="1rem 0">
-
    <IssueStateFilterButton
-
      {status}
-
      counters={project.meta.issues}
-
      {changeFilter} />
    <TextInput
      onSubmit={async () => {
        if (searchResults.length === 1) {
added src/components/MoreBreadcrumbsButton.svelte
@@ -0,0 +1,42 @@
+
<script lang="ts">
+
  import type { Snippet } from "svelte";
+

+
  import Border from "./Border.svelte";
+
  import Icon from "./Icon.svelte";
+
  import NakedButton from "./NakedButton.svelte";
+
  import Popover from "./Popover.svelte";
+

+
  interface Props {
+
    children: Snippet;
+
  }
+

+
  const { children }: Props = $props();
+

+
  let popoverExpanded: boolean = $state(false);
+
</script>
+

+
<Popover
+
  popoverPositionLeft="0"
+
  popoverPositionTop="2.5rem"
+
  bind:expanded={popoverExpanded}>
+
  {#snippet toggle(onclick)}
+
    <NakedButton
+
      variant="ghost"
+
      stylePadding="0 4px"
+
      {onclick}
+
      active={popoverExpanded}>
+
      <Icon name="more-vertical" />
+
    </NakedButton>
+
  {/snippet}
+

+
  {#snippet popover()}
+
    <Border variant="ghost">
+
      <div
+
        class="global-flex txt-monospace"
+
        style:flex-direction="column"
+
        style:align-items="flex-start">
+
        {@render children()}
+
      </div>
+
    </Border>
+
  {/snippet}
+
</Popover>
added src/components/NodeBreadcrumb.svelte
@@ -0,0 +1,31 @@
+
<script lang="ts">
+
  import type { Config } from "@bindings/config/Config";
+

+
  import * as router from "@app/lib/router";
+

+
  import { didFromPublicKey, explorerUrl } from "@app/lib/utils";
+

+
  import BreadcrumbCopyButton from "@app/views/repo/BreadcrumbCopyButton.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
+

+
  const activeRouteStore = router.activeRouteStore;
+

+
  interface Props {
+
    config: Config;
+
  }
+

+
  const { config }: Props = $props();
+
</script>
+

+
{#if $activeRouteStore.resource === "home"}
+
  <NodeId publicKey={config.publicKey} alias={config.alias} />
+
  <BreadcrumbCopyButton
+
    icon="user"
+
    id={didFromPublicKey(config.publicKey)}
+
    url={explorerUrl(`users/${didFromPublicKey(config.publicKey)}`)} />
+
{:else}
+
  <Link route={{ resource: "home", activeTab: "all" }}>
+
    <NodeId publicKey={config.publicKey} alias={config.alias} />
+
  </Link>
+
{/if}
modified src/lib/utils.ts
@@ -248,3 +248,11 @@ export function verdictIcon(verdict: Review["verdict"]) {
    return "comment";
  }
}
+

+
export function explorerUrl(
+
  path: string,
+
  seed = "seed.radicle.garden",
+
  explorer = "https://app.radicle.xyz",
+
) {
+
  return `${explorer}/nodes/${seed}/${path}`;
+
}
modified src/views/home/Repos.svelte
@@ -6,22 +6,22 @@
  import type { RepoInfo } from "@bindings/repo/RepoInfo";

  import fuzzysort from "fuzzysort";
+
  import { onMount } from "svelte";

  import * as router from "@app/lib/router";
  import { didFromPublicKey, modifierKey } from "@app/lib/utils";
  import { dynamicInterval } from "@app/lib/interval";
  import { invoke } from "@app/lib/invoke";
-
  import { onMount } from "svelte";
+
  import { setFocused } from "@app/components/Popover.svelte";

  import Border from "@app/components/Border.svelte";
-
  import CopyableId from "@app/components/CopyableId.svelte";
  import HomeSidebar from "@app/components/HomeSidebar.svelte";
  import Icon from "@app/components/Icon.svelte";
  import Layout from "@app/views/repo/Layout.svelte";
+
  import NodeBreadcrumb from "@app/components/NodeBreadcrumb.svelte";
  import OutlineButton from "@app/components/OutlineButton.svelte";
  import RepoCard from "@app/components/RepoCard.svelte";
  import TextInput from "@app/components/TextInput.svelte";
-
  import { setFocused } from "@app/components/Popover.svelte";

  interface Props {
    activeTab: HomeReposTab;
@@ -111,9 +111,10 @@
  hideSidebar
  styleSecondColumnOverflow="visible"
  {config}>
-
  {#snippet headerCenter()}
-
    <CopyableId id={config.publicKey} />
+
  {#snippet breadcrumbs()}
+
    <NodeBreadcrumb {config} />
  {/snippet}
+

  {#snippet secondColumn()}
    <HomeSidebar {activeTab} {repoCount} />
  {/snippet}
added src/views/repo/BreadcrumbCopyButton.svelte
@@ -0,0 +1,76 @@
+
<script lang="ts">
+
  import type { ComponentProps } from "svelte";
+

+
  import { writeToClipboard } from "@app/lib/invoke";
+

+
  import debounce from "lodash/debounce";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Popover, { closeFocused } from "@app/components/Popover.svelte";
+
  import DropdownListItem from "@app/components/DropdownListItem.svelte";
+
  import Border from "@app/components/Border.svelte";
+

+
  interface Props {
+
    icon: ComponentProps<typeof Icon>["name"];
+
    id: string;
+
    url: string;
+
  }
+

+
  const { icon, id, url }: Props = $props();
+

+
  let popoverExpanded: boolean = $state(false);
+
  let triggerIcon: ComponentProps<typeof Icon>["name"] = $state("copy");
+
  const restoreIcon = debounce(() => {
+
    triggerIcon = "copy";
+
  }, 1000);
+
</script>
+

+
<Popover
+
  popoverPositionLeft="0"
+
  popoverPositionTop="2rem"
+
  bind:expanded={popoverExpanded}>
+
  {#snippet toggle(onclick)}
+
    <Icon name={triggerIcon} {onclick} />
+
  {/snippet}
+

+
  {#snippet popover()}
+
    <Border variant="ghost">
+
      <div
+
        class="global-flex txt-monospace"
+
        style:flex-direction="column"
+
        style:align-items="flex-start">
+
        <DropdownListItem
+
          styleGap="0.5rem"
+
          selected={false}
+
          onclick={async () => {
+
            await writeToClipboard(id);
+
            triggerIcon = "checkmark";
+
            restoreIcon();
+
            closeFocused();
+
          }}
+
          styleWidth="100%">
+
          <div class="global-flex">
+
            <Icon name={icon} />
+
            {id}
+
            <Icon name="copy" />
+
          </div>
+
        </DropdownListItem>
+
        <a
+
          style:text-decoration="none"
+
          style:width="100%"
+
          onclick={closeFocused}
+
          href={url}
+
          target="_blank">
+
          <DropdownListItem styleGap="0.5rem" selected={false}>
+
            <div class="global-flex" style:width="100%">
+
              <Icon name="seedling" />
+
              view on seed.radicle.garden
+
              <div style:margin-left="auto">
+
                <Icon name="open-external" />
+
              </div>
+
            </div>
+
          </DropdownListItem>
+
        </a>
+
      </div>
+
    </Border>
+
  {/snippet}
+
</Popover>
modified src/views/repo/CreateIssue.svelte
@@ -16,14 +16,17 @@

  import AssigneeInput from "@app/components/AssigneeInput.svelte";
  import Border from "@app/components/Border.svelte";
+
  import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
+
  import Icon from "@app/components/Icon.svelte";
  import InlineTitle from "@app/components/InlineTitle.svelte";
  import IssueSecondColumn from "@app/components/IssueSecondColumn.svelte";
  import LabelInput from "@app/components/LabelInput.svelte";
  import Sidebar from "@app/components/Sidebar.svelte";
  import TextInput from "@app/components/TextInput.svelte";

+
  import IssuesBreadcrumb from "./IssuesBreadcrumb.svelte";
  import Layout from "./Layout.svelte";
-
  import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
+
  import RepoBreadcrumb from "./RepoBreadcrumb.svelte";

  interface Props {
    repo: RepoInfo;
@@ -41,8 +44,6 @@
    notificationCount,
  }: Props = $props();

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

  let preview: boolean = $state(false);
  let title: string = $state("");
  let status = $state(initialStatus);
@@ -51,6 +52,8 @@
  let assignees: Author[] = $state([]);
  let labels: string[] = $state([]);

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

  async function loadIssues(filter: IssueStatus) {
    try {
      issues = await invoke<Issue[]>("list_issues", {
@@ -117,7 +120,14 @@
  }
</style>

-
<Layout {notificationCount} {config}>
+
<Layout {config} {notificationCount}>
+
  {#snippet breadcrumbs()}
+
    <RepoBreadcrumb name={project.data.name} rid={repo.rid} />
+
    <IssuesBreadcrumb rid={repo.rid} {status} />
+
    <Icon name="chevron-right" />
+
    New
+
  {/snippet}
+

  {#snippet sidebar()}
    <Sidebar activeTab="issues" rid={repo.rid} />
  {/snippet}
@@ -127,7 +137,6 @@
      {repo}
      {issues}
      {status}
-
      title={project.data.name}
      changeFilter={async filter => {
        await loadIssues(filter);
      }} />
modified src/views/repo/Issue.svelte
@@ -15,6 +15,7 @@
  import { invoke } from "@app/lib/invoke";
  import { nodeRunning } from "@app/lib/events";
  import {
+
    explorerUrl,
    issueStatusBackgroundColor,
    issueStatusColor,
    publicKeyFromDid,
@@ -25,18 +26,24 @@
  import AssigneeInput from "@app/components/AssigneeInput.svelte";
  import Border from "@app/components/Border.svelte";
  import CommentComponent from "@app/components/Comment.svelte";
-
  import CopyableId from "@app/components/CopyableId.svelte";
  import Discussion from "@app/components/Discussion.svelte";
  import EditableTitle from "@app/components/EditableTitle.svelte";
  import Icon from "@app/components/Icon.svelte";
+
  import InlineTitle from "@app/components/InlineTitle.svelte";
  import IssueSecondColumn from "@app/components/IssueSecondColumn.svelte";
  import IssueStateButton from "@app/components/IssueStateButton.svelte";
  import IssueTimeline from "@app/components/IssueTimeline.svelte";
  import LabelInput from "@app/components/LabelInput.svelte";
  import NakedButton from "@app/components/NakedButton.svelte";
+
  import NodeBreadcrumb from "@app/components/NodeBreadcrumb.svelte";
  import Sidebar from "@app/components/Sidebar.svelte";

+
  import BreadcrumbCopyButton from "./BreadcrumbCopyButton.svelte";
+
  import IssuesBreadcrumb from "./IssuesBreadcrumb.svelte";
  import Layout from "./Layout.svelte";
+
  import RepoBreadcrumb from "./RepoBreadcrumb.svelte";
+
  import MoreBreadcrumbsButton from "@app/components/MoreBreadcrumbsButton.svelte";
+
  import DropdownListItem from "@app/components/DropdownListItem.svelte";

  interface Props {
    repo: RepoInfo;
@@ -316,9 +323,44 @@
  }
</style>

-
<Layout {notificationCount} {config}>
-
  {#snippet headerCenter()}
-
    <CopyableId id={issue.id} />
+
<Layout {config} {notificationCount}>
+
  {#snippet breadcrumbs()}
+
    <div
+
      class="global-flex global-hide-on-medium-desktop-down"
+
      style:gap="0.25rem">
+
      <NodeBreadcrumb {config} />
+
      <Icon name="chevron-right" />
+
      <RepoBreadcrumb name={project.data.name} rid={repo.rid} />
+
      <Icon name="chevron-right" />
+
      <IssuesBreadcrumb rid={repo.rid} {status} />
+
      <Icon name="chevron-right" />
+
    </div>
+
    <div
+
      class="global-flex global-hide-on-desktop-up"
+
      style:gap="0.25rem"
+
      style:margin-right="0.5rem">
+
      <MoreBreadcrumbsButton>
+
        <DropdownListItem styleGap="0.5rem" selected={false} styleWidth="100%">
+
          <NodeBreadcrumb {config} />
+
        </DropdownListItem>
+
        <DropdownListItem styleGap="0.5rem" selected={false} styleWidth="100%">
+
          <Icon name="repo" />
+
          <RepoBreadcrumb name={project.data.name} rid={repo.rid} />
+
        </DropdownListItem>
+
        <DropdownListItem styleGap="0.5rem" selected={false} styleWidth="100%">
+
          <Icon name={status === "open" ? "issue" : "issue-closed"} />
+
          <IssuesBreadcrumb rid={repo.rid} {status} />
+
        </DropdownListItem>
+
      </MoreBreadcrumbsButton>
+
    </div>
+

+
    <span class="txt-overflow" style:max-width="16rem">
+
      <InlineTitle content={issue.title} fontSize="small" />
+
    </span>
+
    <BreadcrumbCopyButton
+
      url={explorerUrl(`${repo.rid}/issues/${issue.id}`)}
+
      icon={issue.state.status === "open" ? "issue" : "issue-closed"}
+
      id={issue.id} />
  {/snippet}

  {#snippet sidebar()}
@@ -333,8 +375,7 @@
      {status}
      changeFilter={async filter => {
        await loadIssues(filter);
-
      }}
-
      title={project.data.name} />
+
      }} />
  {/snippet}

  <div class="content">
modified src/views/repo/Issues.svelte
@@ -7,18 +7,21 @@
  import fuzzysort from "fuzzysort";

  import * as router from "@app/lib/router";
-
  import { modifierKey } from "@app/lib/utils";
-

-
  import Layout from "./Layout.svelte";
+
  import { explorerUrl, modifierKey } from "@app/lib/utils";

  import Border from "@app/components/Border.svelte";
  import Button from "@app/components/Button.svelte";
-
  import CopyableId from "@app/components/CopyableId.svelte";
  import Icon from "@app/components/Icon.svelte";
  import IssueTeaser from "@app/components/IssueTeaser.svelte";
  import IssuesSecondColumn from "@app/components/IssuesSecondColumn.svelte";
+
  import NodeBreadcrumb from "@app/components/NodeBreadcrumb.svelte";
  import TextInput from "@app/components/TextInput.svelte";

+
  import BreadcrumbCopyButton from "./BreadcrumbCopyButton.svelte";
+
  import IssuesBreadcrumb from "./IssuesBreadcrumb.svelte";
+
  import Layout from "./Layout.svelte";
+
  import RepoBreadcrumb from "./RepoBreadcrumb.svelte";
+

  interface Props {
    repo: RepoInfo;
    issues: Issue[];
@@ -31,6 +34,8 @@
  let { notificationCount, repo, issues, config, status }: Props = $props();
  /* eslint-enable prefer-const */

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

  let searchInput = $state("");

  $effect(() => {
@@ -85,12 +90,20 @@
</style>

<Layout
-
  {notificationCount}
  hideSidebar
  styleSecondColumnOverflow="visible"
-
  {config}>
-
  {#snippet headerCenter()}
-
    <CopyableId id={repo.rid} />
+
  {config}
+
  {notificationCount}>
+
  {#snippet breadcrumbs()}
+
    <NodeBreadcrumb {config} />
+
    <Icon name="chevron-right" />
+
    <RepoBreadcrumb name={project.data.name} rid={repo.rid} />
+
    <Icon name="chevron-right" />
+
    <IssuesBreadcrumb rid={repo.rid} {status} />
+
    <BreadcrumbCopyButton
+
      url={explorerUrl(`${repo.rid}/issues`)}
+
      icon="repo"
+
      id={repo.rid} />
  {/snippet}

  {#snippet secondColumn()}
added src/views/repo/IssuesBreadcrumb.svelte
@@ -0,0 +1,20 @@
+
<script lang="ts">
+
  import type { IssueStatus } from "./router";
+

+
  import { activeRouteStore } from "@app/lib/router";
+

+
  import Link from "@app/components/Link.svelte";
+

+
  interface Props {
+
    rid: string;
+
    status: IssueStatus;
+
  }
+

+
  const { rid, status }: Props = $props();
+
</script>
+

+
{#if $activeRouteStore.resource === "repo.issues"}
+
  Issues
+
{:else}
+
  <Link route={{ resource: "repo.issues", rid, status }}>Issues</Link>
+
{/if}
modified src/views/repo/Layout.svelte
@@ -25,16 +25,15 @@

<script lang="ts">
  import type { Snippet } from "svelte";
+
  import type { Config } from "@bindings/config/Config";

  import { onMount } from "svelte";

  import Header from "@app/components/Header.svelte";
-
  import type { Config } from "@bindings/config/Config";

  interface Props {
    children: Snippet;
    config: Config;
-
    headerCenter?: Snippet;
    secondColumn: Snippet;
    sidebar?: Snippet;
    loadMoreContent?: () => Promise<void>;
@@ -42,12 +41,12 @@
    notificationCount: number;
    hideSidebar?: boolean;
    styleSecondColumnOverflow?: string;
+
    breadcrumbs?: Snippet;
  }

  const {
    children,
    config,
-
    headerCenter = undefined,
    secondColumn,
    sidebar = undefined,
    loadMoreContent = undefined,
@@ -55,6 +54,7 @@
    notificationCount,
    hideSidebar = false,
    styleSecondColumnOverflow = "scroll",
+
    breadcrumbs,
  }: Props = $props();

  let contentContainer: HTMLElement | undefined = $state();
@@ -136,7 +136,7 @@

<div class="layout">
  <div class="header">
-
    <Header {config} center={headerCenter} {notificationCount}></Header>
+
    <Header {breadcrumbs} {config} {notificationCount}></Header>
  </div>

  {#if sidebar}
modified src/views/repo/Patch.svelte
@@ -17,9 +17,11 @@
  import { DEFAULT_TAKE } from "./router";
  import { announce } from "@app/components/AnnounceSwitch.svelte";
  import {
+
    explorerUrl,
    formatOid,
    patchStatusBackgroundColor,
    patchStatusColor,
+
    verdictIcon,
  } from "@app/lib/utils";
  import { invoke } from "@app/lib/invoke";
  import { modifierKey } from "@app/lib/utils";
@@ -28,14 +30,14 @@
  import AssigneeInput from "@app/components/AssigneeInput.svelte";
  import Border from "@app/components/Border.svelte";
  import CheckoutPatchButton from "@app/components/CheckoutPatchButton.svelte";
-
  import CopyableId from "@app/components/CopyableId.svelte";
  import EditableTitle from "@app/components/EditableTitle.svelte";
  import Icon from "@app/components/Icon.svelte";
+
  import InlineTitle from "@app/components/InlineTitle.svelte";
  import LabelInput from "@app/components/LabelInput.svelte";
-
  import Layout from "./Layout.svelte";
  import Link from "@app/components/Link.svelte";
  import NakedButton from "@app/components/NakedButton.svelte";
  import NewPatchButton from "@app/components/NewPatchButton.svelte";
+
  import NodeBreadcrumb from "@app/components/NodeBreadcrumb.svelte";
  import PatchStateButton from "@app/components/PatchStateButton.svelte";
  import PatchStateFilterButton from "@app/components/PatchStateFilterButton.svelte";
  import PatchTeaser from "@app/components/PatchTeaser.svelte";
@@ -48,6 +50,13 @@
  import Tab from "@app/components/Tab.svelte";
  import TextInput from "@app/components/TextInput.svelte";

+
  import Layout from "./Layout.svelte";
+
  import PatchesBreadcrumb from "./PatchesBreadcrumb.svelte";
+
  import RepoBreadcrumb from "./RepoBreadcrumb.svelte";
+
  import BreadcrumbCopyButton from "./BreadcrumbCopyButton.svelte";
+
  import MoreBreadcrumbsButton from "@app/components/MoreBreadcrumbsButton.svelte";
+
  import DropdownListItem from "@app/components/DropdownListItem.svelte";
+

  interface Props {
    repo: RepoInfo;
    patch: Patch;
@@ -285,6 +294,30 @@
      all: true,
    }),
  );
+
  function breadcrumbIcon() {
+
    if (selectedRevision.id === revisions[0].id || tab === "patch") {
+
      return patch.state.status === "open"
+
        ? ("patch" as const)
+
        : (`patch-${patch.state.status}` as const);
+
    } else {
+
      return "revision";
+
    }
+
  }
+
  function breadcrumbTitle() {
+
    if (tab === "patch") {
+
      if (revisions[0].description.slice(-1)[0].body.trim() === "") {
+
        return formatOid(revisions[0].id);
+
      } else {
+
        return revisions[0].description.slice(-1)[0].body.trim();
+
      }
+
    } else {
+
      if (selectedRevision.description.slice(-1)[0].body.trim() === "") {
+
        return formatOid(selectedRevision.id);
+
      } else {
+
        return selectedRevision.description.slice(-1)[0].body.trim();
+
      }
+
    }
+
  }
</script>

<style>
@@ -324,9 +357,94 @@
  }
</style>

-
<Layout {notificationCount} loadMoreSecondColumn={loadMoreTeasers} {config}>
-
  {#snippet headerCenter()}
-
    <CopyableId id={patch.id} />
+
<Layout {config} loadMoreSecondColumn={loadMoreTeasers} {notificationCount}>
+
  {#snippet breadcrumbs()}
+
    <div
+
      class="global-flex global-hide-on-medium-desktop-down"
+
      style:gap="0.25rem">
+
      <NodeBreadcrumb {config} />
+
      <Icon name="chevron-right" />
+
      <RepoBreadcrumb name={project.data.name} rid={repo.rid} />
+
      <Icon name="chevron-right" />
+
      <PatchesBreadcrumb rid={repo.rid} {status} />
+
      <Icon name="chevron-right" />
+
    </div>
+
    <div
+
      class="global-flex global-hide-on-desktop-up"
+
      style:gap="0.25rem"
+
      style:margin-right="0.5rem">
+
      <MoreBreadcrumbsButton>
+
        <DropdownListItem styleGap="0.5rem" selected={false} styleWidth="100%">
+
          <NodeBreadcrumb {config} />
+
        </DropdownListItem>
+
        <DropdownListItem styleGap="0.5rem" selected={false} styleWidth="100%">
+
          <Icon name="repo" />
+
          <RepoBreadcrumb name={project.data.name} rid={repo.rid} />
+
        </DropdownListItem>
+
        <DropdownListItem styleGap="0.5rem" selected={false} styleWidth="100%">
+
          <Icon
+
            name={status === "open" || status === undefined
+
              ? "patch"
+
              : `patch-${status}`} />
+
          <PatchesBreadcrumb rid={repo.rid} {status} />
+
        </DropdownListItem>
+
      </MoreBreadcrumbsButton>
+
    </div>
+
    <span class="txt-overflow" style:max-width="8rem">
+
      {#if review || selectedRevision.id !== revisions.slice(-1)[0].id}
+
        <Link
+
          route={{
+
            resource: "repo.patch",
+
            rid: repo.rid,
+
            patch: patch.id,
+
            status,
+
            reviewId: undefined,
+
          }}>
+
          <InlineTitle content={patch.title} fontSize="small" />
+
        </Link>
+
      {:else}
+
        <InlineTitle content={patch.title} fontSize="small" />
+
      {/if}
+
    </span>
+
    <Icon name="chevron-right" />
+
    {#if review}
+
      <span class="txt-overflow" style:max-width="8rem">
+
        <Link
+
          route={{
+
            resource: "repo.patch",
+
            rid: repo.rid,
+
            patch: patch.id,
+
            status,
+
            reviewId: undefined,
+
          }}>
+
          <span class="txt-overflow" style:max-width="8rem">
+
            {#if selectedRevision.description.slice(-1)[0].body.trim() === ""}
+
              {formatOid(selectedRevision.id)}
+
            {:else}
+
              <InlineTitle
+
                content={selectedRevision.description.slice(-1)[0].body}
+
                fontSize="small" />
+
            {/if}
+
          </span>
+
        </Link>
+
      </span>
+
      <Icon name="chevron-right" />
+
      {review.author.alias}'s review
+
      <BreadcrumbCopyButton
+
        url={explorerUrl(`${repo.rid}/patches/${patch.id}`)}
+
        icon={verdictIcon(review.verdict)}
+
        id={review.id} />
+
    {:else}
+
      <span class="txt-overflow" style:max-width="8rem">
+
        <InlineTitle content={breadcrumbTitle()} fontSize="small" />
+
      </span>
+
      <BreadcrumbCopyButton
+
        url={explorerUrl(`${repo.rid}/patches/${patch.id}`)}
+
        icon={breadcrumbIcon()}
+
        id={revisions[0].id === selectedRevision.id || tab === "patch"
+
          ? patch.id
+
          : selectedRevision.id} />
+
    {/if}
  {/snippet}

  {#snippet sidebar()}
@@ -340,50 +458,33 @@
      style:min-width="450px"
      style:min-height="2.5rem"
      style:margin-bottom="1rem">
-
      <div
-
        class="global-flex"
-
        style:font-weight="var(--font-weight-medium)"
-
        style:gap="4px"
-
        style:white-space="nowrap">
-
        {project.data.name}
-
        <Icon name="chevron-right" />
-
        <Link
-
          underline={false}
-
          route={{
-
            resource: "repo.patches",
-
            rid: repo.rid,
-
            status: "open",
-
          }}>
-
          Patches
-
        </Link>
-
      </div>
+
      <PatchStateFilterButton
+
        counters={project.meta.patches}
+
        {status}
+
        select={async selectedState => {
+
          await loadPatches(selectedState);
+
        }} />
+
      <NakedButton
+
        styleHeight="2.5rem"
+
        keyShortcuts="ctrl+f"
+
        variant="ghost"
+
        active={showFilters}
+
        onclick={() => {
+
          if (showFilters) {
+
            showFilters = false;
+
            searchInput = "";
+
          } else {
+
            showFilters = true;
+
          }
+
        }}>
+
        <Icon name="filter" />
+
      </NakedButton>
      <div class="global-flex" style:margin-left="auto">
-
        <NakedButton
-
          styleHeight="2.5rem"
-
          keyShortcuts="ctrl+f"
-
          variant="ghost"
-
          active={showFilters}
-
          onclick={() => {
-
            if (showFilters) {
-
              showFilters = false;
-
              searchInput = "";
-
            } else {
-
              showFilters = true;
-
            }
-
          }}>
-
          <Icon name="filter" />
-
        </NakedButton>
        <NewPatchButton rid={repo.rid} outline />
      </div>
    </div>
    {#if showFilters}
      <div class="global-flex" style:margin="1rem 0">
-
        <PatchStateFilterButton
-
          counters={project.meta.patches}
-
          {status}
-
          select={async selectedState => {
-
            await loadPatches(selectedState);
-
          }} />
        {#if patchTeasers.length > 0}
          <TextInput
            onFocus={async () => {
modified src/views/repo/Patches.svelte
@@ -10,17 +10,21 @@
  import * as router from "@app/lib/router";
  import { DEFAULT_TAKE } from "./router";
  import { invoke } from "@app/lib/invoke";
-
  import { modifierKey } from "@app/lib/utils";
+
  import { explorerUrl, modifierKey } from "@app/lib/utils";

  import Border from "@app/components/Border.svelte";
-
  import CopyableId from "@app/components/CopyableId.svelte";
  import Icon from "@app/components/Icon.svelte";
-
  import Layout from "./Layout.svelte";
  import NewPatchButton from "@app/components/NewPatchButton.svelte";
+
  import NodeBreadcrumb from "@app/components/NodeBreadcrumb.svelte";
  import PatchTeaser from "@app/components/PatchTeaser.svelte";
  import PatchesSecondColumn from "@app/components/PatchesSecondColumn.svelte";
  import TextInput from "@app/components/TextInput.svelte";

+
  import BreadcrumbCopyButton from "./BreadcrumbCopyButton.svelte";
+
  import Layout from "./Layout.svelte";
+
  import PatchesBreadcrumb from "./PatchesBreadcrumb.svelte";
+
  import RepoBreadcrumb from "./RepoBreadcrumb.svelte";
+

  interface Props {
    repo: RepoInfo;
    patches: PaginatedQuery<Patch[]>;
@@ -124,8 +128,16 @@
  hideSidebar
  styleSecondColumnOverflow="visible"
  {config}>
-
  {#snippet headerCenter()}
-
    <CopyableId id={repo.rid} />
+
  {#snippet breadcrumbs()}
+
    <NodeBreadcrumb {config} />
+
    <Icon name="chevron-right" />
+
    <RepoBreadcrumb name={project.data.name} rid={repo.rid} />
+
    <Icon name="chevron-right" />
+
    <PatchesBreadcrumb rid={repo.rid} {status} />
+
    <BreadcrumbCopyButton
+
      url={explorerUrl(`${repo.rid}/patches`)}
+
      icon="repo"
+
      id={repo.rid} />
  {/snippet}

  {#snippet secondColumn()}
@@ -136,7 +148,7 @@
    <div class="header">
      Patches

-
      <div class="global-flex" style:margin-left="auto">
+
      <div class="global-flex" style:margin-left="auto" style:gap="0.75rem">
        {#if items.length > 0}
          <TextInput
            onFocus={async () => {
added src/views/repo/PatchesBreadcrumb.svelte
@@ -0,0 +1,20 @@
+
<script lang="ts">
+
  import type { PatchStatus } from "./router";
+

+
  import { activeRouteStore } from "@app/lib/router";
+

+
  import Link from "@app/components/Link.svelte";
+

+
  interface Props {
+
    rid: string;
+
    status: PatchStatus | undefined;
+
  }
+

+
  const { rid, status }: Props = $props();
+
</script>
+

+
{#if $activeRouteStore.resource === "repo.patches"}
+
  Patches
+
{:else}
+
  <Link route={{ resource: "repo.patches", rid, status }}>Patches</Link>
+
{/if}
added src/views/repo/RepoBreadcrumb.svelte
@@ -0,0 +1,24 @@
+
<script lang="ts">
+
  import { activeRouteStore } from "@app/lib/router";
+

+
  import { explorerUrl } from "@app/lib/utils";
+

+
  import Link from "@app/components/Link.svelte";
+
  import BreadcrumbCopyButton from "./BreadcrumbCopyButton.svelte";
+

+
  interface Props {
+
    name: string;
+
    rid: string;
+
  }
+

+
  const { name, rid }: Props = $props();
+
</script>
+

+
{#if $activeRouteStore.resource === "repo.home"}
+
  {name}
+
  <BreadcrumbCopyButton icon="repo" id={rid} url={explorerUrl(`${rid}`)} />
+
{:else}
+
  <Link route={{ resource: "repo.home", rid }}>
+
    {name}
+
  </Link>
+
{/if}
modified src/views/repo/RepoHome.svelte
@@ -5,15 +5,17 @@

  import Border from "@app/components/Border.svelte";
  import CheckoutRepoButton from "@app/components/CheckoutRepoButton.svelte";
-
  import CopyableId from "@app/components/CopyableId.svelte";
  import File from "@app/components/File.svelte";
  import Icon from "@app/components/Icon.svelte";
-
  import Layout from "./Layout.svelte";
  import Markdown from "@app/components/Markdown.svelte";
+
  import NodeBreadcrumb from "@app/components/NodeBreadcrumb.svelte";
  import Path from "@app/components/Path.svelte";
  import RepoHomeSecondColumn from "@app/components/RepoHomeSecondColumn.svelte";
  import RepoMetadata from "@app/components/RepoMetadata.svelte";

+
  import Layout from "./Layout.svelte";
+
  import RepoBreadcrumb from "./RepoBreadcrumb.svelte";
+

  interface Props {
    config: Config;
    readme: Readme | null;
@@ -39,12 +41,14 @@
</style>

<Layout
-
  {notificationCount}
  {config}
  hideSidebar
-
  styleSecondColumnOverflow="visible">
-
  {#snippet headerCenter()}
-
    <CopyableId id={repo.rid} />
+
  styleSecondColumnOverflow="visible"
+
  {notificationCount}>
+
  {#snippet breadcrumbs()}
+
    <NodeBreadcrumb {config} />
+
    <Icon name="chevron-right" />
+
    <RepoBreadcrumb name={project.data.name} rid={repo.rid} />
  {/snippet}

  {#snippet secondColumn()}