Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
Update heartwood to rc.8
Open did:key:z6MkkfM3...sVz5 opened 1 year ago
  • Update node config schema for relay property
  • Decouple stats from tree endpoint response
  • Add alias to project delegates and threshold
  • Add apiVersion to config and httpd request

check check-visual check-unit-test check-httpd-api-unit-test check-e2e check-build

👉 Preview 👉 Workflow runs 👉 Branch on GitHub

29 files changed +167 -77 824ea2e6 f50521ca
modified flake.lock
@@ -64,16 +64,16 @@
        "rust-overlay": "rust-overlay"
      },
      "locked": {
-
        "lastModified": 1714387799,
-
        "narHash": "sha256-tjuNtf1+QvW0JBlLPV/rxD4jrSeG8amyUacNZyoTShU=",
-
        "ref": "refs/namespaces/z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT/refs/tags/v1.0.0-rc.6",
-
        "rev": "0c610887ce36151726d56955018f1fb7a9f0a5d2",
-
        "revCount": 1883,
+
        "lastModified": 1714650527,
+
        "narHash": "sha256-F2n7ui0EgXK8fT76M14RVhXXGeRYub+VpH+puDUJ1pQ=",
+
        "ref": "refs/namespaces/z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT/refs/tags/v1.0.0-rc.8",
+
        "rev": "6ce4cecdf3229f4651c33839e073ff383bc40369",
+
        "revCount": 1903,
        "type": "git",
        "url": "https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git"
      },
      "original": {
-
        "ref": "refs/namespaces/z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT/refs/tags/v1.0.0-rc.6",
+
        "ref": "refs/namespaces/z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT/refs/tags/v1.0.0-rc.8",
        "type": "git",
        "url": "https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git"
      }
modified flake.nix
@@ -4,7 +4,7 @@
    nixpkgs.follows = "heartwood/nixpkgs";
    flake-utils.follows = "heartwood/flake-utils";
    heartwood = {
-
      url = "git+https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?ref=refs/namespaces/z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT/refs/tags/v1.0.0-rc.6";
+
      url = "git+https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?ref=refs/namespaces/z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT/refs/tags/v1.0.0-rc.8";
    };
  };

modified httpd-client/index.ts
@@ -6,6 +6,7 @@ import type {
  ProjectListQuery,
  Remote,
  Tree,
+
  TreeStats,
} from "./lib/project.js";
import type {
  SuccessResponse,
@@ -82,6 +83,7 @@ export type {
  Review,
  Revision,
  Scope,
+
  TreeStats,
  Tree,
  Verdict,
};
@@ -101,6 +103,7 @@ const nodeInfoSchema = object({
  message: string(),
  service: string(),
  version: string(),
+
  apiVersion: string(),
  nid: string(),
  path: string(),
  links: array(
modified httpd-client/lib/fetcher.ts
@@ -3,6 +3,9 @@

import type { ZodIssue, ZodType, TypeOf } from "zod";

+
import { config } from "@app/lib/config";
+
import { compare } from "compare-versions";
+

export interface BaseUrl {
  hostname: string;
  port: number;
@@ -40,16 +43,41 @@ export class ResponseError extends Error {
export class ResponseParseError extends Error {
  public method: string;
  public body: unknown;
+
  public description: string;
+
  public apiVersion: string | undefined;
  public zodIssues: ZodIssue[];
  public path?: string;

  public constructor(
    method: string,
    body: unknown,
+
    apiVersion: string | undefined,
    zodIssues: ZodIssue[],
    path?: string,
  ) {
    super("Failed to parse response body");
+

+
    let description: string;
+
    if (
+
      apiVersion === undefined ||
+
      compare(apiVersion, config.nodes.apiVersion, "<")
+
    ) {
+
      description = `The node you are fetching from seems to be outdated, make sure the httpd API version is at least ${config.nodes.apiVersion} currently ${apiVersion ?? "unknown"}.`;
+
    } else if (
+
      config.nodes.apiVersion === undefined ||
+
      compare(apiVersion, config.nodes.apiVersion, ">")
+
    ) {
+
      description = `The web client you are using is outdated, make sure it supports at least ${apiVersion} to interact with this node currently ${config.nodes.apiVersion ?? "unknown"}.`;
+
    } else {
+
      description =
+
        "This is usually due to a version mismatch between the seed and the web interface.";
+
    }
+
    this.apiVersion = apiVersion;
+
    this.description =
+
      "The response received from the seed does not match the expected schema.<br/>".concat(
+
        description,
+
      );
+

    this.method = method;
    this.path = path;
    this.body = body;
@@ -93,8 +121,7 @@ export class Fetcher {
  ): Promise<TypeOf<T>> {
    const response = await this.fetch({
      ...params,
-
      // eslint-disable-next-line @typescript-eslint/naming-convention
-
      query: { ...params.query, bust_cache: "1" },
+
      query: { ...params.query, v: config.nodes.apiVersion },
    });

    if (!response.ok) {
@@ -112,9 +139,12 @@ export class Fetcher {
    if (result.success) {
      return result.data;
    } else {
+
      const response = await this.fetch({ method: "GET" });
+
      const info = await response.json();
      throw new ResponseParseError(
        params.method,
        responseBody,
+
        info.apiVersion,
        result.error.errors,
        params.path,
      );
modified httpd-client/lib/project.ts
@@ -55,8 +55,9 @@ const projectSchema = object({
  name: string(),
  description: string(),
  defaultBranch: string(),
-
  delegates: array(string()),
+
  delegates: array(object({ id: string(), alias: optional(string()) })),
  head: string(),
+
  threshold: number(),
  visibility: union([
    object({ type: literal("public") }),
    object({ type: literal("private"), allow: optional(array(string())) }),
@@ -102,11 +103,13 @@ const treeEntrySchema = object({

export type TreeEntry = z.infer<typeof treeEntrySchema>;

-
export interface TreeStats {
-
  commits: number;
-
  branches: number;
-
  contributors: number;
-
}
+
const treeStatsSchema = object({
+
  commits: number(),
+
  branches: number(),
+
  contributors: number(),
+
});
+

+
export type TreeStats = z.infer<typeof treeStatsSchema>;

export type Tree = z.infer<typeof treeSchema>;

@@ -115,11 +118,6 @@ const treeSchema = object({
  lastCommit: commitHeaderSchema,
  name: string(),
  path: string(),
-
  stats: object({
-
    commits: number(),
-
    branches: number(),
-
    contributors: number(),
-
  }),
});

export type Remote = z.infer<typeof remoteSchema>;
@@ -153,6 +151,7 @@ export class Client {
  // persisted and new instances are frequently created.
  static #cache = {
    tree: new LRUCache<string, Tree>({ max: 300 }),
+
    stats: new LRUCache<string, TreeStats>({ max: 300 }),
    blob: new LRUCache<string, Blob>({ max: 500 }),
  };

@@ -275,6 +274,28 @@ export class Client {
    return tree;
  }

+
  public async getTreeStatsBySha(
+
    id: string,
+
    sha: string,
+
    options?: RequestOptions,
+
  ): Promise<TreeStats> {
+
    const cacheKey = `tree:stats:${sha}`;
+
    const cachedTree = Client.#cache.stats.get(cacheKey);
+
    if (cachedTree) {
+
      return cachedTree;
+
    }
+
    const tree = await this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/stats/tree/${sha}`,
+
        options,
+
      },
+
      treeStatsSchema,
+
    );
+
    Client.#cache.stats.set(cacheKey, tree);
+
    return tree;
+
  }
+

  public async getAllRemotes(
    id: string,
    options?: RequestOptions,
modified httpd-client/lib/project/commit.ts
@@ -236,11 +236,4 @@ const commitSchema = object({

type Commits = z.infer<typeof commitsSchema>;

-
const commitsSchema = object({
-
  commits: array(commitHeaderSchema),
-
  stats: object({
-
    commits: number(),
-
    branches: number(),
-
    contributors: number(),
-
  }),
-
});
+
const commitsSchema = array(commitHeaderSchema);
modified httpd-client/lib/shared.ts
@@ -1,6 +1,6 @@
import type { ZodSchema, z } from "zod";

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

export interface SuccessResponse {
  success: true;
@@ -23,7 +23,7 @@ export const nodeConfigSchema = object({
  connect: array(string()),
  externalAddresses: array(string()),
  network: union([literal("main"), literal("test")]),
-
  relay: boolean(),
+
  relay: union([literal("always"), literal("never"), literal("auto")]),
  limits: object({
    routingMaxSize: number(),
    routingMaxAge: number(),
modified httpd-client/tests/project.test.ts
@@ -53,6 +53,10 @@ describe("project", () => {
    await api.project.getTree(sourceBrowsingRid, aliceMainHead);
  });

+
  test("#getTreeStats(id, sha)", async () => {
+
    await api.project.getTreeStatsBySha(sourceBrowsingRid, aliceMainHead);
+
  });
+

  test("#getTree(id, sha, path)", async () => {
    await api.project.getTree(sourceBrowsingRid, aliceMainHead, "src");
  });
modified httpd-client/vite.config.ts
@@ -3,7 +3,7 @@ import path from "node:path";

export default defineConfig({
  test: {
-
    environment: "node",
+
    environment: "happy-dom",
    include: ["httpd-client/tests/*.test.ts"],
    reporters: "verbose",
    globalSetup: "./tests/support/globalSetup",
modified package-lock.json
@@ -15,6 +15,7 @@
        "baconjs": "^3.0.17",
        "bs58": "^5.0.0",
        "buffer": "^6.0.3",
+
        "compare-versions": "^6.1.0",
        "dompurify": "^3.0.11",
        "hast-util-to-dom": "^4.0.0",
        "hast-util-to-html": "^9.0.0",
@@ -1880,6 +1881,11 @@
        "node": ">= 12"
      }
    },
+
    "node_modules/compare-versions": {
+
      "version": "6.1.0",
+
      "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.0.tgz",
+
      "integrity": "sha512-LNZQXhqUvqUTotpZ00qLSaify3b4VFD588aRr8MKFw4CMUr98ytzCW5wDH5qx/DEY5kCDXcbcRuCqL0szEf2tg=="
+
    },
    "node_modules/concat-map": {
      "version": "0.0.1",
      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
modified package.json
@@ -53,6 +53,7 @@
    "baconjs": "^3.0.17",
    "bs58": "^5.0.0",
    "buffer": "^6.0.3",
+
    "compare-versions": "^6.1.0",
    "dompurify": "^3.0.11",
    "hast-util-to-dom": "^4.0.0",
    "hast-util-to-html": "^9.0.0",
modified src/config.json
@@ -1,6 +1,7 @@
{
  "nodes": {
    "fallbackPublicExplorer": "https://app.radicle.xyz/nodes/$host/$rid$path",
+
    "apiVersion": "0.1.0",
    "defaultHttpdPort": 443,
    "defaultLocalHttpdPort": 8080,
    "defaultHttpdHostname": "seed.radicle.garden",
modified src/lib/config.ts
@@ -4,6 +4,7 @@ import configJson from "@app/config.json";

export interface Config {
  nodes: {
+
    apiVersion: string;
    fallbackPublicExplorer: string;
    defaultHttpdPort: number;
    defaultHttpdHostname: string;
@@ -21,6 +22,7 @@ function getConfig(): Config {
    return {
      nodes: {
        fallbackPublicExplorer: "https://app.radicle.xyz/nodes/$host/$rid$path",
+
        apiVersion: "0.1.0",
        defaultHttpdHostname: "127.0.0.1",
        defaultHttpdPort: 8081,
        defaultLocalHttpdPort: 8081,
modified src/views/home/Index.svelte
@@ -91,7 +91,12 @@
    localProjects instanceof Error ||
    localProjects === undefined
      ? localProjects
-
      : localProjects.filter(p => isDelegate(nodeId, p.project.delegates));
+
      : localProjects.filter(p =>
+
          isDelegate(
+
            nodeId,
+
            p.project.delegates.map(d => d.id),
+
          ),
+
        );
</script>

<style>
@@ -186,13 +191,13 @@
          <div class="project-grid">
            {#if filteredLocalProjects && !(filteredLocalProjects instanceof Error)}
              {#each filteredLocalProjects as projectInfo}
+
                {@const delegates = projectInfo.project.delegates.map(
+
                  d => d.id,
+
                )}
                <ProjectCard
                  {projectInfo}
                  isSeeding={true}
-
                  isDelegate={isDelegate(
-
                    nodeId,
-
                    projectInfo.project.delegates,
-
                  ) ?? false} />
+
                  isDelegate={isDelegate(nodeId, delegates) ?? false} />
              {/each}
            {/if}
          </div>
@@ -261,11 +266,11 @@
      <div class="project-grid">
        {#if preferredSeedProjects && !(preferredSeedProjects instanceof Error)}
          {#each preferredSeedProjects as projectInfo}
+
            {@const delegates = projectInfo.project.delegates.map(d => d.id)}
            <ProjectCard
              {projectInfo}
              isSeeding={isSeeding(projectInfo.project.id)}
-
              isDelegate={isDelegate(nodeId, projectInfo.project.delegates) ??
-
                false} />
+
              isDelegate={isDelegate(nodeId, delegates) ?? false} />
          {/each}
        {/if}
      </div>
modified src/views/home/error.ts
@@ -12,8 +12,7 @@ export function handleError(
    return {
      error,
      title: "Could not parse the request",
-
      description:
-
        "The response received from the seed does not match the expected schema, this is usually due to a version mismatch between the seed and the web interface.",
+
      description: error.description,
    };
  } else if (error instanceof ResponseError) {
    return {
modified src/views/nodes/View.svelte
@@ -194,7 +194,7 @@
                    : isSeeding(projectInfo.project.id)}
                  isDelegate={isDelegate(
                    session?.publicKey,
-
                    projectInfo.project.delegates,
+
                    projectInfo.project.delegates.map(d => d.id),
                  ) ?? false} />
              {/each}
            </div>
modified src/views/nodes/error.ts
@@ -11,8 +11,7 @@ export function handleError(
      params: {
        error,
        title: "Could not parse the request",
-
        description:
-
          "The response received from the seed does not match the expected schema, this is usually due to a version mismatch between the seed and the web interface.",
+
        description: error.description,
      },
    };
  } else if (error instanceof ResponseError) {
modified src/views/projects/History.svelte
@@ -5,6 +5,7 @@
    Project,
    Remote,
    Tree,
+
    TreeStats,
  } from "@httpd-client";
  import type { Route } from "@app/lib/router";

@@ -29,8 +30,8 @@
  export let peers: Remote[];
  export let project: Project;
  export let revision: string | undefined;
-
  export let totalCommitCount: number;
  export let tree: Tree;
+
  export let stats: TreeStats;
  export let seeding: boolean;

  const api = new HttpdClient(baseUrl);
@@ -54,8 +55,7 @@
        page,
        perPage: COMMITS_PER_PAGE,
      });
-
      allCommitHeaders = response.commits;
-
      totalCommitCount = response.stats.commits;
+
      allCommitHeaders = response;
    } catch (e) {
      error = e;
    }
@@ -117,6 +117,7 @@
      branches={branchesWithRoute}
      {revision}
      {tree}
+
      {stats}
      filesLinkActive={false}
      historyLinkActive={true} />
  </div>
@@ -135,11 +136,11 @@
    {/each}
  </div>

-
  {#if loading || allCommitHeaders.length < totalCommitCount}
+
  {#if loading || allCommitHeaders.length < stats.commits}
    <div class="more">
      {#if loading}
        <Loading small={page !== 0} center />
-
      {:else if allCommitHeaders.length < totalCommitCount}
+
      {:else if allCommitHeaders.length < stats.commits}
        <Button size="large" variant="outline" on:click={loadMore}>More</Button>
      {/if}
    </div>
modified src/views/projects/Issue.svelte
@@ -402,6 +402,7 @@
    issue.discussion[0].edits.length > 1
      ? issue.discussion[0].edits.at(-1)
      : undefined;
+
  $: delegates = project.delegates.map(d => d.id);

  type State = "read" | "edit" | "submit";

@@ -507,7 +508,7 @@
            {/if}
          </div>
          <div style="display: flex; gap: 0.5rem;">
-
            {#if session && role.isDelegateOrAuthor(session.publicKey, project.delegates, issue.author.id) && issueState === "read"}
+
            {#if session && role.isDelegateOrAuthor(session.publicKey, delegates, issue.author.id) && issueState === "read"}
              <Button
                variant="outline"
                title="edit issue"
@@ -518,7 +519,7 @@
            {/if}
            {#if issueState === "read"}
              <Share {baseUrl} />
-
              {#if session && role.isDelegateOrAuthor(session.publicKey, project.delegates, issue.author.id)}
+
              {#if session && role.isDelegateOrAuthor(session.publicKey, delegates, issue.author.id)}
                <CobStateButton
                  items={items.filter(
                    ([, state]) => !isEqual(state, issue.state),
@@ -645,7 +646,7 @@
                canEditComment={partial(
                  role.isDelegateOrAuthor,
                  session?.publicKey,
-
                  project.delegates,
+
                  delegates,
                )}
                editComment={$experimental &&
                  session &&
@@ -672,7 +673,7 @@
            submit={partial(createComment, session.id)} />
          <div
            style="display:flex; flex-direction: column; align-items: flex-start;">
-
            {#if role.isDelegateOrAuthor(session.publicKey, project.delegates, issue.author.id)}
+
            {#if role.isDelegateOrAuthor(session.publicKey, delegates, issue.author.id)}
              <div class="connector" />
              <CobStateButton
                items={items.filter(
@@ -689,7 +690,7 @@
    <div class="metadata global-hide-on-mobile">
      <AssigneeInput
        locallyAuthenticated={Boolean(
-
          role.isDelegate(session?.publicKey, project.delegates),
+
          role.isDelegate(session?.publicKey, delegates),
        )}
        assignees={issue.assignees}
        submitInProgress={assigneeState === "submit"}
@@ -706,7 +707,7 @@
        }} />
      <LabelInput
        locallyAuthenticated={Boolean(
-
          role.isDelegate(session?.publicKey, project.delegates),
+
          role.isDelegate(session?.publicKey, delegates),
        )}
        labels={issue.labels}
        submitInProgress={labelState === "submit"}
modified src/views/projects/Issue/New.svelte
@@ -136,12 +136,16 @@
      <div class="metadata">
        <AssigneeInput
          locallyAuthenticated={session &&
-
            project.delegates.includes(`did:key:${session.publicKey}`)}
+
            project.delegates
+
              .map(d => d.id)
+
              .includes(`did:key:${session.publicKey}`)}
          on:save={({ detail: updatedAssignees }) =>
            (assignees = updatedAssignees)} />
        <LabelInput
          locallyAuthenticated={session &&
-
            project.delegates.includes(`did:key:${session.publicKey}`)}
+
            project.delegates
+
              .map(d => d.id)
+
              .includes(`did:key:${session.publicKey}`)}
          on:save={({ detail: updatedLabels }) => (labels = updatedLabels)} />
      </div>
    </div>
modified src/views/projects/Patch.svelte
@@ -604,6 +604,7 @@
        })),
    ].sort((a, b) => a.timestamp - b.timestamp),
  ]);
+
  $: delegates = project.delegates.map(d => d.id);
  $: firstRevision = timelineTuple[0][0];
  $: latestRevision = patch.revisions[patch.revisions.length - 1];
  $: session =
@@ -747,7 +748,7 @@
              </div>
            {/if}
          </div>
-
          {#if session && role.isDelegateOrAuthor(session.publicKey, project.delegates, patch.author.id) && patchState === "read"}
+
          {#if session && role.isDelegateOrAuthor(session.publicKey, delegates, patch.author.id) && patchState === "read"}
            <Button
              variant="outline"
              title="edit patch"
@@ -758,7 +759,7 @@
          {/if}
          {#if patchState === "read"}
            <Share {baseUrl} />
-
            {#if session && role.isDelegateOrAuthor(session.publicKey, project.delegates, patch.author.id)}
+
            {#if session && role.isDelegateOrAuthor(session.publicKey, delegates, patch.author.id)}
              <CobStateButton
                items={items.filter(
                  ([, state]) => !isEqual(state, patch.state),
@@ -1004,7 +1005,7 @@
              canEdit={partial(
                role.isDelegateOrAuthor,
                session?.publicKey,
-
                project.delegates,
+
                delegates,
              )}
              editRevision={$experimental &&
                session &&
@@ -1040,7 +1041,7 @@
                      session.id,
                      revision.revisionId,
                    )} />
-
                  {#if role.isDelegateOrAuthor(session.publicKey, project.delegates, patch.author.id)}
+
                  {#if role.isDelegateOrAuthor(session.publicKey, delegates, patch.author.id)}
                    <div class="connector" />
                    <div style="display: flex;">
                      <CobStateButton
@@ -1100,10 +1101,7 @@
        </div>
      </div>
      <LabelInput
-
        locallyAuthenticated={role.isDelegate(
-
          session?.publicKey,
-
          project.delegates,
-
        )}
+
        locallyAuthenticated={role.isDelegate(session?.publicKey, delegates)}
        submitInProgress={labelState === "submit"}
        labels={patch.labels}
        on:save={async ({ detail: newLabels }) => {
modified src/views/projects/Source.svelte
@@ -1,5 +1,11 @@
<script lang="ts">
-
  import type { BaseUrl, Project, Remote, Tree } from "@httpd-client";
+
  import type {
+
    BaseUrl,
+
    Project,
+
    Remote,
+
    Tree,
+
    TreeStats,
+
  } from "@httpd-client";
  import type { BlobResult } from "./router";
  import type { Route } from "@app/lib/router";

@@ -24,6 +30,7 @@
  export let project: Project;
  export let revision: string | undefined;
  export let tree: Tree;
+
  export let stats: TreeStats;
  export let seeding: boolean;

  let mobileFileTree = false;
@@ -125,6 +132,7 @@
      branches={branchesWithRoute}
      {revision}
      {tree}
+
      {stats}
      filesLinkActive={true}
      historyLinkActive={false} />
  </div>
modified src/views/projects/Source/Header.svelte
@@ -1,5 +1,11 @@
<script lang="ts">
-
  import type { BaseUrl, Project, Remote, Tree } from "@httpd-client";
+
  import type {
+
    BaseUrl,
+
    Project,
+
    Remote,
+
    Tree,
+
    TreeStats,
+
  } from "@httpd-client";
  import { type Route } from "@app/lib/router";

  import BranchSelector from "./BranchSelector.svelte";
@@ -16,6 +22,7 @@
  export let historyLinkActive: boolean;
  export let revision: string | undefined;
  export let tree: Tree;
+
  export let stats: TreeStats;
  export let project: Project;

  let selectedBranch: string | undefined;
@@ -121,7 +128,7 @@
        <div class="title-counter">
          Commits
          <div class="counter" class:selected={historyLinkActive}>
-
            {tree.stats.commits}
+
            {stats.commits}
          </div>
        </div>
      </Button>
modified src/views/projects/error.ts
@@ -43,8 +43,7 @@ export function handleError(
      params: {
        error,
        title: "Could not parse the request",
-
        description:
-
          "The response received from the seed does not match the expected schema, this is usually due to a version mismatch between the seed and the web interface.",
+
        description: error.description,
      },
    };
  } else if (
modified src/views/projects/router.ts
@@ -18,6 +18,7 @@ import type {
  Project,
  Remote,
  Tree,
+
  TreeStats,
} from "@httpd-client";

import * as Syntax from "@app/lib/syntax";
@@ -117,6 +118,7 @@ export type ProjectLoadedRoute =
        branches: string[];
        revision: string | undefined;
        tree: Tree;
+
        stats: TreeStats;
        path: string;
        rawPath: (commit?: string) => string;
        blobResult: BlobResult;
@@ -133,8 +135,8 @@ export type ProjectLoadedRoute =
        branches: string[];
        revision: string | undefined;
        tree: Tree;
+
        stats: TreeStats;
        commitHeaders: CommitHeader[];
-
        totalCommitCount: number;
        seeding: boolean;
      };
    }
@@ -443,6 +445,8 @@ async function loadTreeView(
    branchMap,
  );

+
  const stats = await api.project.getTreeStatsBySha(route.project, commit);
+

  const path = route.path || "/";

  const [tree, blobResult] = await Promise.all([
@@ -460,6 +464,7 @@ async function loadTreeView(
      rawPath,
      revision: route.revision,
      tree,
+
      stats,
      path,
      blobResult,
      seeding,
@@ -533,8 +538,9 @@ async function loadHistoryView(
    );
  }

-
  const [tree, commitsResponse, seeding] = await Promise.all([
+
  const [tree, stats, commitHeaders, seeding] = await Promise.all([
    api.project.getTree(route.project, commitId),
+
    api.project.getTreeStatsBySha(route.project, commitId),
    await api.project.getAllCommits(project.id, {
      parent: commitId,
      page: 0,
@@ -553,8 +559,8 @@ async function loadHistoryView(
      branches: Object.keys(branchMap || {}),
      revision: route.revision,
      tree,
-
      commitHeaders: commitsResponse.commits,
-
      totalCommitCount: commitsResponse.stats.commits,
+
      stats,
+
      commitHeaders,
      seeding,
    },
  };
modified tests/e2e/node.spec.ts
@@ -19,7 +19,7 @@ test("node metadata", async ({ page, peerManager }) => {
  await expect(
    page.getByText(`${shortNodeRemote}@seed.radicle.test:8123`),
  ).toBeVisible();
-
  await expect(page.getByText("1.0.0-rc.6-")).toBeVisible();
+
  await expect(page.getByText("1.0.0-rc.8-")).toBeVisible();
  await peer.stopHttpd();
  await peer.stopNode();
});
modified tests/support/fixtures.ts
@@ -77,6 +77,7 @@ export const test = base.extend<{
            nodes: {
              fallbackPublicExplorer:
                "https://app.radicle.xyz/nodes/$host/$rid$path",
+
              apiVersion: "0.1.0",
              defaultHttpdPort: 8081,
              defaultHttpdHostname: "127.0.0.1",
              defaultLocalHttpdPort: 8081,
@@ -214,6 +215,7 @@ export function appConfigWithFixture(defaultLocalHttpdPort = 8081) {
  window.APP_CONFIG = {
    nodes: {
      fallbackPublicExplorer: "https://app.radicle.xyz/nodes/$host/$rid$path",
+
      apiVersion: "0.1.0",
      defaultHttpdPort: 8081,
      defaultHttpdHostname: "127.0.0.1",
      defaultLocalHttpdPort,
modified tests/support/heartwood-release
@@ -1 +1 @@
-
1.0.0-rc.6

\ No newline at end of file
+
1.0.0-rc.8

\ No newline at end of file
modified tests/support/peerManager.ts
@@ -16,7 +16,7 @@ import * as readline from "node:readline/promises";
import * as Process from "./process.js";
import { randomTag } from "@tests/support/support.js";
import { sleep } from "@app/lib/sleep.js";
-
import { array, boolean, literal, number, object, string, union, z } from "zod";
+
import { array, literal, number, object, string, union, z } from "zod";
import { logPrefix } from "./logPrefix.js";

export type RefsUpdate =
@@ -132,7 +132,7 @@ export const NodeConfigSchema = object({
    connect: array(string()),
    externalAddresses: array(string()),
    network: union([literal("main"), literal("test")]),
-
    relay: boolean(),
+
    relay: union([literal("always"), literal("never"), literal("auto")]),
    limits: object({
      routingMaxSize: number(),
      routingMaxAge: number(),