From 092f3354eefdfa96bbace95fb7a65cc02d655eb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C5=ABdolfs=20O=C5=A1i=C5=86=C5=A1?= Date: Thu, 3 Sep 2026 20:26:57 +0200 Subject: [PATCH] Distinguish local repos from seeded ones A repository can sit in storage without being seeded, which `rad ls` hides and `rad ls --all` shows as "local". A new Settings preference picks the `Show` passed to `list_repos_summary`, defaulting to seeded. --- .../radicle-types/bindings/repo/RepoInfo.ts | 8 ++ .../bindings/repo/RepoSummary.ts | 11 +- crates/radicle-types/src/repo.rs | 7 ++ crates/radicle-types/src/traits/repo.rs | 7 +- crates/test-http-api/src/api.rs | 10 ++ src/components/RepoHeader.svelte | 5 +- src/components/RepoListSwitch.svelte | 30 ++++++ src/components/SidebarRepoList.svelte | 100 +++++++++++++++--- src/components/VisibilityBadge.svelte | 59 ++++++++++- src/lib/invoke.ts | 8 +- src/lib/repoListScope.ts | 16 +++ src/lib/router/definitions.ts | 3 +- src/modals/ConfirmUnseed.svelte | 7 +- src/modals/Settings.svelte | 10 ++ 14 files changed, 250 insertions(+), 31 deletions(-) create mode 100644 src/components/RepoListSwitch.svelte create mode 100644 src/lib/repoListScope.ts diff --git a/crates/radicle-types/bindings/repo/RepoInfo.ts b/crates/radicle-types/bindings/repo/RepoInfo.ts index 4a27623d1..bbb06e7aa 100644 --- a/crates/radicle-types/bindings/repo/RepoInfo.ts +++ b/crates/radicle-types/bindings/repo/RepoInfo.ts @@ -9,6 +9,14 @@ export type RepoInfo = { threshold: number; visibility: Visibility; rid: string; + /** + * How many nodes on the network are known to seed this repository. + */ seeding: number; + /** + * Whether the local node seeds this repository. When false the repository + * is only in local storage, which `rad ls --all` shows as "local". + */ + seeded: boolean; lastCommitTimestamp: number; }; diff --git a/crates/radicle-types/bindings/repo/RepoSummary.ts b/crates/radicle-types/bindings/repo/RepoSummary.ts index 379ccb093..a7c7bd4de 100644 --- a/crates/radicle-types/bindings/repo/RepoSummary.ts +++ b/crates/radicle-types/bindings/repo/RepoSummary.ts @@ -1,3 +1,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type RepoSummary = { rid: string; name: string; private: boolean }; +export type RepoSummary = { + rid: string; + name: string; + private: boolean; + /** + * False for a repository that is in storage but not seeded, which + * `rad ls --all` shows as "local". + */ + seeding: boolean; +}; diff --git a/crates/radicle-types/src/repo.rs b/crates/radicle-types/src/repo.rs index ce01d11b1..2426ee6a7 100644 --- a/crates/radicle-types/src/repo.rs +++ b/crates/radicle-types/src/repo.rs @@ -19,6 +19,9 @@ pub struct RepoSummary { pub rid: identity::RepoId, pub name: String, pub private: bool, + /// False for a repository that is in storage but not seeded, which + /// `rad ls --all` shows as "local". + pub seeding: bool, } #[derive(Serialize, TS)] @@ -44,7 +47,11 @@ pub struct RepoInfo { pub visibility: Visibility, #[ts(as = "String")] pub rid: identity::RepoId, + /// How many nodes on the network are known to seed this repository. pub seeding: usize, + /// Whether the local node seeds this repository. When false the repository + /// is only in local storage, which `rad ls --all` shows as "local". + pub seeded: bool, #[ts(type = "number")] pub last_commit_timestamp: i64, } diff --git a/crates/radicle-types/src/traits/repo.rs b/crates/radicle-types/src/traits/repo.rs index fd3e1babb..9067934fb 100644 --- a/crates/radicle-types/src/traits/repo.rs +++ b/crates/radicle-types/src/traits/repo.rs @@ -394,7 +394,9 @@ pub trait Repo: Profile { let mut entries = Vec::new(); for RepositoryInfo { rid, doc, .. } in repos { - if !policies.is_seeding(&rid)? && show == Show::Seeded { + let seeding = policies.is_seeding(&rid)?; + + if !seeding && show == Show::Seeded { continue; } @@ -409,6 +411,7 @@ pub trait Repo: Profile { rid, name: data.name, private: doc.is_private(), + seeding, }); } @@ -662,6 +665,7 @@ pub trait Repo: Profile { .collect::>(); let db = profile.database()?; let seeding = db.count(&repo.id).unwrap_or_default(); + let seeded = profile.policies()?.is_seeding(&repo.id)?; let (_, head) = repo.head()?; let commit = repo.commit(head)?; let project = doc @@ -698,6 +702,7 @@ pub trait Repo: Profile { }, rid: repo.id, seeding, + seeded, last_commit_timestamp: commit.time().seconds() * 1000, }) } diff --git a/crates/test-http-api/src/api.rs b/crates/test-http-api/src/api.rs index d6d15989a..91bdbc673 100644 --- a/crates/test-http-api/src/api.rs +++ b/crates/test-http-api/src/api.rs @@ -81,6 +81,7 @@ pub fn router(ctx: Context) -> Router { "/seeded_not_replicated", post(seeded_not_replicated_handler), ) + .route("/seed", post(seed_handler)) .route("/unseed", post(unseed_handler)) .route("/clean", post(clean_handler)) .route("/repo_by_id", post(repo_handler)) @@ -178,6 +179,15 @@ async fn seeded_not_replicated_handler(State(ctx): State) -> impl IntoR Ok::<_, Error>(Json(rids)) } +async fn seed_handler( + State(ctx): State, + Json(RepoBody { rid }): Json, +) -> impl IntoResponse { + ctx.seed(rid)?; + + Ok::<_, Error>(Json(())) +} + async fn unseed_handler( State(ctx): State, Json(RepoBody { rid }): Json, diff --git a/src/components/RepoHeader.svelte b/src/components/RepoHeader.svelte index da2c62fec..b5b60d736 100644 --- a/src/components/RepoHeader.svelte +++ b/src/components/RepoHeader.svelte @@ -100,7 +100,10 @@
- +
Delegates diff --git a/src/components/RepoListSwitch.svelte b/src/components/RepoListSwitch.svelte new file mode 100644 index 000000000..214059705 --- /dev/null +++ b/src/components/RepoListSwitch.svelte @@ -0,0 +1,30 @@ + + + + +
+ + + +
diff --git a/src/components/SidebarRepoList.svelte b/src/components/SidebarRepoList.svelte index ced04ea21..8ce249049 100644 --- a/src/components/SidebarRepoList.svelte +++ b/src/components/SidebarRepoList.svelte @@ -42,9 +42,10 @@ import type { RepoSummary } from "@bindings/repo/RepoSummary"; import type { CrossfadeParams } from "svelte/transition"; - import { onMount } from "svelte"; + import { onMount, untrack } from "svelte"; import { flip } from "svelte/animate"; import { backOut, cubicOut } from "svelte/easing"; + import { get } from "svelte/store"; import { crossfade, fade, scale, slide } from "svelte/transition"; import { nodeRunning } from "@app/lib/events"; @@ -58,6 +59,7 @@ writeToClipboard, } from "@app/lib/invoke"; import { show } from "@app/lib/modal"; + import { repoListScope } from "@app/lib/repoListScope"; import * as router from "@app/lib/router"; import { sidebarCollapsed } from "@app/lib/sidebar.svelte"; import { @@ -275,11 +277,23 @@ async function reloadRepos() { invalidateReposSummary(); [repos, seededNotReplicated] = await Promise.all([ - cachedListReposSummary(), + cachedListReposSummary(repoListScope.value), invoke("seeded_not_replicated"), ]); } + // The route loader already fetched the list for the scope in force, so the + // first run only records it; later runs are the preference actually changing. + let loadedScope: string | undefined; + $effect(() => { + const scope = repoListScope.value; + const first = loadedScope === undefined; + loadedScope = scope; + if (!first) { + untrack(() => void reloadRepos()); + } + }); + // Ordered like the CLI: drop the seeding policy first, then let storage prune // what it can. `clean` keeps the local node's and the delegates' refs, so a // repo we have published to survives in a reduced form. Throws, so each @@ -291,6 +305,33 @@ } } + // The repo view reads `RepoInfo` from the route loader, so its visibility + // badge only notices a seeding change if the route loads again. Refreshing + // the list alone leaves the badge showing the state from before the click. + async function refreshActiveRepo(rid: string) { + if (activeRid() !== rid) { + return; + } + await router + .replace(get(router.activeUnloadedRouteStore)) + .catch(error => console.error("Reloading the route failed", error)); + } + + // Seeding a repository that is only in local storage puts it back on the + // network. Nothing is destroyed, so it runs straight off the menu item. + async function startSeeding(rid: string) { + try { + await invoke("seed", { rid }); + } catch (error) { + console.error("Seed failed", error); + } finally { + await reloadRepos().catch(error => + console.error("Reloading repos failed", error), + ); + await refreshActiveRepo(rid); + } + } + // The pending-fetch list's trash button, which stays quiet on failure. The // list still refreshes: dropping the policy row is what removes a repo from // it, and that happens before anything can throw. @@ -331,13 +372,16 @@ () => clearUnseeding(repo.rid), ANIMATION_DURATION_MS * 2, ); - // The repo the user is looking at may have just gone away; its - // route would resolve to nothing. - if ( - activeRid() === repo.rid && - !repos.some(r => r.rid === repo.rid) - ) { - await router.push({ resource: "inbox" }); + if (activeRid() === repo.rid) { + if (repos.some(r => r.rid === repo.rid)) { + // Still listed, so the "All" scope is on and the row merely + // dimmed; the open repo view needs its badge brought up to date. + await refreshActiveRepo(repo.rid); + } else { + // The repo the user is looking at just went away; its route + // would resolve to nothing. + await router.push({ resource: "inbox" }); + } } } }, @@ -525,6 +569,15 @@ background-color: var(--color-surface-mid); } + /* In the "All" scope the list includes repos that are only in storage; + `rad ls --all` dims those too, where it labels them "local". */ + .nav-item.not-seeded { + opacity: 0.55; + } + .nav-item.not-seeded:hover, + .nav-item.not-seeded.active { + opacity: 1; + } .private-icon { display: inline-flex; flex-shrink: 0; @@ -813,7 +866,9 @@ - Seeded Repos + + {repoListScope.value === "all" ? "All Repos" : "Seeded Repos"} + {#if !sidebarCollapsed.value && unpinnedReposCount > 1} - + {#if repo.seeding} + + {:else} + + {/if} {/if} diff --git a/src/components/VisibilityBadge.svelte b/src/components/VisibilityBadge.svelte index 9a2daba10..ae99cc474 100644 --- a/src/components/VisibilityBadge.svelte +++ b/src/components/VisibilityBadge.svelte @@ -3,13 +3,38 @@ import capitalize from "lodash/capitalize.js"; + import { pluralize } from "@app/lib/utils"; + import Icon from "@app/components/Icon.svelte"; interface Props { type: Visibility["type"]; + /// Whether the local node seeds the repository. When it does not, the + /// repository is in storage but off the network, which `rad ls --all` + /// reports as "local" in place of its visibility. + seeded?: boolean; + /// How many nodes on the network are known to seed the repository. + seeds?: number; } - const { type }: Props = $props(); + const { type, seeded = true, seeds = undefined }: Props = $props(); + + const tooltips: Record = { + public: + "Anyone on the network can fetch and replicate this repository, and your node announces it to peers.", + private: + "Only the delegates and explicitly allowed peers can fetch this repository. It stays invisible to the rest of the network, and connections carrying it are encrypted.", + }; + + const tooltip = $derived.by(() => { + if (!seeded) { + return `This repository is in local storage but your node is not seeding it, so it is neither replicated nor announced to the network. Its visibility is set to ${type}.`; + } + if (type === "public" && seeds !== undefined) { + return `${tooltips.public} ${seeds} ${pluralize("node", seeds)} on the network ${seeds === 1 ? "is" : "are"} known to seed it.`; + } + return tooltips[type]; + }); - - {capitalize(type)} + class:public={seeded && type === "public"} + class:private={seeded && type === "private"} + class:local={!seeded} + title={tooltip}> + {#if !seeded} + + Local + {:else} + + {capitalize(type)} + {#if type === "public" && seeds !== undefined} + {seeds} + {/if} + {/if} diff --git a/src/lib/invoke.ts b/src/lib/invoke.ts index 279eaeee7..2caea5f4a 100644 --- a/src/lib/invoke.ts +++ b/src/lib/invoke.ts @@ -9,6 +9,7 @@ import type { RepoSummary } from "@bindings/repo/RepoSummary"; import * as tauri from "@tauri-apps/api/core"; import { cached } from "@app/lib/cached"; +import type { RepoListScope } from "@app/lib/repoListScope"; export async function invoke( cmd: string, @@ -224,9 +225,10 @@ export const cachedGetCommitDiff = cached( * unseeding) go through invalidateReposSummary(). */ export const cachedListReposSummary = cached( - () => invoke("list_repos_summary", { show: "seeded" }), - () => "list_repos_summary", - { max: 1, ttl: 30_000 }, + (show: RepoListScope) => + invoke("list_repos_summary", { show }), + show => `list_repos_summary:${show}`, + { max: 2, ttl: 30_000 }, ); export function invalidateReposSummary(): void { diff --git a/src/lib/repoListScope.ts b/src/lib/repoListScope.ts new file mode 100644 index 000000000..e6f3f6b1e --- /dev/null +++ b/src/lib/repoListScope.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +import useLocalStorage from "@app/lib/useLocalStorage.svelte"; + +// Which repositories the sidebar lists, mirroring `rad ls` and `rad ls --all`: +// only the ones you seed, or everything in local storage. +const schema = z.union([z.literal("seeded"), z.literal("all")]); + +export type RepoListScope = z.infer; + +export const repoListScope = useLocalStorage( + "repoListScope", + schema, + "seeded", + !window.localStorage, +); diff --git a/src/lib/router/definitions.ts b/src/lib/router/definitions.ts index 0394803f0..f0d2c859f 100644 --- a/src/lib/router/definitions.ts +++ b/src/lib/router/definitions.ts @@ -14,6 +14,7 @@ import { } from "@app/views/repo/router"; import { cachedListReposSummary, invoke } from "@app/lib/invoke"; +import { repoListScope } from "@app/lib/repoListScope"; interface BootingRoute { resource: "booting"; @@ -69,7 +70,7 @@ export async function loadSidebarData(): Promise { const [config, repos, notificationCount, seededNotReplicated] = await Promise.all([ invoke("config"), - cachedListReposSummary(), + cachedListReposSummary(repoListScope.value), invoke("notification_count"), invoke("seeded_not_replicated"), ]); diff --git a/src/modals/ConfirmUnseed.svelte b/src/modals/ConfirmUnseed.svelte index 715f85abb..c354fc70e 100644 --- a/src/modals/ConfirmUnseed.svelte +++ b/src/modals/ConfirmUnseed.svelte @@ -1,5 +1,6 @@