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.
This commit is contained in:
Rūdolfs Ošiņš
2026-09-09 14:51:21 +02:00
parent 4c899537ab
commit 092f3354ee
14 changed files with 250 additions and 31 deletions
@@ -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;
};
@@ -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;
};
+7
View File
@@ -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,
}
+6 -1
View File
@@ -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::<Vec<_>>();
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,
})
}
+10
View File
@@ -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<Context>) -> impl IntoR
Ok::<_, Error>(Json(rids))
}
async fn seed_handler(
State(ctx): State<Context>,
Json(RepoBody { rid }): Json<RepoBody>,
) -> impl IntoResponse {
ctx.seed(rid)?;
Ok::<_, Error>(Json(()))
}
async fn unseed_handler(
State(ctx): State<Context>,
Json(RepoBody { rid }): Json<RepoBody>,
+4 -1
View File
@@ -100,7 +100,10 @@
</div>
<div class="meta">
<VisibilityBadge type={repo.visibility.type} />
<VisibilityBadge
type={repo.visibility.type}
seeded={repo.seeded}
seeds={repo.seeding} />
<div class="meta-item">
<span class="meta-label">Delegates</span>
+30
View File
@@ -0,0 +1,30 @@
<script lang="ts">
import { repoListScope } from "@app/lib/repoListScope";
import Button from "@app/components/Button.svelte";
</script>
<style>
.container {
display: flex;
align-items: center;
}
</style>
<div class="container">
<Button
variant="ghost"
flatRight
active={repoListScope.value === "seeded"}
onclick={() => (repoListScope.value = "seeded")}>
Seeded
</Button>
<Button
variant="ghost"
flatLeft
active={repoListScope.value === "all"}
onclick={() => (repoListScope.value = "all")}>
All
</Button>
</div>
+83 -17
View File
@@ -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<string[]>("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<null>("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 @@
</span>
</span>
</span>
<span class="label">Seeded Repos</span>
<span class="label">
{repoListScope.value === "all" ? "All Repos" : "Seeded Repos"}
</span>
{#if !sidebarCollapsed.value && unpinnedReposCount > 1}
<span
class="global-counter-badge"
@@ -853,6 +908,7 @@
{@const pinState = pinnedRepoIds.value.includes(repo.rid)}
<a
class="nav-item"
class:not-seeded={!repo.seeding}
class:active={isRepoHome(repo.rid)}
class:context-active={contextMenu?.repo.rid === repo.rid}
class:dragging={pinned && drag.draggingRid === repo.rid}
@@ -1017,12 +1073,22 @@
Open in {explorerHost(config)}
</a>
<div class="menu-separator"></div>
<button
class="menu-item"
role="menuitem"
onclick={() => confirmUnseed(repo)}>
<Icon name="seed" />
Stop seeding
</button>
{#if repo.seeding}
<button
class="menu-item"
role="menuitem"
onclick={() => confirmUnseed(repo)}>
<Icon name="seed" />
Stop seeding
</button>
{:else}
<button
class="menu-item"
role="menuitem"
onclick={() => void startSeeding(repo.rid)}>
<Icon name="seed-filled" />
Start seeding
</button>
{/if}
</ContextMenu>
{/if}
+54 -5
View File
@@ -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<Visibility["type"], string> = {
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];
});
</script>
<style>
@@ -27,12 +52,36 @@
background-color: var(--color-feedback-warning-bg);
color: var(--color-feedback-warning-text);
}
/* A repository that is only on this machine is not a state to celebrate or
warn about, so it stays neutral. */
.local {
background-color: var(--color-surface-subtle);
color: var(--color-text-secondary);
}
/* The shared counter chip is grey and full height, which reads as a separate
element bolted on. Inheriting the badge's colour and dropping the vertical
padding keeps it part of the badge. */
.count {
color: inherit;
padding: 0 0.25rem;
min-width: 1rem;
}
</style>
<span
class="global-chip badge"
class:public={type === "public"}
class:private={type === "private"}>
<Icon name={type === "public" ? "globe" : "lock"} />
{capitalize(type)}
class:public={seeded && type === "public"}
class:private={seeded && type === "private"}
class:local={!seeded}
title={tooltip}>
{#if !seeded}
<Icon name="device" />
Local
{:else}
<Icon name={type === "public" ? "globe" : "lock"} />
{capitalize(type)}
{#if type === "public" && seeds !== undefined}
<span class="global-counter-badge count">{seeds}</span>
{/if}
{/if}
</span>
+5 -3
View File
@@ -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<T = null>(
cmd: string,
@@ -224,9 +225,10 @@ export const cachedGetCommitDiff = cached(
* unseeding) go through invalidateReposSummary().
*/
export const cachedListReposSummary = cached(
() => invoke<RepoSummary[]>("list_repos_summary", { show: "seeded" }),
() => "list_repos_summary",
{ max: 1, ttl: 30_000 },
(show: RepoListScope) =>
invoke<RepoSummary[]>("list_repos_summary", { show }),
show => `list_repos_summary:${show}`,
{ max: 2, ttl: 30_000 },
);
export function invalidateReposSummary(): void {
+16
View File
@@ -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<typeof schema>;
export const repoListScope = useLocalStorage(
"repoListScope",
schema,
"seeded",
!window.localStorage,
);
+2 -1
View File
@@ -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<SidebarData> {
const [config, repos, notificationCount, seededNotReplicated] =
await Promise.all([
invoke<Config>("config"),
cachedListReposSummary(),
cachedListReposSummary(repoListScope.value),
invoke<number>("notification_count"),
invoke<string[]>("seeded_not_replicated"),
]);
+5 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { disableHide, enableHide, hide } from "@app/lib/modal";
import { repoListScope } from "@app/lib/repoListScope";
import Button from "@app/components/Button.svelte";
import Checkbox from "@app/components/Checkbox.svelte";
@@ -146,8 +147,10 @@
<span class="note-icon"><Icon name="seed" /></span>
<div class="note-body">
<span>
Your node stops replicating and announcing this repository, and it
leaves the sidebar.
Your node stops replicating and announcing this repository{repoListScope.value ===
"seeded"
? ", and it leaves the sidebar."
: "; the sidebar dims it."}
{#if !clean}
The files stay on disk.
{/if}
+10
View File
@@ -17,6 +17,7 @@
import ExternalLink from "@app/components/ExternalLink.svelte";
import FontSizeSwitch from "@app/components/FontSizeSwitch.svelte";
import Icon from "@app/components/Icon.svelte";
import RepoListSwitch from "@app/components/RepoListSwitch.svelte";
import SegmentedSwitch from "@app/components/SegmentedSwitch.svelte";
import ThemeSwitch from "@app/components/ThemeSwitch.svelte";
import UpdateSwitch from "@app/components/UpdateSwitch.svelte";
@@ -151,6 +152,15 @@
</div>
<FontSizeSwitch />
</div>
<div class="row">
<div class="row-label">
<span class="row-title">Repository list</span>
<span class="row-description">
List only the repositories you seed, or everything in local storage
</span>
</div>
<RepoListSwitch />
</div>
<div class="row">
<div class="row-label">
<span class="row-title">Notification badge</span>