diff --git a/src/components/Header.svelte b/src/components/Header.svelte
index b1281df75..52e46639b 100644
--- a/src/components/Header.svelte
+++ b/src/components/Header.svelte
@@ -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 @@
{/if}
{#if seedPickerBaseUrl}
-
+
{/if}
{#if isMarketingRoute}
diff --git a/src/lib/router.ts b/src/lib/router.ts
index 9dab32fb8..f6aace796 100644
--- a/src/lib/router.ts
+++ b/src/lib/router.ts
@@ -340,16 +340,21 @@ export async function replace(newRoute: Route): Promise {
}
// 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);
}
}
diff --git a/src/views/NotFound.svelte b/src/views/NotFound.svelte
index bc3336b61..d70411d1b 100644
--- a/src/views/NotFound.svelte
+++ b/src/views/NotFound.svelte
@@ -33,7 +33,10 @@
{title}
{#if baseUrl}
-
+
+
{/if}
{#if description}
diff --git a/src/views/explore/SeedPicker.svelte b/src/views/explore/SeedPicker.svelte
index de82fc824..f1fd2d0a6 100644
--- a/src/views/explore/SeedPicker.svelte
+++ b/src/views/explore/SeedPicker.svelte
@@ -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);
- }
- }