Implement repo seeding

This commit is contained in:
Rūdolfs Ošiņš
2025-06-11 14:32:58 +02:00
parent 30f586a012
commit 299adcaccb
21 changed files with 733 additions and 213 deletions
+15
View File
@@ -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()
}
+3
View File
@@ -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,
+32 -1
View File
@@ -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)
}
}
+294
View File
@@ -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>
+3
View File
@@ -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}
@@ -1,8 +1,16 @@
<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";
@@ -11,7 +19,6 @@
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;
@@ -22,28 +29,35 @@
</script>
<style>
.guide-header {
padding-bottom: 1rem;
}
.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
popoverId="popover-guide"
popoverPadding="0"
popoverPositionTop="2.5rem"
bind:expanded={popoverExpanded}
popoverPositionRight="-9.3rem">
{#snippet toggle(onclick)}
<NakedButton
id={guidePopoverToggleId}
variant="ghost"
{onclick}
stylePadding="0 4px"
stylePadding="0 0.25rem"
active={popoverExpanded}>
<Icon name="info" /> Guide
</NakedButton>
@@ -53,7 +67,8 @@
variant="ghost"
styleGap="0"
stylePadding="1rem"
styleMinWidth="36rem"
styleMinWidth="32rem"
styleBackgroundColor="var(--color-background-float)"
styleOverflow="auto"
styleMaxHeight="calc(100vh - 5rem)"
styleAlignItems="flex-start"
@@ -61,14 +76,12 @@
<div
style:position="relative"
style:display="flex"
style:line-height="1.625rem"
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)">
style:width="100%">
<div class="txt-semibold txt-medium" style:margin-bottom="1rem">
Getting started
Get started
</div>
<div class="txt-small" style:display="inline">
Hello <span style:padding-left="0.25rem">
@@ -85,23 +98,44 @@
you can share this with anyone to find you on the network.
</div>
<div class="spacer"></div>
{#if radicleInstalled()}
{#if radicleInstalled() || $nodeRunning}
<div class="global-flex txt-small">
<Icon name="checkbox-checked" />Radicle CLI is setup
<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="0.5rem">
<Icon name="checkbox-unchecked" />Make sure to install Radicle CLI
<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 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:
To install Radicle node and CLI tooling, run this in your shell:
</div>
<Command
styleWidth="fit-content"
@@ -109,9 +143,6 @@
</div>
{/if}
</div>
<div class="guide-header txt-medium txt-semibold">Guide</div>
<RepoGuide />
</Border>
{/snippet}
</Popover>
+10 -6
View File
@@ -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>
+43
View File
@@ -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" />
@@ -10,7 +10,7 @@
import Border from "./Border.svelte";
import Icon from "./Icon.svelte";
import Inbox from "@app/views/home/Inbox.svelte";
import Inbox from "./Inbox.svelte";
import OutlineButton from "./OutlineButton.svelte";
import Popover from "./Popover.svelte";
+3
View File
@@ -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
+9 -1
View File
@@ -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>
+1 -14
View File
@@ -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) {
+120
View File
@@ -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>
-67
View File
@@ -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>
-21
View File
@@ -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>
```
-15
View File
@@ -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. 🚀
+1 -1
View File
@@ -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>
+5
View File
@@ -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}
+126 -54
View File
@@ -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>
+10 -6
View File
@@ -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,
},
};
}