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 @@ + + + + +