From 00f079d0d9fb4828e570bc568ffde1dcd43ebb25 Mon Sep 17 00:00:00 2001 From: Daniel Norman Date: Mon, 14 Sep 2026 17:16:09 +0200 Subject: [PATCH] Make the node breadcrumb a seed picker - replace the node breadcrumb link with a SeedPicker in the repo, user and node views, so the node serving a page can be switched where it is named - remove current node picker from settings panel - reach the node view through the picker's "Open node view" action - cover the new breadcrumb picker in the e2e tests - list the active seed in the seed picker - the seed serving the page is also rendered in the list with a bookmark action instead of a remove one. --- src/App/Settings.svelte | 32 +++----------- src/components/Header.svelte | 17 ++++---- src/components/NodeAvatar.svelte | 25 +++++++++++ src/marketing/Landing.svelte | 14 +++--- src/views/NotFound.svelte | 2 +- src/views/explore/SeedPicker.svelte | 66 ++++++++++++++++++++++------- src/views/nodes/SeedSelector.ts | 21 ++++++--- src/views/nodes/View.svelte | 15 +++++++ src/views/repos/Layout.svelte | 39 ++++++----------- src/views/repos/router.ts | 5 +++ src/views/users/View.svelte | 36 ++++++---------- tests/e2e/explore.spec.ts | 3 -- tests/e2e/node.spec.ts | 47 +++++++++++++++++++- tests/e2e/router.spec.ts | 7 ++- 14 files changed, 206 insertions(+), 123 deletions(-) create mode 100644 src/components/NodeAvatar.svelte diff --git a/src/App/Settings.svelte b/src/App/Settings.svelte index 99c9be51d..cbf943bc4 100644 --- a/src/App/Settings.svelte +++ b/src/App/Settings.svelte @@ -9,8 +9,7 @@ followSystemTheme, loadTheme, } from "@app/lib/appearance"; - import { activeUnloadedRouteStore, routeBaseUrl } from "@app/lib/router"; - import { determineSeed, selectedSeed } from "@app/views/nodes/SeedSelector"; + import { searchSeed } from "@app/views/nodes/SeedSelector"; import Button from "@app/components/Button.svelte"; import Icon from "@app/components/Icon.svelte"; @@ -20,25 +19,20 @@ 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). let seedSearchAvailable: boolean | undefined = undefined; - $: void checkSeedSearch(searchSeed); + $: void checkSeedSearch($searchSeed); async function checkSeedSearch(seed: BaseUrl) { const key = seedKey(seed); seedSearchAvailable = undefined; try { const info = await new HttpdClient(seed).getInfo(); - if (seedKey(searchSeed) === key) { + if (seedKey($searchSeed) === key) { seedSearchAvailable = info.httpd.searchAvailable; } } catch { - if (seedKey(searchSeed) === key) { + if (seedKey($searchSeed) === key) { seedSearchAvailable = false; } } @@ -103,28 +97,14 @@
- {#if currentNode} -
-
-
Current node
-
- -
-
-
The node serving the page you’re viewing.
-
- {/if}
Search seed
diff --git a/src/components/Header.svelte b/src/components/Header.svelte index 52e46639b..141bbc5fc 100644 --- a/src/components/Header.svelte +++ b/src/components/Header.svelte @@ -16,7 +16,7 @@ import { activeRouteStore, homeRoute } from "@app/lib/router"; import config from "@app/lib/config"; import { isMobile } from "@app/lib/media"; - import { determineSeed, selectedSeed } from "@app/views/nodes/SeedSelector"; + import { determineSeed, searchSeed } from "@app/views/nodes/SeedSelector"; import Settings from "@app/App/Settings.svelte"; import Help from "@app/App/Help.svelte"; @@ -65,21 +65,20 @@ ? ($activeRouteStore.params.baseUrl ?? determineSeed()) : undefined; - // Search always queries the selected explore seed, regardless of which seed - // the current page (e.g. a repo view) was served from. - $: searchSeed = $selectedSeed ?? determineSeed(); + // Search always queries the stored search seed, regardless of which seed the + // current page (e.g. a repo view) was served from. // The explore routes already resolved /info during load; reuse it, but only // when it describes the same seed we're about to search. $: routeSearchAvailable = ($activeRouteStore.resource === "explore" || $activeRouteStore.resource === "explore.repos") && - seedKey($activeRouteStore.params.baseUrl) === seedKey(searchSeed) + seedKey($activeRouteStore.params.baseUrl) === seedKey($searchSeed) ? $activeRouteStore.params.searchAvailable : undefined; let searchAvailable = false; - $: void resolveSearchAvailable(searchSeed, routeSearchAvailable); + $: void resolveSearchAvailable($searchSeed, routeSearchAvailable); async function resolveSearchAvailable( seed: BaseUrl, @@ -103,7 +102,7 @@ // Older nodes without /info (or an unreachable seed) have no search. searchAvailableCache[key] = false; } - if (seedKey(searchSeed) === key) { + if (seedKey($searchSeed) === key) { searchAvailable = searchAvailableCache[key]; } } @@ -234,13 +233,13 @@ {#if showSearch && !collapsedSearch} {/if}
{#if showSearch && collapsedSearch} - + {/if} {#if seedPickerBaseUrl} diff --git a/src/components/NodeAvatar.svelte b/src/components/NodeAvatar.svelte new file mode 100644 index 000000000..b82f67a25 --- /dev/null +++ b/src/components/NodeAvatar.svelte @@ -0,0 +1,25 @@ + + + + +{#if avatarUrl} + Node avatar +{:else} + +{/if} diff --git a/src/marketing/Landing.svelte b/src/marketing/Landing.svelte index 75bdc7793..bcc3178c2 100644 --- a/src/marketing/Landing.svelte +++ b/src/marketing/Landing.svelte @@ -4,7 +4,7 @@ import { HttpdClient } from "@http-client"; import { push, routeToPath, useDefaultNavigation } from "@app/lib/router"; - import { determineSeed, selectedSeed } from "@app/views/nodes/SeedSelector"; + import { searchSeed } from "@app/views/nodes/SeedSelector"; import CopyCommand from "@app/marketing/CopyCommand.svelte"; import Meta from "@app/marketing/Meta.svelte"; @@ -44,18 +44,16 @@ } } - // Reload whenever the explore seed changes (e.g. picked in Settings) so the - // carousel always reflects the currently selected seed, not just the one - // resolved on first mount. `determineSeed()` applies the fallback logic; the - // `$selectedSeed` reference is what makes this reactive. + // Reload whenever the search seed changes (e.g. picked in Settings) so the + // carousel always reflects the current seed, not just the one resolved on + // first mount. let loadToken = 0; // When the selected seed exposes a search backend the carousel shows the // most-seeded repositories; otherwise it falls back to the repos pinned on // the seed. Either way, links point at the repo on the current deployment. - async function loadCarousel(_selected: BaseUrl | undefined) { + async function loadCarousel(seed: BaseUrl) { const token = ++loadToken; - const seed = determineSeed(); const api = new HttpdClient(seed); loading = true; try { @@ -97,7 +95,7 @@ } } - $: void loadCarousel($selectedSeed); + $: void loadCarousel($searchSeed); function onRepoClick(event: MouseEvent, route: Route) { if (useDefaultNavigation(event)) { diff --git a/src/views/NotFound.svelte b/src/views/NotFound.svelte index d70411d1b..f32091600 100644 --- a/src/views/NotFound.svelte +++ b/src/views/NotFound.svelte @@ -36,7 +36,7 @@ - + {/if}
{#if description} diff --git a/src/views/explore/SeedPicker.svelte b/src/views/explore/SeedPicker.svelte index f1fd2d0a6..efa4af531 100644 --- a/src/views/explore/SeedPicker.svelte +++ b/src/views/explore/SeedPicker.svelte @@ -19,7 +19,7 @@ clearSeedFailure, failedSeeds, removeBookmark, - selectedSeed, + explicitSeed, } from "@app/views/nodes/SeedSelector"; import { closeFocused } from "@app/components/Popover.svelte"; @@ -38,9 +38,20 @@ export let mode: "node" | "search"; export let ariaLabel = "Seed selector"; export let title = "Switch seed"; + // Where the picker sits. A breadcrumb sits on the left of the header, so + // its popover is anchored to the toggle's left edge instead of its right. + export let variant: "header" | "breadcrumb" | "panel" = "header"; const VALIDATION_TIMEOUT_MS = 10000; + // The seed currently in use isn't necessarily bookmarked or a default: it + // can be reached through a shared link or through failover. List it anyway, + // so it is always possible to open its node view from here. + $: isListed = + $bookmarkedSeeds.some(s => isEqual(s, baseUrl)) || + config.preferredSeeds.some(s => isEqual(s, baseUrl)); + $: customSeeds = isListed ? $bookmarkedSeeds : [...$bookmarkedSeeds, baseUrl]; + let expanded: boolean = false; let loading = false; let addingNew = false; @@ -147,7 +158,7 @@ // 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); + explicitSeed.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 @@ -205,6 +216,10 @@ return failed.some(s => isEqual(s, seed)); } + function isBookmarked(seed: BaseUrl, bookmarks: BaseUrl[]) { + return bookmarks.some(s => isEqual(s, seed)); + } + function handleEscape(event: KeyboardEvent) { if (expanded && event.key === "Escape") { closeFocused(); @@ -219,11 +234,17 @@ align-items: center; gap: 0.375rem; max-width: 14rem; - padding: 0.375rem 0.5rem; + padding: 0.375rem 0; border-radius: var(--border-radius-sm); - color: var(--color-text-secondary); cursor: pointer; } + .target.breadcrumb { + padding: 0.375rem 0.5rem; + color: var(--color-text-primary); + } + .target.panel { + padding: 0.375rem 0.5rem; + } .target:hover { background-color: var(--color-surface-mid); color: var(--color-text-primary); @@ -296,13 +317,14 @@
- +
{baseUrl.hostname}
- + {#if variant !== "breadcrumb"} + + {/if}
- {#if $bookmarkedSeeds.length > 0} - + {#if customSeeds.length > 0} + openNodeView(item)}> - removeBookmark(item)}> - - + {#if isBookmarked(item, $bookmarkedSeeds)} + removeBookmark(item)}> + + + {:else} + addBookmark(item)}> + + + {/if}
diff --git a/src/views/nodes/SeedSelector.ts b/src/views/nodes/SeedSelector.ts index 78f0b069a..5f94c2c31 100644 --- a/src/views/nodes/SeedSelector.ts +++ b/src/views/nodes/SeedSelector.ts @@ -3,7 +3,7 @@ import type { BaseUrl } from "@http-client"; import isEqual from "lodash/isEqual"; import storedWritable from "@app/lib/localStore"; import * as z from "zod"; -import { get, writable } from "svelte/store"; +import { derived, get, writable } from "svelte/store"; import config from "@app/lib/config"; @@ -19,8 +19,10 @@ const HARDCODED_FALLBACK: BaseUrl = { port: 443, }; -// Seed that is opened on cold app start on the landing page. -export const selectedSeed = storedWritable( +// The seed the user explicitly picked, or `undefined` when they never did. +// Read `searchSeed` instead of this unless you need to distinguish an explicit +// pick from a resolved fallback. +export const explicitSeed = storedWritable( "selectedSeed", seedSchema, undefined, @@ -63,13 +65,13 @@ export function clearSeedFailure(seed: BaseUrl) { export function removeBookmark(seed: BaseUrl) { bookmarkedSeeds.update(previous => previous.filter(x => !isEqual(x, seed))); - if (isEqual(get(selectedSeed), seed)) { + if (isEqual(get(explicitSeed), 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); + explicitSeed.set(undefined); } } @@ -94,7 +96,7 @@ function getOrCreateBucketRandom(): number { // probabilistically rebalances them onto new seeds when the list grows. // 3. Fallback to a hardcoded seed if nothing is configured. export function determineSeed(): BaseUrl { - const explicit = get(selectedSeed); + const explicit = get(explicitSeed); if (explicit) return explicit; const pool = config.preferredSeeds; @@ -106,6 +108,13 @@ export function determineSeed(): BaseUrl { return HARDCODED_FALLBACK; } +// The seed that searches and the explore listing run against. Derived so +// components react to a new pick, and to the bucket number that `determineSeed` +// creates on first use. +export const searchSeed = derived([explicitSeed, seedBucketRandom], () => + determineSeed(), +); + // Ordered list of seeds to try for failover: the primary first, then the rest // of the preferred pool (deduplicated). export function seedCandidates(): BaseUrl[] { diff --git a/src/views/nodes/View.svelte b/src/views/nodes/View.svelte index 5999725b8..cf1d88a98 100644 --- a/src/views/nodes/View.svelte +++ b/src/views/nodes/View.svelte @@ -10,6 +10,8 @@ import Layout from "@app/components/Layout.svelte"; import Popover from "@app/components/Popover.svelte"; import ReposView from "./ReposView.svelte"; + import NodeAvatar from "@app/components/NodeAvatar.svelte"; + import SeedPicker from "@app/views/explore/SeedPicker.svelte"; import UserAvatar from "@app/components/UserAvatar.svelte"; import PolicyExplainer from "./PolicyExplainer.svelte"; @@ -94,6 +96,19 @@ + + + + + + + +