From d36b064dd1bcfc580c69f33bc2b1329d5b7d3c0a Mon Sep 17 00:00:00 2001 From: Daniel Norman Date: Mon, 14 Sep 2026 17:15:54 +0200 Subject: [PATCH] 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. --- src/App/Settings.svelte | 27 ++++++++- src/components/Header.svelte | 9 +-- src/lib/router.ts | 86 ++++++++++++++++++++++++++--- src/views/NotFound.svelte | 5 +- src/views/explore/SeedPicker.svelte | 68 +++++++++++------------ src/views/nodes/SeedSelector.ts | 8 +++ src/views/repos/error.ts | 5 +- tests/e2e/explore.spec.ts | 9 ++- tests/e2e/node.spec.ts | 27 ++++++++- 9 files changed, 186 insertions(+), 58 deletions(-) diff --git a/src/App/Settings.svelte b/src/App/Settings.svelte index f81a98d29..99c9be51d 100644 --- a/src/App/Settings.svelte +++ b/src/App/Settings.svelte @@ -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 @@
+ {#if currentNode} +
+
+
Current node
+
+ +
+
+
The node serving the page you’re viewing.
+
+ {/if}
-
Explore seed
+
Search seed
- +
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); - } - }