Radish alpha
r
Radicle desktop app
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add seeded-artifacts modal with disk usage and unseed
Daniel Norman committed 7 days ago
commit 40385b017259d650622ca7e0440e3f686c1bd9a4
parent cea53bb7cb148209d520ac8d49b08854656c7391
8 files changed +393 -2
modified crates/radicle-tauri/src/commands/cob/release.rs
@@ -207,6 +207,31 @@ pub async fn is_seeding(iroh: tauri::State<'_, IrohState>, cid: String) -> Resul
    seeder::is_seeded_str(&iroh.blobs, &cid).await
}

+
/// List every artifact we are currently seeding, across all repos, with
+
/// a disk-usage estimate per entry. Used by the global seeding settings
+
/// view so the user can audit (and prune) what they're sharing.
+
#[tauri::command]
+
pub async fn list_seeded_artifacts(
+
    ctx: tauri::State<'_, AppState>,
+
    iroh: tauri::State<'_, IrohState>,
+
) -> Result<Vec<release::SeededArtifact>, Error> {
+
    let cids = seeder::seeded_cids(&iroh.blobs).await?;
+
    let cid_strings: std::collections::HashSet<String> =
+
        cids.iter().map(|c| c.to_string()).collect();
+

+
    let ctx_clone = ctx.inner().clone();
+
    let mut entries =
+
        tauri::async_runtime::spawn_blocking(move || ctx_clone.find_seeded_artifacts(&cid_strings))
+
            .await??;
+

+
    // Sizes are filled in here, on the async runtime, because the iroh
+
    // Store is async-only and the trait method runs on the blocking pool.
+
    for entry in &mut entries {
+
        entry.size_bytes = seeder::artifact_size_str(&iroh.blobs, &entry.cid).await;
+
    }
+
    Ok(entries)
+
}
+

// Download ------------------------------------------------------------------

use radicle_types::fetch;
modified crates/radicle-tauri/src/lib.rs
@@ -59,6 +59,7 @@ pub fn run() {
            cob::release::seed_artifact,
            cob::release::unseed_artifact,
            cob::release::is_seeding,
+
            cob::release::list_seeded_artifacts,
            cob::release::download_artifact,
            cob::release::pick_artifact_files,
            cob::release::pick_artifact_directory,
added crates/radicle-types/bindings/cob/release/SeededArtifact.ts
@@ -0,0 +1,25 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
import type { ArtifactKind } from "./ArtifactKind";
+

+
/**
+
 * Single row in the global "what am I seeding?" list. Carries enough
+
 * context for the UI to navigate back to the originating release and to
+
 * call `unseed_artifact` without an extra lookup.
+
 */
+
export type SeededArtifact = {
+
  rid: string;
+
  /**
+
   * Project name from the repo's identity doc, or an empty string when
+
   * the repo has no project payload (rare; surfaces as the RID in UI).
+
   */
+
  repoName: string;
+
  releaseId: string;
+
  cid: string;
+
  name: string;
+
  kind: ArtifactKind;
+
  /**
+
   * Best-effort sum of stored bytes. Returns 0 if the size lookup fails
+
   * rather than failing the entire listing.
+
   */
+
  sizeBytes: number;
+
};
modified crates/radicle-types/src/cobs/release.rs
@@ -73,6 +73,29 @@ pub enum ArtifactKind {
    Unknown,
}

+
/// Single row in the global "what am I seeding?" list. Carries enough
+
/// context for the UI to navigate back to the originating release and to
+
/// call `unseed_artifact` without an extra lookup.
+
#[derive(Clone, Serialize, TS, Debug)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "cob/release/")]
+
pub struct SeededArtifact {
+
    #[ts(as = "String")]
+
    pub rid: radicle::identity::RepoId,
+
    /// Project name from the repo's identity doc, or an empty string when
+
    /// the repo has no project payload (rare; surfaces as the RID in UI).
+
    pub repo_name: String,
+
    pub release_id: String,
+
    pub cid: String,
+
    pub name: String,
+
    pub kind: ArtifactKind,
+
    /// Best-effort sum of stored bytes. Returns 0 if the size lookup fails
+
    /// rather than failing the entire listing.
+
    #[ts(type = "number")]
+
    pub size_bytes: u64,
+
}
+

#[derive(Clone, Serialize, TS, Debug)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
modified crates/radicle-types/src/seeder.rs
@@ -214,6 +214,49 @@ pub fn our_iroh_url(endpoint: &iroh::Endpoint) -> String {
    format!("iroh://{}", endpoint.id())
}

+
/// Sum of stored bytes for one seeded CID. Blobs report their own size;
+
/// collections walk their hash sequence and sum the children. Errors
+
/// resolve to `Ok(0)` so the global seeding view can still render the row.
+
pub async fn artifact_size(store: &Store, cid: &Cid) -> u64 {
+
    let Ok(kind) = cid_utils::artifact_kind(cid) else {
+
        return 0;
+
    };
+
    let Ok(Some(tag)) = store.tags().get(seeded_tag(cid).as_bytes()).await else {
+
        return 0;
+
    };
+
    match kind {
+
        ArtifactKind::Blob => blob_size(store, tag.hash).await,
+
        ArtifactKind::Collection => match Collection::load(tag.hash, store).await {
+
            Ok(collection) => {
+
                let mut total = 0u64;
+
                for (_, child) in collection.iter() {
+
                    total = total.saturating_add(blob_size(store, *child).await);
+
                }
+
                total
+
            }
+
            Err(_) => 0,
+
        },
+
    }
+
}
+

+
/// String-taking wrapper around [`artifact_size`] so Tauri commands that
+
/// don't depend on the `cid` crate can call into us with the raw CID.
+
pub async fn artifact_size_str(store: &Store, cid: &str) -> u64 {
+
    match Cid::from_str(cid) {
+
        Ok(parsed) => artifact_size(store, &parsed).await,
+
        Err(_) => 0,
+
    }
+
}
+

+
async fn blob_size(store: &Store, hash: Hash) -> u64 {
+
    use iroh_blobs::api::proto::BlobStatus;
+
    match store.blobs().status(hash).await {
+
        Ok(BlobStatus::Complete { size }) => size,
+
        Ok(BlobStatus::Partial { size }) => size.unwrap_or(0),
+
        _ => 0,
+
    }
+
}
+

/// Return the set of CIDs we currently have seeded locally. Decoding
/// failures (unlikely — we wrote the tags ourselves) are skipped.
pub async fn seeded_cids(store: &Store) -> Result<HashSet<Cid>, Error> {
modified crates/radicle-types/src/traits/release.rs
@@ -1,8 +1,9 @@
-
use std::collections::{BTreeMap, BTreeSet, HashMap};
+
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::path::PathBuf;
use std::str::FromStr;

use radicle::identity;
+
use radicle::identity::doc;
use radicle::storage::{ReadRepository, ReadStorage};
use radicle_artifact::share::cid_utils;
use radicle_artifact::Releases as ReleasesStore;
@@ -150,6 +151,64 @@ pub trait Releases: Profile {
                })?;
        Ok(artifact.locations().clone())
    }
+

+
    /// Walk every locally-stored repo and return the artifact entries
+
    /// whose CID is in the supplied seeded set. The caller (Tauri command)
+
    /// supplies the set so this trait stays free of iroh dependencies.
+
    fn find_seeded_artifacts(
+
        &self,
+
        seeded: &HashSet<String>,
+
    ) -> Result<Vec<release::SeededArtifact>, Error> {
+
        let profile = self.profile();
+
        let storage = &profile.storage;
+

+
        let mut out = Vec::new();
+
        for radicle::storage::RepositoryInfo { rid, doc, .. } in storage.repositories()? {
+
            let Ok(repo) = profile.storage.repository(rid) else {
+
                continue;
+
            };
+
            let Ok(releases) = ReleasesStore::open(&repo) else {
+
                continue;
+
            };
+
            let repo_name = doc
+
                .payload()
+
                .get(&doc::PayloadId::project())
+
                .and_then(|payload| {
+
                    crate::repo::ProjectPayloadData::try_from((*payload).clone()).ok()
+
                })
+
                .map(|p| p.name)
+
                .unwrap_or_default();
+

+
            for item in releases.all()? {
+
                let Ok((id, release)) = item else { continue };
+
                for (cid, artifact) in release.artifacts() {
+
                    let cid_str = cid.to_string();
+
                    if !seeded.contains(&cid_str) {
+
                        continue;
+
                    }
+
                    let kind = match cid_utils::artifact_kind(cid) {
+
                        Ok(cid_utils::ArtifactKind::Blob) => release::ArtifactKind::Blob,
+
                        Ok(cid_utils::ArtifactKind::Collection) => {
+
                            release::ArtifactKind::Collection
+
                        }
+
                        Err(_) => release::ArtifactKind::Unknown,
+
                    };
+
                    out.push(release::SeededArtifact {
+
                        rid,
+
                        repo_name: repo_name.clone(),
+
                        release_id: id.to_string(),
+
                        cid: cid_str,
+
                        name: artifact.name().to_string(),
+
                        kind,
+
                        // Size is filled in by the Tauri command after this
+
                        // returns; the trait can't reach the iroh store.
+
                        size_bytes: 0,
+
                    });
+
                }
+
            }
+
        }
+
        Ok(out)
+
    }
}

/// Build a lookup from tag-object OID to short refname so the list view
added src/modals/SeedingSettings.svelte
@@ -0,0 +1,201 @@
+
<script lang="ts">
+
  import type { SeededArtifact } from "@bindings/cob/release/SeededArtifact";
+

+
  import { onMount } from "svelte";
+

+
  import { invoke } from "@app/lib/invoke";
+
  import { hide } from "@app/lib/modal";
+

+
  import Button from "@app/components/Button.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Id from "@app/components/Id.svelte";
+
  import ScrollArea from "@app/components/ScrollArea.svelte";
+

+
  let artifacts = $state<SeededArtifact[]>([]);
+
  let loading = $state(true);
+
  const busy = $state<Record<string, boolean>>({});
+

+
  onMount(() => {
+
    void refresh();
+
  });
+

+
  async function refresh() {
+
    loading = true;
+
    try {
+
      artifacts = await invoke<SeededArtifact[]>("list_seeded_artifacts");
+
    } catch (err) {
+
      console.error("list_seeded_artifacts failed", err);
+
      artifacts = [];
+
    } finally {
+
      loading = false;
+
    }
+
  }
+

+
  async function unseed(artifact: SeededArtifact) {
+
    const key = `${artifact.rid}/${artifact.cid}`;
+
    busy[key] = true;
+
    try {
+
      await invoke("unseed_artifact", {
+
        rid: artifact.rid,
+
        releaseId: artifact.releaseId,
+
        cid: artifact.cid,
+
      });
+
      artifacts = artifacts.filter(a => a.cid !== artifact.cid);
+
    } catch (err) {
+
      console.error("unseed failed", err);
+
    } finally {
+
      busy[key] = false;
+
    }
+
  }
+

+
  function formatBytes(bytes: number): string {
+
    if (bytes === 0) return "—";
+
    const units = ["B", "KiB", "MiB", "GiB", "TiB"];
+
    let value = bytes;
+
    let unit = 0;
+
    while (value >= 1024 && unit < units.length - 1) {
+
      value /= 1024;
+
      unit += 1;
+
    }
+
    const formatted = value < 10 ? value.toFixed(1) : value.toFixed(0);
+
    return `${formatted} ${units[unit]}`;
+
  }
+

+
  const totalBytes = $derived(
+
    artifacts.reduce((sum, a) => sum + a.sizeBytes, 0),
+
  );
+
</script>
+

+
<style>
+
  .modal {
+
    width: 48rem;
+
    max-height: 80vh;
+
    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);
+
  }
+
  .summary {
+
    padding: 1rem 1.5rem;
+
    border-bottom: 1px solid var(--color-border-subtle);
+
    display: flex;
+
    justify-content: space-between;
+
    align-items: center;
+
    font: var(--txt-body-m-regular);
+
    color: var(--color-text-secondary);
+
  }
+
  .list {
+
    overflow-y: auto;
+
    flex: 1;
+
  }
+
  .row {
+
    display: grid;
+
    grid-template-columns: 1fr auto auto;
+
    gap: 1rem;
+
    padding: 0.75rem 1.5rem;
+
    border-bottom: 1px solid var(--color-border-subtle);
+
    align-items: center;
+
  }
+
  .row:last-child {
+
    border-bottom: none;
+
  }
+
  .row .name {
+
    font: var(--txt-body-m-semibold);
+
    color: var(--color-text-primary);
+
    word-break: break-word;
+
  }
+
  .row .meta {
+
    display: flex;
+
    flex-wrap: wrap;
+
    gap: 0.5rem;
+
    align-items: center;
+
    font: var(--txt-body-s-regular);
+
    color: var(--color-text-secondary);
+
    margin-top: 0.25rem;
+
  }
+
  .repo-name {
+
    font: var(--txt-body-s-semibold);
+
    color: var(--color-text-secondary);
+
  }
+
  .size {
+
    font: var(--txt-body-m-mono);
+
    color: var(--color-text-secondary);
+
    white-space: nowrap;
+
  }
+
  .empty {
+
    padding: 3rem 1.5rem;
+
    text-align: center;
+
    color: var(--color-text-secondary);
+
  }
+
</style>
+

+
<div class="modal">
+
  <div class="header">
+
    <span class="title">Seeded artifacts</span>
+
    <Button variant="naked" onclick={hide}>
+
      <span style:color="var(--color-text-tertiary)">
+
        <Icon name="close" />
+
      </span>
+
    </Button>
+
  </div>
+

+
  <div class="summary">
+
    <span>
+
      {artifacts.length}
+
      {artifacts.length === 1 ? "artifact" : "artifacts"} seeded
+
    </span>
+
    <span class="size">{formatBytes(totalBytes)} on disk</span>
+
  </div>
+

+
  <ScrollArea style="flex: 1; min-height: 0;">
+
    {#if loading}
+
      <div class="empty">Loading…</div>
+
    {:else if artifacts.length === 0}
+
      <div class="empty">
+
        You're not seeding any artifacts. Publish or seed one from a release to
+
        see it here.
+
      </div>
+
    {:else}
+
      <div class="list">
+
        {#each artifacts as artifact (artifact.cid)}
+
          <div class="row">
+
            <div>
+
              <div class="name">{artifact.name}</div>
+
              <div class="meta">
+
                <span class="repo-name">
+
                  {artifact.repoName === "" ? artifact.rid : artifact.repoName}
+
                </span>
+
                <span>·</span>
+
                <Id id={artifact.cid} clipboard={artifact.cid} />
+
                <span>·</span>
+
                <span>{artifact.kind}</span>
+
              </div>
+
            </div>
+
            <div class="size">{formatBytes(artifact.sizeBytes)}</div>
+
            <Button
+
              variant="outline"
+
              onclick={() => unseed(artifact)}
+
              disabled={busy[`${artifact.rid}/${artifact.cid}`]}>
+
              Unseed
+
            </Button>
+
          </div>
+
        {/each}
+
      </div>
+
    {/if}
+
  </ScrollArea>
+
</div>
modified src/modals/Settings.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import { hide } from "@app/lib/modal";
+
  import { hide, show } from "@app/lib/modal";
  import { updateChecker } from "@app/lib/updateChecker.svelte";

  import AnnounceSwitch from "@app/components/AnnounceSwitch.svelte";
@@ -12,6 +12,7 @@
  import Icon from "@app/components/Icon.svelte";
  import ThemeSwitch from "@app/components/ThemeSwitch.svelte";
  import UpdateSwitch from "@app/components/UpdateSwitch.svelte";
+
  import SeedingSettings from "@app/modals/SeedingSettings.svelte";
</script>

<style>
@@ -123,6 +124,19 @@
    </div>
    <div class="row">
      <div class="row-label">
+
        <span class="row-title">Seeded artifacts</span>
+
        <span class="row-description">
+
          See what you're sharing over iroh and free up disk space
+
        </span>
+
      </div>
+
      <Button
+
        variant="outline"
+
        onclick={() => show({ component: SeedingSettings, props: {} })}>
+
        Manage
+
      </Button>
+
    </div>
+
    <div class="row">
+
      <div class="row-label">
        <span class="row-title">Code font</span>
        <span class="row-description">Use a monospace font in code views</span>
      </div>