mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-01 09:40:28 +08:00
Add Stop seeding to the sidebar context menu
`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ņš <rudolfs@osins.org>
This commit is contained in:
co-authored by
Rūdolfs Ošiņš
parent
f4ab2544ae
commit
fe37d6a85c
@@ -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<Vec<RepoId>, Error> {
|
||||
ctx.seeded_not_replicated()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Vec<repo::RepoSummary>, 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());
|
||||
|
||||
@@ -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<Context>) -> impl IntoR
|
||||
Ok::<_, Error>(Json(rids))
|
||||
}
|
||||
|
||||
async fn unseed_handler(
|
||||
State(ctx): State<Context>,
|
||||
Json(RepoBody { rid }): Json<RepoBody>,
|
||||
) -> impl IntoResponse {
|
||||
ctx.unseed(rid)?;
|
||||
|
||||
Ok::<_, Error>(Json(()))
|
||||
}
|
||||
|
||||
async fn clean_handler(
|
||||
State(ctx): State<Context>,
|
||||
Json(RepoBody { rid }): Json<RepoBody>,
|
||||
) -> 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,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
|
||||
interface Props {
|
||||
checked?: boolean;
|
||||
disabled?: boolean;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
/* eslint-disable prefer-const */
|
||||
let {
|
||||
checked = $bindable(false),
|
||||
disabled = false,
|
||||
children,
|
||||
}: Props = $props();
|
||||
/* eslint-enable prefer-const */
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.checkbox {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
font: var(--txt-body-m-regular);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.checkbox:has(input:disabled) {
|
||||
cursor: not-allowed;
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
/* The native input stays in the layout so it keeps focus, keyboard toggling
|
||||
and the label association; the box below is what gets drawn. */
|
||||
input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 1px solid var(--color-border-mid);
|
||||
border-radius: var(--border-radius-md);
|
||||
background-color: var(--color-surface-base);
|
||||
color: transparent;
|
||||
}
|
||||
.checkbox:hover .box {
|
||||
background-color: var(--color-surface-subtle);
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
.checkbox:has(input:checked) .box {
|
||||
color: var(--color-text-brand);
|
||||
}
|
||||
.checkbox:has(input:focus-visible) .box {
|
||||
outline: 1px solid var(--color-border-brand);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.checkbox:has(input:disabled) .box,
|
||||
.checkbox:has(input:disabled):hover .box {
|
||||
background-color: var(--color-surface-base);
|
||||
border-color: var(--color-border-subtle);
|
||||
}
|
||||
.checkbox:has(input:disabled:checked) .box {
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
.label {
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" bind:checked {disabled} />
|
||||
<span class="box"><Icon name="checkmark" /></span>
|
||||
{#if children}
|
||||
<span class="label">{@render children()}</span>
|
||||
{/if}
|
||||
</label>
|
||||
@@ -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<typeof setTimeout> | 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<string | undefined>(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<null>("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<null>("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);
|
||||
}
|
||||
</style>
|
||||
|
||||
{#if seededNotReplicated.length > 0}
|
||||
@@ -762,5 +845,13 @@
|
||||
<Icon name="open-external" />
|
||||
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>
|
||||
</ContextMenu>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<script lang="ts">
|
||||
import { disableHide, enableHide, hide } from "@app/lib/modal";
|
||||
|
||||
import Button from "@app/components/Button.svelte";
|
||||
import Checkbox from "@app/components/Checkbox.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
rid: string;
|
||||
confirm: (clean: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
const { name, rid, confirm }: Props = $props();
|
||||
|
||||
let clean = $state(false);
|
||||
let working = $state(false);
|
||||
let error = $state<string | undefined>(undefined);
|
||||
|
||||
async function run() {
|
||||
if (working) return;
|
||||
working = true;
|
||||
error = undefined;
|
||||
// Two commands run back to back, so the scrim and the close button stay
|
||||
// inert until both have settled.
|
||||
disableHide();
|
||||
try {
|
||||
await confirm(clean);
|
||||
enableHide();
|
||||
hide();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Unable to stop seeding.";
|
||||
enableHide();
|
||||
} finally {
|
||||
working = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.modal {
|
||||
width: 26rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--border-radius-lg);
|
||||
background-color: var(--color-surface-canvas);
|
||||
overflow: hidden;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1.5rem;
|
||||
height: 3.25rem;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
.title {
|
||||
font: var(--txt-heading-s);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
font: var(--txt-body-m-regular);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.repo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font: var(--txt-body-m-medium);
|
||||
color: var(--color-text-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
.repo-icon {
|
||||
display: inline-flex;
|
||||
color: var(--color-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rid {
|
||||
font: var(--txt-code-regular);
|
||||
color: var(--color-text-tertiary);
|
||||
word-break: break-all;
|
||||
}
|
||||
/* Deliberately not a warning band: `rad unseed` drops the seeding policy and
|
||||
nothing else, so this is reversible and loses no data. */
|
||||
.note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-surface-subtle);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.note-icon {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.note-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.error {
|
||||
color: var(--color-feedback-error-text);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0 1.5rem 1.5rem;
|
||||
}
|
||||
.confirm-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="modal">
|
||||
<div class="header">
|
||||
<span class="title">Stop seeding</span>
|
||||
<Button variant="naked" onclick={hide}>
|
||||
<span style:color="var(--color-text-tertiary)">
|
||||
<Icon name="close" />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<div class="repo txt-overflow">
|
||||
<span class="repo-icon"><Icon name="repository" /></span>
|
||||
{name}
|
||||
</div>
|
||||
<span class="rid">{rid}</span>
|
||||
|
||||
<div class="note txt-body-m-regular">
|
||||
<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.
|
||||
{#if !clean}
|
||||
The files stay on disk.
|
||||
{/if}
|
||||
</span>
|
||||
<!-- Its own paragraph rather than a swapped-in sentence, so ticking the
|
||||
box reads as text appearing instead of a block reflowing. -->
|
||||
{#if clean}
|
||||
<span>
|
||||
The files go too, except the refs your node signed and the
|
||||
delegates'. Other nodes have already fetched yours, so replacing
|
||||
them later would fork your own history, and the repository can't be
|
||||
verified without the delegates'. If you have never published here
|
||||
there is nothing signed, and it is deleted outright.
|
||||
</span>
|
||||
{/if}
|
||||
<Checkbox bind:checked={clean} disabled={working}>
|
||||
Delete the files from local storage
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<span class="error">{error}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<Button variant="outline" onclick={hide}>Cancel</Button>
|
||||
<Button variant="ghost" disabled={working} onclick={() => void run()}>
|
||||
<span class="confirm-label">
|
||||
<Icon name={clean ? "trash" : "seed"} />
|
||||
{working ? "Stopping…" : "Stop seeding"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user