Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
Add bookmarking ability to seed selector
Open rudolfs opened 1 year ago
15 files changed +424 -373 853829ea 5174cda6
modified config/custom-environment-variables.json
@@ -10,7 +10,7 @@
  },
  "supportWebsite": "SUPPORT_WEBSITE",
  "fallbackPreferredSeed": {
-
    "__name": "FALLBACK_PREFERRED_SEED",
+
    "__name": "PREFERRED_SEEDS",
    "__format": "json"
  }
}
modified config/default.json
@@ -9,9 +9,21 @@
    "commitsPerPage": 30
  },
  "supportWebsite": "https://radicle.zulipchat.com",
-
  "fallbackPreferredSeed": {
-
    "hostname": "seed.radicle.garden",
-
    "port": 443,
-
    "scheme": "https"
-
  }
+
  "preferredSeeds": [
+
    {
+
      "hostname": "ash.radicle.garden",
+
      "port": 443,
+
      "scheme": "https"
+
    },
+
    {
+
      "hostname": "seed.radicle.xyz",
+
      "port": 443,
+
      "scheme": "https"
+
    },
+
    {
+
      "hostname": "seed.radicle.garden",
+
      "port": 443,
+
      "scheme": "https"
+
    }
+
  ]
}
modified config/test.json
@@ -3,9 +3,11 @@
    "defaultHttpdPort": 8081,
    "defaultHttpdScheme": "http"
  },
-
  "fallbackPreferredSeed": {
-
    "hostname": "127.0.0.1",
-
    "port": 8081,
-
    "scheme": "http"
-
  }
+
  "preferredSeeds": [
+
    {
+
      "hostname": "127.0.0.1",
+
      "port": 8081,
+
      "scheme": "http"
+
    }
+
  ]
}
modified module.d.ts
@@ -12,7 +12,7 @@ declare module "virtual:*" {
    };
    reactions: string[];
    supportWebsite: string;
-
    fallbackPreferredSeed: BaseUrl;
+
    preferredSeeds: BaseUrl[];
  };

  export default config;
modified src/components/DropdownList.svelte
@@ -1,6 +1,7 @@
<script lang="ts" generics="T">
  export let items: T[];
  export let styleDropdownMinWidth: string | undefined = undefined;
+
  export let styleDropdownPadding: string = "0 0 0.25rem 0";
</script>

<style>
@@ -14,19 +15,17 @@
    padding: 0.25rem 0.25rem 0 0.25rem;
    font-size: var(--font-size-small);
  }
-
  .dropdown-item:last-child {
-
    padding-bottom: 0.25rem;
-
  }
</style>

-
<div class="dropdown" style:min-width={styleDropdownMinWidth}>
-
  {#each items as item}
-
    <div class="dropdown-item">
-
      <slot name="item" {item} />
-
    </div>
-
  {:else}
-
    <div class="dropdown-item">
-
      <slot name="empty" />
-
    </div>
-
  {/each}
-
</div>
+
{#if items.length}
+
  <div
+
    class="dropdown"
+
    style:min-width={styleDropdownMinWidth}
+
    style:padding={styleDropdownPadding}>
+
    {#each items as item}
+
      <div class="dropdown-item">
+
        <slot name="item" {item} />
+
      </div>
+
    {/each}
+
  </div>
+
{/if}
modified src/components/Icon.svelte
@@ -7,6 +7,8 @@
    | "arrow-box-up-right"
    | "arrow-reply"
    | "badge"
+
    | "bookmark-off"
+
    | "bookmark-on"
    | "branch"
    | "brush"
    | "chat"
@@ -104,6 +106,14 @@
      fill-rule="evenodd"
      clip-rule="evenodd"
      d="M8.70235 1.76514L9.06964 2.13244C9.25591 2.31871 9.50857 2.42335 9.77201 2.42335H11.0548C11.6034 2.42335 12.0481 2.86806 12.0481 3.41663V4.69945C12.0481 4.9629 12.1528 5.21555 12.3391 5.40182L12.8168 5.87958C13.2047 6.26748 13.2047 6.89641 12.8168 7.28428L12.3391 7.76207C12.1528 7.94834 12.0481 8.20099 12.0481 8.46441V9.43104C12.0481 9.78994 11.8577 10.1044 11.5725 10.2789L12.6676 12.9387C12.7421 13.1197 12.7034 13.3277 12.5686 13.4697C12.4338 13.6117 12.2281 13.6612 12.0435 13.5962L10.7832 13.1526L10.082 14.2898C9.98071 14.454 9.79344 14.5447 9.60178 14.5224C9.41011 14.5001 9.24873 14.3687 9.18792 14.1856L8.33941 11.6302C8.12056 11.7095 7.87942 11.7095 7.66057 11.6301L6.81206 14.1856C6.75125 14.3687 6.58987 14.5001 6.3982 14.5224C6.20654 14.5447 6.01927 14.454 5.91799 14.2898L5.21675 13.1526L3.95651 13.5962C3.77184 13.6612 3.5662 13.6117 3.4314 13.4697C3.2966 13.3277 3.25783 13.1197 3.33237 12.9387L4.44826 10.2286C4.20486 10.0475 4.04717 9.75769 4.04717 9.43104V8.55968C4.04717 8.29627 3.9425 8.04361 3.75623 7.85734L3.1832 7.28428C2.7953 6.89641 2.7953 6.26748 3.1832 5.87958L3.75623 5.30655C3.9425 5.12028 4.04717 4.86762 4.04717 4.60418V3.41663C4.04717 2.86806 4.49185 2.42335 5.04045 2.42335H6.228C6.49142 2.42335 6.74407 2.31871 6.93034 2.13244L7.29764 1.76514C7.68554 1.37724 8.31447 1.37724 8.70235 1.76514ZM8.00001 2.46748L8.3673 2.83478C8.73984 3.20732 9.24515 3.41663 9.77201 3.41663H11.0548V4.69945C11.0548 5.22634 11.2641 5.73162 11.6367 6.10416L12.1145 6.58194L11.6367 7.0597C11.2641 7.43227 11.0548 7.93755 11.0548 8.46441V9.43104H10.0882C9.56134 9.43104 9.05606 9.64032 8.68352 10.0129L8.00001 10.6964L7.31649 10.0129C6.94392 9.64032 6.43864 9.43104 5.91178 9.43104H5.04045V8.55968C5.04045 8.03282 4.83114 7.52754 4.4586 7.15497L3.88554 6.58194L4.4586 6.00889C4.83114 5.63635 5.04045 5.13107 5.04045 4.60418V3.41663H6.228C6.75486 3.41663 7.26014 3.20732 7.63271 2.83478L8.00001 2.46748ZM5.44185 10.4243L4.6733 12.2909L5.26446 12.0827C5.48483 12.0052 5.72948 12.0917 5.8521 12.2905L6.20478 12.8625L6.84207 10.9432L6.61412 10.7152C6.42785 10.529 6.17523 10.4243 5.91178 10.4243H5.44185ZM9.15791 10.9432L9.7952 12.8625L10.1479 12.2905C10.2705 12.0917 10.5152 12.0052 10.7355 12.0827L11.3267 12.2909L10.5581 10.4243H10.0882C9.82479 10.4243 9.57213 10.529 9.38586 10.7152L9.15791 10.9432Z" />
+
  {:else if name === "bookmark-on"}
+
    <path
+
      d="M4 2C3.44772 2 3 2.44772 3 3V13C3 13.4247 3.26825 13.8031 3.66899 13.9436C4.06974 14.0842 4.51557 13.9563 4.78087 13.6247L8 9.60078L11.2191 13.6247C11.4844 13.9563 11.9303 14.0842 12.331 13.9436C12.7318 13.8031 13 13.4247 13 13V3C13 2.44772 12.5523 2 12 2H4Z" />
+
  {:else if name === "bookmark-off"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M3 3C3 2.44772 3.44772 2 4 2H12C12.5523 2 13 2.44772 13 3V13C13 13.4247 12.7318 13.8031 12.331 13.9436C11.9303 14.0842 11.4844 13.9563 11.2191 13.6247L8 9.60078L4.78087 13.6247C4.51557 13.9563 4.06974 14.0842 3.66899 13.9436C3.26825 13.8031 3 13.4247 3 13V3ZM4 3H12V13L8.78087 8.97609C8.38054 8.47568 7.61946 8.47568 7.21913 8.97609L4 13V3Z" />
  {:else if name === "branch"}
    <path
      fill-rule="evenodd"
modified src/components/IconButton.svelte
@@ -1,4 +1,6 @@
-
<script lang="ts">
+
<script lang="ts" strictEvent>
+
  import { createEventDispatcher } from "svelte";
+

  import Loading from "./Loading.svelte";

  export let ariaLabel: string | undefined = undefined;
@@ -6,6 +8,10 @@
  export let loading: boolean = false;
  export let title: string | undefined = undefined;
  export let stylePadding: string | undefined = undefined;
+
  export let disabled: boolean = false;
+
  export let stopPropagation: boolean = false;
+

+
  const dispatch = createEventDispatcher<{ click: MouseEvent }>();
</script>

<style>
@@ -29,6 +35,11 @@
    color: var(--color-foreground-contrast);
    background-color: var(--color-fill-ghost);
  }
+
  .disabled,
+
  .disabled:hover {
+
    color: var(--color-fill-counter);
+
    background-color: unset;
+
  }
</style>

{#if loading}
@@ -36,14 +47,23 @@
{:else}
  <!-- svelte-ignore a11y-click-events-have-key-events -->
  <div
-
    style:padding={stylePadding}
-
    role="button"
-
    tabindex="0"
+
    class:disabled
    aria-label={ariaLabel}
-
    {title}
-
    class="button"
    class:inline
-
    on:click>
+
    class="button"
+
    on:click={ev => {
+
      if (stopPropagation) {
+
        ev.stopPropagation();
+
      }
+
      if (disabled) {
+
        return;
+
      }
+
      dispatch("click", ev);
+
    }}
+
    role="button"
+
    style:padding={stylePadding}
+
    tabindex="0"
+
    {title}>
    <slot />
  </div>
{/if}
modified src/components/TextInput.svelte
@@ -12,6 +12,7 @@
  export let value: string | undefined = undefined;

  export let autofocus: boolean = false;
+
  export let autoselect: boolean = false;
  export let disabled: boolean = false;
  export let loading: boolean = false;
  export let valid: boolean = true;
@@ -29,10 +30,16 @@
  let success = false;

  onMount(() => {
-
    if (autofocus && inputElement) {
+
    if (inputElement === undefined) {
+
      return;
+
    }
+
    if (autofocus) {
      // We set preventScroll to true for Svelte animations to work.
      inputElement.focus({ preventScroll: true });
    }
+
    if (autoselect) {
+
      inputElement.select();
+
    }
  });

  const restoreIcon = debounce(() => {
@@ -69,7 +76,7 @@
    position: relative;
    flex: 1;
    align-items: center;
-
    height: var(--button-small-height);
+
    height: var(--button-regular-height);
    background: var(--color-background-dip);
  }
  input {
@@ -146,6 +153,7 @@
    {disabled}
    bind:value
    autocomplete="off"
+
    spellcheck="false"
    on:input
    on:focus={handleFocusEvent}
    on:blur={handleFocusEvent}
deleted src/lib/seeds.ts
@@ -1,78 +0,0 @@
-
import type { BaseUrl } from "@http-client";
-

-
import storedWritable from "@efstajas/svelte-stored-writable";
-
import unionBy from "lodash/unionBy";
-
import { array, number, string, object } from "zod";
-
import { derived } from "svelte/store";
-

-
import config from "virtual:config";
-

-
const preferredSeedSchema = object({
-
  hostname: string(),
-
  port: number(),
-
  scheme: string(),
-
});
-

-
export const configuredPreferredSeeds = storedWritable<BaseUrl[]>(
-
  "configuredPreferredSeeds",
-
  array(preferredSeedSchema),
-
  [],
-
  !localStorage,
-
);
-
const storedPreferredSeed = storedWritable<BaseUrl | undefined>(
-
  "preferredSeed",
-
  preferredSeedSchema,
-
  undefined,
-
  !localStorage,
-
);
-

-
export function addSeedsToConfiguredSeeds(newSeeds: BaseUrl[]) {
-
  configuredPreferredSeeds.update(seeds =>
-
    unionBy(seeds, newSeeds, "hostname"),
-
  );
-
}
-

-
export function selectPreferredSeed(seed: BaseUrl) {
-
  storedPreferredSeed.set(seed);
-
}
-

-
export function removeSeedFromConfiguredSeeds(seedHostname: string) {
-
  configuredPreferredSeeds.update(
-
    seeds => seeds.filter(s => s.hostname !== seedHostname) ?? seeds,
-
  );
-
}
-

-
export const preferredSeeds = derived(
-
  [configuredPreferredSeeds, storedPreferredSeed],
-
  ([configuredPreferredSeeds, storedPreferredSeed]) => {
-
    // No configured preferred seeds
-
    if (configuredPreferredSeeds.length === 0)
-
      return {
-
        selected: config.fallbackPreferredSeed,
-
        seeds: [config.fallbackPreferredSeed],
-
      };
-

-
    // No stored preferred seed
-
    if (!storedPreferredSeed)
-
      return {
-
        selected: configuredPreferredSeeds[0],
-
        seeds: configuredPreferredSeeds,
-
      };
-

-
    // Stored preferred seed not configured
-
    if (
-
      !configuredPreferredSeeds.some(
-
        seed => seed.hostname === storedPreferredSeed.hostname,
-
      )
-
    )
-
      return {
-
        selected: configuredPreferredSeeds[0],
-
        seeds: configuredPreferredSeeds,
-
      };
-

-
    return {
-
      selected: storedPreferredSeed,
-
      seeds: configuredPreferredSeeds,
-
    };
-
  },
-
);
deleted src/views/nodes/PreferredSeedDropdown.svelte
@@ -1,213 +0,0 @@
-
<script lang="ts">
-
  import { HttpdClient, type BaseUrl } from "@http-client";
-

-
  import { derived } from "svelte/store";
-

-
  import config from "virtual:config";
-
  import {
-
    addSeedsToConfiguredSeeds,
-
    configuredPreferredSeeds,
-
    preferredSeeds,
-
    removeSeedFromConfiguredSeeds,
-
    selectPreferredSeed,
-
  } from "@app/lib/seeds";
-
  import { closeFocused } from "@app/components/Popover.svelte";
-
  import { deduplicateStore } from "@app/lib/deduplicateStore";
-
  import { push } from "@app/lib/router";
-

-
  import DropdownList from "@app/components/DropdownList.svelte";
-
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
-
  import IconButton from "@app/components/IconButton.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Popover from "@app/components/Popover.svelte";
-
  import TextInput from "@app/components/TextInput.svelte";
-

-
  const selectedSeed = deduplicateStore(
-
    derived(preferredSeeds, $ => $?.selected),
-
  );
-

-
  const validateInput = async (seed: BaseUrl) => {
-
    if (stateOptions.find(s => s.hostname === seed.hostname)) {
-
      validationMessage = "Seed node already added.";
-
      return false;
-
    }
-
    const api = new HttpdClient(seed);
-
    try {
-
      await api.getNode();
-
      return true;
-
    } catch (e) {
-
      validationMessage = "Seed node isn't reachable";
-
      return false;
-
    }
-
  };
-

-
  // Reset state if inputValue changes
-
  $: {
-
    customSeed;
-
    submittingInput = false;
-
    validationMessage = undefined;
-
    valid = true;
-
  }
-
  $: stateOptions = $preferredSeeds.seeds;
-
  let valid = true;
-
  let submittingInput = false;
-
  let validationMessage: undefined | string = undefined;
-
  let customSeed: string = "";
-
  let expanded = false;
-
</script>
-

-
<style>
-
  .popover {
-
    display: flex;
-
    flex-direction: column;
-
  }
-

-
  .validation-message {
-
    color: var(--color-foreground-red);
-
    margin-top: 0.5rem;
-
    margin-left: 0.5rem;
-
    display: flex;
-
    align-items: center;
-
    gap: 0.25rem;
-
  }
-

-
  .dropdown-item {
-
    display: flex;
-
    justify-content: space-between;
-
    align-items: center;
-
    width: 100%;
-
  }
-

-
  .divider {
-
    height: 1px;
-
    width: 100%;
-
    margin: 0.5rem 0;
-
    background-color: var(--color-border-default);
-
  }
-

-
  .icon-item {
-
    display: flex;
-
    gap: 0.5rem;
-
    align-items: center;
-
  }
-
</style>
-

-
<Popover
-
  bind:expanded
-
  popoverContainerMinWidth="0"
-
  popoverPositionTop="2.5rem"
-
  popoverPositionLeft="0"
-
  popoverPadding="0.25rem"
-
  popoverBorderRadius="var(--border-radius-small)">
-
  <div
-
    class="icon-item"
-
    slot="toggle"
-
    title="Switch preferred seeds"
-
    let:toggle>
-
    <slot />
-
    <IconButton on:click={toggle}>
-
      <Icon name={expanded ? "chevron-up" : "chevron-down"} />
-
    </IconButton>
-
  </div>
-

-
  <svelte:fragment slot="popover">
-
    <div style:width="16rem">
-
      <TextInput
-
        {valid}
-
        name="seed"
-
        bind:value={customSeed}
-
        loading={submittingInput}
-
        placeholder="Navigate to seed"
-
        on:submit={async () => {
-
          submittingInput = true;
-
          const customSeedBaseUrl = {
-
            hostname: customSeed,
-
            port: config.nodes.defaultHttpdPort,
-
            scheme: config.nodes.defaultHttpdScheme,
-
          };
-
          valid = await validateInput(customSeedBaseUrl);
-
          if (valid) {
-
            addSeedsToConfiguredSeeds(
-
              $configuredPreferredSeeds.length === 0
-
                ? [customSeedBaseUrl, config.fallbackPreferredSeed]
-
                : [customSeedBaseUrl],
-
            );
-
            selectPreferredSeed(customSeedBaseUrl);
-
            customSeed = "";
-
            closeFocused();
-
            void push({
-
              resource: "nodes",
-
              params: { baseUrl: $selectedSeed, projectPageIndex: 0 },
-
            });
-
          } else {
-
            submittingInput = false;
-
          }
-
        }} />
-
      {#if validationMessage}
-
        <span class="validation-message txt-small">{validationMessage}</span>
-
      {/if}
-
      <div class="divider" />
-
      <div class="popover">
-
        {#if stateOptions}
-
          <DropdownList items={stateOptions}>
-
            <DropdownListItem
-
              let:item
-
              on:click={() => {
-
                selectPreferredSeed(item);
-
                closeFocused();
-
                void push({
-
                  resource: "nodes",
-
                  params: { baseUrl: $selectedSeed, projectPageIndex: 0 },
-
                });
-
              }}
-
              slot="item"
-
              selected={item.hostname === $selectedSeed.hostname}>
-
              <div class="dropdown-item">
-
                <div class="icon-item" style:min-width="0">
-
                  <Icon name="seedling" />
-
                  <div class="txt-overflow">
-
                    {item.hostname}
-
                  </div>
-
                </div>
-
                {#if stateOptions && stateOptions.length > 1}
-
                  <IconButton
-
                    on:click={() => {
-
                      removeSeedFromConfiguredSeeds(item.hostname);
-
                      selectPreferredSeed(config.fallbackPreferredSeed);
-
                      closeFocused();
-
                      void push({
-
                        resource: "nodes",
-
                        params: { baseUrl: $selectedSeed, projectPageIndex: 0 },
-
                      });
-
                    }}>
-
                    <Icon name="cross" />
-
                  </IconButton>
-
                {/if}
-
              </div>
-
            </DropdownListItem>
-
            <DropdownListItem
-
              on:click={() => {
-
                selectPreferredSeed(config.fallbackPreferredSeed);
-
                closeFocused();
-
                void push({
-
                  resource: "nodes",
-
                  params: { baseUrl: $selectedSeed, projectPageIndex: 0 },
-
                });
-
              }}
-
              slot="empty"
-
              selected>
-
              <div class="dropdown-item">
-
                <div class="icon-item" style:min-width="0">
-
                  <Icon name="seedling" />
-
                  <div class="txt-overflow">
-
                    {config.fallbackPreferredSeed.hostname}
-
                  </div>
-
                </div>
-
              </div>
-
            </DropdownListItem>
-
          </DropdownList>
-
        {/if}
-
      </div>
-
    </div>
-
  </svelte:fragment>
-
</Popover>
added src/views/nodes/SeedSelector.svelte
@@ -0,0 +1,238 @@
+
<script lang="ts">
+
  import type { BaseUrl } from "@http-client";
+

+
  import isEqual from "lodash/isEqual";
+
  import some from "lodash/some";
+

+
  import config from "virtual:config";
+
  import { HttpdClient } from "@http-client";
+
  import {
+
    addBookmark,
+
    bookmarkedSeeds,
+
    removeBookmark,
+
    selectedSeed,
+
  } from "./SeedSelector";
+
  import { closeFocused } from "@app/components/Popover.svelte";
+
  import { push } from "@app/lib/router";
+

+
  import DropdownList from "@app/components/DropdownList.svelte";
+
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";
+

+
  export let baseUrl: BaseUrl;
+

+
  let expanded: boolean = false;
+
  let loading = false;
+
  let seedAddressInput: string = baseUrl.hostname;
+
  let validationMessage: string | undefined = undefined;
+

+
  $: if (expanded === false) {
+
    validationMessage = "";
+
  }
+

+
  async function validateInput(seed: BaseUrl): Promise<string | undefined> {
+
    const api = new HttpdClient(seed);
+
    try {
+
      await api.getNode();
+
    } catch (e) {
+
      console.warn(e);
+
      return "Seed node isn't reachable";
+
    }
+
  }
+

+
  async function navigateToSeed() {
+
    loading = true;
+
    const seed = {
+
      hostname: seedAddressInput.trim(),
+
      port: config.nodes.defaultHttpdPort,
+
      scheme: config.nodes.defaultHttpdScheme,
+
    };
+
    validationMessage = await validateInput(seed);
+
    if (validationMessage === undefined) {
+
      closeFocused();
+
      if (isEqual(baseUrl, seed)) {
+
        loading = false;
+
        return;
+
      }
+
      void push({
+
        resource: "nodes",
+
        params: { baseUrl: seed, projectPageIndex: 0 },
+
      });
+
      selectedSeed.set(seed);
+
    }
+
    loading = false;
+
  }
+

+
  function selectSeed(seed: BaseUrl) {
+
    closeFocused();
+
    selectedSeed.set(seed);
+
    seedAddressInput = seed.hostname;
+
    void push({
+
      resource: "nodes",
+
      params: { baseUrl: seed, projectPageIndex: 0 },
+
    });
+
  }
+
</script>
+

+
<style>
+
  .container {
+
    justify-content: space-between;
+
    width: 100%;
+
  }
+
  .popover {
+
    display: flex;
+
    flex-direction: column;
+
  }
+

+
  .validation-message {
+
    color: var(--color-foreground-red);
+
    margin-top: 0.5rem;
+
    margin-left: 0.5rem;
+
    display: flex;
+
    align-items: center;
+
    gap: 0.25rem;
+
  }
+

+
  .flex-wrapper {
+
    display: flex;
+
    justify-content: space-between;
+
    align-items: center;
+
    width: 100%;
+
  }
+

+
  .target {
+
    display: flex;
+
    flex-direction: row;
+
    gap: 0.5rem;
+
    max-width: 19.5rem;
+
  }
+

+
  @media (max-width: 1010.98px) {
+
    .target {
+
      max-width: 10rem;
+
    }
+
    .container {
+
      justify-content: flex-start;
+
    }
+
  }
+

+
  @media (max-width: 719.98px) {
+
    .target {
+
      max-width: 14.5rem;
+
    }
+
  }
+
</style>
+

+
<div class="global-flex-item container" style:width="100%">
+
  <Popover
+
    bind:expanded
+
    popoverPositionTop="2.5rem"
+
    popoverPadding="0.25rem"
+
    popoverBorderRadius="var(--border-radius-small)">
+
    <div class="target" slot="toggle" title="Switch preferred seed" let:toggle>
+
      <div class="txt-medium txt-semibold txt-overflow">
+
        {baseUrl.hostname}
+
      </div>
+
      <IconButton on:click={toggle} ariaLabel="Toggle seed selector dropdown">
+
        <Icon name={expanded ? "chevron-up" : "chevron-down"} />
+
      </IconButton>
+
    </div>
+

+
    <svelte:fragment slot="popover">
+
      <div style:width="16rem">
+
        <div class="txt-small" style:margin="0.5rem 0.5rem">
+
          Navigate to seed
+
        </div>
+
        <div style:padding="0 0.25rem">
+
          <TextInput
+
            autofocus
+
            autoselect
+
            bind:value={seedAddressInput}
+
            name="seed"
+
            placeholder="seed.radicle.example"
+
            {loading}
+
            on:submit={navigateToSeed} />
+
        </div>
+
        {#if validationMessage}
+
          <span class="validation-message txt-small">{validationMessage}</span>
+
        {/if}
+
        <div class="popover" style:padding-top="0.75rem">
+
          <DropdownList items={$bookmarkedSeeds} styleDropdownPadding="0">
+
            <DropdownListItem
+
              slot="item"
+
              let:item
+
              style="height: 2.5rem"
+
              on:click={() => {
+
                selectSeed(item);
+
              }}
+
              selected={isEqual(baseUrl, item)}>
+
              <div class="flex-wrapper">
+
                <div class="global-flex-item txt-overflow">
+
                  <Icon name="seedling" />
+
                  <div class="txt-overflow">{item.hostname}</div>
+
                </div>
+
                <IconButton
+
                  ariaLabel="Remove bookmark"
+
                  stopPropagation
+
                  on:click={() => removeBookmark(item)}>
+
                  <Icon name="bookmark-on" />
+
                </IconButton>
+
              </div>
+
            </DropdownListItem>
+
          </DropdownList>
+

+
          <DropdownList items={config.preferredSeeds}>
+
            <DropdownListItem
+
              style="height: 2.5rem"
+
              on:click={() => selectSeed(item)}
+
              slot="item"
+
              selected={isEqual(baseUrl, item)}
+
              let:item>
+
              <div class="flex-wrapper">
+
                <div class="global-flex-item txt-overflow">
+
                  <Icon name="seedling" />
+
                  <div class="txt-overflow">{item.hostname}</div>
+
                </div>
+
                <IconButton disabled title="Default seeds can't be removed">
+
                  <Icon name="bookmark-on" />
+
                </IconButton>
+
              </div>
+
            </DropdownListItem>
+
          </DropdownList>
+
          {#if !$bookmarkedSeeds.length && !config.preferredSeeds.length}
+
            <span class="txt-missing txt-small" style:padding="0.5rem">
+
              No default or bookmarked seeds
+
            </span>
+
          {/if}
+
        </div>
+
      </div>
+
    </svelte:fragment>
+
  </Popover>
+

+
  <IconButton
+
    ariaLabel={some($bookmarkedSeeds, baseUrl) ||
+
    some(config.preferredSeeds, baseUrl)
+
      ? "Remove bookmark"
+
      : "Add bookmark"}
+
    stopPropagation
+
    disabled={some(config.preferredSeeds, baseUrl)}
+
    title={some(config.preferredSeeds, baseUrl)
+
      ? "Default seeds can't be removed"
+
      : undefined}
+
    on:click={() => {
+
      if (some($bookmarkedSeeds, baseUrl)) {
+
        removeBookmark(baseUrl);
+
      } else {
+
        addBookmark(baseUrl);
+
      }
+
    }}>
+
    {#if some($bookmarkedSeeds, baseUrl) || some(config.preferredSeeds, baseUrl)}
+
      <Icon name="bookmark-on" />
+
    {:else}
+
      <Icon name="bookmark-off" />
+
    {/if}
+
  </IconButton>
+
</div>
added src/views/nodes/SeedSelector.ts
@@ -0,0 +1,52 @@
+
import type { BaseUrl } from "@http-client";
+

+
import isEqual from "lodash/isEqual";
+
import storedWritable from "@efstajas/svelte-stored-writable";
+
import { array, number, string, object } from "zod";
+
import { get } from "svelte/store";
+

+
import config from "virtual:config";
+

+
const seedSchema = object({
+
  hostname: string(),
+
  port: number(),
+
  scheme: string(),
+
});
+

+
// Seed that is opened on cold app start on the landing page.
+
export const selectedSeed = storedWritable<BaseUrl | undefined>(
+
  "selectedSeed",
+
  seedSchema,
+
  undefined,
+
  !window.localStorage,
+
);
+

+
// A list of seeds that the user has explicitly bookmarked.
+
export const bookmarkedSeeds = storedWritable<BaseUrl[]>(
+
  "bookmarkedSeeds",
+
  array(seedSchema),
+
  [],
+
  !window.localStorage,
+
);
+

+
export function removeBookmark(seed: BaseUrl) {
+
  bookmarkedSeeds.update(previous => previous.filter(x => !isEqual(x, seed)));
+
}
+

+
export function addBookmark(seed: BaseUrl) {
+
  bookmarkedSeeds.update(previous => [...previous, seed]);
+
}
+

+
// First, try using a seed that was selected by the user previously,
+
// if that fails fall back to the first configured seed,
+
// if no seeds are configured, fall back to a hardcoded seed.
+
export function determineSeed() {
+
  return (
+
    get(selectedSeed) ??
+
    config.preferredSeeds[0] ?? {
+
      schema: "https",
+
      hostname: "seed.radicle.xyz",
+
      port: 443,
+
    }
+
  );
+
}
modified src/views/nodes/View.svelte
@@ -21,7 +21,7 @@
  import ProjectCard from "@app/components/ProjectCard.svelte";

  import PolicyExplainer from "./PolicyExplainer.svelte";
-
  import PreferredSeedDropdown from "./PreferredSeedDropdown.svelte";
+
  import SeedSelector from "./SeedSelector.svelte";
  import Seeding from "./Seeding.svelte";
  import UserAgent from "./UserAgent.svelte";
  import NodeAddress from "./NodeAddress.svelte";
@@ -261,13 +261,9 @@
            src={node.avatarUrl
              ? node.avatarUrl
              : "/images/default-seed-avatar.png"} />
-
          <div>
+
          <div style:width="100%">
            <div class="global-flex-item desktop-hostname">
-
              <PreferredSeedDropdown>
-
                <div class="txt-medium txt-semibold txt-overflow">
-
                  {baseUrl.hostname}
-
                </div>
-
              </PreferredSeedDropdown>
+
              <SeedSelector {baseUrl} />
            </div>
            <NodeAddress {node} />
          </div>
@@ -384,11 +380,7 @@
                : "/images/default-seed-avatar.png"} />
            <div>
              <div class="global-flex-item">
-
                <PreferredSeedDropdown>
-
                  <div class="txt-medium txt-semibold txt-overflow">
-
                    {baseUrl.hostname}
-
                  </div>
-
                </PreferredSeedDropdown>
+
                <SeedSelector {baseUrl} />
              </div>
              <NodeAddress {node} />
            </div>
modified src/views/nodes/router.ts
@@ -7,8 +7,7 @@ import { ResponseError, ResponseParseError } from "@http-client/lib/fetcher";
import { baseUrlToString, isLocal } from "@app/lib/utils";
import { handleError } from "@app/views/nodes/error";
import { unreachableError } from "@app/views/projects/error";
-
import { preferredSeeds } from "@app/lib/seeds";
-
import { get } from "svelte/store";
+
import { determineSeed } from "./SeedSelector";

export type NodesRouteParams =
  | {
@@ -49,7 +48,7 @@ export async function loadNodeRoute(
  if (params) {
    baseUrl = params.baseUrl;
  } else {
-
    baseUrl = get(preferredSeeds).selected;
+
    baseUrl = determineSeed();
  }

  const api = new HttpdClient(baseUrl);
modified tests/e2e/node.spec.ts
@@ -51,22 +51,7 @@ test("show pinned repositories", async ({ page }) => {
  ).toBeVisible();
});

-
test("no duplicate entry for preferred seeds", async ({ page }) => {
-
  await page.goto("/");
-
  await expect(page.getByText("127.0.0.1").first()).toBeVisible();
-

-
  await page.getByTitle("Switch preferred seeds").getByRole("button").click();
-
  await expect(page.getByRole("button", { name: "127.0.0.1" })).toBeVisible();
-

-
  await page.getByPlaceholder("Navigate to seed").fill("127.0.0.1");
-
  await page.getByPlaceholder("Navigate to seed").press("Enter");
-
  await expect(page.getByText("Seed node already added.")).toBeVisible();
-

-
  await page.getByPlaceholder("Navigate to seed").fill("");
-
  await expect(page.getByText("Seed node already added.")).toBeHidden();
-
});
-

-
test("adding and removing a new preferred seed", async ({ page }) => {
+
test("edit seed bookmarks", async ({ page }) => {
  // Proxy requests to seed.example.tld to the local test api.
  await page.route(
    url => url.hostname === "seed.example.tld",
@@ -83,29 +68,54 @@ test("adding and removing a new preferred seed", async ({ page }) => {
  );

  await page.goto("/");
-
  await expect(page.getByText("127.0.0.1").first()).toBeVisible();

-
  await page.getByTitle("Switch preferred seeds").getByRole("button").click();
-
  await expect(page.getByRole("button", { name: "127.0.0.1" })).toBeVisible();
+
  await page
+
    .getByRole("button", { name: "Toggle seed selector dropdown" })
+
    .click();
+
  await expect(page.getByPlaceholder("seed.radicle.example")).toHaveValue(
+
    "127.0.0.1",
+
  );
+
  await expect(
+
    page.getByRole("button", { name: "Default seeds can't be removed" }),
+
  ).toBeVisible();
+
  await expect(page.locator(".dropdown > .dropdown-item")).toHaveCount(1);

-
  await page.getByPlaceholder("Navigate to seed").fill("seed.example.tld");
-
  await page.getByPlaceholder("Navigate to seed").press("Enter");
-
  await expect(page.getByText("seed.example.tld").first()).toBeVisible();
+
  // The input box is focussed, has the text selected and ready to be overwritten.
+
  await page.getByPlaceholder("seed.radicle.example").fill("seed.example.tld");
+
  await page.getByPlaceholder("seed.radicle.example").press("Enter");

-
  await page.getByTitle("Switch preferred seeds").getByRole("button").click();
+
  await expect(page).toHaveURL("/nodes/seed.example.tld");
  await expect(
-
    page.getByRole("button", { name: "seed.example.tld" }),
+
    page.getByRole("button", { name: "Add bookmark" }),
  ).toBeVisible();

-
  // Test that removing the selected seed doesn't end in an undefined state.
  await page
-
    .getByRole("button", { name: "seed.example.tld" })
-
    .getByRole("button")
+
    .getByRole("button", { name: "Toggle seed selector dropdown" })
    .click();
-
  await expect(page.getByText("127.0.0.1").first()).toBeVisible();

-
  await page.getByTitle("Switch preferred seeds").getByRole("button").click();
-
  await expect(
-
    page.getByRole("button", { name: "seed.example.tld" }),
-
  ).toBeHidden();
+
  // After navigating to the seed it should not yet be added to the bookmarks.
+
  await expect(page.locator(".dropdown > .dropdown-item")).toHaveCount(1);
+

+
  await page.getByRole("button", { name: "Add bookmark" }).click();
+
  await expect(page.locator(".dropdown > .dropdown-item")).toHaveCount(2);
+

+
  // Test that new seed is persisted and opened when we go to the landing page.
+
  await page.getByRole("link", { name: "Radicle logo" }).click();
+
  await expect(page.getByText("seed.example.tld").first()).toBeVisible();
+

+
  // Test removing a bookmark.
+
  await page
+
    .getByRole("button", { name: "Toggle seed selector dropdown" })
+
    .click();
+
  await page.getByRole("button", { name: "Remove bookmark" }).nth(1).click();
+
  await expect(page.locator(".dropdown > .dropdown-item")).toHaveCount(1);
+

+
  // Remove the bookmark from within the dropdown.
+
  await page.getByRole("button", { name: "Add bookmark" }).click();
+
  await expect(page.locator(".dropdown > .dropdown-item")).toHaveCount(2);
+
  await page
+
    .getByRole("button", { name: "seed.example.tld" })
+
    .getByRole("button", { name: "Remove bookmark" })
+
    .click();
+
  await expect(page.locator(".dropdown > .dropdown-item")).toHaveCount(1);
});