Allow seeding a missing repo from a reference

Following a link to a repo we don't have only led to the explorer, but
the reason it's missing is usually that we haven't seeded it yet.
A deep link that can't be opened now says so instead of doing nothing.
This commit is contained in:
Rūdolfs Ošiņš
2026-10-06 11:16:14 +02:00
parent e010517b79
commit e793ed5f01
12 changed files with 367 additions and 95 deletions
+1 -1
View File
@@ -201,7 +201,7 @@
void listenForDeepLinks();
try {
await invoke("authenticate");
void router.loadFromLocation().then(deepLinksReady);
void router.loadFromLocation().finally(deepLinksReady);
dynamicInterval(
"auth",
checkAuth,
+34 -19
View File
@@ -26,6 +26,7 @@
import type { Config } from "@bindings/config/Config";
import { writeToClipboard } from "@app/lib/invoke";
import { seedRepo } from "@app/lib/seedRepo";
import { explorerHost } from "@app/lib/utils";
import ContextMenu from "@app/components/ContextMenu.svelte";
@@ -35,33 +36,37 @@
x: number;
y: number;
target: HTMLElement;
url: string;
url?: string;
uri: string;
/** A repo that isn't on this node, offered for seeding. */
seedRid?: string;
config: Config;
onclose: () => void;
}
const { x, y, target, url, uri, config, onclose }: Props = $props();
const { x, y, target, url, uri, seedRid, config, onclose }: Props = $props();
</script>
<ContextMenu {x} {y} {target} {onclose}>
<a
class="menu-item"
role="menuitem"
href={url}
target="_blank"
rel="noreferrer noopener">
<Icon name="open-external" />
Open in {explorerHost(config)}
</a>
<button
class="menu-item"
role="menuitem"
onclick={() => writeToClipboard(url)}>
<Icon name="link" />
Copy link to {explorerHost(config)}
</button>
<div class="menu-separator"></div>
{#if url}
<a
class="menu-item"
role="menuitem"
href={url}
target="_blank"
rel="noreferrer noopener">
<Icon name="open-external" />
Open in {explorerHost(config)}
</a>
<button
class="menu-item"
role="menuitem"
onclick={() => writeToClipboard(url)}>
<Icon name="link" />
Copy link to {explorerHost(config)}
</button>
<div class="menu-separator"></div>
{/if}
<button
class="menu-item"
role="menuitem"
@@ -69,4 +74,14 @@
<Icon name="copy" />
Copy rad: URI
</button>
{#if seedRid}
<div class="menu-separator"></div>
<button
class="menu-item"
role="menuitem"
onclick={() => void seedRepo(seedRid).catch(console.error)}>
<Icon name="seed" />
Seed repository
</button>
{/if}
</ContextMenu>
+23 -9
View File
@@ -20,6 +20,7 @@
} from "@app/lib/mentions";
import type { Route } from "@app/lib/router";
import { push, routeToPath } from "@app/lib/router";
import { fetchingRepos } from "@app/lib/seedRepo";
import {
formatOid,
issueStatusColor,
@@ -69,20 +70,32 @@
const repoName = $derived(resolvedRepoName ?? fallback);
const fetching = $derived(
target.type !== "node" && $fetchingRepos.includes(target.rid),
);
let repoLocal: boolean | undefined = $state(undefined);
const seedRid = $derived(
repoLocal === false && !fetching && target.type !== "node"
? target.rid
: undefined,
);
$effect(() => {
if (target.type !== "repo") return;
if (target.type === "node" || fetching) return;
const isRepo = target.type === "repo";
let cancelled = false;
void cachedRepoById(target.rid)
.then(result => {
if (cancelled) return;
if (!result) {
missing = true;
return;
}
resolvedRepoName = result.payloads["xyz.radicle.project"]?.data.name;
repoLocal = result !== null;
if (!isRepo) return;
missing = !result;
resolvedRepoName = result?.payloads["xyz.radicle.project"]?.data.name;
})
.catch(() => {
if (!cancelled) missing = true;
if (cancelled) return;
repoLocal = false;
if (isRepo) missing = true;
});
return () => {
cancelled = true;
@@ -164,7 +177,7 @@
let menu: MenuPosition | undefined = $state(undefined);
function openMenu(event: MouseEvent) {
if (explorerHref) menu = menuPosition(event);
if (explorerHref || seedRid) menu = menuPosition(event);
}
function handleClick(event: MouseEvent) {
@@ -377,13 +390,14 @@
</a>
{/if}
{#if menu && explorerHref && config}
{#if menu && config}
<ExplorerMenu
x={menu.x}
y={menu.y}
target={menu.target}
url={explorerHref}
uri={entityUri(target)}
{seedRid}
{config}
onclose={() => (menu = undefined)} />
{/if}
+13 -6
View File
@@ -5,6 +5,7 @@
import { describeLink, referenceRoute } from "@app/lib/mentions";
import { formatReference, parseReference } from "@app/lib/radUri";
import { push, routeToPath } from "@app/lib/router";
import { fetchingRepos } from "@app/lib/seedRepo";
import { explorerLink } from "@app/lib/utils";
import ExplorerMenu, {
@@ -42,11 +43,15 @@
});
const route = $derived(reference && referenceRoute(reference));
let local = $state(false);
const rid = $derived(
reference?.type === "uri" ? `rad:${reference.uri.repo}` : undefined,
);
const fetching = $derived(rid !== undefined && $fetchingRepos.includes(rid));
let local: boolean | undefined = $state(undefined);
$effect(() => {
if (!route) return;
if (!rid || fetching) return;
let cancelled = false;
void cachedRepoById(route.rid)
void cachedRepoById(rid)
.then(repo => {
if (!cancelled) local = repo !== null;
})
@@ -57,8 +62,9 @@
cancelled = true;
};
});
const inApp = $derived(local && route !== undefined);
const inApp = $derived(local === true && route !== undefined);
const linkHref = $derived(inApp && route ? routeToPath(route) : url);
const seedRid = $derived(local === false && !fetching ? rid : undefined);
function handleClick(event: MouseEvent) {
if (!inApp || !route) return;
@@ -69,7 +75,7 @@
let menu: MenuPosition | undefined = $state(undefined);
function openMenu(event: MouseEvent) {
if (url) menu = menuPosition(event);
if (url || seedRid) menu = menuPosition(event);
}
</script>
@@ -95,13 +101,14 @@
{/if}{label}
</a>
{#if menu && url && config}
{#if menu && config}
<ExplorerMenu
x={menu.x}
y={menu.y}
target={menu.target}
{url}
uri={reference ? formatReference(reference) : href}
{seedRid}
{config}
onclose={() => (menu = undefined)} />
{/if}
+10 -6
View File
@@ -76,6 +76,7 @@
import { show } from "@app/lib/modal";
import { repoListScope } from "@app/lib/repoListScope";
import * as router from "@app/lib/router";
import { seededRepos, seedRepo, setFetchingRepos } from "@app/lib/seedRepo";
import {
ariaKeyShortcuts,
modifierHeld,
@@ -346,6 +347,13 @@
setOrder: rids => withPinAnimation(() => (pinnedRepoIds.value = rids)),
});
$effect(() => setFetchingRepos(seededNotReplicated));
$effect(() => {
if ($seededRepos === 0) return;
untrack(() => void reloadRepos());
});
async function reloadRepos() {
invalidateReposSummary();
[repos, seededNotReplicated] = await Promise.all([
@@ -393,15 +401,11 @@
// network. Nothing is destroyed, so it runs straight off the menu item.
async function startSeeding(rid: string) {
try {
await invoke<null>("seed", { rid });
await seedRepo(rid);
} catch (error) {
console.error("Seed failed", error);
} finally {
await reloadRepos().catch(error =>
console.error("Reloading repos failed", error),
);
await refreshActiveRepo(rid);
}
await refreshActiveRepo(rid);
}
// The pending-fetch list's trash button, which stays quiet on failure. The
+1 -1
View File
@@ -24,7 +24,7 @@ export async function checkAuth() {
import.meta.env.VITE_AUTH_LONG_DELAY || 30_000,
);
if (get(router.activeRouteStore).resource === "booting") {
void router.push({ resource: "inbox" }).then(deepLinksReady);
void router.push({ resource: "inbox" }).finally(deepLinksReady);
} else {
deepLinksReady();
}
+36 -15
View File
@@ -9,15 +9,21 @@ import { parseRadUri } from "@app/lib/radUri";
import * as router from "@app/lib/router";
import { explorerHost, explorerLink } from "@app/lib/utils";
import OpenInBrowser from "@app/modals/OpenInBrowser.svelte";
import NotOnThisNode from "@app/modals/NotOnThisNode.svelte";
import UnopenableLink from "@app/modals/UnopenableLink.svelte";
export type DeepLinkTarget =
{ type: "route"; route: RepoRoute } | { type: "external"; url: string };
| { type: "route"; route: RepoRoute }
| { type: "external"; url: string }
| { type: "missing"; rid: string; url?: string };
export function parseDeepLink(
link: string,
): { type: "uri"; uri: RadUri } | undefined {
const uri = parseRadUri(link.trim());
// A RID copied as `rad:z…` is easily pasted after `rad:///`.
const uri = parseRadUri(
link.trim().replace(/^rad:(?:\/\/\/?)?rad:/i, "rad:"),
);
return uri && { type: "uri", uri };
}
@@ -27,10 +33,13 @@ export function deepLinkTarget(
local: boolean,
config: Config,
): DeepLinkTarget | undefined {
const route = local ? referenceRoute(reference) : undefined;
if (route) return { type: "route", route };
const url = explorerLink(reference, config);
if (!local && reference.type === "uri") {
return { type: "missing", rid: `rad:${reference.uri.repo}`, url };
}
const route = referenceRoute(reference);
if (route) return { type: "route", route };
return url ? { type: "external", url } : undefined;
}
@@ -66,24 +75,36 @@ export function createLinkQueue(open: (link: string) => Promise<void>) {
};
}
// An untrusted link only ever navigates; it never fetches or seeds, and
// leaving the app for the browser needs the user's confirmation.
// An untrusted link only ever navigates; seeding a repo or leaving the app
// for the browser needs the user's confirmation.
async function openDeepLink(link: string) {
const reference = parseDeepLink(link);
if (!reference) return;
if (!reference) {
show({ component: UnopenableLink, props: { link, reason: "invalid" } });
return;
}
const [config, repo] = await Promise.all([
const [config, local] = await Promise.all([
cachedConfig(),
cachedRepoById(`rad:${reference.uri.repo}`).catch(() => undefined),
cachedRepoById(`rad:${reference.uri.repo}`).then(
repo => repo !== null,
() => false,
),
]);
const target = deepLinkTarget(reference, Boolean(repo), config);
const target = deepLinkTarget(reference, local, config);
if (target?.type === "route") {
await router.push(target.route);
} else if (target?.type === "external") {
} else if (target) {
show({
component: OpenInBrowser,
props: { url: target.url, host: explorerHost(config) },
component: NotOnThisNode,
props: {
url: target.url,
host: explorerHost(config),
rid: target.type === "missing" ? target.rid : undefined,
},
});
} else {
show({ component: UnopenableLink, props: { link, reason: "no-page" } });
}
}
+20
View File
@@ -1,3 +1,6 @@
import { writable } from "svelte/store";
import { invalidateReposSummary, invoke } from "@app/lib/invoke";
import { parseRepositoryId } from "@app/lib/utils";
// Validates a RID typed into the "Seed a repo" form. The form accepts a bare
@@ -21,3 +24,20 @@ export function seedTarget(
}
return { rid };
}
const seeded = writable(0);
const fetching = writable<string[]>([]);
// Bumped whenever a repo is seeded from outside the sidebar, which only polls
// while something is already being fetched.
export const seededRepos = { subscribe: seeded.subscribe };
// Seeded repos that haven't been fetched yet, as the sidebar last polled them.
export const fetchingRepos = { subscribe: fetching.subscribe };
export const setFetchingRepos = fetching.set;
export async function seedRepo(rid: string): Promise<void> {
await invoke<null>("seed", { rid });
invalidateReposSummary();
seeded.update(count => count + 1);
}
+135
View File
@@ -0,0 +1,135 @@
<script lang="ts">
import { hide } from "@app/lib/modal";
import { seedRepo } from "@app/lib/seedRepo";
import Button from "@app/components/Button.svelte";
import Icon from "@app/components/Icon.svelte";
interface Props {
url?: string;
host: string;
/** Set when the repo itself is missing, which seeding can fix. */
rid?: string;
}
const { url, host, rid }: Props = $props();
let seeding = $state(false);
let error = $state<string | undefined>(undefined);
async function seed() {
if (!rid || seeding) return;
seeding = true;
error = undefined;
try {
await seedRepo(rid);
hide();
} catch (e) {
error = e instanceof Error ? e.message : "Unable to seed this repo.";
} finally {
seeding = false;
}
}
async function open() {
if (!url) return;
const { open } = await import("@tauri-apps/plugin-shell");
await open(url);
hide();
}
</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;
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);
}
.url {
font: var(--txt-code-regular);
color: var(--color-text-tertiary);
word-break: break-all;
}
.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">Not on this node</span>
</div>
<div class="body">
{#if rid}
<span>
This repo isn't on this node. Seeding it fetches it from the network and
keeps it replicated{#if url}; it can also be viewed on {host}{/if}.
</span>
<span class="url">{rid}</span>
{:else}
<span>
This app has no page for the link, but it can be viewed on {host}.
</span>
<span class="url">{url}</span>
{/if}
{#if error}
<span class="error">{error}</span>
{/if}
</div>
<div class="actions">
<Button variant="outline" onclick={hide}>Cancel</Button>
{#if url}
<Button variant={rid ? "outline" : "ghost"} onclick={() => void open()}>
<span class="confirm-label">
<Icon name="open-external" />
Open in {host}
</span>
</Button>
{/if}
{#if rid}
<Button variant="ghost" disabled={seeding} onclick={() => void seed()}>
<span class="confirm-label">
<Icon name="seed" />
{seeding ? "Seeding…" : "Seed"}
</span>
</Button>
{/if}
</div>
</div>
@@ -2,20 +2,13 @@
import { hide } from "@app/lib/modal";
import Button from "@app/components/Button.svelte";
import Icon from "@app/components/Icon.svelte";
interface Props {
url: string;
host: string;
link: string;
reason: "invalid" | "no-page";
}
const { url, host }: Props = $props();
async function open() {
const { open } = await import("@tauri-apps/plugin-shell");
await open(url);
hide();
}
const { link, reason }: Props = $props();
</script>
<style>
@@ -31,7 +24,6 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.5rem;
height: 3.25rem;
flex-shrink: 0;
@@ -49,7 +41,7 @@
font: var(--txt-body-m-regular);
color: var(--color-text-secondary);
}
.url {
.link {
font: var(--txt-code-regular);
color: var(--color-text-tertiary);
word-break: break-all;
@@ -57,41 +49,27 @@
.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">Open in browser</span>
<Button variant="naked" onclick={hide}>
<span style:color="var(--color-text-tertiary)">
<Icon name="close" />
</span>
</Button>
<span class="title">Can't open link</span>
</div>
<div class="body">
<span>
A link was opened for something that isn't on this node. It can be viewed
on {host} instead.
{#if reason === "invalid"}
This isn't a valid rad: link.
{:else}
Neither this app nor the explorer has a page for this link.
{/if}
</span>
<span class="url">{url}</span>
<span class="link">{link}</span>
</div>
<div class="actions">
<Button variant="outline" onclick={hide}>Cancel</Button>
<Button variant="ghost" onclick={() => void open()}>
<span class="confirm-label">
<Icon name="open-external" />
Open in {host}
</span>
</Button>
<Button variant="outline" onclick={hide}>Close</Button>
</div>
</div>
+63 -1
View File
@@ -1,13 +1,18 @@
import type { Page } from "@playwright/test";
import type { RadiclePeer } from "@tests/support/peerManager.js";
import { createProject } from "@tests/support/collaboration.js";
import {
createCollaborators,
createProject,
} from "@tests/support/collaboration.js";
import {
expect,
reload,
test,
useBackend,
waitForCommand,
} from "@tests/support/fixtures.js";
import { createRepo } from "@tests/support/repo.js";
async function openIssue(page: Page, peer: RadiclePeer) {
const project = await createProject(peer);
@@ -236,3 +241,60 @@ test("every reference type renders and links where it should", async ({
await link(`${repo}/issues?status=all`).click();
await expect(page).toHaveURL(/\/issues\?status=all$/);
});
test("a repo that isn't here can be seeded from its chip", async ({
page,
peerManager,
}) => {
const { bob, eve } = await createCollaborators(peerManager);
const { rid: garden } = await createRepo(eve, { name: "garden" });
await bob.startHttpd();
await useBackend(page, bob);
await openIssue(page, bob);
await commentBox(page).fill(`[garden](${garden})`);
await comment(page);
await reload(page);
const chip = rendered(page).last().locator("a.mention.unresolved");
await expect(chip).toContainText("garden");
await chip.click({ button: "right" });
await waitForCommand(page, "seed", () =>
page.getByRole("menuitem", { name: "Seed repository" }).click(),
);
await expect(
page.getByRole("navigation").getByRole("link", { name: /garden/ }),
).toBeVisible({ timeout: 20_000 });
});
test("a link to a repo that isn't here can be seeded without an explorer page", async ({
page,
peerManager,
}) => {
const { bob, eve } = await createCollaborators(peerManager);
const { rid: garden } = await createRepo(eve, { name: "garden" });
await bob.startHttpd();
await useBackend(page, bob);
await openIssue(page, bob);
await commentBox(page).fill(
`[garden tree](${garden}/tree/${"f".repeat(40)})`,
);
await comment(page);
await reload(page);
await rendered(page)
.last()
.locator("a", { hasText: "garden tree" })
.click({ button: "right" });
await expect(page.getByRole("menuitem")).toHaveText([
"Copy rad: URI",
"Seed repository",
]);
await waitForCommand(page, "seed", () =>
page.getByRole("menuitem", { name: "Seed repository" }).click(),
);
await expect(
page.getByRole("navigation").getByRole("link", { name: /garden/ }),
).toBeVisible({ timeout: 20_000 });
});
+19 -3
View File
@@ -23,7 +23,14 @@ function target(link: string, local = true) {
}
describe("parseDeepLink", () => {
test.each([rid, `rad:///${repo}`, ` ${rid}\n`])("accepts %j", link => {
test.each([
rid,
`rad:///${repo}`,
` ${rid}\n`,
`rad:///${rid}`,
`rad://${rid}`,
`rad:${rid}`,
])("accepts %j", link => {
expect(parseDeepLink(link)).toEqual({ type: "uri", uri: { repo } });
});
@@ -59,13 +66,22 @@ describe("deepLinkTarget", () => {
expect(target(link)).toEqual({ type: "route", route });
});
test("opens the explorer when the repo is not here", () => {
test("offers to seed a repo that is not here", () => {
expect(target(`${rid}/cob/xyz.radicle.issue/${oid}`, false)).toEqual({
type: "external",
type: "missing",
rid,
url: `${explorer}/issues/${oid}`,
});
});
test("offers to seed a repo that is not here when nothing has a page", () => {
expect(target(`${rid}/tree/${oid}`, false)).toEqual({
type: "missing",
rid,
url: undefined,
});
});
test("opens the explorer for a page the app lacks", () => {
expect(target(`${rid}/cob/dev.radicle.artifact/${oid}`)).toEqual({
type: "external",