Radish alpha
r
Radicle desktop app
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add NewRelease creation form
Daniel Norman committed 7 days ago
commit ff0807c784ef137e1f5ea56c980e22c7b3e75f70
parent 2743a647ec6b2f8cf5cba4f5b22a447cbe3fab95
2 files changed +285 -0
added src/views/repo/NewRelease.svelte
@@ -0,0 +1,269 @@
+
<script lang="ts">
+
  // Modal-like create flow used inline on the Releases list view.
+
  // Lifecycle:
+
  //   1. user enters a commit/tag OID
+
  //   2. user picks files (or drops them onto the window)
+
  //   3. for each file, compute CID locally so the user sees what
+
  //      they're about to publish
+
  //   4. submit: create_or_open_release, then add_artifact per file,
+
  //      and seed_artifact if the auto-seed setting is on
+

+
  import type { RepoInfo } from "@bindings/repo/RepoInfo";
+

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

+
  interface Props {
+
    repo: RepoInfo;
+
    onCancel: () => void;
+
  }
+

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

+
  type StagedFile = {
+
    path: string;
+
    name: string;
+
    cid?: string;
+
    error?: string;
+
    computing: boolean;
+
  };
+

+
  let oid = $state("");
+
  let files = $state<StagedFile[]>([]);
+
  let submitting = $state(false);
+
  let autoSeed = $state(true);
+
  let submitError: string | undefined = $state();
+

+
  void invoke<boolean>("get_auto_seed_artifacts").then(v => {
+
    autoSeed = v;
+
  });
+

+
  async function pickFiles() {
+
    const picked = await invoke<string[]>("pick_artifact_files");
+
    for (const p of picked) {
+
      await stageFile(p);
+
    }
+
  }
+

+
  async function pickDirectory() {
+
    const dir = await invoke<string | null>("pick_artifact_directory");
+
    if (dir) await stageFile(dir);
+
  }
+

+
  async function stageFile(path: string) {
+
    const baseName = path.split(/[\\/]/).filter(Boolean).pop() ?? path;
+
    const entry: StagedFile = {
+
      path,
+
      name: baseName,
+
      computing: true,
+
    };
+
    files.push(entry);
+
    try {
+
      entry.cid = await invoke<string>("compute_artifact_cid", { path });
+
    } catch (err) {
+
      entry.error = String(err);
+
    } finally {
+
      entry.computing = false;
+
    }
+
  }
+

+
  function remove(index: number) {
+
    files.splice(index, 1);
+
  }
+

+
  async function submit() {
+
    if (!oid.trim()) {
+
      submitError = "Commit OID is required.";
+
      return;
+
    }
+
    if (files.length === 0) {
+
      submitError = "Add at least one artifact.";
+
      return;
+
    }
+
    submitError = undefined;
+
    submitting = true;
+
    try {
+
      const releaseId = await invoke<string>("create_or_open_release", {
+
        rid: repo.rid,
+
        oid: oid.trim(),
+
      });
+
      for (const f of files) {
+
        if (!f.cid) continue;
+
        await invoke("add_artifact", {
+
          rid: repo.rid,
+
          releaseId,
+
          cid: f.cid,
+
          name: f.name,
+
        });
+
        if (autoSeed) {
+
          try {
+
            await invoke("seed_artifact", {
+
              rid: repo.rid,
+
              releaseId,
+
              cid: f.cid,
+
              sourcePath: f.path,
+
            });
+
          } catch (err) {
+
            // Don't block release creation if a single seed fails — the
+
            // artifact is still recorded on the COB and the user can
+
            // retry seeding from the detail view.
+
            console.error("seed failed for", f.cid, err);
+
          }
+
        }
+
      }
+
      await router.push({
+
        resource: "repo.release",
+
        rid: repo.rid,
+
        release: releaseId,
+
      });
+
    } catch (err) {
+
      submitError = String(err);
+
    } finally {
+
      submitting = false;
+
    }
+
  }
+
</script>
+

+
<style>
+
  .form {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 1rem;
+
    padding: 1rem;
+
    background-color: var(--color-surface-1);
+
    border: 1px solid var(--color-border-subtle);
+
    border-radius: var(--border-radius-sm);
+
  }
+
  .field {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 0.25rem;
+
  }
+
  label,
+
  .field-label {
+
    font: var(--txt-body-s-semibold);
+
    color: var(--color-text-secondary);
+
  }
+
  input[type="text"] {
+
    padding: 0.4rem 0.5rem;
+
    border: 1px solid var(--color-border-subtle);
+
    border-radius: var(--border-radius-sm);
+
    background-color: var(--color-surface-canvas);
+
    font: var(--txt-body-m-regular);
+
  }
+
  .files {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 0.25rem;
+
  }
+
  .file-row {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.5rem;
+
    padding: 0.4rem 0.5rem;
+
    background-color: var(--color-surface-canvas);
+
    border: 1px solid var(--color-border-subtle);
+
    border-radius: var(--border-radius-sm);
+
  }
+
  .file-row .path {
+
    font: var(--txt-body-s-mono);
+
    color: var(--color-text-secondary);
+
    flex: 1;
+
    white-space: nowrap;
+
    overflow: hidden;
+
    text-overflow: ellipsis;
+
  }
+
  .file-row .cid {
+
    font: var(--txt-body-s-mono);
+
    color: var(--color-text-secondary);
+
  }
+
  .file-row input.name {
+
    flex: 0 0 12rem;
+
  }
+
  .actions {
+
    display: flex;
+
    gap: 0.5rem;
+
  }
+
  button {
+
    padding: 0.4rem 0.75rem;
+
    border: 1px solid var(--color-border-subtle);
+
    border-radius: var(--border-radius-sm);
+
    background-color: var(--color-surface-subtle);
+
    cursor: pointer;
+
  }
+
  button.primary {
+
    background-color: var(--color-fill-accent);
+
    color: var(--color-text-inverse);
+
  }
+
  .error {
+
    color: var(--color-fill-error);
+
    font: var(--txt-body-s-regular);
+
  }
+
  .toggle {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.5rem;
+
  }
+
</style>
+

+
<div class="form">
+
  <div class="field">
+
    <label for="release-oid">Commit OID</label>
+
    <input
+
      id="release-oid"
+
      type="text"
+
      placeholder="e.g. ec49ecb..."
+
      bind:value={oid}
+
      disabled={submitting} />
+
  </div>
+

+
  <div class="field">
+
    <span class="field-label">Artifacts</span>
+
    <div class="files">
+
      {#each files as file, i (file.path)}
+
        <div class="file-row">
+
          <input
+
            class="name"
+
            type="text"
+
            bind:value={file.name}
+
            disabled={submitting} />
+
          <span class="path" title={file.path}>{file.path}</span>
+
          {#if file.computing}
+
            <span class="cid">computing…</span>
+
          {:else if file.error}
+
            <span class="error">{file.error}</span>
+
          {:else if file.cid}
+
            <span class="cid">{file.cid.slice(0, 12)}…</span>
+
          {/if}
+
          <button onclick={() => remove(i)} disabled={submitting}>×</button>
+
        </div>
+
      {/each}
+
    </div>
+
    <div class="actions" style:margin-top="0.5rem">
+
      <button onclick={pickFiles} disabled={submitting}>Add files…</button>
+
      <button onclick={pickDirectory} disabled={submitting}>
+
        Add directory…
+
      </button>
+
    </div>
+
  </div>
+

+
  <div class="toggle">
+
    <input
+
      id="auto-seed"
+
      type="checkbox"
+
      bind:checked={autoSeed}
+
      disabled={submitting} />
+
    <label for="auto-seed">Seed over iroh after publishing</label>
+
  </div>
+

+
  {#if submitError}
+
    <div class="error">{submitError}</div>
+
  {/if}
+

+
  <div class="actions" style:justify-content="flex-end">
+
    <button onclick={onCancel} disabled={submitting}>Cancel</button>
+
    <button class="primary" onclick={submit} disabled={submitting}>
+
      {submitting ? "Publishing…" : "Publish release"}
+
    </button>
+
  </div>
+
</div>
modified src/views/repo/Releases.svelte
@@ -5,6 +5,7 @@
  import * as router from "@app/lib/router";

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

@@ -15,6 +16,8 @@

  const { repo, releases }: Props = $props();

+
  let showNew = $state(false);
+

  function shortOid(oid: string): string {
    return oid.slice(0, 7);
  }
@@ -80,8 +83,21 @@
  <div class="page">
    <Topbar>
      <span class="topbar-title">Releases</span>
+
      <div style:margin-left="auto">
+
        <button
+
          onclick={() => (showNew = !showNew)}
+
          style="padding: 0.25rem 0.5rem; border-radius: var(--border-radius-sm); border: 1px solid var(--color-border-subtle); background-color: var(--color-surface-subtle); cursor: pointer;">
+
          {showNew ? "Cancel" : "New release"}
+
        </button>
+
      </div>
    </Topbar>

+
    {#if showNew}
+
      <div style:padding="1rem">
+
        <NewRelease {repo} onCancel={() => (showNew = false)} />
+
      </div>
+
    {/if}
+

    <ScrollArea style="height: 100%; min-width: 0;">
      <div class="list">
        {#each releases as release (release.id)}