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,
|
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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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 { 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)) {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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[] {
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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
@@ -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 />
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user