Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Implement repo seeding
Rūdolfs Ošiņš committed 10 months ago
commit 299adcaccbc510517ad1c9d877ded4df326e5a35
parent 30f586a
24 files changed +1157 -637
modified crates/radicle-tauri/src/commands/repo.rs
@@ -129,3 +129,18 @@ pub(crate) async fn create_repo(

    Ok(())
}
+

+
#[tauri::command]
+
pub fn seed(ctx: tauri::State<AppState>, rid: RepoId) -> Result<(), Error> {
+
    ctx.seed(rid)
+
}
+

+
#[tauri::command]
+
pub fn unseed(ctx: tauri::State<AppState>, rid: RepoId) -> Result<(), Error> {
+
    ctx.unseed(rid)
+
}
+

+
#[tauri::command]
+
pub fn seeded_not_replicated(ctx: tauri::State<AppState>) -> Result<Vec<RepoId>, Error> {
+
    ctx.seeded_not_replicated()
+
}
modified crates/radicle-tauri/src/lib.rs
@@ -57,6 +57,9 @@ pub fn run() {
            repo::repo_by_id,
            repo::repo_count,
            repo::repo_readme,
+
            repo::seed,
+
            repo::seeded_not_replicated,
+
            repo::unseed,
            startup::startup,
            startup::version,
            startup::check_radicle_cli,
modified crates/radicle-types/src/traits/repo.rs
@@ -8,7 +8,7 @@ use radicle::node::routing::Store;
use radicle::patch::cache::Patches as _;
use radicle::storage;
use radicle::storage::{ReadRepository, ReadStorage, RepositoryInfo};
-
use radicle::{git, identity};
+
use radicle::{git, identity, node};

use crate::cobs;
use crate::diff;
@@ -295,4 +295,35 @@ pub trait Repo: Profile {

        Ok(commits)
    }
+

+
    fn unseed(&self, rid: identity::RepoId) -> Result<(), Error> {
+
        let profile = self.profile();
+
        let mut node = radicle::Node::new(profile.socket());
+

+
        profile.unseed(rid, &mut node)?;
+

+
        Ok(())
+
    }
+

+
    fn seed(&self, rid: identity::RepoId) -> Result<(), Error> {
+
        let profile = self.profile();
+
        let mut node = radicle::Node::new(profile.socket());
+

+
        profile.seed(rid, node::policy::Scope::All, &mut node)?;
+

+
        Ok(())
+
    }
+

+
    fn seeded_not_replicated(&self) -> Result<Vec<identity::RepoId>, Error> {
+
        let profile = &self.profile();
+
        let storage = &profile.storage;
+
        let policies = profile.policies()?;
+
        let entries = policies
+
            .seed_policies()?
+
            .filter(|policy| !storage.contains(&policy.rid).unwrap_or(false))
+
            .map(|policy| policy.rid)
+
            .collect::<Vec<_>>();
+

+
        Ok(entries)
+
    }
}
added src/components/AddRepoButton.svelte
@@ -0,0 +1,294 @@
+
<script lang="ts" module>
+
  export const addRepoPopoverToggleId = "add-repo-popover-toggle";
+
</script>
+

+
<script lang="ts">
+
  import type { RepoInfo } from "@bindings/repo/RepoInfo";
+

+
  import { z } from "zod";
+

+
  import useLocalStorage from "@app/lib/useLocalStorage.svelte";
+
  import { announce } from "@app/components/AnnounceSwitch.svelte";
+
  import { closeFocused } from "./Popover.svelte";
+
  import { invoke } from "@app/lib/invoke";
+
  import { nodeRunning } from "@app/lib/events";
+
  import { parseRepositoryId, twemoji } from "@app/lib/utils";
+

+
  import Border from "./Border.svelte";
+
  import Button from "./Button.svelte";
+
  import Command from "./Command.svelte";
+
  import ExternalLink from "./ExternalLink.svelte";
+
  import Icon from "./Icon.svelte";
+
  import Popover from "./Popover.svelte";
+
  import Tab from "./Tab.svelte";
+
  import TextInput from "./TextInput.svelte";
+

+
  interface Props {
+
    onOpen: () => void;
+
    reload: () => Promise<void>;
+
    repos: RepoInfo[];
+
    seededNotReplicated: string[];
+
  }
+

+
  const { onOpen, reload, repos, seededNotReplicated }: Props = $props();
+

+
  let popoverExpanded: boolean = $state(false);
+
  let rid = $state("");
+
  let validationMessage: string | undefined = $state(undefined);
+

+
  // Clear validation message when changing the input.
+
  $effect(() => {
+
    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
+
    rid;
+

+
    validationMessage = undefined;
+
  });
+

+
  // Clear input when the popover is closed.
+
  $effect(() => {
+
    if (!popoverExpanded) {
+
      rid = "";
+
    }
+
  });
+

+
  const tab = useLocalStorage(
+
    "addRepoPopoverSelectedTab",
+
    z.union([z.literal("seed"), z.literal("publish")]),
+
    "seed",
+
    !window.localStorage,
+
  );
+

+
  async function submit() {
+
    const trimmedRid = rid.trim();
+

+
    if (trimmedRid === "") {
+
      return;
+
    }
+

+
    validationMessage = validate(trimmedRid);
+

+
    if (validationMessage === undefined) {
+
      await seed(trimmedRid);
+
      await reload();
+
      rid = "";
+
      closeFocused();
+
    }
+
  }
+

+
  async function seed(rid: string) {
+
    try {
+
      await invoke<null>("seed", {
+
        rid: rid,
+
        opts: { announce: $nodeRunning && $announce },
+
      });
+
    } catch (error) {
+
      console.error("Seeding failed", error);
+
    }
+
  }
+

+
  function validate(rid: string): string | undefined {
+
    const parsedRid = parseRepositoryId(rid);
+
    if (parsedRid === undefined) {
+
      return "RID is not valid";
+
    }
+

+
    if (seededNotReplicated.includes(rid)) {
+
      return "This repo is already queued for fetching";
+
    }
+
    if (repos.map(r => r.rid).includes(rid)) {
+
      return "This repo is already seeded";
+
    }
+
  }
+
</script>
+

+
<style>
+
  li {
+
    padding: 0;
+
  }
+
</style>
+

+
<Popover
+
  popoverPositionRight="0"
+
  popoverPositionTop="3rem"
+
  bind:expanded={popoverExpanded}>
+
  {#snippet toggle(onclick)}
+
    <Button
+
      id={addRepoPopoverToggleId}
+
      styleHeight="2.5rem"
+
      variant="secondary"
+
      onclick={() => {
+
        onOpen();
+
        onclick();
+
      }}
+
      active={popoverExpanded}>
+
      <Icon name="add" />Add repo
+
    </Button>
+
  {/snippet}
+

+
  {#snippet popover()}
+
    <Border
+
      stylePosition="relative"
+
      variant="ghost"
+
      flatBottom
+
      styleDisplay="flex"
+
      styleWidth="100%"
+
      styleGap="1rem"
+
      styleMinWidth="27rem"
+
      stylePadding="0 1rem">
+
      <Tab
+
        active={tab.value === "seed"}
+
        onclick={() => {
+
          tab.value = "seed";
+
        }}>
+
        Seed a repo
+
      </Tab>
+
      <Tab
+
        active={tab.value === "publish"}
+
        onclick={() => {
+
          tab.value = "publish";
+
        }}>
+
        Publish existing repo
+
      </Tab>
+
    </Border>
+

+
    <div style:margin-top="-2px">
+
      <Border
+
        variant="ghost"
+
        flatTop
+
        stylePadding="1rem"
+
        styleDisplay="block"
+
        styleFlexDirection="column"
+
        styleAlignItems="flex-start">
+
        <div class="txt-small" style:line-height="1.625rem">
+
          {#if tab.value === "seed"}
+
            <!-- prettier-ignore -->
+
            <div style:margin-bottom="1rem">
+
              You can search for Radicle repos by name or description at
+
              <ExternalLink href="https://search.radicle.xyz">
+
                search.radicle.xyz
+
              </ExternalLink>.
+
            </div>
+
            <div style:width="100%">
+
              <div class="txt-semibold" style:margin-bottom="0.5rem"></div>
+
              <div
+
                class="global-flex"
+
                style:flex-direction="column"
+
                style:align-items="flex-start"
+
                style:gap="1rem">
+
                <div style:width="100%">
+
                  <div class="global-flex" style:width="100%">
+
                    <TextInput
+
                      autofocus
+
                      valid={validationMessage === undefined}
+
                      bind:value={rid}
+
                      onSubmit={submit}
+
                      placeholder="RID, e.g. rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5" />
+
                    <Button
+
                      variant="ghost"
+
                      styleHeight="2.5rem"
+
                      onclick={submit}
+
                      disabled={rid.trim() === ""}>
+
                      Seed
+
                    </Button>
+
                  </div>
+
                  {#if validationMessage}
+
                    <div
+
                      class="txt-small global-flex"
+
                      style:color="var(--color-foreground-red)"
+
                      style:padding="0.25rem 0 0 0.25rem"
+
                      style:gap="0.25rem">
+
                      <Icon name="warning" />
+
                      {validationMessage}
+
                    </div>
+
                  {/if}
+
                </div>
+
              </div>
+
            </div>
+
            <div
+
              class="global-flex txt-missing"
+
              style:align-items="flex-start"
+
              style:margin-top="2rem">
+
              <span style:margin-top="0.25rem">
+
                <Icon name="info" />
+
              </span>
+
              By seeding a repository, your node fetches it from the network, allowing
+
              you to interact with it locally while also making it available to others.
+
            </div>
+
            {#if !$nodeRunning}
+
              <div
+
                class="global-flex txt-missing"
+
                style:align-items="flex-start"
+
                style:margin-top="1rem">
+
                <span style:margin-top="0.25rem">
+
                  <Icon name="bulb" />
+
                </span>
+

+
                <div>
+
                  Your node is
+
                  <span class="txt-semibold">
+
                    <span
+
                      style:display="inline-block"
+
                      style:vertical-align="text-top">
+
                      <Icon name="offline" />
+
                    </span>
+
                    Offline.
+
                  </span>
+
                  You can still add repos, but they will only be fetched once your
+
                  node is back online.
+
                </div>
+
              </div>
+
            {/if}
+
          {:else if tab.value === "publish"}
+
            <p style="margin: 0 0 1rem 0">
+
              Navigate to an existing Git repo in your terminal
+
              <code
+
                style:white-space="nowrap"
+
                style:padding="0.125rem 0.25rem"
+
                style:background-color="var(--color-fill-ghost)">
+
                cd path/to/your/repo
+
              </code>
+
              and run the following command:
+
            </p>
+

+
            <Command styleWidth="fit-content" command="rad init" />
+

+
            <p style="margin: 1rem 0 0 0">
+
              Follow the setup prompts to initialize the repo and publish it on
+
              the Radicle network:
+
            </p>
+

+
            <ul style:padding="0 1rem">
+
              <li>
+
                <strong>Repository Name:</strong>
+
                The name of your repo.
+
              </li>
+
              <li>
+
                <strong>Description:</strong>
+
                A brief summary of what your repo does.
+
              </li>
+
              <!-- prettier-ignore -->
+
              <li>
+
                <strong>Default Branch:</strong>
+
                Typically
+
                <strong>main</strong>
+
                or
+
                <strong>master</strong>.
+
              </li>
+
              <li>
+
                <strong>Visibility:</strong>
+
                Choose
+
                <strong>public</strong>
+
                to share with others or
+
                <strong>private</strong>
+
                to not publish it to the network yet.
+
              </li>
+
            </ul>
+
            <p use:twemoji style:margin="2rem 0 0 0">
+
              That's it! Your repo is now on the Radicle network. 🚀
+
            </p>
+
          {/if}
+
        </div>
+
      </Border>
+
    </div>
+
  {/snippet}
+
</Popover>
modified src/components/Button.svelte
@@ -2,6 +2,7 @@
  import type { Snippet } from "svelte";

  interface Props {
+
    id?: string;
    children: Snippet;
    variant: "primary" | "secondary" | "ghost" | "success" | "danger";
    onclick?: () => void;
@@ -14,6 +15,7 @@
  }

  const {
+
    id,
    children,
    variant,
    onclick = undefined,
@@ -371,6 +373,7 @@

<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
+
  {id}
  class="container active"
  style:cursor={!disabled ? "pointer" : "default"}
  style:height={styleHeight}
added src/components/GuideButton.svelte
@@ -0,0 +1,148 @@
+
<script lang="ts" module>
+
  export const guidePopoverToggleId = "guide-popover-toggle";
+
</script>
+

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

+
  import { activeRouteStore, push } from "@app/lib/router";
+
  import { addRepoPopoverToggleId } from "./AddRepoButton.svelte";
+
  import { didFromPublicKey, truncateDid } from "@app/lib/utils";
+
  import { nodeRunning } from "@app/lib/events";
+
  import { radicleInstalled } from "@app/lib/checkRadicleCLI.svelte";
+
  import { sleep } from "@app/lib/sleep";
+

+
  import Border from "@app/components/Border.svelte";
+
  import Command from "@app/components/Command.svelte";
+
  import CopyableId from "@app/components/CopyableId.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import NakedButton from "@app/components/NakedButton.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+

+
  interface Props {
+
    config: Config;
+
  }
+
  const { config }: Props = $props();
+

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

+
<style>
+
  .spacer {
+
    width: 100%;
+
    border-bottom: 1px solid var(--color-border-default);
+
    height: 1px;
+
    margin: 1rem 0;
+
  }
+

+
  button {
+
    text-decoration: underline;
+
    border: 0;
+
    color: var(--color-foreground-contrast);
+
    margin: 0;
+
    padding: 0;
+
    background-color: transparent;
+
    cursor: pointer;
+
  }
+
</style>
+

+
<Popover
+
  popoverPadding="0"
+
  popoverPositionTop="2.5rem"
+
  bind:expanded={popoverExpanded}
+
  popoverPositionRight="-9.3rem">
+
  {#snippet toggle(onclick)}
+
    <NakedButton
+
      id={guidePopoverToggleId}
+
      variant="ghost"
+
      {onclick}
+
      stylePadding="0 0.25rem"
+
      active={popoverExpanded}>
+
      <Icon name="info" /> Guide
+
    </NakedButton>
+
  {/snippet}
+
  {#snippet popover()}
+
    <Border
+
      variant="ghost"
+
      styleGap="0"
+
      stylePadding="1rem"
+
      styleMinWidth="32rem"
+
      styleBackgroundColor="var(--color-background-float)"
+
      styleOverflow="auto"
+
      styleMaxHeight="calc(100vh - 5rem)"
+
      styleAlignItems="flex-start"
+
      styleFlexDirection="column">
+
      <div
+
        style:position="relative"
+
        style:display="flex"
+
        style:line-height="1.625rem"
+
        style:gap="0.5rem"
+
        style:flex-direction="column"
+
        style:width="100%">
+
        <div class="txt-semibold txt-medium" style:margin-bottom="1rem">
+
          Get started
+
        </div>
+
        <div class="txt-small" style:display="inline">
+
          Hello <span style:padding-left="0.25rem">
+
            <NodeId inline publicKey={config.publicKey} alias={config.alias} />,
+
          </span>
+
          your identity has been created and stored on your machine.
+
        </div>
+
        <div class="txt-small">
+
          Your public key is <CopyableId
+
            inline
+
            id={didFromPublicKey(config.publicKey)}>
+
            {truncateDid(config.publicKey)}
+
          </CopyableId>
+
          you can share this with anyone to find you on the network.
+
        </div>
+
        <div class="spacer"></div>
+
        {#if radicleInstalled() || $nodeRunning}
+
          <div class="global-flex txt-small">
+
            <div class="global-flex">
+
              <Icon name="thumb-up" />Radicle CLI is installed, you're good to
+
              go.
+
            </div>
+
            <button
+
              class="txt-small"
+
              onclick={async () => {
+
                if ($activeRouteStore.resource !== "home") {
+
                  await push({
+
                    resource: "home",
+
                    activeTab: "all",
+
                  });
+
                }
+
                await sleep(1);
+
                const addRepoButton = document.getElementById(
+
                  addRepoPopoverToggleId,
+
                );
+
                addRepoButton?.click();
+
              }}>
+
              Try adding a repo!
+
            </button>
+
          </div>
+
        {:else}
+
          <div class="txt-small">
+
            <div class="global-flex" style:padding-bottom="1rem">
+
              <Icon name="warning" />Radicle CLI is not installed
+
            </div>
+
            <div style:padding-bottom="1rem">
+
              To interact with repositories on the Radicle network, you’ll need
+
              to install Radicle node along with its accompanying CLI tools. The
+
              node runs in the background, enabling seamless pushing and pulling
+
              of changes, while the CLI tools let you manage the node and
+
              provide interoperability between Git and Radicle.
+
            </div>
+
            <div style:padding-bottom="0.5rem">
+
              To install Radicle node and CLI tooling, run this in your shell:
+
            </div>
+
            <Command
+
              styleWidth="fit-content"
+
              command="curl -sSf https://radicle.xyz/install | sh" />
+
          </div>
+
        {/if}
+
      </div>
+
    </Border>
+
  {/snippet}
+
</Popover>
modified src/components/Header.svelte
@@ -9,11 +9,13 @@

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

  import Icon from "@app/components/Icon.svelte";
-
  import InboxPopover from "@app/components/InboxPopover.svelte";
-
  import InfoButton from "@app/components/InfoButton.svelte";
+
  import InboxButton from "@app/components/InboxButton.svelte";
+
  import GuideButton, {
+
    guidePopoverToggleId,
+
  } from "@app/components/GuideButton.svelte";
  import NakedButton from "@app/components/NakedButton.svelte";
  import NodeStatusButton from "@app/components/NodeStatusButton.svelte";

@@ -41,7 +43,9 @@
    }

    if (firstLaunchStorage.value === true) {
-
      setFocused("popover-guide");
+
      const guidePopoverButton = document.getElementById(guidePopoverToggleId);
+
      await sleep(1);
+
      guidePopoverButton?.click();
      firstLaunchStorage.value = false;
    }
  });
@@ -108,9 +112,9 @@
      </div>

      <div class="global-flex">
-
        <InfoButton {config} />
+
        <GuideButton {config} />
        <NodeStatusButton />
-
        <InboxPopover {notificationCount} />
+
        <InboxButton {notificationCount} />
      </div>
    </div>
  </div>
modified src/components/Icon.svelte
@@ -17,6 +17,7 @@
      | "branch"
      | "broom"
      | "broom-double"
+
      | "bulb"
      | "checkbox-checked"
      | "checkbox-unchecked"
      | "checkmark"
@@ -44,6 +45,7 @@
      | "file"
      | "filter"
      | "home"
+
      | "hourglass"
      | "inbox"
      | "info"
      | "issue"
@@ -258,6 +260,24 @@
    <path d="M9 3H10V9H9V3Z" />
    <path d="M10 3H11V9H10V3Z" />
    <path d="M9 2H11V3H9V2Z" />
+
  {:else if name === "bulb"}
+
    <path d="M9 13.5H7V14.5H9V13.5Z" />
+
    <path d="M5 7.50003V9.50003H4L4 7.50003H5Z" />
+
    <path d="M4 4.50003L4 7.50003H3L3 4.50003H4Z" />
+
    <path d="M12 4.50003V7.50003H13V4.50003H12Z" />
+
    <path d="M11 7.50003V9.50003L12 9.50003V7.50003H11Z" />
+
    <path d="M5 4.50003L4 4.50003L4 3.50003L5 3.50003L5 4.50003Z" />
+
    <path d="M11 4.50003H12V3.50003H11V4.50003Z" />
+
    <path d="M6 9.50003V10.5H5L5 9.50003H6Z" />
+
    <path d="M6 2.50003L10 2.50003V1.50003L6 1.50003L6 2.50003Z" />
+
    <path d="M10 9.50003L11 9.50003V10.5H10V9.50003Z" />
+
    <path d="M9 10.5H10V13.5H9L9 10.5Z" />
+
    <path d="M6 10.5H7V13.5H6L6 10.5Z" />
+
    <path d="M7 10.5H10L10 11.5H7V10.5Z" />
+
    <path d="M10 2.50003H12V3.50003L10 3.50003L10 2.50003Z" />
+
    <path d="M4 2.50003L6 2.50003V3.50003L4 3.50003L4 2.50003Z" />
+
    <path d="M7 7.50003H9V9.50003H7V7.50003Z" />
+
    <path d="M7 12.5H8V13.5H7L7 12.5Z" />
  {:else if name === "checkmark"}
    <path d="M7 11V12H6V11H7Z" />
    <path d="M8 10V11L7 11L7 10H8Z" />
@@ -680,6 +700,29 @@
    <path d="M6 10.5H7V13.5H6V10.5Z" />
    <path d="M7 9.50003H9V10.5L7 10.5L7 9.50003Z" />
    <path d="M9 10.5L10 10.5V13.5H9L9 10.5Z" />
+
  {:else if name === "hourglass"}
+
    <path d="M13 14H3V13H13V14Z" />
+
    <path d="M3 13H2V12H3V13Z" />
+
    <path d="M14 13H13V12H14V13Z" />
+
    <path d="M4 12H3V11H4V12Z" />
+
    <path d="M11 12H5V11H6V10H10V11H11V12Z" />
+
    <path d="M13 12H12V11H13V12Z" />
+
    <path d="M5 11H4V10H5V11Z" />
+
    <path d="M12 11H11V10H12V11Z" />
+
    <path d="M6 10H5V9H6V10Z" />
+
    <path d="M11 10H10V9H11V10Z" />
+
    <path d="M7 9H6V7H7V9Z" />
+
    <path d="M10 9H9V7H10V9Z" />
+
    <path d="M6 7H5V6H6V7Z" />
+
    <path d="M10 6H9V7H7V6H6V5H10V6Z" />
+
    <path d="M11 7H10V6H11V7Z" />
+
    <path d="M5 6H4V5H5V6Z" />
+
    <path d="M12 6H11V5H12V6Z" />
+
    <path d="M4 5H3V4H4V5Z" />
+
    <path d="M13 5H12V4H13V5Z" />
+
    <path d="M3 4H2V3H3V4Z" />
+
    <path d="M14 4H13V3H14V4Z" />
+
    <path d="M13 3H3V2H13V3Z" />
  {:else if name === "inbox"}
    <path d="M2 3H3V13H2V3Z" />
    <path d="M13 3H14V13H13V3Z" />
added src/components/Inbox.svelte
@@ -0,0 +1,187 @@
+
<script lang="ts">
+
  import type { NotificationsByRepo } from "@bindings/cob/inbox/NotificationsByRepo";
+

+
  import ConfirmClear from "@app/components/ConfirmClear.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import NotificationsByRepoComponent from "@app/components/NotificationsByRepo.svelte";
+
  import NakedButton from "@app/components/NakedButton.svelte";
+

+
  interface Props {
+
    clearAll: () => Promise<void>;
+
    clearByIds: (ids: string[]) => Promise<void>;
+
    clearByRepo: (rid: string) => Promise<void>;
+
    loadNew: () => Promise<void>;
+
    notificationCount: number | undefined;
+
    notificationsByRepo: NotificationsByRepo[];
+
    showAll: (rid: string) => Promise<void>;
+
  }
+

+
  const {
+
    clearAll,
+
    clearByIds,
+
    clearByRepo,
+
    loadNew,
+
    notificationCount,
+
    notificationsByRepo,
+
    showAll,
+
  }: Props = $props();
+

+
  let pinnedRepos: string[] = $state(loadPinnedRepos());
+
  let hiddenRepos: string[] = $state(loadHiddenRepos());
+

+
  function loadPinnedRepos(): string[] {
+
    const storedPinnedRepos = localStorage
+
      ? localStorage.getItem("pinnedInboxRepos")
+
      : null;
+

+
    if (storedPinnedRepos === null) {
+
      return [];
+
    } else {
+
      return JSON.parse(storedPinnedRepos);
+
    }
+
  }
+

+
  function updatePinnedRepos(newRepos: string[]) {
+
    pinnedRepos = newRepos;
+
    localStorage.setItem("pinnedInboxRepos", JSON.stringify(newRepos));
+
  }
+

+
  function togglePin(rid: string) {
+
    const repos = loadPinnedRepos();
+
    if (repos.includes(rid)) {
+
      updatePinnedRepos(repos.filter(r => r !== rid));
+
    } else {
+
      updatePinnedRepos([rid, ...repos]);
+
    }
+
  }
+

+
  function loadHiddenRepos(): string[] {
+
    const storedHiddenRepos = localStorage
+
      ? localStorage.getItem("hiddenInboxRepos")
+
      : null;
+

+
    if (storedHiddenRepos === null) {
+
      return [];
+
    } else {
+
      return JSON.parse(storedHiddenRepos);
+
    }
+
  }
+

+
  function updateHiddenRepos(newRepos: string[]) {
+
    hiddenRepos = newRepos;
+
    localStorage.setItem("hiddenInboxRepos", JSON.stringify(newRepos));
+
  }
+

+
  function toggleHide(rid: string) {
+
    const repos = loadHiddenRepos();
+
    if (repos.includes(rid)) {
+
      updateHiddenRepos(repos.filter(r => r !== rid));
+
    } else {
+
      updateHiddenRepos([rid, ...repos]);
+
    }
+
  }
+

+
  function sortedRepos(
+
    allRepos: NotificationsByRepo[],
+
    pinned: string[],
+
    hidden: string[],
+
  ) {
+
    // Preserve pinning order.
+
    const pinnedRepos = pinned
+
      .map(p => allRepos.find(r => r.rid === p))
+
      .filter((repo): repo is NotificationsByRepo => repo !== undefined);
+

+
    const sortedRepos = allRepos
+
      .filter(r => !pinned.includes(r.rid) && !hidden.includes(r.rid))
+
      .sort((a, b) => a.name.localeCompare(b.name));
+
    const hiddenRepos = allRepos
+
      .filter(r => hidden.includes(r.rid))
+
      .sort((a, b) => a.name.localeCompare(b.name));
+

+
    return [...pinnedRepos, ...sortedRepos, ...hiddenRepos];
+
  }
+

+
  function loadedNotificationCount() {
+
    return notificationsByRepo.reduce((acc, repo) => {
+
      return acc + repo.count;
+
    }, 0);
+
  }
+
</script>
+

+
<style>
+
  .container {
+
    width: 100%;
+
  }
+
  .header {
+
    font-weight: var(--font-weight-medium);
+
    font-size: var(--font-size-medium);
+
    display: flex;
+
    align-items: center;
+
    min-height: 2rem;
+
  }
+
  .clear-inbox {
+
    margin-left: auto;
+
    margin-right: 1rem;
+
    display: none;
+
  }
+
  .header:hover .clear-inbox {
+
    display: flex;
+
  }
+
  .repo-list {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 1rem;
+
    margin-top: 1rem;
+
  }
+
</style>
+

+
<div class="container">
+
  <div class="header">
+
    <div>
+
      Inbox
+
      {#if notificationCount !== undefined && notificationCount > 0}
+
        {notificationCount}
+
      {/if}
+
    </div>
+
    {#if notificationCount === undefined || notificationCount === 0}
+
      <div
+
        class="txt-missing txt-small global-flex"
+
        style:gap="0.25rem"
+
        style:margin-left="auto">
+
        <Icon name="thumb-up" />
+
        Yay, inbox zero!
+
      </div>
+
    {/if}
+
    {#if notificationCount !== undefined && notificationCount > loadedNotificationCount()}
+
      <div class="txt-missing txt-small global-flex" style:margin-left="1rem">
+
        <NakedButton variant="ghost" onclick={loadNew}>
+
          See {notificationCount - loadedNotificationCount()} new
+
        </NakedButton>
+
      </div>
+
    {/if}
+
    {#if notificationCount && notificationCount > 0}
+
      <div class="clear-inbox">
+
        <ConfirmClear count={notificationCount} clear={clearAll} />
+
      </div>
+
    {/if}
+
  </div>
+

+
  {#if notificationCount !== undefined && notificationCount > 0}
+
    <div class="repo-list">
+
      {#each sortedRepos(notificationsByRepo, pinnedRepos, hiddenRepos) as repo}
+
        <NotificationsByRepoComponent
+
          count={repo.count}
+
          groupedNotifications={repo.notifications}
+
          hidden={hiddenRepos.includes(repo.rid)}
+
          name={repo.name}
+
          pinned={pinnedRepos.includes(repo.rid)}
+
          rid={repo.rid}
+
          {clearByIds}
+
          {clearByRepo}
+
          {showAll}
+
          {toggleHide}
+
          {togglePin} />
+
      {/each}
+
    </div>
+
  {/if}
+
</div>
added src/components/InboxButton.svelte
@@ -0,0 +1,147 @@
+
<script lang="ts">
+
  import type { NotificationsByRepo } from "@bindings/cob/inbox/NotificationsByRepo";
+

+
  import { getCurrentWindow } from "@tauri-apps/api/window";
+

+
  import { onMount } from "svelte";
+

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

+
  import Border from "./Border.svelte";
+
  import Icon from "./Icon.svelte";
+
  import Inbox from "./Inbox.svelte";
+
  import OutlineButton from "./OutlineButton.svelte";
+
  import Popover from "./Popover.svelte";
+

+
  interface Props {
+
    notificationCount: number;
+
  }
+

+
  let { notificationCount }: Props = $props();
+

+
  let notificationPopoverExpaneded: boolean = $state(false);
+
  let buttonActive: boolean = $state(false);
+

+
  $effect(() => {
+
    if (notificationPopoverExpaneded === false) {
+
      buttonActive = false;
+
    }
+
  });
+

+
  onMount(async () => {
+
    await loadCounter();
+
  });
+

+
  dynamicInterval("auth", loadCounter, 3_000);
+

+
  async function loadCounter() {
+
    notificationCount = await invoke<number>("notification_count");
+
    if (window.__TAURI_INTERNALS__) {
+
      await getCurrentWindow().setBadgeCount(
+
        notificationCount === 0 ? undefined : notificationCount,
+
      );
+
    }
+
  }
+

+
  let notificationsByRepo: NotificationsByRepo[] = $state([]);
+

+
  async function loadNotifications() {
+
    notificationsByRepo = await invoke<NotificationsByRepo[]>(
+
      "list_notifications",
+
      { params: { take: 100 } },
+
    );
+
  }
+

+
  async function clearAll() {
+
    try {
+
      await invoke("clear_notifications", {
+
        params: { type: "all" },
+
      });
+
    } catch (error) {
+
      console.error("Clearing notifications failed", error);
+
    } finally {
+
      await loadCounter();
+
      await loadNotifications();
+
    }
+
  }
+

+
  async function clearByRepo(rid: string) {
+
    try {
+
      await invoke("clear_notifications", {
+
        params: { type: "repo", content: rid },
+
      });
+
    } catch (error) {
+
      console.error("Clearing notifications failed", error);
+
    } finally {
+
      await loadCounter();
+
      await loadNotifications();
+
    }
+
  }
+

+
  async function clearByIds(ids: string[]) {
+
    try {
+
      await invoke("clear_notifications", {
+
        params: { type: "ids", content: ids },
+
      });
+
    } catch (error) {
+
      console.error("Clearing notifications failed", error);
+
    } finally {
+
      await loadCounter();
+
      await loadNotifications();
+
    }
+
  }
+

+
  async function showAll(rid: string) {
+
    const allNotificationsForRepo = await invoke<NotificationsByRepo[]>(
+
      "list_notifications",
+
      { params: { repos: [rid] } },
+
    );
+
    notificationsByRepo = [
+
      ...notificationsByRepo.filter(r => r.rid !== rid),
+
      ...allNotificationsForRepo,
+
    ];
+
  }
+
</script>
+

+
<Popover
+
  popoverPositionRight="0"
+
  popoverPositionTop="3rem"
+
  bind:expanded={notificationPopoverExpaneded}>
+
  {#snippet toggle(onclick)}
+
    <OutlineButton
+
      onclick={async () => {
+
        buttonActive = true;
+
        await loadNotifications();
+
        onclick();
+
      }}
+
      variant={notificationCount && notificationCount > 0
+
        ? "secondary"
+
        : "ghost"}
+
      active={buttonActive}>
+
      <Icon name="inbox" />
+
      {#if notificationCount !== undefined && notificationCount > 0}
+
        {notificationCount}
+
      {/if}
+
    </OutlineButton>
+
  {/snippet}
+

+
  {#snippet popover()}
+
    <Border
+
      variant="ghost"
+
      styleWidth="40rem"
+
      stylePadding="1rem"
+
      styleAlignItems="flex-start"
+
      styleOverflow="auto"
+
      styleMaxHeight="calc(100vh - 5rem)">
+
      <Inbox
+
        {clearAll}
+
        {clearByIds}
+
        {clearByRepo}
+
        loadNew={loadNotifications}
+
        {notificationCount}
+
        {notificationsByRepo}
+
        {showAll} />
+
    </Border>
+
  {/snippet}
+
</Popover>
deleted src/components/InboxPopover.svelte
@@ -1,147 +0,0 @@
-
<script lang="ts">
-
  import type { NotificationsByRepo } from "@bindings/cob/inbox/NotificationsByRepo";
-

-
  import { getCurrentWindow } from "@tauri-apps/api/window";
-

-
  import { onMount } from "svelte";
-

-
  import { dynamicInterval } from "@app/lib/interval";
-
  import { invoke } from "@app/lib/invoke";
-

-
  import Border from "./Border.svelte";
-
  import Icon from "./Icon.svelte";
-
  import Inbox from "@app/views/home/Inbox.svelte";
-
  import OutlineButton from "./OutlineButton.svelte";
-
  import Popover from "./Popover.svelte";
-

-
  interface Props {
-
    notificationCount: number;
-
  }
-

-
  let { notificationCount }: Props = $props();
-

-
  let notificationPopoverExpaneded: boolean = $state(false);
-
  let buttonActive: boolean = $state(false);
-

-
  $effect(() => {
-
    if (notificationPopoverExpaneded === false) {
-
      buttonActive = false;
-
    }
-
  });
-

-
  onMount(async () => {
-
    await loadCounter();
-
  });
-

-
  dynamicInterval("auth", loadCounter, 3_000);
-

-
  async function loadCounter() {
-
    notificationCount = await invoke<number>("notification_count");
-
    if (window.__TAURI_INTERNALS__) {
-
      await getCurrentWindow().setBadgeCount(
-
        notificationCount === 0 ? undefined : notificationCount,
-
      );
-
    }
-
  }
-

-
  let notificationsByRepo: NotificationsByRepo[] = $state([]);
-

-
  async function loadNotifications() {
-
    notificationsByRepo = await invoke<NotificationsByRepo[]>(
-
      "list_notifications",
-
      { params: { take: 100 } },
-
    );
-
  }
-

-
  async function clearAll() {
-
    try {
-
      await invoke("clear_notifications", {
-
        params: { type: "all" },
-
      });
-
    } catch (error) {
-
      console.error("Clearing notifications failed", error);
-
    } finally {
-
      await loadCounter();
-
      await loadNotifications();
-
    }
-
  }
-

-
  async function clearByRepo(rid: string) {
-
    try {
-
      await invoke("clear_notifications", {
-
        params: { type: "repo", content: rid },
-
      });
-
    } catch (error) {
-
      console.error("Clearing notifications failed", error);
-
    } finally {
-
      await loadCounter();
-
      await loadNotifications();
-
    }
-
  }
-

-
  async function clearByIds(ids: string[]) {
-
    try {
-
      await invoke("clear_notifications", {
-
        params: { type: "ids", content: ids },
-
      });
-
    } catch (error) {
-
      console.error("Clearing notifications failed", error);
-
    } finally {
-
      await loadCounter();
-
      await loadNotifications();
-
    }
-
  }
-

-
  async function showAll(rid: string) {
-
    const allNotificationsForRepo = await invoke<NotificationsByRepo[]>(
-
      "list_notifications",
-
      { params: { repos: [rid] } },
-
    );
-
    notificationsByRepo = [
-
      ...notificationsByRepo.filter(r => r.rid !== rid),
-
      ...allNotificationsForRepo,
-
    ];
-
  }
-
</script>
-

-
<Popover
-
  popoverPositionRight="0"
-
  popoverPositionTop="3rem"
-
  bind:expanded={notificationPopoverExpaneded}>
-
  {#snippet toggle(onclick)}
-
    <OutlineButton
-
      onclick={async () => {
-
        buttonActive = true;
-
        await loadNotifications();
-
        onclick();
-
      }}
-
      variant={notificationCount && notificationCount > 0
-
        ? "secondary"
-
        : "ghost"}
-
      active={buttonActive}>
-
      <Icon name="inbox" />
-
      {#if notificationCount !== undefined && notificationCount > 0}
-
        {notificationCount}
-
      {/if}
-
    </OutlineButton>
-
  {/snippet}
-

-
  {#snippet popover()}
-
    <Border
-
      variant="ghost"
-
      styleWidth="40rem"
-
      stylePadding="1rem"
-
      styleAlignItems="flex-start"
-
      styleOverflow="auto"
-
      styleMaxHeight="calc(100vh - 5rem)">
-
      <Inbox
-
        {clearAll}
-
        {clearByIds}
-
        {clearByRepo}
-
        loadNew={loadNotifications}
-
        {notificationCount}
-
        {notificationsByRepo}
-
        {showAll} />
-
    </Border>
-
  {/snippet}
-
</Popover>
deleted src/components/InfoButton.svelte
@@ -1,117 +0,0 @@
-
<script lang="ts">
-
  import type { Config } from "@bindings/config/Config";
-

-
  import { didFromPublicKey, truncateDid } from "@app/lib/utils";
-
  import { radicleInstalled } from "@app/lib/checkRadicleCLI.svelte";
-

-
  import Border from "@app/components/Border.svelte";
-
  import Command from "@app/components/Command.svelte";
-
  import CopyableId from "@app/components/CopyableId.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import NakedButton from "@app/components/NakedButton.svelte";
-
  import NodeId from "@app/components/NodeId.svelte";
-
  import Popover from "@app/components/Popover.svelte";
-
  import RepoGuide from "@app/components/RepoGuide.svelte";
-

-
  interface Props {
-
    config: Config;
-
  }
-
  const { config }: Props = $props();
-

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

-
<style>
-
  .guide-header {
-
    padding-bottom: 1rem;
-
  }
-
  .spacer {
-
    width: 100%;
-
    border-bottom: 1px solid var(--color-border-default);
-
    height: 1px;
-
    margin: 1rem 0;
-
  }
-
</style>
-

-
<Popover
-
  popoverId="popover-guide"
-
  popoverPadding="0"
-
  popoverPositionTop="2.5rem"
-
  bind:expanded={popoverExpanded}
-
  popoverPositionRight="-9.3rem">
-
  {#snippet toggle(onclick)}
-
    <NakedButton
-
      variant="ghost"
-
      {onclick}
-
      stylePadding="0 4px"
-
      active={popoverExpanded}>
-
      <Icon name="info" /> Guide
-
    </NakedButton>
-
  {/snippet}
-
  {#snippet popover()}
-
    <Border
-
      variant="ghost"
-
      styleGap="0"
-
      stylePadding="1rem"
-
      styleMinWidth="36rem"
-
      styleOverflow="auto"
-
      styleMaxHeight="calc(100vh - 5rem)"
-
      styleAlignItems="flex-start"
-
      styleFlexDirection="column">
-
      <div
-
        style:position="relative"
-
        style:display="flex"
-
        style:gap="0.5rem"
-
        style:flex-direction="column"
-
        style:padding="1rem"
-
        style:margin-bottom="1rem"
-
        style:width="100%"
-
        style:background-color="var(--color-background-float)">
-
        <div class="txt-semibold txt-medium" style:margin-bottom="1rem">
-
          Getting started
-
        </div>
-
        <div class="txt-small" style:display="inline">
-
          Hello <span style:padding-left="0.25rem">
-
            <NodeId inline publicKey={config.publicKey} alias={config.alias} />,
-
          </span>
-
          your identity has been created and stored on your machine.
-
        </div>
-
        <div class="txt-small">
-
          Your public key is <CopyableId
-
            inline
-
            id={didFromPublicKey(config.publicKey)}>
-
            {truncateDid(config.publicKey)}
-
          </CopyableId>
-
          you can share this with anyone to find you on the network.
-
        </div>
-
        <div class="spacer"></div>
-
        {#if radicleInstalled()}
-
          <div class="global-flex txt-small">
-
            <Icon name="checkbox-checked" />Radicle CLI is setup
-
          </div>
-
        {:else}
-
          <div class="txt-small">
-
            <div class="global-flex" style:padding-bottom="0.5rem">
-
              <Icon name="checkbox-unchecked" />Make sure to install Radicle CLI
-
            </div>
-
            <div style:padding-bottom="0.5rem">
-
              To be able to interact with repos on the Radicle network you'll
-
              need to install a node on your computer. This node will identify
-
              itself on the network with your keys to push and pull changes.
-
            </div>
-
            <div style:padding-bottom="0.5rem">
-
              To install the node and other Radicle CLI tooling, simply run the
-
              command below from your shell:
-
            </div>
-
            <Command
-
              styleWidth="fit-content"
-
              command="curl -sSf https://radicle.xyz/install | sh" />
-
          </div>
-
        {/if}
-
      </div>
-
      <div class="guide-header txt-medium txt-semibold">Guide</div>
-

-
      <RepoGuide />
-
    </Border>
-
  {/snippet}
-
</Popover>
modified src/components/NakedButton.svelte
@@ -2,6 +2,7 @@
  import type { Snippet } from "svelte";

  interface Props {
+
    id?: string;
    children: Snippet;
    title?: string;
    disabled?: boolean;
@@ -14,6 +15,7 @@
  }

  const {
+
    id,
    children,
    title,
    disabled,
@@ -240,6 +242,7 @@

<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
+
  {id}
  class="container"
  class:disabled
  class:active
modified src/components/NodeStatusButton.svelte
@@ -2,6 +2,7 @@
  import { nodeRunning } from "@app/lib/events";

  import Border from "@app/components/Border.svelte";
+
  import Command from "@app/components/Command.svelte";
  import Icon from "@app/components/Icon.svelte";
  import NakedButton from "@app/components/NakedButton.svelte";
  import Popover from "@app/components/Popover.svelte";
@@ -32,13 +33,20 @@
      styleMinWidth="20rem"
      styleAlignItems="flex-start"
      styleFlexDirection="column">
-
      <div class="txt-small txt-missing">
+
      <div class="txt-small" style:line-height="1.625rem">
        {#if $nodeRunning}
          Your node is up and running, your changes will be synced
          automatically.
        {:else}
          Your node is not running, changes you make are safe but won't be
          announced.
+

+
          <div style:margin-top="1rem">
+
            You can start your node with:
+
            <div style:margin-top="0.5rem">
+
              <Command styleWidth="fit-content" command="rad node start" />
+
            </div>
+
          </div>
        {/if}
      </div>
    </Border>
modified src/components/Popover.svelte
@@ -6,17 +6,6 @@
  export function closeFocused() {
    focused = undefined;
  }
-

-
  export function setFocused(id: string) {
-
    const thisComponent = document.querySelector(`[data-popover-id="${id}"]`);
-
    if (thisComponent) {
-
      if (focused?.element === thisComponent) {
-
        closeFocused();
-
      } else {
-
        focused = { element: thisComponent as HTMLDivElement, id };
-
      }
-
    }
-
  }
</script>

<script lang="ts">
@@ -25,7 +14,6 @@
  interface Props {
    toggle: Snippet<[() => void]>;
    popover: Snippet;
-
    popoverId?: string;
    popoverContainerMinWidth?: string;
    popoverPadding?: string;
    popoverPositionBottom?: string;
@@ -39,7 +27,6 @@
  let {
    toggle,
    popover,
-
    popoverId,
    popoverContainerMinWidth,
    popoverPadding,
    popoverPositionBottom,
@@ -50,7 +37,7 @@
  }: Props = $props();
  /* eslint-enable prefer-const */

-
  const id = popoverId ?? crypto.randomUUID();
+
  const id = crypto.randomUUID();
  let thisComponent: HTMLDivElement | undefined = $state();

  function clickOutside(ev: MouseEvent | TouchEvent) {
added src/components/RepoCardPlaceholder.svelte
@@ -0,0 +1,120 @@
+
<script lang="ts">
+
  import { formatRepositoryId } from "@app/lib/utils";
+

+
  import { invoke } from "@app/lib/invoke";
+
  import { nodeRunning } from "@app/lib/events";
+
  import { announce } from "@app/components/AnnounceSwitch.svelte";
+

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

+
  interface Props {
+
    reload: () => Promise<void>;
+
    rid: string;
+
  }
+

+
  const { reload, rid }: Props = $props();
+

+
  async function unseed() {
+
    try {
+
      await invoke<null>("unseed", {
+
        rid: rid,
+
        opts: { announce: $nodeRunning && $announce },
+
      });
+
      await reload();
+
    } catch (error) {
+
      console.error("Seeding failed", error);
+
    }
+
  }
+
</script>
+

+
<style>
+
  .unseed {
+
    display: none;
+
    color: var(--color-fill-gray);
+
    height: 1.375rem;
+
  }
+
  .container:hover .unseed {
+
    display: flex;
+
  }
+
  .header {
+
    display: flex;
+
    align-items: center;
+
    justify-content: space-between;
+
    width: 100%;
+
    gap: 0.5rem;
+
  }
+
  .footer {
+
    margin-top: 1rem;
+
  }
+
</style>
+

+
<div class="container">
+
  <Border
+
    variant="float"
+
    styleWidth="100%"
+
    styleHeight="8.375rem"
+
    styleAlignItems="flex-start"
+
    styleFlexDirection="column"
+
    styleGap="0"
+
    stylePadding="0.5rem 0.75rem"
+
    styleOverflow="hidden">
+
    <div class="header txt-small">
+
      <div class="global-flex txt-overflow">
+
        <div
+
          class="global-counter"
+
          style:background-color="var(--color-fill-ghost)">
+
        </div>
+
        <span class="global-flex" style:height="1.375rem">
+
          <div
+
            style:height="1rem"
+
            style:width="7rem"
+
            style:background-color="var(--color-fill-ghost)">
+
          </div>
+
        </span>
+
      </div>
+
      <div class="global-flex">
+
        <div class="global-flex unseed">
+
          <NakedButton
+
            stylePadding="0 0.25rem"
+
            variant="ghost"
+
            onclick={unseed}>
+
            <Icon name="broom" />
+
            Remove
+
          </NakedButton>
+
        </div>
+
      </div>
+
    </div>
+
    <div class="global-flex" style:height="1.375rem" style:margin-top="0.25rem">
+
      <div
+
        style:height="0.875rem"
+
        style:width="13rem"
+
        style:background-color="var(--color-fill-ghost)">
+
      </div>
+
    </div>
+
    <Id
+
      ariaLabel="repo-id"
+
      clipboard={rid}
+
      shorten={false}
+
      variant="oid"
+
      id={formatRepositoryId(rid)} />
+

+
    <div
+
      class="global-flex footer txt-small"
+
      style:margin-top="auto"
+
      style:width="100%">
+
      <span
+
        title={$nodeRunning
+
          ? "This may take a while depending on your network connectivity and repo size."
+
          : "Your node is offline. Start your node to fetch this repo."}
+
        class="global-flex"
+
        style:color="var(--color-fill-gray)"
+
        style:margin-left="auto">
+
        <Icon name="hourglass" />
+
        Queued for fetching…
+
      </span>
+
    </div>
+
  </Border>
+
</div>
deleted src/components/RepoGuide.svelte
@@ -1,67 +0,0 @@
-
<script lang="ts">
-
  import { z } from "zod";
-

-
  import useLocalStorage from "@app/lib/useLocalStorage.svelte";
-

-
  import clone from "@app/components/RepoGuide/clone.md?raw";
-
  import publish from "@app/components/RepoGuide/publish.md?raw";
-

-
  import Border from "@app/components/Border.svelte";
-
  import Markdown from "@app/components/Markdown.svelte";
-
  import Tab from "@app/components/Tab.svelte";
-

-
  const tab = useLocalStorage(
-
    "repoGuideTab",
-
    z.union([z.literal("clone"), z.literal("publish")]),
-
    "publish",
-
    !window.localStorage,
-
  );
-
</script>
-

-
<style>
-
  .container {
-
    overflow: scroll;
-
  }
-
  .tab {
-
    height: 1.5rem;
-
    color: var(--color-foreground-contrast);
-
  }
-
</style>
-

-
{#snippet tabSnippet(name: typeof tab.value, content: string)}
-
  <Tab
-
    active={tab.value === name}
-
    onclick={() => {
-
      tab.value = name;
-
    }}>
-
    <span class="tab">{content}</span>
-
  </Tab>
-
{/snippet}
-

-
<Border
-
  stylePosition="relative"
-
  variant="ghost"
-
  flatBottom
-
  styleDisplay="flex"
-
  styleWidth="100%"
-
  styleGap="1rem"
-
  stylePadding="0 1rem">
-
  {@render tabSnippet("clone", "Clone a repo from the network")}
-
  {@render tabSnippet("publish", "Publish existing repo")}
-
</Border>
-

-
<Border
-
  variant="ghost"
-
  flatTop
-
  stylePadding="1rem"
-
  styleDisplay="block"
-
  styleFlexDirection="column"
-
  styleAlignItems="flex-start">
-
  <div class="container txt-small">
-
    {#if tab.value === "clone"}
-
      <Markdown content={clone} />
-
    {:else if tab.value === "publish"}
-
      <Markdown content={publish} />
-
    {/if}
-
  </div>
-
</Border>
deleted src/components/RepoGuide/clone.md
@@ -1,21 +0,0 @@
-
#### 1. Find a repo on the Radicle network
-

-
You can search for Radicle repos by name or description at [search.radicle.xyz](https://search.radicle.xyz).
-

-
To clone a repo, you’ll need its Repository Identifier (RID) — a unique string that begins with `rad:`.
-

-
#### 2. Start your node
-

-
If you node is Offline, you should start it by running:
-

-
```sh
-
rad node start
-
```
-

-
#### 3. Clone the repo
-

-
To clone a repo, use the `rad clone` command followed by the RID of the repo you want to clone.
-

-
```sh
-
rad clone <RID>
-
```
deleted src/components/RepoGuide/publish.md
@@ -1,15 +0,0 @@
-
#### Publish existing repo on Radicle
-

-
Navigate to your existing Git repo and publish it to Radicle by following the setup prompts:
-

-
- **Repository Name:** Enter a name for your repo.
-
- **Description:** Provide a brief summary of what your repo does.
-
- **Default Branch:** Typically **main** or **master**.
-
- **Visibility:** Choose **public** to share with others or **private** to not publish it to the network yet.
-

-
```sh
-
cd path/to/your/repo
-
rad init
-
```
-

-
That's it! Your repo is now on the Radicle network. 🚀
modified src/components/RepoHeader.svelte
@@ -63,7 +63,7 @@
        style:padding="0 0.375rem"
        style:background-color="var(--color-fill-ghost)"
        style:gap="0.25rem">
-
        <Icon name="seedling" />
+
        <Icon name="seedling-filled" />
        {repo.seeding}
      </div>
    </div>
modified src/components/TextInput.svelte
@@ -15,6 +15,7 @@
    name?: string;
    onDismiss?: () => void;
    onFocus?: () => void;
+
    onBlur?: () => void;
    onSubmit?: () => void;
    oninput?: FormEventHandler<HTMLInputElement>;
    placeholder?: string;
@@ -33,6 +34,7 @@
    name,
    onDismiss,
    onFocus,
+
    onBlur,
    onSubmit,
    oninput,
    placeholder,
@@ -110,6 +112,9 @@
      focussed = true;
    }}
    onblur={() => {
+
      if (onBlur) {
+
        onBlur();
+
      }
      focussed = false;
    }}
    bind:this={inputElement}
deleted src/views/home/Inbox.svelte
@@ -1,187 +0,0 @@
-
<script lang="ts">
-
  import type { NotificationsByRepo } from "@bindings/cob/inbox/NotificationsByRepo";
-

-
  import ConfirmClear from "@app/components/ConfirmClear.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import NotificationsByRepoComponent from "@app/components/NotificationsByRepo.svelte";
-
  import NakedButton from "@app/components/NakedButton.svelte";
-

-
  interface Props {
-
    clearAll: () => Promise<void>;
-
    clearByIds: (ids: string[]) => Promise<void>;
-
    clearByRepo: (rid: string) => Promise<void>;
-
    loadNew: () => Promise<void>;
-
    notificationCount: number | undefined;
-
    notificationsByRepo: NotificationsByRepo[];
-
    showAll: (rid: string) => Promise<void>;
-
  }
-

-
  const {
-
    clearAll,
-
    clearByIds,
-
    clearByRepo,
-
    loadNew,
-
    notificationCount,
-
    notificationsByRepo,
-
    showAll,
-
  }: Props = $props();
-

-
  let pinnedRepos: string[] = $state(loadPinnedRepos());
-
  let hiddenRepos: string[] = $state(loadHiddenRepos());
-

-
  function loadPinnedRepos(): string[] {
-
    const storedPinnedRepos = localStorage
-
      ? localStorage.getItem("pinnedInboxRepos")
-
      : null;
-

-
    if (storedPinnedRepos === null) {
-
      return [];
-
    } else {
-
      return JSON.parse(storedPinnedRepos);
-
    }
-
  }
-

-
  function updatePinnedRepos(newRepos: string[]) {
-
    pinnedRepos = newRepos;
-
    localStorage.setItem("pinnedInboxRepos", JSON.stringify(newRepos));
-
  }
-

-
  function togglePin(rid: string) {
-
    const repos = loadPinnedRepos();
-
    if (repos.includes(rid)) {
-
      updatePinnedRepos(repos.filter(r => r !== rid));
-
    } else {
-
      updatePinnedRepos([rid, ...repos]);
-
    }
-
  }
-

-
  function loadHiddenRepos(): string[] {
-
    const storedHiddenRepos = localStorage
-
      ? localStorage.getItem("hiddenInboxRepos")
-
      : null;
-

-
    if (storedHiddenRepos === null) {
-
      return [];
-
    } else {
-
      return JSON.parse(storedHiddenRepos);
-
    }
-
  }
-

-
  function updateHiddenRepos(newRepos: string[]) {
-
    hiddenRepos = newRepos;
-
    localStorage.setItem("hiddenInboxRepos", JSON.stringify(newRepos));
-
  }
-

-
  function toggleHide(rid: string) {
-
    const repos = loadHiddenRepos();
-
    if (repos.includes(rid)) {
-
      updateHiddenRepos(repos.filter(r => r !== rid));
-
    } else {
-
      updateHiddenRepos([rid, ...repos]);
-
    }
-
  }
-

-
  function sortedRepos(
-
    allRepos: NotificationsByRepo[],
-
    pinned: string[],
-
    hidden: string[],
-
  ) {
-
    // Preserve pinning order.
-
    const pinnedRepos = pinned
-
      .map(p => allRepos.find(r => r.rid === p))
-
      .filter((repo): repo is NotificationsByRepo => repo !== undefined);
-

-
    const sortedRepos = allRepos
-
      .filter(r => !pinned.includes(r.rid) && !hidden.includes(r.rid))
-
      .sort((a, b) => a.name.localeCompare(b.name));
-
    const hiddenRepos = allRepos
-
      .filter(r => hidden.includes(r.rid))
-
      .sort((a, b) => a.name.localeCompare(b.name));
-

-
    return [...pinnedRepos, ...sortedRepos, ...hiddenRepos];
-
  }
-

-
  function loadedNotificationCount() {
-
    return notificationsByRepo.reduce((acc, repo) => {
-
      return acc + repo.count;
-
    }, 0);
-
  }
-
</script>
-

-
<style>
-
  .container {
-
    width: 100%;
-
  }
-
  .header {
-
    font-weight: var(--font-weight-medium);
-
    font-size: var(--font-size-medium);
-
    display: flex;
-
    align-items: center;
-
    min-height: 2rem;
-
  }
-
  .clear-inbox {
-
    margin-left: auto;
-
    margin-right: 1rem;
-
    display: none;
-
  }
-
  .header:hover .clear-inbox {
-
    display: flex;
-
  }
-
  .repo-list {
-
    display: flex;
-
    flex-direction: column;
-
    gap: 1rem;
-
    margin-top: 1rem;
-
  }
-
</style>
-

-
<div class="container">
-
  <div class="header">
-
    <div>
-
      Inbox
-
      {#if notificationCount !== undefined && notificationCount > 0}
-
        {notificationCount}
-
      {/if}
-
    </div>
-
    {#if notificationCount === undefined || notificationCount === 0}
-
      <div
-
        class="txt-missing txt-small global-flex"
-
        style:gap="0.25rem"
-
        style:margin-left="auto">
-
        <Icon name="thumb-up" />
-
        Yay, inbox zero!
-
      </div>
-
    {/if}
-
    {#if notificationCount !== undefined && notificationCount > loadedNotificationCount()}
-
      <div class="txt-missing txt-small global-flex" style:margin-left="1rem">
-
        <NakedButton variant="ghost" onclick={loadNew}>
-
          See {notificationCount - loadedNotificationCount()} new
-
        </NakedButton>
-
      </div>
-
    {/if}
-
    {#if notificationCount && notificationCount > 0}
-
      <div class="clear-inbox">
-
        <ConfirmClear count={notificationCount} clear={clearAll} />
-
      </div>
-
    {/if}
-
  </div>
-

-
  {#if notificationCount !== undefined && notificationCount > 0}
-
    <div class="repo-list">
-
      {#each sortedRepos(notificationsByRepo, pinnedRepos, hiddenRepos) as repo}
-
        <NotificationsByRepoComponent
-
          count={repo.count}
-
          groupedNotifications={repo.notifications}
-
          hidden={hiddenRepos.includes(repo.rid)}
-
          name={repo.name}
-
          pinned={pinnedRepos.includes(repo.rid)}
-
          rid={repo.rid}
-
          {clearByIds}
-
          {clearByRepo}
-
          {showAll}
-
          {toggleHide}
-
          {togglePin} />
-
      {/each}
-
    </div>
-
  {/if}
-
</div>
modified src/views/home/Repos.svelte
@@ -11,40 +11,57 @@
  import * as router from "@app/lib/router";
  import { didFromPublicKey, modifierKey } from "@app/lib/utils";
  import { dynamicInterval } from "@app/lib/interval";
+
  import { guidePopoverToggleId } from "@app/components/GuideButton.svelte";
  import { invoke } from "@app/lib/invoke";
-
  import { setFocused } from "@app/components/Popover.svelte";
+
  import { sleep } from "@app/lib/sleep";

+
  import AddRepoButton from "@app/components/AddRepoButton.svelte";
  import Border from "@app/components/Border.svelte";
  import HomeSidebar from "@app/components/HomeSidebar.svelte";
  import Icon from "@app/components/Icon.svelte";
  import Layout from "@app/views/repo/Layout.svelte";
+
  import NakedButton from "@app/components/NakedButton.svelte";
  import NodeBreadcrumb from "@app/components/NodeBreadcrumb.svelte";
-
  import OutlineButton from "@app/components/OutlineButton.svelte";
  import RepoCard from "@app/components/RepoCard.svelte";
+
  import RepoCardPlaceholder from "@app/components/RepoCardPlaceholder.svelte";
  import TextInput from "@app/components/TextInput.svelte";

  interface Props {
    activeTab: HomeReposTab;
    config: Config;
+
    notificationCount: number;
    repoCount: RepoCount;
    repos: RepoInfo[];
-
    notificationCount: number;
+
    seededNotReplicated: string[];
  }

  /* eslint-disable prefer-const */
-
  let { config, repos, repoCount, activeTab, notificationCount }: Props =
+
  let {
+
    activeTab,
+
    config,
+
    notificationCount,
+
    repoCount,
+
    repos,
+
    seededNotReplicated,
+
  }: Props =
    /* eslint-enable prefer-const */
    $props();

-
  let lock = false;
  const startup = $state<{ error?: ErrorWrapper }>({ error: undefined });
+
  let showFilters: boolean = $state(false);
+
  let searchInput = $state("");

-
  async function checkRepos() {
+
  let lock = false;
+

+
  async function reloadRepoList() {
    try {
      if (lock) {
        return;
      }
-
      if (repoCount.total > 0) {
+
      if (seededNotReplicated.length === 0 && repoCount.total > 0) {
+
        return;
+
      }
+
      if (searchInput !== "") {
        return;
      }
      lock = true;
@@ -58,24 +75,27 @@
  }

  onMount(() => {
-
    dynamicInterval("repos", checkRepos, 5_000);
+
    dynamicInterval("repos", reloadRepoList, 5_000);
  });

  async function reload() {
-
    [repos, repoCount, config] = await Promise.all([
+
    [repos, repoCount, config, seededNotReplicated] = await Promise.all([
      invoke<RepoInfo[]>("list_repos", { show: activeTab ?? "all" }),
      invoke<RepoCount>("repo_count"),
      invoke<Config>("config"),
+
      invoke<string[]>("seeded_not_replicated"),
    ]);
  }

-
  let searchInput = $state("");
-

  const searchableRepos = $derived(
    repos
      .flatMap(r => {
        if (r.payloads["xyz.radicle.project"]) {
-
          return { repo: r, name: r.payloads["xyz.radicle.project"].data.name };
+
          return {
+
            repo: r,
+
            name: r.payloads["xyz.radicle.project"].data.name,
+
            description: r.payloads["xyz.radicle.project"].data.description,
+
          };
        }
      })
      .filter((item): item is NonNullable<typeof item> => item !== undefined),
@@ -83,7 +103,7 @@

  const searchResults = $derived(
    fuzzysort.go(searchInput, searchableRepos, {
-
      keys: ["name", "repo.rid"],
+
      keys: ["name", "description", "repo.rid"],
      threshold: 0.5,
      all: true,
    }),
@@ -104,10 +124,19 @@
    font-size: var(--font-size-medium);
    display: flex;
    justify-content: space-between;
-
    padding-right: 1.5rem;
+
    padding-right: 0.325rem;
    align-items: center;
    min-height: 2.5rem;
  }
+
  button {
+
    text-decoration: underline;
+
    border: 0;
+
    color: var(--color-foreground-dim);
+
    margin: 0;
+
    padding: 0;
+
    background-color: transparent;
+
    cursor: pointer;
+
  }
</style>

<Layout
@@ -124,38 +153,77 @@
  {/snippet}
  <div class="container">
    <div class="global-flex" style:margin-bottom="1rem">
-
      <div class="header">Repositories</div>
-
      {#if repos.length > 0}
-
        <div class="global-flex" style:margin-left="auto">
-
          <TextInput
-
            onSubmit={async () => {
-
              if (searchResults.length === 1) {
-
                await router.push({
-
                  resource: "repo.home",
-
                  rid: searchResults[0].obj.repo.rid,
-
                });
-
              }
-
            }}
-
            onDismiss={() => {
-
              searchInput = "";
-
            }}
-
            placeholder={`Fuzzy filter repositories ${modifierKey()} + f`}
-
            keyShortcuts="ctrl+f"
-
            bind:value={searchInput}>
-
            {#snippet left()}
-
              <div
-
                style:color="var(--color-foreground-dim)"
-
                style:padding-left="0.5rem">
-
                <Icon name="filter" />
-
              </div>
-
            {/snippet}
-
          </TextInput>
-
        </div>
-
      {/if}
+
      <div class="global-flex">
+
        <div class="header">Repositories</div>
+
        {#if repos.length > 0}
+
          {#if !showFilters}
+
            <NakedButton
+
              styleHeight="2.5rem"
+
              keyShortcuts="ctrl+f"
+
              variant="ghost"
+
              active={showFilters}
+
              onclick={() => {
+
                if (showFilters) {
+
                  showFilters = false;
+
                  searchInput = "";
+
                } else {
+
                  showFilters = true;
+
                }
+
              }}>
+
              <Icon name="filter" />
+
            </NakedButton>
+
          {/if}
+
          {#if showFilters}
+
            <TextInput
+
              autofocus
+
              onSubmit={async () => {
+
                if (searchResults.length === 1) {
+
                  await router.push({
+
                    resource: "repo.home",
+
                    rid: searchResults[0].obj.repo.rid,
+
                  });
+
                }
+
              }}
+
              onDismiss={() => {
+
                searchInput = "";
+
                showFilters = false;
+
              }}
+
              onBlur={() => {
+
                if (searchInput.trim() === "") {
+
                  showFilters = false;
+
                }
+
              }}
+
              placeholder={`Fuzzy filter repositories ${modifierKey()} + f`}
+
              keyShortcuts="ctrl+f"
+
              bind:value={searchInput}>
+
              {#snippet left()}
+
                <div style:padding-left="0.5rem">
+
                  <Icon name="filter" />
+
                </div>
+
              {/snippet}
+
            </TextInput>
+
          {/if}
+
        {/if}
+
      </div>
+
      <div class="global-flex" style:margin-left="auto">
+
        <AddRepoButton
+
          {repos}
+
          {reload}
+
          {seededNotReplicated}
+
          onOpen={() => {
+
            searchInput = "";
+
            showFilters = false;
+
          }} />
+
      </div>
    </div>
-
    {#if repoCount.total > 0}
-
      {#if searchResults.length > 0}
+
    {#if repoCount.total > 0 || seededNotReplicated.length > 0}
+
      {#if searchResults.length > 0 || seededNotReplicated.length > 0}
        <div class="repo-grid">
+
          {#if !showFilters}
+
            {#each seededNotReplicated as rid}
+
              <RepoCardPlaceholder {rid} {reload} />
+
            {/each}
+
          {/if}
          {#each searchResults as result}
            <RepoCard
              focussed={searchResults.length === 1 && searchInput !== ""}
@@ -190,17 +258,21 @@
        </Border>
      {/if}
    {:else}
-
      <div class="txt-missing txt-small" style:margin-bottom="1.5rem">
-
        You don't have any repositories in your Radicle storage yet. To get
-
        started, check out the guide below.
+
      <div class="txt-missing txt-small">
+
        You don't have any repositories in your Radicle storage yet.
      </div>
-
      <div style="display: flex; gap: 1rem;">
-
        <OutlineButton
-
          popoverToggle="popover-guide"
-
          onclick={() => setFocused("popover-guide")}
-
          variant="ghost">
-
          <Icon name="info" />Guide
-
        </OutlineButton>
+
      <!-- prettier-ignore -->
+
      <div class="txt-missing txt-small">
+
        To get started, check out
+
        <button
+
          class="txt-small"
+
          onclick={async () => {
+
                const guidePopoverButton = document.getElementById(guidePopoverToggleId);
+
                await sleep(1);
+
                guidePopoverButton?.click();
+
          }}>
+
          the guide
+
        </button>.
      </div>
    {/if}
  </div>
modified src/views/home/router.ts
@@ -19,6 +19,7 @@ export interface LoadedHomeRoute {
    repos: RepoInfo[];
    config: Config;
    notificationCount: number;
+
    seededNotReplicated: string[];
  };
}

@@ -35,12 +36,14 @@ export async function loadHome(route: HomeRoute): Promise<LoadedHomeRoute> {
    }
  }

-
  const [config, repoCount, repos, notificationCount] = await Promise.all([
-
    invoke<Config>("config"),
-
    invoke<RepoCount>("repo_count"),
-
    invoke<RepoInfo[]>("list_repos", { show }),
-
    invoke<number>("notification_count"),
-
  ]);
+
  const [config, repoCount, repos, notificationCount, seededNotReplicated] =
+
    await Promise.all([
+
      invoke<Config>("config"),
+
      invoke<RepoCount>("repo_count"),
+
      invoke<RepoInfo[]>("list_repos", { show }),
+
      invoke<number>("notification_count"),
+
      invoke<string[]>("seeded_not_replicated"),
+
    ]);
  return {
    resource: "home",
    params: {
@@ -49,6 +52,7 @@ export async function loadHome(route: HomeRoute): Promise<LoadedHomeRoute> {
      repos,
      config,
      notificationCount,
+
      seededNotReplicated,
    },
  };
}