mirror of
https://seed.radicle.dev/rad:z4D5UCArafTzTQpDZNQRuqswh3ury.git
synced 2026-10-18 19:10:45 +08:00
Add Releases and Release views
- Add the Releases list and Release page - Add modals to create, delete and redact releases - Register artifacts and add or remove their locations - Download, seed and unseed artifacts from the release page - Share one artifact node state and show its status - Pick the release commit from a list - Rename Stop seeding to Unseed Co-authored-by: Daniel Norman <daniel@norman.life> Co-authored-by: Rūdolfs Ošiņš <rudolfs@osins.org>
This commit is contained in:
committed by
Rūdolfs Ošiņš
co-authored by
Daniel Norman
Rūdolfs Ošiņš
parent
837766212e
commit
595a81150b
+10
-18
@@ -3,7 +3,6 @@
|
||||
import type { ErrorWrapper } from "@bindings/error/ErrorWrapper";
|
||||
import type { RepoInfo } from "@bindings/repo/RepoInfo";
|
||||
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
@@ -15,7 +14,6 @@
|
||||
} from "@app/lib/appearance.svelte";
|
||||
import { checkAuth, startup } from "@app/lib/auth.svelte";
|
||||
import { deepLinksReady, listenForDeepLinks } from "@app/lib/deepLink";
|
||||
import { nodeRunning } from "@app/lib/events";
|
||||
import { dynamicInterval } from "@app/lib/interval";
|
||||
import { invoke } from "@app/lib/invoke";
|
||||
import { hide, modalStore, show, toggle } from "@app/lib/modal";
|
||||
@@ -23,6 +21,7 @@
|
||||
import { isLoadedRepoRoute } from "@app/lib/router/definitions";
|
||||
import { listenForShortcuts, useShortcuts } from "@app/lib/shortcuts.svelte";
|
||||
import {
|
||||
createEventEmittersOnce,
|
||||
setUnlistenNodeEvents,
|
||||
unlistenNodeEvents,
|
||||
} from "@app/lib/startup.svelte";
|
||||
@@ -48,6 +47,8 @@
|
||||
import Issues from "@app/views/repo/Issues.svelte";
|
||||
import Patch from "@app/views/repo/Patch.svelte";
|
||||
import Patches from "@app/views/repo/Patches.svelte";
|
||||
import Release from "@app/views/repo/Release.svelte";
|
||||
import Releases from "@app/views/repo/Releases.svelte";
|
||||
import RepoCommit from "@app/views/repo/RepoCommit.svelte";
|
||||
import RepoCommits from "@app/views/repo/RepoCommits.svelte";
|
||||
import RepoHome from "@app/views/repo/RepoHome.svelte";
|
||||
@@ -181,22 +182,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.__TAURI_INTERNALS__) {
|
||||
setUnlistenNodeEvents(
|
||||
await listen<boolean>("node_running", event => {
|
||||
nodeRunning.set(event.payload);
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// The test backend can't emit events.
|
||||
const pollNodeRunning = () =>
|
||||
invoke<boolean>("node_running")
|
||||
.then(running => nodeRunning.set(running))
|
||||
.catch(console.error);
|
||||
void pollNodeRunning();
|
||||
const interval = setInterval(pollNodeRunning, 2_000);
|
||||
setUnlistenNodeEvents(() => clearInterval(interval));
|
||||
}
|
||||
setUnlistenNodeEvents(await createEventEmittersOnce());
|
||||
|
||||
void listenForDeepLinks();
|
||||
try {
|
||||
@@ -299,6 +285,12 @@
|
||||
<Patch {...$activeRouteStore.params} />
|
||||
{:else if $activeRouteStore.resource === "repo.patches"}
|
||||
<Patches {...$activeRouteStore.params} />
|
||||
{:else if $activeRouteStore.resource === "repo.release"}
|
||||
{#key $activeRouteStore.params.release.id}
|
||||
<Release {...$activeRouteStore.params} />
|
||||
{/key}
|
||||
{:else if $activeRouteStore.resource === "repo.releases"}
|
||||
<Releases {...$activeRouteStore.params} />
|
||||
{:else}
|
||||
{unreachable($activeRouteStore)}
|
||||
{/if}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
import useLocalStorage from "@app/lib/useLocalStorage.svelte";
|
||||
import { isMac } from "@app/lib/utils";
|
||||
|
||||
import ArtifactNodeStatusButton from "@app/components/ArtifactNodeStatusButton.svelte";
|
||||
import { badgeCounter } from "@app/components/BadgeCounterSwitch.svelte";
|
||||
import Button from "@app/components/Button.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
@@ -718,6 +719,7 @@
|
||||
<span class="update-badge">New Update</span>
|
||||
{/if}
|
||||
</Button>
|
||||
<ArtifactNodeStatusButton />
|
||||
<NodeStatusButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
<script lang="ts">
|
||||
import type { ArtifactProgress } from "@bindings/artifact/ArtifactProgress";
|
||||
import type { Artifact } from "@bindings/cob/release/Artifact";
|
||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
import { artifactNodeRunning } from "@app/lib/events";
|
||||
import { invoke } from "@app/lib/invoke";
|
||||
import * as releases from "@app/lib/releases";
|
||||
import { formatBytes } from "@app/lib/utils";
|
||||
|
||||
import Button from "@app/components/Button.svelte";
|
||||
import Checkbox from "@app/components/Checkbox.svelte";
|
||||
import Command from "@app/components/Command.svelte";
|
||||
import DelegateBadge from "@app/components/DelegateBadge.svelte";
|
||||
import ExternalLink from "@app/components/ExternalLink.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import Popover from "@app/components/Popover.svelte";
|
||||
|
||||
interface Props {
|
||||
artifact: Artifact;
|
||||
delegateIds: Set<string>;
|
||||
releaseId: string;
|
||||
rid: string;
|
||||
/// Whether the node already seeds this artifact, asked of the node rather
|
||||
/// than assumed from what this component did.
|
||||
seeding: boolean;
|
||||
onDownloaded: () => void;
|
||||
}
|
||||
|
||||
const {
|
||||
artifact,
|
||||
delegateIds,
|
||||
releaseId,
|
||||
rid,
|
||||
seeding,
|
||||
onDownloaded,
|
||||
}: Props = $props();
|
||||
|
||||
let activeTab: "app" | "cli" | "browser" = $state("app");
|
||||
let expanded = $state(false);
|
||||
// Which transfer is running: a download writes to disk, a fetch only fills
|
||||
// the node's store.
|
||||
let running: "download" | "fetch" | undefined = $state();
|
||||
let progress: ArtifactProgress | undefined = $state();
|
||||
let downloadError: string | undefined = $state();
|
||||
let finished: "download" | "fetch" | undefined = $state();
|
||||
let seed = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
if (seeding) {
|
||||
seed = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Clear the outcome of a finished download once the popover closes, so a
|
||||
// later visit starts fresh.
|
||||
$effect(() => {
|
||||
if (!expanded && !running) {
|
||||
finished = undefined;
|
||||
downloadError = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
// The node reports byte movement per CID, so a shared event channel
|
||||
// is filtered down to this artifact.
|
||||
$effect(() => {
|
||||
// Events only exist under Tauri, not against the test HTTP API.
|
||||
if (!window.__TAURI_INTERNALS__) {
|
||||
return;
|
||||
}
|
||||
let unlisten: UnlistenFn | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
void listen<ArtifactProgress>("artifact_progress", event => {
|
||||
if (event.payload.cid === artifact.cid) {
|
||||
progress = event.payload;
|
||||
}
|
||||
}).then(fn => {
|
||||
if (cancelled) {
|
||||
fn();
|
||||
} else {
|
||||
unlisten = fn;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unlisten?.();
|
||||
};
|
||||
});
|
||||
|
||||
const percent = $derived.by(() => {
|
||||
if (!progress?.total || progress.offset === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.min(100, Math.round((progress.offset / progress.total) * 100));
|
||||
});
|
||||
|
||||
const phaseLabel = $derived.by(() => {
|
||||
switch (progress?.phase) {
|
||||
case "connecting":
|
||||
return "Connecting…";
|
||||
case "tryingLocation":
|
||||
return "Trying a location…";
|
||||
case "locationFailed":
|
||||
return "Location failed, trying another…";
|
||||
case "downloading":
|
||||
return "Downloading…";
|
||||
case "exporting":
|
||||
return "Writing to disk…";
|
||||
default:
|
||||
return "Starting…";
|
||||
}
|
||||
});
|
||||
|
||||
async function transfer(kind: "download" | "fetch") {
|
||||
downloadError = undefined;
|
||||
finished = undefined;
|
||||
|
||||
running = kind;
|
||||
progress = undefined;
|
||||
try {
|
||||
const done = await invoke<boolean>("download_artifact", {
|
||||
rid,
|
||||
releaseId,
|
||||
cid: artifact.cid,
|
||||
saveAs: kind === "download" ? artifact.name : undefined,
|
||||
seed,
|
||||
});
|
||||
if (!done) {
|
||||
return;
|
||||
}
|
||||
finished = kind;
|
||||
// Seeding is the node's state, not ours; ask what actually happened.
|
||||
onDownloaded();
|
||||
} catch {
|
||||
downloadError = `${kind === "download" ? "Download" : "Fetch"} failed. The artifact node may be offline, or no location is reachable.`;
|
||||
} finally {
|
||||
running = undefined;
|
||||
progress = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const webLocations = $derived(
|
||||
releases.webLocations(artifact.locations, delegateIds),
|
||||
);
|
||||
// Any location at all can be fetched with the CLI, web locations included.
|
||||
const downloadable = $derived(seeding || artifact.locations.length > 0);
|
||||
const downloadLabel = $derived(seeding ? "Save" : "Download");
|
||||
|
||||
// The app and CLI tabs fetch through the artifact node, so they are only
|
||||
// usable while it answers. The browser tab does not need it.
|
||||
const nodeRunning = $derived($artifactNodeRunning);
|
||||
const START_COMMAND = "rad-artifact node start";
|
||||
const command = $derived(
|
||||
`rad-artifact download --cid ${artifact.cid} --repo ${rid} --seed`,
|
||||
);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.popover {
|
||||
width: 24rem;
|
||||
padding: 1rem;
|
||||
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);
|
||||
}
|
||||
.start-command {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--color-feedback-warning-text);
|
||||
}
|
||||
.locations {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.location {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.url {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.progress-track {
|
||||
height: 0.25rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-surface-subtle);
|
||||
overflow: hidden;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background-color: var(--color-fill-secondary);
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
color: var(--color-text-tertiary);
|
||||
font: var(--txt-body-s-regular);
|
||||
}
|
||||
.error {
|
||||
margin-top: 0.75rem;
|
||||
color: var(--color-foreground-red);
|
||||
font: var(--txt-body-s-regular);
|
||||
}
|
||||
.success {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
color: var(--color-foreground-success);
|
||||
font: var(--txt-body-s-regular);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.seed-option {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<Popover placement="bottom-end" popoverPadding="0" bind:expanded>
|
||||
{#snippet toggle(onclick)}
|
||||
<Button
|
||||
{onclick}
|
||||
styleHeight="1.75rem"
|
||||
disabled={!downloadable}
|
||||
title={downloadable
|
||||
? undefined
|
||||
: "This artifact has no location to download from"}>
|
||||
<Icon name="download" />{downloadLabel}
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet popover()}
|
||||
<div class="popover">
|
||||
<div class="tabs">
|
||||
<Button
|
||||
styleWidth="100%"
|
||||
flatRight
|
||||
bordered
|
||||
active={activeTab === "app"}
|
||||
onclick={() => (activeTab = "app")}>
|
||||
<Icon name="download" />App
|
||||
</Button>
|
||||
<Button
|
||||
styleWidth="100%"
|
||||
bordered
|
||||
flatLeft
|
||||
flatRight
|
||||
active={activeTab === "cli"}
|
||||
onclick={() => (activeTab = "cli")}>
|
||||
<Icon name="logo" />CLI
|
||||
</Button>
|
||||
<Button
|
||||
styleWidth="100%"
|
||||
flatLeft
|
||||
bordered
|
||||
disabled={webLocations.length === 0}
|
||||
title={webLocations.length === 0
|
||||
? "This artifact has no web locations"
|
||||
: undefined}
|
||||
active={activeTab === "browser"}
|
||||
onclick={() => (activeTab = "browser")}>
|
||||
<Icon name="open-external" />Browser
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if activeTab === "app"}
|
||||
<label for="download-artifact">
|
||||
{#if nodeRunning === false}
|
||||
Your artifact node is not running, so nothing can be downloaded
|
||||
here. Start it with:
|
||||
{:else if seeding}
|
||||
Save a copy of the file from your artifact node's store.
|
||||
{:else}
|
||||
Download through your artifact node, which checks what arrives
|
||||
against the CID. Fetch only adds it to the node's store.
|
||||
{/if}
|
||||
</label>
|
||||
{#if nodeRunning === false}
|
||||
<div class="start-command">
|
||||
<Command command={START_COMMAND} styleWidth="100%" />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<Button
|
||||
styleWidth="100%"
|
||||
disabled={running !== undefined || nodeRunning === false}
|
||||
title={nodeRunning === false
|
||||
? "Your artifact node is not running"
|
||||
: undefined}
|
||||
onclick={() => transfer("download")}>
|
||||
<Icon name="download" />
|
||||
{running === "download"
|
||||
? seeding
|
||||
? "Saving…"
|
||||
: "Downloading…"
|
||||
: downloadLabel}
|
||||
</Button>
|
||||
{#if !seeding}
|
||||
<Button
|
||||
styleWidth="100%"
|
||||
disabled={running !== undefined || nodeRunning === false}
|
||||
title={nodeRunning === false
|
||||
? "Your artifact node is not running"
|
||||
: "Fetch into your artifact node's store without saving a file"}
|
||||
onclick={() => transfer("fetch")}>
|
||||
<Icon name="arrow-down" />
|
||||
{running === "fetch" ? "Fetching…" : "Fetch"}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if running}
|
||||
<div class="progress-track">
|
||||
<div class="progress-bar" style:width="{percent ?? 0}%"></div>
|
||||
</div>
|
||||
<div class="status">
|
||||
<span>{phaseLabel}</span>
|
||||
{#if progress?.offset !== undefined}
|
||||
<span>
|
||||
{formatBytes(progress.offset)}{progress.total
|
||||
? ` / ${formatBytes(progress.total)}`
|
||||
: ""}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if finished}
|
||||
<div class="success">
|
||||
<Icon name="checkmark" />
|
||||
{finished === "download" ? "Saved" : "Fetched"}{seeding
|
||||
? " and seeding"
|
||||
: ""}.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if downloadError}
|
||||
<div class="error">{downloadError}</div>
|
||||
{/if}
|
||||
|
||||
{#if !seeding}
|
||||
<div class="seed-option">
|
||||
<Checkbox bind:checked={seed}>Seed after fetching</Checkbox>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if activeTab === "cli"}
|
||||
{#if nodeRunning === false}
|
||||
<label for="start-command">
|
||||
Your artifact node is not running. Start it with:
|
||||
</label>
|
||||
<div class="start-command">
|
||||
<Command command={START_COMMAND} styleWidth="100%" />
|
||||
</div>
|
||||
{/if}
|
||||
<label for="download-command">
|
||||
Use the Radicle Artifact CLI to download this artifact.
|
||||
</label>
|
||||
<Command {command} styleWidth="100%" />
|
||||
{:else}
|
||||
<div class="warning">
|
||||
<Icon name="warning" />
|
||||
These downloads are not checked against the CID.
|
||||
</div>
|
||||
<div class="locations">
|
||||
{#each webLocations as location (`${location.user.did}:${location.url}`)}
|
||||
<div class="location">
|
||||
{#if delegateIds.has(location.user.did)}
|
||||
<DelegateBadge tooltip="Location added by a delegate" />
|
||||
{/if}
|
||||
<span class="url" title={location.url}>{location.url}</span>
|
||||
<ExternalLink href={location.url} title="Download" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
@@ -0,0 +1,267 @@
|
||||
<script lang="ts">
|
||||
import type { ArtifactBinaries } from "@bindings/artifact/ArtifactBinaries";
|
||||
import type { ArtifactNodeStatus } from "@bindings/artifact/ArtifactNodeStatus";
|
||||
|
||||
import { artifactNodeRunning } from "@app/lib/events";
|
||||
import { poll } from "@app/lib/interval";
|
||||
import { invoke } from "@app/lib/invoke";
|
||||
import { formatBytes, formatUptime } from "@app/lib/utils";
|
||||
|
||||
import Button from "@app/components/Button.svelte";
|
||||
import Command from "@app/components/Command.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import Id from "@app/components/Id.svelte";
|
||||
import Popover from "@app/components/Popover.svelte";
|
||||
|
||||
// Installs both `rad-artifact` and `rad-artifact-node`.
|
||||
const INSTALL_COMMAND =
|
||||
"curl -sSf https://files.radicle.dev/releases/radicle-artifact/install | sh";
|
||||
|
||||
let popoverExpanded: boolean = $state(false);
|
||||
const running = $derived($artifactNodeRunning);
|
||||
let status: ArtifactNodeStatus | undefined = $state();
|
||||
let binaries: ArtifactBinaries | undefined = $state();
|
||||
|
||||
// Undefined while the check is outstanding, so the guidance does not flash
|
||||
// "install" at someone who has it.
|
||||
const installed = $derived(
|
||||
binaries === undefined ? undefined : binaries.cli && binaries.node,
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (!popoverExpanded) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
invoke<ArtifactBinaries>("artifact_binaries")
|
||||
.then(binaries_ => {
|
||||
if (!cancelled) {
|
||||
binaries = binaries_;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
binaries = undefined;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
// Cleared on close, so a reopened popover never shows an old snapshot.
|
||||
$effect(() => {
|
||||
if (!popoverExpanded) {
|
||||
status = undefined;
|
||||
return;
|
||||
}
|
||||
return poll(async active => {
|
||||
try {
|
||||
const status_ = await invoke<ArtifactNodeStatus>(
|
||||
"artifact_node_status",
|
||||
);
|
||||
if (active()) {
|
||||
status = status_;
|
||||
}
|
||||
} catch {
|
||||
if (active()) {
|
||||
status = undefined;
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
const uptime = $derived(
|
||||
status
|
||||
? formatUptime(Math.max(0, Date.now() / 1000 - status.startedAtUnix))
|
||||
: undefined,
|
||||
);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.state {
|
||||
margin-left: auto;
|
||||
color: var(--color-text-tertiary);
|
||||
font: var(--txt-body-s-regular);
|
||||
}
|
||||
.popover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
width: 32rem;
|
||||
padding: 1rem;
|
||||
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);
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.375rem 1rem;
|
||||
}
|
||||
.key {
|
||||
color: var(--color-text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.value {
|
||||
color: var(--color-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.endpoint {
|
||||
color: var(--color-text-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.detail {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--color-feedback-warning-text);
|
||||
}
|
||||
</style>
|
||||
|
||||
<Popover
|
||||
popoverPadding="0"
|
||||
placement="top-start"
|
||||
bind:expanded={popoverExpanded}>
|
||||
{#snippet toggle(onclick)}
|
||||
<Button
|
||||
variant="naked"
|
||||
{onclick}
|
||||
active={popoverExpanded}
|
||||
styleWidth="100%"
|
||||
styleJustifyContent="flex-start">
|
||||
<span style:color="var(--color-text-tertiary)">
|
||||
<Icon name="parcel" />
|
||||
</span>
|
||||
Artifacts
|
||||
{#if running !== undefined}
|
||||
<span class="state">{running ? "Seeding" : "Offline"}</span>
|
||||
{/if}
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#snippet popover()}
|
||||
<div class="popover">
|
||||
{#if status}
|
||||
{#if status.relayUnreachable}
|
||||
<div class="warning">
|
||||
<Icon name="warning" />
|
||||
No relay connected: peers may not reach this node.
|
||||
</div>
|
||||
{/if}
|
||||
<div class="stats">
|
||||
<span class="key">Endpoint</span>
|
||||
<span class="endpoint">
|
||||
<Id
|
||||
id={status.endpointId}
|
||||
clipboard={status.endpointId}
|
||||
label="endpoint"
|
||||
shorten={false} />
|
||||
</span>
|
||||
<span class="key">Uptime</span>
|
||||
<span class="value">{uptime}</span>
|
||||
<span class="key">Seeding</span>
|
||||
<span class="value">
|
||||
{status.seededCount}
|
||||
{status.seededCount === 1 ? "artifact" : "artifacts"}
|
||||
({formatBytes(status.seededBytesLogical)})
|
||||
</span>
|
||||
<span class="key">Store</span>
|
||||
<span class="endpoint">
|
||||
<Id
|
||||
id={status.storePath}
|
||||
clipboard={status.storePath}
|
||||
label="store path"
|
||||
shorten={false} />
|
||||
</span>
|
||||
<span class="key">Connections</span>
|
||||
<span class="value">{status.connectionsActive} active</span>
|
||||
<span class="key">Traffic</span>
|
||||
<span class="value">
|
||||
{formatBytes(status.inBytes)} in, {formatBytes(status.outBytes)} out
|
||||
</span>
|
||||
<span class="key">Relay</span>
|
||||
{#if status.relays[0]}
|
||||
{@const relay = status.relays[0]}
|
||||
<span class="list" title={relay.lastError}>
|
||||
<span class="value">{relay.url}</span>
|
||||
<span class="detail">
|
||||
{#if relay.connected}
|
||||
Connected{relay.latencyMs !== undefined
|
||||
? `, ${relay.latencyMs}ms`
|
||||
: ""}
|
||||
{:else}
|
||||
Disconnected
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="value">None</span>
|
||||
{/if}
|
||||
{#if status.pkarrUri}
|
||||
<span class="key">Pkarr key</span>
|
||||
<span class="endpoint">
|
||||
<Id
|
||||
id={status.pkarrUri}
|
||||
clipboard={status.pkarrUri}
|
||||
label="pkarr key"
|
||||
shorten={false} />
|
||||
</span>
|
||||
{/if}
|
||||
{#if status.pkarrRelays.length > 0}
|
||||
<span class="key">Pkarr relays</span>
|
||||
<span class="list">
|
||||
{#each status.pkarrRelays as relay (relay)}
|
||||
<span class="endpoint">
|
||||
<Id
|
||||
id={relay}
|
||||
clipboard={relay}
|
||||
label="pkarr relay"
|
||||
shorten={false} />
|
||||
</span>
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if running === false && installed !== undefined}
|
||||
<div style:line-height="1.625rem">
|
||||
{#if installed}
|
||||
The artifact node is not running, so artifacts cannot be downloaded
|
||||
or seeded from this app.
|
||||
{:else if binaries?.cli}
|
||||
The artifact seeding daemon is not installed, so artifacts cannot be
|
||||
downloaded or seeded from this app.
|
||||
{:else}
|
||||
The artifact tools are not installed, so artifacts cannot be
|
||||
downloaded or seeded from this app.
|
||||
{/if}
|
||||
<div style:margin-top="1rem">
|
||||
{installed ? "Start it with:" : "Install them with:"}
|
||||
<div style:margin-top="0.5rem">
|
||||
{#if installed}
|
||||
<Command
|
||||
styleWidth="fit-content"
|
||||
command="rad-artifact node start" />
|
||||
{:else}
|
||||
<Command styleWidth="100%" command={INSTALL_COMMAND} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="value">Checking the artifact node…</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import TooltipBadge from "@app/components/TooltipBadge.svelte";
|
||||
|
||||
interface Props {
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
const { tooltip = "Delegate" }: Props = $props();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.delegate {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
|
||||
<TooltipBadge {tooltip} tight>
|
||||
{#snippet badge()}
|
||||
<span class="delegate" aria-label={tooltip}>
|
||||
<Icon name="badge" />
|
||||
</span>
|
||||
{/snippet}
|
||||
</TooltipBadge>
|
||||
@@ -8,9 +8,16 @@
|
||||
empty?: Snippet;
|
||||
items: T[];
|
||||
styleDropdownMinWidth?: string;
|
||||
styleDropdownMaxHeight?: string;
|
||||
}
|
||||
|
||||
const { item, empty, items, styleDropdownMinWidth }: Props = $props();
|
||||
const {
|
||||
item,
|
||||
empty,
|
||||
items,
|
||||
styleDropdownMinWidth,
|
||||
styleDropdownMaxHeight = "60vh",
|
||||
}: Props = $props();
|
||||
|
||||
let dropdownElement: HTMLDivElement | undefined = undefined;
|
||||
|
||||
@@ -34,7 +41,6 @@
|
||||
<style>
|
||||
.dropdown {
|
||||
align-items: center;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.dropdown-item {
|
||||
@@ -46,7 +52,8 @@
|
||||
<div
|
||||
class="dropdown"
|
||||
bind:this={dropdownElement}
|
||||
style:min-width={styleDropdownMinWidth}>
|
||||
style:min-width={styleDropdownMinWidth}
|
||||
style:max-height={styleDropdownMaxHeight}>
|
||||
{#each items as i}
|
||||
<div class="dropdown-item">
|
||||
{@render item(i)}
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
| "offline"
|
||||
| "online"
|
||||
| "open-external"
|
||||
| "parcel"
|
||||
| "patch"
|
||||
| "patch-archived"
|
||||
| "patch-draft"
|
||||
@@ -580,6 +581,9 @@
|
||||
<path d="M6 2.5V3.5H3.5V12.5H12.5V10H13.5V13.5H2.5V2.5H6Z" />
|
||||
<path
|
||||
d="M6.29297 9L11.793 3.5L8.5 3.5L8.5 2.5L13.5 2.5L13.5 7.5L12.5 7.5L12.5 4.20703L7 9.70703L6.29297 9Z" />
|
||||
{:else if name === "parcel"}
|
||||
<path
|
||||
d="M2.5 10.7129L7.5 13.6289V8.78613L2.5 5.87011V10.7129ZM8.5 8.78613V13.6289L13.5 10.7129V5.87011L8.5 8.78613ZM5.99121 6.75L8 7.92089L13.0078 5L11 3.82812L5.99121 6.75ZM2.99121 5L5 6.17089L10.0078 3.25L8 2.07812L2.99121 5ZM14.5 11C14.5 11.1778 14.4055 11.342 14.252 11.4316L8.25195 14.9316C8.09627 15.0224 7.90373 15.0224 7.74805 14.9316L1.74805 11.4316C1.59451 11.342 1.5 11.1778 1.5 11V5C1.5 4.82221 1.59451 4.65798 1.74805 4.56835L7.74805 1.06835C7.90373 0.977541 8.09627 0.977541 8.25195 1.06835L14.252 4.56835C14.4055 4.65798 14.5 4.82221 14.5 5V11Z" />
|
||||
{:else if name === "patch"}
|
||||
<path
|
||||
d="M5.5 12C5.5 11.1716 4.82843 10.5 4 10.5C3.17157 10.5 2.5 11.1716 2.5 12C2.5 12.8284 3.17157 13.5 4 13.5C4.82843 13.5 5.5 12.8284 5.5 12ZM13.5 12C13.5 11.1716 12.8284 10.5 12 10.5C11.1716 10.5 10.5 11.1716 10.5 12C10.5 12.8284 11.1716 13.5 12 13.5C12.8284 13.5 13.5 12.8284 13.5 12ZM7.5 2.5H11.5V3.5H9.20703L12.5 6.79297V9.5498C13.6411 9.78142 14.5 10.7905 14.5 12C14.5 13.3807 13.3807 14.5 12 14.5C10.6193 14.5 9.5 13.3807 9.5 12C9.5 10.7905 10.3589 9.78142 11.5 9.5498V7.20703L8.5 4.20703V6.5H7.5V2.5ZM5.5 4C5.5 3.17157 4.82843 2.5 4 2.5C3.17157 2.5 2.5 3.17157 2.5 4C2.5 4.82843 3.17157 5.5 4 5.5C4.82843 5.5 5.5 4.82843 5.5 4ZM6.5 4C6.5 5.20943 5.64105 6.21753 4.5 6.44922V9.5498C5.64114 9.78142 6.5 10.7905 6.5 12C6.5 13.3807 5.38071 14.5 4 14.5C2.61929 14.5 1.5 13.3807 1.5 12C1.5 10.7905 2.35886 9.78142 3.5 9.5498V6.44922C2.35895 6.21753 1.5 5.20943 1.5 4C1.5 2.61929 2.61929 1.5 4 1.5C5.38071 1.5 6.5 2.61929 6.5 4Z" />
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
popoverPadding?: string;
|
||||
expanded?: boolean;
|
||||
styleDisplay?: string;
|
||||
/* Popovers portal to the body at a z-index that sits below the modal
|
||||
layer, so one opened from inside a modal needs to be raised above it. */
|
||||
styleZIndex?: string;
|
||||
}
|
||||
|
||||
/* eslint-disable prefer-const */
|
||||
@@ -41,6 +44,7 @@
|
||||
popoverPadding,
|
||||
expanded = $bindable(false),
|
||||
styleDisplay = undefined,
|
||||
styleZIndex = undefined,
|
||||
}: Props = $props();
|
||||
/* eslint-enable prefer-const */
|
||||
|
||||
@@ -120,7 +124,8 @@
|
||||
use:portal
|
||||
bind:this={floatingEl}
|
||||
class="popover"
|
||||
style:padding={popoverPadding}>
|
||||
style:padding={popoverPadding}
|
||||
style:z-index={styleZIndex}>
|
||||
{@render popover()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import type { Release } from "@bindings/cob/release/Release";
|
||||
import type { RepoInfo } from "@bindings/repo/RepoInfo";
|
||||
|
||||
import debounce from "lodash/debounce";
|
||||
|
||||
import { writeToClipboard } from "@app/lib/invoke";
|
||||
import { routeToPath } from "@app/lib/router";
|
||||
import {
|
||||
absoluteTimestamp,
|
||||
authorForNodeId,
|
||||
formatOid,
|
||||
formatTimestamp,
|
||||
} from "@app/lib/utils";
|
||||
|
||||
import DelegateBadge from "@app/components/DelegateBadge.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import NodeId from "@app/components/NodeId.svelte";
|
||||
|
||||
interface Props {
|
||||
release: Release;
|
||||
repo: RepoInfo;
|
||||
delegateIds: Set<string>;
|
||||
}
|
||||
|
||||
const { release, repo, delegateIds }: Props = $props();
|
||||
|
||||
let releaseIdCopied = $state(false);
|
||||
const resetReleaseIdCopied = debounce(() => {
|
||||
releaseIdCopied = false;
|
||||
}, 1000);
|
||||
async function copyReleaseId() {
|
||||
await writeToClipboard(release.id);
|
||||
releaseIdCopied = true;
|
||||
resetReleaseIdCopied();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font: var(--txt-body-m-regular);
|
||||
}
|
||||
.author-chip,
|
||||
.release-id-chip,
|
||||
.tag-chip,
|
||||
.commit-chip,
|
||||
.time-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 2rem;
|
||||
padding: 0 0.5rem;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-surface-canvas);
|
||||
color: var(--color-text-tertiary);
|
||||
font: var(--txt-body-m-regular);
|
||||
}
|
||||
.author-chip {
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.tag-chip,
|
||||
.time-chip {
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.release-id-chip,
|
||||
.commit-chip {
|
||||
gap: 0.375rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.release-id-chip:hover,
|
||||
.release-id-chip:focus-visible,
|
||||
.commit-chip:hover,
|
||||
.commit-chip:focus-visible {
|
||||
background-color: var(--color-surface-subtle);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.rid-icon-value,
|
||||
.commit-value {
|
||||
font: var(--txt-code-regular);
|
||||
}
|
||||
.rid-icon-default,
|
||||
.rid-icon-hover {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.rid-icon-hover {
|
||||
display: none;
|
||||
}
|
||||
.release-id-chip:hover .rid-icon-default,
|
||||
.release-id-chip:focus-visible .rid-icon-default {
|
||||
display: none;
|
||||
}
|
||||
.release-id-chip:hover .rid-icon-hover,
|
||||
.release-id-chip:focus-visible .rid-icon-hover {
|
||||
display: inline-flex;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="meta-row">
|
||||
<div class="author-chip" title="Release creator">
|
||||
<NodeId {...authorForNodeId(release.creator)} />
|
||||
{#if delegateIds.has(release.creator.did)}
|
||||
<DelegateBadge />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="release-id-chip"
|
||||
title={releaseIdCopied ? "Copied to clipboard" : "Copy release ID"}
|
||||
onclick={copyReleaseId}>
|
||||
{#if releaseIdCopied}
|
||||
<Icon name="checkmark" />
|
||||
{:else}
|
||||
<span class="rid-icon-default"><Icon name="hash" /></span>
|
||||
<span class="rid-icon-hover"><Icon name="copy" /></span>
|
||||
{/if}
|
||||
<span class="rid-icon-value">{formatOid(release.id)}</span>
|
||||
</button>
|
||||
|
||||
{#if release.tagName}
|
||||
<span class="tag-chip" title="Tag this release was cut from">
|
||||
<Icon name="label" />
|
||||
<span>{release.tagName}</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<a
|
||||
class="commit-chip"
|
||||
title="Browse the released commit"
|
||||
href={routeToPath({
|
||||
resource: "repo.commit",
|
||||
rid: repo.rid,
|
||||
commit: release.oid,
|
||||
})}>
|
||||
<Icon name="commit" />
|
||||
<span class="commit-value">{formatOid(release.oid)}</span>
|
||||
</a>
|
||||
|
||||
<span class="time-chip" title={absoluteTimestamp(release.createdAt)}>
|
||||
<Icon name="clock" />
|
||||
<span>Created {formatTimestamp(release.createdAt)}</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
import type { Release } from "@bindings/cob/release/Release";
|
||||
import type { ReleaseScope } from "@bindings/cob/release/ReleaseScope";
|
||||
|
||||
import { push } from "@app/lib/router";
|
||||
import {
|
||||
absoluteTimestamp,
|
||||
authorForNodeId,
|
||||
formatTimestamp,
|
||||
} from "@app/lib/utils";
|
||||
|
||||
import DelegateBadge from "@app/components/DelegateBadge.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import Id from "@app/components/Id.svelte";
|
||||
import InlineTitle from "@app/components/InlineTitle.svelte";
|
||||
import NodeId from "@app/components/NodeId.svelte";
|
||||
|
||||
interface Props {
|
||||
scope: ReleaseScope;
|
||||
delegateIds: Set<string>;
|
||||
release: Release;
|
||||
rid: string;
|
||||
}
|
||||
|
||||
const { scope, delegateIds, release, rid }: Props = $props();
|
||||
|
||||
const title = $derived(release.title || release.tagName || release.id);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.release-teaser {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
min-height: 5rem;
|
||||
background-color: var(--color-surface-canvas);
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
font: var(--txt-body-l-regular);
|
||||
word-break: break-word;
|
||||
width: 100%;
|
||||
}
|
||||
.release-teaser:hover {
|
||||
background-color: var(--color-surface-subtle);
|
||||
}
|
||||
.icon {
|
||||
padding: 0;
|
||||
margin-right: 1rem;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.tag {
|
||||
font: var(--txt-body-s-regular);
|
||||
color: var(--color-text-tertiary);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: 0 0.375rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.counter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font: var(--txt-body-m-regular);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--border-radius-sm);
|
||||
height: 1.5rem;
|
||||
padding: 0 0.5rem;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="release-teaser"
|
||||
onclick={() => {
|
||||
void push({
|
||||
resource: "repo.release",
|
||||
rid,
|
||||
release: release.id,
|
||||
scope,
|
||||
});
|
||||
}}>
|
||||
<div class="global-chip icon">
|
||||
<Icon name="parcel" />
|
||||
</div>
|
||||
<div
|
||||
class="global-flex"
|
||||
style:flex-direction="column"
|
||||
style:align-items="flex-start">
|
||||
<div class="global-flex" style:flex-wrap="wrap">
|
||||
<InlineTitle content={title} />
|
||||
{#if release.tagName}
|
||||
<span class="tag">{release.tagName}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="global-flex txt-body-m-regular" style:flex-wrap="wrap">
|
||||
<NodeId {...authorForNodeId(release.creator)} />
|
||||
{#if delegateIds.has(release.creator.did)}
|
||||
<DelegateBadge />
|
||||
{/if}
|
||||
created
|
||||
<Id id={release.id} clipboard={release.id} label="release ID" />
|
||||
<span title={absoluteTimestamp(release.createdAt)}>
|
||||
{formatTimestamp(release.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="global-flex" style:margin-left="auto">
|
||||
<div class="counter" title="Artifacts">
|
||||
<Icon name="attach" />
|
||||
{release.artifacts.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,6 +52,7 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { ReleaseCounts } from "@bindings/cob/release/ReleaseCounts";
|
||||
import type { Config } from "@bindings/config/Config";
|
||||
import type { RepoInfo } from "@bindings/repo/RepoInfo";
|
||||
import type { CrossfadeParams } from "svelte/transition";
|
||||
@@ -194,6 +195,37 @@
|
||||
return () => resetDynamicInterval("seededNotReplicated");
|
||||
});
|
||||
|
||||
// Kept while navigating inside the repo, so the badge doesn't flicker.
|
||||
let releaseCount = $state<number | undefined>(undefined);
|
||||
let countedRid: string | undefined = undefined;
|
||||
|
||||
$effect(() => {
|
||||
const rid = activeRepo?.rid;
|
||||
if (rid === undefined) {
|
||||
releaseCount = undefined;
|
||||
countedRid = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
if (countedRid !== rid) {
|
||||
releaseCount = undefined;
|
||||
countedRid = rid;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
invoke<ReleaseCounts>("release_counts", { rid })
|
||||
.then(counts => {
|
||||
if (!cancelled) releaseCount = counts.delegate + counts.other;
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) releaseCount = undefined;
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
const filteredRepos = $derived(
|
||||
filterQuery.trim()
|
||||
? repos.filter(r =>
|
||||
@@ -517,6 +549,14 @@
|
||||
activeRid() === rid
|
||||
);
|
||||
}
|
||||
|
||||
function isReleases(rid: string): boolean {
|
||||
return (
|
||||
($activeRoute.resource === "repo.releases" ||
|
||||
$activeRoute.resource === "repo.release") &&
|
||||
activeRid() === rid
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -1161,6 +1201,17 @@
|
||||
isPatches(repo.rid),
|
||||
activeProject?.meta.patches.open || undefined,
|
||||
)}
|
||||
{@render subItem(
|
||||
repo.name,
|
||||
router.routeToPath({
|
||||
resource: "repo.releases",
|
||||
rid: repo.rid,
|
||||
}),
|
||||
"parcel",
|
||||
"Releases",
|
||||
isReleases(repo.rid),
|
||||
releaseCount || undefined,
|
||||
)}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
@@ -1168,7 +1219,7 @@
|
||||
{#snippet subItem(
|
||||
repoName: string,
|
||||
href: string,
|
||||
icon: "branch" | "issue" | "patch",
|
||||
icon: "branch" | "issue" | "patch" | "parcel",
|
||||
label: string,
|
||||
active: boolean,
|
||||
count: number | undefined,
|
||||
@@ -1283,7 +1334,7 @@
|
||||
role="menuitem"
|
||||
onclick={() => confirmUnseed(repo)}>
|
||||
<Icon name="seed" />
|
||||
Stop seeding
|
||||
Unseed
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
import HoverPopover from "@app/components/HoverPopover.svelte";
|
||||
|
||||
interface Props {
|
||||
tooltip: string;
|
||||
badge: Snippet;
|
||||
tight?: boolean;
|
||||
}
|
||||
|
||||
const { tooltip, badge, tight = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* HoverPopover wraps its trigger in an inline-block container and a block
|
||||
button, whose line boxes push the badge off the centre line of whatever
|
||||
it sits beside. Neither needs a box of its own here. */
|
||||
.badge :global(.container),
|
||||
.badge :global([role="button"]) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 0;
|
||||
}
|
||||
.tight:not(:first-child) {
|
||||
margin-left: -0.25rem;
|
||||
}
|
||||
.tooltip {
|
||||
font: var(--txt-body-s-regular);
|
||||
color: var(--color-text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
<span class="badge" class:tight>
|
||||
<HoverPopover stylePadding="0.25rem 0.5rem">
|
||||
{#snippet toggle()}
|
||||
{@render badge()}
|
||||
{/snippet}
|
||||
{#snippet popover()}
|
||||
<span class="tooltip">{tooltip}</span>
|
||||
{/snippet}
|
||||
</HoverPopover>
|
||||
</span>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import TooltipBadge from "@app/components/TooltipBadge.svelte";
|
||||
|
||||
interface Props {
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
const { tooltip = "You are the author" }: Props = $props();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.you {
|
||||
font: var(--txt-body-s-regular);
|
||||
color: var(--color-text-tertiary);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: 0 0.375rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
<TooltipBadge {tooltip}>
|
||||
{#snippet badge()}
|
||||
<span class="you" aria-label={tooltip}>You</span>
|
||||
{/snippet}
|
||||
</TooltipBadge>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { invoke } from "@app/lib/invoke";
|
||||
|
||||
export async function pickFiles(): Promise<string[]> {
|
||||
return invoke<string[]>("pick_artifact_files");
|
||||
}
|
||||
|
||||
export async function pickFolder(): Promise<string | undefined> {
|
||||
return (await invoke<string | null>("pick_artifact_directory")) ?? undefined;
|
||||
}
|
||||
|
||||
export async function pickLike(
|
||||
directory: boolean,
|
||||
): Promise<string | undefined> {
|
||||
return directory ? pickFolder() : (await pickFiles())[0];
|
||||
}
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
export function basename(path: string): string {
|
||||
return path.split(/[\\/]/).pop() ?? path;
|
||||
const parts = path.split(/[\\/]/).filter(p => p.length > 0);
|
||||
return parts[parts.length - 1] ?? path;
|
||||
}
|
||||
|
||||
export function embedPreviewKind(
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export const nodeRunning = writable<boolean>(false);
|
||||
// Undefined until the first probe, so nothing reports the node as down before
|
||||
// it has been asked.
|
||||
export const artifactNodeRunning = writable<boolean | undefined>(undefined);
|
||||
|
||||
@@ -23,3 +23,26 @@ export function resetDynamicInterval(key: string) {
|
||||
clearTimeout(dynamicIntervals.get(key));
|
||||
dynamicIntervals.delete(key);
|
||||
}
|
||||
|
||||
// Unlike setInterval, a slow run never overlaps the next one.
|
||||
export function poll(
|
||||
task: (active: () => boolean) => Promise<void>,
|
||||
period: number,
|
||||
): () => void {
|
||||
let stopped = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const active = () => !stopped;
|
||||
|
||||
const run = async () => {
|
||||
await task(active);
|
||||
if (!stopped) {
|
||||
timer = setTimeout(() => void run(), period);
|
||||
}
|
||||
};
|
||||
void run();
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { Author } from "@bindings/cob/Author";
|
||||
import type { Artifact } from "@bindings/cob/release/Artifact";
|
||||
import type { Location } from "@bindings/cob/release/Location";
|
||||
import type { ReleaseCounts } from "@bindings/cob/release/ReleaseCounts";
|
||||
import type { ReleaseScope } from "@bindings/cob/release/ReleaseScope";
|
||||
import type { Commit } from "@bindings/repo/Commit";
|
||||
import type { RepoRefs } from "@bindings/repo/RepoRefs";
|
||||
import type { Tag } from "@bindings/repo/Tag";
|
||||
|
||||
// With no scope asked for and no delegate releases, open on the untrusted
|
||||
// scope rather than on an empty list.
|
||||
export function releaseListScope(
|
||||
requested: ReleaseScope | undefined,
|
||||
counts: ReleaseCounts,
|
||||
): ReleaseScope {
|
||||
if (requested !== undefined) {
|
||||
return requested;
|
||||
}
|
||||
return counts.delegate === 0 && counts.other > 0 ? "untrusted" : "trusted";
|
||||
}
|
||||
|
||||
export interface ArtifactView {
|
||||
scope: ReleaseScope;
|
||||
shown: Artifact[];
|
||||
// Redacted artifacts within the current author scope.
|
||||
redactedCount: number;
|
||||
// What each scope would show, so an artifact hidden as redacted never
|
||||
// counts towards a scope.
|
||||
counts: Record<ReleaseScope, number>;
|
||||
// Filter only when both scopes hold something.
|
||||
showFilters: boolean;
|
||||
}
|
||||
|
||||
// Split a release's artifacts into two disjoint scopes by author. Show the
|
||||
// scope asked for unless it is empty, and hide artifacts redacted by their
|
||||
// author or a delegate unless asked to show them.
|
||||
export function artifactView(
|
||||
artifacts: Artifact[],
|
||||
delegates: Set<string>,
|
||||
wanted: ReleaseScope,
|
||||
showRedacted: boolean,
|
||||
): ArtifactView {
|
||||
const byScope: Record<ReleaseScope, Artifact[]> = {
|
||||
trusted: artifacts.filter(a => delegates.has(a.author.did)),
|
||||
untrusted: artifacts.filter(a => !delegates.has(a.author.did)),
|
||||
};
|
||||
const visible = (list: Artifact[]) =>
|
||||
showRedacted ? list : list.filter(a => !a.redacted);
|
||||
const other = wanted === "trusted" ? "untrusted" : "trusted";
|
||||
const scope = byScope[wanted].length > 0 ? wanted : other;
|
||||
|
||||
return {
|
||||
scope,
|
||||
shown: visible(byScope[scope]),
|
||||
redactedCount: byScope[scope].filter(a => a.redacted).length,
|
||||
counts: {
|
||||
trusted: visible(byScope.trusted).length,
|
||||
untrusted: visible(byScope.untrusted).length,
|
||||
},
|
||||
showFilters: byScope.trusted.length > 0 && byScope.untrusted.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Delegate takes precedence over author, since the author may also be a
|
||||
// delegate.
|
||||
export function redactedByDelegate(
|
||||
artifact: Artifact,
|
||||
delegates: Set<string>,
|
||||
): boolean {
|
||||
return artifact.redactions.some(r => delegates.has(r.user.did));
|
||||
}
|
||||
|
||||
// Group locations by the contributing node, preserving order.
|
||||
export function locationsByNode(
|
||||
locations: Location[],
|
||||
): { user: Author; urls: string[] }[] {
|
||||
const groups = new Map<string, { user: Author; urls: string[] }>();
|
||||
for (const { user, url } of locations) {
|
||||
const group = groups.get(user.did) ?? { user, urls: [] };
|
||||
group.urls.push(url);
|
||||
groups.set(user.did, group);
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
// Order a node-keyed list so delegates come first. The backend returns these
|
||||
// sorted by DID (arbitrary to a reader); sort is stable, so the original
|
||||
// order is preserved within each group.
|
||||
export function delegatesFirst<T>(
|
||||
items: T[],
|
||||
did: (item: T) => string,
|
||||
delegates: Set<string>,
|
||||
): T[] {
|
||||
return [...items].sort(
|
||||
(a, b) => Number(delegates.has(did(b))) - Number(delegates.has(did(a))),
|
||||
);
|
||||
}
|
||||
|
||||
// Locations a browser can open, as opposed to ones fetched from a seeder
|
||||
// over the radicle-artifact protocol. Delegate locations come first: they
|
||||
// are the ones a reader can trust most.
|
||||
export function webLocations(
|
||||
locations: Location[],
|
||||
delegates: Set<string>,
|
||||
): Location[] {
|
||||
return delegatesFirst(
|
||||
locations.filter(l => /^https?:\/\//i.test(l.url)),
|
||||
l => l.user.did,
|
||||
delegates,
|
||||
);
|
||||
}
|
||||
|
||||
// The trust signal worth carrying on the collapsed row. Only delegates count:
|
||||
// anyone can make keys that attest their own artifact.
|
||||
export function attestedBy(
|
||||
attestations: Author[],
|
||||
delegates: Set<string>,
|
||||
): number {
|
||||
return attestations.filter(a => delegates.has(a.did)).length;
|
||||
}
|
||||
|
||||
// Only metadata by the author or a delegate is shown, so only they get an
|
||||
// editor.
|
||||
export function canEditMetadata(
|
||||
artifact: Artifact,
|
||||
ownDid: string,
|
||||
delegates: Set<string>,
|
||||
): boolean {
|
||||
return artifact.author.did === ownDid || delegates.has(ownDid);
|
||||
}
|
||||
|
||||
// The author vouches by registering, so the COB ignores their attestation,
|
||||
// and a redaction bars the same user from attesting.
|
||||
export function canAttest(artifact: Artifact, ownDid: string): boolean {
|
||||
return (
|
||||
artifact.author.did !== ownDid &&
|
||||
!artifact.redacted &&
|
||||
!artifact.attestations.some(a => a.did === ownDid) &&
|
||||
!artifact.redactions.some(r => r.user.did === ownDid)
|
||||
);
|
||||
}
|
||||
|
||||
// The COB stores values as free-form JSON. Text that parses as JSON is sent
|
||||
// as that value, so numbers and booleans round-trip; anything else is sent
|
||||
// as a plain string.
|
||||
export function parseMetadataValue(input: string): unknown {
|
||||
try {
|
||||
return JSON.parse(input);
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
export function displayMetadataValue(value: unknown): string {
|
||||
return typeof value === "string" ? value : JSON.stringify(value);
|
||||
}
|
||||
|
||||
// Commits whose SHA starts with, or whose summary contains, the query. A
|
||||
// pasted commit outside the loaded history still shows as a choice.
|
||||
export function matchCommits(
|
||||
commits: Commit[],
|
||||
query: string,
|
||||
resolved: Commit | undefined,
|
||||
): Commit[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
const matches = commits.filter(
|
||||
c => c.id.startsWith(q) || c.summary.toLowerCase().includes(q),
|
||||
);
|
||||
if (resolved && !matches.some(c => c.id === resolved.id)) {
|
||||
return [resolved];
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
// Only canonical tags: a peer's tag of the same name could point a release
|
||||
// at a commit the delegates never tagged.
|
||||
export function canonicalTags(refs: RepoRefs): { name: string; tag: Tag }[] {
|
||||
return Object.entries(refs.canonical.tags)
|
||||
.map(([name, tag]) => ({ name, tag }))
|
||||
.sort((a, b) => b.tag.timestamp - a.tag.timestamp);
|
||||
}
|
||||
+3
-1
@@ -239,7 +239,9 @@ export function routeToPath(route: Route): string {
|
||||
route.resource === "repo.issue" ||
|
||||
route.resource === "repo.issues" ||
|
||||
route.resource === "repo.patch" ||
|
||||
route.resource === "repo.patches"
|
||||
route.resource === "repo.patches" ||
|
||||
route.resource === "repo.release" ||
|
||||
route.resource === "repo.releases"
|
||||
) {
|
||||
return repoRouteToPath(route);
|
||||
} else if (route.resource === "booting") {
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
loadIssues,
|
||||
loadPatch,
|
||||
loadPatches,
|
||||
loadRelease,
|
||||
loadReleases,
|
||||
loadRepoCommit,
|
||||
loadRepoCommits,
|
||||
loadRepoHome,
|
||||
@@ -64,7 +66,9 @@ export function isLoadedRepoRoute(
|
||||
route.resource === "repo.issue" ||
|
||||
route.resource === "repo.issues" ||
|
||||
route.resource === "repo.patch" ||
|
||||
route.resource === "repo.patches"
|
||||
route.resource === "repo.patches" ||
|
||||
route.resource === "repo.release" ||
|
||||
route.resource === "repo.releases"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,6 +125,10 @@ export async function loadRoute(
|
||||
return loadPatch(route, previousLoaded);
|
||||
} else if (route.resource === "repo.patches") {
|
||||
return loadPatches(route);
|
||||
} else if (route.resource === "repo.release") {
|
||||
return loadRelease(route);
|
||||
} else if (route.resource === "repo.releases") {
|
||||
return loadReleases(route);
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { Artifact } from "@bindings/cob/release/Artifact";
|
||||
import type { ArtifactDigest } from "@bindings/cob/release/ArtifactDigest";
|
||||
|
||||
import { formatBytes } from "@app/lib/utils";
|
||||
|
||||
export interface Picked {
|
||||
path: string;
|
||||
name: string;
|
||||
digest: ArtifactDigest;
|
||||
}
|
||||
|
||||
// Identity is the content hash, so picking bytes the release already holds
|
||||
// lands on the existing entry.
|
||||
export interface StagedArtifact extends Picked {
|
||||
register: boolean;
|
||||
seed: boolean;
|
||||
duplicateOf?: string;
|
||||
existing?: {
|
||||
name: string;
|
||||
keepsName: boolean;
|
||||
redaction?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function stageArtifacts(
|
||||
picked: Picked[],
|
||||
artifacts: Artifact[],
|
||||
options: {
|
||||
ownDid: string;
|
||||
delegates: Set<string>;
|
||||
nodeRunning: boolean;
|
||||
seeded: Set<string>;
|
||||
},
|
||||
): StagedArtifact[] {
|
||||
const byCid = new Map(artifacts.map(a => [a.cid, a]));
|
||||
const firstPick = new Map<string, string>();
|
||||
return picked.map(item => {
|
||||
const duplicateOf = firstPick.get(item.digest.cid);
|
||||
if (duplicateOf !== undefined) {
|
||||
return { ...item, register: false, seed: false, duplicateOf };
|
||||
}
|
||||
firstPick.set(item.digest.cid, item.name);
|
||||
const match = byCid.get(item.digest.cid);
|
||||
const seed = options.nodeRunning && !options.seeded.has(item.digest.cid);
|
||||
if (!match) {
|
||||
return { ...item, register: true, seed };
|
||||
}
|
||||
const own = match.author.did === options.ownDid;
|
||||
const renamed = match.name !== item.name;
|
||||
const redaction = match.redacted
|
||||
? (match.redactions.find(
|
||||
r =>
|
||||
r.user.did === match.author.did ||
|
||||
options.delegates.has(r.user.did),
|
||||
)?.reason ?? "")
|
||||
: undefined;
|
||||
return {
|
||||
...item,
|
||||
register: own && renamed,
|
||||
seed,
|
||||
existing: {
|
||||
name: match.name,
|
||||
keepsName: renamed && !own,
|
||||
redaction,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// A redacted match is left alone unless you confirm registering it again.
|
||||
export function effective(
|
||||
item: StagedArtifact,
|
||||
includeRedacted: boolean,
|
||||
): { register: boolean; seed: boolean } {
|
||||
if (item.existing?.redaction !== undefined && !includeRedacted) {
|
||||
return { register: false, seed: false };
|
||||
}
|
||||
return { register: item.register, seed: item.seed };
|
||||
}
|
||||
|
||||
export interface PlannedArtifact {
|
||||
item: StagedArtifact;
|
||||
register: boolean;
|
||||
seed: boolean;
|
||||
}
|
||||
|
||||
export interface RegisterPlan {
|
||||
items: PlannedArtifact[];
|
||||
acting: PlannedArtifact[];
|
||||
registering: PlannedArtifact[];
|
||||
newCount: number;
|
||||
renamedCount: number;
|
||||
seededCount: number;
|
||||
skippedCount: number;
|
||||
}
|
||||
|
||||
export function registerPlan(
|
||||
staged: StagedArtifact[],
|
||||
includeRedacted: boolean,
|
||||
): RegisterPlan {
|
||||
const items = staged.map(item => ({
|
||||
item,
|
||||
...effective(item, includeRedacted),
|
||||
}));
|
||||
const acting = items.filter(p => p.register || p.seed);
|
||||
const registering = items.filter(p => p.register);
|
||||
const newCount = registering.filter(p => !p.item.existing).length;
|
||||
return {
|
||||
items,
|
||||
acting,
|
||||
registering,
|
||||
newCount,
|
||||
renamedCount: registering.length - newCount,
|
||||
seededCount: acting.length - registering.length,
|
||||
skippedCount: items.length - acting.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function planTitle(plan: RegisterPlan): string {
|
||||
const count = plan.acting.length;
|
||||
if (count === 0) {
|
||||
return "Already registered";
|
||||
}
|
||||
const verb = plan.newCount > 0 ? "Register" : "Update";
|
||||
return `${verb} ${count} ${count === 1 ? "artifact" : "artifacts"}`;
|
||||
}
|
||||
|
||||
export function planSummary(plan: RegisterPlan): string {
|
||||
return (
|
||||
[
|
||||
[plan.newCount, "registered"],
|
||||
[plan.renamedCount, "renamed"],
|
||||
[plan.seededCount, "seeded"],
|
||||
[plan.skippedCount, "skipped"],
|
||||
] as const
|
||||
)
|
||||
.filter(([count]) => count !== 0)
|
||||
.map(([count, label]) => `${count} ${label}`)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
export function planDetail(
|
||||
{ item, register, seed }: PlannedArtifact,
|
||||
includeRedacted: boolean,
|
||||
): string {
|
||||
const existing = item.existing;
|
||||
if (item.duplicateOf !== undefined) {
|
||||
return `same as “${item.duplicateOf}” · skipped`;
|
||||
}
|
||||
if (!existing) {
|
||||
const size = formatBytes(item.digest.sizeBytes);
|
||||
const files = item.digest.fileCount;
|
||||
return item.digest.directory
|
||||
? `${files} ${files === 1 ? "file" : "files"} · ${size}`
|
||||
: size;
|
||||
}
|
||||
if (existing.redaction !== undefined && !includeRedacted) {
|
||||
return "redacted · skipped";
|
||||
}
|
||||
const name = register
|
||||
? `renames “${existing.name}”`
|
||||
: existing.keepsName
|
||||
? `stays “${existing.name}”`
|
||||
: "already registered";
|
||||
if (seed) {
|
||||
return `${name} · will seed`;
|
||||
}
|
||||
return register ? name : `${name} · skipped`;
|
||||
}
|
||||
|
||||
export function duplicatePicks(
|
||||
picks: { path: string; name: string; digest?: { cid: string } }[],
|
||||
): Map<string, string> {
|
||||
const first = new Map<string, string>();
|
||||
const duplicates = new Map<string, string>();
|
||||
for (const pick of picks) {
|
||||
if (!pick.digest) {
|
||||
continue;
|
||||
}
|
||||
const earlier = first.get(pick.digest.cid);
|
||||
if (earlier === undefined) {
|
||||
first.set(pick.digest.cid, pick.name);
|
||||
} else {
|
||||
duplicates.set(pick.path, earlier);
|
||||
}
|
||||
}
|
||||
return duplicates;
|
||||
}
|
||||
@@ -3,7 +3,9 @@ import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import once from "lodash/once";
|
||||
|
||||
import { nodeRunning } from "./events";
|
||||
import { invoke } from "@app/lib/invoke";
|
||||
|
||||
import { artifactNodeRunning, nodeRunning } from "./events";
|
||||
|
||||
export let unlistenNodeEvents: UnlistenFn | undefined = undefined;
|
||||
|
||||
@@ -12,8 +14,29 @@ export function setUnlistenNodeEvents(unlisten: UnlistenFn) {
|
||||
}
|
||||
|
||||
// Will be called once in the startup of the app
|
||||
export const createEventEmittersOnce = once(async () => {
|
||||
return await listen<boolean>("node_running", event => {
|
||||
nodeRunning.set(event.payload);
|
||||
});
|
||||
export const createEventEmittersOnce = once(async (): Promise<UnlistenFn> => {
|
||||
if (!window.__TAURI_INTERNALS__) {
|
||||
// The test backend can't emit events, so poll at the backend's pace.
|
||||
const pollNodes = () => {
|
||||
invoke<boolean>("node_running")
|
||||
.then(running => nodeRunning.set(running))
|
||||
.catch(console.error);
|
||||
invoke<boolean>("artifact_node_running")
|
||||
.then(running => artifactNodeRunning.set(running))
|
||||
.catch(console.error);
|
||||
};
|
||||
pollNodes();
|
||||
const interval = setInterval(pollNodes, 2_000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
|
||||
const unlisteners = await Promise.all([
|
||||
listen<boolean>("node_running", event => {
|
||||
nodeRunning.set(event.payload);
|
||||
}),
|
||||
listen<boolean>("artifact_node_running", event => {
|
||||
artifactNodeRunning.set(event.payload);
|
||||
}),
|
||||
]);
|
||||
return () => unlisteners.forEach(unlisten => unlisten());
|
||||
});
|
||||
|
||||
@@ -453,3 +453,64 @@ export function creditedCoAuthors(commit: {
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
const units = ["KiB", "MiB", "GiB", "TiB"];
|
||||
let value = bytes / 1024;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
export function formatUptime(seconds: number): string {
|
||||
if (seconds < 60) {
|
||||
return `${Math.floor(seconds)}s`;
|
||||
}
|
||||
if (seconds < 3600) {
|
||||
return `${Math.floor(seconds / 60)}m`;
|
||||
}
|
||||
if (seconds < 86400) {
|
||||
return `${Math.floor(seconds / 3600)}h`;
|
||||
}
|
||||
return `${Math.floor(seconds / 86400)}d`;
|
||||
}
|
||||
|
||||
// CIDs in a release share a long prefix, so the head grows until each label is
|
||||
// distinct.
|
||||
export function shortenCids(
|
||||
cids: string[],
|
||||
minHead = 7,
|
||||
tailLength = 6,
|
||||
maxHead = 16,
|
||||
): Map<string, string> {
|
||||
const display = new Map<string, string>();
|
||||
const unique = [...new Set(cids)];
|
||||
if (unique.length === 0) {
|
||||
return display;
|
||||
}
|
||||
|
||||
let head = minHead;
|
||||
while (
|
||||
head < maxHead &&
|
||||
new Set(unique.map(cid => cid.slice(0, head))).size < unique.length
|
||||
) {
|
||||
head += 1;
|
||||
}
|
||||
|
||||
for (const cid of unique) {
|
||||
display.set(
|
||||
cid,
|
||||
head + tailLength >= cid.length
|
||||
? cid
|
||||
: `${cid.slice(0, head)}…${cid.slice(-tailLength)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return display;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import ConfirmModal from "@app/modals/ConfirmModal.svelte";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
/// Whether other peers registered artifacts in the release, whose refs
|
||||
/// keep it alive.
|
||||
shared: boolean;
|
||||
confirm: () => Promise<void>;
|
||||
}
|
||||
|
||||
const { title, shared, confirm }: Props = $props();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.release {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font: var(--txt-body-m-medium);
|
||||
color: var(--color-text-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
.release-icon {
|
||||
display: inline-flex;
|
||||
color: var(--color-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<ConfirmModal
|
||||
title="Delete release"
|
||||
width="26rem"
|
||||
icon="trash"
|
||||
label="Delete"
|
||||
busyLabel="Deleting…"
|
||||
describeError={() => "Could not delete the release."}
|
||||
{confirm}>
|
||||
{#snippet body()}
|
||||
<div class="release txt-overflow">
|
||||
<span class="release-icon"><Icon name="parcel" /></span>
|
||||
{title}
|
||||
</div>
|
||||
<span>
|
||||
This removes the release and its artifact list, and your node stops
|
||||
seeding its artifacts. Peers that already fetched the release keep their
|
||||
copy until they sync.
|
||||
</span>
|
||||
{#if shared}
|
||||
<span>
|
||||
Others registered artifacts in this release, so it stays visible with
|
||||
their changes.
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ConfirmModal>
|
||||
@@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import type { ComponentProps, Snippet } from "svelte";
|
||||
|
||||
import { disableHide, enableHide, hide } from "@app/lib/modal";
|
||||
|
||||
import Button from "@app/components/Button.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
width: string;
|
||||
icon: ComponentProps<typeof Icon>["name"];
|
||||
label: string;
|
||||
busyLabel: string;
|
||||
failure?: string;
|
||||
confirm: () => Promise<void>;
|
||||
body: Snippet<[{ working: boolean; run: () => void }]>;
|
||||
disabled?: boolean;
|
||||
canConfirm?: boolean;
|
||||
describeError?: (error: unknown) => string;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
width,
|
||||
icon,
|
||||
label,
|
||||
busyLabel,
|
||||
failure = "Something went wrong.",
|
||||
confirm,
|
||||
body,
|
||||
disabled = false,
|
||||
canConfirm = true,
|
||||
describeError = error => (error instanceof Error ? error.message : failure),
|
||||
}: Props = $props();
|
||||
|
||||
let working = $state(false);
|
||||
let error = $state<string | undefined>(undefined);
|
||||
|
||||
async function run() {
|
||||
if (working || disabled) return;
|
||||
working = true;
|
||||
error = undefined;
|
||||
disableHide();
|
||||
try {
|
||||
await confirm();
|
||||
enableHide();
|
||||
hide();
|
||||
} catch (e) {
|
||||
error = describeError(e);
|
||||
enableHide();
|
||||
} finally {
|
||||
working = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--border-radius-lg);
|
||||
background-color: var(--color-surface-canvas);
|
||||
overflow: hidden;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1.5rem;
|
||||
height: 3.25rem;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
.title {
|
||||
font: var(--txt-heading-s);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
font: var(--txt-body-m-regular);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.error {
|
||||
color: var(--color-feedback-error-text);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0 1.5rem 1.5rem;
|
||||
}
|
||||
.confirm-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="modal" style:width>
|
||||
<div class="header">
|
||||
<span class="title">{title}</span>
|
||||
<Button variant="naked" onclick={hide}>
|
||||
<span style:color="var(--color-text-tertiary)">
|
||||
<Icon name="close" />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
{@render body({ working, run: () => void run() })}
|
||||
|
||||
{#if error}
|
||||
<span class="error">{error}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
{#if canConfirm}
|
||||
<Button variant="outline" onclick={hide}>Cancel</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={working || disabled}
|
||||
onclick={() => void run()}>
|
||||
<span class="confirm-label">
|
||||
<Icon name={icon} />
|
||||
{working ? busyLabel : label}
|
||||
</span>
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="outline" onclick={hide}>Close</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import TextInput from "@app/components/TextInput.svelte";
|
||||
import ConfirmModal from "@app/modals/ConfirmModal.svelte";
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
/// Whether this node is currently seeding the bytes, which redacting also
|
||||
/// stops.
|
||||
seeding: boolean;
|
||||
confirm: (reason: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const { name, seeding, confirm }: Props = $props();
|
||||
|
||||
let reason = $state("");
|
||||
|
||||
const ready = $derived(reason.trim().length > 0);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.artifact {
|
||||
color: var(--color-text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
.label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-surface-subtle);
|
||||
}
|
||||
.warning-icon {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
color: var(--color-feedback-warning-text);
|
||||
}
|
||||
.warning-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<ConfirmModal
|
||||
title="Redact artifact"
|
||||
width="28rem"
|
||||
icon="warning"
|
||||
label="Redact"
|
||||
busyLabel="Redacting…"
|
||||
failure="Unable to redact this artifact."
|
||||
disabled={!ready}
|
||||
confirm={() => confirm(reason.trim())}>
|
||||
{#snippet body({ working, run })}
|
||||
<span class="artifact">{name}</span>
|
||||
|
||||
<span>
|
||||
This marks the artifact as one that should no longer be used, and hides it
|
||||
from the release by default.
|
||||
</span>
|
||||
|
||||
<label class="label">
|
||||
Reason
|
||||
<TextInput
|
||||
autofocus
|
||||
name="redact-reason"
|
||||
placeholder="Why should this no longer be used?"
|
||||
disabled={working}
|
||||
onSubmit={run}
|
||||
bind:value={reason} />
|
||||
</label>
|
||||
|
||||
<div class="warning">
|
||||
<span class="warning-icon"><Icon name="warning" /></span>
|
||||
<div class="warning-body">
|
||||
<span>
|
||||
Nothing is deleted. The CID stays in the release history, and anyone
|
||||
who already downloaded it keeps their copy.
|
||||
</span>
|
||||
{#if seeding}
|
||||
<span>
|
||||
Your node stops seeding it and removes the location it added.
|
||||
</span>
|
||||
{/if}
|
||||
<span>
|
||||
This cannot be undone, and you will not be able to attest to this
|
||||
artifact afterwards.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</ConfirmModal>
|
||||
@@ -0,0 +1,211 @@
|
||||
<script lang="ts">
|
||||
import type { StagedArtifact } from "@app/lib/stageArtifacts";
|
||||
import {
|
||||
planDetail,
|
||||
planSummary,
|
||||
planTitle,
|
||||
registerPlan,
|
||||
} from "@app/lib/stageArtifacts";
|
||||
import { formatBytes } from "@app/lib/utils";
|
||||
|
||||
import Checkbox from "@app/components/Checkbox.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import ConfirmModal from "@app/modals/ConfirmModal.svelte";
|
||||
|
||||
interface Props {
|
||||
staged: StagedArtifact[];
|
||||
confirm: (
|
||||
staged: StagedArtifact[],
|
||||
includeRedacted: boolean,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
const { staged, confirm }: Props = $props();
|
||||
|
||||
let includeRedacted = $state(false);
|
||||
|
||||
const plan = $derived(registerPlan(staged, includeRedacted));
|
||||
const acting = $derived(plan.acting);
|
||||
const registering = $derived(plan.registering);
|
||||
const newCount = $derived(plan.newCount);
|
||||
const willSeed = $derived(acting.some(p => p.seed));
|
||||
const anyExisting = $derived(staged.some(s => s.existing));
|
||||
const anyDuplicate = $derived(staged.some(s => s.duplicateOf !== undefined));
|
||||
const anyKeepsName = $derived(staged.some(s => s.existing?.keepsName));
|
||||
const redacted = $derived(
|
||||
staged.filter(s => s.existing?.redaction !== undefined),
|
||||
);
|
||||
const registersFolder = $derived(
|
||||
registering.some(p => p.item.digest.directory),
|
||||
);
|
||||
|
||||
const totalBytes = $derived(
|
||||
acting.reduce((sum, p) => sum + p.item.digest.sizeBytes, 0),
|
||||
);
|
||||
const totalFiles = $derived(
|
||||
acting.reduce((sum, p) => sum + p.item.digest.fileCount, 0),
|
||||
);
|
||||
|
||||
const title = $derived(planTitle(plan));
|
||||
const summary = $derived(planSummary(plan));
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-height: 13rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.item-icon {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.item-name {
|
||||
color: var(--color-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
.item-detail {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text-tertiary);
|
||||
font: var(--txt-body-s-regular);
|
||||
}
|
||||
.total {
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
color: var(--color-text-tertiary);
|
||||
font: var(--txt-body-s-regular);
|
||||
}
|
||||
.warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-surface-subtle);
|
||||
}
|
||||
.warning-icon {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
color: var(--color-feedback-warning-text);
|
||||
}
|
||||
.warning-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.note {
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-surface-subtle);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
<ConfirmModal
|
||||
{title}
|
||||
width="30rem"
|
||||
icon="plus"
|
||||
label={newCount > 0 ? "Register" : "Update"}
|
||||
busyLabel={newCount > 0 ? "Registering…" : "Updating…"}
|
||||
failure="Unable to register these artifacts."
|
||||
canConfirm={acting.length > 0}
|
||||
confirm={() => confirm(staged, includeRedacted)}>
|
||||
{#snippet body({ working })}
|
||||
<div class="list">
|
||||
{#each plan.items as p}
|
||||
<div class="item">
|
||||
<span class="item-icon">
|
||||
<Icon name={p.item.digest.directory ? "folder" : "attach"} />
|
||||
</span>
|
||||
<span class="item-name">{p.item.name}</span>
|
||||
<span class="item-detail">{planDetail(p, includeRedacted)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if acting.length > 0 && (acting.length > 1 || acting.some(p => p.item.digest.directory))}
|
||||
<div class="total">
|
||||
{totalFiles}
|
||||
{totalFiles === 1 ? "file" : "files"} in total, {formatBytes(
|
||||
totalBytes,
|
||||
)}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if anyExisting || anyDuplicate}
|
||||
<div class="note">
|
||||
An artifact is identified by the hash of its contents, not by its name,
|
||||
so anything already in this release stays the single entry it is.
|
||||
{#if anyDuplicate}
|
||||
Picks with the same contents are registered once.
|
||||
{/if}
|
||||
{#if acting.length === 0}
|
||||
Nothing will be registered.
|
||||
{:else}
|
||||
{summary}.
|
||||
{/if}
|
||||
{#if anyKeepsName}
|
||||
A name marked “stays” is kept: only its author can rename it.
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if redacted.length > 0}
|
||||
<div class="warning">
|
||||
<span class="warning-icon"><Icon name="warning" /></span>
|
||||
<div class="warning-body">
|
||||
{#each redacted as item (item.digest.cid)}
|
||||
<span>
|
||||
“{item.existing?.name}” was redacted{item.existing?.redaction
|
||||
? `: ${item.existing.redaction}`
|
||||
: "."}
|
||||
</span>
|
||||
{/each}
|
||||
<Checkbox bind:checked={includeRedacted} disabled={working}>
|
||||
Register {redacted.length === 1 ? "it" : "them"} again
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if acting.length > 0}
|
||||
<div class="warning">
|
||||
<span class="warning-icon"><Icon name="warning" /></span>
|
||||
<div class="warning-body">
|
||||
{#if registering.length > 0}
|
||||
<span>
|
||||
{#if registersFolder}
|
||||
A folder is registered whole, as one artifact.
|
||||
{:else}
|
||||
These names and CIDs are signed into the release and reach
|
||||
everyone who has this repository.
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{#if willSeed}
|
||||
<span>
|
||||
Your artifact node is running, so it seeds the artifact and adds
|
||||
your node's location.
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ConfirmModal>
|
||||
@@ -1,10 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { disableHide, enableHide, hide } from "@app/lib/modal";
|
||||
import { repoListScope } from "@app/lib/repoListScope";
|
||||
|
||||
import Button from "@app/components/Button.svelte";
|
||||
import Checkbox from "@app/components/Checkbox.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import ConfirmModal from "@app/modals/ConfirmModal.svelte";
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
@@ -15,60 +14,9 @@
|
||||
const { name, rid, confirm }: Props = $props();
|
||||
|
||||
let clean = $state(false);
|
||||
let working = $state(false);
|
||||
let error = $state<string | undefined>(undefined);
|
||||
|
||||
async function run() {
|
||||
if (working) return;
|
||||
working = true;
|
||||
error = undefined;
|
||||
// Two commands run back to back, so the scrim and the close button stay
|
||||
// inert until both have settled.
|
||||
disableHide();
|
||||
try {
|
||||
await confirm(clean);
|
||||
enableHide();
|
||||
hide();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Unable to stop seeding.";
|
||||
enableHide();
|
||||
} finally {
|
||||
working = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.modal {
|
||||
width: 26rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--border-radius-lg);
|
||||
background-color: var(--color-surface-canvas);
|
||||
overflow: hidden;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1.5rem;
|
||||
height: 3.25rem;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
.title {
|
||||
font: var(--txt-heading-s);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
font: var(--txt-body-m-regular);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.repo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -110,33 +58,17 @@
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.error {
|
||||
color: var(--color-feedback-error-text);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0 1.5rem 1.5rem;
|
||||
}
|
||||
.confirm-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="modal">
|
||||
<div class="header">
|
||||
<span class="title">Stop seeding</span>
|
||||
<Button variant="naked" onclick={hide}>
|
||||
<span style:color="var(--color-text-tertiary)">
|
||||
<Icon name="close" />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<ConfirmModal
|
||||
title="Unseed"
|
||||
width="26rem"
|
||||
icon={clean ? "trash" : "seed"}
|
||||
label="Unseed"
|
||||
busyLabel="Unseeding…"
|
||||
failure="Unable to unseed."
|
||||
confirm={() => confirm(clean)}>
|
||||
{#snippet body({ working })}
|
||||
<div class="repo txt-overflow">
|
||||
<span class="repo-icon"><Icon name="repository" /></span>
|
||||
{name}
|
||||
@@ -156,7 +88,7 @@
|
||||
{/if}
|
||||
</span>
|
||||
<!-- Its own paragraph rather than a swapped-in sentence, so ticking the
|
||||
box reads as text appearing instead of a block reflowing. -->
|
||||
box reads as text appearing instead of a block reflowing. -->
|
||||
{#if clean}
|
||||
<span>
|
||||
The files go too, except the refs your node signed and the
|
||||
@@ -171,19 +103,5 @@
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<span class="error">{error}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<Button variant="outline" onclick={hide}>Cancel</Button>
|
||||
<Button variant="ghost" disabled={working} onclick={() => void run()}>
|
||||
<span class="confirm-label">
|
||||
<Icon name={clean ? "trash" : "seed"} />
|
||||
{working ? "Stopping…" : "Stop seeding"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</ConfirmModal>
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
<script lang="ts">
|
||||
import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery";
|
||||
import type { ArtifactDigest } from "@bindings/cob/release/ArtifactDigest";
|
||||
import type { Commit } from "@bindings/repo/Commit";
|
||||
import type { RepoInfo } from "@bindings/repo/RepoInfo";
|
||||
import type { RepoRefs } from "@bindings/repo/RepoRefs";
|
||||
import type { Tag } from "@bindings/repo/Tag";
|
||||
|
||||
import { untrack } from "svelte";
|
||||
|
||||
import { pickFiles, pickFolder } from "@app/lib/artifactPickers";
|
||||
import { basename } from "@app/lib/embeds";
|
||||
import { artifactNodeRunning } from "@app/lib/events";
|
||||
import { invoke, InvokeError } from "@app/lib/invoke";
|
||||
import { disableHide, enableHide, forceHide } from "@app/lib/modal";
|
||||
import { isOid } from "@app/lib/radUri";
|
||||
import { canonicalTags, matchCommits } from "@app/lib/releases";
|
||||
import * as router from "@app/lib/router";
|
||||
import { duplicatePicks } from "@app/lib/stageArtifacts";
|
||||
import { formatBytes, formatOid } from "@app/lib/utils";
|
||||
|
||||
import Button from "@app/components/Button.svelte";
|
||||
import Checkbox from "@app/components/Checkbox.svelte";
|
||||
import DropdownList from "@app/components/DropdownList.svelte";
|
||||
import DropdownListItem from "@app/components/DropdownListItem.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import Popover, { closeFocused } from "@app/components/Popover.svelte";
|
||||
import RepoAvatar from "@app/components/RepoAvatar.svelte";
|
||||
import ScrollArea from "@app/components/ScrollArea.svelte";
|
||||
import TextInput from "@app/components/TextInput.svelte";
|
||||
|
||||
interface Props {
|
||||
repo: RepoInfo;
|
||||
}
|
||||
|
||||
const { repo }: Props = $props();
|
||||
|
||||
interface NamedTag {
|
||||
name: string;
|
||||
tag: Tag;
|
||||
}
|
||||
|
||||
interface PendingPick {
|
||||
path: string;
|
||||
name: string;
|
||||
hashing: boolean;
|
||||
digest?: ArtifactDigest;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let tags: NamedTag[] = $state([]);
|
||||
let tagsError = $state(false);
|
||||
let selectedTag: NamedTag | undefined = $state();
|
||||
let commitInput = $state("");
|
||||
let commits: Commit[] = $state([]);
|
||||
let commitPickerExpanded = $state(false);
|
||||
// The commit the input names, once it is known to exist in the repo.
|
||||
let resolvedCommit: Commit | undefined = $state();
|
||||
let commitMissing = $state(false);
|
||||
let staged: PendingPick[] = $state([]);
|
||||
let submitting = $state(false);
|
||||
let submitError: string | undefined = $state();
|
||||
let seedFailures = $state(0);
|
||||
// Once set, the target is fixed and closing the modal opens the release.
|
||||
let createdId: string | undefined = $state();
|
||||
let popoverExpanded = $state(false);
|
||||
const nodeRunning = $derived($artifactNodeRunning === true);
|
||||
let seedAfterRegister = $state(true);
|
||||
const canSeed = $derived(nodeRunning && seedAfterRegister);
|
||||
|
||||
// The store rejects lightweight tags, whose `tagOid` is absent.
|
||||
const target = $derived.by(() => {
|
||||
if (selectedTag) {
|
||||
return {
|
||||
oid: selectedTag.tag.oid,
|
||||
tag: selectedTag.tag.tagOid,
|
||||
};
|
||||
}
|
||||
const oid = commitInput.trim();
|
||||
return resolvedCommit?.id === oid ? { oid, tag: undefined } : undefined;
|
||||
});
|
||||
|
||||
const commitQuery = $derived(commitInput.trim().toLowerCase());
|
||||
const commitItems = $derived(
|
||||
matchCommits(commits, commitQuery, resolvedCommit),
|
||||
);
|
||||
|
||||
const duplicateOf = $derived(duplicatePicks(staged));
|
||||
const anyFolder = $derived(staged.some(a => a.digest?.directory));
|
||||
|
||||
const ready = $derived(
|
||||
staged.length > 0 &&
|
||||
staged.every(a => a.digest !== undefined) &&
|
||||
target !== undefined,
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (staged.length > 0 || selectedTag || commitInput.trim() !== "") {
|
||||
disableHide();
|
||||
} else {
|
||||
enableHide();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
void invoke<RepoRefs>("list_repo_refs", { rid: repo.rid })
|
||||
.then(refs => {
|
||||
tags = canonicalTags(refs);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error("Could not list tags:", error);
|
||||
tagsError = true;
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
void invoke<PaginatedQuery<Commit[]>>("list_repo_commits", {
|
||||
rid: repo.rid,
|
||||
take: 50,
|
||||
})
|
||||
.then(result => {
|
||||
commits = result.content;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error("Could not list commits:", error);
|
||||
});
|
||||
});
|
||||
|
||||
function lookupCommit(oid: string) {
|
||||
resolvedCommit = undefined;
|
||||
commitMissing = false;
|
||||
if (!isOid(oid)) {
|
||||
return;
|
||||
}
|
||||
const known = commits.find(c => c.id === oid);
|
||||
if (known) {
|
||||
resolvedCommit = known;
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void invoke<Commit>("repo_commit", { rid: repo.rid, sha: oid })
|
||||
.then(commit => {
|
||||
if (!cancelled) {
|
||||
resolvedCommit = commit;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
commitMissing = true;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
// Only the input drives the lookup: the modal store re-creates `repo`
|
||||
// whenever hiding is toggled, which would otherwise repeat it.
|
||||
$effect(() => {
|
||||
const oid = commitInput.trim();
|
||||
return untrack(() => lookupCommit(oid));
|
||||
});
|
||||
|
||||
async function stage(paths: string[]) {
|
||||
const picked = paths
|
||||
.filter(path => !staged.some(a => a.path === path))
|
||||
.map(path => ({ path, name: basename(path), hashing: true }));
|
||||
if (picked.length === 0) {
|
||||
return;
|
||||
}
|
||||
staged = [...staged, ...picked];
|
||||
|
||||
await Promise.all(
|
||||
picked.map(async entry => {
|
||||
try {
|
||||
const digest = await invoke<ArtifactDigest>("compute_artifact_cid", {
|
||||
path: entry.path,
|
||||
});
|
||||
staged = staged.map(a =>
|
||||
a.path === entry.path ? { ...a, digest, hashing: false } : a,
|
||||
);
|
||||
} catch {
|
||||
staged = staged.map(a =>
|
||||
a.path === entry.path
|
||||
? { ...a, hashing: false, error: "Could not read file" }
|
||||
: a,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function chooseFiles() {
|
||||
const paths = await pickFiles();
|
||||
if (paths.length > 0) {
|
||||
await stage(paths);
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseFolder() {
|
||||
const path = await pickFolder();
|
||||
if (path) {
|
||||
await stage([path]);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!target || !ready) {
|
||||
return;
|
||||
}
|
||||
submitting = true;
|
||||
submitError = undefined;
|
||||
seedFailures = 0;
|
||||
let releaseId: string | undefined;
|
||||
try {
|
||||
releaseId = await invoke<string>("create_or_open_release", {
|
||||
rid: repo.rid,
|
||||
oid: target.oid,
|
||||
tag: target.tag,
|
||||
});
|
||||
createdId = releaseId;
|
||||
for (const artifact of staged) {
|
||||
if (!artifact.digest || duplicateOf.has(artifact.path)) {
|
||||
continue;
|
||||
}
|
||||
await invoke("register_artifact", {
|
||||
rid: repo.rid,
|
||||
releaseId,
|
||||
cid: artifact.digest.cid,
|
||||
name: artifact.name,
|
||||
sizeBytes: artifact.digest.sizeBytes,
|
||||
});
|
||||
|
||||
if (canSeed) {
|
||||
// A seed failure must not discard the release we just created.
|
||||
try {
|
||||
await invoke("seed_artifact", {
|
||||
rid: repo.rid,
|
||||
releaseId,
|
||||
cid: artifact.digest.cid,
|
||||
sourcePath: artifact.path,
|
||||
});
|
||||
} catch {
|
||||
seedFailures += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Creating and registering are idempotent, so a retry only re-seeds.
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof InvokeError &&
|
||||
error.code === "RepoError.CommitNotFound"
|
||||
) {
|
||||
submitError = `Commit ${formatOid(target.oid)} is not in this repo.`;
|
||||
} else if (releaseId === undefined) {
|
||||
submitError = "Could not create the release.";
|
||||
} else {
|
||||
submitError = "Could not register the artifacts.";
|
||||
}
|
||||
return;
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
if (seedFailures > 0 || releaseId === undefined) {
|
||||
return;
|
||||
}
|
||||
await close();
|
||||
}
|
||||
|
||||
async function close() {
|
||||
if (createdId === undefined) {
|
||||
forceHide();
|
||||
return;
|
||||
}
|
||||
// Navigate before hiding: closing the modal drops its props.
|
||||
try {
|
||||
await router.push({
|
||||
resource: "repo.release",
|
||||
rid: repo.rid,
|
||||
release: createdId,
|
||||
});
|
||||
} finally {
|
||||
enableHide();
|
||||
forceHide();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.modal {
|
||||
width: 44rem;
|
||||
font: var(--txt-body-m-regular);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--border-radius-lg);
|
||||
background-color: var(--color-surface-canvas);
|
||||
overflow: hidden;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1.5rem;
|
||||
height: 3.25rem;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font: var(--txt-body-m-regular);
|
||||
color: var(--color-text-secondary);
|
||||
min-width: 0;
|
||||
}
|
||||
.repo-name {
|
||||
font: var(--txt-body-m-semibold);
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.title {
|
||||
font: var(--txt-body-m-regular);
|
||||
color: var(--color-text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.body {
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.section-title {
|
||||
font: var(--txt-body-m-semibold);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.hint {
|
||||
font: var(--txt-body-m-regular);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.target-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.commit-summary {
|
||||
min-width: 0;
|
||||
max-width: 28rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.artifact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
font: var(--txt-body-m-regular);
|
||||
}
|
||||
.artifact:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.artifact-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.artifact-meta {
|
||||
font: var(--txt-body-m-regular);
|
||||
color: var(--color-text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.artifact-list {
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
.warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-surface-subtle);
|
||||
}
|
||||
.warning-icon {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
color: var(--color-feedback-warning-text);
|
||||
}
|
||||
.error {
|
||||
font: var(--txt-body-m-regular);
|
||||
color: var(--color-foreground-red);
|
||||
}
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="modal">
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<RepoAvatar
|
||||
name={repo.payloads["xyz.radicle.project"]?.data.name ?? ""}
|
||||
rid={repo.rid}
|
||||
styleWidth="1rem" />
|
||||
<span class="repo-name">
|
||||
{repo.payloads["xyz.radicle.project"]?.data.name}
|
||||
</span>
|
||||
<Icon name="chevron-right" />
|
||||
<span class="title">New release</span>
|
||||
</div>
|
||||
<Button variant="naked" onclick={close}>
|
||||
<span style:color="var(--color-text-tertiary)">
|
||||
<Icon name="close" />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<div class="section">
|
||||
<span class="section-title">Target</span>
|
||||
<div class="target-row">
|
||||
<Popover
|
||||
popoverPadding="0"
|
||||
placement="bottom-start"
|
||||
styleZIndex="400"
|
||||
bind:expanded={popoverExpanded}>
|
||||
{#snippet toggle(onclick)}
|
||||
<Button
|
||||
variant="outline"
|
||||
{onclick}
|
||||
disabled={tags.length === 0 || createdId !== undefined}
|
||||
active={popoverExpanded}
|
||||
title={tagsError
|
||||
? "Could not read this repo's tags"
|
||||
: tags.length === 0
|
||||
? "This repo has no canonical tags"
|
||||
: undefined}>
|
||||
<Icon name="label" />
|
||||
<span>
|
||||
{selectedTag ? selectedTag.name : "Select a tag"}
|
||||
</span>
|
||||
<Icon name={popoverExpanded ? "chevron-up" : "chevron-down"} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#snippet popover()}
|
||||
<div
|
||||
style:border="1px solid var(--color-border-subtle)"
|
||||
style:border-radius="var(--border-radius-sm)"
|
||||
style:background-color="var(--color-surface-canvas)">
|
||||
<DropdownList items={tags} styleDropdownMinWidth="16rem">
|
||||
{#snippet item(entry)}
|
||||
<DropdownListItem
|
||||
selected={selectedTag?.name === entry.name}
|
||||
styleGap="0.5rem"
|
||||
onclick={() => {
|
||||
selectedTag = entry;
|
||||
commitInput = "";
|
||||
closeFocused();
|
||||
}}>
|
||||
<Icon name="label" />
|
||||
<span style:color="var(--color-text-secondary)">
|
||||
{entry.name}
|
||||
</span>
|
||||
<span class="artifact-meta">
|
||||
{formatOid(entry.tag.oid)}
|
||||
</span>
|
||||
</DropdownListItem>
|
||||
{/snippet}
|
||||
{#snippet empty()}
|
||||
<div class="hint" style:padding="0.5rem">No tags</div>
|
||||
{/snippet}
|
||||
</DropdownList>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
|
||||
<span class="hint">or</span>
|
||||
|
||||
<div style:flex="1">
|
||||
<Popover
|
||||
popoverPadding="0"
|
||||
placement="bottom-start"
|
||||
styleZIndex="400"
|
||||
bind:expanded={commitPickerExpanded}>
|
||||
{#snippet toggle(onclick)}
|
||||
<TextInput
|
||||
placeholder="Pick a commit or paste a SHA"
|
||||
disabled={createdId !== undefined}
|
||||
valid={!commitMissing}
|
||||
bind:value={commitInput}
|
||||
onFocus={() => {
|
||||
if (!commitPickerExpanded) {
|
||||
onclick();
|
||||
}
|
||||
}}
|
||||
onDismiss={closeFocused}
|
||||
oninput={() => {
|
||||
if (commitInput.trim() !== "") {
|
||||
selectedTag = undefined;
|
||||
}
|
||||
if (!commitPickerExpanded) {
|
||||
onclick();
|
||||
}
|
||||
}} />
|
||||
{/snippet}
|
||||
{#snippet popover()}
|
||||
<div
|
||||
style:border="1px solid var(--color-border-subtle)"
|
||||
style:border-radius="var(--border-radius-sm)"
|
||||
style:background-color="var(--color-surface-canvas)">
|
||||
<DropdownList
|
||||
items={commitItems}
|
||||
styleDropdownMinWidth="24rem"
|
||||
styleDropdownMaxHeight="20rem">
|
||||
{#snippet item(commit)}
|
||||
<DropdownListItem
|
||||
selected={commitInput.trim() === commit.id}
|
||||
styleGap="0.5rem"
|
||||
onclick={() => {
|
||||
commitInput = commit.id;
|
||||
selectedTag = undefined;
|
||||
closeFocused();
|
||||
}}>
|
||||
<Icon name="commit" />
|
||||
<span class="artifact-meta">
|
||||
{formatOid(commit.id)}
|
||||
</span>
|
||||
<span class="commit-summary">{commit.summary}</span>
|
||||
</DropdownListItem>
|
||||
{/snippet}
|
||||
{#snippet empty()}
|
||||
<div class="hint" style:padding="0.5rem">
|
||||
{#if commitMissing}
|
||||
No commit with this SHA
|
||||
{:else if isOid(commitQuery)}
|
||||
Looking up commit…
|
||||
{:else}
|
||||
No matching commits
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</DropdownList>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
{#if commitMissing}
|
||||
<span class="error">
|
||||
Commit {formatOid(commitInput.trim())} is not in this repo.
|
||||
</span>
|
||||
{:else if resolvedCommit && !selectedTag}
|
||||
<span class="hint">{resolvedCommit.summary}</span>
|
||||
{/if}
|
||||
{#if selectedTag && !selectedTag.tag.tagOid}
|
||||
<span class="hint">
|
||||
Lightweight tag: the release records the commit only.
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<span class="section-title">Artifacts</span>
|
||||
{#if staged.length > 0}
|
||||
<ScrollArea style="max-height: 16rem;">
|
||||
<div class="artifact-list">
|
||||
{#each staged as artifact (artifact.path)}
|
||||
<div class="artifact">
|
||||
<Icon name={artifact.error ? "warning" : "attach"} />
|
||||
<span class="artifact-name" title={artifact.path}>
|
||||
{artifact.name}
|
||||
</span>
|
||||
{#if artifact.hashing}
|
||||
<span class="artifact-meta">Hashing…</span>
|
||||
{:else if artifact.error}
|
||||
<span class="error">{artifact.error}</span>
|
||||
{:else if duplicateOf.has(artifact.path)}
|
||||
<span class="artifact-meta">
|
||||
same as “{duplicateOf.get(artifact.path)}”
|
||||
</span>
|
||||
{:else if artifact.digest}
|
||||
<span class="artifact-meta">
|
||||
{formatBytes(artifact.digest.sizeBytes)}
|
||||
</span>
|
||||
{/if}
|
||||
<Button
|
||||
variant="naked"
|
||||
onclick={() => {
|
||||
staged = staged.filter(a => a.path !== artifact.path);
|
||||
}}>
|
||||
<span style:color="var(--color-text-tertiary)">
|
||||
<Icon name="close" />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{:else}
|
||||
<span class="hint">
|
||||
Choose the files to register in this release. Each one is hashed
|
||||
locally to get its CID; seeding the bytes is a separate step.
|
||||
</span>
|
||||
{/if}
|
||||
{#if anyFolder}
|
||||
<div class="warning">
|
||||
<span class="warning-icon"><Icon name="warning" /></span>
|
||||
<span>A folder is registered whole, as one artifact.</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="target-row">
|
||||
<Button variant="outline" onclick={chooseFiles}>
|
||||
<Icon name="plus" />Choose files
|
||||
</Button>
|
||||
<Button variant="outline" onclick={chooseFolder}>
|
||||
<Icon name="plus" />Choose folder
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style:margin-top="0.25rem"
|
||||
style:width="fit-content"
|
||||
title={nodeRunning
|
||||
? undefined
|
||||
: "The artifact node is not running, so the artifacts are registered but not seeded."}>
|
||||
<Checkbox bind:checked={seedAfterRegister} disabled={!nodeRunning}>
|
||||
Seed these artifacts so others can download them
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
{#if submitError}
|
||||
<span class="error">{submitError}</span>
|
||||
{:else if seedFailures > 0}
|
||||
<span class="error">
|
||||
Registered, but {seedFailures}
|
||||
{seedFailures === 1 ? "artifact" : "artifacts"} could not be seeded.
|
||||
</span>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<Button variant="outline" onclick={close}>
|
||||
{createdId ? "Open release" : "Cancel"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!ready || submitting}
|
||||
onclick={submit}>
|
||||
{#if submitting}
|
||||
{createdId ? "Retrying…" : "Creating…"}
|
||||
{:else}
|
||||
{createdId ? "Retry" : "Create release"}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,9 +31,7 @@
|
||||
error = undefined;
|
||||
try {
|
||||
await invoke("authenticate", { passphrase });
|
||||
if (window.__TAURI_INTERNALS__) {
|
||||
setUnlistenNodeEvents(await createEventEmittersOnce());
|
||||
}
|
||||
setUnlistenNodeEvents(await createEventEmittersOnce());
|
||||
passphrase = " ".repeat(passphrase.length);
|
||||
await router.push({ resource: "inbox" });
|
||||
} catch (err) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,182 @@
|
||||
<script lang="ts">
|
||||
import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery";
|
||||
import type { Release } from "@bindings/cob/release/Release";
|
||||
import type { ReleaseCounts } from "@bindings/cob/release/ReleaseCounts";
|
||||
import type { ReleaseScope } from "@bindings/cob/release/ReleaseScope";
|
||||
import type { RepoInfo } from "@bindings/repo/RepoInfo";
|
||||
|
||||
import { listReleases, RELEASES_PER_PAGE } from "@app/views/repo/router";
|
||||
|
||||
import { modalStore, show } from "@app/lib/modal";
|
||||
import { createPaginatedList } from "@app/lib/paginatedList.svelte";
|
||||
import * as router from "@app/lib/router";
|
||||
import { isMac } from "@app/lib/utils";
|
||||
|
||||
import Button from "@app/components/Button.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import ReleaseTeaser from "@app/components/ReleaseTeaser.svelte";
|
||||
import ScrollArea from "@app/components/ScrollArea.svelte";
|
||||
import Topbar from "@app/components/Topbar.svelte";
|
||||
import VirtualList from "@app/components/VirtualList.svelte";
|
||||
import CreateReleaseModal from "@app/modals/CreateRelease.svelte";
|
||||
|
||||
import Layout from "./Layout.svelte";
|
||||
|
||||
interface Props {
|
||||
repo: RepoInfo;
|
||||
releases: PaginatedQuery<Release[]>;
|
||||
releaseCounts: ReleaseCounts;
|
||||
scope: ReleaseScope;
|
||||
showFilters: boolean;
|
||||
}
|
||||
|
||||
const { repo, releases, releaseCounts, scope, showFilters }: Props = $props();
|
||||
|
||||
const delegateIds = $derived(new Set(repo.delegates.map(d => d.did)));
|
||||
|
||||
function openCreateRelease() {
|
||||
show({ component: CreateReleaseModal, props: { repo } });
|
||||
}
|
||||
|
||||
const list = createPaginatedList<Release>({
|
||||
key: () => `repo.releases:${repo.rid}:${scope}`,
|
||||
page: () => releases,
|
||||
fetchPage: (skip, take) => listReleases(repo.rid, scope, skip, take),
|
||||
pageSize: RELEASES_PER_PAGE,
|
||||
id: release => release.id,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.topbar-title {
|
||||
font: var(--txt-body-m-semibold);
|
||||
color: var(--color-text-secondary);
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font: var(--txt-body-m-regular);
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.filter:hover {
|
||||
background-color: var(--color-surface-subtle);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.filter.active {
|
||||
background-color: var(--color-surface-subtle);
|
||||
}
|
||||
.filter .global-counter-badge {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
.row {
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
</style>
|
||||
|
||||
<svelte:document
|
||||
onkeydown={e => {
|
||||
const auxiliarKey = isMac() ? e.metaKey : e.ctrlKey;
|
||||
if (auxiliarKey && e.key.toLowerCase() === "n" && !$modalStore) {
|
||||
e.preventDefault();
|
||||
openCreateRelease();
|
||||
}
|
||||
}} />
|
||||
|
||||
<Layout selfScroll>
|
||||
<div class="page">
|
||||
<Topbar>
|
||||
<span class="topbar-title">Releases</span>
|
||||
{#if showFilters}
|
||||
<div class="filters">
|
||||
<a
|
||||
class="filter"
|
||||
class:active={scope === "trusted"}
|
||||
href={router.routeToPath({
|
||||
resource: "repo.releases",
|
||||
rid: repo.rid,
|
||||
})}>
|
||||
<Icon name="badge" />Delegates
|
||||
<span class="global-counter-badge">
|
||||
{releaseCounts.delegate}
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
class="filter"
|
||||
class:active={scope === "untrusted"}
|
||||
title="Non-delegates"
|
||||
href={router.routeToPath({
|
||||
resource: "repo.releases",
|
||||
rid: repo.rid,
|
||||
scope: "untrusted",
|
||||
})}>
|
||||
<Icon name="avatar-incognito" />Others
|
||||
<span class="global-counter-badge">
|
||||
{releaseCounts.other}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
<div style:margin-left="auto">
|
||||
<Button
|
||||
styleHeight="2rem"
|
||||
variant="secondary"
|
||||
onclick={openCreateRelease}>
|
||||
<Icon name="plus" />New release
|
||||
</Button>
|
||||
</div>
|
||||
</Topbar>
|
||||
|
||||
{#if list.items.length === 0}
|
||||
<div
|
||||
class="global-flex"
|
||||
style:flex="1"
|
||||
style:justify-content="center"
|
||||
style:align-items="center">
|
||||
<div class="txt-missing txt-body-m-regular">
|
||||
{!showFilters
|
||||
? "No releases"
|
||||
: scope === "trusted"
|
||||
? "No releases by delegates"
|
||||
: "No releases by others"}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<ScrollArea style="height: 100%; min-width: 0;">
|
||||
<VirtualList
|
||||
items={list.items}
|
||||
hasMore={list.more}
|
||||
loadingMore={list.loadingMore}
|
||||
onLoadMore={() => list.loadMore()}
|
||||
estimatedItemSize={80}
|
||||
getKey={release => release.id}
|
||||
initialCache={list.initialCache}
|
||||
initialScrollOffset={list.initialScrollOffset}
|
||||
onRestored={list.consumeRestoredScroll}
|
||||
onState={list.persistScroll}>
|
||||
{#snippet row(release)}
|
||||
<div class="row">
|
||||
<ReleaseTeaser {release} {scope} {delegateIds} rid={repo.rid} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</VirtualList>
|
||||
</ScrollArea>
|
||||
{/if}
|
||||
</div>
|
||||
</Layout>
|
||||
+180
-2
@@ -6,6 +6,10 @@ import type { Action as PatchAction } from "@bindings/cob/patch/Action";
|
||||
import type { Patch } from "@bindings/cob/patch/Patch";
|
||||
import type { Review } from "@bindings/cob/patch/Review";
|
||||
import type { Revision } from "@bindings/cob/patch/Revision";
|
||||
import type { Release } from "@bindings/cob/release/Release";
|
||||
import type { ReleaseCounts } from "@bindings/cob/release/ReleaseCounts";
|
||||
import type { ReleaseFilter } from "@bindings/cob/release/ReleaseFilter";
|
||||
import type { ReleaseScope } from "@bindings/cob/release/ReleaseScope";
|
||||
import type { Thread } from "@bindings/cob/thread/Thread";
|
||||
import type { Config } from "@bindings/config/Config";
|
||||
import type { Diff } from "@bindings/diff/Diff";
|
||||
@@ -20,6 +24,7 @@ import {
|
||||
cachedGetDiffText,
|
||||
invoke,
|
||||
} from "@app/lib/invoke";
|
||||
import { releaseListScope } from "@app/lib/releases";
|
||||
import type { LoadedRoute, SidebarData } from "@app/lib/router/definitions";
|
||||
import { loadSidebarData } from "@app/lib/router/definitions";
|
||||
import { unreachable } from "@app/lib/utils";
|
||||
@@ -28,6 +33,7 @@ export type IssueStatus = "all" | Issue["state"]["status"];
|
||||
|
||||
export const DEFAULT_TAKE = 20;
|
||||
export const COMMITS_PAGE_SIZE = 300;
|
||||
export const RELEASES_PER_PAGE = 30;
|
||||
|
||||
export interface RepoHomeRoute {
|
||||
resource: "repo.home";
|
||||
@@ -187,6 +193,45 @@ export interface LoadedRepoPatchesRoute {
|
||||
};
|
||||
}
|
||||
|
||||
export interface RepoReleasesRoute {
|
||||
resource: "repo.releases";
|
||||
rid: string;
|
||||
scope?: ReleaseScope;
|
||||
}
|
||||
|
||||
export interface LoadedRepoReleasesRoute {
|
||||
resource: "repo.releases";
|
||||
params: {
|
||||
repo: RepoInfo;
|
||||
releases: PaginatedQuery<Release[]>;
|
||||
releaseCounts: ReleaseCounts;
|
||||
scope: ReleaseScope;
|
||||
showFilters: boolean;
|
||||
sidebarData: SidebarData;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RepoReleaseRoute {
|
||||
resource: "repo.release";
|
||||
rid: string;
|
||||
release: string;
|
||||
// The release list the user came from, shown in the breadcrumb.
|
||||
scope?: ReleaseScope;
|
||||
artifactScope?: ReleaseScope;
|
||||
}
|
||||
|
||||
export interface LoadedRepoReleaseRoute {
|
||||
resource: "repo.release";
|
||||
params: {
|
||||
repo: RepoInfo;
|
||||
config: Config;
|
||||
release: Release;
|
||||
scope?: ReleaseScope;
|
||||
artifactScope?: ReleaseScope;
|
||||
sidebarData: SidebarData;
|
||||
};
|
||||
}
|
||||
|
||||
export type RepoRoute =
|
||||
| RepoHomeRoute
|
||||
| RepoCommitsRoute
|
||||
@@ -195,7 +240,9 @@ export type RepoRoute =
|
||||
| RepoIssueRoute
|
||||
| RepoIssuesRoute
|
||||
| RepoPatchRoute
|
||||
| RepoPatchesRoute;
|
||||
| RepoPatchesRoute
|
||||
| RepoReleaseRoute
|
||||
| RepoReleasesRoute;
|
||||
export type LoadedRepoRoute =
|
||||
| LoadedRepoHomeRoute
|
||||
| LoadedRepoCommitsRoute
|
||||
@@ -204,7 +251,9 @@ export type LoadedRepoRoute =
|
||||
| LoadedRepoIssueRoute
|
||||
| LoadedRepoIssuesRoute
|
||||
| LoadedRepoPatchRoute
|
||||
| LoadedRepoPatchesRoute;
|
||||
| LoadedRepoPatchesRoute
|
||||
| LoadedRepoReleaseRoute
|
||||
| LoadedRepoReleasesRoute;
|
||||
|
||||
export async function loadPatch(
|
||||
route: RepoPatchRoute,
|
||||
@@ -503,6 +552,94 @@ export async function loadIssues(
|
||||
};
|
||||
}
|
||||
|
||||
export function listReleases(
|
||||
rid: string,
|
||||
scope: ReleaseScope,
|
||||
skip: number,
|
||||
// Undefined lists every release.
|
||||
take: number | undefined,
|
||||
) {
|
||||
return invoke<PaginatedQuery<Release[]>>("list_releases", {
|
||||
rid,
|
||||
filter: { scope, showRedacted: false } satisfies ReleaseFilter,
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadReleases(
|
||||
route: RepoReleasesRoute,
|
||||
): Promise<LoadedRepoReleasesRoute> {
|
||||
let scope = route.scope ?? "trusted";
|
||||
const [sidebarData, repo, firstPage, releaseCounts] = await Promise.all([
|
||||
loadSidebarData(),
|
||||
invoke<RepoInfo>("repo_by_id", {
|
||||
rid: route.rid,
|
||||
}),
|
||||
listReleases(route.rid, scope, 0, RELEASES_PER_PAGE),
|
||||
invoke<ReleaseCounts>("release_counts", {
|
||||
rid: route.rid,
|
||||
}),
|
||||
]);
|
||||
let releases = firstPage;
|
||||
|
||||
const fallback = releaseListScope(route.scope, releaseCounts);
|
||||
if (fallback !== scope) {
|
||||
scope = fallback;
|
||||
releases = await listReleases(route.rid, scope, 0, RELEASES_PER_PAGE);
|
||||
}
|
||||
|
||||
const showFilters = releaseCounts.delegate > 0 && releaseCounts.other > 0;
|
||||
|
||||
return {
|
||||
resource: "repo.releases",
|
||||
params: {
|
||||
sidebarData,
|
||||
repo,
|
||||
releases,
|
||||
releaseCounts,
|
||||
scope,
|
||||
showFilters,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadRelease(
|
||||
route: RepoReleaseRoute,
|
||||
): Promise<LoadedRepoReleaseRoute | LoadedRepoReleasesRoute> {
|
||||
const [sidebarData, repo, release] = await Promise.all([
|
||||
loadSidebarData(),
|
||||
invoke<RepoInfo>("repo_by_id", {
|
||||
rid: route.rid,
|
||||
}),
|
||||
invoke<Release | null>("release_by_id", {
|
||||
rid: route.rid,
|
||||
id: route.release,
|
||||
}),
|
||||
]);
|
||||
|
||||
// A redacted or stale release falls back to the list.
|
||||
if (!release) {
|
||||
return loadReleases({
|
||||
resource: "repo.releases",
|
||||
rid: route.rid,
|
||||
scope: route.scope,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
resource: "repo.release",
|
||||
params: {
|
||||
sidebarData,
|
||||
repo,
|
||||
config: sidebarData.config,
|
||||
release,
|
||||
scope: route.scope,
|
||||
artifactScope: route.artifactScope,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function repoRouteToPath(route: RepoRoute): string {
|
||||
const pathSegments = ["/repos", route.rid];
|
||||
const searchParams = new URLSearchParams();
|
||||
@@ -565,11 +702,34 @@ export function repoRouteToPath(route: RepoRoute): string {
|
||||
url += `?${searchParams}`;
|
||||
}
|
||||
return url;
|
||||
} else if (route.resource === "repo.release") {
|
||||
let url = [...pathSegments, "releases", route.release].join("/");
|
||||
if (route.scope) {
|
||||
searchParams.set("scope", route.scope);
|
||||
}
|
||||
if (route.artifactScope === "untrusted") {
|
||||
searchParams.set("artifacts", "untrusted");
|
||||
}
|
||||
if (searchParams.size > 0) {
|
||||
url += `?${searchParams}`;
|
||||
}
|
||||
return url;
|
||||
} else if (route.resource === "repo.releases") {
|
||||
let url = [...pathSegments, "releases"].join("/");
|
||||
if (route.scope) {
|
||||
searchParams.set("scope", route.scope);
|
||||
url += `?${searchParams}`;
|
||||
}
|
||||
return url;
|
||||
} else {
|
||||
return unreachable(route);
|
||||
}
|
||||
}
|
||||
|
||||
function parseReleaseScope(value: string | null): ReleaseScope | undefined {
|
||||
return value === "trusted" || value === "untrusted" ? value : undefined;
|
||||
}
|
||||
|
||||
export function repoUrlToRoute(
|
||||
segments: string[],
|
||||
searchParams: URLSearchParams,
|
||||
@@ -654,6 +814,24 @@ export function repoUrlToRoute(
|
||||
} else {
|
||||
return { resource: "repo.patches", rid, status };
|
||||
}
|
||||
} else if (resource === "releases") {
|
||||
const id = segments.shift();
|
||||
const scope = parseReleaseScope(searchParams.get("scope"));
|
||||
if (id) {
|
||||
const artifactScope =
|
||||
searchParams.get("artifacts") === "untrusted"
|
||||
? "untrusted"
|
||||
: undefined;
|
||||
return {
|
||||
resource: "repo.release",
|
||||
rid,
|
||||
release: id,
|
||||
scope,
|
||||
artifactScope,
|
||||
};
|
||||
} else {
|
||||
return { resource: "repo.releases", rid, scope };
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { PeerManager, RadiclePeer } from "@tests/support/peerManager.js";
|
||||
|
||||
import * as Fs from "node:fs/promises";
|
||||
import * as Path from "node:path";
|
||||
|
||||
import {
|
||||
clone,
|
||||
createCollaborators,
|
||||
createProject,
|
||||
} from "@tests/support/collaboration.js";
|
||||
import {
|
||||
expect,
|
||||
goto,
|
||||
reload,
|
||||
test,
|
||||
useBackend,
|
||||
} from "@tests/support/fixtures.js";
|
||||
|
||||
test("the commit picker lists commits and rejects unknown SHAs", async ({
|
||||
page,
|
||||
peer,
|
||||
}) => {
|
||||
const { rid, repoFolder } = await createProject(peer);
|
||||
const { stdout: head } = await peer.git(["rev-parse", "main"], {
|
||||
cwd: repoFolder,
|
||||
});
|
||||
|
||||
await page.goto(`/repos/${rid}/releases`);
|
||||
await page.getByRole("button", { name: "New release" }).click();
|
||||
|
||||
const input = page.getByPlaceholder("Pick a commit or paste a SHA");
|
||||
await input.click();
|
||||
await page.getByText("Add notes").click();
|
||||
await expect(input).toHaveValue(head.trim());
|
||||
|
||||
await input.fill("cafecafecafecafecafecafecafecafecafecafe");
|
||||
await expect(
|
||||
page.getByText("Commit cafecaf is not in this repo."),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Create release" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
// Fills the New release modal with one artifact, without submitting it.
|
||||
async function fillRelease(page: Page, peer: RadiclePeer) {
|
||||
const { rid, repoFolder } = await createProject(peer);
|
||||
const file = Path.join(repoFolder, "build.tar");
|
||||
await Fs.writeFile(file, "artifact bytes");
|
||||
// The native file dialog only exists under Tauri.
|
||||
await page.route(/\/pick_artifact_files$/, route =>
|
||||
route.fulfill({ json: [file] }),
|
||||
);
|
||||
|
||||
await page.goto(`/repos/${rid}/releases`);
|
||||
await page.getByRole("button", { name: "New release" }).click();
|
||||
await page.getByPlaceholder("Pick a commit or paste a SHA").click();
|
||||
await page.getByText("Add notes").click();
|
||||
await page.getByRole("button", { name: "Choose files" }).click();
|
||||
return rid;
|
||||
}
|
||||
|
||||
test("creating a release opens it and lists it", async ({ page, peer }) => {
|
||||
const rid = await fillRelease(page, peer);
|
||||
await page.getByRole("button", { name: "Create release" }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
|
||||
await expect(page.getByRole("button", { name: /build\.tar/ })).toBeVisible();
|
||||
// The test peer runs no artifact node, so there is nothing to seed with.
|
||||
await expect(
|
||||
page.getByTitle("Your artifact node is not running"),
|
||||
).toBeDisabled();
|
||||
|
||||
await goto(page, `/repos/${rid}/releases`);
|
||||
await expect(page.getByText("Add notes")).toBeVisible();
|
||||
});
|
||||
|
||||
test("a release whose seeding failed still opens", async ({ page, peer }) => {
|
||||
await page.route(/\/artifact_node_running$/, route =>
|
||||
route.fulfill({ json: true }),
|
||||
);
|
||||
await page.route(/\/seed_artifact$/, route =>
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
json: { code: "UnknownError", message: "seeding failed" },
|
||||
}),
|
||||
);
|
||||
const rid = await fillRelease(page, peer);
|
||||
await page.getByRole("button", { name: "Create release" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText("Registered, but 1 artifact could not be seeded."),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Retry" })).toBeEnabled();
|
||||
await page.getByRole("button", { name: "Open release" }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
|
||||
await expect(page.getByRole("button", { name: /build\.tar/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test("a release can be deleted", async ({ page, peer }) => {
|
||||
const rid = await fillRelease(page, peer);
|
||||
await page.getByRole("button", { name: "Create release" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
|
||||
|
||||
await page.getByRole("button", { name: "Delete" }).click();
|
||||
await page
|
||||
.locator("[data-modal-content]")
|
||||
.getByRole("button", { name: "Delete" })
|
||||
.click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases$`));
|
||||
await expect(page.getByText("No releases")).toBeVisible();
|
||||
});
|
||||
|
||||
test("re-registering an artifact only signs what changes", async ({
|
||||
page,
|
||||
peer,
|
||||
}) => {
|
||||
const rid = await fillRelease(page, peer);
|
||||
await page.getByRole("button", { name: "Create release" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
|
||||
const modal = page.locator("[data-modal-content]");
|
||||
|
||||
// The same bytes under the same name change nothing.
|
||||
await page.getByRole("button", { name: "Register artifacts" }).click();
|
||||
await page.getByRole("button", { name: "Files…" }).click();
|
||||
await expect(modal.getByText("Nothing will be registered.")).toBeVisible();
|
||||
await expect(
|
||||
modal.getByRole("button", { name: "icon-plus Register" }),
|
||||
).toBeHidden();
|
||||
await modal.getByRole("button", { name: "Close", exact: true }).click();
|
||||
|
||||
// The same bytes under a new name rename your own artifact.
|
||||
const renamed = Path.join(peer.checkoutPath, "renamed.tar");
|
||||
await Fs.writeFile(renamed, "artifact bytes");
|
||||
await page.route(/\/pick_artifact_files$/, route =>
|
||||
route.fulfill({ json: [renamed] }),
|
||||
);
|
||||
await page.getByRole("button", { name: "Register artifacts" }).click();
|
||||
await page.getByRole("button", { name: "Files…" }).click();
|
||||
await expect(modal.getByText("renames “build.tar”")).toBeVisible();
|
||||
await modal.getByRole("button", { name: "icon-plus Update" }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /renamed\.tar/ }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("picks with the same contents are registered once", async ({
|
||||
page,
|
||||
peer,
|
||||
}) => {
|
||||
const rid = await fillRelease(page, peer);
|
||||
const copy = Path.join(peer.checkoutPath, "copy.tar");
|
||||
await Fs.writeFile(copy, "artifact bytes");
|
||||
await page.route(/\/pick_artifact_files$/, route =>
|
||||
route.fulfill({ json: [copy] }),
|
||||
);
|
||||
await page.getByRole("button", { name: "Choose files" }).click();
|
||||
await expect(page.getByText("same as “build.tar”")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Create release" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
|
||||
await expect(page.getByRole("button", { name: /build\.tar/ })).toBeVisible();
|
||||
|
||||
// Two new picks with the same contents in one go.
|
||||
const first = Path.join(peer.checkoutPath, "a.tar");
|
||||
const second = Path.join(peer.checkoutPath, "b.tar");
|
||||
await Fs.writeFile(first, "other bytes");
|
||||
await Fs.writeFile(second, "other bytes");
|
||||
await page.route(/\/pick_artifact_files$/, route =>
|
||||
route.fulfill({ json: [first, second] }),
|
||||
);
|
||||
const modal = page.locator("[data-modal-content]");
|
||||
await page.getByRole("button", { name: "Register artifacts" }).click();
|
||||
await page.getByRole("button", { name: "Files…" }).click();
|
||||
await expect(modal.getByText("same as “a.tar” · skipped")).toBeVisible();
|
||||
await modal.getByRole("button", { name: "icon-plus Register" }).click();
|
||||
await expect(modal).toBeHidden();
|
||||
await expect(page.getByRole("button", { name: /a\.tar/ })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /b\.tar/ })).toBeHidden();
|
||||
});
|
||||
|
||||
test("a location can be added and removed", async ({ page, peer }) => {
|
||||
const rid = await fillRelease(page, peer);
|
||||
await page.getByRole("button", { name: "Create release" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
|
||||
await page.getByRole("button", { name: /build\.tar/ }).click();
|
||||
|
||||
const add = page.getByTitle("Add a URL others can download");
|
||||
const input = page.getByPlaceholder("https://…");
|
||||
await add.click();
|
||||
await input.fill("iroh://abc");
|
||||
await input.press("Enter");
|
||||
await expect(
|
||||
page.getByText("This is not a URL peers can fetch from."),
|
||||
).toBeVisible();
|
||||
|
||||
const url = "https://example.com/build.tar";
|
||||
await input.fill(url);
|
||||
await input.press("Enter");
|
||||
await expect(page.getByText(url)).toBeVisible();
|
||||
|
||||
await page.getByTitle("Remove location").click();
|
||||
await page.getByRole("button", { name: "icon-trash Remove" }).click();
|
||||
await expect(page.getByText(url)).toBeHidden();
|
||||
await expect(page.getByText("No locations")).toBeVisible();
|
||||
});
|
||||
|
||||
test("a location added by someone else cannot be removed", async ({
|
||||
page,
|
||||
peer,
|
||||
}) => {
|
||||
const rid = await fillRelease(page, peer);
|
||||
await page.getByRole("button", { name: "Create release" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
|
||||
|
||||
// The test peers run no artifact node, so another user's location is
|
||||
// injected into the release the view reads.
|
||||
const url = "https://example.com/theirs.tar";
|
||||
await page.route(/\/release_by_id$/, async route => {
|
||||
const release = await (await route.fetch()).json();
|
||||
for (const artifact of release.artifacts) {
|
||||
artifact.locations.push({
|
||||
user: {
|
||||
did: "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
|
||||
},
|
||||
url,
|
||||
});
|
||||
}
|
||||
await route.fulfill({ json: release });
|
||||
});
|
||||
await reload(page);
|
||||
await page.getByRole("button", { name: /build\.tar/ }).click();
|
||||
|
||||
await expect(page.getByText(url)).toBeVisible();
|
||||
await expect(page.getByTitle("Remove location")).toBeHidden();
|
||||
});
|
||||
|
||||
test("removing your node's location while seeding stops seeding", async ({
|
||||
page,
|
||||
peer,
|
||||
}) => {
|
||||
const rid = await fillRelease(page, peer);
|
||||
await page.getByRole("button", { name: "Create release" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
|
||||
await page.getByRole("button", { name: /build\.tar/ }).click();
|
||||
await page.getByTitle("Add a URL others can download").click();
|
||||
await page.getByPlaceholder("https://…").fill("radiroh://");
|
||||
const added = page.waitForRequest(/\/add_artifact_location$/);
|
||||
await page.getByPlaceholder("https://…").press("Enter");
|
||||
const { cid, releaseId } = (await added).postDataJSON();
|
||||
await expect(page.getByText("radiroh://")).toBeVisible();
|
||||
|
||||
// The test peers run no artifact node, so it is made to report the
|
||||
// artifact as seeded and to accept the unseed.
|
||||
await page.route(/\/seeded_artifacts$/, route =>
|
||||
route.fulfill({ json: [cid] }),
|
||||
);
|
||||
const unseed = page.waitForRequest(/\/unseed_artifact$/);
|
||||
await page.route(/\/unseed_artifact$/, route =>
|
||||
route.fulfill({ json: null }),
|
||||
);
|
||||
await reload(page);
|
||||
await page.getByRole("button", { name: /build\.tar/ }).click();
|
||||
|
||||
await page.getByTitle("Remove location").click();
|
||||
await expect(page.getByText("Unseed this artifact?")).toBeVisible();
|
||||
await page.getByRole("button", { name: "icon-trash Unseed" }).click();
|
||||
expect((await unseed).postDataJSON()).toMatchObject({ releaseId, cid });
|
||||
});
|
||||
|
||||
test("metadata can be added, edited and removed", async ({ page, peer }) => {
|
||||
const rid = await fillRelease(page, peer);
|
||||
await page.getByRole("button", { name: "Create release" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
|
||||
await page.getByRole("button", { name: /build\.tar/ }).click();
|
||||
const metadata = page.locator(".section").filter({ hasText: /^Metadata/ });
|
||||
|
||||
await metadata.getByRole("button", { name: "Add" }).click();
|
||||
await metadata.getByPlaceholder("Key").fill("arch");
|
||||
await metadata.getByPlaceholder("Value").fill("x86_64");
|
||||
await metadata.getByPlaceholder("Value").press("Enter");
|
||||
await expect(metadata.getByText("x86_64")).toBeVisible();
|
||||
await reload(page);
|
||||
await page.getByRole("button", { name: /build\.tar/ }).click();
|
||||
await expect(metadata.getByText("x86_64")).toBeVisible();
|
||||
|
||||
await metadata.getByTitle("Edit").click();
|
||||
await metadata.getByPlaceholder("Value").fill("aarch64");
|
||||
await metadata.getByRole("button", { name: "Save" }).click();
|
||||
await expect(metadata.getByText("aarch64")).toBeVisible();
|
||||
|
||||
await metadata.getByTitle("Remove").click();
|
||||
await page.getByRole("button", { name: "icon-trash Remove" }).click();
|
||||
await expect(metadata.getByText("No metadata")).toBeVisible();
|
||||
});
|
||||
|
||||
test("an artifact can be redacted", async ({ page, peer }) => {
|
||||
const rid = await fillRelease(page, peer);
|
||||
await page.getByRole("button", { name: "Create release" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
|
||||
await page.getByRole("button", { name: /build\.tar/ }).click();
|
||||
|
||||
await page.getByRole("button", { name: "Redact" }).click();
|
||||
const modal = page.locator("[data-modal-content]");
|
||||
await modal
|
||||
.getByPlaceholder("Why should this no longer be used?")
|
||||
.fill("bad");
|
||||
await modal.getByRole("button", { name: "Redact" }).click();
|
||||
await expect(modal).toBeHidden();
|
||||
|
||||
// A redaction by the author hides the artifact by default.
|
||||
await expect(page.getByText("No artifacts")).toBeVisible();
|
||||
await page.getByRole("button", { name: /Show redacted/ }).click();
|
||||
await page.getByRole("button", { name: /build\.tar/ }).click();
|
||||
await expect(page.getByText("bad", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
// Calls a command on the peer's test backend directly, bypassing the app.
|
||||
function backend(peer: RadiclePeer) {
|
||||
return async (cmd: string, args: object) => {
|
||||
const { hostname, port } = peer.httpdBaseUrl;
|
||||
const response = await fetch(`http://${hostname}:${port}/${cmd}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(args),
|
||||
});
|
||||
expect(response.ok).toBe(true);
|
||||
return response.json();
|
||||
};
|
||||
}
|
||||
|
||||
test("opening another release starts from a fresh page", async ({
|
||||
page,
|
||||
peer,
|
||||
}) => {
|
||||
const { rid, repoFolder } = await createProject(peer);
|
||||
const file = Path.join(repoFolder, "build.tar");
|
||||
await Fs.writeFile(file, "artifact bytes");
|
||||
const call = backend(peer);
|
||||
const releaseIds: string[] = [];
|
||||
// Both releases carry the same bytes, so their artifacts share a CID.
|
||||
for (const revision of ["main~1", "main"]) {
|
||||
const { stdout: oid } = await peer.git(["rev-parse", revision], {
|
||||
cwd: repoFolder,
|
||||
});
|
||||
const releaseId: string = await call("create_or_open_release", {
|
||||
rid,
|
||||
oid: oid.trim(),
|
||||
tag: null,
|
||||
});
|
||||
const { cid, sizeBytes } = await call("compute_artifact_cid", {
|
||||
path: file,
|
||||
});
|
||||
await call("register_artifact", {
|
||||
rid,
|
||||
releaseId,
|
||||
cid,
|
||||
name: "build.tar",
|
||||
sizeBytes,
|
||||
});
|
||||
releaseIds.push(releaseId);
|
||||
}
|
||||
const [first, second] = releaseIds;
|
||||
|
||||
await page.goto(`/repos/${rid}/releases/${first}`);
|
||||
await page.getByRole("button", { name: /build\.tar/ }).click();
|
||||
const attestations = page
|
||||
.locator(".section")
|
||||
.filter({ hasText: /^Attestations/ });
|
||||
await expect(attestations).toBeVisible();
|
||||
|
||||
// A link to another release, as a mention renders one, navigates in place.
|
||||
await page.evaluate(href => {
|
||||
const link = document.createElement("a");
|
||||
link.href = href;
|
||||
link.textContent = "Next release";
|
||||
document.body.append(link);
|
||||
}, `/repos/${rid}/releases/${second}`);
|
||||
await page.getByRole("link", { name: "Next release" }).click();
|
||||
|
||||
await expect(page.locator(".title").getByText("Add notes")).toBeVisible();
|
||||
await expect(attestations).toBeHidden();
|
||||
});
|
||||
|
||||
test("a failed refresh after registering is not reported as a failure", async ({
|
||||
page,
|
||||
peer,
|
||||
}) => {
|
||||
const rid = await fillRelease(page, peer);
|
||||
await page.getByRole("button", { name: "Create release" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
|
||||
|
||||
const other = Path.join(peer.checkoutPath, "other.tar");
|
||||
await Fs.writeFile(other, "other bytes");
|
||||
await page.route(/\/pick_artifact_files$/, route =>
|
||||
route.fulfill({ json: [other] }),
|
||||
);
|
||||
await page.route(/\/release_by_id$/, route =>
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
json: { code: "UnknownError", message: "refresh failed" },
|
||||
}),
|
||||
);
|
||||
const modal = page.locator("[data-modal-content]");
|
||||
await page.getByRole("button", { name: "Register artifacts" }).click();
|
||||
await page.getByRole("button", { name: "Files…" }).click();
|
||||
await modal.getByRole("button", { name: "icon-plus Register" }).click();
|
||||
|
||||
await expect(modal).toBeHidden();
|
||||
await expect(page.getByText("Registering failed.")).toBeHidden();
|
||||
});
|
||||
|
||||
// Bob publishes an artifact; the app runs as eve, who isn't a delegate.
|
||||
async function openBobsArtifact(page: Page, peerManager: PeerManager) {
|
||||
const { bob, eve } = await createCollaborators(peerManager);
|
||||
const { rid, repoFolder } = await createProject(bob);
|
||||
const { stdout: head } = await bob.git(["rev-parse", "main"], {
|
||||
cwd: repoFolder,
|
||||
});
|
||||
const file = Path.join(bob.checkoutPath, "build.tar");
|
||||
await Fs.writeFile(file, "artifact bytes");
|
||||
|
||||
await bob.startHttpd();
|
||||
const call = backend(bob);
|
||||
const releaseId: string = await call("create_or_open_release", {
|
||||
rid,
|
||||
oid: head.trim(),
|
||||
tag: null,
|
||||
});
|
||||
const { cid, sizeBytes } = await call("compute_artifact_cid", { path: file });
|
||||
await call("register_artifact", {
|
||||
rid,
|
||||
releaseId,
|
||||
cid,
|
||||
name: "build.tar",
|
||||
sizeBytes,
|
||||
});
|
||||
|
||||
await clone(eve, rid);
|
||||
await eve.startHttpd();
|
||||
await useBackend(page, eve);
|
||||
await page.goto(`/repos/${rid}/releases/${releaseId}`);
|
||||
await page.getByRole("button", { name: /build\.tar/ }).click();
|
||||
return eve;
|
||||
}
|
||||
|
||||
test("a non-delegate can attest an artifact by reproducing it", async ({
|
||||
page,
|
||||
peerManager,
|
||||
}) => {
|
||||
const eve = await openBobsArtifact(page, peerManager);
|
||||
const other = Path.join(eve.checkoutPath, "other.tar");
|
||||
const same = Path.join(eve.checkoutPath, "same.tar");
|
||||
await Fs.writeFile(other, "other bytes");
|
||||
await Fs.writeFile(same, "artifact bytes");
|
||||
const attestations = page
|
||||
.locator(".section")
|
||||
.filter({ hasText: /^Attestations/ });
|
||||
|
||||
await page.route(/\/pick_artifact_files$/, route =>
|
||||
route.fulfill({ json: [other] }),
|
||||
);
|
||||
await attestations.getByRole("button", { name: /Attest/ }).click();
|
||||
await expect(
|
||||
page.getByText("Your build has a different CID, so it was not attested."),
|
||||
).toBeVisible();
|
||||
|
||||
await page.route(/\/pick_artifact_files$/, route =>
|
||||
route.fulfill({ json: [same] }),
|
||||
);
|
||||
await attestations.getByRole("button", { name: /Attest/ }).click();
|
||||
await expect(attestations.getByText("eve")).toBeVisible();
|
||||
await expect(
|
||||
attestations.getByRole("button", { name: /Attest/ }),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test("a non-delegate cannot redact, edit or delete someone else's release", async ({
|
||||
page,
|
||||
peerManager,
|
||||
}) => {
|
||||
await openBobsArtifact(page, peerManager);
|
||||
|
||||
await expect(
|
||||
page.locator(".section").filter({ hasText: /^Attestations/ }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Redact" })).toBeHidden();
|
||||
await expect(page.getByRole("button", { name: "Delete" })).toBeHidden();
|
||||
const metadata = page.locator(".section").filter({ hasText: /^Metadata/ });
|
||||
await expect(metadata.getByRole("button", { name: "Add" })).toBeHidden();
|
||||
});
|
||||
@@ -47,12 +47,12 @@ async function stopSeeding(page: Page, name: string, deleteFiles: boolean) {
|
||||
await page.getByRole("link", { name: new RegExp(name) }).click({
|
||||
button: "right",
|
||||
});
|
||||
await page.getByRole("menuitem", { name: /Stop seeding/ }).click();
|
||||
await page.getByRole("menuitem", { name: /Unseed/ }).click();
|
||||
if (deleteFiles) {
|
||||
await page.getByText("Delete the files from local storage").click();
|
||||
}
|
||||
await waitForCommand(page, deleteFiles ? "clean" : "unseed", () =>
|
||||
page.getByRole("button", { name: /Stop seeding$/ }).click(),
|
||||
page.getByRole("button", { name: /Unseed$/ }).click(),
|
||||
);
|
||||
await expect(page.getByRole("link", { name: new RegExp(name) })).toBeHidden();
|
||||
}
|
||||
@@ -85,7 +85,7 @@ async function exists(path: string) {
|
||||
);
|
||||
}
|
||||
|
||||
test("stopping seeding keeps the repo's files", async ({
|
||||
test("unseeding keeps the repo's files", async ({
|
||||
page,
|
||||
peerManager,
|
||||
stateDir,
|
||||
@@ -97,7 +97,7 @@ test("stopping seeding keeps the repo's files", async ({
|
||||
expect(await exists(storedRepo(stateDir, rid))).toBe(true);
|
||||
});
|
||||
|
||||
test("stopping seeding can delete the repo's files", async ({
|
||||
test("unseeding can delete the repo's files", async ({
|
||||
page,
|
||||
peerManager,
|
||||
stateDir,
|
||||
|
||||
@@ -13,6 +13,7 @@ describe("basename", () => {
|
||||
["/home/alice/cat.png", "cat.png"],
|
||||
["C:\\Users\\alice\\cat.png", "cat.png"],
|
||||
["cat.png", "cat.png"],
|
||||
["/home/alice/build/", "build"],
|
||||
])("of %j is %j", (path, expected) => {
|
||||
expect(basename(path)).toBe(expected);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { dynamicInterval, resetDynamicInterval } from "@app/lib/interval";
|
||||
import { dynamicInterval, poll, resetDynamicInterval } from "@app/lib/interval";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
@@ -72,3 +72,41 @@ test("keys run independently", () => {
|
||||
expect(a).toHaveBeenCalledTimes(1);
|
||||
expect(b).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("poll runs at once and again a period after each run settles", async () => {
|
||||
let release: (() => void) | undefined;
|
||||
const task = vi.fn(() => new Promise<void>(resolve => (release = resolve)));
|
||||
const stop = poll(task, 100);
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A slow run never overlaps the next one.
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
|
||||
release?.();
|
||||
await vi.advanceTimersByTimeAsync(99);
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(task).toHaveBeenCalledTimes(2);
|
||||
stop();
|
||||
});
|
||||
|
||||
test("stopping a poll ends it and marks a pending run inactive", async () => {
|
||||
let active: (() => boolean) | undefined;
|
||||
let release: (() => void) | undefined;
|
||||
const task = vi.fn(
|
||||
(isActive: () => boolean) =>
|
||||
new Promise<void>(resolve => {
|
||||
active = isActive;
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const stop = poll(task, 100);
|
||||
expect(active?.()).toBe(true);
|
||||
|
||||
stop();
|
||||
expect(active?.()).toBe(false);
|
||||
release?.();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import type { Artifact } from "@bindings/cob/release/Artifact";
|
||||
import type { ReleaseCounts } from "@bindings/cob/release/ReleaseCounts";
|
||||
import type { Commit } from "@bindings/repo/Commit";
|
||||
import type { RepoRefs } from "@bindings/repo/RepoRefs";
|
||||
import type { Tag } from "@bindings/repo/Tag";
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
artifactView,
|
||||
attestedBy,
|
||||
canAttest,
|
||||
canEditMetadata,
|
||||
canonicalTags,
|
||||
delegatesFirst,
|
||||
displayMetadataValue,
|
||||
locationsByNode,
|
||||
matchCommits,
|
||||
parseMetadataValue,
|
||||
redactedByDelegate,
|
||||
releaseListScope,
|
||||
webLocations,
|
||||
} from "@app/lib/releases";
|
||||
|
||||
const me = "did:key:z6MkMe";
|
||||
const them = "did:key:z6MkThem";
|
||||
const delegate = "did:key:z6MkDelegate";
|
||||
const delegates = new Set([delegate]);
|
||||
|
||||
function artifact(cid: string, overrides: Partial<Artifact> = {}): Artifact {
|
||||
return {
|
||||
cid,
|
||||
name: cid,
|
||||
directory: false,
|
||||
author: { did: delegate },
|
||||
locations: [],
|
||||
attestations: [],
|
||||
redactions: [],
|
||||
redacted: false,
|
||||
metadata: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function counts(delegate: number, other: number): ReleaseCounts {
|
||||
return { delegate, delegateRedacted: 0, other, otherRedacted: 0 };
|
||||
}
|
||||
|
||||
describe("releaseListScope", () => {
|
||||
test("keeps a requested scope", () => {
|
||||
expect(releaseListScope("trusted", counts(0, 3))).toBe("trusted");
|
||||
});
|
||||
|
||||
test("opens on untrusted when only others released", () => {
|
||||
expect(releaseListScope(undefined, counts(0, 3))).toBe("untrusted");
|
||||
});
|
||||
|
||||
test.each([
|
||||
[1, 3],
|
||||
[0, 0],
|
||||
])("opens on trusted with %d delegate and %d other", (d, o) => {
|
||||
expect(releaseListScope(undefined, counts(d, o))).toBe("trusted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("artifactView", () => {
|
||||
const ours = artifact("ours");
|
||||
const ourRedacted = artifact("ours-redacted", { redacted: true });
|
||||
const theirs = artifact("theirs", { author: { did: them } });
|
||||
|
||||
test("shows the wanted scope", () => {
|
||||
const view = artifactView([ours, theirs], delegates, "untrusted", false);
|
||||
expect(view.scope).toBe("untrusted");
|
||||
expect(view.shown).toEqual([theirs]);
|
||||
expect(view.showFilters).toBe(true);
|
||||
});
|
||||
|
||||
test("falls back to the other scope when the wanted one is empty", () => {
|
||||
const view = artifactView([theirs], delegates, "trusted", false);
|
||||
expect(view.scope).toBe("untrusted");
|
||||
expect(view.shown).toEqual([theirs]);
|
||||
expect(view.showFilters).toBe(false);
|
||||
});
|
||||
|
||||
test("hides redacted artifacts unless asked", () => {
|
||||
const hidden = artifactView(
|
||||
[ours, ourRedacted],
|
||||
delegates,
|
||||
"trusted",
|
||||
false,
|
||||
);
|
||||
expect(hidden.shown).toEqual([ours]);
|
||||
expect(hidden.redactedCount).toBe(1);
|
||||
expect(hidden.counts.trusted).toBe(1);
|
||||
|
||||
const shown = artifactView([ours, ourRedacted], delegates, "trusted", true);
|
||||
expect(shown.shown).toEqual([ours, ourRedacted]);
|
||||
expect(shown.counts.trusted).toBe(2);
|
||||
});
|
||||
|
||||
test("keeps a scope whose artifacts are all redacted", () => {
|
||||
const view = artifactView(
|
||||
[ourRedacted, theirs],
|
||||
delegates,
|
||||
"trusted",
|
||||
false,
|
||||
);
|
||||
expect(view.scope).toBe("trusted");
|
||||
expect(view.shown).toEqual([]);
|
||||
expect(view.counts).toEqual({ trusted: 0, untrusted: 1 });
|
||||
});
|
||||
|
||||
test("counts redactions within the current scope only", () => {
|
||||
const theirRedacted = artifact("theirs-redacted", {
|
||||
author: { did: them },
|
||||
redacted: true,
|
||||
});
|
||||
const view = artifactView(
|
||||
[ours, theirs, theirRedacted],
|
||||
delegates,
|
||||
"trusted",
|
||||
false,
|
||||
);
|
||||
expect(view.redactedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("redactedByDelegate tells a delegate's redaction from others'", () => {
|
||||
const byAuthor = artifact("a", {
|
||||
author: { did: them },
|
||||
redactions: [{ user: { did: them }, reason: "" }],
|
||||
});
|
||||
const byDelegate = artifact("b", {
|
||||
redactions: [
|
||||
{ user: { did: them }, reason: "" },
|
||||
{ user: { did: delegate }, reason: "" },
|
||||
],
|
||||
});
|
||||
expect(redactedByDelegate(byAuthor, delegates)).toBe(false);
|
||||
expect(redactedByDelegate(byDelegate, delegates)).toBe(true);
|
||||
});
|
||||
|
||||
test("locationsByNode groups by node in first-seen order", () => {
|
||||
expect(
|
||||
locationsByNode([
|
||||
{ user: { did: them }, url: "a" },
|
||||
{ user: { did: me }, url: "b" },
|
||||
{ user: { did: them }, url: "c" },
|
||||
]),
|
||||
).toEqual([
|
||||
{ user: { did: them }, urls: ["a", "c"] },
|
||||
{ user: { did: me }, urls: ["b"] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("delegatesFirst moves delegates up and keeps order within groups", () => {
|
||||
const items = [them, delegate, me, `${delegate}2`];
|
||||
const result = delegatesFirst(
|
||||
items,
|
||||
did => did,
|
||||
new Set([delegate, `${delegate}2`]),
|
||||
);
|
||||
expect(result).toEqual([delegate, `${delegate}2`, them, me]);
|
||||
expect(items[0]).toBe(them);
|
||||
});
|
||||
|
||||
test("webLocations keeps http(s) URLs, delegates first", () => {
|
||||
const theirs = { user: { did: them }, url: "https://them.example/a" };
|
||||
const ours = { user: { did: delegate }, url: "HTTP://us.example/a" };
|
||||
expect(
|
||||
webLocations(
|
||||
[theirs, { user: { did: delegate }, url: "radiroh://" }, ours],
|
||||
delegates,
|
||||
),
|
||||
).toEqual([ours, theirs]);
|
||||
});
|
||||
|
||||
describe("attestedBy", () => {
|
||||
test("is zero without attestations", () => {
|
||||
expect(attestedBy([], delegates)).toBe(0);
|
||||
});
|
||||
|
||||
test("counts only delegates", () => {
|
||||
expect(
|
||||
attestedBy([{ did: them }, { did: delegate }, { did: me }], delegates),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
test("ignores attestations by others", () => {
|
||||
expect(attestedBy([{ did: them }, { did: me }], delegates)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canAttest", () => {
|
||||
test("lets others attest", () => {
|
||||
expect(canAttest(artifact("a"), me)).toBe(true);
|
||||
});
|
||||
|
||||
test.each<[string, Partial<Artifact>]>([
|
||||
["its author", { author: { did: me } }],
|
||||
["a trusted redaction", { redacted: true }],
|
||||
["an earlier attestation", { attestations: [{ did: me }] }],
|
||||
["your own redaction", { redactions: [{ user: { did: me }, reason: "" }] }],
|
||||
])("is barred by %s", (_, overrides) => {
|
||||
expect(canAttest(artifact("a", overrides), me)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canEditMetadata", () => {
|
||||
const theirs = artifact("a", { author: { did: them } });
|
||||
|
||||
test.each([
|
||||
[them, true],
|
||||
[delegate, true],
|
||||
[me, false],
|
||||
])("%s can edit: %s", (did, expected) => {
|
||||
expect(canEditMetadata(theirs, did, delegates)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("metadata values", () => {
|
||||
test.each<[string, unknown]>([
|
||||
["42", 42],
|
||||
["true", true],
|
||||
['{"a":1}', { a: 1 }],
|
||||
["x86_64", "x86_64"],
|
||||
['"quoted"', "quoted"],
|
||||
])("%s parses to %j and displays back", (input, value) => {
|
||||
expect(parseMetadataValue(input)).toEqual(value);
|
||||
expect(parseMetadataValue(displayMetadataValue(value))).toEqual(value);
|
||||
});
|
||||
|
||||
test("shows a string as it is, without JSON quotes", () => {
|
||||
expect(displayMetadataValue("x86_64")).toBe("x86_64");
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchCommits", () => {
|
||||
function commit(id: string, summary: string): Commit {
|
||||
const person = { name: "", email: "", time: 0 };
|
||||
return {
|
||||
id,
|
||||
summary,
|
||||
message: summary,
|
||||
author: person,
|
||||
committer: person,
|
||||
parents: [],
|
||||
};
|
||||
}
|
||||
const first = commit("abc123", "Add notes");
|
||||
const second = commit("def456", "Fix build");
|
||||
const pasted = commit("fff999", "Old change");
|
||||
|
||||
test.each([
|
||||
["", [first, second]],
|
||||
["ABC", [first]],
|
||||
[" fix ", [second]],
|
||||
["123", []],
|
||||
])("%j matches", (query, expected) => {
|
||||
expect(matchCommits([first, second], query, undefined)).toEqual(expected);
|
||||
});
|
||||
|
||||
test("offers a resolved commit outside the loaded history", () => {
|
||||
expect(matchCommits([first, second], "fff999", pasted)).toEqual([pasted]);
|
||||
});
|
||||
|
||||
test("keeps the matches when the resolved commit is among them", () => {
|
||||
expect(matchCommits([first, second], "", first)).toEqual([first, second]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonicalTags", () => {
|
||||
function tag(timestamp: number, tagOid?: string): Tag {
|
||||
return { oid: "c", timestamp, tagOid };
|
||||
}
|
||||
function refs(
|
||||
canonical: Record<string, Tag>,
|
||||
...remotes: Record<string, Tag>[]
|
||||
): RepoRefs {
|
||||
return {
|
||||
canonical: { branches: {}, tags: canonical },
|
||||
remotes: remotes.map((tags, i) => ({
|
||||
id: `${i}`,
|
||||
delegate: false,
|
||||
branches: {},
|
||||
tags,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
test("lists canonical tags, newest first", () => {
|
||||
expect(
|
||||
canonicalTags(refs({ v1: tag(1), v2: tag(2) })).map(t => t.name),
|
||||
).toEqual(["v2", "v1"]);
|
||||
});
|
||||
|
||||
test("ignores tags only a peer has", () => {
|
||||
expect(canonicalTags(refs({ v1: tag(1) }, { v2: tag(2) }))).toEqual([
|
||||
{ name: "v1", tag: tag(1) },
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps the canonical tag over a peer's annotated one", () => {
|
||||
const canonical = tag(1);
|
||||
expect(
|
||||
canonicalTags(refs({ v1: canonical }, { v1: tag(2, "evil") }))[0].tag,
|
||||
).toBe(canonical);
|
||||
});
|
||||
});
|
||||
@@ -82,6 +82,48 @@ describe("repoRouteToPath and repoUrlToRoute round trip", () => {
|
||||
view: "changes",
|
||||
},
|
||||
],
|
||||
[
|
||||
"/repos/RID/releases",
|
||||
{ resource: "repo.releases", rid, scope: undefined },
|
||||
],
|
||||
[
|
||||
"/repos/RID/releases?scope=untrusted",
|
||||
{ resource: "repo.releases", rid, scope: "untrusted" },
|
||||
],
|
||||
[
|
||||
"/repos/RID/releases?scope=trusted",
|
||||
{ resource: "repo.releases", rid, scope: "trusted" },
|
||||
],
|
||||
[
|
||||
"/repos/RID/releases/abc",
|
||||
{
|
||||
resource: "repo.release",
|
||||
rid,
|
||||
release: "abc",
|
||||
scope: undefined,
|
||||
artifactScope: undefined,
|
||||
},
|
||||
],
|
||||
[
|
||||
"/repos/RID/releases/abc?scope=trusted",
|
||||
{
|
||||
resource: "repo.release",
|
||||
rid,
|
||||
release: "abc",
|
||||
scope: "trusted",
|
||||
artifactScope: undefined,
|
||||
},
|
||||
],
|
||||
[
|
||||
"/repos/RID/releases/abc?scope=untrusted&artifacts=untrusted",
|
||||
{
|
||||
resource: "repo.release",
|
||||
rid,
|
||||
release: "abc",
|
||||
scope: "untrusted",
|
||||
artifactScope: "untrusted",
|
||||
},
|
||||
],
|
||||
])("%s", (template, route) => {
|
||||
const path = template
|
||||
.replace("RID", rid)
|
||||
@@ -131,6 +173,30 @@ describe("repoUrlToRoute", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("leaves the trusted artifact scope out of the URL", () => {
|
||||
expect(
|
||||
repoRouteToPath({
|
||||
resource: "repo.release",
|
||||
rid,
|
||||
release: "abc",
|
||||
scope: undefined,
|
||||
artifactScope: "trusted",
|
||||
}),
|
||||
).toBe(`/repos/${rid}/releases/abc`);
|
||||
});
|
||||
|
||||
test("ignores an unknown release scope", () => {
|
||||
expect(
|
||||
parse(`/repos/${rid}/releases/abc?scope=bogus&artifacts=bogus`),
|
||||
).toEqual({
|
||||
resource: "repo.release",
|
||||
rid,
|
||||
release: "abc",
|
||||
scope: undefined,
|
||||
artifactScope: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("treats a sha as a revision when a peer is given", () => {
|
||||
expect(parse(`/repos/${rid}/commits/remotes/${peer}/${sha}`)).toEqual({
|
||||
resource: "repo.commits",
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import type { Artifact } from "@bindings/cob/release/Artifact";
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { StagedArtifact } from "@app/lib/stageArtifacts";
|
||||
import {
|
||||
duplicatePicks,
|
||||
effective,
|
||||
planDetail,
|
||||
planSummary,
|
||||
planTitle,
|
||||
registerPlan,
|
||||
stageArtifacts,
|
||||
} from "@app/lib/stageArtifacts";
|
||||
|
||||
const me = "did:key:z6MkMe";
|
||||
const them = "did:key:z6MkThem";
|
||||
const delegate = "did:key:z6MkDelegate";
|
||||
|
||||
function picked(name: string, cid = "cid") {
|
||||
return {
|
||||
path: `/tmp/${name}`,
|
||||
name,
|
||||
digest: { cid, sizeBytes: 3, fileCount: 1, directory: false },
|
||||
};
|
||||
}
|
||||
|
||||
function artifact(overrides: Partial<Artifact> = {}): Artifact {
|
||||
return {
|
||||
cid: "cid",
|
||||
name: "build.tar",
|
||||
directory: false,
|
||||
author: { did: me },
|
||||
locations: [],
|
||||
attestations: [],
|
||||
redactions: [],
|
||||
redacted: false,
|
||||
metadata: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function stage(
|
||||
name: string,
|
||||
artifacts: Artifact[],
|
||||
{ nodeRunning = true, seeded = new Set<string>() } = {},
|
||||
) {
|
||||
const [item] = stageArtifacts([picked(name)], artifacts, {
|
||||
ownDid: me,
|
||||
delegates: new Set([delegate]),
|
||||
nodeRunning,
|
||||
seeded,
|
||||
});
|
||||
return item;
|
||||
}
|
||||
|
||||
describe("stageArtifacts", () => {
|
||||
test("registers and seeds a new artifact", () => {
|
||||
const item = stage("build.tar", []);
|
||||
expect(item).toMatchObject({ register: true, seed: true });
|
||||
expect(item.existing).toBeUndefined();
|
||||
});
|
||||
|
||||
test("registers a new artifact without seeding when the node is down", () => {
|
||||
expect(stage("build.tar", [], { nodeRunning: false })).toMatchObject({
|
||||
register: true,
|
||||
seed: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("registers a rename of your own artifact", () => {
|
||||
expect(stage("renamed.tar", [artifact()])).toMatchObject({
|
||||
register: true,
|
||||
existing: { name: "build.tar", keepsName: false },
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the name of someone else's artifact and only seeds", () => {
|
||||
const item = stage("renamed.tar", [artifact({ author: { did: them } })]);
|
||||
expect(item).toMatchObject({
|
||||
register: false,
|
||||
seed: true,
|
||||
existing: { name: "build.tar", keepsName: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("skips an artifact your node already seeds", () => {
|
||||
expect(
|
||||
stage("build.tar", [artifact()], { seeded: new Set(["cid"]) }),
|
||||
).toMatchObject({ register: false, seed: false });
|
||||
});
|
||||
|
||||
test("skips an existing artifact when the node is down", () => {
|
||||
expect(
|
||||
stage("build.tar", [artifact()], { nodeRunning: false }),
|
||||
).toMatchObject({ register: false, seed: false });
|
||||
});
|
||||
|
||||
test("carries the redaction reason of a redacted artifact", () => {
|
||||
const item = stage("build.tar", [
|
||||
artifact({
|
||||
author: { did: them },
|
||||
redacted: true,
|
||||
redactions: [
|
||||
{ user: { did: "did:key:z6MkOther" }, reason: "ignored" },
|
||||
{ user: { did: delegate }, reason: "malware" },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(item.existing?.redaction).toBe("malware");
|
||||
expect(effective(item, false)).toEqual({ register: false, seed: false });
|
||||
expect(effective(item, true)).toEqual({ register: false, seed: true });
|
||||
});
|
||||
|
||||
test("skips a later pick with the same contents", () => {
|
||||
const [first, second] = stageArtifacts(
|
||||
[picked("a.tar"), picked("b.tar")],
|
||||
[],
|
||||
{
|
||||
ownDid: me,
|
||||
delegates: new Set(),
|
||||
nodeRunning: true,
|
||||
seeded: new Set(),
|
||||
},
|
||||
);
|
||||
expect(first).toMatchObject({ register: true, seed: true });
|
||||
expect(first.duplicateOf).toBeUndefined();
|
||||
expect(second).toMatchObject({
|
||||
register: false,
|
||||
seed: false,
|
||||
duplicateOf: "a.tar",
|
||||
});
|
||||
expect(effective(second, true)).toEqual({ register: false, seed: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicatePicks", () => {
|
||||
test("maps later picks to the first with the same contents", () => {
|
||||
expect(
|
||||
duplicatePicks([
|
||||
picked("a.tar", "x"),
|
||||
picked("b.tar", "y"),
|
||||
picked("c.tar", "x"),
|
||||
picked("d.tar", "x"),
|
||||
]),
|
||||
).toEqual(
|
||||
new Map([
|
||||
["/tmp/c.tar", "a.tar"],
|
||||
["/tmp/d.tar", "a.tar"],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores picks still hashing", () => {
|
||||
expect(
|
||||
duplicatePicks([
|
||||
{ path: "/tmp/a.tar", name: "a.tar" },
|
||||
{ path: "/tmp/b.tar", name: "b.tar" },
|
||||
]).size,
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
function staged(overrides: Partial<StagedArtifact> = {}): StagedArtifact {
|
||||
return { ...picked("build.tar"), register: true, seed: false, ...overrides };
|
||||
}
|
||||
|
||||
const renamed = staged({
|
||||
existing: { name: "old.tar", keepsName: false },
|
||||
});
|
||||
const kept = staged({
|
||||
register: false,
|
||||
existing: { name: "old.tar", keepsName: true },
|
||||
});
|
||||
const redacted = staged({
|
||||
register: false,
|
||||
existing: { name: "old.tar", keepsName: false, redaction: "bad" },
|
||||
});
|
||||
const reseeded = staged({
|
||||
register: false,
|
||||
seed: true,
|
||||
existing: { name: "build.tar", keepsName: false },
|
||||
});
|
||||
const duplicate = staged({ register: false, duplicateOf: "a.tar" });
|
||||
|
||||
describe("registerPlan", () => {
|
||||
test("counts what each pick does", () => {
|
||||
const plan = registerPlan(
|
||||
[staged(), renamed, reseeded, kept, duplicate],
|
||||
false,
|
||||
);
|
||||
expect(plan).toMatchObject({
|
||||
newCount: 1,
|
||||
renamedCount: 1,
|
||||
seededCount: 1,
|
||||
skippedCount: 2,
|
||||
});
|
||||
expect(plan.acting).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("skips a redacted match unless it is included", () => {
|
||||
expect(registerPlan([redacted], false).skippedCount).toBe(1);
|
||||
expect(
|
||||
registerPlan([{ ...redacted, register: true }], true).acting,
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("is empty for no picks", () => {
|
||||
expect(registerPlan([], false)).toMatchObject({
|
||||
newCount: 0,
|
||||
skippedCount: 0,
|
||||
acting: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("planTitle", () => {
|
||||
test.each([
|
||||
[[kept], "Already registered"],
|
||||
[[staged()], "Register 1 artifact"],
|
||||
[[staged(), renamed], "Register 2 artifacts"],
|
||||
[[renamed], "Update 1 artifact"],
|
||||
])("%#", (items, expected) => {
|
||||
expect(planTitle(registerPlan(items, false))).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("planSummary", () => {
|
||||
test("lists only the counts that are not zero", () => {
|
||||
expect(planSummary(registerPlan([staged(), kept, duplicate], false))).toBe(
|
||||
"1 registered, 2 skipped",
|
||||
);
|
||||
});
|
||||
|
||||
test("is empty when nothing is staged", () => {
|
||||
expect(planSummary(registerPlan([], false))).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("planDetail", () => {
|
||||
function detail(item: StagedArtifact, includeRedacted = false) {
|
||||
const [planned] = registerPlan([item], includeRedacted).items;
|
||||
return planDetail(planned, includeRedacted);
|
||||
}
|
||||
|
||||
test.each([
|
||||
["a new file", staged(), "3 B"],
|
||||
[
|
||||
"a new folder",
|
||||
staged({
|
||||
digest: { cid: "c", sizeBytes: 3, fileCount: 2, directory: true },
|
||||
}),
|
||||
"2 files · 3 B",
|
||||
],
|
||||
[
|
||||
"a folder with one file",
|
||||
staged({
|
||||
digest: { cid: "c", sizeBytes: 3, fileCount: 1, directory: true },
|
||||
}),
|
||||
"1 file · 3 B",
|
||||
],
|
||||
["a repeated pick", duplicate, "same as “a.tar” · skipped"],
|
||||
["a redacted match", redacted, "redacted · skipped"],
|
||||
["a rename", renamed, "renames “old.tar”"],
|
||||
["someone else's name", kept, "stays “old.tar” · skipped"],
|
||||
[
|
||||
"an existing artifact to seed",
|
||||
reseeded,
|
||||
"already registered · will seed",
|
||||
],
|
||||
])("%s", (_, item, expected) => {
|
||||
expect(detail(item)).toBe(expected);
|
||||
});
|
||||
|
||||
test("describes an included redacted match like any other", () => {
|
||||
expect(detail(redacted, true)).toBe("already registered · skipped");
|
||||
});
|
||||
});
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
creditedCoAuthors,
|
||||
explorerHost,
|
||||
explorerLink,
|
||||
formatBytes,
|
||||
formatGitIdentity,
|
||||
formatRepositoryId,
|
||||
formatTimestamp,
|
||||
formatUptime,
|
||||
identityKey,
|
||||
isPublishableReview,
|
||||
parseNodeId,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
pluralize,
|
||||
revisionPosition,
|
||||
safeHttpUrl,
|
||||
shortenCids,
|
||||
truncateDid,
|
||||
unqualifyBranch,
|
||||
} from "@app/lib/utils";
|
||||
@@ -308,3 +311,74 @@ describe("commit credits", () => {
|
||||
).toEqual([carol]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBytes", () => {
|
||||
test.each([
|
||||
[0, "0 B"],
|
||||
[1023, "1023 B"],
|
||||
[1024, "1.0 KiB"],
|
||||
[1536, "1.5 KiB"],
|
||||
[10 * 1024 - 1, "10.0 KiB"],
|
||||
[10 * 1024, "10 KiB"],
|
||||
[1024 ** 2, "1.0 MiB"],
|
||||
[1024 ** 4, "1.0 TiB"],
|
||||
[1024 ** 5, "1024 TiB"],
|
||||
])("%d is %j", (bytes, expected) => {
|
||||
expect(formatBytes(bytes)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatUptime", () => {
|
||||
test.each([
|
||||
[0, "0s"],
|
||||
[59.9, "59s"],
|
||||
[60, "1m"],
|
||||
[3599, "59m"],
|
||||
[3600, "1h"],
|
||||
[86399, "23h"],
|
||||
[86400, "1d"],
|
||||
[86400 * 400, "400d"],
|
||||
])("%d is %j", (seconds, expected) => {
|
||||
expect(formatUptime(seconds)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shortenCids", () => {
|
||||
const prefix = "bafkr4i";
|
||||
|
||||
test("returns nothing for no ids", () => {
|
||||
expect(shortenCids([]).size).toBe(0);
|
||||
});
|
||||
|
||||
test("keeps the minimum head when ids differ early", () => {
|
||||
const a = "abcdefghij0123456789";
|
||||
const b = "zbcdefghij9876543210";
|
||||
const labels = shortenCids([a, b]);
|
||||
expect(labels.get(a)).toBe("abcdefg…456789");
|
||||
expect(labels.get(b)).toBe("zbcdefg…543210");
|
||||
});
|
||||
|
||||
test("grows the head past a shared prefix until ids differ", () => {
|
||||
const a = `${prefix}aaaa${"x".repeat(20)}111111`;
|
||||
const b = `${prefix}aaab${"x".repeat(20)}111111`;
|
||||
const labels = shortenCids([a, b]);
|
||||
expect(labels.get(a)).toBe(`${prefix}aaaa…111111`);
|
||||
expect(labels.get(b)).toBe(`${prefix}aaab…111111`);
|
||||
});
|
||||
|
||||
test("stops growing the head at the maximum", () => {
|
||||
const a = `${"p".repeat(20)}a${"x".repeat(10)}`;
|
||||
const b = `${"p".repeat(20)}b${"x".repeat(10)}`;
|
||||
expect(shortenCids([a, b]).get(a)).toBe(`${"p".repeat(16)}…xxxxxx`);
|
||||
});
|
||||
|
||||
test("shows an id in full when head and tail cover it", () => {
|
||||
const short = "abcdefghijklm";
|
||||
expect(shortenCids([short]).get(short)).toBe(short);
|
||||
});
|
||||
|
||||
test("labels repeated ids once", () => {
|
||||
const a = "abcdefghij0123456789";
|
||||
expect(shortenCids([a, a]).size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user