Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
Add text input to SeedDropdown to navigate
Merged did:key:z6MkkfM3...sVz5 opened 1 year ago
8 files changed +358 -164 0c7ea70c 3d13bc03
modified src/components/Popover.svelte
@@ -8,6 +8,7 @@
</script>

<script lang="ts">
+
  export let popoverContainerMinWidth: string | undefined = undefined;
  export let popoverBorderRadius: string | undefined = undefined;
  export let popoverPadding: string | undefined = undefined;
  export let popoverPositionBottom: string | undefined = undefined;
@@ -53,7 +54,10 @@

<svelte:window on:click={clickOutside} on:touchstart={clickOutside} />

-
<div bind:this={thisComponent} class="container">
+
<div
+
  bind:this={thisComponent}
+
  class="container"
+
  style:min-width={popoverContainerMinWidth}>
  <slot name="toggle" {expanded} {toggle} />

  {#if expanded}
modified src/components/TextInput.svelte
@@ -154,7 +154,9 @@

  <div class="right-container" bind:clientWidth={rightContainerWidth}>
    {#if loading}
-
      <Loading small noDelay />
+
      <div style:padding-right="0.5rem">
+
        <Loading small noDelay />
+
      </div>
    {/if}

    {#if valid && !loading && isFocused && showKeyHint}
modified src/lib/seeds.ts
@@ -1,73 +1,48 @@
import type { BaseUrl } from "@httpd-client";

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

-
import { api, httpdStore, type HttpdState } from "./httpd";
import config from "virtual:config";

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

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

-
async function loadConfiguredPreferredSeeds() {
-
  if (get(httpdStore).state === "stopped") {
-
    configuredPreferredSeeds.set([]);
-
    return;
-
  }
-

-
  const profile = await api.profile.getProfile();
-

-
  let newValue = profile.config.preferredSeeds.map(seed => {
-
    const preferredSeedValue = seed?.split("@")[1];
-
    const preferredSeedOrigin = preferredSeedValue?.split(":")[0];
-

-
    return {
-
      hostname: preferredSeedOrigin,
-
      port: 443,
-
      scheme: "https",
-
    };
-
  });
-

-
  if (newValue.length === 0) {
-
    newValue = [config.fallbackPreferredSeed];
-
  }
-

-
  configuredPreferredSeeds.set(newValue);
-
}
-

-
export function initialize() {
-
  let previousHttpdState: HttpdState["state"] | undefined;
-

-
  httpdStore.subscribe(async v => {
-
    if (previousHttpdState === v.state) return;
-

-
    await loadConfiguredPreferredSeeds();
-

-
    previousHttpdState = v.state;
-
  });
+
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]) => {
-
    // Not loaded yet
-
    if (!configuredPreferredSeeds) return undefined;
-

    // No configured preferred seeds
    if (configuredPreferredSeeds.length === 0)
      return {
@@ -99,24 +74,3 @@ export const preferredSeeds = derived(
    };
  },
);
-

-
export async function waitForLoad(): Promise<{
-
  selected: BaseUrl;
-
  seeds: BaseUrl[];
-
}> {
-
  if (!get(configuredPreferredSeeds)) {
-
    await new Promise<void>(resolve => {
-
      const unsubscribe = preferredSeeds.subscribe(v => {
-
        if (v) {
-
          unsubscribe();
-
          resolve();
-
        }
-
      });
-
    });
-
  }
-

-
  const seeds = get(preferredSeeds);
-
  if (!seeds) throw new Error("Preferred seed undefined after loading");
-

-
  return seeds;
-
}
modified src/views/home/Index.svelte
@@ -1,7 +1,7 @@
<script lang="ts">
  import type { ComponentProps } from "svelte";
  import type { ProjectInfo } from "@app/components/ProjectCard";
-
  import type { ProjectListQuery } from "@httpd-client";
+
  import type { BaseUrl, ProjectListQuery } from "@httpd-client";

  import storedWritable from "@efstajas/svelte-stored-writable";
  import { derived } from "svelte/store";
@@ -23,11 +23,13 @@
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
  import FilterButton from "./components/FilterButton.svelte";
  import HomepageSection from "./components/HomepageSection.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
  import IconSmall from "@app/components/IconSmall.svelte";
  import NewProjectButton from "./components/NewProjectButton.svelte";
  import Popover from "@app/components/Popover.svelte";
  import PreferredSeedDropdown from "./components/PreferredSeedDropdown.svelte";
-
  import IconButton from "@app/components/IconButton.svelte";
+

+
  export let configPreferredSeeds: BaseUrl[];

  const selectedSeed = deduplicateStore(
    derived(preferredSeeds, $ => $?.selected),
@@ -120,33 +122,22 @@
    font-size: var(--font-size-small);
    font-weight: var(--font-weight-regular);
  }
+
  .flex-icon-item {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.5rem;
+
  }
  .project-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(21rem, 1fr));
    gap: 1rem;
  }
-
  .seed {
-
    max-width: 100%;
-
    display: flex;
-
    align-items: center;
-
    gap: 0.125rem;
-
    color: var(--color-foreground-contrast);
-
  }
-
  .seed-name {
-
    max-width: 100%;
-
    overflow: hidden;
-
    text-overflow: ellipsis;
-
  }

  @media (max-width: 719.98px) {
    .wrapper {
      width: 100%;
      padding: 1rem;
    }
-

-
    .seed-dropdown {
-
      display: none;
-
    }
  }
</style>

@@ -216,18 +207,16 @@
      empty={preferredSeedProjects instanceof Error ||
        preferredSeedProjects?.length === 0}
      title="Explore">
+
      <svelte:fragment slot="title">
+
        <div class="flex-icon-item" style:min-width="0">
+
          <span class="txt-large">Explore</span>
+
          <PreferredSeedDropdown
+
            initialPreferredSeeds={configPreferredSeeds}
+
            selectedSeed={$preferredSeeds.selected} />
+
        </div>
+
      </svelte:fragment>
      <svelte:fragment slot="subtitle">
-
        {#if nodeId && $preferredSeeds}
-
          Pinned repositories on your selected seed node
-
        {:else}
-
          Pinned repositories on
-
          <div class="seed">
-
            <IconSmall name="seedling" />
-
            <span class="seed-name">
-
              {$selectedSeed?.hostname}
-
            </span>
-
          </div>
-
        {/if}
+
        Pinned repositories on your selected seed node
        {#if !nodeId}
          <div class="global-hide-on-mobile-down">
            <Popover popoverPositionTop="2.5rem" popoverPositionLeft="0">
@@ -247,15 +236,6 @@
          </div>
        {/if}
      </svelte:fragment>
-
      <svelte:fragment slot="actions">
-
        <div class="seed-dropdown">
-
          {#if nodeId && $preferredSeeds}
-
            <PreferredSeedDropdown
-
              disabled={!nodeId || preferredSeedProjects === undefined}
-
              preferredSeed={$preferredSeeds?.selected} />
-
          {/if}
-
        </div>
-
      </svelte:fragment>
      <svelte:fragment slot="empty">
        <div class="empty-state">
          {#if preferredSeedProjects instanceof Error}
modified src/views/home/components/HomepageSection.svelte
@@ -3,16 +3,11 @@

  export let title: string;
  export let loading = false;
-

  export let empty: boolean = false;
</script>

<style>
  .section-header {
-
    display: flex;
-
    flex-wrap: wrap;
-
    justify-content: space-between;
-
    align-items: center;
    margin-bottom: 1.5rem;
  }
  .title {
@@ -62,7 +57,9 @@
<section>
  <div class="section-header">
    <div class="title">
-
      <h2>{title}</h2>
+
      <slot name="title">
+
        <h2>{title}</h2>
+
      </slot>
      <div class="actions">
        <slot name="actions" />
      </div>
modified src/views/home/components/PreferredSeedDropdown.svelte
@@ -1,42 +1,78 @@
<script lang="ts">
-
  import type { BaseUrl } from "@httpd-client";
+
  import { HttpdClient, type BaseUrl } from "@httpd-client";

+
  import config from "virtual:config";
  import {
+
    addSeedsToConfiguredSeeds,
+
    configuredPreferredSeeds,
    preferredSeeds as preferredSeedsStore,
+
    removeSeedFromConfiguredSeeds,
    selectPreferredSeed,
  } from "@app/lib/seeds";
  import { closeFocused } from "@app/components/Popover.svelte";
+
  import { httpdStore } from "@app/lib/httpd";

-
  import Popover from "@app/components/Popover.svelte";
-
  import Button from "@app/components/Button.svelte";
-
  import IconSmall from "@app/components/IconSmall.svelte";
+
  import Command from "@app/components/Command.svelte";
  import DropdownList from "@app/components/DropdownList.svelte";
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
-
  import Command from "@app/components/Command.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";

-
  export let preferredSeed: BaseUrl;
-
  export let disabled = false;
+
  export let initialPreferredSeeds: BaseUrl[];
+
  export let selectedSeed: BaseUrl;

-
  $: stateOptions = $preferredSeedsStore?.seeds;
+
  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 = $preferredSeedsStore.seeds;
+
  let valid = true;
+
  let submittingInput = false;
+
  let validationMessage: undefined | string = undefined;
+
  let customSeed: string = "";
  let expanded = false;
</script>

<style>
  .popover {
-
    width: 16rem;
    display: flex;
    flex-direction: column;
-
    gap: 1rem;
  }

-
  .label {
-
    overflow: hidden;
-
    text-overflow: ellipsis;
-
    white-space: nowrap;
+
  .validation-message {
+
    color: var(--color-foreground-red);
+
    margin-top: 0.5rem;
+
    margin-left: 0.5rem;
    display: flex;
    align-items: center;
-
    gap: 0.5rem;
+
    gap: 0.25rem;
+
  }
+

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

  .divider {
@@ -53,53 +89,127 @@
    padding: 0.5rem;
    color: var(--color-foreground-dim);
  }
+
  .icon-item {
+
    display: flex;
+
    gap: 0.5rem;
+
    align-items: center;
+
  }
</style>

<Popover
  bind:expanded
+
  popoverContainerMinWidth="0"
  popoverPositionTop="2.5rem"
  popoverPositionLeft="-0.25rem"
  popoverPadding="0.25rem"
  popoverBorderRadius="var(--border-radius-small)">
-
  <Button
-
    variant="outline"
+
  <div
+
    class="icon-item"
    slot="toggle"
-
    let:toggle
-
    on:click={toggle}
-
    title="Change peer"
-
    {disabled}>
-
    <IconSmall name="seedling" />
-
    {preferredSeed.hostname}
-
    <IconSmall name={expanded ? "chevron-up" : "chevron-down"} />
-
  </Button>
+
    title="Switch preferred seeds"
+
    let:toggle>
+
    <div class="txt-large txt-bold txt-overflow">{selectedSeed.hostname}</div>
+
    <IconButton on:click={toggle}>
+
      <IconSmall name={expanded ? "chevron-up" : "chevron-down"} />
+
    </IconButton>
+
  </div>

  <svelte:fragment slot="popover">
-
    <div class="popover">
-
      {#if stateOptions}
-
        <DropdownList items={stateOptions}>
-
          <DropdownListItem
-
            let:item
-
            on:click={() => {
-
              selectPreferredSeed(item);
-
              closeFocused();
-
            }}
-
            slot="item"
-
            selected={item.hostname === preferredSeed.hostname}>
-
            <div class="label">
-
              <IconSmall name="seedling" />
-
              {item.hostname}
-
            </div>
-
          </DropdownListItem>
-
        </DropdownList>
+
    <div style:width="16rem">
+
      <TextInput
+
        {valid}
+
        name="seed"
+
        bind:value={customSeed}
+
        loading={submittingInput}
+
        placeholder="Navigate to seed URL"
+
        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();
+
          } else {
+
            submittingInput = false;
+
          }
+
        }} />
+
      {#if validationMessage}
+
        <span class="validation-message txt-small">{validationMessage}</span>
      {/if}
-
    </div>
-
    <div class="divider" />
-
    <div class="add-seed-node-instructions txt-small">
-
      <div class="" style:font-weight="bold">Add a different seed node</div>
-
      <div class="">
-
        Update preferred seeds in your Radicle config and restart httpd.
+
      <div class="divider" />
+
      <div class="popover">
+
        {#if stateOptions}
+
          <DropdownList items={stateOptions}>
+
            <DropdownListItem
+
              let:item
+
              on:click={() => {
+
                selectPreferredSeed(item);
+
                closeFocused();
+
              }}
+
              slot="item"
+
              selected={item.hostname === selectedSeed.hostname}>
+
              <div class="dropdown-item">
+
                <div class="icon-item" style:min-width="0">
+
                  <IconSmall name="seedling" />
+
                  <div class="txt-overflow">
+
                    {item.hostname}
+
                  </div>
+
                </div>
+
                {#if stateOptions && stateOptions.length > 1}
+
                  <IconButton
+
                    on:click={() => {
+
                      removeSeedFromConfiguredSeeds(item.hostname);
+
                      selectPreferredSeed(config.fallbackPreferredSeed);
+
                    }}>
+
                    <IconSmall name="cross" />
+
                  </IconButton>
+
                {/if}
+
              </div>
+
            </DropdownListItem>
+
            <DropdownListItem
+
              on:click={() => {
+
                selectPreferredSeed(config.fallbackPreferredSeed);
+
                closeFocused();
+
              }}
+
              slot="empty"
+
              selected>
+
              <div class="dropdown-item">
+
                <div class="icon-item" style:min-width="0">
+
                  <IconSmall name="seedling" />
+
                  <div class="txt-overflow">
+
                    {config.fallbackPreferredSeed.hostname}
+
                  </div>
+
                </div>
+
              </div>
+
            </DropdownListItem>
+
          </DropdownList>
+
        {/if}
      </div>
-
      <Command fullWidth command="rad config edit" />
+
      {#if $httpdStore.state !== "stopped" && !initialPreferredSeeds.find(s => s.hostname === selectedSeed.hostname)}
+
        <div class="divider" />
+
        <div class="add-seed-node-instructions txt-small">
+
          <div class="txt-bold">Store in config</div>
+
          <div>
+
            Add <code style:word-break="break-all">
+
              {selectedSeed.hostname}
+
            </code>
+
            to your
+
            <code>preferredSeeds</code>
+
            in your Radicle config and restart httpd.
+
          </div>
+
          <Command fullWidth command="rad config edit" />
+
        </div>
+
      {/if}
    </div>
  </svelte:fragment>
</Popover>
modified src/views/home/router.ts
@@ -1,6 +1,10 @@
+
import type { BaseUrl } from "@httpd-client";
import type { ErrorRoute } from "@app/lib/router/definitions";

import * as seeds from "@app/lib/seeds";
+
import config from "virtual:config";
+
import { api, httpdStore } from "@app/lib/httpd";
+
import { get } from "svelte/store";

export interface HomeRoute {
  resource: "home";
@@ -8,15 +12,34 @@ export interface HomeRoute {

export interface HomeLoadedRoute {
  resource: "home";
-
  params: Record<string, never>;
+
  params: { configPreferredSeeds: BaseUrl[] };
}

export async function loadHomeRoute(): Promise<HomeLoadedRoute | ErrorRoute> {
-
  seeds.initialize();
-
  await seeds.waitForLoad();
+
  if (get(httpdStore).state !== "stopped") {
+
    const profile = await api.profile.getProfile();
+
    const newValue = profile.config.preferredSeeds.map(seed => {
+
      const preferredSeedValue = seed?.split("@")[1];
+
      const preferredSeedOrigin = preferredSeedValue?.split(":")[0];
+

+
      return {
+
        hostname: preferredSeedOrigin,
+
        port: config.nodes.defaultHttpdPort,
+
        scheme: config.nodes.defaultHttpdScheme,
+
      };
+
    });
+
    if (get(seeds.configuredPreferredSeeds).length === 0) {
+
      seeds.addSeedsToConfiguredSeeds(newValue);
+
    }
+

+
    return {
+
      resource: "home",
+
      params: { configPreferredSeeds: newValue },
+
    };
+
  }

  return {
    resource: "home",
-
    params: {},
+
    params: { configPreferredSeeds: [] },
  };
}
modified tests/e2e/landingPage.spec.ts
@@ -13,3 +13,127 @@ test("show pinned projects", async ({ page }) => {
    page.getByText("Git repository for source browsing tests"),
  ).toBeVisible();
});
+

+
test("no duplicate entry for preferred seeds", async ({ page }) => {
+
  await page.goto("/");
+
  await expect(page.getByText("seed.radicle.garden")).toBeVisible();
+

+
  await page.getByTitle("Switch preferred seeds").getByRole("button").click();
+
  await expect(
+
    page.getByRole("button", { name: "seed.radicle.garden" }),
+
  ).toBeVisible();
+

+
  await page
+
    .getByPlaceholder("Navigate to seed URL")
+
    .fill("seed.radicle.garden");
+
  await page.getByPlaceholder("Navigate to seed URL").press("Enter");
+
  await expect(page.getByText("Seed node already added.")).toBeVisible();
+

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

+
test("adding and removing a new preferred seed", async ({ page }) => {
+
  await page.route(
+
    ({ hostname }) => hostname === "seed.rhizoma.dev",
+
    route => route.fulfill({ json: nodeInfo }),
+
  );
+

+
  await page.goto("/");
+
  await expect(page.getByText("seed.radicle.garden")).toBeVisible();
+

+
  await page.getByTitle("Switch preferred seeds").getByRole("button").click();
+
  await expect(
+
    page.getByRole("button", { name: "seed.radicle.garden" }),
+
  ).toBeVisible();
+

+
  await page.getByPlaceholder("Navigate to seed URL").fill("seed.rhizoma.dev");
+
  await page.getByPlaceholder("Navigate to seed URL").press("Enter");
+
  await expect(page.getByText("seed.rhizoma.dev")).toBeVisible();
+

+
  await page.getByTitle("Switch preferred seeds").getByRole("button").click();
+
  await expect(
+
    page.getByRole("button", { name: "seed.rhizoma.dev" }),
+
  ).toBeVisible();
+

+
  // Test that removing the selected seed doesn't end in an undefined state.
+
  await page
+
    .getByRole("button", { name: "seed.rhizoma.dev" })
+
    .getByRole("button")
+
    .click();
+
  await expect(page.getByText("seed.radicle.garden")).toBeVisible();
+

+
  await page.getByTitle("Switch preferred seeds").getByRole("button").click();
+
  await expect(
+
    page.getByRole("button", { name: "seed.rhizoma.dev" }),
+
  ).toBeHidden();
+
});
+

+
test("stored custom preferred seeds in local storage", async ({ page }) => {
+
  await page.addInitScript(() =>
+
    localStorage.setItem(
+
      "configuredPreferredSeeds",
+
      '[{"hostname":"seed.radicle.xyz","port":443,"scheme":"https"},{"hostname":"seed.rhizoma.dev","port":443,"scheme":"https"}]',
+
    ),
+
  );
+
  await page.goto("/");
+
  await expect(page.getByText("seed.radicle.xyz")).toBeVisible();
+

+
  await page.getByTitle("Switch preferred seeds").getByRole("button").click();
+
  await expect(
+
    page.getByRole("button", { name: "seed.radicle.xyz" }),
+
  ).toBeVisible();
+
  await expect(
+
    page.getByRole("button", { name: "seed.rhizoma.dev" }),
+
  ).toBeVisible();
+
  // Check that the fallback node hasn't been added on load.
+
  await expect(
+
    page.getByRole("button", { name: "seed.radicle.garden" }),
+
  ).toBeHidden();
+
});
+

+
const nodeInfo = {
+
  id: "z6MkkGfMNQmjrp66Po2n4snzcSyTFRFw1m1fbYhCURxLxZpD",
+
  version: "1.0.0-rc.9-d56d619f",
+
  config: {
+
    alias: "rhizoma",
+
    listen: [],
+
    peers: {
+
      type: "dynamic",
+
      target: 0,
+
    },
+
    connect: [],
+
    externalAddresses: ["seed.rhizoma.dev:8776"],
+
    db: {
+
      journalMode: "wal",
+
    },
+
    network: "main",
+
    log: "INFO",
+
    relay: "auto",
+
    limits: {
+
      routingMaxSize: 1000,
+
      routingMaxAge: 604800,
+
      gossipMaxAge: 1209600,
+
      fetchConcurrency: 1,
+
      maxOpenFiles: 4096,
+
      rate: {
+
        inbound: {
+
          fillRate: 2,
+
          capacity: 128,
+
        },
+
        outbound: {
+
          fillRate: 5,
+
          capacity: 256,
+
        },
+
      },
+
      connection: {
+
        inbound: 128,
+
        outbound: 16,
+
      },
+
    },
+
    workers: 32,
+
    policy: "block",
+
    scope: "all",
+
  },
+
  state: "running",
+
};