Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add share button
Sebastian Martinez committed 2 years ago
commit 9ee2a4578fbd1f418110a5bf88f392a4eee51c12
parent 9ca438ad8e97e43d8f0e33b6f40fa572341284b2
27 files changed +569 -126
modified httpd-client/index.ts
@@ -34,12 +34,13 @@ import type {
import type { RequestOptions } from "./lib/fetcher.js";
import type { ZodSchema } from "zod";

-
import { z, array, boolean, literal, number, object, string, union } from "zod";
+
import { z, array, literal, number, object, string, union } from "zod";

import * as project from "./lib/project.js";
+
import * as profile from "./lib/profile.js";
import * as session from "./lib/session.js";
import { Fetcher } from "./lib/fetcher.js";
-
import { successResponseSchema } from "./lib/shared.js";
+
import { nodeConfigSchema, successResponseSchema } from "./lib/shared.js";

export type {
  BaseUrl,
@@ -77,37 +78,7 @@ export type Node = z.infer<typeof nodeSchema>;
const nodeSchema = object({
  id: string(),
  version: string(),
-
  config: object({
-
    alias: string(),
-
    peers: union([
-
      object({ type: literal("static") }),
-
      object({ type: literal("dynamic"), target: number() }),
-
    ]),
-
    connect: array(string()),
-
    externalAddresses: array(string()),
-
    listen: array(string()),
-
    network: union([literal("main"), literal("test")]),
-
    relay: boolean(),
-
    limits: object({
-
      routingMaxSize: number(),
-
      routingMaxAge: number(),
-
      fetchConcurrency: number(),
-
      gossipMaxAge: number(),
-
      maxOpenFiles: number(),
-
      rate: object({
-
        inbound: object({
-
          fillRate: number(),
-
          capacity: number(),
-
        }),
-
        outbound: object({
-
          fillRate: number(),
-
          capacity: number(),
-
        }),
-
      }),
-
    }),
-
    policy: union([literal("allow"), literal("block")]),
-
    scope: union([literal("followed"), literal("all")]),
-
  }).nullable(),
+
  config: nodeConfigSchema.nullable(),
  state: union([literal("running"), literal("stopped")]),
});

@@ -158,6 +129,7 @@ export class HttpdClient {

  public baseUrl: BaseUrl;
  public project: project.Client;
+
  public profile: profile.Client;
  public session: session.Client;

  public constructor(baseUrl: BaseUrl) {
@@ -165,6 +137,7 @@ export class HttpdClient {
    this.#fetcher = new Fetcher(this.baseUrl);

    this.project = new project.Client(this.#fetcher);
+
    this.profile = new profile.Client(this.#fetcher);
    this.session = new session.Client(this.#fetcher);
  }

added httpd-client/lib/profile.ts
@@ -0,0 +1,36 @@
+
import type { Fetcher, RequestOptions } from "./fetcher.js";
+
import type { z } from "zod";
+

+
import { array, boolean, object, string } from "zod";
+
import { nodeConfigSchema } from "./shared.js";
+

+
const profileSchema = object({
+
  config: object({
+
    publicExplorer: string(),
+
    preferredSeeds: array(string()),
+
    cli: object({ hints: boolean() }),
+
    node: nodeConfigSchema,
+
  }),
+
  home: string(),
+
});
+

+
export type Profile = z.infer<typeof profileSchema>;
+

+
export class Client {
+
  #fetcher: Fetcher;
+

+
  public constructor(fetcher: Fetcher) {
+
    this.#fetcher = fetcher;
+
  }
+

+
  public async getProfile(options?: RequestOptions): Promise<Profile> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `profile`,
+
        options,
+
      },
+
      profileSchema,
+
    );
+
  }
+
}
modified httpd-client/lib/shared.ts
@@ -1,6 +1,6 @@
import type { ZodSchema } from "zod";

-
import { literal, object } from "zod";
+
import { array, boolean, literal, number, object, string, union } from "zod";

export interface SuccessResponse {
  success: true;
@@ -9,3 +9,35 @@ export interface SuccessResponse {
export const successResponseSchema = object({
  success: literal(true),
}) satisfies ZodSchema<SuccessResponse>;
+

+
export const nodeConfigSchema = object({
+
  alias: string(),
+
  peers: union([
+
    object({ type: literal("static") }),
+
    object({ type: literal("dynamic"), target: number() }),
+
  ]),
+
  listen: array(string()),
+
  connect: array(string()),
+
  externalAddresses: array(string()),
+
  network: union([literal("main"), literal("test")]),
+
  relay: boolean(),
+
  limits: object({
+
    routingMaxSize: number(),
+
    routingMaxAge: number(),
+
    fetchConcurrency: number(),
+
    gossipMaxAge: number(),
+
    maxOpenFiles: number(),
+
    rate: object({
+
      inbound: object({
+
        fillRate: number(),
+
        capacity: number(),
+
      }),
+
      outbound: object({
+
        fillRate: number(),
+
        capacity: number(),
+
      }),
+
    }),
+
  }),
+
  policy: union([literal("allow"), literal("block")]),
+
  scope: union([literal("followed"), literal("all")]),
+
});
modified package-lock.json
@@ -18,6 +18,7 @@
        "hast-util-to-dom": "^4.0.0",
        "hast-util-to-html": "^9.0.0",
        "lodash": "^4.17.21",
+
        "lru-cache": "^10.1.0",
        "marked": "^10.0.0",
        "marked-katex-extension": "^4.0.4",
        "marked-linkify-it": "^3.1.6",
@@ -3199,15 +3200,11 @@
      }
    },
    "node_modules/lru-cache": {
-
      "version": "6.0.0",
-
      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
-
      "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-
      "dev": true,
-
      "dependencies": {
-
        "yallist": "^4.0.0"
-
      },
+
      "version": "10.1.0",
+
      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz",
+
      "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==",
      "engines": {
-
        "node": ">=10"
+
        "node": "14 || >=16.14"
      }
    },
    "node_modules/magic-string": {
@@ -4215,6 +4212,18 @@
        "node": ">=10"
      }
    },
+
    "node_modules/semver/node_modules/lru-cache": {
+
      "version": "6.0.0",
+
      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+
      "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+
      "dev": true,
+
      "dependencies": {
+
        "yallist": "^4.0.0"
+
      },
+
      "engines": {
+
        "node": ">=10"
+
      }
+
    },
    "node_modules/shebang-command": {
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
modified package.json
@@ -59,6 +59,7 @@
    "hast-util-to-dom": "^4.0.0",
    "hast-util-to-html": "^9.0.0",
    "lodash": "^4.17.21",
+
    "lru-cache": "^10.1.0",
    "marked": "^10.0.0",
    "marked-katex-extension": "^4.0.4",
    "marked-linkify-it": "^3.1.6",
modified src/components/IconSmall.svelte
@@ -41,6 +41,7 @@
    | "home"
    | "issue"
    | "key"
+
    | "link"
    | "lock"
    | "logo"
    | "menu"
@@ -351,6 +352,11 @@
      d="M7.077 8a.923.923 0 101.846 0 .923.923 0 00-1.846 0z"
      clip-rule="evenodd">
    </path>
+
  {:else if name === "link"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M7.64645 3.64645C7.45118 3.84171 7.45118 4.15829 7.64645 4.35355C7.84171 4.54882 8.15829 4.54882 8.35355 4.35355L9.93934 2.76777C10.5251 2.18198 11.4749 2.18198 12.0607 2.76777L13.2322 3.93934C13.818 4.52513 13.818 5.47488 13.2322 6.06066L10.0607 9.23223C9.47487 9.81802 8.52513 9.81802 7.93934 9.23223L7.35355 8.64645C7.15829 8.45119 6.84171 8.45119 6.64645 8.64645C6.45118 8.84171 6.45118 9.15829 6.64645 9.35355L7.23223 9.93934C8.20854 10.9157 9.79146 10.9157 10.7678 9.93934L13.9393 6.76777C14.9156 5.79146 14.9156 4.20855 13.9393 3.23224L12.7678 2.06066C11.7915 1.08435 10.2085 1.08435 9.23223 2.06066L7.64645 3.64645ZM8.35352 12.3536C8.54879 12.1583 8.54879 11.8417 8.35352 11.6464C8.15826 11.4512 7.84168 11.4512 7.64642 11.6464L6.06063 13.2322C5.47485 13.818 4.5251 13.818 3.93931 13.2322L2.76774 12.0607C2.18195 11.4749 2.18195 10.5251 2.76774 9.93934L5.93931 6.76777C6.5251 6.18198 7.47485 6.18198 8.06063 6.76777L8.64642 7.35355C8.84168 7.54882 9.15826 7.54882 9.35352 7.35356C9.54879 7.15829 9.54879 6.84171 9.35352 6.64645L8.76774 6.06066C7.79143 5.08435 6.20852 5.08435 5.2322 6.06066L2.06063 9.23223C1.08432 10.2085 1.08432 11.7915 2.06063 12.7678L3.2322 13.9393C4.20852 14.9156 5.79143 14.9156 6.76774 13.9393L8.35352 12.3536Z" />
  {:else if name === "logo"}
    <path d="M5.04547 1.5H3.86365V2.68182H5.04547V1.5Z" />
    <path d="M12.1364 1.5H10.9546V2.68182H12.1364V1.5Z" />
modified src/config.json
@@ -1,5 +1,6 @@
{
  "nodes": {
+
    "fallbackPublicExplorer": "https://app.radicle.xyz/nodes/$host/$rid$path",
    "defaultHttpdPort": 443,
    "defaultLocalHttpdPort": 8080,
    "defaultHttpdScheme": "https",
added src/lib/cache.ts
@@ -0,0 +1,21 @@
+
import { LRUCache } from "lru-cache";
+

+
export function cached<Args extends unknown[], V>(
+
  f: (...args: Args) => Promise<V>,
+
  makeKey: (...args: Args) => string,
+
  options?: LRUCache.Options<string, { value: V }, unknown>,
+
): (...args: Args) => Promise<V> {
+
  const cache = new LRUCache(options || { max: 500 });
+
  return async function (...args: Args): Promise<V> {
+
    const key = makeKey(...args);
+
    const cached = cache.get(key);
+

+
    if (cached === undefined) {
+
      const value = await f(...args);
+
      cache.set(key, { value });
+
      return value;
+
    } else {
+
      return cached.value;
+
    }
+
  };
+
}
modified src/lib/config.ts
@@ -4,6 +4,7 @@ import configJson from "@app/config.json";

export interface Config {
  nodes: {
+
    fallbackPublicExplorer: string;
    defaultHttpdPort: number;
    defaultLocalHttpdPort: number;
    defaultNodePort: number;
@@ -23,6 +24,7 @@ function getConfig(): Config {
  if (window.VITEST) {
    return {
      nodes: {
+
        fallbackPublicExplorer: "https://app.radicle.xyz/nodes/$host/$rid$path",
        defaultHttpdPort: 8081,
        defaultLocalHttpdPort: 8081,
        defaultHttpdScheme: "http",
modified src/lib/projects.ts
@@ -2,6 +2,7 @@ import type { BaseUrl, Project } from "@httpd-client";

import { HttpdClient } from "@httpd-client";
import { isFulfilled } from "@app/lib/utils";
+
import { cached } from "./cache";

export interface ProjectBaseUrl {
  project: Project;
@@ -23,3 +24,21 @@ export async function getProjectsFromNodes(
  const results = await Promise.allSettled(projectPromises);
  return results.filter(isFulfilled).map(r => r.value);
}
+

+
export const cacheQueryProject = cached(
+
  queryProject,
+
  (baseUrl: BaseUrl, projectId: string) =>
+
    JSON.stringify({ baseUrl, projectId }),
+
  { max: 200, ttl: 60 * 60 * 1000 },
+
);
+

+
async function queryProject(
+
  baseUrl: BaseUrl,
+
  projectId: string,
+
): Promise<"found" | "notFound"> {
+
  const httpd = new HttpdClient(baseUrl);
+
  return await httpd.project
+
    .getById(projectId)
+
    .then<"found">(() => "found")
+
    .catch(() => "notFound");
+
}
modified src/lib/router.ts
@@ -189,7 +189,7 @@ function extractBaseUrl(hostAndPort: string): BaseUrl {
  }
}

-
function urlToRoute(url: URL): Route | null {
+
export function urlToRoute(url: URL): Route | null {
  const segments = url.pathname.substring(1).split("/");

  const resource = segments.shift();
modified src/lib/utils.ts
@@ -93,6 +93,19 @@ export function formatEditedCaption(lastEdit: Comment["edits"][0]) {
  } edited ${formatTimestamp(lastEdit.timestamp / 1000)}`;
}

+
// Generates a publicly shareable link.
+
export function formatPublicExplorer(
+
  publicExplorer: string,
+
  host: string,
+
  rid: string,
+
  fullPath: string,
+
) {
+
  return publicExplorer
+
    .replace("$host", host)
+
    .replace("$rid", rid)
+
    .replace("$path", fullPath.replace(`/nodes/${host}/${rid}`, ""));
+
}
+

// Takes a path, eg. "../images/image.png", and a base from where to start resolving, e.g. "static/images/index.html".
// Returns the resolved path.
export function canonicalize(
modified src/views/projects/Commit.svelte
@@ -7,10 +7,13 @@
  import CommitAuthorship from "@app/views/projects/Commit/CommitAuthorship.svelte";
  import InlineMarkdown from "@app/components/InlineMarkdown.svelte";
  import Layout from "./Layout.svelte";
+
  import Share from "./Share.svelte";

  export let baseUrl: BaseUrl;
  export let commit: Commit;
  export let project: Project;
+
  export let preferredSeeds: string[];
+
  export let publicExplorer: string;

  $: header = commit.commit;
</script>
@@ -20,10 +23,15 @@
    background-color: var(--color-background-float);
  }
  .header {
-
    padding: 0 1rem 1rem 1rem;
+
    padding: 1rem;
    border-radius: var(--border-radius-small);
    border-bottom: 1px solid var(--color-border-hint);
  }
+
  .title {
+
    display: flex;
+
    justify-content: space-between;
+
    align-items: center;
+
  }
  .description {
    font-family: var(--font-family-monospace);
    white-space: pre-wrap;
@@ -35,13 +43,20 @@
  <div class="commit">
    <div class="header">
      <div style="display:flex; flex-direction: column; gap: 0.5rem;">
-
        <InlineMarkdown fontSize="large" content={header.summary} />
+
        <span class="title">
+
          <InlineMarkdown
+
            stripEmphasizedStyling
+
            fontSize="large"
+
            content={header.summary} />
+
          <Share {preferredSeeds} {publicExplorer} {baseUrl} />
+
        </span>
        <CommitAuthorship {header}>
          <span class="global-commit">{formatCommit(header.id)}</span>
        </CommitAuthorship>
      </div>
      {#if header.description}
-
        <pre class="description txt-small">{header.description}</pre>{/if}
+
        <pre class="description txt-small">{header.description}</pre>
+
      {/if}
    </div>
    <Changeset
      {baseUrl}
added src/views/projects/Header/ShareButton.svelte
@@ -0,0 +1,160 @@
+
<script lang="ts">
+
  import type { ProjectRoute } from "@app/views/projects/router";
+

+
  import { cacheQueryProject } from "@app/lib/projects";
+
  import { config } from "@app/lib/config";
+
  import { formatPublicExplorer } from "@app/lib/utils";
+
  import { routeToPath, urlToRoute } from "@app/lib/router";
+

+
  import Clipboard from "@app/components/Clipboard.svelte";
+
  import Command from "@app/components/Command.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
+
  import Loading from "@app/components/Loading.svelte";
+

+
  export let preferredSeeds: string[];
+
  export let publicExplorer: string;
+

+
  let usedFallbackSeed = false;
+

+
  const route = urlToRoute(new URL(window.location.href)) as ProjectRoute;
+
  const seedRoutes = preferredSeeds.reduce<ProjectRoute[]>((acc, seed) => {
+
    const [, address] = seed.split("@");
+
    acc.push({
+
      ...route,
+
      node: {
+
        hostname: address.split(":")[0],
+
        port: config.nodes.defaultHttpdPort,
+
        scheme: config.nodes.defaultHttpdScheme,
+
      },
+
    });
+
    return acc;
+
  }, []);
+

+
  // Set seed.radicle.garden as fallback seed.
+
  $: if (preferredSeeds.length === 0) {
+
    usedFallbackSeed = true;
+
    seedRoutes.push({
+
      ...route,
+
      node: {
+
        hostname: "seed.radicle.garden",
+
        port: config.nodes.defaultHttpdPort,
+
        scheme: config.nodes.defaultHttpdScheme,
+
      },
+
    });
+
  }
+
</script>
+

+
<style>
+
  .share {
+
    width: 22rem;
+
    font-size: var(--font-size-small);
+
  }
+
  .seed-list {
+
    padding: 0;
+
    margin: 1rem 0 0 0;
+
  }
+
  li.seed-item:not(:last-child) {
+
    margin-bottom: 0.5rem;
+
  }
+

+
  .seed-item {
+
    display: flex;
+
    flex-direction: row;
+
    justify-content: space-between;
+
    align-items: center;
+
    width: 100%;
+
    height: 2rem;
+
  }
+
  .seed {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
    gap: 0.5rem;
+
    width: 100%;
+
    min-width: 0;
+
  }
+

+
  .help {
+
    display: flex;
+
    flex-direction: column;
+
    border-top: 1px solid var(--color-fill-separator);
+
    gap: 1rem;
+
    padding-top: 1rem;
+
    margin-top: 1rem;
+
  }
+
  .notFound {
+
    color: var(--color-foreground-dim);
+
  }
+
</style>
+

+
<div class="share">
+
  <div class="txt-bold" style:padding-bottom="0.5rem">
+
    You're on your local node
+
  </div>
+
  <div>
+
    Copy a link to this page on a public seed node, accessible by everyone.
+
  </div>
+
  <ul class="seed-list">
+
    {#each seedRoutes as seed}
+
      {#await cacheQueryProject(seed.node, seed.project)}
+
        <li class="seed-item">
+
          <span class="seed txt-bold">
+
            <IconSmall name="globe" />
+
            <span class="txt-overflow">
+
              {seed.node.hostname}/{seed.project}
+
            </span>
+
          </span>
+
          <span style:height="1.5rem">
+
            <Loading center small noDelay condensed />
+
          </span>
+
        </li>
+
      {:then state}
+
        {@const path = routeToPath(seed)}
+
        <li
+
          class="seed-item"
+
          class:notFound={state === "notFound"}
+
          title={state === "notFound"
+
            ? "Not available on this public seed node"
+
            : ""}>
+
          <div class="seed txt-bold">
+
            <IconSmall name="globe" />
+
            <span class="txt-overflow">
+
              {path.replace("/nodes/", "")}
+
            </span>
+
          </div>
+
          <div style="display: flex; gap: 0.5rem; align-items: center;">
+
            {#if state === "found"}
+
              <Clipboard
+
                text={formatPublicExplorer(
+
                  publicExplorer,
+
                  seed.node.hostname,
+
                  seed.project,
+
                  path,
+
                )} />
+
              <a href={path} target="_blank">
+
                <IconSmall name="arrow-box-up-right" />
+
              </a>
+
            {:else}
+
              <IconSmall name="clipboard" />
+
              <IconSmall name="arrow-box-up-right" />
+
            {/if}
+
          </div>
+
        </li>
+
      {/await}
+
    {/each}
+
    {#if usedFallbackSeed}
+
      <div class="help">
+
        <div>
+
          <div class="txt-bold" style:padding-bottom="0.5rem">
+
            Add more seed nodes
+
          </div>
+
          <div>Update your preferred seeds in your Radicle config.</div>
+
        </div>
+
        <div style="display: flex; gap: 0.5rem; flex-direction: column;">
+
          <div>Run the following command to locate your config:</div>
+
          <Command command="rad self --config" fullWidth />
+
        </div>
+
      </div>
+
    {/if}
+
  </ul>
+
</div>
modified src/views/projects/History.svelte
@@ -31,6 +31,8 @@
  export let totalCommitCount: number;
  export let tree: Tree;
  export let seeding: boolean;
+
  export let preferredSeeds: string[];
+
  export let publicExplorer: string;

  const api = new HttpdClient(baseUrl);

@@ -109,7 +111,13 @@
</style>

<Layout {baseUrl} {project} activeTab="source">
-
  <ProjectNameHeader {project} {baseUrl} {seeding} slot="header" />
+
  <ProjectNameHeader
+
    {project}
+
    {baseUrl}
+
    {seeding}
+
    {preferredSeeds}
+
    {publicExplorer}
+
    slot="header" />

  <div style:margin="1rem 0 1rem 1rem" slot="subheader">
    <Header
modified src/views/projects/Issue.svelte
@@ -42,10 +42,13 @@
  import Reactions from "@app/components/Reactions.svelte";
  import TextInput from "@app/components/TextInput.svelte";
  import ThreadComponent from "@app/components/Thread.svelte";
+
  import Share from "./Share.svelte";

  export let baseUrl: BaseUrl;
  export let issue: Issue;
  export let project: Project;
+
  export let preferredSeeds: string[];
+
  export let publicExplorer: string;
  export let rawPath: (commit?: string) => string;

  const api = new HttpdClient(baseUrl);
@@ -477,28 +480,42 @@
    <div class="main">
      <CobHeader>
        <svelte:fragment slot="title">
-
          {#if issueState !== "read"}
-
            <TextInput
-
              placeholder="Title"
-
              bind:value={issue.title}
-
              showKeyHint={false} />
-
          {:else if !issue.title}
-
            <span class="txt-missing">No title</span>
-
          {:else}
-
            <div class="title">
-
              <InlineMarkdown
-
                stripEmphasizedStyling
-
                fontSize="large"
-
                content={issue.title} />
-
            </div>
-
          {/if}
-
          {#if session && role.isDelegateOrAuthor(session.publicKey, project.delegates, issue.author.id) && issueState === "read"}
-
            <IconButton
-
              title="edit issue"
-
              on:click={() => (issueState = "edit")}>
-
              <IconSmall name={"edit"} />
-
            </IconButton>
-
          {/if}
+
          <div style="display: flex; gap: 1rem; width: 100%;">
+
            {#if issueState !== "read"}
+
              <TextInput
+
                placeholder="Title"
+
                bind:value={issue.title}
+
                showKeyHint={false} />
+
            {:else if !issue.title}
+
              <span class="txt-missing">No title</span>
+
            {:else}
+
              <div class="title">
+
                <InlineMarkdown
+
                  stripEmphasizedStyling
+
                  fontSize="large"
+
                  content={issue.title} />
+
              </div>
+
            {/if}
+
            {#if session && role.isDelegateOrAuthor(session.publicKey, project.delegates, issue.author.id) && issueState === "read"}
+
              <IconButton
+
                title="edit issue"
+
                on:click={() => (issueState = "edit")}>
+
                <IconSmall name={"edit"} />
+
              </IconButton>
+
            {/if}
+
          </div>
+
          <div style="display: flex; gap: 1rem;">
+
            <Share {preferredSeeds} {publicExplorer} {baseUrl} />
+
            {#if session && role.isDelegateOrAuthor(session.publicKey, project.delegates, issue.author.id)}
+
              <CobStateButton
+
                items={items.filter(
+
                  ([, state]) => !isEqual(state, issue.state),
+
                )}
+
                {selectedItem}
+
                state={issue.state}
+
                save={partial(saveStatus, session.id)} />
+
            {/if}
+
          </div>
        </svelte:fragment>
        <svelte:fragment slot="state">
          {#if issue.state.status === "open"}
modified src/views/projects/Issues.svelte
@@ -20,11 +20,14 @@
  import Loading from "@app/components/Loading.svelte";
  import Placeholder from "@app/components/Placeholder.svelte";
  import Popover from "@app/components/Popover.svelte";
+
  import Share from "./Share.svelte";

  export let baseUrl: BaseUrl;
  export let issues: Issue[];
  export let project: Project;
  export let state: IssueState["status"];
+
  export let preferredSeeds: string[];
+
  export let publicExplorer: string;

  let loading = false;
  let page = 0;
@@ -143,8 +146,9 @@
      </DropdownList>
    </Popover>

-
    {#if $httpdStore.state === "authenticated" && isLocal(baseUrl.hostname)}
-
      <div style="margin-left: auto;">
+
    <div style="margin-left: auto; display: flex; gap: 1rem;">
+
      <Share {preferredSeeds} {publicExplorer} {baseUrl} />
+
      {#if $httpdStore.state === "authenticated" && isLocal(baseUrl.hostname)}
        <Link
          route={{
            resource: "project.newIssue",
@@ -156,8 +160,8 @@
            New Issue
          </Button>
        </Link>
-
      </div>
-
    {/if}
+
      {/if}
+
    </div>
  </div>

  <List items={allIssues}>
modified src/views/projects/Patch.svelte
@@ -77,12 +77,15 @@
  import Radio from "@app/components/Radio.svelte";
  import RevisionComponent from "@app/views/projects/Cob/Revision.svelte";
  import TextInput from "@app/components/TextInput.svelte";
+
  import Share from "./Share.svelte";

  export let baseUrl: BaseUrl;
  export let patch: Patch;
  export let rawPath: (commit?: string) => string;
  export let project: Project;
  export let view: PatchView;
+
  export let preferredSeeds: string[];
+
  export let publicExplorer: string;

  $: api = new HttpdClient(baseUrl);

@@ -661,27 +664,37 @@
    <div class="main">
      <CobHeader>
        <svelte:fragment slot="title">
-
          {#if patchState !== "read"}
-
            <TextInput
-
              placeholder="Title"
-
              bind:value={patch.title}
-
              showKeyHint={false} />
-
          {:else if !patch.title}
-
            <span class="txt-missing">No title</span>
-
          {:else}
-
            <div class="title">
-
              <InlineMarkdown
-
                stripEmphasizedStyling
-
                fontSize="large"
-
                content={patch.title} />
-
            </div>
-
          {/if}
-
          {#if session && role.isDelegateOrAuthor(session.publicKey, project.delegates, patch.author.id) && patchState === "read"}
-
            <IconButton
-
              title="edit patch"
-
              on:click={() => (patchState = "edit")}>
-
              <IconSmall name={"edit"} />
-
            </IconButton>
+
          <div style="display: flex; gap: 1rem; width: 100%;">
+
            {#if patchState !== "read"}
+
              <TextInput
+
                placeholder="Title"
+
                bind:value={patch.title}
+
                showKeyHint={false} />
+
            {:else if !patch.title}
+
              <span class="txt-missing">No title</span>
+
            {:else}
+
              <div class="title">
+
                <InlineMarkdown
+
                  stripEmphasizedStyling
+
                  fontSize="large"
+
                  content={patch.title} />
+
              </div>
+
            {/if}
+
            {#if session && role.isDelegateOrAuthor(session.publicKey, project.delegates, patch.author.id) && patchState === "read"}
+
              <IconButton
+
                title="edit patch"
+
                on:click={() => (patchState = "edit")}>
+
                <IconSmall name={"edit"} />
+
              </IconButton>
+
            {/if}
+
          </div>
+
          <Share {preferredSeeds} {publicExplorer} {baseUrl} />
+
          {#if session && role.isDelegateOrAuthor(session.publicKey, project.delegates, patch.author.id) && view.name === "activity"}
+
            <CobStateButton
+
              items={items.filter(([, state]) => !isEqual(state, patch.state))}
+
              {selectedItem}
+
              state={patch.state}
+
              save={partial(saveStatus, session.id)} />
          {/if}
        </svelte:fragment>
        <svelte:fragment slot="state">
@@ -778,13 +791,6 @@
        </Radio>

        <div style="margin-left: auto; margin-top: -0.5rem;">
-
          {#if session && role.isDelegateOrAuthor(session.publicKey, project.delegates, patch.author.id) && view.name === "activity"}
-
            <CobStateButton
-
              items={items.filter(([, state]) => !isEqual(state, patch.state))}
-
              {selectedItem}
-
              state={patch.state}
-
              save={partial(saveStatus, session.id)} />
-
          {/if}
          {#if view.name === "changes"}
            <div style="margin-left: auto; ">
              <Popover
modified src/views/projects/Patches.svelte
@@ -17,11 +17,14 @@
  import PatchTeaser from "./Patch/PatchTeaser.svelte";
  import Placeholder from "@app/components/Placeholder.svelte";
  import Popover, { closeFocused } from "@app/components/Popover.svelte";
+
  import Share from "./Share.svelte";

  export let baseUrl: BaseUrl;
  export let patches: Patch[];
  export let project: Project;
  export let state: PatchState["status"];
+
  export let preferredSeeds: string[];
+
  export let publicExplorer: string;

  let loading = false;
  let page = 0;
@@ -146,6 +149,10 @@
        </Link>
      </DropdownList>
    </Popover>
+

+
    <div style="margin-left: auto; display: flex; gap: 1rem;">
+
      <Share {preferredSeeds} {publicExplorer} {baseUrl} />
+
    </div>
  </div>

  <List items={allPatches}>
added src/views/projects/Share.svelte
@@ -0,0 +1,58 @@
+
<script lang="ts">
+
  import type { BaseUrl } from "@httpd-client";
+

+
  import debounce from "lodash/debounce";
+
  import { httpdStore } from "@app/lib/httpd";
+
  import { isLocal, toClipboard } from "@app/lib/utils";
+

+
  import Button from "@app/components/Button.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+
  import ShareButton from "./Header/ShareButton.svelte";
+

+
  export let preferredSeeds: string[];
+
  export let publicExplorer: string;
+
  export let baseUrl: BaseUrl;
+

+
  const caption = "Link to seed";
+
  let icon: "link" | "checkmark" = "link";
+

+
  const restoreIcon = debounce(() => {
+
    icon = "link";
+
  }, 800);
+

+
  async function copy(text: string) {
+
    await toClipboard(text);
+
    icon = "checkmark";
+
    restoreIcon();
+
  }
+
</script>
+

+
{#if $httpdStore.state !== "stopped" && isLocal(baseUrl.hostname)}
+
  <Popover
+
    popoverPadding="1rem"
+
    popoverPositionTop="2.5rem"
+
    popoverPositionRight="0">
+
    <Button
+
      variant="outline"
+
      size="regular"
+
      slot="toggle"
+
      let:toggle
+
      on:click={toggle}>
+
      <IconSmall name={icon} />
+
      {caption}
+
    </Button>
+
    <ShareButton {publicExplorer} {preferredSeeds} slot="popover" />
+
  </Popover>
+
{:else}
+
  <Button
+
    variant="outline"
+
    size="regular"
+
    on:click={() =>
+
      void copy(
+
        new URL(publicExplorer).origin.concat(window.location.pathname),
+
      )}>
+
    <IconSmall name={icon} />
+
    {caption}
+
  </Button>
+
{/if}
modified src/views/projects/Sidebar.svelte
@@ -2,9 +2,8 @@
  import type { ActiveTab } from "./Header.svelte";
  import type { BaseUrl, Project } from "@httpd-client";

-
  import { ResponseError } from "@httpd-client/lib/fetcher";
-

-
  import { api, httpdStore } from "@app/lib/httpd";
+
  import { cacheQueryProject } from "@app/lib/projects";
+
  import { httpdStore } from "@app/lib/httpd";
  import { isLocal } from "@app/lib/utils";
  import { onMount } from "svelte";

@@ -43,7 +42,7 @@

  httpdStore.subscribe(async () => {
    if ($httpdStore.state !== "stopped" && !queryingLocalProject) {
-
      await detectLocalProject();
+
      await cacheQueryProject(baseUrl, project.id);
    }
  });

@@ -64,14 +63,7 @@

  async function detectLocalProject(): Promise<void> {
    queryingLocalProject = true;
-
    localProject = await api.project
-
      .getById(project.id)
-
      .then<"found">(() => "found")
-
      .catch((error: unknown) =>
-
        error instanceof ResponseError && error.status === 404
-
          ? "notFound"
-
          : undefined,
-
      );
+
    localProject = await cacheQueryProject(baseUrl, project.id);
    queryingLocalProject = false;
  }

modified src/views/projects/Source.svelte
@@ -25,6 +25,8 @@
  export let revision: string | undefined;
  export let tree: Tree;
  export let seeding: boolean;
+
  export let preferredSeeds: string[];
+
  export let publicExplorer: string;

  let mobileFileTree = false;

@@ -111,7 +113,13 @@
</style>

<Layout {baseUrl} {project} activeTab="source">
-
  <ProjectNameHeader {project} {baseUrl} {seeding} slot="header" />
+
  <ProjectNameHeader
+
    {project}
+
    {baseUrl}
+
    {seeding}
+
    {preferredSeeds}
+
    {publicExplorer}
+
    slot="header" />

  <div style:margin="1rem 0 1rem 1rem" slot="subheader">
    <Header
modified src/views/projects/Source/ProjectNameHeader.svelte
@@ -3,20 +3,23 @@

  import * as modal from "@app/lib/modal";
  import capitalize from "lodash/capitalize";
-
  import { twemoji } from "@app/lib/utils";
  import { httpdStore, api } from "@app/lib/httpd";
+
  import { twemoji } from "@app/lib/utils";

  import Badge from "@app/components/Badge.svelte";
  import CloneButton from "../Header/CloneButton.svelte";
+
  import CopyableId from "@app/components/CopyableId.svelte";
  import ErrorModal from "@app/modals/ErrorModal.svelte";
  import InlineMarkdown from "@app/components/InlineMarkdown.svelte";
  import Link from "@app/components/Link.svelte";
  import SeedButton from "../Header/SeedButton.svelte";
-
  import CopyableId from "@app/components/CopyableId.svelte";
+
  import Share from "@app/views/projects/Share.svelte";

  export let project: Project;
  export let baseUrl: BaseUrl;
  export let seeding: boolean;
+
  export let preferredSeeds: string[];
+
  export let publicExplorer: string;

  let editSeedingInProgress = false;

@@ -118,6 +121,7 @@
    <div
      class="global-hide-on-mobile"
      style="margin-left: auto; display: flex; gap: 0.5rem;">
+
      <Share {preferredSeeds} {publicExplorer} {baseUrl} />
      <SeedButton
        {seeding}
        disabled={editSeedingInProgress}
modified src/views/projects/router.ts
@@ -21,6 +21,7 @@ import type {

import * as Syntax from "@app/lib/syntax";
import * as httpd from "@app/lib/httpd";
+
import { config } from "@app/lib/config";
import { HttpdClient } from "@httpd-client";
import { ResponseError } from "@httpd-client/lib/fetcher";
import { nodePath } from "@app/views/nodes/router";
@@ -117,6 +118,8 @@ export type ProjectLoadedRoute =
        rawPath: (commit?: string) => string;
        blobResult: BlobResult;
        seeding: boolean;
+
        preferredSeeds: string[];
+
        publicExplorer: string;
      };
    }
  | {
@@ -132,6 +135,8 @@ export type ProjectLoadedRoute =
        commitHeaders: CommitHeader[];
        totalCommitCount: number;
        seeding: boolean;
+
        preferredSeeds: string[];
+
        publicExplorer: string;
      };
    }
  | {
@@ -140,6 +145,8 @@ export type ProjectLoadedRoute =
        baseUrl: BaseUrl;
        project: Project;
        commit: Commit;
+
        preferredSeeds: string[];
+
        publicExplorer: string;
      };
    }
  | {
@@ -149,6 +156,8 @@ export type ProjectLoadedRoute =
        project: Project;
        rawPath: (commit?: string) => string;
        issue: Issue;
+
        preferredSeeds: string[];
+
        publicExplorer: string;
      };
    }
  | {
@@ -158,6 +167,8 @@ export type ProjectLoadedRoute =
        project: Project;
        issues: Issue[];
        state: IssueState["status"];
+
        preferredSeeds: string[];
+
        publicExplorer: string;
      };
    }
  | {
@@ -175,6 +186,8 @@ export type ProjectLoadedRoute =
        project: Project;
        patches: Patch[];
        state: PatchState["status"];
+
        preferredSeeds: string[];
+
        publicExplorer: string;
      };
    }
  | {
@@ -185,6 +198,8 @@ export type ProjectLoadedRoute =
        rawPath: (commit?: string) => string;
        patch: Patch;
        view: PatchView;
+
        preferredSeeds: string[];
+
        publicExplorer: string;
      };
    };

@@ -269,7 +284,8 @@ export async function loadProjectRoute(
    } else if (route.resource === "project.history") {
      return await loadHistoryView(route);
    } else if (route.resource === "project.commit") {
-
      const [project, commit] = await Promise.all([
+
      const [profile, project, commit] = await Promise.all([
+
        api.profile.getProfile().catch(() => undefined),
        api.project.getById(route.project),
        api.project.getCommitBySha(route.project, route.commit),
      ]);
@@ -280,10 +296,15 @@ export async function loadProjectRoute(
          baseUrl: route.node,
          project,
          commit,
+
          preferredSeeds: profile?.config.preferredSeeds || [],
+
          publicExplorer:
+
            profile?.config.publicExplorer ||
+
            config.nodes.fallbackPublicExplorer,
        },
      };
    } else if (route.resource === "project.issue") {
-
      const [project, issue] = await Promise.all([
+
      const [profile, project, issue] = await Promise.all([
+
        api.profile.getProfile().catch(() => undefined),
        api.project.getById(route.project),
        api.project.getIssueById(route.project, route.issue),
      ]);
@@ -294,6 +315,10 @@ export async function loadProjectRoute(
          project,
          rawPath,
          issue,
+
          preferredSeeds: profile?.config.preferredSeeds || [],
+
          publicExplorer:
+
            profile?.config.publicExplorer ||
+
            config.nodes.fallbackPublicExplorer,
        },
      };
    } else if (route.resource === "project.patch") {
@@ -355,7 +380,8 @@ async function loadPatchesView(
  const searchParams = new URLSearchParams(route.search || "");
  const state = (searchParams.get("state") as PatchState["status"]) || "open";

-
  const [project, patches] = await Promise.all([
+
  const [profile, project, patches] = await Promise.all([
+
    api.profile.getProfile().catch(() => undefined),
    api.project.getById(route.project),
    api.project.getAllPatches(route.project, {
      state,
@@ -371,6 +397,9 @@ async function loadPatchesView(
      patches,
      state,
      project,
+
      preferredSeeds: profile?.config.preferredSeeds || [],
+
      publicExplorer:
+
        profile?.config.publicExplorer || config.nodes.fallbackPublicExplorer,
    },
  };
}
@@ -381,7 +410,8 @@ async function loadIssuesView(
  const api = new HttpdClient(route.node);
  const state = route.state || "open";

-
  const [project, issues] = await Promise.all([
+
  const [profile, project, issues] = await Promise.all([
+
    api.profile.getProfile().catch(() => undefined),
    api.project.getById(route.project),
    api.project.getAllIssues(route.project, {
      state,
@@ -398,6 +428,9 @@ async function loadIssuesView(
      issues,
      state,
      project,
+
      preferredSeeds: profile?.config.preferredSeeds || [],
+
      publicExplorer:
+
        profile?.config.publicExplorer || config.nodes.fallbackPublicExplorer,
    },
  };
}
@@ -411,7 +444,8 @@ async function loadTreeView(
      route.project
    }${commit ? `/${commit}` : ""}`;

-
  const [project, peers, branchMap, seeding] = await Promise.all([
+
  const [profile, project, peers, branchMap, seeding] = await Promise.all([
+
    api.profile.getProfile().catch(() => undefined),
    api.project.getById(route.project),
    api.project.getAllRemotes(route.project),
    getPeerBranches(api, route.project, route.peer),
@@ -453,6 +487,9 @@ async function loadTreeView(
      path,
      blobResult,
      seeding,
+
      preferredSeeds: profile?.config.preferredSeeds || [],
+
      publicExplorer:
+
        profile?.config.publicExplorer || config.nodes.fallbackPublicExplorer,
    },
  };
}
@@ -502,7 +539,8 @@ async function loadHistoryView(
): Promise<ProjectLoadedRoute> {
  const api = new HttpdClient(route.node);

-
  const [project, peers, branchMap] = await Promise.all([
+
  const [profile, project, peers, branchMap] = await Promise.all([
+
    api.profile.getProfile().catch(() => undefined),
    api.project.getById(route.project),
    api.project.getAllRemotes(route.project),
    getPeerBranches(api, route.project, route.peer),
@@ -546,6 +584,9 @@ async function loadHistoryView(
      commitHeaders: commitsResponse.commits.map(c => c.commit),
      totalCommitCount: commitsResponse.stats.commits,
      seeding,
+
      preferredSeeds: profile?.config.preferredSeeds || [],
+
      publicExplorer:
+
        profile?.config.publicExplorer || config.nodes.fallbackPublicExplorer,
    },
  };
}
@@ -558,7 +599,8 @@ async function loadPatchView(
    `${route.node.scheme}://${route.node.hostname}:${route.node.port}/raw/${
      route.project
    }${commit ? `/${commit}` : ""}`;
-
  const [project, patch] = await Promise.all([
+
  const [profile, project, patch] = await Promise.all([
+
    api.profile.getProfile().catch(() => undefined),
    api.project.getById(route.project),
    api.project.getPatchById(route.project, route.patch),
  ]);
@@ -615,6 +657,9 @@ async function loadPatchView(
      rawPath,
      patch,
      view,
+
      preferredSeeds: profile?.config.preferredSeeds || [],
+
      publicExplorer:
+
        profile?.config.publicExplorer || config.nodes.fallbackPublicExplorer,
    },
  };
}
modified tests/e2e/node.spec.ts
@@ -60,8 +60,8 @@ test("seeding projects", async ({ page, authenticatedPeer }) => {

  await page.goto(authenticatedPeer.ridUrl(rid));
  await page.getByRole("button", { name: "Seeding" }).click();
-
  await expect(page.getByRole("button", { name: "Seed" })).toBeVisible();
+
  await expect(page.getByRole("button", { name: "Seed 1" })).toBeVisible();

-
  await page.getByRole("button", { name: "Seed" }).click();
+
  await page.getByRole("button", { name: "Seed 1" }).click();
  await expect(page.getByRole("button", { name: "Seeding" })).toBeVisible();
});
modified tests/e2e/project/issues.spec.ts
@@ -49,7 +49,10 @@ test("issue counters", async ({ page, authenticatedPeer }) => {
  await page
    .getByRole("link", { name: "First issue to test counters" })
    .click();
-
  await page.getByRole("button", { name: "Close issue as solved" }).click();
+
  await page
+
    .getByRole("button", { name: "Close issue as solved" })
+
    .first()
+
    .click();
  await expect(page.getByRole("button", { name: "Issues 1" })).toBeVisible();
});

modified tests/support/fixtures.ts
@@ -71,6 +71,8 @@ export const test = base.extend<{
        await page.addInitScript(() => {
          window.APP_CONFIG = {
            nodes: {
+
              fallbackPublicExplorer:
+
                "https://app.radicle.xyz/nodes/$host/$rid$path",
              defaultHttpdPort: 8081,
              defaultLocalHttpdPort: 8081,
              defaultHttpdScheme: "http",
@@ -196,6 +198,7 @@ function log(text: string, label: string, outputLog: Stream.Writable) {
export function appConfigWithFixture(defaultLocalHttpdPort = 8081) {
  window.APP_CONFIG = {
    nodes: {
+
      fallbackPublicExplorer: "https://app.radicle.xyz/nodes/$host/$rid$path",
      defaultHttpdPort: 8081,
      defaultLocalHttpdPort: defaultLocalHttpdPort,
      defaultHttpdScheme: "http",