diff --git a/crates/radicle-tauri/src/commands/repo.rs b/crates/radicle-tauri/src/commands/repo.rs index 476ca7bc4..ea0eb5588 100644 --- a/crates/radicle-tauri/src/commands/repo.rs +++ b/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, rid: RepoId) -> Result<(), Error> { + ctx.seed(rid) +} + +#[tauri::command] +pub fn unseed(ctx: tauri::State, rid: RepoId) -> Result<(), Error> { + ctx.unseed(rid) +} + +#[tauri::command] +pub fn seeded_not_replicated(ctx: tauri::State) -> Result, Error> { + ctx.seeded_not_replicated() +} diff --git a/crates/radicle-tauri/src/lib.rs b/crates/radicle-tauri/src/lib.rs index 0e7198a06..fc4688b6e 100644 --- a/crates/radicle-tauri/src/lib.rs +++ b/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, diff --git a/crates/radicle-types/src/traits/repo.rs b/crates/radicle-types/src/traits/repo.rs index 4ea62f5ed..b4904612b 100644 --- a/crates/radicle-types/src/traits/repo.rs +++ b/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, 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::>(); + + Ok(entries) + } } diff --git a/src/components/AddRepoButton.svelte b/src/components/AddRepoButton.svelte new file mode 100644 index 000000000..a7189c750 --- /dev/null +++ b/src/components/AddRepoButton.svelte @@ -0,0 +1,294 @@ + + + + + + + + {#snippet toggle(onclick)} + + {/snippet} + + {#snippet popover()} + + { + tab.value = "seed"; + }}> + Seed a repo + + { + tab.value = "publish"; + }}> + Publish existing repo + + + +
+ +
+ {#if tab.value === "seed"} + +
+ You can search for Radicle repos by name or description at + + search.radicle.xyz + . +
+
+
+
+
+
+ + +
+ {#if validationMessage} +
+ + {validationMessage} +
+ {/if} +
+
+
+
+ + + + 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. +
+ {#if !$nodeRunning} +
+ + + + +
+ Your node is + + + + + Offline. + + You can still add repos, but they will only be fetched once your + node is back online. +
+
+ {/if} + {:else if tab.value === "publish"} +

+ Navigate to an existing Git repo in your terminal + + cd path/to/your/repo + + and run the following command: +

+ + + +

+ Follow the setup prompts to initialize the repo and publish it on + the Radicle network: +

+ +
    +
  • + Repository Name: + The name of your repo. +
  • +
  • + Description: + 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. +
  • +
+

+ That's it! Your repo is now on the Radicle network. 🚀 +

+ {/if} +
+
+
+ {/snippet} +
diff --git a/src/components/Button.svelte b/src/components/Button.svelte index b9cc3d966..a10878563 100644 --- a/src/components/Button.svelte +++ b/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 @@
+ export const guidePopoverToggleId = "guide-popover-toggle"; + + {#snippet toggle(onclick)} Guide @@ -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 @@
+ style:width="100%">
- Getting started + Get started
Hello @@ -85,23 +98,44 @@ you can share this with anyone to find you on the network.
- {#if radicleInstalled()} + {#if radicleInstalled() || $nodeRunning}
- Radicle CLI is setup +
+ Radicle CLI is installed, you're good to + go. +
+
{:else}
-
- Make sure to install Radicle CLI +
+ Radicle CLI is not installed +
+
+ 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.
- 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. -
-
- 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:
{/if}
-
Guide
- - {/snippet} diff --git a/src/components/Header.svelte b/src/components/Header.svelte index 5f4052cf2..50e0b2a2c 100644 --- a/src/components/Header.svelte +++ b/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 @@
- + - +
diff --git a/src/components/Icon.svelte b/src/components/Icon.svelte index 0ac3ea636..d551c042f 100644 --- a/src/components/Icon.svelte +++ b/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 @@ + {:else if name === "bulb"} + + + + + + + + + + + + + + + + + {:else if name === "checkmark"} @@ -680,6 +700,29 @@ + {:else if name === "hourglass"} + + + + + + + + + + + + + + + + + + + + + + {:else if name === "inbox"} diff --git a/src/views/home/Inbox.svelte b/src/components/Inbox.svelte similarity index 100% rename from src/views/home/Inbox.svelte rename to src/components/Inbox.svelte diff --git a/src/components/InboxPopover.svelte b/src/components/InboxButton.svelte similarity index 98% rename from src/components/InboxPopover.svelte rename to src/components/InboxButton.svelte index 369d5ecbd..a004af3ec 100644 --- a/src/components/InboxPopover.svelte +++ b/src/components/InboxButton.svelte @@ -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"; diff --git a/src/components/NakedButton.svelte b/src/components/NakedButton.svelte index 5396eaa11..640076771 100644 --- a/src/components/NakedButton.svelte +++ b/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 @@
-
+
{#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. + +
+ You can start your node with: +
+ +
+
{/if}
diff --git a/src/components/Popover.svelte b/src/components/Popover.svelte index 18a743b32..d1574673d 100644 --- a/src/components/Popover.svelte +++ b/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 }; - } - } - } + + + +
+ +
+
+
+
+ +
+
+
+
+
+
+ + + Remove + +
+
+
+
+
+
+
+ + + +
+
diff --git a/src/components/RepoGuide.svelte b/src/components/RepoGuide.svelte deleted file mode 100644 index d67b86c3d..000000000 --- a/src/components/RepoGuide.svelte +++ /dev/null @@ -1,67 +0,0 @@ - - - - -{#snippet tabSnippet(name: typeof tab.value, content: string)} - { - tab.value = name; - }}> - {content} - -{/snippet} - - - {@render tabSnippet("clone", "Clone a repo from the network")} - {@render tabSnippet("publish", "Publish existing repo")} - - - -
- {#if tab.value === "clone"} - - {:else if tab.value === "publish"} - - {/if} -
-
diff --git a/src/components/RepoGuide/clone.md b/src/components/RepoGuide/clone.md deleted file mode 100644 index 3bdd60b3a..000000000 --- a/src/components/RepoGuide/clone.md +++ /dev/null @@ -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 -``` diff --git a/src/components/RepoGuide/publish.md b/src/components/RepoGuide/publish.md deleted file mode 100644 index d024bfbf3..000000000 --- a/src/components/RepoGuide/publish.md +++ /dev/null @@ -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. 🚀 diff --git a/src/components/RepoHeader.svelte b/src/components/RepoHeader.svelte index 2e1684572..bcc4f2f8a 100644 --- a/src/components/RepoHeader.svelte +++ b/src/components/RepoHeader.svelte @@ -63,7 +63,7 @@ style:padding="0 0.375rem" style:background-color="var(--color-fill-ghost)" style:gap="0.25rem"> - + {repo.seeding}
diff --git a/src/components/TextInput.svelte b/src/components/TextInput.svelte index a421133c1..eef10b656 100644 --- a/src/components/TextInput.svelte +++ b/src/components/TextInput.svelte @@ -15,6 +15,7 @@ name?: string; onDismiss?: () => void; onFocus?: () => void; + onBlur?: () => void; onSubmit?: () => void; oninput?: FormEventHandler; 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} diff --git a/src/views/home/Repos.svelte b/src/views/home/Repos.svelte index 6cfef1464..660d9e859 100644 --- a/src/views/home/Repos.svelte +++ b/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("list_repos", { show: activeTab ?? "all" }), invoke("repo_count"), invoke("config"), + invoke("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 => 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; + }
-
Repositories
- {#if repos.length > 0} -
- { - 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()} -
- -
- {/snippet} -
-
- {/if} +
+
Repositories
+ {#if repos.length > 0} + {#if !showFilters} + { + if (showFilters) { + showFilters = false; + searchInput = ""; + } else { + showFilters = true; + } + }}> + + + {/if} + {#if showFilters} + { + 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()} +
+ +
+ {/snippet} +
+ {/if} + {/if} +
+
+ { + searchInput = ""; + showFilters = false; + }} /> +
- {#if repoCount.total > 0} - {#if searchResults.length > 0} + {#if repoCount.total > 0 || seededNotReplicated.length > 0} + {#if searchResults.length > 0 || seededNotReplicated.length > 0}
+ {#if !showFilters} + {#each seededNotReplicated as rid} + + {/each} + {/if} {#each searchResults as result} {/if} {:else} -
- You don't have any repositories in your Radicle storage yet. To get - started, check out the guide below. +
+ You don't have any repositories in your Radicle storage yet.
-
- setFocused("popover-guide")} - variant="ghost"> - Guide - + +
+ To get started, check out + .
{/if}
diff --git a/src/views/home/router.ts b/src/views/home/router.ts index 5091f9153..59a2e243b 100644 --- a/src/views/home/router.ts +++ b/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 { } } - const [config, repoCount, repos, notificationCount] = await Promise.all([ - invoke("config"), - invoke("repo_count"), - invoke("list_repos", { show }), - invoke("notification_count"), - ]); + const [config, repoCount, repos, notificationCount, seededNotReplicated] = + await Promise.all([ + invoke("config"), + invoke("repo_count"), + invoke("list_repos", { show }), + invoke("notification_count"), + invoke("seeded_not_replicated"), + ]); return { resource: "home", params: { @@ -49,6 +52,7 @@ export async function loadHome(route: HomeRoute): Promise { repos, config, notificationCount, + seededNotReplicated, }, }; }