Radish alpha
r
Radicle desktop app
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add UI to view and edit artifact metadata
Daniel Norman committed 7 days ago
commit f57254d3d5ff05cb423f43b0dcfa9a4c0d24e14f
parent 634527c1aa443a9663d5a5a19bc5be59de29c498
7 files changed +316 -7
modified crates/radicle-tauri/src/commands/cob/release.rs
@@ -113,6 +113,33 @@ pub async fn attest_artifact(
}

#[tauri::command]
+
pub async fn set_metadata(
+
    ctx: tauri::State<'_, AppState>,
+
    rid: RepoId,
+
    release_id: String,
+
    cid: String,
+
    key: String,
+
    value: serde_json::Value,
+
) -> Result<(), Error> {
+
    let ctx = ctx.inner().clone();
+
    tauri::async_runtime::spawn_blocking(move || ctx.set_metadata(rid, release_id, cid, key, value))
+
        .await?
+
}
+

+
#[tauri::command]
+
pub async fn remove_metadata(
+
    ctx: tauri::State<'_, AppState>,
+
    rid: RepoId,
+
    release_id: String,
+
    cid: String,
+
    key: String,
+
) -> Result<(), Error> {
+
    let ctx = ctx.inner().clone();
+
    tauri::async_runtime::spawn_blocking(move || ctx.remove_metadata(rid, release_id, cid, key))
+
        .await?
+
}
+

+
#[tauri::command]
pub async fn redact_artifact(
    ctx: tauri::State<'_, AppState>,
    rid: RepoId,
modified crates/radicle-tauri/src/lib.rs
@@ -53,6 +53,8 @@ pub fn run() {
            cob::release::add_location,
            cob::release::remove_location,
            cob::release::attest_artifact,
+
            cob::release::set_metadata,
+
            cob::release::remove_metadata,
            cob::release::redact_artifact,
            cob::release::seed_artifact,
            cob::release::unseed_artifact,
modified crates/radicle-types/src/traits/release_mut.rs
@@ -129,6 +129,47 @@ pub trait ReleasesMut: Releases {
        Ok(())
    }

+
    fn set_metadata(
+
        &self,
+
        rid: identity::RepoId,
+
        release_id: String,
+
        cid: String,
+
        key: String,
+
        value: serde_json::Value,
+
    ) -> Result<(), Error> {
+
        let profile = self.profile();
+
        let signer = profile.signer()?;
+
        let repo = profile.storage.repository(rid)?;
+

+
        let id = radicle_artifact::ReleaseId::from_str(&release_id)?;
+
        let cid = cid::Cid::from_str(&cid)?;
+

+
        let mut releases = ReleasesStore::open(&repo)?;
+
        let mut release = releases.get_mut(&id)?;
+
        release.set_metadata(cid, key, value, &signer)?;
+
        Ok(())
+
    }
+

+
    fn remove_metadata(
+
        &self,
+
        rid: identity::RepoId,
+
        release_id: String,
+
        cid: String,
+
        key: String,
+
    ) -> Result<(), Error> {
+
        let profile = self.profile();
+
        let signer = profile.signer()?;
+
        let repo = profile.storage.repository(rid)?;
+

+
        let id = radicle_artifact::ReleaseId::from_str(&release_id)?;
+
        let cid = cid::Cid::from_str(&cid)?;
+

+
        let mut releases = ReleasesStore::open(&repo)?;
+
        let mut release = releases.get_mut(&id)?;
+
        release.remove_metadata(cid, key, &signer)?;
+
        Ok(())
+
    }
+

    fn redact_artifact(
        &self,
        rid: identity::RepoId,
modified crates/test-http-api/src/api.rs
@@ -119,6 +119,8 @@ pub fn router(ctx: Context) -> Router {
        .route("/add_location", post(add_location_handler))
        .route("/remove_location", post(remove_location_handler))
        .route("/attest_artifact", post(attest_artifact_handler))
+
        .route("/set_metadata", post(set_metadata_handler))
+
        .route("/remove_metadata", post(remove_metadata_handler))
        .route("/redact_artifact", post(redact_artifact_handler))
        .route(
            "/get_auto_seed_artifacts",
@@ -694,6 +696,23 @@ struct RedactBody {
}

#[derive(Serialize, Deserialize)]
+
struct SetMetadataBody {
+
    pub rid: identity::RepoId,
+
    pub release_id: String,
+
    pub cid: String,
+
    pub key: String,
+
    pub value: serde_json::Value,
+
}
+

+
#[derive(Serialize, Deserialize)]
+
struct RemoveMetadataBody {
+
    pub rid: identity::RepoId,
+
    pub release_id: String,
+
    pub cid: String,
+
    pub key: String,
+
}
+

+
#[derive(Serialize, Deserialize)]
struct AutoSeedBody {
    pub enabled: bool,
}
@@ -784,6 +803,33 @@ async fn attest_artifact_handler(
    Ok::<_, Error>(Json(()))
}

+
async fn set_metadata_handler(
+
    State(ctx): State<Context>,
+
    Json(SetMetadataBody {
+
        rid,
+
        release_id,
+
        cid,
+
        key,
+
        value,
+
    }): Json<SetMetadataBody>,
+
) -> impl IntoResponse {
+
    ctx.set_metadata(rid, release_id, cid, key, value)?;
+
    Ok::<_, Error>(Json(()))
+
}
+

+
async fn remove_metadata_handler(
+
    State(ctx): State<Context>,
+
    Json(RemoveMetadataBody {
+
        rid,
+
        release_id,
+
        cid,
+
        key,
+
    }): Json<RemoveMetadataBody>,
+
) -> impl IntoResponse {
+
    ctx.remove_metadata(rid, release_id, cid, key)?;
+
    Ok::<_, Error>(Json(()))
+
}
+

async fn redact_artifact_handler(
    State(ctx): State<Context>,
    Json(RedactBody {
modified src/views/repo/NewRelease.svelte
@@ -51,9 +51,7 @@
  // selected and it's annotated. Lightweight tags resolve to a commit OID
  // only and don't contribute a separate tag OID.
  const selectedTag = $derived(tags.find(t => t.name === selectedTagName));
-
  const tagOid = $derived(
-
    selectedTag?.annotated ? selectedTag.oid : undefined,
-
  );
+
  const tagOid = $derived(selectedTag?.annotated ? selectedTag.oid : undefined);

  void invoke<boolean>("get_auto_seed_artifacts").then(v => {
    autoSeed = v;
@@ -297,8 +295,7 @@
      placeholder="e.g. ec49ecb..."
      list="release-oid-options"
      value={oid}
-
      oninput={e =>
-
        onCommitInput((e.currentTarget as HTMLInputElement).value)}
+
      oninput={e => onCommitInput((e.currentTarget as HTMLInputElement).value)}
      disabled={submitting} />
    <datalist id="release-oid-options">
      {#each commits as commit (commit.id)}
modified src/views/repo/Release.svelte
@@ -1,12 +1,14 @@
<script lang="ts">
  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 { RepoInfo } from "@bindings/repo/RepoInfo";

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

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

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

@@ -15,9 +17,46 @@
  interface Props {
    repo: RepoInfo;
    release: Release;
+
    config: Config;
  }

-
  const { repo, release }: Props = $props();
+
  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;
+
  });
+

+
  async function refresh() {
+
    const next = await invoke<Release | null>("release_by_id", {
+
      rid: repo.rid,
+
      releaseId: release.id,
+
    });
+
    if (next) release = next;
+
  }
+

+
  const delegateDids = $derived(repo.delegates.map(d => d.did));
+

+
  function canEditMetadata(artifact: Artifact): boolean {
+
    return (
+
      isDelegateOrAuthor(
+
        config.publicKey,
+
        delegateDids,
+
        artifact.author.did,
+
      ) === true
+
    );
+
  }
+

+
  // 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) {
+
      if (!draft[a.cid]) draft[a.cid] = { key: "", value: "" };
+
    }
+
  });

  // The DTO carries shared_by_us derived from COB locations, but right
  // after a Seed/Unseed we want immediate feedback before the next
@@ -131,6 +170,51 @@
    }
  }

+
  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);
+
    } catch {
+
      value = raw;
+
    }
+
    busy[artifact.cid] = true;
+
    try {
+
      await invoke("set_metadata", {
+
        rid: repo.rid,
+
        releaseId: release.id,
+
        cid: artifact.cid,
+
        key,
+
        value,
+
      });
+
      draft[artifact.cid] = { key: "", value: "" };
+
      await refresh();
+
    } catch (err) {
+
      console.error("set_metadata failed", err);
+
    } finally {
+
      busy[artifact.cid] = false;
+
    }
+
  }
+

+
  async function removeMetadata(artifact: Artifact, key: string) {
+
    busy[artifact.cid] = true;
+
    try {
+
      await invoke("remove_metadata", {
+
        rid: repo.rid,
+
        releaseId: release.id,
+
        cid: artifact.cid,
+
        key,
+
      });
+
      await refresh();
+
    } catch (err) {
+
      console.error("remove_metadata 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> {
@@ -216,6 +300,69 @@
    font: var(--txt-body-s-regular);
    color: var(--color-text-secondary);
  }
+
  .metadata {
+
    display: grid;
+
    grid-template-columns: auto 1fr;
+
    column-gap: 0.5rem;
+
    row-gap: 0.125rem;
+
    margin: 0.5rem 0 0;
+
    font: var(--txt-body-s-regular);
+
  }
+
  .metadata dt {
+
    font-family: var(--font-family-mono);
+
    color: var(--color-text-secondary);
+
  }
+
  .metadata dd {
+
    margin: 0;
+
    display: flex;
+
    align-items: center;
+
    gap: 0.25rem;
+
  }
+
  .metadata .value {
+
    word-break: break-word;
+
  }
+
  .remove-meta {
+
    padding: 0 0.25rem;
+
    background: transparent;
+
    border: none;
+
    color: var(--color-text-secondary);
+
    cursor: pointer;
+
    line-height: 1;
+
  }
+
  .remove-meta:disabled {
+
    opacity: 0.5;
+
    cursor: default;
+
  }
+
  .add-meta {
+
    display: flex;
+
    gap: 0.25rem;
+
    margin-top: 0.5rem;
+
  }
+
  .add-meta input {
+
    padding: 0.25rem 0.5rem;
+
    border: 1px solid var(--color-border-subtle);
+
    border-radius: var(--border-radius-sm);
+
    font: var(--txt-body-s-regular);
+
  }
+
  .add-meta input:first-child {
+
    font-family: var(--font-family-mono);
+
    width: 8rem;
+
  }
+
  .add-meta input:nth-child(2) {
+
    flex: 1;
+
  }
+
  .add-meta 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);
+
  }
+
  .add-meta button:disabled {
+
    opacity: 0.5;
+
    cursor: default;
+
  }
  .progress {
    margin-top: 0.5rem;
    font: var(--txt-body-s-regular);
@@ -284,6 +431,48 @@
          </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}
modified src/views/repo/router.ts
@@ -176,6 +176,7 @@ export interface LoadedRepoReleaseRoute {
  resource: "repo.release";
  params: {
    repo: RepoInfo;
+
    config: Config;
    releases: Release[];
    release: Release;
    sidebarData: SidebarData;
@@ -425,7 +426,13 @@ export async function loadRelease(

  return {
    resource: "repo.release",
-
    params: { sidebarData, repo, release, releases },
+
    params: {
+
      sidebarData,
+
      repo,
+
      config: sidebarData.config,
+
      release,
+
      releases,
+
    },
  };
}