From fe37d6a85cb745db20544d7638db1e2053404f86 Mon Sep 17 00:00:00 2001 From: Brandon Oxendine Date: Mon, 31 Aug 2026 21:07:31 +0200 Subject: [PATCH] Add Stop seeding to the sidebar context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rad unseed` only drops the seeding policy and leaves the repository in storage, so while `list_repos_summary` listed everything on disk an unseeded repository never left the sidebar and unseeding looked like it had done nothing. That call now filters by seeding policy. The confirmation dialog also offers to delete the files, running the equivalent of `rad clean` after the unseed. Co-authored-by: Rūdolfs Ošiņš --- crates/radicle-tauri/src/commands/repo.rs | 5 + crates/radicle-tauri/src/lib.rs | 1 + crates/radicle-types/src/traits/repo.rs | 25 ++- crates/test-http-api/src/api.rs | 20 +++ src/components/Checkbox.svelte | 88 ++++++++++ src/components/SidebarRepoList.svelte | 95 ++++++++++- src/modals/ConfirmUnseed.svelte | 186 ++++++++++++++++++++++ 7 files changed, 417 insertions(+), 3 deletions(-) create mode 100644 src/components/Checkbox.svelte create mode 100644 src/modals/ConfirmUnseed.svelte diff --git a/crates/radicle-tauri/src/commands/repo.rs b/crates/radicle-tauri/src/commands/repo.rs index a740182d9..4e62ce92a 100644 --- a/crates/radicle-tauri/src/commands/repo.rs +++ b/crates/radicle-tauri/src/commands/repo.rs @@ -155,6 +155,11 @@ pub fn unseed(ctx: tauri::State<'_, AppState>, rid: RepoId) -> Result<(), Error> ctx.unseed(rid) } +#[tauri::command] +pub fn clean(ctx: tauri::State<'_, AppState>, rid: RepoId) -> Result<(), Error> { + ctx.clean(rid) +} + #[tauri::command] pub async fn seeded_not_replicated(ctx: tauri::State<'_, AppState>) -> Result, Error> { ctx.seeded_not_replicated() diff --git a/crates/radicle-tauri/src/lib.rs b/crates/radicle-tauri/src/lib.rs index d36ad779a..d58d5e202 100644 --- a/crates/radicle-tauri/src/lib.rs +++ b/crates/radicle-tauri/src/lib.rs @@ -64,6 +64,7 @@ pub fn run() { inbox::list_notifications, profile::alias, profile::config, + repo::clean, repo::diff_stats, repo::list_commits, repo::list_repo_commits, diff --git a/crates/radicle-types/src/traits/repo.rs b/crates/radicle-types/src/traits/repo.rs index a8f68ccbf..f5a515c9d 100644 --- a/crates/radicle-types/src/traits/repo.rs +++ b/crates/radicle-types/src/traits/repo.rs @@ -10,7 +10,7 @@ use radicle::node::AliasStore; use radicle::node::routing::Store; use radicle::patch::cache::Patches as _; use radicle::storage; -use radicle::storage::{ReadRepository, ReadStorage, RepositoryInfo}; +use radicle::storage::{ReadRepository, ReadStorage, RepositoryInfo, WriteStorage}; use radicle::{git, identity, node}; use crate::cobs; @@ -396,10 +396,19 @@ pub trait Repo: Profile { fn list_repos_summary(&self) -> Result, Error> { let profile = self.profile(); let storage = &profile.storage; + let policies = profile.policies()?; let repos = storage.repositories()?; let mut entries = Vec::new(); for RepositoryInfo { rid, doc, .. } in repos { + // `rad unseed` only drops the seeding policy; the repository stays + // in storage. This list backs the sidebar, which is about what you + // seed, so an unseeded repo has to leave it even though its files + // are still on disk — otherwise unseeding looks like it did nothing. + if !policies.is_seeding(&rid)? { + continue; + } + let Some(data) = doc .payload() .get(&doc::PayloadId::project()) @@ -928,6 +937,20 @@ pub trait Repo: Profile { Ok(()) } + /// Remove the repository's remotes from storage, mirroring `rad clean`. + /// + /// If the local node has never written signed refs for this repository, + /// storage drops it entirely. Otherwise only the remotes that are neither + /// the local node's nor a delegate's are removed, and the repository stays + /// on disk. + fn clean(&self, rid: identity::RepoId) -> Result<(), Error> { + let profile = self.profile(); + + profile.storage.clean(rid)?; + + Ok(()) + } + fn seed(&self, rid: identity::RepoId) -> Result<(), Error> { let profile = self.profile(); let mut node = radicle::Node::new(profile.home().socket_from_env()); diff --git a/crates/test-http-api/src/api.rs b/crates/test-http-api/src/api.rs index ff4ba24fb..540f4d5ec 100644 --- a/crates/test-http-api/src/api.rs +++ b/crates/test-http-api/src/api.rs @@ -81,6 +81,8 @@ pub fn router(ctx: Context) -> Router { "/seeded_not_replicated", post(seeded_not_replicated_handler), ) + .route("/unseed", post(unseed_handler)) + .route("/clean", post(clean_handler)) .route("/repo_by_id", post(repo_handler)) .route("/list_repo_refs", post(list_repo_refs_handler)) .route("/version", post(version_handler)) @@ -172,6 +174,24 @@ async fn seeded_not_replicated_handler(State(ctx): State) -> impl IntoR Ok::<_, Error>(Json(rids)) } +async fn unseed_handler( + State(ctx): State, + Json(RepoBody { rid }): Json, +) -> impl IntoResponse { + ctx.unseed(rid)?; + + Ok::<_, Error>(Json(())) +} + +async fn clean_handler( + State(ctx): State, + Json(RepoBody { rid }): Json, +) -> impl IntoResponse { + ctx.clean(rid)?; + + Ok::<_, Error>(Json(())) +} + async fn list_notifications_handler() -> impl IntoResponse { Ok::<_, Error>(Json(Vec::< radicle_types::domain::inbox::models::notification::NotificationsByRepo, diff --git a/src/components/Checkbox.svelte b/src/components/Checkbox.svelte new file mode 100644 index 000000000..ddc31da24 --- /dev/null +++ b/src/components/Checkbox.svelte @@ -0,0 +1,88 @@ + + + + + diff --git a/src/components/SidebarRepoList.svelte b/src/components/SidebarRepoList.svelte index d991d41ed..8642aedb5 100644 --- a/src/components/SidebarRepoList.svelte +++ b/src/components/SidebarRepoList.svelte @@ -43,6 +43,7 @@ import { onMount } from "svelte"; import { flip } from "svelte/animate"; + import { cubicOut } from "svelte/easing"; import { crossfade } from "svelte/transition"; import { nodeRunning } from "@app/lib/events"; @@ -53,6 +54,7 @@ invoke, writeToClipboard, } from "@app/lib/invoke"; + import { show } from "@app/lib/modal"; import * as router from "@app/lib/router"; import { explorerHost, @@ -69,6 +71,7 @@ DRAG_RID_ATTRIBUTE, PINNED_LIST_CLASS, } from "@app/components/usePinnedDragReorder.svelte"; + import ConfirmUnseed from "@app/modals/ConfirmUnseed.svelte"; interface Props { initialRepos: RepoSummary[]; @@ -158,14 +161,48 @@ const ANIMATION_DURATION_MS = 220; let animatingPinnedList = $state(false); let animationTimeout: ReturnType | undefined; + // The rid currently leaving the list because it was unseeded. Rows normally + // vanish instantly (filtering, refreshes); this one fades, and the flip + // animation below then closes the gap it leaves behind. + let unseedingRid = $state(undefined); const animationDuration = $derived( - animatingPinnedList ? ANIMATION_DURATION_MS : 0, + animatingPinnedList || unseedingRid !== undefined + ? ANIMATION_DURATION_MS + : 0, ); const [send, receive] = crossfade({ duration: ANIMATION_DURATION_MS, - fallback: () => ({ duration: 0 }), + // A row leaving with no counterpart is either the repo being unseeded, or a + // filter/refresh that should not animate at all. + fallback: (node, params) => + (params as { key?: string }).key !== undefined && + (params as { key?: string }).key === unseedingRid + ? fadeCollapse(node, ANIMATION_DURATION_MS) + : { duration: 0 }, }); + // Fades the row out while collapsing the space it occupies, so the rows below + // rise into the gap as it closes. `animate:flip` cannot do this on its own: a + // row mid-out-transition still takes up its full height, so flip measures no + // movement for its siblings, and they jump once it is finally removed. + function fadeCollapse(node: Element, duration: number) { + const el = node as HTMLElement; + const height = el.offsetHeight; + const parent = el.parentElement; + // The list separates rows with `gap`, which outlives a zero-height row; a + // negative margin takes that last sliver with it. + const gap = parent + ? parseFloat(getComputedStyle(parent).rowGap || "0") || 0 + : 0; + + return { + duration, + easing: cubicOut, + css: (t: number) => + `opacity: ${t}; height: ${t * height}px; margin-bottom: ${(t - 1) * gap}px; overflow: hidden; pointer-events: none;`, + }; + } + function withPinAnimation(fn: () => void) { animatingPinnedList = true; if (animationTimeout !== undefined) clearTimeout(animationTimeout); @@ -208,6 +245,47 @@ } } + // Unseeding drops the seeding policy and removes the repo from this list, so + // it goes through a confirmation rather than firing straight off a menu item. + // It does not reuse `unseed` above, which swallows its error to keep the + // pending-fetch list's trash button quiet — here the error has to reach the + // dialog. + function confirmUnseed(repo: RepoSummary) { + show({ + component: ConfirmUnseed, + props: { + name: repo.name, + rid: repo.rid, + async confirm(clean: boolean) { + await invoke("unseed", { rid: repo.rid }); + // 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. + if (clean) { + await invoke("clean", { rid: repo.rid }); + } + // Marked before the list refreshes so the row that disappears fades + // out instead of blinking away, and the rows below glide up into the + // gap. Cleared once both halves have run. + unseedingRid = repo.rid; + try { + await reloadRepos(); + } finally { + setTimeout(() => { + unseedingRid = undefined; + }, ANIMATION_DURATION_MS * 2); + } + // The repo the user is looking at just went away; its route would + // resolve to nothing. + if (activeRid() === repo.rid) { + await router.push({ resource: "inbox" }); + } + }, + }, + }); + } + const activeRoute = router.activeRouteStore; function activeRid(): string | undefined { @@ -473,6 +551,11 @@ .menu-item:hover :global(svg) { color: var(--color-text-primary); } + .menu-separator { + height: 1px; + margin: 0.25rem 0; + background-color: var(--color-border-subtle); + } {#if seededNotReplicated.length > 0} @@ -762,5 +845,13 @@ Open in {explorerHost(config)} + + {/if} diff --git a/src/modals/ConfirmUnseed.svelte b/src/modals/ConfirmUnseed.svelte new file mode 100644 index 000000000..715f85abb --- /dev/null +++ b/src/modals/ConfirmUnseed.svelte @@ -0,0 +1,186 @@ + + + + +