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.
This commit is contained in:
Daniel Norman
2026-09-25 10:23:40 +02:00
committed by Rūdolfs Ošiņš
parent b9ba3b7917
commit 00f079d0d9
14 changed files with 206 additions and 123 deletions
+6 -26
View File
@@ -9,8 +9,7 @@
followSystemTheme, followSystemTheme,
loadTheme, loadTheme,
} from "@app/lib/appearance"; } from "@app/lib/appearance";
import { activeUnloadedRouteStore, routeBaseUrl } from "@app/lib/router"; import { searchSeed } from "@app/views/nodes/SeedSelector";
import { determineSeed, selectedSeed } from "@app/views/nodes/SeedSelector";
import Button from "@app/components/Button.svelte"; import Button from "@app/components/Button.svelte";
import Icon from "@app/components/Icon.svelte"; import Icon from "@app/components/Icon.svelte";
@@ -20,25 +19,20 @@
return `${seed.scheme}://${seed.hostname}:${seed.port}`; 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). // Whether the selected seed exposes the search backend (drives the callout).
let seedSearchAvailable: boolean | undefined = undefined; let seedSearchAvailable: boolean | undefined = undefined;
$: void checkSeedSearch(searchSeed); $: void checkSeedSearch($searchSeed);
async function checkSeedSearch(seed: BaseUrl) { async function checkSeedSearch(seed: BaseUrl) {
const key = seedKey(seed); const key = seedKey(seed);
seedSearchAvailable = undefined; seedSearchAvailable = undefined;
try { try {
const info = await new HttpdClient(seed).getInfo(); const info = await new HttpdClient(seed).getInfo();
if (seedKey(searchSeed) === key) { if (seedKey($searchSeed) === key) {
seedSearchAvailable = info.httpd.searchAvailable; seedSearchAvailable = info.httpd.searchAvailable;
} }
} catch { } catch {
if (seedKey(searchSeed) === key) { if (seedKey($searchSeed) === key) {
seedSearchAvailable = false; seedSearchAvailable = false;
} }
} }
@@ -103,28 +97,14 @@
</style> </style>
<div class="settings"> <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">
<div class="seed-setting-row"> <div class="seed-setting-row">
<div>Search seed</div> <div>Search seed</div>
<div class="right"> <div class="right">
<SeedPicker <SeedPicker
baseUrl={searchSeed} baseUrl={$searchSeed}
mode="search" mode="search"
variant="panel"
ariaLabel="Search seed selector" ariaLabel="Search seed selector"
title="Switch the seed used for search and explore" /> title="Switch the seed used for search and explore" />
</div> </div>
+8 -9
View File
@@ -16,7 +16,7 @@
import { activeRouteStore, homeRoute } from "@app/lib/router"; import { activeRouteStore, homeRoute } from "@app/lib/router";
import config from "@app/lib/config"; import config from "@app/lib/config";
import { isMobile } from "@app/lib/media"; 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 Settings from "@app/App/Settings.svelte";
import Help from "@app/App/Help.svelte"; import Help from "@app/App/Help.svelte";
@@ -65,21 +65,20 @@
? ($activeRouteStore.params.baseUrl ?? determineSeed()) ? ($activeRouteStore.params.baseUrl ?? determineSeed())
: undefined; : undefined;
// Search always queries the selected explore seed, regardless of which seed // Search always queries the stored search seed, regardless of which seed the
// the current page (e.g. a repo view) was served from. // current page (e.g. a repo view) was served from.
$: searchSeed = $selectedSeed ?? determineSeed();
// The explore routes already resolved /info during load; reuse it, but only // The explore routes already resolved /info during load; reuse it, but only
// when it describes the same seed we're about to search. // when it describes the same seed we're about to search.
$: routeSearchAvailable = $: routeSearchAvailable =
($activeRouteStore.resource === "explore" || ($activeRouteStore.resource === "explore" ||
$activeRouteStore.resource === "explore.repos") && $activeRouteStore.resource === "explore.repos") &&
seedKey($activeRouteStore.params.baseUrl) === seedKey(searchSeed) seedKey($activeRouteStore.params.baseUrl) === seedKey($searchSeed)
? $activeRouteStore.params.searchAvailable ? $activeRouteStore.params.searchAvailable
: undefined; : undefined;
let searchAvailable = false; let searchAvailable = false;
$: void resolveSearchAvailable(searchSeed, routeSearchAvailable); $: void resolveSearchAvailable($searchSeed, routeSearchAvailable);
async function resolveSearchAvailable( async function resolveSearchAvailable(
seed: BaseUrl, seed: BaseUrl,
@@ -103,7 +102,7 @@
// Older nodes without /info (or an unreachable seed) have no search. // Older nodes without /info (or an unreachable seed) have no search.
searchAvailableCache[key] = false; searchAvailableCache[key] = false;
} }
if (seedKey(searchSeed) === key) { if (seedKey($searchSeed) === key) {
searchAvailable = searchAvailableCache[key]; searchAvailable = searchAvailableCache[key];
} }
} }
@@ -234,13 +233,13 @@
{#if showSearch && !collapsedSearch} {#if showSearch && !collapsedSearch}
<div class="header-search"> <div class="header-search">
<RepoSearch baseUrl={searchSeed} /> <RepoSearch baseUrl={$searchSeed} />
</div> </div>
{/if} {/if}
<div class="right-section"> <div class="right-section">
{#if showSearch && collapsedSearch} {#if showSearch && collapsedSearch}
<HeaderSearch baseUrl={searchSeed} /> <HeaderSearch baseUrl={$searchSeed} />
{/if} {/if}
{#if seedPickerBaseUrl} {#if seedPickerBaseUrl}
<SeedPicker baseUrl={seedPickerBaseUrl} mode="node" /> <SeedPicker baseUrl={seedPickerBaseUrl} mode="node" />
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
import UserAvatar from "@app/components/UserAvatar.svelte";
export let nodeId: string;
export let avatarUrl: string | undefined = undefined;
export let styleWidth: string = "1.5rem";
export let size: number = 24;
</script>
<style>
.avatar {
border-radius: var(--border-radius-md);
}
</style>
{#if avatarUrl}
<img
width={size}
height={size}
class="avatar"
alt="Node avatar"
src={avatarUrl} />
{:else}
<UserAvatar {nodeId} {styleWidth} />
{/if}
+6 -8
View File
@@ -4,7 +4,7 @@
import { HttpdClient } from "@http-client"; import { HttpdClient } from "@http-client";
import { push, routeToPath, useDefaultNavigation } from "@app/lib/router"; 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 CopyCommand from "@app/marketing/CopyCommand.svelte";
import Meta from "@app/marketing/Meta.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 // Reload whenever the search seed changes (e.g. picked in Settings) so the
// carousel always reflects the currently selected seed, not just the one // carousel always reflects the current seed, not just the one resolved on
// resolved on first mount. `determineSeed()` applies the fallback logic; the // first mount.
// `$selectedSeed` reference is what makes this reactive.
let loadToken = 0; let loadToken = 0;
// When the selected seed exposes a search backend the carousel shows the // When the selected seed exposes a search backend the carousel shows the
// most-seeded repositories; otherwise it falls back to the repos pinned on // 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. // 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 token = ++loadToken;
const seed = determineSeed();
const api = new HttpdClient(seed); const api = new HttpdClient(seed);
loading = true; loading = true;
try { try {
@@ -97,7 +95,7 @@
} }
} }
$: void loadCarousel($selectedSeed); $: void loadCarousel($searchSeed);
function onRepoClick(event: MouseEvent, route: Route) { function onRepoClick(event: MouseEvent, route: Route) {
if (useDefaultNavigation(event)) { if (useDefaultNavigation(event)) {
+1 -1
View File
@@ -36,7 +36,7 @@
<!-- This page is always reached from a URL that names a node, and what <!-- 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, it offers is "try this page on a different node" — a navigation,
not a change to the stored search seed. --> not a change to the stored search seed. -->
<SeedPicker {baseUrl} mode="node" /> <SeedPicker {baseUrl} mode="node" variant="panel" />
{/if} {/if}
</div> </div>
{#if description} {#if description}
+50 -16
View File
@@ -19,7 +19,7 @@
clearSeedFailure, clearSeedFailure,
failedSeeds, failedSeeds,
removeBookmark, removeBookmark,
selectedSeed, explicitSeed,
} from "@app/views/nodes/SeedSelector"; } from "@app/views/nodes/SeedSelector";
import { closeFocused } from "@app/components/Popover.svelte"; import { closeFocused } from "@app/components/Popover.svelte";
@@ -38,9 +38,20 @@
export let mode: "node" | "search"; export let mode: "node" | "search";
export let ariaLabel = "Seed selector"; export let ariaLabel = "Seed selector";
export let title = "Switch seed"; 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; 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 expanded: boolean = false;
let loading = false; let loading = false;
let addingNew = false; let addingNew = false;
@@ -147,7 +158,7 @@
// Either the picker is setting the search seed, or it's in node mode on a // 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 // 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. // 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, // 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 // 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 // unloaded route rather than the loaded one, because a failed listing
@@ -205,6 +216,10 @@
return failed.some(s => isEqual(s, seed)); return failed.some(s => isEqual(s, seed));
} }
function isBookmarked(seed: BaseUrl, bookmarks: BaseUrl[]) {
return bookmarks.some(s => isEqual(s, seed));
}
function handleEscape(event: KeyboardEvent) { function handleEscape(event: KeyboardEvent) {
if (expanded && event.key === "Escape") { if (expanded && event.key === "Escape") {
closeFocused(); closeFocused();
@@ -219,11 +234,17 @@
align-items: center; align-items: center;
gap: 0.375rem; gap: 0.375rem;
max-width: 14rem; max-width: 14rem;
padding: 0.375rem 0.5rem; padding: 0.375rem 0;
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
color: var(--color-text-secondary);
cursor: pointer; cursor: pointer;
} }
.target.breadcrumb {
padding: 0.375rem 0.5rem;
color: var(--color-text-primary);
}
.target.panel {
padding: 0.375rem 0.5rem;
}
.target:hover { .target:hover {
background-color: var(--color-surface-mid); background-color: var(--color-surface-mid);
color: var(--color-text-primary); color: var(--color-text-primary);
@@ -296,13 +317,14 @@
<Popover <Popover
bind:expanded bind:expanded
popoverPositionTop="2.5rem" popoverPositionTop="2.5rem"
popoverPositionRight="0" popoverPositionLeft={variant === "breadcrumb" ? "0" : undefined}
popoverPositionRight={variant === "breadcrumb" ? undefined : "0"}
popoverPadding="0.25rem" popoverPadding="0.25rem"
popoverBorderRadius="var(--border-radius-md)"> popoverBorderRadius="var(--border-radius-md)">
<div <div
slot="toggle" slot="toggle"
let:toggle let:toggle
class="target" class="target {variant}"
{title} {title}
aria-label={ariaLabel} aria-label={ariaLabel}
on:click={toggle} on:click={toggle}
@@ -311,16 +333,18 @@
tabindex="0" tabindex="0"
aria-haspopup="dialog" aria-haspopup="dialog"
aria-expanded={expanded}> aria-expanded={expanded}>
<Icon name="seed" /> <slot name="icon"><Icon name="seed" /></slot>
<div class="hostname txt-overflow">{baseUrl.hostname}</div> <div class="hostname txt-overflow">{baseUrl.hostname}</div>
<Icon name={expanded ? "chevron-up" : "chevron-down"} /> {#if variant !== "breadcrumb"}
<Icon name={expanded ? "chevron-up" : "chevron-down"} />
{/if}
</div> </div>
<svelte:fragment slot="popover"> <svelte:fragment slot="popover">
<div class="popover-content"> <div class="popover-content">
<div class="section-label">Custom seeds</div> <div class="section-label">Custom seeds</div>
{#if $bookmarkedSeeds.length > 0} {#if customSeeds.length > 0}
<DropdownList items={$bookmarkedSeeds} styleDropdownPadding="0"> <DropdownList items={customSeeds} styleDropdownPadding="0">
<DropdownListItem <DropdownListItem
slot="item" slot="item"
let:item let:item
@@ -343,12 +367,22 @@
on:click={() => openNodeView(item)}> on:click={() => openNodeView(item)}>
<Icon name="open-external" /> <Icon name="open-external" />
</IconButton> </IconButton>
<IconButton {#if isBookmarked(item, $bookmarkedSeeds)}
ariaLabel="Remove bookmark" <IconButton
stopPropagation ariaLabel="Remove bookmark"
on:click={() => removeBookmark(item)}> stopPropagation
<Icon name="close" /> on:click={() => removeBookmark(item)}>
</IconButton> <Icon name="close" />
</IconButton>
{:else}
<IconButton
ariaLabel="Bookmark seed"
title="Bookmark seed"
stopPropagation
on:click={() => addBookmark(item)}>
<Icon name="plus" />
</IconButton>
{/if}
</div> </div>
</div> </div>
</DropdownListItem> </DropdownListItem>
+15 -6
View File
@@ -3,7 +3,7 @@ import type { BaseUrl } from "@http-client";
import isEqual from "lodash/isEqual"; import isEqual from "lodash/isEqual";
import storedWritable from "@app/lib/localStore"; import storedWritable from "@app/lib/localStore";
import * as z from "zod"; import * as z from "zod";
import { get, writable } from "svelte/store"; import { derived, get, writable } from "svelte/store";
import config from "@app/lib/config"; import config from "@app/lib/config";
@@ -19,8 +19,10 @@ const HARDCODED_FALLBACK: BaseUrl = {
port: 443, port: 443,
}; };
// Seed that is opened on cold app start on the landing page. // The seed the user explicitly picked, or `undefined` when they never did.
export const selectedSeed = storedWritable<BaseUrl | undefined>( // Read `searchSeed` instead of this unless you need to distinguish an explicit
// pick from a resolved fallback.
export const explicitSeed = storedWritable<BaseUrl | undefined>(
"selectedSeed", "selectedSeed",
seedSchema, seedSchema,
undefined, undefined,
@@ -63,13 +65,13 @@ export function clearSeedFailure(seed: BaseUrl) {
export function removeBookmark(seed: BaseUrl) { export function removeBookmark(seed: BaseUrl) {
bookmarkedSeeds.update(previous => previous.filter(x => !isEqual(x, seed))); 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 // Clear the explicit pick so `determineSeed()` falls back through its
// normal resolution path (bucket-pick a preferred seed, or the hardcoded // normal resolution path (bucket-pick a preferred seed, or the hardcoded
// fallback). Setting it to the first remaining bookmark or preferred seed // fallback). Setting it to the first remaining bookmark or preferred seed
// would orphan the store when neither exists, and would also bypass the // would orphan the store when neither exists, and would also bypass the
// bucket-based load balancing. // 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. // probabilistically rebalances them onto new seeds when the list grows.
// 3. Fallback to a hardcoded seed if nothing is configured. // 3. Fallback to a hardcoded seed if nothing is configured.
export function determineSeed(): BaseUrl { export function determineSeed(): BaseUrl {
const explicit = get(selectedSeed); const explicit = get(explicitSeed);
if (explicit) return explicit; if (explicit) return explicit;
const pool = config.preferredSeeds; const pool = config.preferredSeeds;
@@ -106,6 +108,13 @@ export function determineSeed(): BaseUrl {
return HARDCODED_FALLBACK; 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 // Ordered list of seeds to try for failover: the primary first, then the rest
// of the preferred pool (deduplicated). // of the preferred pool (deduplicated).
export function seedCandidates(): BaseUrl[] { export function seedCandidates(): BaseUrl[] {
+15
View File
@@ -10,6 +10,8 @@
import Layout from "@app/components/Layout.svelte"; import Layout from "@app/components/Layout.svelte";
import Popover from "@app/components/Popover.svelte"; import Popover from "@app/components/Popover.svelte";
import ReposView from "./ReposView.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 UserAvatar from "@app/components/UserAvatar.svelte";
import PolicyExplainer from "./PolicyExplainer.svelte"; import PolicyExplainer from "./PolicyExplainer.svelte";
@@ -94,6 +96,19 @@
</style> </style>
<Layout> <Layout>
<svelte:fragment slot="breadcrumbs">
<SeedPicker
{baseUrl}
mode="node"
variant="breadcrumb"
ariaLabel="Current node selector"
title="Switch the node serving this page">
<svelte:fragment slot="icon">
<NodeAvatar nodeId={node.id} avatarUrl={node.avatarUrl} />
</svelte:fragment>
</SeedPicker>
</svelte:fragment>
<div slot="sidebar" class="sidebar-wrapper"> <div slot="sidebar" class="sidebar-wrapper">
{#if node.bannerUrl} {#if node.bannerUrl}
<img style:width="100%" alt="Node banner" src={node.bannerUrl} /> <img style:width="100%" alt="Node banner" src={node.bannerUrl} />
+12 -27
View File
@@ -8,8 +8,9 @@
import Link from "@app/components/Link.svelte"; import Link from "@app/components/Link.svelte";
import MobileFooter from "@app/App/MobileFooter.svelte"; import MobileFooter from "@app/App/MobileFooter.svelte";
import RepoHeader from "./Header.svelte"; import RepoHeader from "./Header.svelte";
import SeedPicker from "@app/views/explore/SeedPicker.svelte";
import Separator from "./Separator.svelte"; import Separator from "./Separator.svelte";
import UserAvatar from "@app/components/UserAvatar.svelte"; import NodeAvatar from "@app/components/NodeAvatar.svelte";
export let activeTab: ActiveTab | undefined = undefined; export let activeTab: ActiveTab | undefined = undefined;
export let baseUrl: BaseUrl; export let baseUrl: BaseUrl;
@@ -56,10 +57,6 @@
.breadcrumb :global(a:hover) { .breadcrumb :global(a:hover) {
color: var(--color-text-brand); color: var(--color-text-brand);
} }
.avatar {
border-radius: var(--border-radius-md);
}
@media (max-width: 719.98px) { @media (max-width: 719.98px) {
.tab-bar { .tab-bar {
display: none; display: none;
@@ -84,28 +81,16 @@
<svelte:fragment slot="breadcrumbs"> <svelte:fragment slot="breadcrumbs">
<nav class="breadcrumbs" aria-label="Breadcrumb"> <nav class="breadcrumbs" aria-label="Breadcrumb">
<span class="breadcrumb"> <span class="breadcrumb">
<Link <SeedPicker
ariaLabel="Home" {baseUrl}
style="display: flex; align-items: center; gap: 0.5rem;" mode="node"
route={{ variant="breadcrumb"
resource: "nodes", ariaLabel="Current node selector"
params: { title="Switch the node serving this page">
baseUrl, <svelte:fragment slot="icon">
repoPageIndex: 0, <NodeAvatar {nodeId} avatarUrl={nodeAvatarUrl} />
}, </svelte:fragment>
}}> </SeedPicker>
{#if nodeAvatarUrl}
<img
width="24"
height="24"
class="avatar"
alt="Node avatar"
src={nodeAvatarUrl} />
{:else}
<UserAvatar {nodeId} styleWidth="1.5rem" />
{/if}
{baseUrl.hostname}
</Link>
</span> </span>
<Separator /> <Separator />
+5
View File
@@ -25,6 +25,8 @@ import type {
Tree, Tree,
} from "@http-client"; } from "@http-client";
import isEqual from "lodash/isEqual";
import * as Syntax from "@app/lib/syntax"; import * as Syntax from "@app/lib/syntax";
import config from "@app/lib/config"; import config from "@app/lib/config";
import { HttpdClient } from "@http-client"; import { HttpdClient } from "@http-client";
@@ -637,6 +639,7 @@ async function loadTreeView(
if ( if (
(previousLoaded.resource === "repo.source" || (previousLoaded.resource === "repo.source" ||
previousLoaded.resource === "repo.history") && previousLoaded.resource === "repo.history") &&
isEqual(previousLoaded.params.baseUrl, route.node) &&
[previousLoaded.params.repo.rid, previousLoaded.params.repo.alias].includes( [previousLoaded.params.repo.rid, previousLoaded.params.repo.alias].includes(
route.repo, route.repo,
) && ) &&
@@ -780,6 +783,7 @@ async function loadHistoryView(
if ( if (
(previousLoaded.resource === "repo.source" || (previousLoaded.resource === "repo.source" ||
previousLoaded.resource === "repo.history") && previousLoaded.resource === "repo.history") &&
isEqual(previousLoaded.params.baseUrl, route.node) &&
[previousLoaded.params.repo.rid, previousLoaded.params.repo.alias].includes( [previousLoaded.params.repo.rid, previousLoaded.params.repo.alias].includes(
route.repo, route.repo,
) && ) &&
@@ -909,6 +913,7 @@ async function loadPatchView(
if ( if (
previousLoaded.resource === "repo.patch" && previousLoaded.resource === "repo.patch" &&
isEqual(previousLoaded.params.baseUrl, route.node) &&
[previousLoaded.params.repo.rid, previousLoaded.params.repo.alias].includes( [previousLoaded.params.repo.rid, previousLoaded.params.repo.alias].includes(
route.repo, route.repo,
) && ) &&
+12 -24
View File
@@ -11,6 +11,8 @@
import Layout from "@app/components/Layout.svelte"; import Layout from "@app/components/Layout.svelte";
import Link from "@app/components/Link.svelte"; import Link from "@app/components/Link.svelte";
import Popover from "@app/components/Popover.svelte"; import Popover from "@app/components/Popover.svelte";
import NodeAvatar from "@app/components/NodeAvatar.svelte";
import SeedPicker from "@app/views/explore/SeedPicker.svelte";
import Separator from "@app/views/repos/Separator.svelte"; import Separator from "@app/views/repos/Separator.svelte";
import UserAddress from "@app/views/users/UserAddress.svelte"; import UserAddress from "@app/views/users/UserAddress.svelte";
import UserAvatar from "@app/components/UserAvatar.svelte"; import UserAvatar from "@app/components/UserAvatar.svelte";
@@ -78,36 +80,22 @@
.breadcrumb :global(a:hover) { .breadcrumb :global(a:hover) {
color: var(--color-text-brand); color: var(--color-text-brand);
} }
.avatar {
border-radius: var(--border-radius-md);
}
</style> </style>
<Layout> <Layout>
<svelte:fragment slot="breadcrumbs"> <svelte:fragment slot="breadcrumbs">
<div class="breadcrumbs"> <div class="breadcrumbs">
<span class="breadcrumb"> <span class="breadcrumb">
<Link <SeedPicker
style="display: flex; align-items: center; gap: 0.5rem;" {baseUrl}
route={{ mode="node"
resource: "nodes", variant="breadcrumb"
params: { ariaLabel="Current node selector"
baseUrl, title="Switch the node serving this page">
repoPageIndex: 0, <svelte:fragment slot="icon">
}, <NodeAvatar {nodeId} avatarUrl={nodeAvatarUrl} />
}}> </svelte:fragment>
{#if nodeAvatarUrl} </SeedPicker>
<img
width="24"
height="24"
class="avatar"
alt="Node avatar"
src={nodeAvatarUrl} />
{:else}
<UserAvatar {nodeId} styleWidth="1.5rem" />
{/if}
{baseUrl.hostname}
</Link>
</span> </span>
<Separator /> <Separator />
-3
View File
@@ -64,9 +64,6 @@ test("seed picker lives in the settings dropdown on explore", async ({
await expect( await expect(
page.getByRole("button", { name: "Search seed selector" }), page.getByRole("button", { name: "Search seed selector" }),
).toContainText("localhost"); ).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 }) => { test("explore shows an error when no seed can be reached", async ({ page }) => {
+45 -2
View File
@@ -185,10 +185,53 @@ test("node can be switched from a repo the node doesn't have", async ({
await expect( await expect(
page.getByRole("button", { name: "Seed selector" }), page.getByRole("button", { name: "Seed selector" }),
).toBeVisible(); ).toBeVisible();
});
test("repo page shows the node selector in the header", async ({ page }) => {
await page.goto(`/nodes/localhost/${sourceBrowsingRid}`, {
waitUntil: "networkidle",
});
await page.getByRole("button", { name: "Settings" }).click();
await expect(page.getByText("Current node")).toBeVisible();
await expect( await expect(
page.getByRole("button", { name: "Current node selector" }), page.getByRole("button", { name: "Current node selector" }),
).toContainText("localhost"); ).toContainText("localhost");
}); });
test("breadcrumb picker lists a seed that isn't bookmarked", async ({
page,
}) => {
// Proxy requests to seed.example.tld to the local test api, so the page can
// be served by a seed that is neither bookmarked nor a preferred one.
await page.route(
url => url.hostname === "seed.example.tld",
route =>
route.fulfill({
status: 301,
headers: {
Location: route
.request()
.url()
.replace("seed.example.tld", "localhost"),
},
}),
);
await page.goto(`/nodes/seed.example.tld/${sourceBrowsingRid}`, {
waitUntil: "networkidle",
});
const picker = page.getByRole("button", { name: "Current node selector" });
await expect(picker).toContainText("seed.example.tld");
await picker.click();
// The seed serving the page is listed under the custom seeds, even though
// it was never bookmarked, and its node view is reachable from there.
const item = page
.locator(".item")
.filter({ hasText: "seed.example.tld" })
.first();
await expect(item).toBeVisible();
await item.getByRole("button", { name: "Open node view" }).click();
await expect(page).toHaveURL("/nodes/seed.example.tld");
});
+6 -1
View File
@@ -35,7 +35,12 @@ test("navigation between node and repo pages", async ({ page }) => {
await expectBackAndForwardNavigationWorks("/nodes/localhost", page); await expectBackAndForwardNavigationWorks("/nodes/localhost", page);
await expectUrlPersistsReload(page); await expectUrlPersistsReload(page);
await page.getByRole("link", { name: "Home" }).click(); // The node breadcrumb is a picker now, so the node page is reached through
// its "Open node view" action.
await page.getByRole("button", { name: "Current node selector" }).click();
await page
.getByRole("button", { name: "Open node view", exact: true })
.click();
await expect(page).toHaveURL("/nodes/localhost"); await expect(page).toHaveURL("/nodes/localhost");
}); });