Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
Simplify sidebar context boxes
Sebastian Martinez committed 1 year ago
commit b8171e3a3f819251d9132258a494100eb5e473c3
parent e4b15e7
8 files changed +154 -364
modified http-client/index.ts
@@ -12,6 +12,7 @@ import type {
  SuccessResponse,
  CodeLocation,
  Range,
+
  SeedingPolicy,
  DefaultSeedingPolicy,
} from "./lib/shared.js";
import type { Comment, Embed, Reaction } from "./lib/project/comment.js";
@@ -50,6 +51,7 @@ import { Fetcher } from "./lib/fetcher.js";
import {
  nodeConfigSchema,
  scopeSchema,
+
  seedingPolicySchema,
  successResponseSchema,
} from "./lib/shared.js";

@@ -85,6 +87,7 @@ export type {
  Remote,
  Review,
  Revision,
+
  SeedingPolicy,
  TreeStats,
  Tree,
  Verdict,
@@ -211,14 +214,14 @@ export class HttpdClient {
  public async getPoliciesById(
    id: string,
    options?: RequestOptions,
-
  ): Promise<NodePolicies["policy"][]> {
+
  ): Promise<SeedingPolicy> {
    return this.#fetcher.fetchOk(
      {
        method: "GET",
        path: `node/policies/repos/${id}`,
        options,
      },
-
      array(nodePoliciesSchema.shape.policy),
+
      seedingPolicySchema,
    );
  }

modified http-client/lib/shared.ts
@@ -12,6 +12,18 @@ export const successResponseSchema = object({

export const scopeSchema = union([literal("followed"), literal("all")]);

+
export const seedingPolicySchema = union([
+
  object({
+
    policy: literal("block"),
+
  }),
+
  object({
+
    policy: literal("allow"),
+
    scope: scopeSchema,
+
  }),
+
]);
+

+
export type SeedingPolicy = z.infer<typeof seedingPolicySchema>;
+

const defaultSeedingPolicySchema = union([
  object({
    default: literal("block"),
modified src/lib/utils.ts
@@ -1,4 +1,4 @@
-
import type { BaseUrl, DefaultSeedingPolicy } from "@http-client";
+
import type { BaseUrl } from "@http-client";

import md5 from "md5";
import bs58 from "bs58";
@@ -13,14 +13,6 @@ export function formatLocationHash(hash: string | null): number | null {
  return null;
}

-
export function formatShortSeedingPolicy(
-
  policy: DefaultSeedingPolicy | undefined,
-
) {
-
  return policy?.default === "allow" && policy?.scope === "all"
-
    ? "permissive"
-
    : "restrictive";
-
}
-

// Removes the first and last character which are always `/`.
export function formatUserAgent(agent: string): string {
  return agent.slice(1, -1);
modified src/views/nodes/View.svelte
@@ -7,7 +7,6 @@
  import { api, httpdStore } from "@app/lib/httpd";
  import {
    baseUrlToString,
-
    formatShortSeedingPolicy,
    formatUserAgent,
    isLocal,
    truncateId,
@@ -32,7 +31,10 @@
  export let seedingPolicy: DefaultSeedingPolicy | undefined = undefined;
  export let agent: string;

-
  $: shortScope = formatShortSeedingPolicy(seedingPolicy);
+
  $: shortScope =
+
    seedingPolicy?.default === "allow" && seedingPolicy?.scope === "all"
+
      ? "permissive"
+
      : "restrictive";
  $: hostname = isLocal(baseUrl.hostname) ? "Local Node" : baseUrl.hostname;
  $: session =
    $httpdStore.state === "authenticated" && isLocal(api.baseUrl.hostname)
modified src/views/projects/Sidebar.svelte
@@ -2,22 +2,12 @@
  import type { ActiveTab } from "./Header.svelte";
  import type { BaseUrl, Node, Project } from "@http-client";

-
  import { onMount } from "svelte";
-

-
  import { experimental } from "@app/lib/appearance";
-
  import { httpdStore, api } from "@app/lib/httpd";
-
  import { isLocal } from "@app/lib/utils";
-
  import { queryProject } from "@app/lib/projects";
-

  import Button from "@app/components/Button.svelte";
-
  import ContextHelp from "@app/views/projects/Sidebar/ContextHelp.svelte";
  import ContextRepo from "@app/views/projects/Sidebar/ContextRepo.svelte";
+
  import Help from "@app/App/Help.svelte";
  import IconSmall from "@app/components/IconSmall.svelte";
  import Link from "@app/components/Link.svelte";
-
  import Loading from "@app/components/Loading.svelte";
  import Popover from "@app/components/Popover.svelte";
-

-
  import Help from "@app/App/Help.svelte";
  import Settings from "@app/App/Settings.svelte";

  const SIDEBAR_STATE_KEY = "sidebarState";
@@ -37,20 +27,6 @@
    );
  }

-
  // To avoid concurrent request.
-
  let queryingLocalProject: boolean = true;
-
  let localProject: "notFound" | "found" | undefined = undefined;
-
  $: hideContextHelp =
-
    $experimental &&
-
    isLocal(baseUrl.hostname) &&
-
    $httpdStore.state === "authenticated";
-

-
  httpdStore.subscribe(async () => {
-
    if ($httpdStore.state !== "stopped" && !queryingLocalProject) {
-
      await detectLocalProject();
-
    }
-
  });
-

  function loadSidebarState(): boolean {
    const storedSidebarState = window.localStorage.getItem(SIDEBAR_STATE_KEY);

@@ -66,19 +42,7 @@
    storeSidebarState(expanded);
  }

-
  async function detectLocalProject(): Promise<void> {
-
    queryingLocalProject = true;
-
    localProject = await queryProject(api.baseUrl, project.id);
-
    queryingLocalProject = false;
-
  }
-

-
  onMount(async () => {
-
    if ($httpdStore.state !== "stopped") {
-
      await detectLocalProject();
-
    } else {
-
      localProject = "notFound";
-
    }
-
  });
+
  $: seedingPolicy = formatShortSeedingPolicy(node.config?.seedingPolicy);
</script>

<style>
@@ -132,17 +96,13 @@
    justify-content: space-between;
    width: 100%;
  }
-
  .repo,
-
  .help {
+
  .repo {
    z-index: 10;
-
    opacity: 1;
-
    transition:
-
      opacity 150ms,
-
      display 150ms allow-discrete;
-
    transition-delay: 150ms;
+
    opacity: 0;
+
    height: 0;
+
    overflow: hidden;
  }
-
  .help-box {
-
    width: 100%;
+
  .box {
    padding: 1rem;
    margin-bottom: 0.5rem;
    background-color: var(--color-background-float);
@@ -150,20 +110,27 @@
    font-size: var(--font-size-small);
    border-radius: var(--border-radius-small);
  }
-
  .repo-box {
-
    margin-bottom: 0.5rem;
+
  .repo.expanded {
+
    opacity: 1;
+
    height: initial;
+
    overflow: initial;
+
    transition: opacity 150ms;
+
    transition-delay: 150ms;
  }
  .vertical-buttons {
    opacity: 1;
    height: fit-content;
    display: flex;
    flex-direction: column-reverse;
-
    transition:
-
      opacity 150ms ease-in-out,
-
      display 150ms ease-in-out allow-discrete;
+
    transition: opacity 150ms ease-in-out;
    transition-delay: 60ms;
    margin-bottom: 0.5rem;
  }
+
  .vertical-buttons.expanded {
+
    opacity: 0;
+
    height: 0;
+
    overflow: hidden;
+
  }
  .horizontal-buttons {
    display: flex;
    gap: 0.5rem;
@@ -210,7 +177,7 @@
        stylePadding="0.5rem 0.75rem"
        size="large"
        styleWidth="100%"
-
        styleJustifyContent={"flex-start"}
+
        styleJustifyContent="flex-start"
        variant={activeTab === "source" ? "gray" : "background"}>
        <IconSmall name="chevron-left-right" />
        <span class="title-counter" class:expanded>Source</span>
@@ -227,7 +194,7 @@
        stylePadding="0.5rem 0.75rem"
        let:hover
        size="large"
-
        styleJustifyContent={"flex-start"}
+
        styleJustifyContent="flex-start"
        styleWidth="100%"
        variant={activeTab === "issues" ? "gray" : "background"}>
        <IconSmall name="issue" />
@@ -255,7 +222,7 @@
        let:hover
        size="large"
        styleWidth="100%"
-
        styleJustifyContent={"flex-start"}
+
        styleJustifyContent="flex-start"
        variant={activeTab === "patches" ? "gray" : "background"}>
        <IconSmall name="patch" />
        <div class="title-counter" class:expanded>
@@ -272,110 +239,60 @@
  </div>
  <!-- Context and other information section -->
  <div class="bottom">
-
    {#if expanded}
-
      <div class="help">
-
        {#if !hideContextHelp && $experimental}
-
          {#if !localProject}
-
            <div
-
              style="display: flex; justify-content: center; align-items: center; height: 2rem;">
-
              <Loading small />
-
            </div>
-
          {:else}
-
            <div class="help-box">
-
              <ContextHelp
-
                {localProject}
-
                {baseUrl}
-
                projectId={project.id}
-
                hideLocalButton={isLocal(baseUrl.hostname) ||
-
                  localProject !== "found"}
-
                disableLocalButton={$httpdStore.state !== "authenticated"} />
-
            </div>
-
          {/if}
-
        {/if}
-
      </div>
-
      <div class="repo">
-
        <div class="repo-box">
-
          <ContextRepo {project} {baseUrl} {node} />
-
        </div>
-
      </div>
-
    {:else}
-
      <div class="vertical-buttons" style:gap="0.5rem">
-
        <Popover popoverPositionBottom="0" popoverPositionLeft="3rem">
-
          <Button
-
            stylePadding="0 0.75rem"
-
            variant="background"
-
            title="Settings"
-
            slot="toggle"
-
            let:toggle
-
            on:click={toggle}>
-
            <IconSmall name="settings" />
-
          </Button>
-

-
          <Settings slot="popover" />
-
        </Popover>
-

-
        <Popover popoverPositionBottom="0" popoverPositionLeft="3rem">
-
          <Button
-
            stylePadding="0 0.75rem"
-
            variant="background"
-
            title="Help"
-
            slot="toggle"
-
            let:toggle
-
            on:click={toggle}>
-
            <IconSmall name="help" />
-
          </Button>
+
    <div class="repo box" class:expanded>
+
      <ContextRepo
+
        projectThreshold={project.threshold}
+
        projectDelegates={project.delegates}
+
        {seedingPolicy} />
+
    </div>
+
    <div class="vertical-buttons" class:expanded style:gap="0.5rem">
+
      <Popover popoverPositionBottom="0" popoverPositionLeft="3rem">
+
        <Button
+
          stylePadding="0 0.75rem"
+
          variant="background"
+
          title="Settings"
+
          slot="toggle"
+
          let:toggle
+
          on:click={toggle}>
+
          <IconSmall name="settings" />
+
        </Button>

-
          <Help slot="popover" />
-
        </Popover>
+
        <Settings slot="popover" />
+
      </Popover>

-
        <Popover popoverPositionBottom="0" popoverPositionLeft="2rem">
-
          <Button
-
            stylePadding="0 0.75rem"
-
            variant="background"
-
            title="Info"
-
            slot="toggle"
-
            let:toggle
-
            on:click={toggle}>
-
            <IconSmall name="info" />
-
          </Button>
+
      <Popover popoverPositionBottom="0" popoverPositionLeft="3rem">
+
        <Button
+
          stylePadding="0 0.75rem"
+
          variant="background"
+
          title="Help"
+
          slot="toggle"
+
          let:toggle
+
          on:click={toggle}>
+
          <IconSmall name="help" />
+
        </Button>

-
          <div slot="popover">
-
            <ContextRepo disablePopovers {node} {baseUrl} {project} />
-
          </div>
-
        </Popover>
+
        <Help slot="popover" />
+
      </Popover>

-
        {#if !hideContextHelp && $experimental}
-
          {#if !localProject}
-
            <div
-
              style="display: flex; justify-content: center; align-items: center; height: 2rem;">
-
              <Loading small condensed />
-
            </div>
-
          {:else}
-
            <Popover popoverPositionBottom="0" popoverPositionLeft="3rem">
-
              <Button
-
                stylePadding="0 0.75rem"
-
                variant="background"
-
                title="Local node"
-
                slot="toggle"
-
                let:toggle
-
                on:click={toggle}>
-
                <IconSmall name="device" />
-
              </Button>
+
      <Popover popoverPositionBottom="0" popoverPositionLeft="3rem">
+
        <Button
+
          stylePadding="0 0.75rem"
+
          variant="background"
+
          title="Info"
+
          slot="toggle"
+
          let:toggle
+
          on:click={toggle}>
+
          <IconSmall name="info" />
+
        </Button>

-
              <ContextHelp
-
                {localProject}
-
                {baseUrl}
-
                popover
-
                projectId={project.id}
-
                hideLocalButton={isLocal(baseUrl.hostname) ||
-
                  localProject !== "found"}
-
                disableLocalButton={$httpdStore.state !== "authenticated"}
-
                slot="popover" />
-
            </Popover>
-
          {/if}
-
        {/if}
-
      </div>
-
    {/if}
+
        <div slot="popover" class="txt-small" style:width="18rem">
+
          <ContextRepo
+
            projectThreshold={project.threshold}
+
            projectDelegates={project.delegates}
+
            {seedingPolicy} />
+
        </div>
+
      </Popover>
+
    </div>
    <!-- Footer -->
    {#if !collapsedOnly}
      <div class="sidebar-footer" style:flex-direction="row">
deleted src/views/projects/Sidebar/ContextHelp.svelte
@@ -1,91 +0,0 @@
-
<script lang="ts">
-
  import type { BaseUrl } from "@http-client";
-
  import type { Route } from "@app/lib/router/definitions";
-

-
  import { activeUnloadedRouteStore } from "@app/lib/router";
-
  import { api, httpdStore } from "@app/lib/httpd";
-
  import { isLocal } from "@app/lib/utils";
-

-
  import Button from "@app/components/Button.svelte";
-
  import Command from "@app/components/Command.svelte";
-
  import ExternalLink from "@app/components/ExternalLink.svelte";
-
  import IconSmall from "@app/components/IconSmall.svelte";
-
  import Link from "@app/components/Link.svelte";
-

-
  export let baseUrl: BaseUrl;
-
  export let disableLocalButton: boolean;
-
  export let hideLocalButton: boolean;
-
  export let localProject: "notFound" | "found" | undefined;
-
  export let projectId: string;
-
  export let popover: boolean = false;
-

-
  let route: Route;
-

-
  $: {
-
    route = $activeUnloadedRouteStore;
-
    if ("node" in route) {
-
      route.node = api.baseUrl;
-
    }
-
  }
-
</script>
-

-
<style>
-
  .help {
-
    font-size: var(--font-size-small);
-
  }
-
  .popover {
-
    width: 18.5rem;
-
  }
-
  .title {
-
    padding-bottom: 0.75rem;
-
    text-wrap: nowrap;
-
    overflow: hidden;
-
  }
-
  .description {
-
    text-wrap: nowrap;
-
    overflow: hidden;
-
  }
-
</style>
-

-
<div class="help" class:popover>
-
  {#if $httpdStore.state === "stopped"}
-
    <div class="title txt-bold">Local node not connected</div>
-
    <div class="description">
-
      Click the Connect button in the top right
-
      <br />
-
      corner to get started.
-
    </div>
-
  {:else if localProject === "notFound"}
-
    <div class="title txt-bold">Repository not available locally</div>
-
    <div class="description" style:padding-bottom="0.5rem">
-
      This repository hasn't been found on your local
-
      <br />
-
      node. To get a local copy start seeding it
-
      <br />
-
      using the following command.
-
    </div>
-
    <Command command={`rad seed ${projectId}`} />
-
  {:else if $httpdStore.state === "running" && localProject === "found"}
-
    <div class="title txt-bold">Not authenticated</div>
-
    <div class="description">To make changes you need to authenticate.</div>
-
    <div class="description">
-
      Click the Authenticate button in the top
-
      <br />
-
      right corner to get authenticated.
-
    </div>
-
  {:else if !isLocal(baseUrl.hostname) && localProject === "found"}
-
    <div class="title txt-bold">Read Only</div>
-
    <div class="description">This is a read only preview hosted on</div>
-
    <ExternalLink href={baseUrl.hostname} />
-
  {/if}
-

-
  {#if !hideLocalButton}
-
    <div class="txt-overflow" style:padding-top="1rem">
-
      <Link {route} disabled={disableLocalButton}>
-
        <Button size="large" styleWidth="100%" disabled={disableLocalButton}>
-
          <IconSmall name="device" />Make changes on your local node
-
        </Button>
-
      </Link>
-
    </div>
-
  {/if}
-
</div>
modified src/views/projects/Sidebar/ContextRepo.svelte
@@ -1,119 +1,88 @@
<script lang="ts">
-
  import type { BaseUrl, Node, Project } from "@http-client";
+
  import type { Project, SeedingPolicy } from "@http-client";

  import { capitalize } from "lodash";
-
  import { formatShortSeedingPolicy, isLocal } from "@app/lib/utils";

  import IconButton from "@app/components/IconButton.svelte";
  import IconSmall from "@app/components/IconSmall.svelte";
  import NodeId from "@app/components/NodeId.svelte";
-
  import Popover from "@app/components/Popover.svelte";
-
  import ScopePolicyExplainer from "@app/components/ScopePolicyExplainer.svelte";

-
  export let disablePopovers: boolean = false;
-
  export let project: Project;
-
  export let baseUrl: BaseUrl;
-
  export let node: Node;
+
  export let projectThreshold: number;
+
  export let projectDelegates: Project["delegates"];
+
  export let seedingPolicy: SeedingPolicy;

-
  let expandedNode = false;
-

-
  $: shortSeedingPolicy = formatShortSeedingPolicy(node.config?.seedingPolicy);
+
  let delegateExpanded = false;
+
  let policyExpanded = false;
</script>

<style>
-
  .nids {
-
    display: flex;
-
    flex-direction: column;
-
    gap: 1rem;
-
    padding: 0.5rem 0;
-
  }
-

-
  .node {
-
    display: flex;
-
    margin-top: 0.5rem;
-
    flex-direction: column;
-
    gap: 1rem;
-
    padding-top: 0.5rem;
-
  }
-
  .policies {
-
    display: flex;
-
    flex-direction: column;
-
    gap: 0.5rem;
-
  }
-
  .item {
+
  .item-header {
+
    gap: 2rem;
    display: flex;
-
    flex-wrap: nowrap;
    align-items: center;
    justify-content: space-between;
-
    gap: 0.5rem;
+
    margin: 0.2rem 0;
+
  }
+
  .item-header:first-child {
+
    margin-top: 0;
+
  }
+
  .item-header:last-child {
+
    margin-bottom: 0;
  }
-
  .no-wrap {
-
    text-wrap: nowrap;
+
  .nid {
+
    margin: 0.5rem 0;
  }
</style>

-
<div class="delegates txt-small">
-
  <div class="item" style:height="2rem">
-
    <div class="item">
-
      <IconSmall name="badge" />
-
      <span class="txt-bold">Delegates</span>
-
      <span class="txt-missing">{project.delegates.length}</span>
-
    </div>
-
    <div class="item">
-
      <IconSmall name="quorum" />
-
      <span class="txt-bold">
-
        {project.threshold}/{project.delegates.length}
-
      </span>
-
      {#if !disablePopovers}
-
        <Popover
-
          popoverPositionBottom="0"
-
          popoverPositionLeft="2rem"
-
          popoverPositionRight="-17rem">
-
          <IconButton slot="toggle" let:toggle on:click={toggle}>
-
            <IconSmall name="help" />
-
          </IconButton>
-

-
          <div slot="popover">
-
            {project.threshold} out of {project.delegates.length} delegates have
-
            to accept changes to be included in the canonical branch.
-
          </div>
-
        </Popover>
-
      {/if}
-
    </div>
-
  </div>
-
  <div class="nids">
-
    {#each project.delegates as { id: nodeId, alias }}
-
      <div style:width="fit-content">
-
        <NodeId {alias} {nodeId} />
-
      </div>
-
    {/each}
+
<div class="item-header">
+
  <span>Delegates</span>
+
  <div class="global-flex-item">
+
    <span class="txt-bold">
+
      {projectThreshold}/{projectDelegates.length}
+
    </span>
+
    <IconButton on:click={() => (delegateExpanded = !delegateExpanded)}>
+
      <IconSmall name={delegateExpanded ? "chevron-up" : "chevron-down"} />
+
    </IconButton>
  </div>
</div>
-
<div class="txt-small node">
-
  <div class="item no-wrap txt-bold" style="justify-content: flex-start; ">
-
    {#if isLocal(baseUrl.hostname)}
-
      <IconSmall name="device" />Local Node
+
{#if delegateExpanded}
+
  <div style:color="var(--color-foreground-dim" style:margin-bottom="1rem">
+
    {#if projectDelegates.length === 1}
+
      Any changes accepted by the sole delegate will be included in the
+
      canonical branch.
    {:else}
-
      <IconSmall name="seedling" />{baseUrl.hostname}
+
      {projectThreshold} out of {projectDelegates.length} delegates have to accept
+
      changes to be included in the canonical branch.
    {/if}
  </div>
-

-
  <div class="policies">
-
    <div class="item">
-
      <div class="item" style="justify-content: flex-start;">
-
        <IconButton on:click={() => (expandedNode = !expandedNode)}>
-
          <IconSmall name={`chevron-${expandedNode ? "down" : "right"}`} />
-
        </IconButton>
-
        <span class="no-wrap">Seeding Policy</span>
-
      </div>
-
      <div class="txt-bold">
-
        {capitalize(shortSeedingPolicy)}
-
      </div>
+
  {#each projectDelegates as delegate}
+
    <div class="nid">
+
      <NodeId nodeId={delegate.id} alias={delegate.alias} />
    </div>
-
    {#if expandedNode && node.config?.seedingPolicy}
-
      <div style:padding-left="2.3rem">
-
        <ScopePolicyExplainer seedingPolicy={node.config.seedingPolicy} />
-
      </div>
-
    {/if}
+
  {/each}
+
{/if}
+
<div class="item-header">
+
  <span style:text-wrap="nowrap">Seeding Scope</span>
+
  <div class="global-flex-item">
+
    <span class="txt-bold">
+
      {capitalize(
+
        "scope" in seedingPolicy ? seedingPolicy.scope : "not defined",
+
      )}
+
    </span>
+
    <IconButton on:click={() => (policyExpanded = !policyExpanded)}>
+
      <IconSmall name={policyExpanded ? "chevron-up" : "chevron-down"} />
+
    </IconButton>
  </div>
</div>
+
{#if policyExpanded}
+
  <div style:color="var(--color-foreground-dim)">
+
    {#if seedingPolicy.policy === "block"}
+
      Seeding scope only has an effect when a repository is seeded. This repo
+
      isn't seeded by the seed node.
+
    {:else if seedingPolicy.scope === "all"}
+
      This repository tracks changes by any peer.
+
    {:else}
+
      This repository tracks only peers followed by the seed node.
+
    {/if}
+
  </div>
+
{/if}
modified tests/unit/utils.test.ts
@@ -11,20 +11,6 @@ describe("Format functions", () => {

  test.each([
    {
-
      input: { default: "allow", scope: "all" },
-
      expected: "permissive",
-
    } as const,
-
    {
-
      input: { default: "allow", scope: "followed" },
-
      expected: "restrictive",
-
    } as const,
-
    { input: { default: "block" }, expected: "restrictive" } as const,
-
  ])("formatShortSeedingPolicy $input => $expected", ({ input, expected }) => {
-
    expect(utils.formatShortSeedingPolicy(input)).toEqual(expected);
-
  });
-

-
  test.each([
-
    {
      id: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
      expected: "rad:zKtT7D…19WzjT",
    },