mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-18 19:30:27 +08:00
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:
+6
-26
@@ -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 @@
|
||||
</style>
|
||||
|
||||
<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-row">
|
||||
<div>Search seed</div>
|
||||
<div class="right">
|
||||
<SeedPicker
|
||||
baseUrl={searchSeed}
|
||||
baseUrl={$searchSeed}
|
||||
mode="search"
|
||||
variant="panel"
|
||||
ariaLabel="Search seed selector"
|
||||
title="Switch the seed used for search and explore" />
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
<div class="header-search">
|
||||
<RepoSearch baseUrl={searchSeed} />
|
||||
<RepoSearch baseUrl={$searchSeed} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="right-section">
|
||||
{#if showSearch && collapsedSearch}
|
||||
<HeaderSearch baseUrl={searchSeed} />
|
||||
<HeaderSearch baseUrl={$searchSeed} />
|
||||
{/if}
|
||||
{#if seedPickerBaseUrl}
|
||||
<SeedPicker baseUrl={seedPickerBaseUrl} mode="node" />
|
||||
|
||||
@@ -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}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<!-- 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,
|
||||
not a change to the stored search seed. -->
|
||||
<SeedPicker {baseUrl} mode="node" />
|
||||
<SeedPicker {baseUrl} mode="node" variant="panel" />
|
||||
{/if}
|
||||
</div>
|
||||
{#if description}
|
||||
|
||||
@@ -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 @@
|
||||
<Popover
|
||||
bind:expanded
|
||||
popoverPositionTop="2.5rem"
|
||||
popoverPositionRight="0"
|
||||
popoverPositionLeft={variant === "breadcrumb" ? "0" : undefined}
|
||||
popoverPositionRight={variant === "breadcrumb" ? undefined : "0"}
|
||||
popoverPadding="0.25rem"
|
||||
popoverBorderRadius="var(--border-radius-md)">
|
||||
<div
|
||||
slot="toggle"
|
||||
let:toggle
|
||||
class="target"
|
||||
class="target {variant}"
|
||||
{title}
|
||||
aria-label={ariaLabel}
|
||||
on:click={toggle}
|
||||
@@ -311,16 +333,18 @@
|
||||
tabindex="0"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={expanded}>
|
||||
<Icon name="seed" />
|
||||
<slot name="icon"><Icon name="seed" /></slot>
|
||||
<div class="hostname txt-overflow">{baseUrl.hostname}</div>
|
||||
{#if variant !== "breadcrumb"}
|
||||
<Icon name={expanded ? "chevron-up" : "chevron-down"} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<svelte:fragment slot="popover">
|
||||
<div class="popover-content">
|
||||
<div class="section-label">Custom seeds</div>
|
||||
{#if $bookmarkedSeeds.length > 0}
|
||||
<DropdownList items={$bookmarkedSeeds} styleDropdownPadding="0">
|
||||
{#if customSeeds.length > 0}
|
||||
<DropdownList items={customSeeds} styleDropdownPadding="0">
|
||||
<DropdownListItem
|
||||
slot="item"
|
||||
let:item
|
||||
@@ -343,12 +367,22 @@
|
||||
on:click={() => openNodeView(item)}>
|
||||
<Icon name="open-external" />
|
||||
</IconButton>
|
||||
{#if isBookmarked(item, $bookmarkedSeeds)}
|
||||
<IconButton
|
||||
ariaLabel="Remove bookmark"
|
||||
stopPropagation
|
||||
on:click={() => removeBookmark(item)}>
|
||||
<Icon name="close" />
|
||||
</IconButton>
|
||||
{:else}
|
||||
<IconButton
|
||||
ariaLabel="Bookmark seed"
|
||||
title="Bookmark seed"
|
||||
stopPropagation
|
||||
on:click={() => addBookmark(item)}>
|
||||
<Icon name="plus" />
|
||||
</IconButton>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownListItem>
|
||||
|
||||
@@ -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<BaseUrl | undefined>(
|
||||
// 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<BaseUrl | undefined>(
|
||||
"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[] {
|
||||
|
||||
@@ -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 @@
|
||||
</style>
|
||||
|
||||
<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">
|
||||
{#if node.bannerUrl}
|
||||
<img style:width="100%" alt="Node banner" src={node.bannerUrl} />
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
import Link from "@app/components/Link.svelte";
|
||||
import MobileFooter from "@app/App/MobileFooter.svelte";
|
||||
import RepoHeader from "./Header.svelte";
|
||||
import SeedPicker from "@app/views/explore/SeedPicker.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 baseUrl: BaseUrl;
|
||||
@@ -56,10 +57,6 @@
|
||||
.breadcrumb :global(a:hover) {
|
||||
color: var(--color-text-brand);
|
||||
}
|
||||
.avatar {
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
|
||||
@media (max-width: 719.98px) {
|
||||
.tab-bar {
|
||||
display: none;
|
||||
@@ -84,28 +81,16 @@
|
||||
<svelte:fragment slot="breadcrumbs">
|
||||
<nav class="breadcrumbs" aria-label="Breadcrumb">
|
||||
<span class="breadcrumb">
|
||||
<Link
|
||||
ariaLabel="Home"
|
||||
style="display: flex; align-items: center; gap: 0.5rem;"
|
||||
route={{
|
||||
resource: "nodes",
|
||||
params: {
|
||||
baseUrl,
|
||||
repoPageIndex: 0,
|
||||
},
|
||||
}}>
|
||||
{#if nodeAvatarUrl}
|
||||
<img
|
||||
width="24"
|
||||
height="24"
|
||||
class="avatar"
|
||||
alt="Node avatar"
|
||||
src={nodeAvatarUrl} />
|
||||
{:else}
|
||||
<UserAvatar {nodeId} styleWidth="1.5rem" />
|
||||
{/if}
|
||||
{baseUrl.hostname}
|
||||
</Link>
|
||||
<SeedPicker
|
||||
{baseUrl}
|
||||
mode="node"
|
||||
variant="breadcrumb"
|
||||
ariaLabel="Current node selector"
|
||||
title="Switch the node serving this page">
|
||||
<svelte:fragment slot="icon">
|
||||
<NodeAvatar {nodeId} avatarUrl={nodeAvatarUrl} />
|
||||
</svelte:fragment>
|
||||
</SeedPicker>
|
||||
</span>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -25,6 +25,8 @@ import type {
|
||||
Tree,
|
||||
} from "@http-client";
|
||||
|
||||
import isEqual from "lodash/isEqual";
|
||||
|
||||
import * as Syntax from "@app/lib/syntax";
|
||||
import config from "@app/lib/config";
|
||||
import { HttpdClient } from "@http-client";
|
||||
@@ -637,6 +639,7 @@ async function loadTreeView(
|
||||
if (
|
||||
(previousLoaded.resource === "repo.source" ||
|
||||
previousLoaded.resource === "repo.history") &&
|
||||
isEqual(previousLoaded.params.baseUrl, route.node) &&
|
||||
[previousLoaded.params.repo.rid, previousLoaded.params.repo.alias].includes(
|
||||
route.repo,
|
||||
) &&
|
||||
@@ -780,6 +783,7 @@ async function loadHistoryView(
|
||||
if (
|
||||
(previousLoaded.resource === "repo.source" ||
|
||||
previousLoaded.resource === "repo.history") &&
|
||||
isEqual(previousLoaded.params.baseUrl, route.node) &&
|
||||
[previousLoaded.params.repo.rid, previousLoaded.params.repo.alias].includes(
|
||||
route.repo,
|
||||
) &&
|
||||
@@ -909,6 +913,7 @@ async function loadPatchView(
|
||||
|
||||
if (
|
||||
previousLoaded.resource === "repo.patch" &&
|
||||
isEqual(previousLoaded.params.baseUrl, route.node) &&
|
||||
[previousLoaded.params.repo.rid, previousLoaded.params.repo.alias].includes(
|
||||
route.repo,
|
||||
) &&
|
||||
|
||||
+12
-24
@@ -11,6 +11,8 @@
|
||||
import Layout from "@app/components/Layout.svelte";
|
||||
import Link from "@app/components/Link.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 UserAddress from "@app/views/users/UserAddress.svelte";
|
||||
import UserAvatar from "@app/components/UserAvatar.svelte";
|
||||
@@ -78,36 +80,22 @@
|
||||
.breadcrumb :global(a:hover) {
|
||||
color: var(--color-text-brand);
|
||||
}
|
||||
.avatar {
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
</style>
|
||||
|
||||
<Layout>
|
||||
<svelte:fragment slot="breadcrumbs">
|
||||
<div class="breadcrumbs">
|
||||
<span class="breadcrumb">
|
||||
<Link
|
||||
style="display: flex; align-items: center; gap: 0.5rem;"
|
||||
route={{
|
||||
resource: "nodes",
|
||||
params: {
|
||||
baseUrl,
|
||||
repoPageIndex: 0,
|
||||
},
|
||||
}}>
|
||||
{#if nodeAvatarUrl}
|
||||
<img
|
||||
width="24"
|
||||
height="24"
|
||||
class="avatar"
|
||||
alt="Node avatar"
|
||||
src={nodeAvatarUrl} />
|
||||
{:else}
|
||||
<UserAvatar {nodeId} styleWidth="1.5rem" />
|
||||
{/if}
|
||||
{baseUrl.hostname}
|
||||
</Link>
|
||||
<SeedPicker
|
||||
{baseUrl}
|
||||
mode="node"
|
||||
variant="breadcrumb"
|
||||
ariaLabel="Current node selector"
|
||||
title="Switch the node serving this page">
|
||||
<svelte:fragment slot="icon">
|
||||
<NodeAvatar {nodeId} avatarUrl={nodeAvatarUrl} />
|
||||
</svelte:fragment>
|
||||
</SeedPicker>
|
||||
</span>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -64,9 +64,6 @@ test("seed picker lives in the settings dropdown on explore", async ({
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Search seed selector" }),
|
||||
).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 }) => {
|
||||
|
||||
+45
-2
@@ -185,10 +185,53 @@ test("node can be switched from a repo the node doesn't have", async ({
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Seed selector" }),
|
||||
).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(
|
||||
page.getByRole("button", { name: "Current node selector" }),
|
||||
).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");
|
||||
});
|
||||
|
||||
@@ -35,7 +35,12 @@ test("navigation between node and repo pages", async ({ page }) => {
|
||||
await expectBackAndForwardNavigationWorks("/nodes/localhost", 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");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user