diff --git a/src/App.svelte b/src/App.svelte
index e0b772518..944a72aed 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -201,7 +201,7 @@
void listenForDeepLinks();
try {
await invoke("authenticate");
- void router.loadFromLocation().then(deepLinksReady);
+ void router.loadFromLocation().finally(deepLinksReady);
dynamicInterval(
"auth",
checkAuth,
diff --git a/src/components/ExplorerMenu.svelte b/src/components/ExplorerMenu.svelte
index 244b90c70..6dbf9e167 100644
--- a/src/components/ExplorerMenu.svelte
+++ b/src/components/ExplorerMenu.svelte
@@ -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();
-
-
- Open in {explorerHost(config)}
-
-
-
+ {#if url}
+
+
+ Open in {explorerHost(config)}
+
+
+
+ {/if}
Copy rad: URI
+ {#if seedRid}
+
+
+ {/if}
diff --git a/src/components/Mention.svelte b/src/components/Mention.svelte
index 6df9b6f3d..eec73d403 100644
--- a/src/components/Mention.svelte
+++ b/src/components/Mention.svelte
@@ -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 @@
{/if}
-{#if menu && explorerHref && config}
+{#if menu && config}
(menu = undefined)} />
{/if}
diff --git a/src/components/ReferenceLink.svelte b/src/components/ReferenceLink.svelte
index d9b73497b..aa16c422c 100644
--- a/src/components/ReferenceLink.svelte
+++ b/src/components/ReferenceLink.svelte
@@ -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);
}
@@ -95,13 +101,14 @@
{/if}{label}
-{#if menu && url && config}
+{#if menu && config}
(menu = undefined)} />
{/if}
diff --git a/src/components/SidebarRepoList.svelte b/src/components/SidebarRepoList.svelte
index 08f4040c1..b29758c96 100644
--- a/src/components/SidebarRepoList.svelte
+++ b/src/components/SidebarRepoList.svelte
@@ -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("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
diff --git a/src/lib/auth.svelte.ts b/src/lib/auth.svelte.ts
index b192087bc..84e3a9f27 100644
--- a/src/lib/auth.svelte.ts
+++ b/src/lib/auth.svelte.ts
@@ -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();
}
diff --git a/src/lib/deepLink.ts b/src/lib/deepLink.ts
index 014a2904c..80c84d311 100644
--- a/src/lib/deepLink.ts
+++ b/src/lib/deepLink.ts
@@ -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) {
};
}
-// 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" } });
}
}
diff --git a/src/lib/seedRepo.ts b/src/lib/seedRepo.ts
index 3d74793d8..cf4e8ca5a 100644
--- a/src/lib/seedRepo.ts
+++ b/src/lib/seedRepo.ts
@@ -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([]);
+
+// 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 {
+ await invoke("seed", { rid });
+ invalidateReposSummary();
+ seeded.update(count => count + 1);
+}
diff --git a/src/modals/NotOnThisNode.svelte b/src/modals/NotOnThisNode.svelte
new file mode 100644
index 000000000..744488ffb
--- /dev/null
+++ b/src/modals/NotOnThisNode.svelte
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+ Not on this node
+
+
+
+ {#if rid}
+
+ 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}.
+
+ {rid}
+ {:else}
+
+ This app has no page for the link, but it can be viewed on {host}.
+
+ {url}
+ {/if}
+ {#if error}
+ {error}
+ {/if}
+
- 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}
- {url}
+ {link}
-
-
+
diff --git a/tests/e2e/repo/mentions.spec.ts b/tests/e2e/repo/mentions.spec.ts
index f6cd4e6ea..ad1e2ba5a 100644
--- a/tests/e2e/repo/mentions.spec.ts
+++ b/tests/e2e/repo/mentions.spec.ts
@@ -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 });
+});
diff --git a/tests/unit/deepLink.test.ts b/tests/unit/deepLink.test.ts
index 3f89211df..db430b73e 100644
--- a/tests/unit/deepLink.test.ts
+++ b/tests/unit/deepLink.test.ts
@@ -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",