Radish alpha
r
Radicle desktop app
Radicle
Git (anonymous pull)
Log in to clone via SSH
Rework release detail view with breadcrumb, commit panel, redact
Daniel Norman committed 7 days ago
commit cd0cc35b01fe763fa71f451946573232154c0106
parent d083f93acdbeda4bbd894f5ea85a08271ff374cf
1 file changed +285 -123
modified src/views/repo/Release.svelte
@@ -2,6 +2,7 @@
  import type { Artifact } from "@bindings/cob/release/Artifact";
  import type { Release } from "@bindings/cob/release/Release";
  import type { Config } from "@bindings/config/Config";
+
  import type { Commit } from "@bindings/repo/Commit";
  import type { RepoInfo } from "@bindings/repo/RepoInfo";

  import { listen } from "@tauri-apps/api/event";
@@ -9,7 +10,13 @@

  import { invoke } from "@app/lib/invoke";
  import { isDelegateOrAuthor } from "@app/lib/roles";
+
  import * as router from "@app/lib/router";
+
  import { authorForNodeId } from "@app/lib/utils";

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

  import Layout from "./Layout.svelte";
@@ -22,13 +29,25 @@

  const { repo, release: releaseProp, config }: Props = $props();

-
  // Shadow the prop so mutations can reflect immediately. The effect keeps
-
  // us in sync if the route reloads the release with a fresh prop.
  let release = $state(releaseProp);
  $effect(() => {
    release = releaseProp;
  });

+
  // Resolve the released commit for the description panel. Lookup is best
+
  // effort; if the commit was garbage collected we just hide the panel.
+
  let commit = $state<Commit | undefined>(undefined);
+
  $effect(() => {
+
    const oid = release.oid;
+
    void invoke<Commit>("repo_commit", { rid: repo.rid, sha: oid })
+
      .then(c => {
+
        commit = c;
+
      })
+
      .catch(() => {
+
        commit = undefined;
+
      });
+
  });
+

  async function refresh() {
    const next = await invoke<Release | null>("release_by_id", {
      rid: repo.rid,
@@ -49,8 +68,6 @@
    );
  }

-
  // Per-artifact draft for the inline "add metadata" form. Keys/values stay
-
  // local until the user submits; we clear on success.
  const draft = $state<Record<string, { key: string; value: string }>>({});
  $effect(() => {
    for (const a of release.artifacts) {
@@ -58,10 +75,8 @@
    }
  });

-
  // 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 localAvailable = $state<Record<string, boolean>>({});
  const busy = $state<Record<string, boolean>>({});
  const progress = $state<
    Record<string, { stage: string; bytes?: number } | undefined>
@@ -75,25 +90,39 @@
      const { cid, stage, bytes } = e.payload;
      if (stage === "done") {
        progress[cid] = undefined;
+
        // Refresh the local-availability pill — a finished download means
+
        // the bytes are now in the store even if we never seeded.
+
        void refreshAvailability(cid);
      } else {
        progress[cid] = { stage, bytes };
      }
    });
+
    for (const a of release.artifacts) {
+
      void refreshAvailability(a.cid);
+
    }
  });

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

+
  async function refreshAvailability(cid: string) {
+
    try {
+
      localAvailable[cid] = await invoke<boolean>("is_seeding", { cid });
+
    } catch {
+
      localAvailable[cid] = false;
+
    }
+
  }
+

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

+
  function isAvailableLocally(a: Artifact): boolean {
+
    return localAvailable[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. Pick by kind so the
-
    // user sees exactly one picker (folder for collections, file for blobs)
-
    // rather than the previous folder-then-file fallback.
    let path: string | null;
    if (artifact.kind === "collection") {
      path = await invoke<string | null>("pick_artifact_directory");
@@ -111,6 +140,7 @@
        sourcePath: path,
      });
      localShared[artifact.cid] = true;
+
      localAvailable[artifact.cid] = true;
    } catch (err) {
      console.error("seed failed", err);
    } finally {
@@ -127,6 +157,7 @@
        cid: artifact.cid,
      });
      localShared[artifact.cid] = false;
+
      void refreshAvailability(artifact.cid);
    } catch (err) {
      console.error("unseed failed", err);
    } finally {
@@ -135,7 +166,6 @@
  }

  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")
@@ -165,6 +195,7 @@
        releaseId: release.id,
        cid: artifact.cid,
      });
+
      await refresh();
    } catch (err) {
      console.error("attest failed", err);
    } finally {
@@ -172,10 +203,31 @@
    }
  }

+
  async function redact(artifact: Artifact) {
+
    const reason = window.prompt(
+
      `Redact artifact "${artifact.name}"?\n\n` +
+
        "This adds a redaction signed by you to the release COB. The CID stays\n" +
+
        "on the COB so other peers can see why you redacted it. Type a reason:",
+
    );
+
    if (reason === null) return;
+
    busy[artifact.cid] = true;
+
    try {
+
      await invoke("redact_artifact", {
+
        rid: repo.rid,
+
        releaseId: release.id,
+
        cid: artifact.cid,
+
        reason,
+
      });
+
      await refresh();
+
    } catch (err) {
+
      console.error("redact failed", err);
+
    } finally {
+
      busy[artifact.cid] = false;
+
    }
+
  }
+

  async function setMetadata(artifact: Artifact, key: string, raw: string) {
    if (!key.trim()) return;
-
    // Try JSON first (so users can submit numbers, booleans, objects);
-
    // fall back to the raw string for ergonomic plain-text values.
    let value: unknown;
    try {
      value = JSON.parse(raw);
@@ -217,17 +269,14 @@
    }
  }

-
  // Save-file picker isn't a release-specific command; reuse the dialog
-
  // plugin via the existing pick_artifact_files (single-file fallback).
+
  // Tauri's dialog plugin offers a save_file picker, but exposing it would
+
  // be a new IPC surface. Reuse the existing pick_artifact_files command —
+
  // it works as a "pick a destination" prompt on macOS / Linux today.
  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();
  }
@@ -244,21 +293,65 @@
</script>

<style>
+
  .page {
+
    display: flex;
+
    flex-direction: column;
+
    height: 100%;
+
  }
+
  .breadcrumb {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.375rem;
+
  }
+
  .breadcrumb-link {
+
    cursor: pointer;
+
    background: none;
+
    border: none;
+
    padding: 0;
+
    font: var(--txt-body-m-regular);
+
    color: var(--color-text-secondary);
+
  }
+
  .breadcrumb-link:hover {
+
    color: var(--color-text-primary);
+
  }
  .header {
-
    padding: 1rem;
+
    padding: 1.25rem 1.5rem;
    border-bottom: 1px solid var(--color-border-subtle);
+
    display: flex;
+
    flex-direction: column;
+
    gap: 0.75rem;
  }
-
  .oid {
-
    font: var(--txt-body-m-mono);
+
  .header h1 {
+
    margin: 0;
+
    font: var(--txt-body-l-semibold);
+
    word-break: break-word;
+
  }
+
  .header .meta {
+
    display: flex;
+
    flex-wrap: wrap;
+
    align-items: center;
+
    gap: 0.5rem;
+
    font: var(--txt-body-m-regular);
    color: var(--color-text-secondary);
  }
-
  .timestamp {
-
    font: var(--txt-body-s-regular);
+
  .commit-panel {
+
    background-color: var(--color-surface-canvas);
+
    border-radius: var(--border-radius-sm);
+
    padding: 0.75rem 1rem;
+
    font: var(--txt-body-m-regular);
+
  }
+
  .commit-summary {
+
    font: var(--txt-body-m-semibold);
+
    color: var(--color-text-primary);
+
    margin-bottom: 0.25rem;
+
  }
+
  .commit-body {
+
    white-space: pre-wrap;
    color: var(--color-text-secondary);
-
    margin-left: 0.5rem;
+
    font: var(--txt-body-s-mono);
  }
  .artifact {
-
    padding: 0.75rem 1rem;
+
    padding: 1rem 1.5rem;
    border-bottom: 1px solid var(--color-border-subtle);
    background-color: var(--color-surface-1);
  }
@@ -266,18 +359,31 @@
    display: flex;
    align-items: center;
    gap: 0.75rem;
+
    flex-wrap: wrap;
  }
  .name {
    font: var(--txt-body-m-semibold);
  }
-
  .cid {
-
    font: var(--txt-body-s-mono);
+
  .kind {
+
    font: var(--txt-body-s-regular);
    color: var(--color-text-secondary);
  }
-
  .kind {
+
  .pill {
+
    display: inline-flex;
+
    align-items: center;
+
    gap: 0.25rem;
+
    height: 1.5rem;
+
    padding: 0 0.5rem;
+
    border-radius: var(--border-radius-sm);
+
    border: 1px solid var(--color-border-subtle);
    font: var(--txt-body-s-regular);
    color: var(--color-text-secondary);
  }
+
  .pill.available {
+
    background-color: var(--color-feedback-success-bg);
+
    border-color: var(--color-feedback-success-border);
+
    color: var(--color-feedback-success-text);
+
  }
  .actions {
    margin-left: auto;
    display: flex;
@@ -291,11 +397,14 @@
    cursor: pointer;
    font: var(--txt-body-s-regular);
  }
+
  .actions button.danger {
+
    color: var(--color-fill-error);
+
  }
  .actions button:disabled {
    opacity: 0.5;
    cursor: default;
  }
-
  .meta {
+
  .meta-line {
    display: flex;
    gap: 1rem;
    margin-top: 0.5rem;
@@ -378,108 +487,161 @@
</style>

<Layout>
-
  <Topbar>
-
    <span class="oid">Release {release.oid.slice(0, 7)}</span>
-
    <span class="timestamp">{formatTimestamp(release.timestamp)}</span>
-
  </Topbar>
-

-
  <div class="header">
-
    <div class="oid">{release.oid}</div>
-
    {#if release.tag}
-
      <div class="oid" style:margin-top="0.25rem">
-
        annotated tag {release.tag.slice(0, 7)}
+
  <div class="page">
+
    <Topbar>
+
      <div class="breadcrumb">
+
        <Icon name="commit" />
+
        <button
+
          class="breadcrumb-link"
+
          onclick={() =>
+
            router.push({
+
              resource: "repo.releases",
+
              rid: repo.rid,
+
            })}>
+
          Releases
+
        </button>
+
        <Icon name="chevron-right" />
+
        <Id id={release.oid} clipboard={release.oid} placement="bottom-start" />
      </div>
-
    {/if}
-
  </div>
+
    </Topbar>

-
  {#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>
+
    <ScrollArea style="flex: 1; min-height: 0;">
+
      <div class="header">
+
        <h1>
+
          {release.tagName ??
+
            release.commitSummary ??
+
            `Release ${release.oid.slice(0, 7)}`}
+
        </h1>
+
        <div class="meta">
+
          <NodeId {...authorForNodeId(release.creator)} />
+
          released
+
          <Id id={release.oid} clipboard={release.oid} />
+
          {#if release.tagName}
+
            <span class="pill">{release.tagName}</span>
          {/if}
-
          <button
-
            onclick={() => attest(artifact)}
-
            disabled={busy[artifact.cid]}>
-
            Attest
-
          </button>
+
          <span>{formatTimestamp(release.timestamp)}</span>
        </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 commit}
+
          <div class="commit-panel">
+
            <div class="commit-summary">{commit.summary}</div>
+
            {#if commit.message.trim() !== commit.summary.trim()}
+
              <div class="commit-body">
+
                {commit.message.slice(commit.summary.length).trim()}
+
              </div>
+
            {/if}
+
          </div>
        {/if}
      </div>
-
      {#if artifact.metadata && Object.keys(artifact.metadata).length > 0}
-
        <dl class="metadata">
-
          {#each Object.entries(artifact.metadata) as [key, value] (key)}
-
            <dt>{key}</dt>
-
            <dd>
-
              <span class="value">
-
                {typeof value === "string" ? value : JSON.stringify(value)}
+

+
      {#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>
+
            {#if isAvailableLocally(artifact)}
+
              <span class="pill available" title="Available in local store">
+
                <Icon name="checkmark" />
+
                Local
              </span>
+
            {/if}
+
            <div class="actions">
+
              <button
+
                onclick={() => download(artifact)}
+
                disabled={busy[artifact.cid] || artifact.locations.length === 0}
+
                title={artifact.locations.length === 0
+
                  ? "No locations to download from"
+
                  : "Download to disk"}>
+
                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>
              {#if canEditMetadata(artifact)}
                <button
-
                  class="remove-meta"
-
                  title="Remove"
-
                  onclick={() => removeMetadata(artifact, key)}
+
                  class="danger"
+
                  onclick={() => redact(artifact)}
                  disabled={busy[artifact.cid]}>
-
                  ×
+
                  Redact
                </button>
              {/if}
-
            </dd>
-
          {/each}
-
        </dl>
-
      {/if}
-
      {#if canEditMetadata(artifact) && draft[artifact.cid]}
-
        <form
-
          class="add-meta"
-
          onsubmit={e => {
-
            e.preventDefault();
-
            const d = draft[artifact.cid];
-
            void setMetadata(artifact, d.key, d.value);
-
          }}>
-
          <input
-
            type="text"
-
            placeholder="key"
-
            bind:value={draft[artifact.cid].key}
-
            disabled={busy[artifact.cid]} />
-
          <input
-
            type="text"
-
            placeholder="value (string or JSON)"
-
            bind:value={draft[artifact.cid].value}
-
            disabled={busy[artifact.cid]} />
-
          <button type="submit" disabled={busy[artifact.cid]}>Add</button>
-
        </form>
-
      {/if}
-
      {#if progress[artifact.cid]}
-
        <div class="progress">{progressText(artifact.cid)}</div>
-
      {/if}
-
    </div>
-
  {:else}
-
    <div class="empty">No artifacts in this release</div>
-
  {/each}
+
            </div>
+
          </div>
+
          <div style:margin-top="0.375rem">
+
            <Id id={artifact.cid} clipboard={artifact.cid} shorten={false} />
+
          </div>
+
          <div class="meta-line">
+
            <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 artifact.metadata && Object.keys(artifact.metadata).length > 0}
+
            <dl class="metadata">
+
              {#each Object.entries(artifact.metadata) as [key, value] (key)}
+
                <dt>{key}</dt>
+
                <dd>
+
                  <span class="value">
+
                    {typeof value === "string" ? value : JSON.stringify(value)}
+
                  </span>
+
                  {#if canEditMetadata(artifact)}
+
                    <button
+
                      class="remove-meta"
+
                      title="Remove"
+
                      onclick={() => removeMetadata(artifact, key)}
+
                      disabled={busy[artifact.cid]}>
+
                      ×
+
                    </button>
+
                  {/if}
+
                </dd>
+
              {/each}
+
            </dl>
+
          {/if}
+
          {#if canEditMetadata(artifact) && draft[artifact.cid]}
+
            <form
+
              class="add-meta"
+
              onsubmit={e => {
+
                e.preventDefault();
+
                const d = draft[artifact.cid];
+
                void setMetadata(artifact, d.key, d.value);
+
              }}>
+
              <input
+
                type="text"
+
                placeholder="key"
+
                bind:value={draft[artifact.cid].key}
+
                disabled={busy[artifact.cid]} />
+
              <input
+
                type="text"
+
                placeholder="value (string or JSON)"
+
                bind:value={draft[artifact.cid].value}
+
                disabled={busy[artifact.cid]} />
+
              <button type="submit" disabled={busy[artifact.cid]}>Add</button>
+
            </form>
+
          {/if}
+
          {#if progress[artifact.cid]}
+
            <div class="progress">{progressText(artifact.cid)}</div>
+
          {/if}
+
        </div>
+
      {:else}
+
        <div class="empty">No artifacts in this release</div>
+
      {/each}
+
    </ScrollArea>
+
  </div>
</Layout>