mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-29 08:10:59 +08:00
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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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"),
|
||||
]);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user