Radish alpha
r
Radicle desktop app
Radicle
Git (anonymous pull)
Log in to clone via SSH
Build out Release detail view
Daniel Norman committed 7 days ago
commit 2743a647ec6b2f8cf5cba4f5b22a447cbe3fab95
parent da7e970c75cbe9fb769aa3bb2c18c8af4fc6e1c2
1 file changed +262 -6
modified src/views/repo/Release.svelte
@@ -1,10 +1,13 @@
<script lang="ts">
-
  // Detail view for a single release.
-
  // Stub: see Releases.svelte for the list view; this page is filled in
-
  // alongside the seed/download UI in a follow-up commit.
+
  import type { Artifact } from "@bindings/cob/release/Artifact";
  import type { Release } from "@bindings/cob/release/Release";
  import type { RepoInfo } from "@bindings/repo/RepoInfo";

+
  import { listen } from "@tauri-apps/api/event";
+
  import { onDestroy, onMount } from "svelte";
+

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

  import Layout from "./Layout.svelte";
  import Topbar from "@app/components/Topbar.svelte";

@@ -14,12 +17,265 @@
    release: Release;
  }

-
  const { release }: Props = $props();
+
  const { repo, release }: Props = $props();
+

+
  // The DTO carries shared_by_us derived from COB locations, but right
+
  // after a Seed/Unseed we want immediate feedback before the next
+
  // list_releases roundtrip. Track a per-cid override layered on top.
+
  const localShared = $state<Record<string, boolean>>({});
+
  const busy = $state<Record<string, boolean>>({});
+
  const progress = $state<
+
    Record<string, { stage: string; bytes?: number } | undefined>
+
  >({});
+

+
  type ProgressEvent = { cid: string; stage: string; bytes?: number };
+
  let unlistenProgress: (() => void) | undefined;
+

+
  onMount(async () => {
+
    unlistenProgress = await listen<ProgressEvent>("artifact_progress", e => {
+
      const { cid, stage, bytes } = e.payload;
+
      if (stage === "done") {
+
        progress[cid] = undefined;
+
      } else {
+
        progress[cid] = { stage, bytes };
+
      }
+
    });
+
  });
+

+
  onDestroy(() => {
+
    if (unlistenProgress) unlistenProgress();
+
  });
+

+
  function isShared(a: Artifact): boolean {
+
    return localShared[a.cid] ?? a.sharedByUs;
+
  }
+

+
  async function seed(artifact: Artifact) {
+
    // The frontend doesn't ship the source path for already-COB-recorded
+
    // artifacts, so we ask the user to point at it. (Auto-seed at create
+
    // time stays in NewRelease.svelte where the path is in scope.)
+
    const source = await invoke<string | null>("pick_artifact_directory");
+
    let path = source;
+
    if (!path) {
+
      const files = await invoke<string[]>("pick_artifact_files");
+
      if (files.length === 0) return;
+
      path = files[0];
+
    }
+
    busy[artifact.cid] = true;
+
    try {
+
      await invoke("seed_artifact", {
+
        rid: repo.rid,
+
        releaseId: release.id,
+
        cid: artifact.cid,
+
        sourcePath: path,
+
      });
+
      localShared[artifact.cid] = true;
+
    } catch (err) {
+
      console.error("seed failed", err);
+
    } finally {
+
      busy[artifact.cid] = false;
+
    }
+
  }
+

+
  async function unseed(artifact: Artifact) {
+
    busy[artifact.cid] = true;
+
    try {
+
      await invoke("unseed_artifact", {
+
        rid: repo.rid,
+
        releaseId: release.id,
+
        cid: artifact.cid,
+
      });
+
      localShared[artifact.cid] = false;
+
    } catch (err) {
+
      console.error("unseed failed", err);
+
    } finally {
+
      busy[artifact.cid] = false;
+
    }
+
  }
+

+
  async function download(artifact: Artifact) {
+
    // Pick a destination — file for blobs, directory for collections.
+
    const isCollection = artifact.kind === "collection";
+
    const dest = isCollection
+
      ? await invoke<string | null>("pick_artifact_directory")
+
      : await pickSaveFile(artifact.name);
+
    if (!dest) return;
+

+
    busy[artifact.cid] = true;
+
    try {
+
      await invoke("download_artifact", {
+
        rid: repo.rid,
+
        releaseId: release.id,
+
        cid: artifact.cid,
+
        dest,
+
      });
+
    } catch (err) {
+
      console.error("download failed", err);
+
    } finally {
+
      busy[artifact.cid] = false;
+
    }
+
  }
+

+
  async function attest(artifact: Artifact) {
+
    busy[artifact.cid] = true;
+
    try {
+
      await invoke("attest_artifact", {
+
        rid: repo.rid,
+
        releaseId: release.id,
+
        cid: artifact.cid,
+
      });
+
    } catch (err) {
+
      console.error("attest failed", err);
+
    } finally {
+
      busy[artifact.cid] = false;
+
    }
+
  }
+

+
  // Save-file picker isn't a release-specific command; reuse the dialog
+
  // plugin via the existing pick_artifact_files (single-file fallback).
+
  async function pickSaveFile(_suggestedName: string): Promise<string | null> {
+
    const files = await invoke<string[]>("pick_artifact_files");
+
    return files[0] ?? null;
+
  }
+

+
  function shortCid(cid: string): string {
+
    return cid.length <= 16 ? cid : `${cid.slice(0, 8)}…${cid.slice(-6)}`;
+
  }
+

+
  function formatTimestamp(ts: number): string {
+
    return new Date(ts * 1000).toLocaleString();
+
  }
+

+
  function progressText(cid: string): string {
+
    const p = progress[cid];
+
    if (!p) return "";
+
    if (p.stage === "downloading" && p.bytes !== undefined) {
+
      const mb = (p.bytes / (1024 * 1024)).toFixed(1);
+
      return `downloading ${mb} MiB`;
+
    }
+
    return p.stage;
+
  }
</script>

+
<style>
+
  .header {
+
    padding: 1rem;
+
    border-bottom: 1px solid var(--color-border-subtle);
+
  }
+
  .oid {
+
    font: var(--txt-body-m-mono);
+
    color: var(--color-text-secondary);
+
  }
+
  .timestamp {
+
    font: var(--txt-body-s-regular);
+
    color: var(--color-text-secondary);
+
    margin-left: 0.5rem;
+
  }
+
  .artifact {
+
    padding: 0.75rem 1rem;
+
    border-bottom: 1px solid var(--color-border-subtle);
+
    background-color: var(--color-surface-1);
+
  }
+
  .artifact-row {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.75rem;
+
  }
+
  .name {
+
    font: var(--txt-body-m-semibold);
+
  }
+
  .cid {
+
    font: var(--txt-body-s-mono);
+
    color: var(--color-text-secondary);
+
  }
+
  .kind {
+
    font: var(--txt-body-s-regular);
+
    color: var(--color-text-secondary);
+
  }
+
  .actions {
+
    margin-left: auto;
+
    display: flex;
+
    gap: 0.25rem;
+
  }
+
  .actions button {
+
    padding: 0.25rem 0.5rem;
+
    background-color: var(--color-surface-subtle);
+
    border: 1px solid var(--color-border-subtle);
+
    border-radius: var(--border-radius-sm);
+
    cursor: pointer;
+
    font: var(--txt-body-s-regular);
+
  }
+
  .actions button:disabled {
+
    opacity: 0.5;
+
    cursor: default;
+
  }
+
  .meta {
+
    display: flex;
+
    gap: 1rem;
+
    margin-top: 0.5rem;
+
    font: var(--txt-body-s-regular);
+
    color: var(--color-text-secondary);
+
  }
+
  .progress {
+
    margin-top: 0.5rem;
+
    font: var(--txt-body-s-regular);
+
    color: var(--color-text-secondary);
+
  }
+
  .empty {
+
    padding: 2rem;
+
    color: var(--color-text-secondary);
+
    text-align: center;
+
  }
+
</style>
+

<Layout>
  <Topbar>
-
    <span>Release {release.oid.slice(0, 7)}</span>
+
    <span class="oid">Release {release.oid.slice(0, 7)}</span>
+
    <span class="timestamp">{formatTimestamp(release.timestamp)}</span>
  </Topbar>
-
  <pre style="padding: 1rem;">{JSON.stringify(release, null, 2)}</pre>
+

+
  <div class="header">
+
    <div class="oid">{release.oid}</div>
+
  </div>
+

+
  {#each release.artifacts as artifact (artifact.cid)}
+
    <div class="artifact">
+
      <div class="artifact-row">
+
        <span class="name">{artifact.name}</span>
+
        <span class="kind">[{artifact.kind}]</span>
+
        <span class="cid">{shortCid(artifact.cid)}</span>
+
        <div class="actions">
+
          <button onclick={() => download(artifact)} disabled={busy[artifact.cid]}>
+
            Download
+
          </button>
+
          {#if isShared(artifact)}
+
            <button onclick={() => unseed(artifact)} disabled={busy[artifact.cid]}>
+
              Unseed
+
            </button>
+
          {:else}
+
            <button onclick={() => seed(artifact)} disabled={busy[artifact.cid]}>
+
              Seed
+
            </button>
+
          {/if}
+
          <button onclick={() => attest(artifact)} disabled={busy[artifact.cid]}>
+
            Attest
+
          </button>
+
        </div>
+
      </div>
+
      <div class="meta">
+
        <span>{artifact.attestations.length} attestations</span>
+
        <span>{artifact.locations.length} locations</span>
+
        {#if artifact.redactions.length > 0}
+
          <span style:color="var(--color-fill-error)">
+
            {artifact.redactions.length} redactions
+
          </span>
+
        {/if}
+
      </div>
+
      {#if progress[artifact.cid]}
+
        <div class="progress">{progressText(artifact.cid)}</div>
+
      {/if}
+
    </div>
+
  {:else}
+
    <div class="empty">No artifacts in this release</div>
+
  {/each}
</Layout>