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} + {#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 @@ + + + + + diff --git a/src/modals/OpenInBrowser.svelte b/src/modals/UnopenableLink.svelte similarity index 54% rename from src/modals/OpenInBrowser.svelte rename to src/modals/UnopenableLink.svelte index 23fe6d557..0ab34364d 100644 --- a/src/modals/OpenInBrowser.svelte +++ b/src/modals/UnopenableLink.svelte @@ -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(); 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",