Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add httpd-client api library
Rūdolfs Ošiņš committed 3 years ago
commit c8c251fabd690ed82a4a43322a8f5257be0d09c5
parent 045890d2e110dbe408dbd2c3de8766f08c47c970
80 files changed +2386 -1796
added .github/workflows/check-httpd-api-unit-test.yml
@@ -0,0 +1,25 @@
+
name: check-httpd-api-unit-test
+
on:
+
  push:
+
    branches:
+
      - master
+
  pull_request:
+
    branches:
+
      - master
+

+
jobs:
+
  check-httpd-api-unit-test:
+
    runs-on: ubuntu-latest
+
    steps:
+
      - name: Setup Node
+
        uses: actions/setup-node@v3
+
        with:
+
          node-version: "18.15.0"
+
      - name: Checkout
+
        uses: actions/checkout@v3
+
      - run: npm ci
+
      - name: Start http-api test server
+
        run: |
+
          mkdir -p tests/artifacts;
+
          ./scripts/run-httpd-with-fixtures --non-interactive --download 2>&1 | tee tests/artifacts/httpd-api.log &
+
      - run: npm run test:httpd-api:unit
added httpd-client/index.ts
@@ -0,0 +1,136 @@
+
import type { BaseUrl } from "./lib/fetcher.js";
+
import type { Blob, Project, Remote, Tree } from "./lib/project.js";
+
import type { Comment } from "./lib/project/comment.js";
+
import type {
+
  Commit,
+
  CommitHeader,
+
  Diff,
+
  DiffAddedDeletedModifiedChangeset,
+
  HunkLine,
+
} from "./lib/project/commit.js";
+
import type { Issue, IssueState } from "./lib/project/issue.js";
+
import type { Merge, Patch, PatchState, Review } from "./lib/project/patch.js";
+
import type { RequestOptions, Method } from "./lib/fetcher.js";
+
import type { ZodSchema } from "zod";
+

+
import { array, literal, number, strictObject, string, union } from "zod";
+

+
import * as project from "./lib/project.js";
+
import * as session from "./lib/session.js";
+
import { Fetcher } from "./lib/fetcher.js";
+

+
export type {
+
  BaseUrl,
+
  Blob,
+
  Comment,
+
  Commit,
+
  CommitHeader,
+
  Diff,
+
  DiffAddedDeletedModifiedChangeset,
+
  HunkLine,
+
  Issue,
+
  IssueState,
+
  Merge,
+
  Patch,
+
  PatchState,
+
  Project,
+
  Remote,
+
  Review,
+
  Tree,
+
};
+

+
export interface Node {
+
  id: string;
+
}
+

+
const nodeSchema = strictObject({
+
  id: string(),
+
}) satisfies ZodSchema<Node>;
+

+
export interface Root {
+
  message: string;
+
  service: string;
+
  version: string;
+
  node: Node;
+
  path: string;
+
  links: { href: string; rel: string; type: Method }[];
+
}
+

+
const rootSchema = strictObject({
+
  message: string(),
+
  service: string(),
+
  version: string(),
+
  node: nodeSchema,
+
  path: string(),
+
  links: array(
+
    strictObject({
+
      href: string(),
+
      rel: string(),
+
      type: union([
+
        literal("GET"),
+
        literal("POST"),
+
        literal("PUT"),
+
        literal("DELETE"),
+
      ]),
+
    }),
+
  ),
+
}) satisfies ZodSchema<Root>;
+

+
export interface NodeStats {
+
  projects: { count: number };
+
  users: { count: number };
+
}
+

+
const nodeStatsSchema = strictObject({
+
  projects: strictObject({ count: number() }),
+
  users: strictObject({ count: number() }),
+
}) satisfies ZodSchema<NodeStats>;
+

+
export class HttpdClient {
+
  #fetcher: Fetcher;
+
  #baseUrl: BaseUrl;
+

+
  public project: project.Client;
+
  public session: session.Client;
+

+
  public constructor(baseUrl: BaseUrl) {
+
    this.#baseUrl = baseUrl;
+
    this.#fetcher = new Fetcher(this.#baseUrl);
+

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

+
  public async getRoot(options?: RequestOptions): Promise<Root> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: "",
+
        options,
+
      },
+
      rootSchema,
+
    );
+
  }
+

+
  public async getStats(options?: RequestOptions): Promise<NodeStats> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: "stats",
+
        options,
+
      },
+
      nodeStatsSchema,
+
    );
+
  }
+

+
  public async getNode(options?: RequestOptions): Promise<Node> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: "node",
+
        options,
+
      },
+
      nodeSchema,
+
    );
+
  }
+
}
added httpd-client/lib/fetcher.ts
@@ -0,0 +1,159 @@
+
// This module provides low-level capabilities to interact with a typed
+
// JSON HTTP API.
+

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

+
export interface BaseUrl {
+
  hostname: string;
+
  port: number;
+
  scheme: string;
+
}
+

+
// Error that is thrown by `Fetcher` methods.
+
export class ResponseError extends Error {
+
  public method: string;
+
  public url: string;
+
  public status: number;
+
  public body: unknown;
+

+
  public constructor(method: string, response: Response, body_: unknown) {
+
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+
    const body: any = body_;
+
    if (
+
      typeof body === "object" &&
+
      body !== null &&
+
      typeof body.message === "string"
+
    ) {
+
      super(body.message);
+
    } else {
+
      super("Response error");
+
    }
+

+
    this.method = method;
+
    this.body = body_;
+
    this.status = response.status;
+
    this.url = response.url;
+
  }
+
}
+

+
// Error that is thrown by `Fetcher` methods when parsing the response
+
// body fails.
+
export class ResponseParseError extends Error {
+
  public method: string;
+
  public path: string;
+
  public body: unknown;
+
  public zodIssues: ZodIssue[];
+

+
  public constructor(
+
    method: string,
+
    path: string,
+
    body: unknown,
+
    zodIssues: ZodIssue[],
+
  ) {
+
    super("Failed to parse response body");
+
    this.method = method;
+
    this.path = path;
+
    this.body = body;
+
    this.zodIssues = zodIssues;
+
  }
+
}
+

+
export interface RequestOptions {
+
  abort?: AbortSignal;
+
}
+

+
export interface FetchParams {
+
  method: Method;
+
  // Path to append to the `Fetcher`s base URL to get the final URL.
+
  path: string;
+
  // Object that is serialized into JSON and sent as the data.
+
  body?: unknown;
+
  // Query parameters to be serialized with URLSearchParams.
+
  query?: Record<string, string | number | boolean>;
+
  options?: RequestOptions;
+
  headers?: Record<string, string>;
+
}
+

+
export type Method = "DELETE" | "GET" | "PATCH" | "POST" | "PUT";
+

+
export class Fetcher {
+
  #baseUrl: BaseUrl;
+

+
  public constructor(baseUrl: BaseUrl) {
+
    this.#baseUrl = baseUrl;
+
  }
+

+
  // Execute a fetch and parse the result with the provided schema.
+
  // Return the parsed payload.
+
  //
+
  // Throws `ResponseError` if the response status code is not `200`.
+
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+
  public async fetchOk<T extends ZodType<any>>(
+
    params: FetchParams,
+
    schema: T,
+
  ): Promise<TypeOf<T>> {
+
    const response = await this.fetch(params);
+

+
    const responseBody = await response.json();
+

+
    if (!response.ok) {
+
      throw new ResponseError(params.method, response, responseBody);
+
    }
+

+
    const result = schema.safeParse(responseBody);
+
    if (result.success) {
+
      return result.data;
+
    } else {
+
      throw new ResponseParseError(
+
        params.method,
+
        params.path,
+
        responseBody,
+
        result.error.errors,
+
      );
+
    }
+
  }
+

+
  // Execute a fetch and ignore the response body.
+
  //
+
  // Throws `ResponseError` if the response status code is not `200`.
+
  public async fetchOkNoContent(params: FetchParams): Promise<void> {
+
    const response = await this.fetch(params);
+

+
    if (!response.ok) {
+
      let responseBody = await response.text();
+
      try {
+
        responseBody = JSON.parse(responseBody);
+
      } catch (_e: unknown) {
+
        // We keep the original text response body.
+
      }
+
      throw new ResponseError(params.method, response, responseBody);
+
    }
+
  }
+

+
  private async fetch({
+
    method,
+
    path,
+
    body,
+
    options = {},
+
    query,
+
    headers = {},
+
  }: FetchParams): Promise<Response> {
+
    if (body !== undefined && headers["content-type"] === undefined) {
+
      headers["content-type"] = "application/json";
+
    }
+

+
    let url = `${this.#baseUrl.scheme}://${this.#baseUrl.hostname}:${
+
      this.#baseUrl.port
+
    }/api/v1/${path}`;
+
    if (query) {
+
      const searchparams = new URLSearchParams(query as Record<string, string>);
+
      url = `${url}?${searchparams.toString()}`;
+
    }
+
    return globalThis.fetch(url, {
+
      method,
+
      headers,
+
      body: body === undefined ? undefined : JSON.stringify(body),
+
      signal: options.abort,
+
    });
+
  }
+
}
added httpd-client/lib/project.ts
@@ -0,0 +1,474 @@
+
import type { Commit, CommitHeader, Commits, Diff } from "./project/commit.js";
+
import type { Fetcher, RequestOptions } from "./fetcher.js";
+
import type {
+
  Issue,
+
  IssueCreated,
+
  IssueUpdateAction,
+
} from "./project/issue.js";
+
import type { Patch, PatchUpdateAction } from "./project/patch.js";
+
import type { SuccessResponse } from "./shared.js";
+
import type { ZodSchema } from "zod";
+

+
import { successResponseSchema } from "./shared.js";
+
import {
+
  array,
+
  boolean,
+
  literal,
+
  number,
+
  optional,
+
  record,
+
  strictObject,
+
  string,
+
  union,
+
} from "zod";
+

+
import {
+
  commitHeaderSchema,
+
  commitSchema,
+
  commitsSchema,
+
  diffSchema,
+
} from "./project/commit.js";
+

+
import {
+
  issueCreatedSchema,
+
  issueSchema,
+
  issuesSchema,
+
} from "./project/issue.js";
+

+
import { patchSchema, patchesSchema } from "./project/patch.js";
+

+
export interface Project {
+
  id: string;
+
  name: string;
+
  description: string;
+
  defaultBranch: string;
+
  delegates: string[];
+
  head: string;
+
  patches: {
+
    open: number;
+
    draft: number;
+
    archived: number;
+
    merged: number;
+
  };
+
  issues: {
+
    open: number;
+
    closed: number;
+
  };
+
}
+

+
const projectSchema = strictObject({
+
  id: string(),
+
  name: string(),
+
  description: string(),
+
  defaultBranch: string(),
+
  delegates: array(string()),
+
  head: string(),
+
  patches: strictObject({
+
    open: number(),
+
    draft: number(),
+
    archived: number(),
+
    merged: number(),
+
  }),
+
  issues: strictObject({
+
    open: number(),
+
    closed: number(),
+
  }),
+
}) satisfies ZodSchema<Project>;
+

+
const projectsSchema = array(projectSchema) satisfies ZodSchema<Project[]>;
+

+
export interface Activity {
+
  activity: number[];
+
}
+

+
const activitySchema = strictObject({
+
  activity: array(number()),
+
}) satisfies ZodSchema<Activity>;
+

+
export interface Blob {
+
  binary: boolean;
+
  content?: string;
+
  name: string;
+
  path: string;
+
  lastCommit: CommitHeader;
+
}
+

+
const blobSchema = strictObject({
+
  binary: boolean(),
+
  content: optional(string()),
+
  name: string(),
+
  path: string(),
+
  lastCommit: commitHeaderSchema,
+
}) satisfies ZodSchema<Blob>;
+

+
interface TreeEntry {
+
  path: string;
+
  name: string;
+
  kind: "tree" | "blob";
+
}
+

+
const treeEntrySchema = strictObject({
+
  path: string(),
+
  name: string(),
+
  kind: union([literal("blob"), literal("tree")]),
+
}) satisfies ZodSchema<TreeEntry>;
+

+
export interface TreeStats {
+
  commits: number;
+
  branches: number;
+
  contributors: number;
+
}
+

+
export interface Tree {
+
  entries: TreeEntry[];
+
  lastCommit: CommitHeader;
+
  name: string;
+
  path: string;
+
  stats: TreeStats;
+
}
+

+
const treeSchema = strictObject({
+
  entries: array(treeEntrySchema),
+
  lastCommit: commitHeaderSchema,
+
  name: string(),
+
  path: string(),
+
  stats: strictObject({
+
    commits: number(),
+
    branches: number(),
+
    contributors: number(),
+
  }),
+
}) satisfies ZodSchema<Tree>;
+

+
export interface Remote {
+
  id: string;
+
  heads: Record<string, string>;
+
  delegate: boolean;
+
}
+

+
const remoteSchema = strictObject({
+
  id: string(),
+
  heads: record(string(), string()),
+
  delegate: boolean(),
+
}) satisfies ZodSchema<Remote>;
+

+
const remotesSchema = array(remoteSchema) satisfies ZodSchema<Remote[]>;
+

+
interface DiffResponse {
+
  commits: CommitHeader[];
+
  diff: Diff;
+
}
+

+
const diffResponseSchema = strictObject({
+
  commits: array(commitHeaderSchema),
+
  diff: diffSchema,
+
}) satisfies ZodSchema<DiffResponse>;
+

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

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

+
  public async getByDelegate(
+
    delegateId: string,
+
    options?: RequestOptions,
+
  ): Promise<Project[]> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `delegates/${delegateId}/projects`,
+
        options,
+
      },
+
      projectsSchema,
+
    );
+
  }
+

+
  public async getById(id: string, options?: RequestOptions): Promise<Project> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}`,
+
        options,
+
      },
+
      projectSchema,
+
    );
+
  }
+

+
  public async getAll(
+
    query?: { page?: number; perPage?: number },
+
    options?: RequestOptions,
+
  ): Promise<Project[]> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: "projects",
+
        query,
+
        options,
+
      },
+
      projectsSchema,
+
    );
+
  }
+

+
  public async getActivity(
+
    id: string,
+
    options?: RequestOptions,
+
  ): Promise<Activity> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/activity`,
+
        options,
+
      },
+
      activitySchema,
+
    );
+
  }
+

+
  public async getReadme(
+
    id: string,
+
    sha: string,
+
    options?: RequestOptions,
+
  ): Promise<Blob> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/readme/${sha}`,
+
        options,
+
      },
+
      blobSchema,
+
    );
+
  }
+

+
  public async getBlob(
+
    id: string,
+
    sha: string,
+
    path: string,
+
    options?: RequestOptions,
+
  ): Promise<Blob> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/blob/${sha}/${path}`,
+
        options,
+
      },
+
      blobSchema,
+
    );
+
  }
+

+
  public async getTree(
+
    id: string,
+
    sha: string,
+
    path?: string,
+
    options?: RequestOptions,
+
  ): Promise<Tree> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/tree/${sha}/${path ?? ""}`,
+
        options,
+
      },
+
      treeSchema,
+
    );
+
  }
+

+
  public async getAllRemotes(
+
    id: string,
+
    options?: RequestOptions,
+
  ): Promise<Remote[]> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/remotes`,
+
        options,
+
      },
+
      remotesSchema,
+
    );
+
  }
+

+
  public async getRemoteByPeer(
+
    id: string,
+
    peer: string,
+
    options?: RequestOptions,
+
  ): Promise<Remote> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/remotes/${peer}`,
+
        options,
+
      },
+
      remoteSchema,
+
    );
+
  }
+

+
  public async getAllCommits(
+
    id: string,
+
    query?: {
+
      parent?: string;
+
      since?: number;
+
      until?: number;
+
      page?: number;
+
      perPage?: number;
+
    },
+
    options?: RequestOptions,
+
  ): Promise<Commits> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/commits`,
+
        query,
+
        options,
+
      },
+
      commitsSchema,
+
    );
+
  }
+

+
  public async getCommitBySha(
+
    id: string,
+
    sha: string,
+
    options?: RequestOptions,
+
  ): Promise<Commit> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/commits/${sha}`,
+
        options,
+
      },
+
      commitSchema,
+
    );
+
  }
+

+
  public async getDiff(
+
    id: string,
+
    revisionBase: string,
+
    revisionOid: string,
+
    options?: RequestOptions,
+
  ): Promise<DiffResponse> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/diff/${revisionBase}/${revisionOid}`,
+
        options,
+
      },
+
      diffResponseSchema,
+
    );
+
  }
+

+
  public async getIssueById(
+
    id: string,
+
    issueId: string,
+
    options?: RequestOptions,
+
  ): Promise<Issue> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/issues/${issueId}`,
+
        options,
+
      },
+
      issueSchema,
+
    );
+
  }
+

+
  public async getAllIssues(
+
    id: string,
+
    options?: RequestOptions,
+
  ): Promise<Issue[]> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/issues`,
+
        options,
+
      },
+
      issuesSchema,
+
    );
+
  }
+

+
  public async createIssue(
+
    id: string,
+
    body: {
+
      title: string;
+
      description: string;
+
      assignees: string[];
+
      tags: string[];
+
    },
+
    authToken: string,
+
    options?: RequestOptions,
+
  ): Promise<IssueCreated> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "POST",
+
        path: `projects/${id}/issues`,
+
        headers: { Authorization: `Bearer ${authToken}` },
+
        body,
+
        options,
+
      },
+
      issueCreatedSchema,
+
    );
+
  }
+

+
  public async updateIssue(
+
    id: string,
+
    issueId: string,
+
    body: IssueUpdateAction,
+
    authToken: string,
+
    options?: RequestOptions,
+
  ): Promise<SuccessResponse> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "PATCH",
+
        path: `projects/${id}/issues/${issueId}`,
+
        headers: { Authorization: `Bearer ${authToken}` },
+
        body,
+
        options,
+
      },
+
      successResponseSchema,
+
    );
+
  }
+

+
  public async getPatchById(
+
    id: string,
+
    patchId: string,
+
    options?: RequestOptions,
+
  ): Promise<Patch> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/patches/${patchId}`,
+
        options,
+
      },
+
      patchSchema,
+
    );
+
  }
+

+
  public async getAllPatches(
+
    id: string,
+
    options?: RequestOptions,
+
  ): Promise<Patch[]> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/patches`,
+
        options,
+
      },
+
      patchesSchema,
+
    );
+
  }
+

+
  public async updatePatch(
+
    id: string,
+
    patchId: string,
+
    body: PatchUpdateAction,
+
    authToken: string,
+
    options?: RequestOptions,
+
  ): Promise<SuccessResponse> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "PATCH",
+
        path: `projects/${id}/patches/${patchId}`,
+
        headers: { Authorization: `Bearer ${authToken}` },
+
        body,
+
        options,
+
      },
+
      successResponseSchema,
+
    );
+
  }
+
}
added httpd-client/lib/project/comment.ts
@@ -0,0 +1,31 @@
+
import type { ZodSchema } from "zod";
+
import { array, number, record, strictObject, string } from "zod";
+

+
export type ThreadUpdateAction =
+
  | { type: "comment"; body: string; replyTo?: string }
+
  | { type: "edit"; id: string; body: string }
+
  | { type: "redact"; id: string }
+
  | {
+
      type: "react";
+
      to: string;
+
      reaction: { emoji: string };
+
      active: boolean;
+
    };
+

+
export interface Comment {
+
  id: string;
+
  author: { id: string };
+
  body: string;
+
  reactions: Record<string, number>[];
+
  timestamp: number;
+
  replyTo: string | null;
+
}
+

+
export const commentSchema = strictObject({
+
  id: string(),
+
  author: strictObject({ id: string() }),
+
  body: string(),
+
  reactions: array(record(string(), number())),
+
  timestamp: number(),
+
  replyTo: string().nullable(),
+
}) satisfies ZodSchema<Comment>;
added httpd-client/lib/project/commit.ts
@@ -0,0 +1,169 @@
+
import type { ZodSchema } from "zod";
+
import { array, literal, number, strictObject, string, union } from "zod";
+

+
interface GitPerson {
+
  name: string;
+
  email: string;
+
}
+

+
const gitPersonSchema = strictObject({
+
  name: string(),
+
  email: string(),
+
}) satisfies ZodSchema<GitPerson>;
+

+
export interface CommitHeader {
+
  id: string;
+
  author: GitPerson;
+
  summary: string;
+
  description: string;
+
  committer: GitPerson & { time: number };
+
}
+

+
export const commitHeaderSchema = strictObject({
+
  id: string(),
+
  author: gitPersonSchema,
+
  summary: string(),
+
  description: string(),
+
  committer: gitPersonSchema.merge(strictObject({ time: number() })),
+
}) satisfies ZodSchema<CommitHeader>;
+

+
interface AdditionHunkLine {
+
  line: string;
+
  lineNo: number;
+
  type: "addition";
+
}
+

+
const additionHunkLineSchema = strictObject({
+
  line: string(),
+
  lineNo: number(),
+
  type: literal("addition"),
+
}) satisfies ZodSchema<AdditionHunkLine>;
+

+
interface DeletionHunkLine {
+
  line: string;
+
  lineNo: number;
+
  type: "deletion";
+
}
+

+
const deletionHunkLineSchema = strictObject({
+
  line: string(),
+
  lineNo: number(),
+
  type: literal("deletion"),
+
}) satisfies ZodSchema<DeletionHunkLine>;
+

+
interface ContextHunkLine {
+
  line: string;
+
  lineNoNew: number;
+
  lineNoOld: number;
+
  type: "context";
+
}
+

+
const contextHunkLineSchema = strictObject({
+
  line: string(),
+
  lineNoNew: number(),
+
  lineNoOld: number(),
+
  type: literal("context"),
+
}) satisfies ZodSchema<ContextHunkLine>;
+

+
export type HunkLine = AdditionHunkLine | DeletionHunkLine | ContextHunkLine;
+

+
const hunkLineSchema = union([
+
  additionHunkLineSchema,
+
  deletionHunkLineSchema,
+
  contextHunkLineSchema,
+
]) satisfies ZodSchema<HunkLine>;
+

+
interface ChangesetHunk {
+
  header: string;
+
  lines: HunkLine[];
+
}
+

+
const changesetHunkSchema = strictObject({
+
  header: string(),
+
  lines: array(hunkLineSchema),
+
}) satisfies ZodSchema<ChangesetHunk>;
+

+
export interface DiffAddedDeletedModifiedChangeset {
+
  path: string;
+
  diff: {
+
    type: "plain" | "binary" | "empty";
+
    hunks: ChangesetHunk[];
+
    eof: "noneMissing" | "oldMissing" | "newMissing" | "bothMissing";
+
  };
+
}
+

+
const diffAddedDeletedModifiedChangesetSchema = strictObject({
+
  path: string(),
+
  diff: strictObject({
+
    type: union([literal("plain"), literal("binary"), literal("empty")]),
+
    hunks: array(changesetHunkSchema),
+
    eof: union([
+
      literal("noneMissing"),
+
      literal("oldMissing"),
+
      literal("newMissing"),
+
      literal("bothMissing"),
+
    ]),
+
  }),
+
}) satisfies ZodSchema<DiffAddedDeletedModifiedChangeset>;
+

+
interface DiffCopiedMovedChangeset {
+
  newPath: string;
+
  oldPath: string;
+
}
+

+
const diffCopiedMovedChangesetSchema = strictObject({
+
  newPath: string(),
+
  oldPath: string(),
+
}) satisfies ZodSchema<DiffCopiedMovedChangeset>;
+

+
export interface Diff {
+
  added: DiffAddedDeletedModifiedChangeset[];
+
  deleted: DiffAddedDeletedModifiedChangeset[];
+
  moved: DiffCopiedMovedChangeset[];
+
  copied: DiffCopiedMovedChangeset[];
+
  modified: DiffAddedDeletedModifiedChangeset[];
+
  stats: {
+
    filesChanged: number;
+
    insertions: number;
+
    deletions: number;
+
  };
+
}
+

+
export const diffSchema = strictObject({
+
  added: array(diffAddedDeletedModifiedChangesetSchema),
+
  deleted: array(diffAddedDeletedModifiedChangesetSchema),
+
  moved: array(diffCopiedMovedChangesetSchema),
+
  copied: array(diffCopiedMovedChangesetSchema),
+
  modified: array(diffAddedDeletedModifiedChangesetSchema),
+
  stats: strictObject({
+
    filesChanged: number(),
+
    insertions: number(),
+
    deletions: number(),
+
  }),
+
}) satisfies ZodSchema<Diff>;
+

+
export interface Commit {
+
  commit: CommitHeader;
+
  diff: Diff;
+
  branches: string[];
+
}
+

+
export const commitSchema = strictObject({
+
  commit: commitHeaderSchema,
+
  diff: diffSchema,
+
  branches: array(string()),
+
}) satisfies ZodSchema<Commit>;
+

+
export interface Commits {
+
  commits: Commit[];
+
  stats: { commits: number; branches: number; contributors: number };
+
}
+

+
export const commitsSchema = strictObject({
+
  commits: array(commitSchema),
+
  stats: strictObject({
+
    commits: number(),
+
    branches: number(),
+
    contributors: number(),
+
  }),
+
}) satisfies ZodSchema<Commits>;
added httpd-client/lib/project/issue.ts
@@ -0,0 +1,60 @@
+
import type { Comment, ThreadUpdateAction } from "./comment.js";
+
import type { ZodSchema } from "zod";
+
import { array, boolean, literal, strictObject, string, union } from "zod";
+

+
import { commentSchema } from "./comment.js";
+

+
export type IssueState =
+
  | { status: "open" }
+
  | { status: "closed"; reason: "other" | "solved" };
+

+
const issueStateSchema = union([
+
  strictObject({ status: literal("open") }),
+
  strictObject({
+
    status: literal("closed"),
+
    reason: union([literal("other"), literal("solved")]),
+
  }),
+
]) satisfies ZodSchema<IssueState>;
+

+
export interface Issue {
+
  id: string;
+
  author: { id: string };
+
  title: string;
+
  state: IssueState;
+
  discussion: Comment[];
+
  tags: string[];
+
  assignees: string[];
+
}
+

+
export const issueSchema = strictObject({
+
  id: string(),
+
  author: strictObject({ id: string() }),
+
  title: string(),
+
  state: issueStateSchema,
+
  discussion: array(commentSchema),
+
  tags: array(string()),
+
  assignees: array(string()),
+
}) satisfies ZodSchema<Issue>;
+

+
export interface IssueCreated {
+
  success: boolean;
+
  id: string;
+
}
+

+
export const issueCreatedSchema = strictObject({
+
  success: boolean(),
+
  id: string(),
+
}) satisfies ZodSchema<IssueCreated>;
+

+
export const issuesSchema = array(issueSchema) satisfies ZodSchema<Issue[]>;
+

+
export type IssueUpdateAction =
+
  | {
+
      type: "assign";
+
      add: string[];
+
      remove: string[];
+
    }
+
  | { type: "edit"; title: string }
+
  | { type: "lifecycle"; state: IssueState }
+
  | { type: "tag"; add: string[]; remove: string[] }
+
  | { type: "thread"; action: ThreadUpdateAction };
added httpd-client/lib/project/patch.ts
@@ -0,0 +1,151 @@
+
import type { Comment, ThreadUpdateAction } from "./comment.js";
+
import type { ZodSchema } from "zod";
+

+
import { commentSchema } from "./comment.js";
+

+
import {
+
  array,
+
  literal,
+
  number,
+
  optional,
+
  strictObject,
+
  string,
+
  tuple,
+
  union,
+
} from "zod";
+

+
export type PatchState =
+
  | { status: "draft" }
+
  | { status: "open" }
+
  | { status: "archived" }
+
  | { status: "merged" };
+

+
const patchStateSchema = union([
+
  strictObject({ status: literal("draft") }),
+
  strictObject({ status: literal("open") }),
+
  strictObject({ status: literal("archived") }),
+
  strictObject({ status: literal("merged") }),
+
]) satisfies ZodSchema<PatchState>;
+

+
export interface Merge {
+
  node: string;
+
  commit: string;
+
  timestamp: number;
+
}
+

+
const mergeSchema = strictObject({
+
  node: string(),
+
  commit: string(),
+
  timestamp: number(),
+
}) satisfies ZodSchema<Merge>;
+

+
interface CodeLocation {
+
  path: string;
+
  commit: string;
+
  lines: {
+
    start: number;
+
    end: number;
+
  };
+
}
+

+
const codeLocationSchema = strictObject({
+
  path: string(),
+
  commit: string(),
+
  lines: strictObject({
+
    start: number(),
+
    end: number(),
+
  }),
+
}) satisfies ZodSchema<CodeLocation>;
+

+
interface CodeComment {
+
  location: CodeLocation;
+
  comment: string;
+
  timestamp: number;
+
}
+

+
const codeCommentSchema = strictObject({
+
  location: codeLocationSchema,
+
  comment: string(),
+
  timestamp: number(),
+
}) satisfies ZodSchema<CodeComment>;
+

+
type Verdict = "accept" | "reject";
+

+
export interface Review {
+
  verdict?: Verdict;
+
  comment?: string;
+
  inline: CodeComment[];
+
  timestamp: number;
+
}
+

+
const reviewSchema = strictObject({
+
  verdict: optional(union([literal("accept"), literal("reject")])),
+
  comment: optional(string()),
+
  inline: array(codeCommentSchema),
+
  timestamp: number(),
+
}) satisfies ZodSchema<Review>;
+

+
interface Revision {
+
  id: string;
+
  description: string;
+
  base: string;
+
  oid: string;
+
  refs: string[];
+
  discussions: Comment[];
+
  reviews: [string, Review][];
+
  merges: Merge[];
+
  timestamp: number;
+
}
+

+
const revisionSchema = strictObject({
+
  id: string(),
+
  description: string(),
+
  base: string(),
+
  oid: string(),
+
  refs: array(string()),
+
  discussions: array(commentSchema),
+
  reviews: array(tuple([string(), reviewSchema])),
+
  merges: array(mergeSchema),
+
  timestamp: number(),
+
}) satisfies ZodSchema<Revision>;
+

+
export interface Patch {
+
  id: string;
+
  author: { id: string };
+
  title: string;
+
  description: string;
+
  state: PatchState;
+
  target: string;
+
  tags: string[];
+
  revisions: Revision[];
+
}
+

+
export const patchSchema = strictObject({
+
  id: string(),
+
  author: strictObject({ id: string() }),
+
  title: string(),
+
  description: string(),
+
  state: patchStateSchema,
+
  target: string(),
+
  tags: array(string()),
+
  revisions: array(revisionSchema),
+
}) satisfies ZodSchema<Patch>;
+

+
export const patchesSchema = array(patchSchema) satisfies ZodSchema<Patch[]>;
+

+
export type PatchUpdateAction =
+
  | { type: "edit"; title: string; description: string; target: string }
+
  | { type: "editRevision"; revision: string; description: string }
+
  | { type: "tag"; add: string[]; remove: string[] }
+
  | { type: "revision"; description: string; base: string; oid: string }
+
  | { type: "lifecycle"; state: PatchState }
+
  | { type: "redact"; revision: string }
+
  | {
+
      type: "review";
+
      revision: string;
+
      verdict?: Verdict;
+
      comment?: string;
+
      inline: CodeComment;
+
    }
+
  | { type: "merge"; revision: string; commit: string }
+
  | { type: "thread"; revision: string; action: ThreadUpdateAction };
added httpd-client/lib/session.ts
@@ -0,0 +1,76 @@
+
import type { Fetcher, RequestOptions } from "./fetcher.js";
+
import type { SuccessResponse } from "./shared.js";
+
import type { ZodSchema } from "zod";
+

+
import { number, strictObject, string } from "zod";
+

+
import { successResponseSchema } from "./shared.js";
+

+
interface Session {
+
  sessionId: string;
+
  status: string;
+
  publicKey: string;
+
  issuedAt: number;
+
  expiresAt: number;
+
}
+

+
const sessionSchema = strictObject({
+
  sessionId: string(),
+
  status: string(),
+
  publicKey: string(),
+
  issuedAt: number(),
+
  expiresAt: number(),
+
}) satisfies ZodSchema<Session>;
+

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

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

+
  public async getById(id: string, options?: RequestOptions): Promise<Session> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `sessions/${id}`,
+
        options,
+
      },
+
      sessionSchema,
+
    );
+
  }
+

+
  public async update(
+
    id: string,
+
    body: {
+
      sig: string;
+
      pk: string;
+
    },
+
    options?: RequestOptions,
+
  ): Promise<SuccessResponse> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "PUT",
+
        path: `sessions/${id}`,
+
        body,
+
        options,
+
      },
+
      successResponseSchema,
+
    );
+
  }
+

+
  public async delete(
+
    id: string,
+
    options?: RequestOptions,
+
  ): Promise<SuccessResponse> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "DELETE",
+
        path: `sessions/${id}`,
+
        headers: { Authorization: `Bearer ${id}` },
+
        options,
+
      },
+
      successResponseSchema,
+
    );
+
  }
+
}
added httpd-client/lib/shared.ts
@@ -0,0 +1,11 @@
+
import type { ZodSchema } from "zod";
+

+
import { literal, strictObject } from "zod";
+

+
export interface SuccessResponse {
+
  success: true;
+
}
+

+
export const successResponseSchema = strictObject({
+
  success: literal(true),
+
}) satisfies ZodSchema<SuccessResponse>;
added httpd-client/tests/client.test.ts
@@ -0,0 +1,22 @@
+
import { describe, test } from "vitest";
+
import { HttpdClient } from "../index";
+

+
const api = new HttpdClient({
+
  hostname: "127.0.0.1",
+
  port: 8080,
+
  scheme: "http",
+
});
+

+
describe("client", () => {
+
  test("#getRoot()", async () => {
+
    await api.getRoot();
+
  });
+

+
  test("#getStats()", async () => {
+
    await api.getStats();
+
  });
+

+
  test("#getNode()", async () => {
+
    await api.getNode();
+
  });
+
});
added httpd-client/tests/project.test.ts
@@ -0,0 +1,100 @@
+
import { describe, test } from "vitest";
+
import { HttpdClient } from "../index";
+

+
const api = new HttpdClient({
+
  hostname: "127.0.0.1",
+
  port: 8080,
+
  scheme: "http",
+
});
+

+
describe("project", () => {
+
  test("#getByDelegate(delegateId)", async () => {
+
    await api.project.getByDelegate(
+
      "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
    );
+
  });
+

+
  test("#getAll()", async () => {
+
    await api.project.getAll();
+
  });
+

+
  test("#getById(id)", async () => {
+
    await api.project.getById("rad:zKtT7DmF9H34KkvcKj9PHW19WzjT");
+
  });
+

+
  test("#getActivity(id)", async () => {
+
    await api.project.getActivity("rad:zKtT7DmF9H34KkvcKj9PHW19WzjT");
+
  });
+

+
  test("#getReadme(id, sha)", async () => {
+
    await api.project.getReadme(
+
      "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
+
      "fcc929424b82984b7cbff9c01d2e20d9b1249842",
+
    );
+
  });
+

+
  test("#getBlob(id, sha, path)", async () => {
+
    await api.project.getBlob(
+
      "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
+
      "dd068e9aff9a569e597f6abaf84f120dd0cbbd70",
+
      "src/true.c",
+
    );
+
  });
+

+
  test("#getTree(id, sha)", async () => {
+
    await api.project.getTree(
+
      "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
+
      "dd068e9aff9a569e597f6abaf84f120dd0cbbd70",
+
    );
+
  });
+

+
  test("#getTree(id, sha, path)", async () => {
+
    await api.project.getTree(
+
      "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
+
      "dd068e9aff9a569e597f6abaf84f120dd0cbbd70",
+
      "src",
+
    );
+
  });
+

+
  test("#getAllRemotes(id)", async () => {
+
    await api.project.getAllRemotes("rad:zKtT7DmF9H34KkvcKj9PHW19WzjT");
+
  });
+

+
  test("#getRemoteByPeer(id, peer)", async () => {
+
    await api.project.getRemoteByPeer(
+
      "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
+
      "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
    );
+
  });
+

+
  test("#getAllCommits(id)", async () => {
+
    await api.project.getAllCommits("rad:zKtT7DmF9H34KkvcKj9PHW19WzjT");
+
  });
+

+
  // TODO: test since/until properly.
+
  test("#getAllCommits(id, {parent, since, until, page, perPage})", async () => {
+
    await api.project.getAllCommits("rad:zKtT7DmF9H34KkvcKj9PHW19WzjT", {
+
      parent: "f0b8db68847b01f0964380507a9db6800e5b5342",
+
      since: 1679065819581,
+
      until: 1679065819590,
+
      page: 1,
+
      perPage: 2,
+
    });
+
  });
+

+
  test("#getCommitBySha(id, sha)", async () => {
+
    await api.project.getCommitBySha(
+
      "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
+
      "fcc929424b82984b7cbff9c01d2e20d9b1249842",
+
    );
+
  });
+

+
  test.todo("#getDiff(id, revisionBase, revisionOid)");
+
  test.todo("#getIssueById(id, issueId)");
+
  test.todo("#getAllIssues(id)");
+
  test.todo("#createIssue(id, { title, description, assignees, tags })");
+
  test.todo("#updateIssue(id, issueId, issueUpdateAction, authToken)");
+
  test.todo("#getPatchById(id, patchId)");
+
  test.todo("#getAllPatches(id)");
+
  test.todo("#updatePatch(id, patchId, patchUpdateAction, authToken)");
+
});
added httpd-client/tests/session.test.ts
@@ -0,0 +1,7 @@
+
import { describe, test } from "vitest";
+

+
describe("session", () => {
+
  test.todo("#getById(id)");
+
  test.todo("#update(id, {sig, pk})");
+
  test.todo("#delete(id)");
+
});
added httpd-client/vite.config.ts
@@ -0,0 +1,17 @@
+
/// <reference types="vitest" />
+
import { defineConfig } from "vite";
+
import path from "node:path";
+

+
export default defineConfig({
+
  test: {
+
    environment: "node",
+
    include: ["httpd-client/tests/*.test.ts"],
+
    reporters: "verbose",
+
    globalSetup: "./tests/support/globalSetup",
+
  },
+
  resolve: {
+
    alias: {
+
      "@tests": path.resolve("./tests"),
+
    },
+
  },
+
});
modified package-lock.json
@@ -21,7 +21,8 @@
        "md5": "^2.3.0",
        "plausible-tracker": "^0.3.8",
        "svelte": "^3.57.0",
-
        "twemoji": "^14.0.2"
+
        "twemoji": "^14.0.2",
+
        "zod": "^3.21.2"
      },
      "devDependencies": {
        "@playwright/test": "^1.32.1",
@@ -4191,6 +4192,14 @@
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
+
    "node_modules/zod": {
+
      "version": "3.21.4",
+
      "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz",
+
      "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==",
+
      "funding": {
+
        "url": "https://github.com/sponsors/colinhacks"
+
      }
+
    },
    "node_modules/zwitch": {
      "version": "2.0.4",
      "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
modified package.json
@@ -11,7 +11,8 @@
    "format": "npx prettier '**/*.@(ts|js|svelte|json|css|html|yml)' --ignore-path .gitignore --write",
    "test:unit": "TZ='UTC' vitest run",
    "test:e2e": "TZ='UTC' playwright test",
-
    "test:e2e:ipfs": "TZ='UTC' playwright test ./tests/e2e/hashRouter.spec.ts --config playwright.ipfs.config.ts"
+
    "test:e2e:ipfs": "TZ='UTC' playwright test ./tests/e2e/hashRouter.spec.ts --config playwright.ipfs.config.ts",
+
    "test:httpd-api:unit": "TZ='UTC' vitest run --config httpd-client/vite.config.ts --reporter verbose --threads false"
  },
  "type": "module",
  "engines": {
@@ -55,6 +56,7 @@
    "md5": "^2.3.0",
    "plausible-tracker": "^0.3.8",
    "svelte": "^3.57.0",
-
    "twemoji": "^14.0.2"
+
    "twemoji": "^14.0.2",
+
    "zod": "^3.21.2"
  }
}
modified scripts/run-httpd-with-fixtures
@@ -1,7 +1,7 @@
#!/bin/bash
set -e

-
REV=646376828d0520035246ccbbfcb82127160cd184
+
REV=414477a31676d5e75efa7f3e8dc6624bcf2b2e52

REPO_ROOT=$(git rev-parse --show-toplevel)
FIXTURE=$REPO_ROOT/tests/fixtures/seeds/palm.tar.bz2
@@ -75,10 +75,13 @@ if [ "$DOWNLOAD" = true ]; then
  CACHED_BINARY_NAME="$BINARY_NAME-${REV:0:7}"

  if ! [ -x "$(command -v $BINARY_PATH/$CACHED_BINARY_NAME)" ]; then
-
    echo "Downloading $BINARY_NAME"
    case "$OS" in
-
      Darwin)  curl --fail -s "https://files.radicle.xyz/aarch64-apple-darwin/$REV/$BINARY_NAME" --output "$BINARY_PATH/$CACHED_BINARY_NAME" ;;
-
      Linux)   curl --fail -s "https://files.radicle.xyz/x86_64-unknown-linux-musl/$REV/$BINARY_NAME" --output "$BINARY_PATH/$CACHED_BINARY_NAME" ;;
+
      Darwin)
+
        echo Downloading $BINARY_NAME from https://files.radicle.xyz/$REV/aarch64-apple-darwin/$BINARY_NAME
+
        curl --fail -s "https://files.radicle.xyz/$REV/aarch64-apple-darwin/$BINARY_NAME" --output "$BINARY_PATH/$CACHED_BINARY_NAME" || (echo "Download failed" && exit 1);;
+
      Linux)
+
        echo Downloading $BINARY_NAME from https://files.radicle.xyz/$REV/x86_64-unknown-linux-musl/$BINARY_NAME
+
        curl --fail -s "https://files.radicle.xyz/$REV/x86_64-unknown-linux-musl/$BINARY_NAME" --output "$BINARY_PATH/$CACHED_BINARY_NAME" || (echo "Download failed" && exit 1);;
      *)       echo "There are no precompiled binaries for your OS: $OS, compile $BINARY_NAME manually and make sure it's in PATH." && exit 1 ;;
    esac

modified src/App.svelte
@@ -60,7 +60,7 @@
    {#if $activeRouteStore.resource === "home"}
      <Home />
    {:else if $activeRouteStore.resource === "seeds"}
-
      <Seeds hostAndPort={$activeRouteStore.params.host} />
+
      <Seeds hostnamePort={$activeRouteStore.params.hostnamePort} />
    {:else if $activeRouteStore.resource === "session"}
      <Session activeRoute={$activeRouteStore} />
    {:else if $activeRouteStore.resource === "projects"}
modified src/App/Header/Search.svelte
@@ -42,18 +42,17 @@
    if (searchResult.type === "nothing") {
      shake();
    } else if (searchResult.type === "error") {
-
      // TODO: show some kind of notification to the user.
      shake();
    } else if (searchResult.type === "projects") {
      input = "";
-
      if (searchResult.projects.length === 1) {
+
      if (searchResult.results.length === 1) {
        router.push({
          resource: "projects",
          params: {
            view: { resource: "tree" },
-
            id: searchResult.projects[0].info.id,
+
            id: searchResult.results[0].project.id,
            peer: undefined,
-
            seed: searchResult.projects[0].seed.host,
+
            hostnamePort: searchResult.results[0].baseUrl.hostname,
            hash: undefined,
            search: undefined,
          },
@@ -62,7 +61,7 @@
        modal.show({
          component: SearchResultsModal,
          props: {
-
            results: searchResult.projects,
+
            results: searchResult.results,
            query,
          },
        });
@@ -104,14 +103,14 @@
  }
  .search {
    transition: all 0.2s;
-
    width: 13rem;
+
    width: 11rem;
  }
  .expanded {
-
    width: 20rem;
+
    width: 25.5rem;
  }
  @media (max-width: 720px) {
    .expanded {
-
      width: 13rem;
+
      width: 11rem;
    }
  }
</style>
@@ -132,7 +131,7 @@
        }
      }}
      on:submit={search}
-
      placeholder="Search a name…">
+
      placeholder="Search a RID…">
      <svelte:fragment slot="left">
        <Icon name="magnifying-glass" />
      </svelte:fragment>
modified src/App/Header/SearchResultsModal.svelte
@@ -1,13 +1,14 @@
<script lang="ts" strictEvents>
-
  import type { ProjectResult } from "@app/lib/search";
+
  import type { ProjectAndSeed } from "@app/lib/search";

  import * as modal from "@app/lib/modal";
+
  import { formatRepositoryId } from "@app/lib/utils";
+

  import Link from "@app/components/Link.svelte";
  import Modal from "@app/components/Modal.svelte";
-
  import { formatRepositoryId } from "@app/lib/utils";

  export let query: string;
-
  export let results: ProjectResult[];
+
  export let results: ProjectAndSeed[];
</script>

<style>
@@ -31,7 +32,7 @@
    {#if results.length > 0}
      <div class="txt-highlight txt-medium">Projects</div>
      <ul>
-
        {#each results as project}
+
        {#each results as result}
          <li>
            <Link
              on:click={modal.hide}
@@ -39,14 +40,14 @@
                resource: "projects",
                params: {
                  view: { resource: "tree" },
-
                  seed: project.seed.host,
-
                  id: project.info.id,
+
                  hostnamePort: result.baseUrl.hostname,
+
                  id: result.project.id,
                },
              }}>
-
              <span title={project.seed.host}>
-
                <span>{project.info.name}</span>
+
              <span title={result.baseUrl.hostname}>
+
                <span>{result.project.name}</span>
                <span class="id">
-
                  &nbsp;{formatRepositoryId(project.info.id)}
+
                  &nbsp;{formatRepositoryId(result.project.id)}
                </span>
              </span>
            </Link>
modified src/App/Hotkeys.svelte
@@ -17,7 +17,7 @@
      case "/": {
        event.preventDefault();
        const searchInput: HTMLElement | null = document.querySelector(
-
          '*[placeholder="Search a name…"]',
+
          '*[placeholder="Search a RID…"]',
        );
        searchInput?.focus();
        break;
modified src/components/Authorship.svelte
@@ -1,10 +1,8 @@
<script lang="ts">
-
  import type { Author } from "@app/lib/cobs";
-

  import Avatar from "@app/components/Avatar.svelte";
  import { formatNodeId, formatTimestamp } from "@app/lib/utils";

-
  export let author: Author;
+
  export let authorId: string;
  export let caption: string | undefined = undefined;
  export let highlight: boolean = false;
  export let noAvatar: boolean = false;
@@ -42,13 +40,13 @@

<span class="authorship txt-tiny" title={relativeTimestamp(timestamp)}>
  {#if !noAvatar}
-
    <Avatar inline nodeId={author.id} />
+
    <Avatar inline nodeId={authorId} />
  {/if}
  <span class:highlight class="id highlight layout-desktop">
-
    {formatNodeId(author.id)}
+
    {formatNodeId(authorId)}
  </span>
  <span class:highlight class="id layout-mobile">
-
    {formatNodeId(author.id).replace("did:key:", "")}
+
    {formatNodeId(authorId).replace("did:key:", "")}
  </span>
  <span class="body">
    {#if !caption}
modified src/components/Comment.svelte
@@ -1,6 +1,4 @@
<script lang="ts" strictEvents>
-
  import type { Author } from "@app/lib/cobs";
-

  import Authorship from "@app/components/Authorship.svelte";
  import Button from "@app/components/Button.svelte";
  import Icon from "@app/components/Icon.svelte";
@@ -9,7 +7,7 @@
  import { createEventDispatcher } from "svelte";

  export let id: string | undefined = undefined;
-
  export let author: Author;
+
  export let authorId: string;
  export let timestamp: number;
  export let body: string;
  export let showReplyIcon: boolean = false;
@@ -56,10 +54,10 @@
  <div class="card">
    <div class="card-header">
      <div class="layout-desktop">
-
        <Authorship {caption} {author} {timestamp} />
+
        <Authorship {caption} {authorId} {timestamp} />
      </div>
      <div class="layout-mobile">
-
        <Authorship {author} {timestamp} />
+
        <Authorship {authorId} {timestamp} />
      </div>
      <div class="actions">
        {#if showReplyIcon}
modified src/components/DiffStatBadge.svelte
@@ -1,7 +1,6 @@
<script lang="ts">
-
  import type { DiffStats } from "@app/lib/diff";
-

-
  export let stats: DiffStats;
+
  export let insertions: number;
+
  export let deletions: number;
</script>

<style>
@@ -29,9 +28,9 @@

<div class="badge">
  <span class="positive">
-
    + {stats.insertions}
+
    + {insertions}
  </span>
  <span class="negative">
-
    - {stats.deletions}
+
    - {deletions}
  </span>
</div>
modified src/components/Dropdown.svelte
@@ -8,16 +8,14 @@
</script>

<script lang="ts" strictEvents>
-
  import type { State } from "@app/lib/cobs";
-

  import { createEventDispatcher } from "svelte";
  import { twemoji } from "@app/lib/utils";
  import Badge from "@app/components/Badge.svelte";

-
  type T = $$Generic<State | string | number>;
+
  type T = $$Generic;

  export let items: Item<T>[];
-
  export let selected: string | null = null;
+
  export let selected: T | null = null;

  const dispatch = createEventDispatcher<{ select: Item<T> }>();
  const onSelect = (item: Item<T>) => {
modified src/components/Thread.svelte
@@ -1,14 +1,14 @@
<script lang="ts">
-
  import type * as cobs from "@app/lib/cobs";
+
  import type { Comment } from "@httpd-client";

  import Button from "@app/components/Button.svelte";
-
  import Comment from "@app/components/Comment.svelte";
+
  import CommentComponent from "@app/components/Comment.svelte";
  import Textarea from "@app/components/Textarea.svelte";
  import { createEventDispatcher, tick } from "svelte";
  import { scrollIntoView } from "@app/lib/utils";
  import { sessionStore } from "@app/lib/session";

-
  export let thread: { root: cobs.Comment; replies: cobs.Comment[] };
+
  export let thread: { root: Comment; replies: Comment[] };
  export let rawPath: string;
  export let isDescription = false;
  export let showReplyTextarea = false;
@@ -62,20 +62,20 @@
  }
</style>

-
<Comment
+
<CommentComponent
  {rawPath}
  id={root.id}
-
  author={root.author}
+
  authorId={root.author.id}
  timestamp={root.timestamp}
  body={root.body}
  showReplyIcon={Boolean($sessionStore) && !isDescription}
  on:toggleReply={toggleReply} />
{#each replies as reply}
  <div class="reply">
-
    <Comment
+
    <CommentComponent
      {rawPath}
      id={reply.id}
-
      author={reply.author}
+
      authorId={reply.author.id}
      timestamp={reply.timestamp}
      body={reply.body} />
  </div>
modified src/config.json
@@ -6,7 +6,11 @@
    "defaultNodePort": 8776,
    "pinned": [
      {
-
        "host": "seed.radicle.xyz"
+
        "baseUrl": {
+
          "hostname": "seed.radicle.xyz",
+
          "port": 443,
+
          "scheme": "https"
+
        }
      }
    ]
  },
@@ -15,27 +19,47 @@
      {
        "name": "radicle-interface",
        "id": "rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5",
-
        "seed": "seed.radicle.xyz"
+
        "baseUrl": {
+
          "hostname": "seed.radicle.xyz",
+
          "port": 443,
+
          "scheme": "https"
+
        }
      },
      {
        "name": "heartwood",
        "id": "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5",
-
        "seed": "seed.radicle.xyz"
+
        "baseUrl": {
+
          "hostname": "seed.radicle.xyz",
+
          "port": 443,
+
          "scheme": "https"
+
        }
      },
      {
        "name": "rips",
        "id": "rad:z3trNYnLWS11cJWC6BbxDs5niGo82",
-
        "seed": "seed.radicle.xyz"
+
        "baseUrl": {
+
          "hostname": "seed.radicle.xyz",
+
          "port": 443,
+
          "scheme": "https"
+
        }
      },
      {
        "name": "radicle-git",
        "id": "rad:zMKWcgFLjUSVBBVmxbcMzzokRxub",
-
        "seed": "seed.radicle.xyz"
+
        "baseUrl": {
+
          "hostname": "seed.radicle.xyz",
+
          "port": 443,
+
          "scheme": "https"
+
        }
      },
      {
        "name": "radicle-team",
        "id": "rad:z2Jk1mNqyX7AjT4K83jJW9vQoHn4f",
-
        "seed": "seed.radicle.xyz"
+
        "baseUrl": {
+
          "hostname": "seed.radicle.xyz",
+
          "port": 443,
+
          "scheme": "https"
+
        }
      }
    ]
  }
deleted src/lib/api.ts
@@ -1,127 +0,0 @@
-
export interface Host {
-
  host: string;
-
  port: number;
-
  scheme: string;
-
}
-

-
export class Request {
-
  path: string;
-
  base: string;
-
  port: number;
-

-
  constructor(path: string, api: Host) {
-
    this.port = api.port;
-
    this.base = `${api.scheme}://${api.host}/api/v1`;
-
    this.path = path.startsWith("/") ? path.slice(1) : path;
-
  }
-

-
  async get(
-
    params: Record<string, any> = {},
-
    headers: Record<string, string> = {},
-
  ): Promise<any> {
-
    const query = this.formatParams(params);
-
    const search = new URLSearchParams(query).toString();
-
    const urlString = this.createUrl(search);
-

-
    return await Request.exec(urlString, {
-
      method: "GET",
-
      headers: { ...headers, Accept: "application/json" },
-
    });
-
  }
-

-
  async post(
-
    params: Record<string, any> = {},
-
    headers: Record<string, string> = {},
-
  ): Promise<any> {
-
    const body = this.formatParams(params);
-
    const urlString = this.createUrl();
-

-
    return await Request.exec(urlString, {
-
      method: "POST",
-
      body: JSON.stringify(body),
-
      headers: { ...headers, "Content-Type": "application/json" },
-
    });
-
  }
-

-
  async patch(
-
    params: Record<string, any> = {},
-
    headers: Record<string, string> = {},
-
  ): Promise<any> {
-
    const body = this.formatParams(params);
-
    const urlString = this.createUrl();
-

-
    return await Request.exec(urlString, {
-
      method: "PATCH",
-
      body: JSON.stringify(body),
-
      headers: { ...headers, "Content-Type": "application/json" },
-
    });
-
  }
-

-
  async put(
-
    params: Record<string, any> = {},
-
    headers: Record<string, string> = {},
-
  ): Promise<any> {
-
    const body = this.formatParams(params);
-
    const urlString = this.createUrl();
-

-
    return await Request.exec(urlString, {
-
      method: "PUT",
-
      body: JSON.stringify(body),
-
      headers: { ...headers, "Content-Type": "application/json" },
-
    });
-
  }
-

-
  // Executes a request and returns the response.
-
  static async exec(
-
    urlString: string,
-
    props: Record<string, any>,
-
  ): Promise<any> {
-
    let response = null;
-
    try {
-
      response = await fetch(urlString, props);
-
    } catch (err) {
-
      throw new ApiError("API request failed", urlString);
-
    }
-

-
    if (!response.ok) {
-
      throw new ApiError(response.statusText, urlString);
-
    }
-
    return response.json();
-
  }
-

-
  // Filters out undefined and null values.
-
  private formatParams(params: Record<string, any>): Record<string, string> {
-
    const query: Record<string, string> = {};
-
    for (const [key, val] of Object.entries(params)) {
-
      if (val !== undefined && val !== null) {
-
        query[key] = val;
-
      }
-
    }
-

-
    return query;
-
  }
-

-
  // Creates a URL with an eventual query string and port.
-
  private createUrl(search?: string): string {
-
    const baseUrl = this.path ? `${this.base}/${this.path}` : this.base;
-

-
    const url = new URL(search ? `${baseUrl}?${search}` : baseUrl);
-
    url.port = String(this.port);
-
    return String(url);
-
  }
-
}
-

-
export class ApiError extends Error {
-
  url?: string;
-

-
  constructor(message: string, url?: string) {
-
    super(message);
-

-
    if (Error.captureStackTrace) {
-
      Error.captureStackTrace(this, ApiError);
-
    }
-

-
    this.name = "ApiError";
-
    this.url = url;
-
  }
-
}
modified src/lib/cobs.ts
@@ -1,35 +1,5 @@
-
import type { IssueState } from "@app/lib/issue";
-
import type { PatchState } from "@app/lib/patch";
-

import { parseNodeId } from "@app/lib/utils";

-
export interface Comment {
-
  id: string;
-
  author: Author;
-
  body: string;
-
  reactions: Record<string, number>;
-
  timestamp: number;
-
  replyTo: string | null;
-
}
-

-
export interface Author {
-
  id: string;
-
}
-

-
export interface Thread {
-
  root: Comment;
-
  replies: Comment[];
-
}
-

-
export interface PeerIdentity {
-
  id: string;
-
}
-
export interface PeerInfo {
-
  id: string;
-
  person?: PeerIdentity;
-
  delegate: boolean;
-
}
-

// Formats COBs Object Ids
export function formatObjectId(id: string): string {
  return id.substring(0, 11);
@@ -39,8 +9,6 @@ export function stripDidPrefix(array: string[]): string[] {
  return array.map(id => id.replace("did:key:", ""));
}

-
export type State = IssueState | PatchState;
-

export function validateTag(
  value: string,
  items: string[],
modified src/lib/commit.ts
@@ -1,47 +1,12 @@
-
import type { Stats } from "@app/lib/project";
-
import type { Diff, DiffStats } from "@app/lib/diff";
-
import { ApiError } from "@app/lib/api";
-
import { getDaysPassed } from "@app/lib/utils";
-

-
export interface CommitsHistory {
-
  commits: CommitMetadata[];
-
  stats: Stats;
-
}
-

-
export interface CommitMetadata {
-
  commit: CommitHeader;
-
}
-

-
export interface Author {
-
  email: string;
-
  name: string;
-
  time: number;
-
}
+
import type { CommitHeader } from "@httpd-client";

-
export interface CommitStats {
-
  branches: number;
-
  commits: number;
-
  contributors: number;
-
}
-

-
export interface GroupedCommitsHistory {
-
  commits: CommitGroup[];
-
  stats: Stats;
-
}
-

-
export interface CommitHeader {
-
  author: Author;
-
  committer: Author;
-
  description: string;
-
  id: string;
-
  summary: string;
-
}
+
import { getDaysPassed } from "@app/lib/utils";

// A set of commits grouped by time.
-
export interface CommitGroup {
+
interface CommitGroup {
  date: string;
  time: number;
-
  commits: CommitMetadata[];
+
  commits: CommitHeader[];
  week: number;
}

@@ -52,14 +17,7 @@ export interface WeeklyActivity {
  week: number;
}

-
export interface Commit {
-
  commit: CommitHeader;
-
  stats: DiffStats;
-
  diff: Diff;
-
  branches: string[];
-
}
-

-
export function formatGroupTime(timestamp: number): string {
+
function formatGroupTime(timestamp: number): string {
  return new Date(timestamp).toLocaleDateString("en-US", {
    day: "numeric",
    weekday: "long",
@@ -68,24 +26,22 @@ export function formatGroupTime(timestamp: number): string {
  });
}

-
export function groupCommits(
-
  commits: { commit: CommitHeader }[],
-
): CommitGroup[] {
+
export function groupCommits(commits: CommitHeader[]): CommitGroup[] {
  const groupedCommits: CommitGroup[] = [];
  let groupDate: Date | undefined = undefined;

  try {
    commits = commits.sort((a, b) => {
-
      if (a.commit.committer.time > b.commit.committer.time) {
+
      if (a.committer.time > b.committer.time) {
        return -1;
-
      } else if (a.commit.committer.time < b.commit.committer.time) {
+
      } else if (a.committer.time < b.committer.time) {
        return 1;
      }

      return 0;
    });

-
    for (const { commit } of commits) {
+
    for (const commit of commits) {
      const time = commit.committer.time * 1000;
      const date = new Date(time);
      const isNewDay =
@@ -104,11 +60,11 @@ export function groupCommits(
        });
        groupDate = date;
      }
-
      groupedCommits[groupedCommits.length - 1].commits.push({ commit });
+
      groupedCommits[groupedCommits.length - 1].commits.push(commit);
    }
    return groupedCommits;
  } catch (err) {
-
    throw new ApiError(
+
    throw new Error(
      "Not able to create commit history, please consider updating seed HTTP API.",
    );
  }
modified src/lib/config.ts
@@ -1,3 +1,5 @@
+
import type { BaseUrl } from "@httpd-client";
+

import configJson from "@app/config.json";

export interface Config {
@@ -6,13 +8,13 @@ export interface Config {
    defaultHttpdPort: number;
    defaultNodePort: number;
    defaultHttpdScheme: string;
-
    pinned: { host: string }[];
+
    pinned: { baseUrl: BaseUrl }[];
  };
  projects: {
    pinned: {
      name: string;
      id: string;
-
      seed: string;
+
      baseUrl: BaseUrl;
    }[];
  };
}
deleted src/lib/diff.ts
@@ -1,104 +0,0 @@
-
export const lineNumberR = (line: LineDiff): string | number => {
-
  switch (line.type) {
-
    case LineDiffType.Addition: {
-
      return line.lineNo;
-
    }
-
    case LineDiffType.Context: {
-
      return line.lineNoNew;
-
    }
-
    case LineDiffType.Deletion: {
-
      return " ";
-
    }
-
  }
-
};
-

-
export const lineNumberL = (line: LineDiff): string | number => {
-
  switch (line.type) {
-
    case LineDiffType.Addition: {
-
      return " ";
-
    }
-
    case LineDiffType.Context: {
-
      return line.lineNoOld;
-
    }
-
    case LineDiffType.Deletion: {
-
      return line.lineNo;
-
    }
-
  }
-
};
-

-
export const lineSign = (line: LineDiff): string => {
-
  switch (line.type) {
-
    case LineDiffType.Addition: {
-
      return "+";
-
    }
-
    case LineDiffType.Context: {
-
      return " ";
-
    }
-
    case LineDiffType.Deletion: {
-
      return "-";
-
    }
-
  }
-
};
-

-
export enum LineDiffType {
-
  Addition = "addition",
-
  Context = "context",
-
  Deletion = "deletion",
-
}
-

-
export interface Addition {
-
  type: LineDiffType.Addition;
-
  line: string;
-
  lineNo: number;
-
}
-

-
export interface Context {
-
  type: LineDiffType.Context;
-
  line: string;
-
  lineNoNew: number;
-
  lineNoOld: number;
-
}
-

-
export interface Deletion {
-
  type: LineDiffType.Deletion;
-
  line: string;
-
  lineNo: number;
-
}
-

-
export type LineDiff = Addition | Deletion | Context;
-

-
export interface FileDiff {
-
  path: string;
-
  diff: Changeset;
-
  eof: EofNewLine | null;
-
}
-

-
export interface Changeset {
-
  type: string;
-
  hunks: Hunk[];
-
}
-

-
export interface Hunk {
-
  header: string;
-
  lines: LineDiff[];
-
}
-

-
export interface Diff {
-
  added: FileDiff[];
-
  deleted: FileDiff[];
-
  moved: string[];
-
  copied: string[];
-
  modified: FileDiff[];
-
  stats: DiffStats;
-
}
-

-
export interface DiffStats {
-
  insertions: number;
-
  deletions: number;
-
}
-

-
export enum EofNewLine {
-
  OldMissing = "oldMissing",
-
  NewMissing = "newMissing",
-
  BothMissing = "bothMissing",
-
}
deleted src/lib/issue.ts
@@ -1,213 +0,0 @@
-
import type { Author, Comment, State } from "@app/lib/cobs";
-
import type { Host } from "@app/lib/api";
-

-
import { stripDidPrefix } from "@app/lib/cobs";
-
import { Request } from "@app/lib/api";
-

-
export interface IIssue {
-
  id: string;
-
  author: Author;
-
  title: string;
-
  state: IssueState;
-
  discussion: Comment[];
-
  tags: string[];
-
  assignees: string[];
-
  timestamp: number;
-
}
-

-
export type IssueState =
-
  | { status: "open" }
-
  | { status: "closed"; reason: "other" | "solved" };
-

-
export function groupIssues(issues: Issue[]): {
-
  open: Issue[];
-
  closed: Issue[];
-
} {
-
  return issues.reduce(
-
    (acc, issue) => {
-
      acc[issue.state.status].push(issue);
-
      return acc;
-
    },
-
    { open: [] as Issue[], closed: [] as Issue[] },
-
  );
-
}
-

-
export function createAddRemoveArrays(
-
  currentArray: string[],
-
  newArray: string[],
-
): { add: string[]; remove: string[] } {
-
  return {
-
    add: newArray.filter(item => !currentArray.includes(item)),
-
    remove: currentArray.filter(item => !newArray.includes(item)),
-
  };
-
}
-

-
export class Issue {
-
  id: string;
-
  author: Author;
-
  title: string;
-
  state: IssueState;
-
  discussion: Comment[];
-
  tags: string[];
-
  assignees: string[];
-
  timestamp: number;
-

-
  constructor(issue: IIssue) {
-
    this.id = issue.id;
-
    this.author = issue.author;
-
    this.title = issue.title;
-
    this.state = issue.state;
-
    this.discussion = issue.discussion;
-
    this.tags = issue.tags;
-
    this.assignees = issue.assignees;
-
    this.timestamp = issue.discussion[0].timestamp;
-
  }
-

-
  // Counts the amount of comments in a discussion, excluding the initial description
-
  countComments(): number {
-
    return this.discussion.reduce((acc, curr, index) => {
-
      if (index !== 0) {
-
        return acc + 1;
-
      }
-
      return acc;
-
    }, 0);
-
  }
-

-
  static async createIssue(
-
    project: string,
-
    title: string,
-
    description: string,
-
    assignees: string[],
-
    tags: string[],
-
    host: Host,
-
    authToken: string,
-
  ): Promise<{ success: true; id: string }> {
-
    return await new Request(`projects/${project}/issues`, host).post(
-
      {
-
        title,
-
        description,
-
        assignees,
-
        tags,
-
      },
-
      { Authorization: `Bearer ${authToken}` },
-
    );
-
  }
-

-
  async editTitle(
-
    project: string,
-
    title: string,
-
    host: Host,
-
    session: string,
-
  ): Promise<void> {
-
    await new Request(`projects/${project}/issues/${this.id}`, host).patch(
-
      {
-
        type: "edit",
-
        title,
-
      },
-
      { Authorization: `Bearer ${session}` },
-
    );
-
  }
-

-
  async editTags(
-
    project: string,
-
    add: string[],
-
    remove: string[],
-
    host: Host,
-
    session: string,
-
  ): Promise<void> {
-
    await new Request(`projects/${project}/issues/${this.id}`, host).patch(
-
      {
-
        type: "tag",
-
        add,
-
        remove,
-
      },
-
      { Authorization: `Bearer ${session}` },
-
    );
-
  }
-

-
  async editAssignees(
-
    project: string,
-
    add: string[],
-
    remove: string[],
-
    host: Host,
-
    session: string,
-
  ): Promise<void> {
-
    await new Request(`projects/${project}/issues/${this.id}`, host).patch(
-
      {
-
        type: "assign",
-
        add: stripDidPrefix(add),
-
        remove: stripDidPrefix(remove),
-
      },
-
      { Authorization: `Bearer ${session}` },
-
    );
-
  }
-

-
  async createComment(
-
    project: string,
-
    body: string,
-
    host: Host,
-
    session: string,
-
  ): Promise<void> {
-
    await new Request(`projects/${project}/issues/${this.id}`, host).patch(
-
      {
-
        type: "thread",
-
        action: {
-
          type: "comment",
-
          body,
-
        },
-
      },
-
      { Authorization: `Bearer ${session}` },
-
    );
-
  }
-

-
  async replyComment(
-
    project: string,
-
    body: string,
-
    replyTo: string,
-
    host: Host,
-
    session: string,
-
  ): Promise<void> {
-
    await new Request(`projects/${project}/issues/${this.id}`, host).patch(
-
      {
-
        type: "thread",
-
        action: {
-
          type: "comment",
-
          body,
-
          replyTo,
-
        },
-
      },
-
      { Authorization: `Bearer ${session}` },
-
    );
-
  }
-

-
  async changeState(
-
    project: string,
-
    state: State,
-
    host: Host,
-
    session: string,
-
  ): Promise<void> {
-
    await new Request(`projects/${project}/issues/${this.id}`, host).patch(
-
      {
-
        type: "lifecycle",
-
        state,
-
      },
-
      { Authorization: `Bearer ${session}` },
-
    );
-
  }
-

-
  static async getIssues(id: string, host: Host): Promise<Issue[]> {
-
    const response: IIssue[] = await new Request(
-
      `projects/${id}/issues`,
-
      host,
-
    ).get();
-
    return response.map(issue => new Issue(issue));
-
  }
-

-
  static async getIssue(id: string, issue: string, host: Host): Promise<Issue> {
-
    const response: IIssue = await new Request(
-
      `projects/${id}/issues/${issue}`,
-
      host,
-
    ).get();
-
    return new Issue(response);
-
  }
-
}
deleted src/lib/patch.ts
@@ -1,171 +0,0 @@
-
import type { Author, Comment } from "@app/lib/cobs";
-
import type { CommitHeader } from "@app/lib/commit";
-
import type { Diff } from "@app/lib/diff";
-
import type { Host } from "@app/lib/api";
-

-
import { Request } from "@app/lib/api";
-

-
interface IPatch {
-
  id: string;
-
  author: Author;
-
  title: string;
-
  description: string;
-
  state: PatchState;
-
  target: string;
-
  tags: string[];
-
  revisions: Revision[];
-
}
-

-
export interface Revision {
-
  id: string;
-
  description: string;
-
  base: string;
-
  oid: string;
-
  refs: string[];
-
  discussions: Comment[];
-
  reviews: [string, Review][];
-
  merges: Merge[];
-
  timestamp: number;
-
}
-

-
export interface Review {
-
  verdict?: "accept" | "reject";
-
  comment?: string;
-
  inline: CodeComment[];
-
  timestamp: number;
-
}
-

-
export interface CodeComment {
-
  location: CodeLocation;
-
  comment: string;
-
  timestamp: number;
-
}
-

-
interface CodeLocation {
-
  path: string;
-
  commit: string;
-
  lines: {
-
    start: number;
-
    end: number;
-
  };
-
}
-

-
export interface Merge {
-
  node: string;
-
  commit: string;
-
  timestamp: number;
-
}
-

-
export type PatchState =
-
  | { status: "draft" }
-
  | { status: "open" }
-
  | { status: "archived" }
-
  | { status: "merged" };
-

-
export function groupPatches(patches: Patch[]): {
-
  open: Patch[];
-
  draft: Patch[];
-
  archived: Patch[];
-
  merged: Patch[];
-
} {
-
  return patches.reduce(
-
    (acc, patch) => {
-
      acc[patch.state.status].push(patch);
-
      return acc;
-
    },
-
    {
-
      open: [] as Patch[],
-
      draft: [] as Patch[],
-
      archived: [] as Patch[],
-
      merged: [] as Patch[],
-
    },
-
  );
-
}
-

-
export class Patch {
-
  id: string;
-
  author: Author;
-
  title: string;
-
  description: string;
-
  state: PatchState;
-
  target: string;
-
  tags: string[];
-
  revisions: Revision[];
-

-
  constructor(patch: IPatch) {
-
    this.id = patch.id;
-
    this.author = patch.author;
-
    this.title = patch.title;
-
    this.description = patch.description;
-
    this.state = patch.state;
-
    this.target = patch.target;
-
    this.tags = patch.tags;
-
    this.revisions = patch.revisions;
-
  }
-

-
  async editTags(
-
    project: string,
-
    add: string[],
-
    remove: string[],
-
    host: Host,
-
    session: string,
-
  ): Promise<void> {
-
    await new Request(`projects/${project}/patches/${this.id}`, host).patch(
-
      {
-
        type: "tag",
-
        add,
-
        remove,
-
      },
-
      { Authorization: `Bearer ${session}` },
-
    );
-
  }
-

-
  static async getPatches(id: string, host: Host): Promise<Patch[]> {
-
    const response: IPatch[] = await new Request(
-
      `projects/${id}/patches`,
-
      host,
-
    ).get();
-
    return response.map(patch => new Patch(patch));
-
  }
-

-
  async replyComment(
-
    project: string,
-
    revision: string,
-
    body: string,
-
    replyTo: string,
-
    host: Host,
-
    session: string,
-
  ): Promise<void> {
-
    await new Request(`projects/${project}/patches/${this.id}`, host).patch(
-
      {
-
        type: "thread",
-
        revision,
-
        action: {
-
          type: "comment",
-
          body,
-
          replyTo,
-
        },
-
      },
-
      { Authorization: `Bearer ${session}` },
-
    );
-
  }
-

-
  async getPatchDiff(
-
    project: string,
-
    revision: Revision,
-
    host: Host,
-
  ): Promise<{ commits: CommitHeader[]; diff: Diff }> {
-
    return await new Request(
-
      `projects/${project}/diff/${revision.base}/${revision.oid}`,
-
      host,
-
    ).get();
-
  }
-

-
  static async getPatch(id: string, patch: string, host: Host): Promise<Patch> {
-
    const response: IPatch = await new Request(
-
      `projects/${id}/patches/${patch}`,
-
      host,
-
    ).get();
-
    return new Patch(response);
-
  }
-
}
deleted src/lib/project.ts
@@ -1,351 +0,0 @@
-
import type { Commit, CommitHeader, CommitsHistory } from "@app/lib/commit";
-
import type { Host } from "@app/lib/api";
-
import type { ProjectResult } from "@app/lib/search";
-

-
import * as utils from "@app/lib/utils";
-
import { Request } from "@app/lib/api";
-
import { Seed } from "@app/lib/seed";
-
import { isFulfilled, isOid, isRepositoryId } from "@app/lib/utils";
-

-
export type Branches = { [key: string]: string };
-
export type MaybeBlob = Blob | undefined;
-
export type MaybeTree = Tree | undefined;
-

-
// Enumerates the space below the Header component in the projects View component
-
export enum ProjectContent {
-
  Tree,
-
  History,
-
  Commit,
-
  Issues,
-
  Issue,
-
}
-

-
export interface ProjectInfo {
-
  head: string;
-
  id: string;
-
  name: string;
-
  description: string;
-
  defaultBranch: string;
-
  delegates: string[];
-
  patches: {
-
    open: number;
-
    draft: number;
-
    archived: number;
-
    merged?: number;
-
  };
-
  issues: {
-
    open: number;
-
    closed: number;
-
  };
-
}
-

-
export interface Tree {
-
  path: string;
-
  entries: Array<Entry>;
-
  stats: Stats;
-
  name: string;
-
  lastCommit: CommitHeader;
-
}
-

-
type Kind = "tree" | "blob";
-

-
export interface Stats {
-
  commits: number;
-
  contributors: number;
-
}
-

-
export interface Entry {
-
  path: string;
-
  name: string;
-
  kind: Kind;
-
}
-

-
export interface Blob {
-
  binary: boolean;
-
  content?: string;
-
  path: string;
-
  name: string;
-
  lastCommit: CommitHeader;
-
}
-

-
export interface Remote {
-
  heads: Branches;
-
}
-

-
export interface Person {
-
  name: string;
-
}
-

-
export interface Peer {
-
  id: string;
-
  heads: Branches;
-
  delegate: boolean;
-
}
-

-
// We need a SHA1 commit in some places, so we return early if the revision is a SHA and else we look into branches.
-
export function getOid(revision: string, branches?: Branches): string | null {
-
  if (isOid(revision)) return revision;
-

-
  if (branches) {
-
    const oid = branches[revision];
-

-
    if (oid) {
-
      return oid;
-
    }
-
  }
-
  return null;
-
}
-

-
// Parses the path consisting of a revision (eg. branch or commit) and file path into a tuple [revision, file-path]
-
export function parseRoute(
-
  input: string,
-
  branches: Branches,
-
): { path?: string; revision?: string } {
-
  const branch = Object.entries(branches).find(([branchName]) =>
-
    input.startsWith(branchName),
-
  );
-
  const commitPath = [input.slice(0, 40), input.slice(41)];
-
  const parsed: { path?: string; revision?: string } = {};
-

-
  if (branch) {
-
    const [rev, path] = [
-
      input.slice(0, branch[0].length),
-
      input.slice(branch[0].length + 1),
-
    ];
-

-
    parsed.revision = rev;
-
    parsed.path = path ? path : "/";
-
  } else if (isOid(commitPath[0])) {
-
    parsed.revision = commitPath[0];
-
    parsed.path = commitPath[1] ? commitPath[1] : "/";
-
  } else {
-
    parsed.path = input;
-
  }
-
  return parsed;
-
}
-

-
export class Project implements ProjectInfo {
-
  id: string;
-
  head: string;
-
  name: string;
-
  description: string;
-
  defaultBranch: string;
-
  delegates: string[];
-
  seed: Seed;
-
  peers: Peer[];
-
  branches: Branches;
-
  patches: {
-
    open: number;
-
    draft: number;
-
    archived: number;
-
    merged?: number;
-
  };
-
  issues: {
-
    open: number;
-
    closed: number;
-
  };
-

-
  constructor(
-
    id: string,
-
    info: ProjectInfo,
-
    seed: Seed,
-
    peers: Peer[],
-
    branches: Branches,
-
  ) {
-
    this.id = id;
-
    this.head = info.head;
-
    this.name = info.name;
-
    this.description = info.description;
-
    this.defaultBranch = info.defaultBranch;
-
    this.delegates = info.delegates;
-
    this.seed = seed;
-
    this.peers = peers;
-
    this.branches = branches;
-
    this.patches = info.patches;
-
    this.issues = info.issues;
-
  }
-

-
  async getRoot(
-
    revision: string | null,
-
  ): Promise<{ tree: Tree; commit: string }> {
-
    const head = this.branches[this.defaultBranch];
-
    const commit = revision ? getOid(revision, this.branches) : head;
-

-
    if (!commit) {
-
      throw new Error(`Revision ${revision} not found`);
-
    }
-
    const tree = await this.getTree(commit, "/");
-

-
    return { tree, commit };
-
  }
-

-
  static async getInfo(nameOrId: string, host: Host): Promise<ProjectInfo> {
-
    return await new Request(`projects/${nameOrId}`, host).get();
-
  }
-

-
  static async getProjects(
-
    host: Host,
-
    opts?: {
-
      perPage?: number;
-
      page?: number;
-
    },
-
  ): Promise<ProjectInfo[]> {
-
    const params: Record<string, any> = {
-
      "per-page": opts?.perPage,
-
      page: opts?.page,
-
    };
-
    return await new Request("projects", host).get(params);
-
  }
-

-
  static async getDelegateProjects(
-
    delegate: string,
-
    host: Host,
-
    opts?: {
-
      perPage?: number;
-
      page?: number;
-
    },
-
  ): Promise<ProjectInfo[]> {
-
    const params: Record<string, any> = {
-
      "per-page": opts?.perPage,
-
      page: opts?.page,
-
    };
-
    return new Request(`delegates/${delegate}/projects`, host).get(params);
-
  }
-

-
  static async getRemote(
-
    id: string,
-
    peer: string,
-
    host: Host,
-
  ): Promise<Remote> {
-
    return new Request(`projects/${id}/remotes/${peer}`, host).get();
-
  }
-

-
  static async getRemotes(id: string, host: Host): Promise<Peer[]> {
-
    return new Request(`projects/${id}/remotes`, host).get();
-
  }
-

-
  static async getCommits(
-
    id: string,
-
    host: Host,
-
    opts?: {
-
      parent?: string | null;
-
      since?: string;
-
      until?: string;
-
      perPage?: number;
-
      page?: number;
-
      verified?: boolean;
-
    },
-
  ): Promise<CommitsHistory> {
-
    const params: Record<string, any> = {
-
      parent: opts?.parent,
-
      since: opts?.since,
-
      until: opts?.until,
-
      "per-page": opts?.perPage,
-
      page: opts?.page,
-
      verified: opts?.verified,
-
    };
-
    const result = await new Request(`projects/${id}/commits`, host).get(
-
      params,
-
    );
-
    return result;
-
  }
-

-
  static async getActivity(
-
    id: string,
-
    host: Host,
-
  ): Promise<{ activity: number[] }> {
-
    return new Request(`projects/${id}/activity`, host).get();
-
  }
-

-
  async getCommit(commit: string): Promise<Commit> {
-
    const result = await new Request(
-
      `projects/${this.id}/commits/${commit}`,
-
      this.seed.addr,
-
    ).get();
-

-
    return result;
-
  }
-

-
  async getTree(commit: string, path: string): Promise<Tree> {
-
    if (path === "/") path = "";
-
    const result = await new Request(
-
      `projects/${this.id}/tree/${commit}/${path}`,
-
      this.seed.addr,
-
    ).get();
-
    return result;
-
  }
-

-
  async getBlob(commit: string, path: string): Promise<Blob> {
-
    const result = await new Request(
-
      `projects/${this.id}/blob/${commit}/${path}`,
-
      this.seed.addr,
-
    ).get();
-
    return result;
-
  }
-

-
  async getReadme(commit: string): Promise<Blob> {
-
    const result = await new Request(
-
      `projects/${this.id}/readme/${commit}`,
-
      this.seed.addr,
-
    ).get();
-
    return result;
-
  }
-

-
  getRawPath(commit?: string): string {
-
    return `${this.seed.addr.scheme}://${this.seed.addr.host}:${
-
      this.seed.addr.port
-
    }/raw/${this.id}/${commit ?? this.head}`;
-
  }
-

-
  static async get(
-
    id: string,
-
    seedHost: string,
-
    peer?: string,
-
  ): Promise<Project> {
-
    let seed: Seed | undefined = undefined;
-

-
    try {
-
      seed = await Seed.lookup(utils.extractHost(seedHost));
-
    } catch (error) {
-
      throw new Error(`Couldn't load project: ${error}`);
-
    }
-

-
    const info = await Project.getInfo(id, seed.addr);
-
    id = isRepositoryId(id) ? id : info.id;
-

-
    let peers: Peer[] = [];
-

-
    peers = await Project.getRemotes(id, seed.addr);
-

-
    let remote: Remote = {
-
      heads: info.head ? { [info.defaultBranch]: info.head } : {},
-
    };
-

-
    if (peer) {
-
      try {
-
        remote = await Project.getRemote(id, peer, seed.addr);
-
      } catch {
-
        remote.heads = {};
-
      }
-
    }
-

-
    return new Project(id, info, seed, peers, remote.heads);
-
  }
-

-
  static async getMulti(
-
    projs: { nameOrId: string; seed: Host }[],
-
  ): Promise<ProjectResult[]> {
-
    const promises = [];
-

-
    for (const proj of projs) {
-
      promises.push(
-
        Project.getInfo(proj.nameOrId, proj.seed).then(info => {
-
          return { info, seed: proj.seed };
-
        }),
-
      );
-
    }
-
    const results = await Promise.allSettled(promises);
-

-
    return results.filter(isFulfilled).map(r => r.value);
-
  }
-
}
modified src/lib/router.ts
@@ -149,8 +149,8 @@ function pathToRoute(path: string): Route | null {
  const resource = segments.shift();
  switch (resource) {
    case "seeds": {
-
      const host = segments.shift();
-
      if (host) {
+
      const hostnamePort = segments.shift();
+
      if (hostnamePort) {
        const id = segments.shift();
        if (id) {
          // Allows project paths with or without trailing slash
@@ -164,24 +164,24 @@ function pathToRoute(path: string): Route | null {
                view: { resource: "tree" },
                id,
                peer: undefined,
-
                seed: host,
+
                hostnamePort,
              },
            };
          }
-
          const params = resolveProjectRoute(url, host, id, segments);
+
          const params = resolveProjectRoute(url, hostnamePort, id, segments);
          if (params) {
            return {
              resource: "projects",
              params: {
                ...params,
-
                seed: host,
+
                hostnamePort,
                id,
              },
            };
          }
          return null;
        }
-
        return { resource: "seeds", params: { host } };
+
        return { resource: "seeds", params: { hostnamePort } };
      }
      return null;
    }
@@ -214,9 +214,9 @@ export function routeToPath(route: Route) {
  } else if (route.resource === "session") {
    return `/session?id=${route.params.id}&sig=${route.params.signature}&pk=${route.params.publicKey}`;
  } else if (route.resource === "seeds") {
-
    return `/seeds/${route.params.host}`;
+
    return `/seeds/${route.params.hostnamePort}`;
  } else if (route.resource === "projects") {
-
    const hostPrefix = `/seeds/${route.params.seed}`;
+
    const hostnamePortPrefix = `/seeds/${route.params.hostnamePort}`;
    const content = `/${route.params.view.resource}`;

    let peer = "";
@@ -252,31 +252,31 @@ export function routeToPath(route: Route) {

    if (route.params.view.resource === "tree") {
      if (suffix) {
-
        return `${hostPrefix}/${route.params.id}${peer}/tree${suffix}`;
+
        return `${hostnamePortPrefix}/${route.params.id}${peer}/tree${suffix}`;
      }
-
      return `${hostPrefix}/${route.params.id}${peer}`;
+
      return `${hostnamePortPrefix}/${route.params.id}${peer}`;
    } else if (route.params.view.resource === "commits") {
-
      return `${hostPrefix}/${route.params.id}${peer}/commits${suffix}`;
+
      return `${hostnamePortPrefix}/${route.params.id}${peer}/commits${suffix}`;
    } else if (route.params.view.resource === "history") {
-
      return `${hostPrefix}/${route.params.id}${peer}/history${suffix}`;
+
      return `${hostnamePortPrefix}/${route.params.id}${peer}/history${suffix}`;
    } else if (
      route.params.view.resource === "issues" &&
      route.params.view.params?.view.resource === "new"
    ) {
-
      return `${hostPrefix}/${route.params.id}${peer}/issues/new${suffix}`;
+
      return `${hostnamePortPrefix}/${route.params.id}${peer}/issues/new${suffix}`;
    } else if (route.params.view.resource === "issues") {
-
      return `${hostPrefix}/${route.params.id}${peer}/issues${suffix}`;
+
      return `${hostnamePortPrefix}/${route.params.id}${peer}/issues${suffix}`;
    } else if (route.params.view.resource === "issue") {
-
      return `${hostPrefix}/${route.params.id}${peer}/issues/${route.params.view.params.issue}`;
+
      return `${hostnamePortPrefix}/${route.params.id}${peer}/issues/${route.params.view.params.issue}`;
    } else if (route.params.view.resource === "patches") {
-
      return `${hostPrefix}/${route.params.id}${peer}/patches${suffix}`;
+
      return `${hostnamePortPrefix}/${route.params.id}${peer}/patches${suffix}`;
    } else if (route.params.view.resource === "patch") {
      if (route.params.view.params.revision) {
-
        return `${hostPrefix}/${route.params.id}${peer}/patches/${route.params.view.params.patch}/${route.params.view.params.revision}${suffix}`;
+
        return `${hostnamePortPrefix}/${route.params.id}${peer}/patches/${route.params.view.params.patch}/${route.params.view.params.revision}${suffix}`;
      }
-
      return `${hostPrefix}/${route.params.id}${peer}/patches/${route.params.view.params.patch}${suffix}`;
+
      return `${hostnamePortPrefix}/${route.params.id}${peer}/patches/${route.params.view.params.patch}${suffix}`;
    } else {
-
      return `${hostPrefix}/${route.params.id}${peer}${content}`;
+
      return `${hostnamePortPrefix}/${route.params.id}${peer}${content}`;
    }
  } else if (route.resource === "404") {
    return route.params.url;
@@ -287,7 +287,7 @@ export function routeToPath(route: Route) {

function resolveProjectRoute(
  url: URL,
-
  seed: string,
+
  hostnamePort: string,
  id: string,
  segments: string[],
): ProjectsParams | null {
@@ -304,7 +304,7 @@ function resolveProjectRoute(
    return {
      view: { resource: "tree" },
      id,
-
      seed,
+
      hostnamePort,
      peer,
      path: undefined,
      revision: undefined,
@@ -317,7 +317,7 @@ function resolveProjectRoute(
    return {
      view: { resource: "history" },
      id,
-
      seed,
+
      hostnamePort,
      peer,
      path: undefined,
      revision: undefined,
@@ -328,7 +328,7 @@ function resolveProjectRoute(
    return {
      view: { resource: "commits" },
      id,
-
      seed,
+
      hostnamePort,
      peer,
      path: undefined,
      revision: undefined,
@@ -341,7 +341,7 @@ function resolveProjectRoute(
      return {
        view: { resource: "issues", params: { view: { resource: "new" } } },
        id,
-
        seed,
+
        hostnamePort,
        peer,
        search: sanitizeQueryString(url.search),
        path: undefined,
@@ -351,7 +351,7 @@ function resolveProjectRoute(
      return {
        view: { resource: "issue", params: { issue: issueOrAction } },
        id,
-
        seed,
+
        hostnamePort,
        peer,
        path: undefined,
        revision: undefined,
@@ -361,7 +361,7 @@ function resolveProjectRoute(
      return {
        view: { resource: "issues" },
        id,
-
        seed,
+
        hostnamePort,
        peer,
        search: sanitizeQueryString(url.search),
        path: undefined,
@@ -375,7 +375,7 @@ function resolveProjectRoute(
      return {
        view: { resource: "patch", params: { patch, revision } },
        id,
-
        seed,
+
        hostnamePort,
        peer,
        path: undefined,
        revision: undefined,
@@ -385,7 +385,7 @@ function resolveProjectRoute(
      return {
        view: { resource: "patches" },
        id,
-
        seed,
+
        hostnamePort,
        peer,
        search: sanitizeQueryString(url.search),
        path: undefined,
modified src/lib/router/definitions.ts
@@ -6,7 +6,7 @@ export type Route =
      params: { id: string; signature: string; publicKey: string };
    }
  | { resource: "404"; params: { url: string } }
-
  | { resource: "seeds"; params: { host: string } };
+
  | { resource: "seeds"; params: { hostnamePort: string } };

export interface ProjectsParams {
  id: string;
@@ -28,7 +28,7 @@ export interface ProjectsParams {
        };
      }
    | { resource: "patch"; params: { patch: string; revision?: string } };
-
  seed: string;
+
  hostnamePort: string;
  hash?: string;
  line?: string;
  path?: string;
modified src/lib/search.ts
@@ -1,61 +1,42 @@
-
import type { Host } from "@app/lib/api";
-
import type { ProjectInfo } from "@app/lib/project";
+
import type { BaseUrl, Project } from "@httpd-client";

import * as utils from "@app/lib/utils";
-
import { Project } from "@app/lib/project";
+
import { HttpdClient } from "@httpd-client";
import { config } from "@app/lib/config";
+
import { isFulfilled } from "@app/lib/utils";

-
export interface ProjectResult {
-
  info: ProjectInfo;
-
  seed: Host;
+
export interface ProjectAndSeed {
+
  project: Project;
+
  baseUrl: BaseUrl;
}

type SearchResult =
  | { type: "nothing" }
  | { type: "error"; message: string }
-
  | { type: "projects"; projects: ProjectResult[] };
+
  | { type: "projects"; results: ProjectAndSeed[] };

export async function searchProjectsAndProfiles(
  query: string,
): Promise<SearchResult> {
  try {
-
    const projectOnSeeds = config.seeds.pinned.map(seed => ({
-
      nameOrId: query,
-
      seed: {
-
        host: seed.host,
-
        port: config.seeds.defaultHttpdPort,
-
        scheme: config.seeds.defaultHttpdScheme,
-
      },
+
    const pinned = config.seeds.pinned.map(seed => ({
+
      id: query,
+
      baseUrl: seed.baseUrl,
    }));

-
    // The query is a radicle project ID.
    if (utils.isRepositoryId(query)) {
-
      const projects = await Project.getMulti(projectOnSeeds);
+
      const results = await getProjectsFromSeeds(pinned);

-
      if (projects.length === 0) {
+
      if (results.length === 0) {
        return { type: "nothing" };
      } else {
        return {
          type: "projects",
-
          projects,
+
          results,
        };
      }
    }

-
    let projects: ProjectResult[] = [];
-
    try {
-
      projects = await Project.getMulti(projectOnSeeds);
-
    } catch {
-
      // TODO: collect errors and forward to user.
-
    }
-

-
    if (projects.length > 0) {
-
      return {
-
        type: "projects",
-
        projects,
-
      };
-
    }
-

    return { type: "nothing" };
  } catch (error) {
    let message = "An unknown error occoured while searching.";
@@ -67,3 +48,19 @@ export async function searchProjectsAndProfiles(
    return { type: "error", message };
  }
}
+

+
export async function getProjectsFromSeeds(
+
  params: { id: string; baseUrl: BaseUrl }[],
+
): Promise<ProjectAndSeed[]> {
+
  const projectPromises = params.map(async param => {
+
    const api = new HttpdClient(param.baseUrl);
+
    const project = await api.project.getById(param.id);
+
    return {
+
      project,
+
      baseUrl: param.baseUrl,
+
    };
+
  });
+

+
  const results = await Promise.allSettled(projectPromises);
+
  return results.filter(isFulfilled).map(r => r.value);
+
}
deleted src/lib/seed.ts
@@ -1,86 +0,0 @@
-
import type { Host } from "@app/lib/api";
-

-
import * as proj from "@app/lib/project";
-
import { Request } from "@app/lib/api";
-
import { assert } from "@app/lib/error";
-
import { config } from "@app/lib/config";
-

-
export interface Stats {
-
  projects: { count: number };
-
  users: { count: number };
-
}
-

-
export class Seed {
-
  addr: Host;
-
  node: Host & { id: string };
-

-
  version?: string;
-

-
  constructor(seed: { host: Host; id: string; version?: string }) {
-
    assert(/^[a-zA-Z0-9]+$/.test(seed.id), `invalid seed id ${seed.id}`);
-

-
    this.addr = seed.host;
-
    this.node = {
-
      host: seed.host.host,
-
      id: seed.id,
-
      port: config.seeds.defaultNodePort,
-
      scheme: seed.host.scheme,
-
    };
-

-
    if (seed.version) {
-
      this.version = seed.version;
-
    }
-
  }
-

-
  get id(): string {
-
    return this.node.id;
-
  }
-

-
  get host(): string {
-
    return this.addr.host;
-
  }
-

-
  async getNode(): Promise<{ id: string }> {
-
    return Seed.getNode(this.addr);
-
  }
-

-
  async getProject(id: string): Promise<proj.ProjectInfo> {
-
    return proj.Project.getInfo(id, this.addr);
-
  }
-

-
  async getProjects(perPage: number, id?: string): Promise<proj.ProjectInfo[]> {
-
    const result = id
-
      ? await proj.Project.getDelegateProjects(id, this.addr, { perPage })
-
      : await proj.Project.getProjects(this.addr, { perPage });
-

-
    return result;
-
  }
-

-
  async getStats(): Promise<{
-
    projects: { count: number };
-
    users: { count: number };
-
  }> {
-
    return new Request("/stats", this.addr).get();
-
  }
-

-
  static async getNode(host: Host): Promise<{ id: string }> {
-
    return new Request("/node", host).get();
-
  }
-

-
  static async getInfo(host: Host): Promise<{ version: string }> {
-
    return new Request("/", host).get();
-
  }
-

-
  static async lookup(host: Host): Promise<Seed> {
-
    const [info, node] = await Promise.all([
-
      Seed.getInfo(host),
-
      Seed.getNode(host),
-
    ]);
-

-
    return new Seed({
-
      id: node.id,
-
      version: info.version,
-
      host,
-
    });
-
  }
-
}
modified src/lib/session.ts
@@ -1,40 +1,37 @@
import { derived, get, writable } from "svelte/store";

-
export interface Session {
-
  id: string;
-
  publicKey: string;
-
}
+
import { HttpdClient } from "@httpd-client";

-
interface SessionResponse {
-
  sessionId: string;
-
  status: string;
+
export interface StoredSession {
+
  id: string;
  publicKey: string;
-
  issuedAt: number;
-
  expiresAt: number;
}

-
const store = writable<Session | undefined>(undefined);
+
const store = writable<StoredSession | undefined>(undefined);
export const sessionStore = derived(store, s => s);

-
const endpoint = "http://localhost:8080/api/v1/sessions";
+
const api = new HttpdClient({
+
  hostname: "127.0.0.1",
+
  port: 8080,
+
  scheme: "http",
+
});

export async function authenticate(params: {
  id: string;
  signature: string;
  publicKey: string;
-
}): Promise<"success" | "failure"> {
-
  disconnect();
+
}): Promise<boolean> {
+
  await disconnect();

-
  const request = await fetch(`${endpoint}/${params.id}`, {
-
    method: "PUT",
-
    headers: { "Content-Type": "application/json" },
-
    body: JSON.stringify({ sig: params.signature, pk: params.publicKey }),
-
  });
-
  if (request.ok) {
+
  try {
+
    await api.session.update(params.id, {
+
      sig: params.signature,
+
      pk: params.publicKey,
+
    });
    save(params.id, params.publicKey);
-
    return "success";
-
  } else {
-
    return "failure";
+
    return true;
+
  } catch {
+
    return false;
  }
}

@@ -52,11 +49,8 @@ function pollSession() {
    }

    try {
-
      const resp = await fetch(`${endpoint}/${session.id}`, {
-
        method: "GET",
-
      });
+
      const sess = await api.session.getById(session.id);

-
      const sess: SessionResponse = await resp.json();
      const unixTimeInSeconds = Math.floor(Date.now() / 1000);
      if (
        sess.status === "unauthorized" ||
@@ -76,13 +70,7 @@ export async function disconnect() {
    return "success";
  }

-
  await fetch(`${endpoint}/${session.id}`, {
-
    method: "DELETE",
-
    headers: {
-
      "Content-Type": "application/json",
-
      Authorization: `Bearer ${session.id}`,
-
    },
-
  });
+
  await api.session.delete(session.id);

  clear();
}
modified src/lib/utils.ts
@@ -1,4 +1,4 @@
-
import type { Host } from "@app/lib/api";
+
import type { BaseUrl } from "@httpd-client";

import md5 from "md5";
import bs58 from "bs58";
@@ -23,20 +23,30 @@ export function setOpenGraphMetaTag(
  });
}

-
export function formatSeedAddress(
+
export function getRawBasePath(
  id: string,
-
  host: string,
-
  port: number,
+
  baseUrl: BaseUrl,
+
  commit: string,
): string {
-
  return `${id}@${host}:${port}`;
+
  return `${baseUrl.scheme}://${baseUrl.hostname}:${baseUrl.port}/raw/${id}/${commit}`;
}

-
export function formatSeedHost(host: string): string {
-
  if (isLocal(host)) {
-
    return "radicle.local";
-
  } else {
-
    return host;
+
// We need a SHA1 commit in some places, so we return early if the revision is
+
// a SHA and else we look into branches.
+
export function getOid(
+
  revision: string,
+
  branches?: Record<string, string>,
+
): string | null {
+
  if (isOid(revision)) return revision;
+

+
  if (branches) {
+
    const oid = branches[revision];
+

+
    if (oid) {
+
      return oid;
+
    }
  }
+
  return null;
}

export function formatLocationHash(hash: string | null): number | null {
@@ -231,7 +241,7 @@ export function isDomain(input: string): boolean {
  );
}

-
// Check whether the given address is a local host address.
+
// Check whether the given address is a localhost address.
export function isLocal(addr: string): boolean {
  return (
    addr.startsWith("127.0.0.1") ||
@@ -269,27 +279,37 @@ export function twemoji(
  });
}

-
export function extractHost(origin: string): Host {
+
export function extractBaseUrl(hostnamePort: string): BaseUrl {
  if (
-
    origin === "radicle.local" ||
-
    origin === "radicle.local:8080" ||
-
    origin === "0.0.0.0" ||
-
    origin === "0.0.0.0:8080" ||
-
    origin === "127.0.0.1" ||
-
    origin === "127.0.0.1:8080"
+
    hostnamePort === "radicle.local" ||
+
    hostnamePort === "radicle.local:8080" ||
+
    hostnamePort === "0.0.0.0" ||
+
    hostnamePort === "0.0.0.0:8080" ||
+
    hostnamePort === "127.0.0.1" ||
+
    hostnamePort === "127.0.0.1:8080"
  ) {
-
    return { host: "127.0.0.1", port: 8080, scheme: "http" };
-
  } else if (origin.includes(":")) {
+
    return { hostname: "127.0.0.1", port: 8080, scheme: "http" };
+
  } else if (hostnamePort.includes(":")) {
    return {
-
      host: origin.split(":")[0],
-
      port: Number(origin.split(":")[1]),
+
      hostname: hostnamePort.split(":")[0],
+
      port: Number(hostnamePort.split(":")[1]),
      scheme: config.seeds.defaultHttpdScheme,
    };
  } else {
    return {
-
      host: origin,
+
      hostname: hostnamePort,
      port: config.seeds.defaultHttpdPort,
      scheme: config.seeds.defaultHttpdScheme,
    };
  }
}
+

+
export function createAddRemoveArrays(
+
  currentArray: string[],
+
  newArray: string[],
+
): { add: string[]; remove: string[] } {
+
  return {
+
    add: newArray.filter(item => !currentArray.includes(item)),
+
    remove: currentArray.filter(item => !newArray.includes(item)),
+
  };
+
}
modified src/views/home/Index.svelte
@@ -1,14 +1,14 @@
<script lang="ts">
-
  import type { Host } from "@app/lib/api";
-
  import type { ProjectInfo } from "@app/lib/project";
+
  import type { BaseUrl, Project } from "@httpd-client";

  import * as router from "@app/lib/router";
+
  import { config } from "@app/lib/config";
+
  import { getProjectsFromSeeds } from "@app/lib/search";
+
  import { setOpenGraphMetaTag, twemoji } from "@app/lib/utils";
+

  import Loading from "@app/components/Loading.svelte";
  import Message from "@app/components/Message.svelte";
  import Widget from "@app/views/projects/Widget.svelte";
-
  import { config } from "@app/lib/config";
-
  import { Project } from "@app/lib/project";
-
  import { setOpenGraphMetaTag, twemoji } from "@app/lib/utils";

  setOpenGraphMetaTag([
    { prop: "og:title", content: "Radicle Interface" },
@@ -16,27 +16,13 @@
    { prop: "og:url", content: window.location.href },
  ]);

-
  const getProjects =
-
    config.projects.pinned.length > 0
-
      ? Project.getMulti(
-
          config.projects.pinned.map(project => ({
-
            nameOrId: project.id,
-
            seed: {
-
              host: project.seed,
-
              port: config.seeds.defaultHttpdPort,
-
              scheme: config.seeds.defaultHttpdScheme,
-
            },
-
          })),
-
        )
-
      : Promise.resolve([]);
-

-
  function onClick(project: ProjectInfo, seed: Host) {
+
  function goToProject(project: Project, baseUrl: BaseUrl) {
    router.push({
      resource: "projects",
      params: {
        view: { resource: "tree" },
        id: project.id,
-
        seed: seed.host,
+
        hostnamePort: baseUrl.hostname,
        peer: undefined,
        revision: undefined,
      },
@@ -101,7 +87,7 @@
    </p>
  </div>

-
  {#await getProjects}
+
  {#await getProjectsFromSeeds(config.projects.pinned)}
    <div class="loading">
      <Loading center />
    </div>
@@ -117,9 +103,9 @@
          <div class="project">
            <Widget
              compact
-
              project={result.info}
-
              seed={{ addr: result.seed }}
-
              on:click={() => onClick(result.info, result.seed)} />
+
              project={result.project}
+
              baseUrl={result.baseUrl}
+
              on:click={() => goToProject(result.project, result.baseUrl)} />
          </div>
        {/each}
      </div>
modified src/views/projects/Blob.svelte
@@ -1,17 +1,19 @@
<script lang="ts">
-
  import type { Blob } from "@app/lib/project";
+
  import type { Blob } from "@httpd-client";
  import type { MaybeHighlighted } from "@app/lib/syntax";
  import type { ProjectRoute } from "@app/lib/router/definitions";

-
  import HeaderToggleLabel from "@app/views/projects/HeaderToggleLabel.svelte";
-
  import Readme from "@app/views/projects/Readme.svelte";
  import { afterUpdate, beforeUpdate, onMount } from "svelte";
+
  import { toHtml } from "hast-util-to-html";
+

  import { highlight } from "@app/lib/syntax";
  import { isMarkdownPath, scrollIntoView, twemoji } from "@app/lib/utils";
  import { lineNumbersGutter } from "@app/lib/syntax";
-
  import { toHtml } from "hast-util-to-html";
  import { updateProjectRoute } from "@app/lib/router";

+
  import HeaderToggleLabel from "@app/views/projects/HeaderToggleLabel.svelte";
+
  import Readme from "@app/views/projects/Readme.svelte";
+

  export let activeRoute: ProjectRoute;
  export let blob: Blob;
  export let rawPath: string;
modified src/views/projects/BranchSelector.svelte
@@ -1,13 +1,13 @@
<script lang="ts" strictEvents>
  import { createEventDispatcher } from "svelte";
-
  import type { ProjectInfo, Branches } from "@app/lib/project";
-
  import { getOid } from "@app/lib/project";
-
  import { formatCommit } from "@app/lib/utils";
+

+
  import * as utils from "@app/lib/utils";
  import Dropdown from "@app/components/Dropdown.svelte";
  import Floating from "@app/components/Floating.svelte";

-
  export let branches: Branches;
-
  export let project: ProjectInfo;
+
  export let branches: Record<string, string>;
+
  export let projectDefaultBranch: string;
+
  export let projectHead: string | undefined = undefined;
  export let revision: string;

  const dispatch = createEventDispatcher<{ branchChanged: string }>();
@@ -21,10 +21,10 @@
    .sort()
    .map(b => ({ key: b, value: b, title: `Switch to ${b}`, badge: null }));
  $: showSelector = branchList.length > 1;
-
  $: head = project.head ?? branches[project.defaultBranch];
-
  $: commit = getOid(revision, branches) || head;
+
  $: head = projectHead ?? branches[projectDefaultBranch];
+
  $: commit = utils.getOid(revision, branches) || head;
  $: if (commit === head) {
-
    branchLabel = project.defaultBranch;
+
    branchLabel = projectDefaultBranch;
  } else if (branches[revision]) {
    branchLabel = revision;
  } else {
@@ -92,7 +92,7 @@
        </svelte:fragment>
      </Floating>
      <div class="hash layout-desktop">
-
        {formatCommit(commit)}
+
        {utils.formatCommit(commit)}
      </div>
    {:else}
      <div class="unlabeled hash layout-desktop">
@@ -100,22 +100,22 @@
      </div>
    {/if}
    <div class="hash layout-mobile">
-
      {formatCommit(commit)}
+
      {utils.formatCommit(commit)}
    </div>
    <!-- If there is no branch listing available, show default branch name if commit is head and else show entire commit -->
  {:else if commit === head}
    <div class="stat branch not-allowed">
-
      {project.defaultBranch}
+
      {projectDefaultBranch}
    </div>
    <div class="hash">
-
      {formatCommit(commit)}
+
      {utils.formatCommit(commit)}
    </div>
  {:else}
    <div class="unlabeled hash layout-desktop">
      {commit}
    </div>
    <div class="hash layout-mobile">
-
      {formatCommit(commit)}
+
      {utils.formatCommit(commit)}
    </div>
  {/if}
</div>
modified src/views/projects/Browser.svelte
@@ -7,17 +7,21 @@
</script>

<script lang="ts">
-
  import type * as proj from "@app/lib/project";
+
  import type { BaseUrl, Blob, Project, Tree } from "@httpd-client";
  import type { ProjectRoute } from "@app/lib/router/definitions";

+
  import { onMount } from "svelte";
+

  import * as router from "@app/lib/router";
+
  import * as utils from "@app/lib/utils";
+
  import { HttpdClient } from "@httpd-client";
+

  import Button from "@app/components/Button.svelte";
  import Loading from "@app/components/Loading.svelte";
  import Placeholder from "@app/components/Placeholder.svelte";
-
  import { onMount } from "svelte";

-
  import Tree from "./Tree.svelte";
-
  import Blob from "./Blob.svelte";
+
  import BlobComponent from "./Blob.svelte";
+
  import TreeComponent from "./Tree.svelte";

  enum Status {
    Loading,
@@ -26,10 +30,11 @@

  type State =
    | { status: Status.Loading; path: string }
-
    | { status: Status.Loaded; path: string; blob: proj.Blob };
+
    | { status: Status.Loaded; path: string; blob: Blob };

-
  export let project: proj.Project;
-
  export let tree: proj.Tree;
+
  export let project: Project;
+
  export let baseUrl: BaseUrl;
+
  export let tree: Tree;
  export let commit: string;
  export let activeRoute: ProjectRoute;

@@ -41,17 +46,24 @@
  // Whether the mobile file tree is visible.
  let mobileFileTree = false;

+
  const api = new HttpdClient(baseUrl);
+

  const loadBlob = async (path: string) => {
    if (state.status === Status.Loaded && state.path === path) {
      return state.blob;
    }

-
    const promise =
-
      path === "/" ? project.getReadme(commit) : project.getBlob(commit, path);
-

    state = { status: Status.Loading, path };
-
    state = { status: Status.Loaded, path, blob: await promise };
-
    return state.blob;
+

+
    let blob;
+
    if (path === "/") {
+
      blob = await api.project.getReadme(project.id, commit);
+
    } else {
+
      blob = await api.project.getBlob(project.id, commit, path);
+
    }
+

+
    state = { status: Status.Loaded, path, blob };
+
    return blob;
  };

  onMount(() => {
@@ -84,7 +96,7 @@
  };

  const fetchTree = async (path: string) => {
-
    return project.getTree(commit, path).catch(() => {
+
    return api.project.getTree(project.id, commit, path).catch(() => {
      browserErrorStore.set({
        message: "Not able to expand directory",
        path,
@@ -201,7 +213,7 @@
    {#if tree.entries.length > 0}
      <div class="column-left" class:column-left-visible={mobileFileTree}>
        <div class="source-tree sticky">
-
          <Tree
+
          <TreeComponent
            {tree}
            {path}
            {fetchTree}
@@ -232,11 +244,11 @@
            <Loading small center />
          {:then blob}
            {#if blob}
-
              <Blob
+
              <BlobComponent
                {line}
                {blob}
                {activeRoute}
-
                rawPath={project.getRawPath(commit)} />
+
                rawPath={utils.getRawBasePath(project.id, baseUrl, commit)} />
            {/if}
          {/await}
        {/if}
modified src/views/projects/CloneButton.svelte
@@ -1,18 +1,18 @@
<script lang="ts">
-
  import type { Host } from "@app/lib/api";
+
  import type { BaseUrl } from "@httpd-client";

  import Clipboard from "@app/components/Clipboard.svelte";
  import Floating from "@app/components/Floating.svelte";
  import { closeFocused } from "@app/components/Floating.svelte";
  import { parseRepositoryId } from "@app/lib/utils";

-
  export let seedHost: Host;
+
  export let baseUrl: BaseUrl;
  export let id: string;
  export let name: string;

  $: radCloneUrl = `rad clone ${id}`;
-
  $: gitCloneUrl = `git clone ${seedHost.scheme}://${seedHost.host}:${
-
    seedHost.port
+
  $: gitCloneUrl = `git clone ${baseUrl.scheme}://${baseUrl.hostname}:${
+
    baseUrl.port
  }/${parseRepositoryId(id)?.pubkey ?? id}.git ${name}`;
</script>

modified src/views/projects/Cob/CobStateButton.svelte
@@ -1,6 +1,7 @@
-
<script lang="ts">
+
<script lang="ts" strictEvents>
  import type { Item } from "@app/components/Dropdown.svelte";
-
  import type { State } from "@app/lib/cobs";
+

+
  type T = $$Generic;

  import Button from "@app/components/Button.svelte";
  import Dropdown from "@app/components/Dropdown.svelte";
@@ -10,13 +11,15 @@
  import { createEventDispatcher } from "svelte";
  import { isEqual } from "lodash";

-
  export let state: State;
-
  export let selectedItem: Item<State>;
-
  export let items: Item<State>[];
+
  export let state: T;
+
  export let selectedItem: Item<T>;
+
  export let items: Item<T>[];

-
  const dispatch = createEventDispatcher<{ saveStatus: State }>();
+
  const dispatch = createEventDispatcher<{
+
    saveStatus: T;
+
  }>();

-
  function switchCaption({ detail: item }: CustomEvent<Item<State>>) {
+
  function switchCaption({ detail: item }: CustomEvent<Item<T>>) {
    selectedItem = item;
    closeFocused();
  }
modified src/views/projects/Commit.svelte
@@ -1,11 +1,12 @@
<script lang="ts">
-
  import type { Commit } from "@app/lib/commit";
+
  import type { Commit } from "@httpd-client";

  import { formatCommit, twemoji } from "@app/lib/utils";

+
  import * as router from "@app/lib/router";
+

  import Changeset from "@app/views/projects/SourceBrowser/Changeset.svelte";
  import CommitAuthorship from "@app/views/projects/Commit/CommitAuthorship.svelte";
-
  import * as router from "@app/lib/router";

  export let commit: Commit;

@@ -64,8 +65,5 @@
    <pre class="description txt-small">{header.description}</pre>
    <CommitAuthorship {header} />
  </header>
-
  <Changeset
-
    stats={commit.diff.stats}
-
    diff={commit.diff}
-
    on:browse={onBrowse} />
+
  <Changeset diff={commit.diff} on:browse={onBrowse} />
</div>
modified src/views/projects/Commit/CommitAuthorship.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { CommitHeader } from "@app/lib/commit";
+
  import type { CommitHeader } from "@httpd-client";

  import { formatTimestamp, gravatarURL } from "@app/lib/utils";

modified src/views/projects/Commit/CommitTeaser.svelte
@@ -1,12 +1,14 @@
<script lang="ts" strictEvents>
-
  import type { CommitMetadata } from "@app/lib/commit";
-
  import { formatCommit, twemoji } from "@app/lib/utils";
+
  import type { CommitHeader } from "@httpd-client";
+

  import { createEventDispatcher } from "svelte";

-
  import Icon from "@app/components/Icon.svelte";
+
  import { formatCommit, twemoji } from "@app/lib/utils";
+

  import CommitAuthorship from "./CommitAuthorship.svelte";
+
  import Icon from "@app/components/Icon.svelte";

-
  export let commit: CommitMetadata;
+
  export let commit: CommitHeader;

  const dispatch = createEventDispatcher<{ browseCommit: string }>();

@@ -92,18 +94,18 @@
  <div class="column-left">
    <div class="header">
      <div class="summary" use:twemoji>
-
        {commit.commit.summary}
+
        {commit.summary}
      </div>
    </div>
-
    <CommitAuthorship header={commit.commit} />
+
    <CommitAuthorship header={commit} />
  </div>
  <div class="column-right">
-
    <span class="hash txt-highlight">{formatCommit(commit.commit.id)}</span>
+
    <span class="hash txt-highlight">{formatCommit(commit.id)}</span>
    <!-- svelte-ignore a11y-click-events-have-key-events -->
    <div
      class="browse"
      title="Browse the repository at this point in the history"
-
      on:click|stopPropagation={() => browseCommit(commit.commit.id)}>
+
      on:click|stopPropagation={() => browseCommit(commit.id)}>
      <Icon name="browse" />
    </div>
  </div>
modified src/views/projects/Header.svelte
@@ -1,23 +1,25 @@
<script lang="ts">
-
  import type { Project } from "@app/lib/project";
-
  import type { Tree } from "@app/lib/project";
+
  import type { BaseUrl, Project, Remote, Tree } from "@httpd-client";
  import type { ProjectRoute } from "@app/lib/router/definitions";

  import * as router from "@app/lib/router";
+

+
  import { closeFocused } from "@app/components/Floating.svelte";
+
  import { config } from "@app/lib/config";
+
  import { pluralize } from "@app/lib/pluralize";
+

  import BranchSelector from "@app/views/projects/BranchSelector.svelte";
  import CloneButton from "@app/views/projects/CloneButton.svelte";
  import HeaderToggleLabel from "@app/views/projects/HeaderToggleLabel.svelte";
  import PeerSelector from "@app/views/projects/PeerSelector.svelte";
-
  import { closeFocused } from "@app/components/Floating.svelte";
-
  import { config } from "@app/lib/config";
-
  import { pluralize } from "@app/lib/pluralize";

-
  export let activeRoute: ProjectRoute;
  export let project: Project;
+
  export let activeRoute: ProjectRoute;
  export let tree: Tree;
  export let commit: string;
-

-
  const { id, peers, branches, seed } = project;
+
  export let peers: Remote[];
+
  export let branches: Record<string, string>;
+
  export let baseUrl: BaseUrl;

  $: revision = activeRoute.params.revision ?? commit;

@@ -53,13 +55,16 @@
  };

  function goToSeed() {
-
    if (seed.addr.port !== config.seeds.defaultHttpdPort) {
+
    if (baseUrl.port !== config.seeds.defaultHttpdPort) {
      router.push({
        resource: "seeds",
-
        params: { host: `${seed.addr.host}:${seed.addr.port}` },
+
        params: { hostnamePort: `${baseUrl.hostname}:${baseUrl.port}` },
      });
    } else {
-
      router.push({ resource: "seeds", params: { host: seed.addr.host } });
+
      router.push({
+
        resource: "seeds",
+
        params: { hostnamePort: baseUrl.hostname },
+
      });
    }
  }
</script>
@@ -95,25 +100,22 @@
  {/if}

  <BranchSelector
+
    projectDefaultBranch={project.defaultBranch}
+
    projectHead={project.head}
    {branches}
-
    {project}
    {revision}
    on:branchChanged={event => updateRevision(event.detail)} />

-
  {#if seed.addr.host}
-
    <CloneButton seedHost={seed.addr} {id} name={project.name} />
-
  {/if}
+
  <CloneButton {baseUrl} id={project.id} name={project.name} />

  <span>
-
    {#if seed.addr.host}
-
      <HeaderToggleLabel
-
        clickable
-
        ariaLabel="Seed"
-
        title="Project data is fetched from this seed"
-
        on:click={goToSeed}>
-
        <span>{seed.addr.host}</span>
-
      </HeaderToggleLabel>
-
    {/if}
+
    <HeaderToggleLabel
+
      clickable
+
      ariaLabel="Seed"
+
      title="Project data is fetched from this seed"
+
      on:click={goToSeed}>
+
      <span>{baseUrl.hostname}</span>
+
    </HeaderToggleLabel>
  </span>
  <HeaderToggleLabel
    ariaLabel="Commit count"
@@ -128,7 +130,7 @@
    active={activeRoute.params.view.resource === "issues"}
    clickable
    on:click={() => toggleContent("issues", false)}>
-
    <span class="txt-bold">{project.issues.open ?? 0}</span>
+
    <span class="txt-bold">{project.issues.open}</span>
    {pluralize("issue", project.issues.open)}
  </HeaderToggleLabel>
  <HeaderToggleLabel
@@ -136,7 +138,7 @@
    active={activeRoute.params.view.resource === "patches"}
    clickable
    on:click={() => toggleContent("patches", false)}>
-
    <span class="txt-bold">{project.patches.open ?? 0}</span>
+
    <span class="txt-bold">{project.patches.open}</span>
    {pluralize("patch", project.patches.open)}
  </HeaderToggleLabel>
  <HeaderToggleLabel ariaLabel="Contributor count">
modified src/views/projects/History.svelte
@@ -1,24 +1,27 @@
<script lang="ts">
-
  import type { CommitMetadata, CommitsHistory } from "@app/lib/commit";
+
  import type { BaseUrl, CommitHeader } from "@httpd-client";

-
  import CommitTeaser from "./Commit/CommitTeaser.svelte";
-
  import { Project } from "@app/lib/project";
+
  import * as router from "@app/lib/router";
+
  import { HttpdClient } from "@httpd-client";
  import { groupCommits } from "@app/lib/commit";
+

+
  import CommitTeaser from "./Commit/CommitTeaser.svelte";
  import List from "@app/components/List.svelte";
-
  import * as router from "@app/lib/router";

-
  export let project: Project;
-
  export let history: CommitsHistory;
+
  export let id: string;
+
  export let baseUrl: BaseUrl;
+
  export let history: CommitHeader[];
+

+
  const api = new HttpdClient(baseUrl);

-
  const fetchMoreCommits = async (): Promise<CommitMetadata[]> => {
-
    const response = await Project.getCommits(project.id, project.seed.addr, {
+
  const fetchMoreCommits = async (): Promise<CommitHeader[]> => {
+
    const response = await api.project.getAllCommits(id, {
      // Fetching 31 elements since we remove the first one
-
      parent: history.commits[history.commits.length - 1].commit.id,
+
      parent: history[history.length - 1].id,
      perPage: 31,
-
      verified: true,
    });
    // Removing the first element of the array, since it's the same as the last of the current list
-
    return response.commits.slice(1);
+
    return response.commits.slice(1).map(c => c.commit);
  };

  const browseCommit = (event: { detail: string }) => {
@@ -49,7 +52,7 @@
</style>

<div class="history">
-
  <List bind:items={history.commits} query={fetchMoreCommits}>
+
  <List bind:items={history} query={fetchMoreCommits}>
    <svelte:fragment slot="list" let:items>
      {@const commits = groupCommits(items)}
      {#each commits as group (group.time)}
@@ -58,13 +61,13 @@
            <p>{group.date}</p>
          </header>
          <div class="commit-group-headers">
-
            {#each group.commits as commit (commit.commit.id)}
+
            {#each group.commits as commit (commit.id)}
              <CommitTeaser
                {commit}
                on:click={() => {
                  router.updateProjectRoute({
                    view: { resource: "commits" },
-
                    revision: commit.commit.id,
+
                    revision: commit.id,
                  });
                }}
                on:browseCommit={browseCommit} />
modified src/views/projects/Issue.svelte
@@ -1,9 +1,15 @@
<script lang="ts" strictEvents>
-
  import type { Project } from "@app/lib/project";
-
  import type { IssueState } from "@app/lib/issue";
-
  import type { State } from "@app/lib/cobs";
+
  import type { BaseUrl, Issue, IssueState } from "@httpd-client";
  import type { Item } from "@app/components/Dropdown.svelte";

+
  import { createEventDispatcher } from "svelte";
+

+
  import * as utils from "@app/lib/utils";
+
  import { HttpdClient } from "@httpd-client";
+
  import { parseNodeId, formatNodeId } from "@app/lib/utils";
+
  import { sessionStore } from "@app/lib/session";
+
  import { validateAssignee, validateTag } from "@app/lib/cobs";
+

  import Authorship from "@app/components/Authorship.svelte";
  import Avatar from "@app/components/Avatar.svelte";
  import Badge from "@app/components/Badge.svelte";
@@ -13,21 +19,18 @@
  import CobStateButton from "@app/views/projects/Cob/CobStateButton.svelte";
  import Textarea from "@app/components/Textarea.svelte";
  import Thread from "@app/components/Thread.svelte";
-
  import { createAddRemoveArrays, Issue } from "@app/lib/issue";
-
  import { createEventDispatcher } from "svelte";
-
  import { isLocal } from "@app/lib/utils";
-
  import { parseNodeId, formatNodeId } from "@app/lib/utils";
-
  import { sessionStore } from "@app/lib/session";
-
  import { validateAssignee, validateTag } from "@app/lib/cobs";

  export let issue: Issue;
-
  export let project: Project;
+
  export let baseUrl: BaseUrl;
+
  export let projectId: string;
+
  export let projectHead: string;

  const dispatch = createEventDispatcher<{ update: never }>();
-
  const rawPath = project.getRawPath();
+
  const rawPath = utils.getRawBasePath(projectId, baseUrl, projectHead);
+
  const api = new HttpdClient(baseUrl);

  const action: "create" | "edit" | "view" =
-
    $sessionStore && isLocal(project.seed.addr.host) ? "edit" : "view";
+
    $sessionStore && utils.isLocal(baseUrl.hostname) ? "edit" : "view";
  const items: Item<IssueState>[] = [
    { title: "Reopen issue", state: { status: "open" } as const },
    {
@@ -49,38 +52,40 @@
    detail: reply,
  }: CustomEvent<{ id: string; body: string }>) {
    if ($sessionStore && reply.body.trim().length > 0) {
-
      await issue.replyComment(
-
        project.id,
-
        reply.body,
-
        reply.id,
-
        project.seed.addr,
+
      await api.project.updateIssue(
+
        projectId,
+
        issue.id,
+
        {
+
          type: "thread",
+
          action: { type: "comment", body: reply.body, replyTo: reply.id },
+
        },
        $sessionStore.id,
      );
-
      issue = await Issue.getIssue(project.id, issue.id, project.seed.addr);
+
      issue = await api.project.getIssueById(projectId, issue.id);
    }
  }

  async function createComment(body: string) {
    if ($sessionStore && body.trim().length > 0) {
-
      await issue.createComment(
-
        project.id,
-
        body,
-
        project.seed.addr,
+
      await api.project.updateIssue(
+
        projectId,
+
        issue.id,
+
        { type: "thread", action: { type: "comment", body } },
        $sessionStore.id,
      );
-
      issue = await Issue.getIssue(project.id, issue.id, project.seed.addr);
+
      issue = await api.project.getIssueById(projectId, issue.id);
    }
  }

  async function editTitle({ detail: title }: CustomEvent<string>) {
    if ($sessionStore && title.trim().length > 0 && title !== issue.title) {
-
      await issue.editTitle(
-
        project.id,
-
        title,
-
        project.seed.addr,
+
      await api.project.updateIssue(
+
        projectId,
+
        issue.id,
+
        { type: "edit", title },
        $sessionStore.id,
      );
-
      issue = await Issue.getIssue(project.id, issue.id, project.seed.addr);
+
      issue = await api.project.getIssueById(projectId, issue.id);
    } else {
      // Reassigning issue.title overwrites the invalid title in IssueHeader
      issue.title = issue.title;
@@ -89,48 +94,49 @@

  async function saveTags({ detail: tags }: CustomEvent<string[]>) {
    if ($sessionStore) {
-
      const { add, remove } = createAddRemoveArrays(issue.tags, tags);
+
      const { add, remove } = utils.createAddRemoveArrays(issue.tags, tags);
      if (add.length === 0 && remove.length === 0) {
        return;
      }
-
      await issue.editTags(
-
        project.id,
-
        add,
-
        remove,
-
        project.seed.addr,
+
      await api.project.updateIssue(
+
        projectId,
+
        issue.id,
+
        { type: "tag", add, remove },
        $sessionStore.id,
      );
-
      issue = await Issue.getIssue(project.id, issue.id, project.seed.addr);
+
      issue = await api.project.getIssueById(projectId, issue.id);
    }
  }

  async function saveAssignees({ detail: assignees }: CustomEvent<string[]>) {
    if ($sessionStore) {
-
      const { add, remove } = createAddRemoveArrays(issue.assignees, assignees);
+
      const { add, remove } = utils.createAddRemoveArrays(
+
        issue.assignees,
+
        assignees,
+
      );
      if (add.length === 0 && remove.length === 0) {
        return;
      }
-
      await issue.editAssignees(
-
        project.id,
-
        add,
-
        remove,
-
        project.seed.addr,
+
      await api.project.updateIssue(
+
        projectId,
+
        issue.id,
+
        { type: "assign", add, remove },
        $sessionStore.id,
      );
-
      issue = await Issue.getIssue(project.id, issue.id, project.seed.addr);
+
      issue = await api.project.getIssueById(projectId, issue.id);
    }
  }

-
  async function saveStatus({ detail: state }: CustomEvent<State>) {
+
  async function saveStatus({ detail: state }: CustomEvent<IssueState>) {
    if ($sessionStore) {
-
      await issue.changeState(
-
        project.id,
-
        state,
-
        project.seed.addr,
+
      await api.project.updateIssue(
+
        projectId,
+
        issue.id,
+
        { type: "lifecycle", state },
        $sessionStore.id,
      );
      dispatch("update");
-
      issue = await Issue.getIssue(project.id, issue.id, project.seed.addr);
+
      issue = await api.project.getIssueById(projectId, issue.id);
    }
  }

@@ -207,8 +213,8 @@
        {/if}
        <Authorship
          highlight
-
          timestamp={issue.timestamp}
-
          author={issue.author}
+
          timestamp={issue.discussion[0].timestamp}
+
          authorId={issue.author.id}
          caption="opened this issue" />
      </svelte:fragment>
    </CobHeader>
modified src/views/projects/Issue/IssueTeaser.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { Issue } from "@app/lib/issue";
+
  import type { Issue } from "@httpd-client";

  import { formatObjectId } from "@app/lib/cobs";
  import Authorship from "@app/components/Authorship.svelte";
@@ -9,7 +9,18 @@

  export let issue: Issue;

-
  const commentCount = issue.countComments();
+
  const commentCount = countComments(issue);
+

+
  // Counts the amount of comments in a discussion, excluding the initial
+
  // description.
+
  function countComments(issue: Issue): number {
+
    return issue.discussion.reduce((acc, _curr, index) => {
+
      if (index !== 0) {
+
        return acc + 1;
+
      }
+
      return acc;
+
    }, 0);
+
  }
</script>

<style>
@@ -120,10 +131,12 @@
      <span class="id">
        <span class="highlight">{formatObjectId(issue.id)}</span>
        opened
-
        <span class="highlight">{formatTimestamp(issue.timestamp)}</span>
+
        <span class="highlight">
+
          {formatTimestamp(issue.discussion[0].timestamp)}
+
        </span>
        by
      </span>
-
      <Authorship highlight noAvatar author={issue.author} />
+
      <Authorship highlight noAvatar authorId={issue.author.id} />
    </div>
  </div>
  {#if commentCount > 0}
modified src/views/projects/Issue/New.svelte
@@ -1,8 +1,15 @@
<script lang="ts" strictEvents>
-
  import type { Project } from "@app/lib/project";
-
  import type { Session } from "@app/lib/session";
+
  import type { BaseUrl } from "@httpd-client";
+
  import type { StoredSession } from "@app/lib/session";
+

+
  import { createEventDispatcher } from "svelte";

  import * as modal from "@app/lib/modal";
+
  import * as utils from "@app/lib/utils";
+
  import { HttpdClient } from "@httpd-client";
+
  import { sessionStore } from "@app/lib/session";
+
  import { stripDidPrefix, validateTag } from "@app/lib/cobs";
+

  import AuthenticationErrorModal from "@app/views/session/AuthenticationErrorModal.svelte";
  import Authorship from "@app/components/Authorship.svelte";
  import Avatar from "@app/components/Avatar.svelte";
@@ -11,18 +18,15 @@
  import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
  import CobSideInput from "@app/views/projects/Cob/CobSideInput.svelte";
  import Comment from "@app/components/Comment.svelte";
-
  import { Issue } from "@app/lib/issue";
-
  import { createEventDispatcher } from "svelte";
-
  import { formatNodeId, isLocal, parseNodeId } from "@app/lib/utils";
-
  import { sessionStore } from "@app/lib/session";
-
  import { stripDidPrefix, validateTag } from "@app/lib/cobs";

-
  export let session: Session;
-
  export let project: Project;
+
  export let session: StoredSession;
+
  export let projectId: string;
+
  export let projectHead: string;
+
  export let baseUrl: BaseUrl;

  const dispatch = createEventDispatcher<{ create: string }>();
  const action: "edit" | "view" =
-
    $sessionStore && isLocal(project.seed.addr.host) ? "edit" : "view";
+
    $sessionStore && utils.isLocal(baseUrl.hostname) ? "edit" : "view";

  let preview: boolean = false;

@@ -31,15 +35,18 @@
  let assignees: string[] = [];
  let tags: string[] = [];

+
  const api = new HttpdClient(baseUrl);
+

  async function createIssue() {
    try {
-
      const result = await Issue.createIssue(
-
        project.id,
-
        issueTitle,
-
        issueText,
-
        stripDidPrefix(assignees),
-
        tags,
-
        project.seed.addr,
+
      const result = await api.project.createIssue(
+
        projectId,
+
        {
+
          title: issueTitle,
+
          description: issueText,
+
          assignees: stripDidPrefix(assignees),
+
          tags: tags,
+
        },
        session.id,
      );
      dispatch("create", result.id);
@@ -103,7 +110,7 @@
          <Badge variant="positive">open</Badge>
          <Authorship
            timestamp={Date.now()}
-
            author={{ id: session.publicKey }}
+
            authorId={session.publicKey}
            caption="opened this issue" />
        </svelte:fragment>
      </CobHeader>
@@ -111,10 +118,10 @@
        <Comment
          bind:body={issueText}
          on:submit={createIssue}
-
          author={{ id: session.publicKey }}
+
          authorId={session.publicKey}
          timestamp={Date.now()}
          action={preview ? "view" : "create"}
-
          rawPath={project.getRawPath()} />
+
          rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)} />
      </div>
      <div class="actions">
        <Button
@@ -142,11 +149,11 @@
        title="Assignees"
        placeholder="Add assignee"
        on:save={({ detail: assignees }) => (assignees = assignees)}
-
        validate={item => Boolean(parseNodeId(item))}
+
        validate={item => Boolean(utils.parseNodeId(item))}
        validateAdd={(item, items) => validateTag(item, items)}>
        <svelte:fragment let:item>
          <Avatar inline nodeId={item} />
-
          <span>{formatNodeId(item)}</span>
+
          <span>{utils.formatNodeId(item)}</span>
        </svelte:fragment>
      </CobSideInput>
      <CobSideInput
modified src/views/projects/Issues.svelte
@@ -1,18 +1,17 @@
<script lang="ts" context="module">
-
  import type { IssueState } from "@app/lib/issue";
+
  import type { IssueState } from "@httpd-client";

  export type IssueStatus = IssueState["status"];
</script>

<script lang="ts">
-
  import type { Project } from "@app/lib/project";
-
  import type { Issue } from "@app/lib/issue";
+
  import type { Issue } from "@httpd-client";
  import type { Tab } from "@app/components/TabBar.svelte";
+
  import type { BaseUrl } from "@httpd-client";

  import * as router from "@app/lib/router";
  import * as utils from "@app/lib/utils";
  import capitalize from "lodash/capitalize";
-
  import { groupIssues } from "@app/lib/issue";
  import { sessionStore } from "@app/lib/session";

  import HeaderToggleLabel from "@app/views/projects/HeaderToggleLabel.svelte";
@@ -22,10 +21,24 @@

  export let issues: Issue[];
  export let status: IssueStatus;
-
  export let project: Project;
+
  export let baseUrl: BaseUrl;
+
  export let issueCounters: { open: number; closed: number };

  let options: Tab<IssueStatus>[];

+
  function groupIssues(issues: Issue[]): {
+
    open: Issue[];
+
    closed: Issue[];
+
  } {
+
    return issues.reduce(
+
      (acc, issue) => {
+
        acc[issue.state.status].push(issue);
+
        return acc;
+
      },
+
      { open: [] as Issue[], closed: [] as Issue[] },
+
    );
+
  }
+

  const stateOptions: IssueStatus[] = ["open", "closed"];
  $: options = stateOptions.map<{
    value: IssueStatus;
@@ -33,12 +46,13 @@
    disabled: boolean;
  }>((s: IssueStatus) => ({
    value: s,
-
    title: `${project.issues[s]} ${s}`,
-
    disabled: project.issues[s] === 0,
+
    title: `${issueCounters[s]} ${s}`,
+
    disabled: issueCounters[s] === 0,
  }));
  $: filteredIssues = groupIssues(issues)[status];
  $: sortedIssues = filteredIssues.sort(
-
    ({ timestamp: t1 }, { timestamp: t2 }) => t2 - t1,
+
    ({ discussion: t1 }, { discussion: t2 }) =>
+
      t2[0].timestamp - t1[0].timestamp,
  );
</script>

@@ -80,7 +94,7 @@
        active={status} />
    </div>
    <HeaderToggleLabel
-
      disabled={!$sessionStore || !utils.isLocal(project.seed.host)}
+
      disabled={!$sessionStore || !utils.isLocal(baseUrl.hostname)}
      on:click={() => {
        router.updateProjectRoute({
          view: {
modified src/views/projects/Patch.svelte
@@ -1,53 +1,58 @@
-
<script lang="ts" context="module">
-
  import type * as cobs from "@app/lib/cobs";
-
  import type { Merge, Review } from "@app/lib/patch";
+
<script lang="ts">
+
  import type { BaseUrl, Comment, Merge, Patch, Review } from "@httpd-client";
+

+
  interface Thread {
+
    root: Comment;
+
    replies: Comment[];
+
  }

-
  export interface TimelineReview {
+
  interface TimelineReview {
    inner: [string, Review];
    type: "review";
    timestamp: number;
  }

-
  export interface TimelineMerge {
+
  interface TimelineMerge {
    inner: Merge;
    type: "merge";
    timestamp: number;
  }

-
  export interface TimelineThread {
-
    inner: cobs.Thread;
+
  interface TimelineThread {
+
    inner: Thread;
    type: "thread";
    timestamp: number;
  }
-
</script>

-
<script lang="ts">
-
  import type { Project } from "@app/lib/project";
+
  import capitalize from "lodash/capitalize";

  import * as router from "@app/lib/router";
+
  import * as utils from "@app/lib/utils";
+
  import { HttpdClient } from "@httpd-client";
+
  import { formatObjectId, validateTag } from "@app/lib/cobs";
+
  import { sessionStore } from "@app/lib/session";
+

  import Authorship from "@app/components/Authorship.svelte";
  import Badge from "@app/components/Badge.svelte";
  import Changeset from "./SourceBrowser/Changeset.svelte";
  import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
  import CobSideInput from "./Cob/CobSideInput.svelte";
-
  import Comment from "@app/components/Comment.svelte";
+
  import CommentComponent from "@app/components/Comment.svelte";
  import CommitTeaser from "./Commit/CommitTeaser.svelte";
  import Dropdown from "@app/components/Dropdown.svelte";
  import Floating from "@app/components/Floating.svelte";
  import HeaderToggleLabel from "./HeaderToggleLabel.svelte";
  import TabBar from "@app/components/TabBar.svelte";
-
  import Thread from "@app/components/Thread.svelte";
-
  import capitalize from "lodash/capitalize";
-
  import { Patch } from "@app/lib/patch";
-
  import { createAddRemoveArrays } from "@app/lib/issue";
-
  import { formatCommit, isLocal } from "@app/lib/utils";
-
  import { formatObjectId, validateTag } from "@app/lib/cobs";
-
  import { sessionStore } from "@app/lib/session";
+
  import ThreadComponent from "@app/components/Thread.svelte";

+
  export let projectId: string;
+
  export let baseUrl: BaseUrl;
  export let patch: Patch;
+
  export let projectHead: string;
  export let revision: string | undefined = undefined;
  export let currentTab: "activity" | "commits";
-
  export let project: Project;
+

+
  const api = new HttpdClient(baseUrl);

  const browseCommit = (event: { detail: string }) => {
    router.updateProjectRoute({
@@ -61,32 +66,37 @@
    detail: reply,
  }: CustomEvent<{ id: string; body: string }>) {
    if ($sessionStore && reply.body.trim().length > 0) {
-
      await patch.replyComment(
-
        project.id,
-
        currentRevision.id,
-
        reply.body,
-
        reply.id,
-
        project.seed.addr,
+
      await api.project.updatePatch(
+
        projectId,
+
        patch.id,
+
        {
+
          type: "thread",
+
          revision: currentRevision.id,
+
          action: {
+
            type: "comment",
+
            body: reply.body,
+
            replyTo: reply.id,
+
          },
+
        },
        $sessionStore.id,
      );
-
      patch = await Patch.getPatch(project.id, patch.id, project.seed.addr);
+
      patch = await api.project.getPatchById(projectId, patch.id);
    }
  }

  async function saveTags({ detail: tags }: CustomEvent<string[]>) {
    if ($sessionStore) {
-
      const { add, remove } = createAddRemoveArrays(patch.tags, tags);
+
      const { add, remove } = utils.createAddRemoveArrays(patch.tags, tags);
      if (add.length === 0 && remove.length === 0) {
        return;
      }
-
      await patch.editTags(
-
        project.id,
-
        add,
-
        remove,
-
        project.seed.addr,
+
      await api.project.updatePatch(
+
        projectId,
+
        currentRevision.id,
+
        { type: "tag", add, remove },
        $sessionStore.id,
      );
-
      patch = await Patch.getPatch(project.id, patch.id, project.seed.addr);
+
      patch = await api.project.getPatchById(projectId, patch.id);
    }
  }

@@ -102,7 +112,7 @@
  }

  const action: "create" | "edit" | "view" =
-
    $sessionStore && isLocal(project.seed.addr.host) ? "edit" : "view";
+
    $sessionStore && utils.isLocal(baseUrl.hostname) ? "edit" : "view";

  // Reactive due to eventual changes in patch.revisions
  $: enumeratedRevisions = patch.revisions.map((r, i) => [r, i] as const);
@@ -145,10 +155,10 @@
  $: timeline = [...reviews, ...merges, ...threads].sort(
    (a, b) => a.timestamp - b.timestamp,
  );
-
  $: diffPromise = patch.getPatchDiff(
-
    project.id,
-
    currentRevision,
-
    project.seed.addr,
+
  $: diffPromise = api.project.getDiff(
+
    projectId,
+
    currentRevision.base,
+
    currentRevision.oid,
  );
</script>

@@ -246,11 +256,11 @@
          <Authorship
            highlight
            timestamp={patch.revisions[0].timestamp}
-
            author={patch.author}
+
            authorId={patch.author.id}
            caption="opened this patch" />
        </div>
        <div class="layout-mobile">
-
          <Authorship highlight author={patch.author} />
+
          <Authorship highlight authorId={patch.author.id} />
        </div>
      </svelte:fragment>
    </CobHeader>
@@ -264,11 +274,11 @@
    {#if currentTab === "activity"}
      <div style:margin-top="1rem">
        <div class="txt-tiny">
-
          <Comment
+
          <CommentComponent
            caption="created this revision"
-
            author={patch.author}
+
            authorId={patch.author.id}
            timestamp={currentRevision.timestamp}
-
            rawPath={project.getRawPath()}
+
            rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
            body={currentRevisionIndex === 0
              ? patch.description
              : currentRevision.description} />
@@ -276,8 +286,8 @@
        {#each timeline as element}
          {#if element.type === "thread"}
            <!-- TODO: Implement reply creation and comment editing -->
-
            <Thread
-
              rawPath={project.getRawPath()}
+
            <ThreadComponent
+
              rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
              isDescription={false}
              thread={element.inner}
              on:reply={createReply}
@@ -285,19 +295,19 @@
          {:else if element.type === "merge"}
            <div class="action layout-desktop txt-tiny">
              <Authorship
-
                author={{ id: element.inner.node }}
+
                authorId={element.inner.node}
                timestamp={element.timestamp}>
                merged
                <span class="highlight">
-
                  {formatCommit(element.inner.commit)}
+
                  {utils.formatCommit(element.inner.commit)}
                </span>
              </Authorship>
            </div>
            <div class="action layout-mobile txt-tiny">
-
              <Authorship author={{ id: element.inner.node }}>
+
              <Authorship authorId={element.inner.node}>
                merged
                <span class="highlight">
-
                  {formatCommit(element.inner.commit)}
+
                  {utils.formatCommit(element.inner.commit)}
                </span>
              </Authorship>
            </div>
@@ -305,21 +315,21 @@
            <!-- TODO: Implement inline code comments -->
            {@const [author, review] = element.inner}
            <div class="action layout-desktop txt-tiny">
-
              <Authorship author={{ id: author }} timestamp={element.timestamp}>
+
              <Authorship authorId={author} timestamp={element.timestamp}>
                {formatVerdict(review.verdict)}
              </Authorship>
            </div>
            <div class="action layout-mobile txt-tiny">
-
              <Authorship author={{ id: author }}>
+
              <Authorship authorId={author}>
                {formatVerdict(review.verdict)}
              </Authorship>
            </div>
            {#if review.comment}
-
              <Comment
+
              <CommentComponent
                caption="left a comment"
-
                author={{ id: author }}
+
                authorId={author}
                timestamp={review.timestamp}
-
                rawPath={project.getRawPath()}
+
                rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
                body={review.comment} />
            {/if}
          {/if}
@@ -330,7 +340,7 @@
        <div style:margin-top="1rem">
          {#each diff.commits as commit}
            <CommitTeaser
-
              commit={{ commit: commit }}
+
              {commit}
              on:click={() => {
                router.updateProjectRoute({
                  view: { resource: "commits" },
@@ -347,7 +357,6 @@
        <div style:margin-top="1rem">
          <Changeset
            diff={diff.diff}
-
            stats={diff.diff.stats}
            on:browse={({ detail: path }) => {
              router.updateProjectRoute({
                view: { resource: "tree" },
modified src/views/projects/Patch/PatchTeaser.svelte
@@ -1,23 +1,28 @@
<script lang="ts">
-
  import type { Patch } from "@app/lib/patch";
-
  import type { Project } from "@app/lib/project";
+
  import type { BaseUrl } from "@httpd-client";
+
  import type { Patch } from "@httpd-client";
+

+
  import { HttpdClient } from "@httpd-client";
+
  import { formatObjectId } from "@app/lib/cobs";
+
  import { formatTimestamp } from "@app/lib/utils";

  import Authorship from "@app/components/Authorship.svelte";
  import Badge from "@app/components/Badge.svelte";
  import DiffStatBadge from "@app/components/DiffStatBadge.svelte";
  import Icon from "@app/components/Icon.svelte";
-
  import { formatObjectId } from "@app/lib/cobs";
-
  import { formatTimestamp } from "@app/lib/utils";

-
  export let project: Project;
+
  export let projectId: string;
+
  export let baseUrl: BaseUrl;
  export let patch: Patch;

+
  const api = new HttpdClient(baseUrl);
+

  const latestRevisionIndex = patch.revisions.length - 1;
  const latestRevision = patch.revisions[latestRevisionIndex];
-
  $: diffPromise = patch.getPatchDiff(
-
    project.id,
-
    latestRevision,
-
    project.seed.addr,
+
  $: diffPromise = api.project.getDiff(
+
    projectId,
+
    latestRevision.base,
+
    latestRevision.oid,
  );
</script>

@@ -136,14 +141,16 @@
          {formatTimestamp(latestRevision.timestamp)}
        </span>
        by
-
        <Authorship highlight noAvatar author={patch.author} />
+
        <Authorship highlight noAvatar authorId={patch.author.id} />
      </span>
    </div>
  </div>
  <div class="column-right">
    <div class="comment-count">
      {#await diffPromise then { diff }}
-
        <DiffStatBadge stats={diff.stats} />
+
        <DiffStatBadge
+
          insertions={diff.stats.insertions}
+
          deletions={diff.stats.deletions} />
      {/await}
      {#if latestRevision.discussions.length > 0}
        <Icon name="chat" />
modified src/views/projects/Patches.svelte
@@ -1,27 +1,53 @@
<script lang="ts" context="module">
-
  import type { PatchState } from "@app/lib/patch";
+
  import type { PatchState } from "@httpd-client";

  export type PatchStatus = PatchState["status"];
</script>

<script lang="ts">
-
  import type { Patch } from "@app/lib/patch";
-
  import type { Project } from "@app/lib/project";
+
  import type { Patch } from "@httpd-client";
  import type { Tab } from "@app/components/TabBar.svelte";
+
  import type { BaseUrl } from "@httpd-client";

  import * as router from "@app/lib/router";
  import PatchTeaser from "./Patch/PatchTeaser.svelte";
  import Placeholder from "@app/components/Placeholder.svelte";
  import capitalize from "lodash/capitalize";
  import TabBar from "@app/components/TabBar.svelte";
-
  import { groupPatches } from "@app/lib/patch";

  export let patches: Patch[];
  export let status: PatchStatus;
-
  export let project: Project;
+
  export let baseUrl: BaseUrl;
+
  export let projectId: string;
+
  export let projectPatches: {
+
    draft: number;
+
    open: number;
+
    archived: number;
+
    merged: number;
+
  };

  let options: Tab<PatchStatus>[];

+
  function groupPatches(patches: Patch[]): {
+
    open: Patch[];
+
    draft: Patch[];
+
    archived: Patch[];
+
    merged: Patch[];
+
  } {
+
    return patches.reduce(
+
      (acc, patch) => {
+
        acc[patch.state.status].push(patch);
+
        return acc;
+
      },
+
      {
+
        open: [] as Patch[],
+
        draft: [] as Patch[],
+
        archived: [] as Patch[],
+
        merged: [] as Patch[],
+
      },
+
    );
+
  }
+

  const stateOptions: PatchStatus[] = ["draft", "open", "archived", "merged"];
  $: options = stateOptions.map<{
    value: PatchStatus;
@@ -29,11 +55,8 @@
    disabled: boolean;
  }>((s: PatchStatus) => ({
    value: s,
-
    title:
-
      project.patches[s] !== undefined
-
        ? `${project.patches[s]} ${s}`
-
        : `0 ${s}`,
-
    disabled: project.patches[s] === 0 || project.patches[s] === undefined,
+
    title: `${projectPatches[s]} ${s}`,
+
    disabled: projectPatches[s] === 0,
  }));
  $: filteredPatches = groupPatches(patches)[status];
  $: sortedPatches = filteredPatches.sort(
@@ -85,7 +108,7 @@
              },
            });
          }}>
-
          <PatchTeaser {project} {patch} />
+
          <PatchTeaser {baseUrl} {projectId} {patch} />
        </div>
      {/each}
    </div>
modified src/views/projects/PeerSelector.svelte
@@ -1,22 +1,24 @@
<script lang="ts" strictEvents>
-
  import type { Peer } from "@app/lib/project";
  import type { Item } from "@app/components/Dropdown.svelte";
+
  import type { Remote } from "@httpd-client";
+

+
  import { createEventDispatcher, onMount } from "svelte";
+

+
  import { formatNodeId, truncateId } from "@app/lib/utils";

  import Badge from "@app/components/Badge.svelte";
  import Dropdown from "@app/components/Dropdown.svelte";
  import Floating from "@app/components/Floating.svelte";
  import Icon from "@app/components/Icon.svelte";
-
  import { createEventDispatcher, onMount } from "svelte";
-
  import { formatNodeId, truncateId } from "@app/lib/utils";

  export let peer: string | null = null;
-
  export let peers: Peer[];
+
  export let peers: Remote[];

-
  let meta: Peer | undefined;
+
  let meta: Remote | undefined;

  let items: Item<string>[] = [];

-
  function createTitle(p: Peer): string {
+
  function createTitle(p: Remote): string {
    const nodeId = formatNodeId(p.id);
    return p.delegate
      ? `${nodeId} is a delegate of this project`
modified src/views/projects/ProjectMeta.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { Project } from "@app/lib/project";
+
  import type { Project } from "@httpd-client";

  import Clipboard from "@app/components/Clipboard.svelte";
  import DOMPurify from "dompurify";
modified src/views/projects/SourceBrowser/Changeset.svelte
@@ -1,11 +1,11 @@
<script lang="ts">
-
  import type { Diff, DiffStats } from "@app/lib/diff";
+
  import type { Diff } from "@httpd-client";

-
  import FileDiff from "@app/views/projects/SourceBrowser/FileDiff.svelte";
  import { pluralize } from "@app/lib/pluralize";

+
  import FileDiff from "@app/views/projects/SourceBrowser/FileDiff.svelte";
+

  export let diff: Diff;
-
  export let stats: DiffStats;

  const diffDescription = ({ modified, added, deleted }: Diff): string => {
    const s = [];
@@ -42,13 +42,13 @@
  <span>{diffDescription(diff)}</span>
  with
  <span class="additions">
-
    {stats.insertions}
-
    {pluralize("insertion", stats.insertions)}
+
    {diff.stats.insertions}
+
    {pluralize("insertion", diff.stats.insertions)}
  </span>
  and
  <span class="deletions">
-
    {stats.deletions}
-
    {pluralize("deletion", stats.deletions)}
+
    {diff.stats.deletions}
+
    {pluralize("deletion", diff.stats.deletions)}
  </span>
</div>
<div class="diff-listing">
modified src/views/projects/SourceBrowser/FileDiff.svelte
@@ -1,13 +1,17 @@
<script lang="ts" strictEvents>
+
  import type {
+
    DiffAddedDeletedModifiedChangeset,
+
    HunkLine,
+
  } from "@httpd-client";
+

  import { createEventDispatcher } from "svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import { lineNumberL, lineNumberR, lineSign } from "@app/lib/diff";
-
  import type { FileDiff } from "@app/lib/diff";
+

  import Badge from "@app/components/Badge.svelte";
+
  import Icon from "@app/components/Icon.svelte";

  const dispatch = createEventDispatcher<{ browse: string }>();

-
  export let file: FileDiff;
+
  export let file: DiffAddedDeletedModifiedChangeset;
  export let mode: string | null = null;

  function collapse() {
@@ -15,6 +19,48 @@
  }

  let collapsed = false;
+

+
  function lineNumberR(line: HunkLine): string | number {
+
    switch (line.type) {
+
      case "addition": {
+
        return line.lineNo;
+
      }
+
      case "context": {
+
        return line.lineNoNew;
+
      }
+
      case "deletion": {
+
        return " ";
+
      }
+
    }
+
  }
+

+
  function lineNumberL(line: HunkLine): string | number {
+
    switch (line.type) {
+
      case "addition": {
+
        return " ";
+
      }
+
      case "context": {
+
        return line.lineNoOld;
+
      }
+
      case "deletion": {
+
        return line.lineNo;
+
      }
+
    }
+
  }
+

+
  function lineSign(line: HunkLine): string {
+
    switch (line.type) {
+
      case "addition": {
+
        return "+";
+
      }
+
      case "context": {
+
        return " ";
+
      }
+
      case "deletion": {
+
        return "-";
+
      }
+
    }
+
  }
</script>

<style>
modified src/views/projects/Tree.svelte
@@ -1,12 +1,12 @@
<script lang="ts" strictEvents>
-
  import type { MaybeTree, Tree } from "@app/lib/project";
+
  import type { Tree } from "@httpd-client";

  import { createEventDispatcher } from "svelte";

  import File from "./Tree/File.svelte";
  import Folder from "./Tree/Folder.svelte";

-
  export let fetchTree: (path: string) => Promise<MaybeTree>;
+
  export let fetchTree: (path: string) => Promise<Tree | undefined>;
  export let path: string;
  export let tree: Tree;
  export let loadingPath: string | null = null;
modified src/views/projects/Tree/Folder.svelte
@@ -1,19 +1,20 @@
<script lang="ts" strictEvents>
-
  import type { MaybeTree } from "@app/lib/project";
+
  import type { Tree } from "@httpd-client";

-
  import Loading from "@app/components/Loading.svelte";
  import { createEventDispatcher } from "svelte";

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

  import File from "./File.svelte";

-
  export let fetchTree: (path: string) => Promise<MaybeTree>;
+
  export let fetchTree: (path: string) => Promise<Tree | undefined>;
  export let name: string;
  export let prefix: string;
  export let currentPath: string;
  export let loadingPath: string | null = null;

  let expanded = currentPath.indexOf(prefix) === 0;
-
  let tree: Promise<MaybeTree> = fetchTree(prefix).then(tree => {
+
  let tree: Promise<Tree | undefined> = fetchTree(prefix).then(tree => {
    if (expanded) return tree;
  });

modified src/views/projects/View.svelte
@@ -1,49 +1,89 @@
<script lang="ts">
-
  import type { ProjectRoute } from "@app/lib/router/definitions";
  import type { IssueStatus } from "./Issues.svelte";
  import type { PatchStatus } from "./Patches.svelte";
+
  import type { ProjectRoute } from "@app/lib/router/definitions";
+
  import type { Tree } from "@httpd-client";

-
  import * as issue from "@app/lib/issue";
-
  import * as patch from "@app/lib/patch";
-
  import * as proj from "@app/lib/project";
  import * as router from "@app/lib/router";
-
  import Loading from "@app/components/Loading.svelte";
-
  import NotFound from "@app/components/NotFound.svelte";
+
  import * as utils from "@app/lib/utils";
+
  import { HttpdClient } from "@httpd-client";
  import { formatNodeId, unreachable } from "@app/lib/utils";
  import { sessionStore } from "@app/lib/session";

+
  import Loading from "@app/components/Loading.svelte";
+
  import Message from "@app/components/Message.svelte";
+
  import NotFound from "@app/components/NotFound.svelte";
+
  import Placeholder from "@app/components/Placeholder.svelte";
+

  import Browser from "./Browser.svelte";
  import Commit from "./Commit.svelte";
  import Header from "./Header.svelte";
  import History from "./History.svelte";
  import Issue from "./Issue.svelte";
  import Issues from "./Issues.svelte";
-
  import Message from "@app/components/Message.svelte";
  import NewIssue from "./Issue/New.svelte";
  import Patch from "./Patch.svelte";
  import Patches from "./Patches.svelte";
-
  import Placeholder from "@app/components/Placeholder.svelte";
  import ProjectMeta from "./ProjectMeta.svelte";

  export let activeRoute: ProjectRoute;

  $: id = activeRoute.params.id;
  $: peer = activeRoute.params.peer;
-
  $: seed = activeRoute.params.seed;

  $: searchParams = new URLSearchParams(activeRoute.params.search || "");
  $: issueFilter = (searchParams.get("state") as IssueStatus) || "open";
  $: patchTabFilter =
    (searchParams.get("tab") as "activity" | "commits") || "activity";
  $: patchFilter = (searchParams.get("state") as PatchStatus) || "open";
+
  $: baseUrl = utils.extractBaseUrl(activeRoute.params.hostnamePort);
+
  $: api = new HttpdClient(baseUrl);
+

+
  // Parses the path consisting of a revision (eg. branch or commit) and file
+
  // path into a tuple [revision, file-path]
+
  function parseRoute(
+
    input: string,
+
    branches: Record<string, string>,
+
  ): { path?: string; revision?: string } {
+
    const branch = Object.entries(branches).find(([branchName]) =>
+
      input.startsWith(branchName),
+
    );
+
    const commitPath = [input.slice(0, 40), input.slice(41)];
+
    const parsed: { path?: string; revision?: string } = {};
+

+
    if (branch) {
+
      const [rev, path] = [
+
        input.slice(0, branch[0].length),
+
        input.slice(branch[0].length + 1),
+
      ];
+

+
      parsed.revision = rev;
+
      parsed.path = path ? path : "/";
+
    } else if (utils.isOid(commitPath[0])) {
+
      parsed.revision = commitPath[0];
+
      parsed.path = commitPath[1] ? commitPath[1] : "/";
+
    } else {
+
      parsed.path = input;
+
    }
+
    return parsed;
+
  }
+

+
  const getProject = async (id: string, peer?: string) => {
+
    const project = await api.project.getById(id);
+
    const peers = await api.project.getAllRemotes(id);
+
    let branches = project.head
+
      ? { [project.defaultBranch]: project.head }
+
      : {};
+
    if (peer) {
+
      try {
+
        branches = (await api.project.getRemoteByPeer(id, peer)).heads;
+
      } catch {
+
        branches = {};
+
      }
+
    }

-
  const getProject = async (id: string, seed: string, peer?: string) => {
-
    const project = await proj.Project.get(id, seed, peer);
    if (activeRoute.params.route) {
-
      const { revision, path } = proj.parseRoute(
-
        activeRoute.params.route,
-
        project.branches,
-
      );
+
      const { revision, path } = parseRoute(activeRoute.params.route, branches);
      router.updateProjectRoute(
        {
          revision,
@@ -61,15 +101,30 @@
      activeRoute.params.revision = project.defaultBranch;
    }

-
    return project;
+
    return { project, branches, peers };
  };

+
  async function getRoot(
+
    revision: string | null,
+
    branches: Record<string, string>,
+
    head: string,
+
  ): Promise<{ tree: Tree; commit: string }> {
+
    const commit = revision ? utils.getOid(revision, branches) : head;
+

+
    if (!commit) {
+
      throw new Error(`Revision ${revision} not found`);
+
    }
+
    const tree = await api.project.getTree(id, commit);
+

+
    return { tree, commit };
+
  }
+

  function handleIssueCreation({ detail: issueId }: CustomEvent<string>) {
    router.push({
      resource: "projects",
      params: {
        id,
-
        seed,
+
        hostnamePort: baseUrl.hostname,
        view: {
          resource: "issue",
          params: { issue: issueId },
@@ -77,15 +132,15 @@
      },
    });
    // This assignment allows us to have an up-to-date issue count
-
    projectPromise = getProject(id, seed, peer);
+
    projectPromise = getProject(id, peer);
  }

  function handleIssueUpdate() {
-
    projectPromise = getProject(id, seed, peer);
+
    projectPromise = getProject(id, peer);
  }

  // React to peer changes
-
  $: projectPromise = getProject(id, seed, peer);
+
  $: projectPromise = getProject(id, peer);

  // Content can be altered in child components.
  $: revision = activeRoute.params.revision || null;
@@ -125,28 +180,38 @@
      <Loading center />
    </header>
  </main>
-
{:then project}
+
{:then { project, peers, branches }}
  <main>
    <ProjectMeta {project} nodeId={peer} />
-
    {#await project.getRoot(revision)}
+
    {#await getRoot(revision, branches, project.head)}
      <Loading center />
    {:then { tree, commit }}
-
      <Header {tree} {commit} {project} {activeRoute} />
+
      <Header
+
        {tree}
+
        {commit}
+
        {project}
+
        {branches}
+
        {peers}
+
        {activeRoute}
+
        {baseUrl} />

      {#if activeRoute.params.view.resource === "tree"}
-
        <Browser {project} {commit} {tree} {activeRoute} />
+
        <Browser {baseUrl} {project} {commit} {tree} {activeRoute} />
      {:else if activeRoute.params.view.resource === "history"}
-
        {#await proj.Project.getCommits( project.id, project.seed.addr, { parent: commit, verified: true }, )}
+
        {#await api.project.getAllCommits(project.id, { parent: commit })}
          <Loading center />
        {:then history}
-
          <History {project} {history} />
+
          <History
+
            id={project.id}
+
            {baseUrl}
+
            history={history.commits.map(c => c.commit)} />
        {:catch e}
          <div class="message">
            <Message error>{e.message}</Message>
          </div>
        {/await}
      {:else if activeRoute.params.view.resource === "commits"}
-
        {#await project.getCommit(commit)}
+
        {#await api.project.getCommitBySha(id, commit)}
          <Loading center />
        {:then commit}
          <Commit {commit} />
@@ -160,7 +225,9 @@
          <NewIssue
            on:create={handleIssueCreation}
            session={$sessionStore}
-
            {project} />
+
            projectId={project.id}
+
            projectHead={project.head}
+
            {baseUrl} />
        {:else}
          <div class="message">
            <Message error>
@@ -170,41 +237,57 @@
          </div>
        {/if}
      {:else if activeRoute.params.view.resource === "issues"}
-
        {#await issue.Issue.getIssues(project.id, project.seed.addr)}
+
        {#await api.project.getAllIssues(project.id)}
          <Loading center />
        {:then issues}
-
          <Issues {project} status={issueFilter} {issues} />
+
          <Issues
+
            {baseUrl}
+
            issueCounters={project.issues}
+
            status={issueFilter}
+
            {issues} />
        {:catch e}
          <div class="message">
            <Message error>{e.message}</Message>
          </div>
        {/await}
      {:else if activeRoute.params.view.resource === "issue"}
-
        {#await issue.Issue.getIssue(project.id, activeRoute.params.view.params.issue, project.seed.addr)}
+
        {#await api.project.getIssueById(project.id, activeRoute.params.view.params.issue)}
          <Loading center />
        {:then issue}
-
          <Issue on:update={handleIssueUpdate} {project} {issue} />
+
          <Issue
+
            on:update={handleIssueUpdate}
+
            projectId={project.id}
+
            projectHead={project.head}
+
            {baseUrl}
+
            {issue} />
        {:catch e}
          <div class="message">
            <Message error>{e.message}</Message>
          </div>
        {/await}
      {:else if activeRoute.params.view.resource === "patches"}
-
        {#await patch.Patch.getPatches(project.id, project.seed.addr)}
+
        {#await api.project.getAllPatches(project.id)}
          <Loading center />
        {:then patches}
-
          <Patches {patches} status={patchFilter} {project} />
+
          <Patches
+
            {patches}
+
            status={patchFilter}
+
            projectId={project.id}
+
            {baseUrl}
+
            projectPatches={project.patches} />
        {:catch e}
          <div class="message">
            <Message error>{e.message}</Message>
          </div>
        {/await}
      {:else if activeRoute.params.view.resource === "patch"}
-
        {#await patch.Patch.getPatch(project.id, activeRoute.params.view.params.patch, project.seed.addr)}
+
        {#await api.project.getPatchById(project.id, activeRoute.params.view.params.patch)}
          <Loading center />
        {:then patch}
          <Patch
-
            {project}
+
            {baseUrl}
+
            projectId={project.id}
+
            projectHead={project.head}
            revision={activeRoute.params.view.params.revision}
            currentTab={patchTabFilter}
            {patch} />
modified src/views/projects/Widget.svelte
@@ -1,18 +1,19 @@
<script lang="ts">
-
  import type * as proj from "@app/lib/project";
+
  import type { BaseUrl, Project } from "@httpd-client";
+

  import Diagram from "@app/views/projects/Diagram.svelte";
-
  import { groupCommitsByWeek } from "@app/lib/commit";
-
  import type { Host } from "@app/lib/api";
-
  import { Project } from "@app/lib/project";
+
  import { HttpdClient } from "@httpd-client";
  import { formatCommit, twemoji } from "@app/lib/utils";
+
  import { groupCommitsByWeek } from "@app/lib/commit";

-
  export let project: proj.ProjectInfo;
-
  export let seed: { addr: Host };
+
  export let project: Project;
+
  export let baseUrl: BaseUrl;
  export let faded = false;
  export let compact = false;

  const loadCommits = async () => {
-
    const commits = await Project.getActivity(project.id, seed.addr);
+
    const api = new HttpdClient(baseUrl);
+
    const commits = await api.project.getActivity(project.id);

    return groupCommitsByWeek(commits.activity);
  };
modified src/views/seeds/View.svelte
@@ -1,29 +1,29 @@
<script lang="ts">
-
  import type { ProjectInfo } from "@app/lib/project";
-
  import type { Stats } from "@app/lib/seed";
+
  import type { Project, NodeStats } from "@httpd-client";

-
  import { Project } from "@app/lib/project";
-
  import { Seed } from "@app/lib/seed";
-
  import { formatSeedHost, extractHost } from "@app/lib/utils";
+
  import { config } from "@app/lib/config";
+
  import { HttpdClient } from "@httpd-client";
+
  import { extractBaseUrl, isLocal, truncateId } from "@app/lib/utils";

+
  import Clipboard from "@app/components/Clipboard.svelte";
  import Loading from "@app/components/Loading.svelte";
  import NotFound from "@app/components/NotFound.svelte";
  import Projects from "@app/views/seeds/View/Projects.svelte";
-
  import SeedAddress from "@app/views/seeds/View/SeedAddress.svelte";

-
  export let hostAndPort: string;
+
  export let hostnamePort: string;

-
  $: seedHost = extractHost(hostAndPort);
-
  $: hostName = formatSeedHost(seedHost.host);
+
  const baseUrl = extractBaseUrl(hostnamePort);
+
  const hostName = isLocal(baseUrl.hostname)
+
    ? "radicle.local"
+
    : baseUrl.hostname;
+
  const api = new HttpdClient(baseUrl);

-
  const getProjectsAndStats = async (
-
    seed: Seed,
-
  ): Promise<{
-
    stats: Stats;
-
    projects: ProjectInfo[];
+
  const getProjectsAndStats = async (): Promise<{
+
    stats: NodeStats;
+
    projects: Project[];
  }> => {
-
    const stats = await seed.getStats();
-
    const projects = await Project.getProjects(seed.addr, { perPage: 10 });
+
    const stats = await api.getStats();
+
    const projects = await api.project.getAll({ page: 0, perPage: 10 });
    return { stats, projects };
  };
</script>
@@ -56,6 +56,18 @@
    display: flex;
    align-items: center;
  }
+
  .seed-wrapper {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.2rem;
+
  }
+
  .seed-address {
+
    display: inline-flex;
+
    font-size: var(--font-size-regular);
+
    line-height: 2rem;
+
    color: var(--color-foreground-6);
+
    vertical-align: middle;
+
  }

  @media (max-width: 720px) {
    main {
@@ -72,11 +84,11 @@
  <title>{hostName}</title>
</svelte:head>

-
{#await Seed.lookup(seedHost)}
+
{#await api.getRoot()}
  <main class="layout-centered">
    <Loading center />
  </main>
-
{:then seed}
+
{:then nodeInfo}
  <main>
    <header>
      <span class="title txt-title">
@@ -89,18 +101,25 @@
    <div class="fields">
      <!-- Seed Address -->
      <div class="txt-highlight">Address</div>
-
      <SeedAddress {seed} port={seed.node.port} />
+
      <div class="seed-wrapper">
+
        <div class="seed-address">
+
          {truncateId(nodeInfo.node.id)}@{baseUrl.hostname}
+
        </div>
+
        <Clipboard
+
          small
+
          text={`${nodeInfo.node.id}@${baseUrl.hostname}:${config.seeds.defaultNodePort}`} />
+
      </div>
      <div class="layout-desktop" />
      <!-- API Version -->
      <div class="txt-highlight">Version</div>
-
      <div>{seed.version}</div>
+
      <div>{nodeInfo.version}</div>
      <div class="layout-desktop" />
    </div>
    <!-- Seed Projects -->
-
    {#await getProjectsAndStats(seed)}
+
    {#await getProjectsAndStats()}
      <Loading center />
    {:then result}
-
      <Projects {seed} projects={result.projects} stats={result.stats} />
+
      <Projects {baseUrl} projects={result.projects} stats={result.stats} />
    {:catch err}
      <div class="error txt-tiny">
        <div>
@@ -113,7 +132,7 @@
{:catch}
  <div class="layout-centered">
    <NotFound
-
      title={seedHost.host}
+
      title={baseUrl.hostname}
      subtitle="Not able to query information from this seed." />
  </div>
{/await}
modified src/views/seeds/View/Projects.svelte
@@ -1,27 +1,28 @@
<script lang="ts">
-
  import type { ProjectInfo } from "@app/lib/project";
-
  import type { Seed, Stats } from "@app/lib/seed";
+
  import type { BaseUrl, Project, NodeStats } from "@httpd-client";

-
  import * as proj from "@app/lib/project";
  import * as router from "@app/lib/router";
  import List from "@app/components/List.svelte";
  import Widget from "@app/views/projects/Widget.svelte";
+
  import { HttpdClient } from "@httpd-client";
  import { config } from "@app/lib/config";

-
  export let seed: Seed;
-
  export let projects: proj.ProjectInfo[];
-
  export let stats: Stats;
+
  export let baseUrl: BaseUrl;
+
  export let projects: Project[];
+
  export let stats: NodeStats;

+
  const api = new HttpdClient(baseUrl);
  // A pointer to the current page of projects added to the listing
  let page = 0;

-
  const fetchMoreProjects = async (): Promise<proj.ProjectInfo[]> => {
+
  const fetchMoreProjects = async (): Promise<Project[]> => {
    try {
-
      stats = await seed.getStats();
-
      const projects = await proj.Project.getProjects(seed.addr, {
-
        perPage: 10,
+
      stats = await api.getStats();
+
      const projects = await api.project.getAll({
        page: (page += 1),
+
        perPage: 10,
      });
+

      if (projects.length > 0) {
        return projects;
      }
@@ -34,16 +35,16 @@
    return [];
  };

-
  const onClick = (project: ProjectInfo) => {
+
  const onClick = (project: Project) => {
    router.push({
      resource: "projects",
      params: {
        view: { resource: "tree" },
        id: project.id,
-
        seed:
-
          seed.addr.port === config.seeds.defaultHttpdPort
-
            ? seed.addr.host
-
            : `${seed.addr.host}:${seed.addr.port}`,
+
        hostnamePort:
+
          baseUrl.port === config.seeds.defaultHttpdPort
+
            ? baseUrl.hostname
+
            : `${baseUrl.hostname}:${baseUrl.port}`,
        revision: undefined,
        hash: undefined,
        search: undefined,
@@ -70,7 +71,7 @@
      {#each items as project}
        {#if project.head}
          <div class="project">
-
            <Widget {project} {seed} on:click={() => onClick(project)} />
+
            <Widget {project} {baseUrl} on:click={() => onClick(project)} />
          </div>
        {/if}
      {/each}
deleted src/views/seeds/View/SeedAddress.svelte
@@ -1,38 +0,0 @@
-
<script lang="ts">
-
  import type { Seed } from "@app/lib/seed";
-

-
  import Clipboard from "@app/components/Clipboard.svelte";
-
  import { formatSeedAddress, truncateId } from "@app/lib/utils";
-
  import { config } from "@app/lib/config";
-

-
  export let seed: Seed;
-
  export let port: number;
-
</script>
-

-
<style>
-
  .wrapper {
-
    display: flex;
-
    align-items: center;
-
    gap: 0.2rem;
-
  }
-
  .seed-address {
-
    display: inline-flex;
-
    font-size: var(--font-size-regular);
-
    line-height: 2rem;
-
    color: var(--color-foreground-6);
-
    vertical-align: middle;
-
  }
-
  .seed-address > * {
-
    vertical-align: middle;
-
  }
-
</style>
-

-
<div class="wrapper">
-
  <div class="seed-address">
-
    {truncateId(seed.id)}@{seed.host}
-
    {#if port !== config.seeds.defaultNodePort}
-
      <span class="txt-faded">:{port}</span>
-
    {/if}
-
  </div>
-
  <Clipboard small text={formatSeedAddress(seed.id, seed.host, port)} />
-
</div>
modified src/views/session/Index.svelte
@@ -14,11 +14,14 @@
  export let activeRoute: Extract<Route, { resource: "session" }>;

  onMount(async () => {
-
    const status = await session.authenticate(activeRoute.params);
+
    const isAuthenticated = await session.authenticate(activeRoute.params);

-
    if (status === "success") {
+
    if (isAuthenticated) {
      modal.show({ component: AuthenticatedModal, props: {} });
-
      router.push({ resource: "seeds", params: { host: "radicle.local" } });
+
      router.push({
+
        resource: "seeds",
+
        params: { hostnamePort: "radicle.local" },
+
      });
    } else {
      modal.show({
        component: AuthenticationErrorModal,
modified tests/e2e/hotkeys.spec.ts
@@ -1,6 +1,6 @@
import { test, expect } from "@tests/support/fixtures.js";

-
const searchPlaceholder = "Search a name…";
+
const searchPlaceholder = "Search a RID…";

test("global hotkeys", async ({ page }) => {
  await page.goto("/");
modified tests/e2e/search.spec.ts
@@ -7,7 +7,7 @@ import {

test("navigate to existing project", async ({ page }) => {
  await page.goto("/");
-
  const searchInput = page.getByPlaceholder("Search a name…");
+
  const searchInput = page.getByPlaceholder("Search a RID…");
  await searchInput.click();
  await searchInput.fill(`${rid}`);
  await searchInput.press("Enter");
@@ -18,7 +18,7 @@ test("navigate to existing project", async ({ page }) => {

test("navigate to a project that does not exist", async ({ page }) => {
  await page.goto("/");
-
  const searchInput = page.getByPlaceholder("Search a name…");
+
  const searchInput = page.getByPlaceholder("Search a RID…");
  await searchInput.click();

  const nonExistantId = "rad:zAAAAAAAAAAAAAAAAAAAAAAAAAAA";
modified tests/e2e/seed.spec.ts
@@ -3,7 +3,6 @@ import {
  expect,
  rid,
  seedRemote,
-
  seedVersion,
  test,
} from "@tests/support/fixtures.js";

@@ -15,7 +14,7 @@ test("seed metadata", async ({ page }) => {
  await expect(
    page.locator(`text=${seedRemote.substring(0, 6)}…${seedRemote.slice(-6)}`),
  ).toBeVisible();
-
  await expect(page.locator(`text=${seedVersion}`)).toBeVisible();
+
  await expect(page.locator(`text=0.1.0-`)).toBeVisible();
});

test("seed projects", async ({ page }) => {
modified tests/support/fixtures.ts
@@ -52,7 +52,15 @@ export const test = base.extend<{
              defaultHttpdPort: 8080,
              defaultHttpdScheme: "http",
              defaultNodePort: 8776,
-
              pinned: [{ host: "127.0.0.1" }],
+
              pinned: [
+
                {
+
                  baseUrl: {
+
                    hostname: "127.0.0.1",
+
                    port: 8080,
+
                    scheme: "http",
+
                  },
+
                },
+
              ],
            },
            projects: { pinned: [] },
          };
@@ -134,14 +142,26 @@ export function configFixture() {
      defaultHttpdPort: 8080,
      defaultHttpdScheme: "http",
      defaultNodePort: 8776,
-
      pinned: [{ host: "127.0.0.1" }],
+
      pinned: [
+
        {
+
          baseUrl: {
+
            hostname: "127.0.0.1",
+
            port: 8080,
+
            scheme: "http",
+
          },
+
        },
+
      ],
    },
    projects: {
      pinned: [
        {
          name: "source-browsing",
          id: "rad:git:hnrkdi8be7n4hhqoz9rpzrgd68u9dr3zsxgmy",
-
          seed: "127.0.0.1",
+
          baseUrl: {
+
            hostname: "127.0.0.1",
+
            port: 8080,
+
            scheme: "http",
+
          },
        },
      ],
    },
@@ -155,14 +175,26 @@ export function appConfigWithFixture() {
      defaultHttpdPort: 8080,
      defaultHttpdScheme: "http",
      defaultNodePort: 8776,
-
      pinned: [{ host: "127.0.0.1" }],
+
      pinned: [
+
        {
+
          baseUrl: {
+
            hostname: "127.0.0.1",
+
            port: 8080,
+
            scheme: "http",
+
          },
+
        },
+
      ],
    },
    projects: {
      pinned: [
        {
          name: "source-browsing",
          id: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
-
          seed: "127.0.0.1",
+
          baseUrl: {
+
            hostname: "127.0.0.1",
+
            port: 8080,
+
            scheme: "http",
+
          },
        },
      ],
    },
@@ -177,5 +209,4 @@ export const bobRemote =
export const rid = "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT";
export const projectFixtureUrl = `/seeds/127.0.0.1/${rid}`;
export const seedPort = 8080;
-
export const seedVersion = "0.1.0-6463768";
export const seedRemote = "z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8";
deleted tests/unit/cobs.test.ts
@@ -1,26 +0,0 @@
-
import { describe, expect, test } from "vitest";
-
import * as issue from "@app/lib/issue";
-

-
describe("Issues", () => {
-
  test.each([
-
    {
-
      currentArray: ["a", "b"],
-
      newArray: ["a", "b", "c"],
-
      expected: { add: ["c"], remove: [] },
-
    },
-
    {
-
      currentArray: ["a", "b"],
-
      newArray: ["c"],
-
      expected: { add: ["c"], remove: ["a", "b"] },
-
    },
-
    { currentArray: [], newArray: ["c"], expected: { add: ["c"], remove: [] } },
-
    { currentArray: ["a"], newArray: ["a"], expected: { add: [], remove: [] } },
-
  ])(
-
    "createAssigneeArrays $hash => $expected",
-
    ({ currentArray, newArray, expected }) => {
-
      expect(issue.createAddRemoveArrays(currentArray, newArray)).toEqual(
-
        expected,
-
      );
-
    },
-
  );
-
});
modified tests/unit/router.test.ts
@@ -9,7 +9,10 @@ describe("routeToPath", () => {
  test.each([
    { input: { resource: "home" }, output: "/", description: "Home Route" },
    {
-
      input: { resource: "seeds", params: { host: "willow.radicle.garden" } },
+
      input: {
+
        resource: "seeds",
+
        params: { hostnamePort: "willow.radicle.garden" },
+
      },
      output: "/seeds/willow.radicle.garden",
      description: "Seed View Route",
    },
@@ -18,7 +21,7 @@ describe("routeToPath", () => {
        resource: "projects",
        params: {
          view: { resource: "tree" },
-
          seed: "willow.radicle.garden",
+
          hostnamePort: "willow.radicle.garden",
          id: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
        },
      },
@@ -41,7 +44,10 @@ describe("pathToRoute", () => {
    { input: "/", output: { resource: "home" }, description: "Home Route" },
    {
      input: "/seeds/willow.radicle.garden",
-
      output: { resource: "seeds", params: { host: "willow.radicle.garden" } },
+
      output: {
+
        resource: "seeds",
+
        params: { hostnamePort: "willow.radicle.garden" },
+
      },
      description: "Seed View Route",
    },
    {
@@ -50,7 +56,7 @@ describe("pathToRoute", () => {
        resource: "projects",
        params: {
          view: { resource: "tree" },
-
          seed: "willow.radicle.garden",
+
          hostnamePort: "willow.radicle.garden",
          profile: undefined,
          peer: undefined,
          id: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
@@ -70,7 +76,7 @@ describe("pathToRoute", () => {
        resource: "projects",
        params: {
          view: { resource: "tree" },
-
          seed: "willow.radicle.garden",
+
          hostnamePort: "willow.radicle.garden",
          profile: undefined,
          peer: undefined,
          id: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
modified tests/unit/utils.test.ts
@@ -272,3 +272,27 @@ describe("Date Manipulation", () => {
    );
  });
});
+

+
describe("createAddRemoveArrays", () => {
+
  test.each([
+
    {
+
      currentArray: ["a", "b"],
+
      newArray: ["a", "b", "c"],
+
      expected: { add: ["c"], remove: [] },
+
    },
+
    {
+
      currentArray: ["a", "b"],
+
      newArray: ["c"],
+
      expected: { add: ["c"], remove: ["a", "b"] },
+
    },
+
    { currentArray: [], newArray: ["c"], expected: { add: ["c"], remove: [] } },
+
    { currentArray: ["a"], newArray: ["a"], expected: { add: [], remove: [] } },
+
  ])(
+
    "createAssigneeArrays $hash => $expected",
+
    ({ currentArray, newArray, expected }) => {
+
      expect(utils.createAddRemoveArrays(currentArray, newArray)).toEqual(
+
        expected,
+
      );
+
    },
+
  );
+
});
modified tsconfig.json
@@ -1,6 +1,6 @@
{
  "extends": "@tsconfig/svelte/tsconfig.json",
-
  "include": ["src", "tests"],
+
  "include": ["src", "tests", "httpd-client"],
  "exclude": ["node_modules/*"],
  "compilerOptions": {
    "target": "es2020",
@@ -17,6 +17,8 @@
    "skipLibCheck": true,
    "paths": {
      "@app/*": ["./src/*"],
+
      "@httpd-client": ["./httpd-client/index.ts"],
+
      "@httpd-client/*": ["./httpd-client/*"],
      "@public/*": ["./public/*"],
      "@tests/*": ["./tests/*"]
    }
modified vite.config.ts
@@ -32,6 +32,7 @@ export default defineConfig({
    alias: {
      "@app": path.resolve("./src"),
      "@public": path.resolve("./public"),
+
      "@httpd-client": path.resolve("./httpd-client"),
    },
  },
  build: {