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); - } - }