| |
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>
|