mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-29 16:20:59 +08:00
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:
+1
-1
@@ -201,7 +201,7 @@
|
||||
void listenForDeepLinks();
|
||||
try {
|
||||
await invoke("authenticate");
|
||||
void router.loadFromLocation().then(deepLinksReady);
|
||||
void router.loadFromLocation().finally(deepLinksReady);
|
||||
dynamicInterval(
|
||||
"auth",
|
||||
checkAuth,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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" } });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user