mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-18 19:30:27 +08:00
Separate the search seed from the viewed node
Picking a seed only set the stored preference and left the page on whatever node the URL named, so the node could not be changed from the UI at all. The two are now independent: you can browse a repo on one node while searching a larger index on another.
This commit is contained in:
+25
-2
@@ -9,6 +9,7 @@
|
||||
followSystemTheme,
|
||||
loadTheme,
|
||||
} from "@app/lib/appearance";
|
||||
import { activeUnloadedRouteStore, routeBaseUrl } from "@app/lib/router";
|
||||
import { determineSeed, selectedSeed } from "@app/views/nodes/SeedSelector";
|
||||
|
||||
import Button from "@app/components/Button.svelte";
|
||||
@@ -19,6 +20,9 @@
|
||||
return `${seed.scheme}://${seed.hostname}:${seed.port}`;
|
||||
}
|
||||
|
||||
// The node serving the page on screen. Undefined on routes that name no node
|
||||
// in their URL (explore, landing, docs), where the row is hidden.
|
||||
$: currentNode = routeBaseUrl($activeUnloadedRouteStore);
|
||||
$: searchSeed = $selectedSeed ?? determineSeed();
|
||||
|
||||
// Whether the selected seed exposes the search backend (drives the callout).
|
||||
@@ -99,11 +103,30 @@
|
||||
</style>
|
||||
|
||||
<div class="settings">
|
||||
{#if currentNode}
|
||||
<div class="seed-setting">
|
||||
<div class="seed-setting-row">
|
||||
<div>Current node</div>
|
||||
<div class="right">
|
||||
<SeedPicker
|
||||
baseUrl={currentNode}
|
||||
mode="node"
|
||||
ariaLabel="Current node selector"
|
||||
title="Switch the node serving this page" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="description">The node serving the page you’re viewing.</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="seed-setting">
|
||||
<div class="seed-setting-row">
|
||||
<div>Explore seed</div>
|
||||
<div>Search seed</div>
|
||||
<div class="right">
|
||||
<SeedPicker baseUrl={searchSeed} navigateOnApply={false} />
|
||||
<SeedPicker
|
||||
baseUrl={searchSeed}
|
||||
mode="search"
|
||||
ariaLabel="Search seed selector"
|
||||
title="Switch the seed used for search and explore" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="description">
|
||||
|
||||
@@ -56,9 +56,10 @@
|
||||
|
||||
// Seed selection lives in Settings now, so the header no longer shows a
|
||||
// general seed picker. It's kept only on error routes as an escape hatch to
|
||||
// a working seed, using the route's seed when known and otherwise falling
|
||||
// back to the currently selected one. (The notFound view renders its own
|
||||
// inline picker, so it's excluded here.)
|
||||
// a working node, using the route's node when known and otherwise falling
|
||||
// back to the currently selected seed, so the picker never disappears and
|
||||
// strand the page. (The notFound view renders its own inline picker, so it's
|
||||
// excluded here.)
|
||||
$: seedPickerBaseUrl =
|
||||
$activeRouteStore.resource === "error"
|
||||
? ($activeRouteStore.params.baseUrl ?? determineSeed())
|
||||
@@ -242,7 +243,7 @@
|
||||
<HeaderSearch baseUrl={searchSeed} />
|
||||
{/if}
|
||||
{#if seedPickerBaseUrl}
|
||||
<SeedPicker baseUrl={seedPickerBaseUrl} />
|
||||
<SeedPicker baseUrl={seedPickerBaseUrl} mode="node" />
|
||||
{/if}
|
||||
{#if isMarketingRoute}
|
||||
<span class="get-started">
|
||||
|
||||
+77
-9
@@ -340,16 +340,21 @@ export async function replace(newRoute: Route): Promise<void> {
|
||||
}
|
||||
|
||||
// Return a copy of `route` with any baseUrl-bearing field replaced by
|
||||
// `baseUrl`. Used by the SeedPicker so that selecting a different seed
|
||||
// `baseUrl`. Used by the SeedPicker so that selecting a different node
|
||||
// from e.g. an "unreachable node" error view retries the same logical
|
||||
// page (repo, user, node) on the new seed instead of looping on the
|
||||
// page (repo, user, node) on the new node instead of looping on the
|
||||
// original bad host. A not-found page is tied to a specific node/repo, so
|
||||
// it retries the node view on the chosen seed. Error and booting routes
|
||||
// carry no logical page to preserve, so they return to the app's home
|
||||
// (the marketing landing for `homepage: "landing"`, otherwise the node page),
|
||||
// which resolves the just-selected seed via `selectedSeed`. Explore routes have
|
||||
// no baseUrl field and resolve the active seed via `selectedSeed`, so they
|
||||
// pass through unchanged.
|
||||
// it retries the node view on the chosen node. Error and booting routes
|
||||
// carry no logical page to preserve, so they return to the app's home.
|
||||
//
|
||||
// Routes that name no node in their URL — explore and the marketing pages —
|
||||
// have nothing to rewrite and pass through unchanged. They are exactly the
|
||||
// routes `routeBaseUrl` returns `undefined` for, and callers must not reach
|
||||
// here with one: `applySeed` sets the search seed for those instead.
|
||||
//
|
||||
// The switch is exhaustive on purpose. A `default` arm would silently drop the
|
||||
// node picker on any route added later, which is how the release routes came
|
||||
// to be missing from it.
|
||||
export function withBaseUrl(route: Route, baseUrl: BaseUrl): Route {
|
||||
switch (route.resource) {
|
||||
case "nodes":
|
||||
@@ -366,6 +371,8 @@ export function withBaseUrl(route: Route, baseUrl: BaseUrl): Route {
|
||||
case "repo.issue":
|
||||
case "repo.patches":
|
||||
case "repo.patch":
|
||||
case "repo.releases":
|
||||
case "repo.release":
|
||||
return { ...route, node: baseUrl };
|
||||
case "notFound":
|
||||
return {
|
||||
@@ -375,8 +382,69 @@ export function withBaseUrl(route: Route, baseUrl: BaseUrl): Route {
|
||||
case "error":
|
||||
case "booting":
|
||||
return homeRoute();
|
||||
default:
|
||||
case "explore":
|
||||
case "explore.repos":
|
||||
case "landing":
|
||||
case "learn":
|
||||
case "install":
|
||||
case "guides":
|
||||
case "desktop":
|
||||
case "cli":
|
||||
case "principles":
|
||||
case "docs":
|
||||
return route;
|
||||
default:
|
||||
return utils.unreachable(route);
|
||||
}
|
||||
}
|
||||
|
||||
// The node named by the current URL, or `undefined` on routes that name none
|
||||
// (explore, marketing). The getter half of the `withBaseUrl` pair: a route
|
||||
// returns a baseUrl here exactly when `withBaseUrl` can point it at another
|
||||
// node.
|
||||
//
|
||||
// This reads the *unloaded* route, because that is what the URL says. When a
|
||||
// page fails to load — a repo that the chosen node doesn't have, say — the
|
||||
// loaded route becomes a notFound or error that may carry no node at all, yet
|
||||
// the URL still names one and the user still needs a way to switch off it.
|
||||
//
|
||||
// The bare `/nodes` home URL names no node but does display one, so it
|
||||
// resolves the same seed `loadNodeRoute` would.
|
||||
//
|
||||
// Exhaustive for the same reason as `withBaseUrl`; the two must agree.
|
||||
export function routeBaseUrl(route: Route): BaseUrl | undefined {
|
||||
switch (route.resource) {
|
||||
case "nodes":
|
||||
return route.params?.baseUrl ?? determineSeed();
|
||||
case "users":
|
||||
return route.baseUrl;
|
||||
case "repo.source":
|
||||
case "repo.history":
|
||||
case "repo.commit":
|
||||
case "repo.issues":
|
||||
case "repo.issue":
|
||||
case "repo.patches":
|
||||
case "repo.patch":
|
||||
case "repo.releases":
|
||||
case "repo.release":
|
||||
return route.node;
|
||||
case "notFound":
|
||||
case "error":
|
||||
return route.params.baseUrl;
|
||||
case "booting":
|
||||
case "explore":
|
||||
case "explore.repos":
|
||||
case "landing":
|
||||
case "learn":
|
||||
case "install":
|
||||
case "guides":
|
||||
case "desktop":
|
||||
case "cli":
|
||||
case "principles":
|
||||
case "docs":
|
||||
return undefined;
|
||||
default:
|
||||
return utils.unreachable(route);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,10 @@
|
||||
<div class="title-row txt-heading-m">
|
||||
<span>{title}</span>
|
||||
{#if baseUrl}
|
||||
<SeedPicker {baseUrl} />
|
||||
<!-- This page is always reached from a URL that names a node, and what
|
||||
it offers is "try this page on a different node" — a navigation,
|
||||
not a change to the stored search seed. -->
|
||||
<SeedPicker {baseUrl} mode="node" />
|
||||
{/if}
|
||||
</div>
|
||||
{#if description}
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
import config from "@app/lib/config";
|
||||
import { HttpdClient, ResponseError } from "@http-client";
|
||||
import {
|
||||
activeRouteStore,
|
||||
activeUnloadedRouteStore,
|
||||
push,
|
||||
routeBaseUrl,
|
||||
withBaseUrl,
|
||||
} from "@app/lib/router";
|
||||
import { isLocal, isOnion } from "@app/lib/utils";
|
||||
@@ -31,10 +31,13 @@
|
||||
import TextInput from "@app/components/TextInput.svelte";
|
||||
|
||||
export let baseUrl: BaseUrl;
|
||||
// When false (e.g. used in global settings), picking a seed updates the
|
||||
// explore/search seed without navigating the current page onto it — only
|
||||
// the explore listing reloads. The default navigates like a seed switch.
|
||||
export let navigateOnApply = true;
|
||||
// What picking a seed applies to:
|
||||
// "node" — move the current page onto the seed, changing the URL.
|
||||
// "search" — set the stored search seed, leaving the page where it is.
|
||||
// Required, so every call site has to say which of the two it means.
|
||||
export let mode: "node" | "search";
|
||||
export let ariaLabel = "Seed selector";
|
||||
export let title = "Switch seed";
|
||||
|
||||
const VALIDATION_TIMEOUT_MS = 10000;
|
||||
|
||||
@@ -123,30 +126,34 @@
|
||||
}
|
||||
|
||||
async function applySeed(seed: BaseUrl) {
|
||||
selectedSeed.set(seed);
|
||||
const isDefault = config.preferredSeeds.some(s => isEqual(s, seed));
|
||||
const isBookmarked = get(bookmarkedSeeds).some(s => isEqual(s, seed));
|
||||
if (!isDefault && !isBookmarked) {
|
||||
addBookmark(seed);
|
||||
}
|
||||
closeFocused();
|
||||
if (navigateOnApply) {
|
||||
|
||||
const route = get(activeUnloadedRouteStore);
|
||||
|
||||
if (mode === "node" && routeBaseUrl(route) !== undefined) {
|
||||
// Rewrite the route's baseUrl-bearing field so we retry the same
|
||||
// logical page (repo, user, node) on the new seed — important for
|
||||
// pages opened via a hardcoded seed URL such as the "node
|
||||
// unreachable" error view. Routes without a baseUrl field
|
||||
// (explore) pass through unchanged and rely on the updated
|
||||
// `selectedSeed` store; transient routes (notFound, error,
|
||||
// booting) fall back to the new seed's node view.
|
||||
const route = withBaseUrl(get(activeUnloadedRouteStore), seed);
|
||||
// logical page (repo, user, node) on the new node — important for
|
||||
// pages opened via a hardcoded node URL such as the "node
|
||||
// unreachable" error view.
|
||||
await push(withBaseUrl(route, seed));
|
||||
return;
|
||||
}
|
||||
|
||||
// Either the picker is setting the search seed, or it's in node mode on a
|
||||
// route whose URL names no node to switch (explore). Both come down to the
|
||||
// same thing: the search seed is the only node such a page reads.
|
||||
selectedSeed.set(seed);
|
||||
// Leave the current page where it is; only the explore listing reloads,
|
||||
// since it's the one view served by the search seed. Keyed off the
|
||||
// unloaded route rather than the loaded one, because a failed listing
|
||||
// loads as an `error` — exactly when the user is switching seeds.
|
||||
if (route.resource === "explore" || route.resource === "explore.repos") {
|
||||
await push(route);
|
||||
} else {
|
||||
// Settings context: don't move the current page onto the new seed;
|
||||
// just reload the explore listing when that's what's on screen.
|
||||
const resource = get(activeRouteStore).resource;
|
||||
if (resource === "explore" || resource === "explore.repos") {
|
||||
await push(get(activeUnloadedRouteStore));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,19 +210,6 @@
|
||||
closeFocused();
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoveBookmark(item: BaseUrl) {
|
||||
const wasActive = isEqual(baseUrl, item);
|
||||
removeBookmark(item);
|
||||
if (wasActive) {
|
||||
// Clear the explicit pick so `determineSeed()` falls back through
|
||||
// its normal resolution path (bucket-pick a preferred seed, or the
|
||||
// hardcoded fallback). Setting it to the first remaining bookmark
|
||||
// or preferred seed would orphan the store when neither exists, and
|
||||
// would also bypass the bucket-based load balancing.
|
||||
selectedSeed.set(undefined);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -309,8 +303,8 @@
|
||||
slot="toggle"
|
||||
let:toggle
|
||||
class="target"
|
||||
title="Switch seed used for explore"
|
||||
aria-label="Seed selector"
|
||||
{title}
|
||||
aria-label={ariaLabel}
|
||||
on:click={toggle}
|
||||
on:keydown={e => e.key === "Enter" && toggle()}
|
||||
role="button"
|
||||
@@ -352,7 +346,7 @@
|
||||
<IconButton
|
||||
ariaLabel="Remove bookmark"
|
||||
stopPropagation
|
||||
on:click={() => handleRemoveBookmark(item)}>
|
||||
on:click={() => removeBookmark(item)}>
|
||||
<Icon name="close" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
@@ -63,6 +63,14 @@ export function clearSeedFailure(seed: BaseUrl) {
|
||||
|
||||
export function removeBookmark(seed: BaseUrl) {
|
||||
bookmarkedSeeds.update(previous => previous.filter(x => !isEqual(x, seed)));
|
||||
if (isEqual(get(selectedSeed), seed)) {
|
||||
// Clear the explicit pick so `determineSeed()` falls back through its
|
||||
// normal resolution path (bucket-pick a preferred seed, or the hardcoded
|
||||
// fallback). Setting it to the first remaining bookmark or preferred seed
|
||||
// would orphan the store when neither exists, and would also bypass the
|
||||
// bucket-based load balancing.
|
||||
selectedSeed.set(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export function addBookmark(seed: BaseUrl) {
|
||||
|
||||
@@ -28,7 +28,7 @@ export function handleError(
|
||||
|
||||
return {
|
||||
resource: "notFound",
|
||||
params: { title: `${subject} not found` },
|
||||
params: { title: `${subject} not found`, baseUrl: route.node },
|
||||
};
|
||||
} else if (error instanceof ResponseError) {
|
||||
return {
|
||||
@@ -37,6 +37,7 @@ export function handleError(
|
||||
error,
|
||||
title: "Could not load this repository",
|
||||
description: `Make sure you are able to connect to the seed <a href="${url}">${url}</a>.`,
|
||||
baseUrl: route.node,
|
||||
},
|
||||
};
|
||||
} else if (error instanceof ResponseParseError) {
|
||||
@@ -46,6 +47,7 @@ export function handleError(
|
||||
error,
|
||||
title: "Could not parse the request",
|
||||
description: error.description,
|
||||
baseUrl: route.node,
|
||||
},
|
||||
};
|
||||
} else if (
|
||||
@@ -69,6 +71,7 @@ export function handleError(
|
||||
title: "Could not load this repository",
|
||||
description:
|
||||
"You stumbled on an unknown error, we aren’t exactly sure what happened.",
|
||||
baseUrl: route.node,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,12 +58,15 @@ test("seed picker lives in the settings dropdown on explore", async ({
|
||||
0,
|
||||
);
|
||||
|
||||
// It's reached through the settings dropdown as the "Explore seed" setting.
|
||||
// It's reached through the settings dropdown as the "Search seed" setting.
|
||||
await page.getByRole("button", { name: "Settings" }).click();
|
||||
await expect(page.getByText("Explore seed")).toBeVisible();
|
||||
await expect(page.getByText("Search seed")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Seed selector" }),
|
||||
page.getByRole("button", { name: "Search seed selector" }),
|
||||
).toContainText("localhost");
|
||||
|
||||
// Explore names no node in its URL, so there's nothing to switch.
|
||||
await expect(page.getByText("Current node")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("explore shows an error when no seed can be reached", async ({ page }) => {
|
||||
|
||||
+26
-1
@@ -134,7 +134,9 @@ test("edit seed bookmarks", async ({ page }) => {
|
||||
page.getByRole("button", { name: "Settings" }).click();
|
||||
await openSettings();
|
||||
|
||||
const seedSelector = page.getByRole("button", { name: "Seed selector" });
|
||||
const seedSelector = page.getByRole("button", {
|
||||
name: "Search seed selector",
|
||||
});
|
||||
|
||||
// Add a custom seed via the seed selector.
|
||||
await seedSelector.click();
|
||||
@@ -167,3 +169,26 @@ test("edit seed bookmarks", async ({ page }) => {
|
||||
page.getByRole("button", { name: "Remove bookmark", exact: true }),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("node can be switched from a repo the node doesn't have", async ({
|
||||
page,
|
||||
}) => {
|
||||
// A syntactically valid RID that localhost doesn't seed, so httpd answers
|
||||
// 404 rather than failing to connect.
|
||||
await page.goto("/nodes/localhost/rad:z4BwwjPCFNVP27FwVbDFgwVwkjciZ", {
|
||||
waitUntil: "networkidle",
|
||||
});
|
||||
|
||||
await expect(page.getByText("Repository not found")).toBeVisible();
|
||||
|
||||
// The URL still names a node, so the page must offer a way off it.
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Seed selector" }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Settings" }).click();
|
||||
await expect(page.getByText("Current node")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Current node selector" }),
|
||||
).toContainText("localhost");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user