Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
Big rename from projects to repos with a project payload
Open did:key:z6MkkfM3...sVz5 opened 1 year ago
169 files changed +12455 -12425 06cc5595 6c2d1d75
modified http-client/index.ts
@@ -2,18 +2,19 @@ import type { BaseUrl } from "./lib/fetcher.js";
import type {
  Blob,
  DiffResponse,
-
  Project,
-
  ProjectListQuery,
  Remote,
+
  Repo,
+
  RepoListQuery,
  Tree,
  TreeStats,
-
} from "./lib/project.js";
+
} from "./lib/repo.js";
import type {
+
  Author,
  Config,
  SeedingPolicy,
  DefaultSeedingPolicy,
} from "./lib/shared.js";
-
import type { Comment, Embed, Reaction } from "./lib/project/comment.js";
+
import type { Comment, Embed, Reaction } from "./lib/repo/comment.js";
import type {
  Commit,
  CommitBlob,
@@ -25,8 +26,8 @@ import type {
  DiffContent,
  DiffFile,
  HunkLine,
-
} from "./lib/project/commit.js";
-
import type { Issue, IssueState } from "./lib/project/issue.js";
+
} from "./lib/repo/commit.js";
+
import type { Issue, IssueState } from "./lib/repo/issue.js";
import type {
  LifecycleState,
  Merge,
@@ -35,13 +36,13 @@ import type {
  Review,
  Revision,
  Verdict,
-
} from "./lib/project/patch.js";
+
} from "./lib/repo/patch.js";
import type { RequestOptions } from "./lib/fetcher.js";
import type { ZodSchema } from "zod";

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

-
import * as project from "./lib/project.js";
+
import * as repo from "./lib/repo.js";
import { Fetcher } from "./lib/fetcher.js";
import {
  nodeConfigSchema,
@@ -50,6 +51,7 @@ import {
} from "./lib/shared.js";

export type {
+
  Author,
  BaseUrl,
  Blob,
  ChangesetWithDiff,
@@ -73,15 +75,15 @@ export type {
  Merge,
  Patch,
  PatchState,
-
  Project,
-
  ProjectListQuery,
  Reaction,
  Remote,
+
  Repo,
+
  RepoListQuery,
  Review,
  Revision,
  SeedingPolicy,
-
  TreeStats,
  Tree,
+
  TreeStats,
  Verdict,
};

@@ -156,13 +158,13 @@ export class HttpdClient {
  #fetcher: Fetcher;

  public baseUrl: BaseUrl;
-
  public project: project.Client;
+
  public repo: repo.Client;

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

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

  public changePort(port: number): void {
@@ -213,14 +215,14 @@ export class HttpdClient {
    );
  }

-
  public async getPolicyById(
-
    id: string,
+
  public async getPolicyByRid(
+
    rid: string,
    options?: RequestOptions,
  ): Promise<SeedingPolicy> {
    return this.#fetcher.fetchOk(
      {
        method: "GET",
-
        path: `node/policies/repos/${id}`,
+
        path: `node/policies/repos/${rid}`,
        options,
      },
      seedingPolicySchema,
@@ -239,13 +241,13 @@ export class HttpdClient {
  }

  public async getNodeIdentity(
-
    id: string,
+
    nid: string,
    options?: RequestOptions,
  ): Promise<NodeIdentity> {
    return this.#fetcher.fetchOk(
      {
        method: "GET",
-
        path: `nodes/${id}`,
+
        path: `nodes/${nid}`,
        options,
      },
      nodeIdentitySchema,
@@ -253,13 +255,13 @@ export class HttpdClient {
  }

  public async getNodeInventory(
-
    id: string,
+
    nid: string,
    options?: RequestOptions,
  ): Promise<string[]> {
    return this.#fetcher.fetchOk(
      {
        method: "GET",
-
        path: `nodes/${id}/inventory`,
+
        path: `nodes/${nid}/inventory`,
        options,
      },
      array(string()),
deleted http-client/lib/project.ts
@@ -1,403 +0,0 @@
-
import type { ZodSchema } from "zod";
-
import type { Fetcher, RequestOptions } from "./fetcher.js";
-
import type { Commit, Commits } from "./project/commit.js";
-
import type { Issue } from "./project/issue.js";
-
import type { Patch } from "./project/patch.js";
-

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

-
import {
-
  commitHeaderSchema,
-
  commitSchema,
-
  commitsSchema,
-
  diffBlobSchema,
-
  diffSchema,
-
} from "./project/commit.js";
-
import { issueSchema, issuesSchema } from "./project/issue.js";
-
import { patchSchema, patchesSchema } from "./project/patch.js";
-

-
const projectSchema = object({
-
  id: string(),
-
  name: string(),
-
  description: string(),
-
  defaultBranch: string(),
-
  delegates: array(object({ id: string(), alias: optional(string()) })),
-
  head: string(),
-
  threshold: number(),
-
  visibility: union([
-
    object({ type: literal("public") }),
-
    object({ type: literal("private"), allow: optional(array(string())) }),
-
  ]).optional(),
-
  patches: object({
-
    open: number(),
-
    draft: number(),
-
    archived: number(),
-
    merged: number(),
-
  }),
-
  issues: object({
-
    open: number(),
-
    closed: number(),
-
  }),
-
  seeding: number(),
-
});
-
const projectsSchema = array(projectSchema);
-

-
export type Project = z.infer<typeof projectSchema>;
-

-
const activitySchema = object({
-
  activity: array(number()),
-
});
-

-
export type Activity = z.infer<typeof activitySchema>;
-

-
const blobSchema = object({
-
  binary: boolean(),
-
  content: optional(string()),
-
  name: string(),
-
  path: string(),
-
  lastCommit: commitHeaderSchema,
-
});
-

-
export type Blob = z.infer<typeof blobSchema>;
-

-
const treeEntrySchema = object({
-
  path: string(),
-
  name: string(),
-
  oid: string(),
-
  kind: union([literal("blob"), literal("tree"), literal("submodule")]),
-
});
-

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

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

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

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

-
const treeSchema = object({
-
  entries: array(treeEntrySchema),
-
  lastCommit: commitHeaderSchema,
-
  name: string(),
-
  path: string(),
-
});
-

-
export type Remote = z.infer<typeof remoteSchema>;
-

-
export const remoteSchema = object({
-
  id: string(),
-
  alias: string().optional(),
-
  heads: record(string(), string()),
-
  delegate: boolean(),
-
});
-

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

-
export type DiffResponse = z.infer<typeof diffResponseSchema>;
-

-
const diffResponseSchema = object({
-
  commits: array(commitHeaderSchema),
-
  diff: diffSchema,
-
  files: record(string(), diffBlobSchema),
-
});
-

-
export type ProjectListQuery = {
-
  page?: number;
-
  perPage?: number;
-
  show?: "pinned" | "all";
-
};
-
export class Client {
-
  #fetcher: Fetcher;
-

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

-
  public async getByDelegate(
-
    delegateId: string,
-
    query?: ProjectListQuery,
-
    options?: RequestOptions,
-
  ): Promise<Project[]> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "GET",
-
        path: `delegates/${delegateId}/projects`,
-
        query,
-
        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?: ProjectListQuery,
-
    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> {
-
    const blob = await this.#fetcher.fetchOk(
-
      {
-
        method: "GET",
-
        path: `projects/${id}/blob/${sha}/${path}`,
-
        options,
-
      },
-
      blobSchema,
-
    );
-
    return blob;
-
  }
-

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

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

-
  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,
-
    query?: {
-
      page?: number;
-
      perPage?: number;
-
      status?: string;
-
    },
-
    options?: RequestOptions,
-
  ): Promise<Issue[]> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "GET",
-
        path: `projects/${id}/issues`,
-
        query,
-
        options,
-
      },
-
      issuesSchema,
-
    );
-
  }
-

-
  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,
-
    query?: {
-
      page?: number;
-
      perPage?: number;
-
      status?: string;
-
    },
-
    options?: RequestOptions,
-
  ): Promise<Patch[]> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "GET",
-
        path: `projects/${id}/patches`,
-
        query,
-
        options,
-
      },
-
      patchesSchema,
-
    );
-
  }
-
}
deleted http-client/lib/project/comment.ts
@@ -1,27 +0,0 @@
-
import type { z } from "zod";
-
import { array, boolean, number, object, string } from "zod";
-
import { authorSchema, codeLocationSchema } from "../shared";
-

-
export type Comment = z.infer<typeof commentSchema>;
-
export type Embed = Comment["embeds"][0];
-
export type Reaction = Comment["reactions"][0];
-

-
export const commentSchema = object({
-
  id: string(),
-
  author: authorSchema,
-
  body: string(),
-
  edits: array(
-
    object({
-
      author: authorSchema,
-
      body: string(),
-
      embeds: array(object({ name: string(), content: string() })),
-
      timestamp: number(),
-
    }),
-
  ),
-
  embeds: array(object({ name: string(), content: string() })),
-
  reactions: array(object({ emoji: string(), authors: array(authorSchema) })),
-
  timestamp: number(),
-
  location: codeLocationSchema.nullable().optional(),
-
  resolved: boolean(),
-
  replyTo: string().nullable(),
-
});
deleted http-client/lib/project/commit.ts
@@ -1,241 +0,0 @@
-
import type { z } from "zod";
-
export type {
-
  Commit,
-
  CommitHeader,
-
  Commits,
-
  Diff,
-
  DiffContent,
-
  DiffFile,
-
  ChangesetWithDiff,
-
  ChangesetWithoutDiff,
-
  HunkLine,
-
  Hunks,
-
};
-

-
import {
-
  array,
-
  boolean,
-
  discriminatedUnion,
-
  literal,
-
  number,
-
  object,
-
  record,
-
  string,
-
  union,
-
} from "zod";
-
export {
-
  commitBlobSchema,
-
  commitHeaderSchema,
-
  commitSchema,
-
  commitsSchema,
-
  diffBlobSchema,
-
  diffSchema,
-
};
-

-
const gitPersonSchema = object({
-
  name: string(),
-
  email: string(),
-
});
-

-
type CommitHeader = z.infer<typeof commitHeaderSchema>;
-

-
const commitHeaderSchema = object({
-
  id: string(),
-
  author: gitPersonSchema,
-
  summary: string(),
-
  description: string(),
-
  parents: array(string()),
-
  committer: gitPersonSchema.merge(object({ time: number() })),
-
});
-

-
const diffBlobSchema = object({
-
  binary: boolean(),
-
  content: string(),
-
  id: string(),
-
});
-

-
export type DiffBlob = z.infer<typeof diffBlobSchema>;
-

-
const commitBlobSchema = object({
-
  binary: boolean(),
-
  content: string(),
-
});
-

-
export type CommitBlob = z.infer<typeof commitBlobSchema>;
-

-
type AdditionHunkLine = z.infer<typeof additionHunkLineSchema>;
-

-
const additionHunkLineSchema = object({
-
  line: string(),
-
  lineNo: number(),
-
  type: literal("addition"),
-
});
-

-
type DeletionHunkLine = z.infer<typeof deletionHunkLineSchema>;
-

-
const deletionHunkLineSchema = object({
-
  line: string(),
-
  lineNo: number(),
-
  type: literal("deletion"),
-
});
-

-
type DiffFile = z.infer<typeof diffFileSchema>;
-

-
const diffFileSchema = object({
-
  oid: string(),
-
  mode: union([
-
    literal("blob"),
-
    literal("blobExecutable"),
-
    literal("tree"),
-
    literal("link"),
-
    literal("commit"),
-
  ]),
-
});
-

-
type ContextHunkLine = z.infer<typeof contextHunkLineSchema>;
-

-
const contextHunkLineSchema = object({
-
  line: string(),
-
  lineNoNew: number(),
-
  lineNoOld: number(),
-
  type: literal("context"),
-
});
-

-
type HunkLine = AdditionHunkLine | DeletionHunkLine | ContextHunkLine;
-

-
const hunkLineSchema = union([
-
  additionHunkLineSchema,
-
  deletionHunkLineSchema,
-
  contextHunkLineSchema,
-
]);
-

-
type Hunks = z.infer<typeof changesetHunkSchema>;
-

-
const changesetHunkSchema = object({
-
  header: string(),
-
  lines: array(hunkLineSchema),
-
});
-

-
type DiffContent = z.infer<typeof diffContentSchema>;
-

-
const diffContentSchema = discriminatedUnion("type", [
-
  object({
-
    type: literal("plain"),
-
    stats: object({
-
      additions: number(),
-
      deletions: number(),
-
    }),
-
    hunks: array(changesetHunkSchema),
-
    eof: union([
-
      literal("noneMissing"),
-
      literal("oldMissing"),
-
      literal("newMissing"),
-
      literal("bothMissing"),
-
    ]),
-
  }),
-
  object({ type: literal("binary") }),
-
  object({ type: literal("empty") }),
-
]);
-
const diffChangesetSchema = object({
-
  path: string(),
-
  diff: diffContentSchema,
-
});
-

-
const diffAddedChangesetSchema = diffChangesetSchema.merge(
-
  object({ state: literal("added"), new: diffFileSchema }),
-
);
-

-
const diffDeletedChangesetSchema = diffChangesetSchema.merge(
-
  object({ state: literal("deleted"), old: diffFileSchema }),
-
);
-

-
const diffModifiedChangesetSchema = diffChangesetSchema.merge(
-
  object({
-
    state: literal("modified"),
-
    new: diffFileSchema,
-
    old: diffFileSchema,
-
  }),
-
);
-

-
const diffCopiedChangesetSchema = object({
-
  state: literal("copied"),
-
  newPath: string(),
-
  oldPath: string(),
-
});
-

-
const diffCopiedWithModificationsChangesetSchema =
-
  diffCopiedChangesetSchema.merge(
-
    object({
-
      old: diffFileSchema,
-
      new: diffFileSchema,
-
      diff: diffContentSchema,
-
    }),
-
  );
-

-
const diffMovedChangesetSchema = object({
-
  state: literal("moved"),
-
  newPath: string(),
-
  oldPath: string(),
-
  current: diffFileSchema,
-
});
-

-
const diffMovedWithModificationsChangesetSchema = diffMovedChangesetSchema
-
  .omit({ current: true })
-
  .merge(
-
    object({
-
      old: diffFileSchema,
-
      new: diffFileSchema,
-
      diff: diffContentSchema,
-
    }),
-
  );
-

-
type Diff = z.infer<typeof diffSchema>;
-

-
type ChangesetWithDiff = z.infer<typeof changesetWithDiffSchema>;
-
type ChangesetWithoutDiff = z.infer<typeof changesetWithoutDiffSchema>;
-

-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
-
const changesetWithDiffSchema = union([
-
  diffAddedChangesetSchema,
-
  diffDeletedChangesetSchema,
-
  diffModifiedChangesetSchema,
-
  diffMovedWithModificationsChangesetSchema,
-
  diffCopiedWithModificationsChangesetSchema,
-
]);
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
-
const changesetWithoutDiffSchema = union([
-
  diffMovedChangesetSchema,
-
  diffCopiedChangesetSchema,
-
]);
-

-
const diffSchema = object({
-
  files: array(
-
    union([
-
      diffAddedChangesetSchema,
-
      diffDeletedChangesetSchema,
-
      diffModifiedChangesetSchema,
-
      diffMovedChangesetSchema,
-
      diffMovedWithModificationsChangesetSchema,
-
      diffCopiedChangesetSchema,
-
      diffCopiedWithModificationsChangesetSchema,
-
    ]),
-
  ),
-
  stats: object({
-
    filesChanged: number(),
-
    insertions: number(),
-
    deletions: number(),
-
  }),
-
});
-

-
type Commit = z.infer<typeof commitSchema>;
-

-
const commitSchema = object({
-
  commit: commitHeaderSchema,
-
  diff: diffSchema,
-
  branches: array(string()),
-
  files: record(string(), commitBlobSchema),
-
});
-

-
type Commits = z.infer<typeof commitsSchema>;
-

-
const commitsSchema = array(commitHeaderSchema);
deleted http-client/lib/project/issue.ts
@@ -1,31 +0,0 @@
-
import type { ZodSchema, z } from "zod";
-
import { array, literal, object, string, union } from "zod";
-

-
import { commentSchema } from "./comment.js";
-
import { authorSchema } from "../shared.js";
-

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

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

-
export const issueSchema = object({
-
  id: string(),
-
  author: authorSchema,
-
  title: string(),
-
  state: issueStateSchema,
-
  discussion: array(commentSchema),
-
  labels: array(string()),
-
  assignees: array(authorSchema),
-
});
-

-
export type Issue = z.infer<typeof issueSchema>;
-

-
export const issuesSchema = array(issueSchema) satisfies ZodSchema<Issue[]>;
deleted http-client/lib/project/patch.ts
@@ -1,106 +0,0 @@
-
import type { ZodSchema, z } from "zod";
-

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

-
import {
-
  array,
-
  literal,
-
  number,
-
  optional,
-
  object,
-
  string,
-
  tuple,
-
  union,
-
} from "zod";
-
import { authorSchema } from "../shared.js";
-

-
export type PatchState = z.infer<typeof patchStateSchema>;
-

-
const patchStateSchema = union([
-
  object({
-
    status: literal("draft"),
-
  }),
-
  object({
-
    status: literal("open"),
-
    conflicts: array(tuple([string(), string()])).optional(),
-
  }),
-
  object({
-
    status: literal("archived"),
-
  }),
-
  object({
-
    status: literal("merged"),
-
    revision: string(),
-
    commit: string(),
-
  }),
-
]);
-

-
export type Merge = z.infer<typeof mergeSchema>;
-

-
const mergeSchema = object({
-
  author: authorSchema,
-
  revision: string(),
-
  commit: string(),
-
  timestamp: number(),
-
});
-

-
export type Verdict = "accept" | "reject";
-

-
const reviewSchema = object({
-
  author: authorSchema,
-
  id: string(),
-
  verdict: optional(union([literal("accept"), literal("reject")]).nullable()),
-
  comments: array(commentSchema),
-
  summary: string().nullable(),
-
  timestamp: number(),
-
});
-

-
export type Review = z.infer<typeof reviewSchema>;
-

-
const revisionSchema = object({
-
  id: string(),
-
  author: authorSchema,
-
  description: string(),
-
  edits: array(
-
    object({
-
      author: authorSchema,
-
      body: string(),
-
      embeds: array(object({ name: string(), content: string() })),
-
      timestamp: number(),
-
    }),
-
  ),
-
  reactions: array(
-
    object({
-
      emoji: string(),
-
      authors: array(authorSchema),
-
    }),
-
  ),
-
  base: string(),
-
  oid: string(),
-
  refs: array(string()),
-
  discussions: array(commentSchema),
-
  reviews: array(reviewSchema),
-
  timestamp: number(),
-
});
-

-
export type Revision = z.infer<typeof revisionSchema>;
-

-
export const patchSchema = object({
-
  id: string(),
-
  author: authorSchema,
-
  title: string(),
-
  state: patchStateSchema,
-
  target: string(),
-
  labels: array(string()),
-
  merges: array(mergeSchema),
-
  assignees: array(string()),
-
  revisions: array(revisionSchema),
-
});
-

-
export type Patch = z.infer<typeof patchSchema>;
-

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

-
export type LifecycleState =
-
  | { status: "draft" }
-
  | { status: "open" }
-
  | { status: "archived" };
added http-client/lib/repo.ts
@@ -0,0 +1,412 @@
+
import type { ZodSchema } from "zod";
+
import type { Fetcher, RequestOptions } from "./fetcher.js";
+
import type { Commit, Commits } from "./repo/commit.js";
+
import type { Issue } from "./repo/issue.js";
+
import type { Patch } from "./repo/patch.js";
+

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

+
import {
+
  commitHeaderSchema,
+
  commitSchema,
+
  commitsSchema,
+
  diffBlobSchema,
+
  diffSchema,
+
} from "./repo/commit.js";
+
import { issueSchema, issuesSchema } from "./repo/issue.js";
+
import { patchSchema, patchesSchema } from "./repo/patch.js";
+
import { authorSchema } from "./shared.js";
+

+
const repoSchema = object({
+
  rid: string(),
+
  payloads: object({
+
    "xyz.radicle.project": object({
+
      data: object({
+
        name: string(),
+
        description: string(),
+
        defaultBranch: string(),
+
      }),
+
      meta: object({
+
        head: string(),
+
        patches: object({
+
          open: number(),
+
          draft: number(),
+
          archived: number(),
+
          merged: number(),
+
        }),
+
        issues: object({
+
          open: number(),
+
          closed: number(),
+
        }),
+
      }),
+
    }),
+
  }),
+
  delegates: array(authorSchema),
+
  threshold: number(),
+
  visibility: union([
+
    object({ type: literal("public") }),
+
    object({ type: literal("private"), allow: optional(array(string())) }),
+
  ]),
+
  seeding: number(),
+
});
+
const reposSchema = array(repoSchema);
+

+
export type Repo = z.infer<typeof repoSchema>;
+

+
const activitySchema = object({
+
  activity: array(number()),
+
});
+

+
export type Activity = z.infer<typeof activitySchema>;
+

+
const blobSchema = object({
+
  binary: boolean(),
+
  content: optional(string()),
+
  name: string(),
+
  path: string(),
+
  lastCommit: commitHeaderSchema,
+
});
+

+
export type Blob = z.infer<typeof blobSchema>;
+

+
const treeEntrySchema = object({
+
  path: string(),
+
  name: string(),
+
  oid: string(),
+
  kind: union([literal("blob"), literal("tree"), literal("submodule")]),
+
});
+

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

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

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

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

+
const treeSchema = object({
+
  entries: array(treeEntrySchema),
+
  lastCommit: commitHeaderSchema,
+
  name: string(),
+
  path: string(),
+
});
+

+
export type Remote = z.infer<typeof remoteSchema>;
+

+
export const remoteSchema = object({
+
  id: string(),
+
  alias: string().optional(),
+
  heads: record(string(), string()),
+
  delegate: boolean(),
+
});
+

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

+
export type DiffResponse = z.infer<typeof diffResponseSchema>;
+

+
const diffResponseSchema = object({
+
  commits: array(commitHeaderSchema),
+
  diff: diffSchema,
+
  files: record(string(), diffBlobSchema),
+
});
+

+
export type RepoListQuery = {
+
  page?: number;
+
  perPage?: number;
+
  show?: "pinned" | "all";
+
};
+
export class Client {
+
  #fetcher: Fetcher;
+

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

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

+
  public async getByRid(rid: string, options?: RequestOptions): Promise<Repo> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `repos/${rid}`,
+
        options,
+
      },
+
      repoSchema,
+
    );
+
  }
+

+
  public async getAll(
+
    query?: RepoListQuery,
+
    options?: RequestOptions,
+
  ): Promise<Repo[]> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: "repos",
+
        query,
+
        options,
+
      },
+
      reposSchema,
+
    );
+
  }
+

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

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

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

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

+
  public async getTreeStatsBySha(
+
    rid: string,
+
    sha: string,
+
    options?: RequestOptions,
+
  ): Promise<TreeStats> {
+
    const tree = await this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `repos/${rid}/stats/tree/${sha}`,
+
        options,
+
      },
+
      treeStatsSchema,
+
    );
+
    return tree;
+
  }
+

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

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

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

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

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

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

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

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

+
  public async getAllPatches(
+
    rid: string,
+
    query?: {
+
      page?: number;
+
      perPage?: number;
+
      status?: string;
+
    },
+
    options?: RequestOptions,
+
  ): Promise<Patch[]> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `repos/${rid}/patches`,
+
        query,
+
        options,
+
      },
+
      patchesSchema,
+
    );
+
  }
+
}
added http-client/lib/repo/comment.ts
@@ -0,0 +1,27 @@
+
import type { z } from "zod";
+
import { array, boolean, number, object, string } from "zod";
+
import { authorSchema, codeLocationSchema } from "../shared";
+

+
export type Comment = z.infer<typeof commentSchema>;
+
export type Embed = Comment["embeds"][0];
+
export type Reaction = Comment["reactions"][0];
+

+
export const commentSchema = object({
+
  id: string(),
+
  author: authorSchema,
+
  body: string(),
+
  edits: array(
+
    object({
+
      author: authorSchema,
+
      body: string(),
+
      embeds: array(object({ name: string(), content: string() })),
+
      timestamp: number(),
+
    }),
+
  ),
+
  embeds: array(object({ name: string(), content: string() })),
+
  reactions: array(object({ emoji: string(), authors: array(authorSchema) })),
+
  timestamp: number(),
+
  location: codeLocationSchema.nullable().optional(),
+
  resolved: boolean(),
+
  replyTo: string().nullable(),
+
});
added http-client/lib/repo/commit.ts
@@ -0,0 +1,241 @@
+
import type { z } from "zod";
+
export type {
+
  Commit,
+
  CommitHeader,
+
  Commits,
+
  Diff,
+
  DiffContent,
+
  DiffFile,
+
  ChangesetWithDiff,
+
  ChangesetWithoutDiff,
+
  HunkLine,
+
  Hunks,
+
};
+

+
import {
+
  array,
+
  boolean,
+
  discriminatedUnion,
+
  literal,
+
  number,
+
  object,
+
  record,
+
  string,
+
  union,
+
} from "zod";
+
export {
+
  commitBlobSchema,
+
  commitHeaderSchema,
+
  commitSchema,
+
  commitsSchema,
+
  diffBlobSchema,
+
  diffSchema,
+
};
+

+
const gitPersonSchema = object({
+
  name: string(),
+
  email: string(),
+
});
+

+
type CommitHeader = z.infer<typeof commitHeaderSchema>;
+

+
const commitHeaderSchema = object({
+
  id: string(),
+
  author: gitPersonSchema,
+
  summary: string(),
+
  description: string(),
+
  parents: array(string()),
+
  committer: gitPersonSchema.merge(object({ time: number() })),
+
});
+

+
const diffBlobSchema = object({
+
  binary: boolean(),
+
  content: string(),
+
  id: string(),
+
});
+

+
export type DiffBlob = z.infer<typeof diffBlobSchema>;
+

+
const commitBlobSchema = object({
+
  binary: boolean(),
+
  content: string(),
+
});
+

+
export type CommitBlob = z.infer<typeof commitBlobSchema>;
+

+
type AdditionHunkLine = z.infer<typeof additionHunkLineSchema>;
+

+
const additionHunkLineSchema = object({
+
  line: string(),
+
  lineNo: number(),
+
  type: literal("addition"),
+
});
+

+
type DeletionHunkLine = z.infer<typeof deletionHunkLineSchema>;
+

+
const deletionHunkLineSchema = object({
+
  line: string(),
+
  lineNo: number(),
+
  type: literal("deletion"),
+
});
+

+
type DiffFile = z.infer<typeof diffFileSchema>;
+

+
const diffFileSchema = object({
+
  oid: string(),
+
  mode: union([
+
    literal("blob"),
+
    literal("blobExecutable"),
+
    literal("tree"),
+
    literal("link"),
+
    literal("commit"),
+
  ]),
+
});
+

+
type ContextHunkLine = z.infer<typeof contextHunkLineSchema>;
+

+
const contextHunkLineSchema = object({
+
  line: string(),
+
  lineNoNew: number(),
+
  lineNoOld: number(),
+
  type: literal("context"),
+
});
+

+
type HunkLine = AdditionHunkLine | DeletionHunkLine | ContextHunkLine;
+

+
const hunkLineSchema = union([
+
  additionHunkLineSchema,
+
  deletionHunkLineSchema,
+
  contextHunkLineSchema,
+
]);
+

+
type Hunks = z.infer<typeof changesetHunkSchema>;
+

+
const changesetHunkSchema = object({
+
  header: string(),
+
  lines: array(hunkLineSchema),
+
});
+

+
type DiffContent = z.infer<typeof diffContentSchema>;
+

+
const diffContentSchema = discriminatedUnion("type", [
+
  object({
+
    type: literal("plain"),
+
    stats: object({
+
      additions: number(),
+
      deletions: number(),
+
    }),
+
    hunks: array(changesetHunkSchema),
+
    eof: union([
+
      literal("noneMissing"),
+
      literal("oldMissing"),
+
      literal("newMissing"),
+
      literal("bothMissing"),
+
    ]),
+
  }),
+
  object({ type: literal("binary") }),
+
  object({ type: literal("empty") }),
+
]);
+
const diffChangesetSchema = object({
+
  path: string(),
+
  diff: diffContentSchema,
+
});
+

+
const diffAddedChangesetSchema = diffChangesetSchema.merge(
+
  object({ state: literal("added"), new: diffFileSchema }),
+
);
+

+
const diffDeletedChangesetSchema = diffChangesetSchema.merge(
+
  object({ state: literal("deleted"), old: diffFileSchema }),
+
);
+

+
const diffModifiedChangesetSchema = diffChangesetSchema.merge(
+
  object({
+
    state: literal("modified"),
+
    new: diffFileSchema,
+
    old: diffFileSchema,
+
  }),
+
);
+

+
const diffCopiedChangesetSchema = object({
+
  state: literal("copied"),
+
  newPath: string(),
+
  oldPath: string(),
+
});
+

+
const diffCopiedWithModificationsChangesetSchema =
+
  diffCopiedChangesetSchema.merge(
+
    object({
+
      old: diffFileSchema,
+
      new: diffFileSchema,
+
      diff: diffContentSchema,
+
    }),
+
  );
+

+
const diffMovedChangesetSchema = object({
+
  state: literal("moved"),
+
  newPath: string(),
+
  oldPath: string(),
+
  current: diffFileSchema,
+
});
+

+
const diffMovedWithModificationsChangesetSchema = diffMovedChangesetSchema
+
  .omit({ current: true })
+
  .merge(
+
    object({
+
      old: diffFileSchema,
+
      new: diffFileSchema,
+
      diff: diffContentSchema,
+
    }),
+
  );
+

+
type Diff = z.infer<typeof diffSchema>;
+

+
type ChangesetWithDiff = z.infer<typeof changesetWithDiffSchema>;
+
type ChangesetWithoutDiff = z.infer<typeof changesetWithoutDiffSchema>;
+

+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
+
const changesetWithDiffSchema = union([
+
  diffAddedChangesetSchema,
+
  diffDeletedChangesetSchema,
+
  diffModifiedChangesetSchema,
+
  diffMovedWithModificationsChangesetSchema,
+
  diffCopiedWithModificationsChangesetSchema,
+
]);
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
+
const changesetWithoutDiffSchema = union([
+
  diffMovedChangesetSchema,
+
  diffCopiedChangesetSchema,
+
]);
+

+
const diffSchema = object({
+
  files: array(
+
    union([
+
      diffAddedChangesetSchema,
+
      diffDeletedChangesetSchema,
+
      diffModifiedChangesetSchema,
+
      diffMovedChangesetSchema,
+
      diffMovedWithModificationsChangesetSchema,
+
      diffCopiedChangesetSchema,
+
      diffCopiedWithModificationsChangesetSchema,
+
    ]),
+
  ),
+
  stats: object({
+
    filesChanged: number(),
+
    insertions: number(),
+
    deletions: number(),
+
  }),
+
});
+

+
type Commit = z.infer<typeof commitSchema>;
+

+
const commitSchema = object({
+
  commit: commitHeaderSchema,
+
  diff: diffSchema,
+
  branches: array(string()),
+
  files: record(string(), commitBlobSchema),
+
});
+

+
type Commits = z.infer<typeof commitsSchema>;
+

+
const commitsSchema = array(commitHeaderSchema);
added http-client/lib/repo/issue.ts
@@ -0,0 +1,31 @@
+
import type { ZodSchema, z } from "zod";
+
import { array, literal, object, string, union } from "zod";
+

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

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

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

+
export const issueSchema = object({
+
  id: string(),
+
  author: authorSchema,
+
  title: string(),
+
  state: issueStateSchema,
+
  discussion: array(commentSchema),
+
  labels: array(string()),
+
  assignees: array(authorSchema),
+
});
+

+
export type Issue = z.infer<typeof issueSchema>;
+

+
export const issuesSchema = array(issueSchema) satisfies ZodSchema<Issue[]>;
added http-client/lib/repo/patch.ts
@@ -0,0 +1,106 @@
+
import type { ZodSchema, z } from "zod";
+

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

+
import {
+
  array,
+
  literal,
+
  number,
+
  optional,
+
  object,
+
  string,
+
  tuple,
+
  union,
+
} from "zod";
+
import { authorSchema } from "../shared.js";
+

+
export type PatchState = z.infer<typeof patchStateSchema>;
+

+
const patchStateSchema = union([
+
  object({
+
    status: literal("draft"),
+
  }),
+
  object({
+
    status: literal("open"),
+
    conflicts: array(tuple([string(), string()])).optional(),
+
  }),
+
  object({
+
    status: literal("archived"),
+
  }),
+
  object({
+
    status: literal("merged"),
+
    revision: string(),
+
    commit: string(),
+
  }),
+
]);
+

+
export type Merge = z.infer<typeof mergeSchema>;
+

+
const mergeSchema = object({
+
  author: authorSchema,
+
  revision: string(),
+
  commit: string(),
+
  timestamp: number(),
+
});
+

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

+
const reviewSchema = object({
+
  author: authorSchema,
+
  id: string(),
+
  verdict: optional(union([literal("accept"), literal("reject")]).nullable()),
+
  comments: array(commentSchema),
+
  summary: string().nullable(),
+
  timestamp: number(),
+
});
+

+
export type Review = z.infer<typeof reviewSchema>;
+

+
const revisionSchema = object({
+
  id: string(),
+
  author: authorSchema,
+
  description: string(),
+
  edits: array(
+
    object({
+
      author: authorSchema,
+
      body: string(),
+
      embeds: array(object({ name: string(), content: string() })),
+
      timestamp: number(),
+
    }),
+
  ),
+
  reactions: array(
+
    object({
+
      emoji: string(),
+
      authors: array(authorSchema),
+
    }),
+
  ),
+
  base: string(),
+
  oid: string(),
+
  refs: array(string()),
+
  discussions: array(commentSchema),
+
  reviews: array(reviewSchema),
+
  timestamp: number(),
+
});
+

+
export type Revision = z.infer<typeof revisionSchema>;
+

+
export const patchSchema = object({
+
  id: string(),
+
  author: authorSchema,
+
  title: string(),
+
  state: patchStateSchema,
+
  target: string(),
+
  labels: array(string()),
+
  merges: array(mergeSchema),
+
  assignees: array(string()),
+
  revisions: array(revisionSchema),
+
});
+

+
export type Patch = z.infer<typeof patchSchema>;
+

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

+
export type LifecycleState =
+
  | { status: "draft" }
+
  | { status: "open" }
+
  | { status: "archived" };
modified http-client/lib/shared.ts
@@ -117,6 +117,8 @@ export const codeLocationSchema = object({
  new: rangeSchema.nullable(),
});

+
export type Author = z.infer<typeof authorSchema>;
+

export const authorSchema = object({
  id: string(),
  alias: string().optional(),
deleted http-client/tests/project.test.ts
@@ -1,118 +0,0 @@
-
import { describe, test } from "vitest";
-

-
import { HttpdClient } from "@http-client";
-
import {
-
  aliceMainHead,
-
  aliceRemote,
-
  cobRid,
-
  defaultHttpdPort,
-
  sourceBrowsingRid,
-
} from "@tests/support/fixtures.js";
-

-
describe("project", () => {
-
  const api = new HttpdClient({
-
    hostname: "127.0.0.1",
-
    port: defaultHttpdPort,
-
    scheme: "http",
-
  });
-

-
  test("#getByDelegate(delegateId)", async () => {
-
    await api.project.getByDelegate(aliceRemote);
-
  });
-

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

-
  test("#getById(id)", async () => {
-
    await api.project.getById(sourceBrowsingRid);
-
  });
-

-
  test("#getActivity(id)", async () => {
-
    await api.project.getActivity(sourceBrowsingRid);
-
  });
-

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

-
  test("#getBlob(id, sha, path)", async () => {
-
    await api.project.getBlob(sourceBrowsingRid, aliceMainHead, "src/true.c");
-
  });
-

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

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

-
  test("#getTree(id, sha, path)", async () => {
-
    await api.project.getTree(sourceBrowsingRid, aliceMainHead, "src");
-
  });
-

-
  test("#getAllRemotes(id)", async () => {
-
    await api.project.getAllRemotes(sourceBrowsingRid);
-
  });
-

-
  test("#getRemoteByPeer(id, peer)", async () => {
-
    await api.project.getRemoteByPeer(
-
      sourceBrowsingRid,
-
      aliceRemote.substring(8),
-
    );
-
  });
-

-
  test("#getAllCommits(id)", async () => {
-
    await api.project.getAllCommits(sourceBrowsingRid);
-
  });
-

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

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

-
  test("#getDiff(id, revisionBase, revisionOid)", async () => {
-
    await api.project.getDiff(
-
      sourceBrowsingRid,
-
      "90f6d058ece12f75f349bc7bbe88142187fe0379",
-
      aliceMainHead,
-
    );
-
  });
-

-
  test("#getIssueById(id, issueId)", async () => {
-
    await api.project.getIssueById(
-
      cobRid,
-
      "d481fe6e562dd78129589d4738f171a8380fcb19",
-
    );
-
  });
-

-
  test("#getAllIssues(id)", async () => {
-
    await api.project.getAllIssues(cobRid, {
-
      page: 0,
-
      perPage: 5,
-
      status: "open",
-
    });
-
  });
-

-
  test("#getPatchById(id, patchId)", async () => {
-
    await api.project.getPatchById(
-
      cobRid,
-
      "59a0821edc73630bce540596cffc7854da557365",
-
    );
-
  });
-

-
  test("#getAllPatches(id)", async () => {
-
    await api.project.getAllPatches(cobRid);
-
  });
-
});
added http-client/tests/repo.test.ts
@@ -0,0 +1,115 @@
+
import { describe, test } from "vitest";
+

+
import { HttpdClient } from "@http-client";
+
import {
+
  aliceMainHead,
+
  aliceRemote,
+
  cobRid,
+
  defaultHttpdPort,
+
  sourceBrowsingRid,
+
} from "@tests/support/fixtures.js";
+

+
describe("repo", () => {
+
  const api = new HttpdClient({
+
    hostname: "127.0.0.1",
+
    port: defaultHttpdPort,
+
    scheme: "http",
+
  });
+

+
  test("#getByDelegate(delegateId)", async () => {
+
    await api.repo.getByDelegate(aliceRemote);
+
  });
+

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

+
  test("#getByRid(rid)", async () => {
+
    await api.repo.getByRid(sourceBrowsingRid);
+
  });
+

+
  test("#getActivity(rid)", async () => {
+
    await api.repo.getActivity(sourceBrowsingRid);
+
  });
+

+
  test("#getReadme(rid, sha)", async () => {
+
    await api.repo.getReadme(sourceBrowsingRid, aliceMainHead);
+
  });
+

+
  test("#getBlob(rid, sha, path)", async () => {
+
    await api.repo.getBlob(sourceBrowsingRid, aliceMainHead, "src/true.c");
+
  });
+

+
  test("#getTree(rid, sha)", async () => {
+
    await api.repo.getTree(sourceBrowsingRid, aliceMainHead);
+
  });
+

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

+
  test("#getTree(rid, sha, path)", async () => {
+
    await api.repo.getTree(sourceBrowsingRid, aliceMainHead, "src");
+
  });
+

+
  test("#getAllRemotes(rid)", async () => {
+
    await api.repo.getAllRemotes(sourceBrowsingRid);
+
  });
+

+
  test("#getRemoteByPeer(rid, peer)", async () => {
+
    await api.repo.getRemoteByPeer(sourceBrowsingRid, aliceRemote.substring(8));
+
  });
+

+
  test("#getAllCommits(rid)", async () => {
+
    await api.repo.getAllCommits(sourceBrowsingRid);
+
  });
+

+
  // TODO: test since/until properly.
+
  test("#getAllCommits(rid, {parent, since, until, page, perPage})", async () => {
+
    await api.repo.getAllCommits(sourceBrowsingRid, {
+
      parent: aliceMainHead,
+
      since: 1679065819581,
+
      until: 1679065819590,
+
      page: 1,
+
      perPage: 2,
+
    });
+
  });
+

+
  test("#getCommitBySha(rid, sha)", async () => {
+
    await api.repo.getCommitBySha(sourceBrowsingRid, aliceMainHead);
+
  });
+

+
  test("#getDiff(rid, revisionBase, revisionOid)", async () => {
+
    await api.repo.getDiff(
+
      sourceBrowsingRid,
+
      "90f6d058ece12f75f349bc7bbe88142187fe0379",
+
      aliceMainHead,
+
    );
+
  });
+

+
  test("#getIssueById(rid, issueId)", async () => {
+
    await api.repo.getIssueById(
+
      cobRid,
+
      "d481fe6e562dd78129589d4738f171a8380fcb19",
+
    );
+
  });
+

+
  test("#getAllIssues(rid)", async () => {
+
    await api.repo.getAllIssues(cobRid, {
+
      page: 0,
+
      perPage: 5,
+
      status: "open",
+
    });
+
  });
+

+
  test("#getPatchByOid(rid, patchId)", async () => {
+
    await api.repo.getPatchById(
+
      cobRid,
+
      "59a0821edc73630bce540596cffc7854da557365",
+
    );
+
  });
+

+
  test("#getAllPatches(rid)", async () => {
+
    await api.repo.getAllPatches(cobRid);
+
  });
+
});
modified http-client/tests/support/support.ts
@@ -12,7 +12,7 @@ export async function assertIssue(
) {
  expect(
    //@prettier-ignore looks more readable than what prettier suggests.
-
    isMatch(await api.project.getIssueById(cobRid, oid), change),
+
    isMatch(await api.repo.getIssueById(cobRid, oid), change),
  ).toBe(true);
}

@@ -23,6 +23,6 @@ export async function assertPatch(
) {
  expect(
    //@prettier-ignore looks more readable than what prettier suggests.
-
    isMatch(await api.project.getPatchById(cobRid, oid), change),
+
    isMatch(await api.repo.getPatchById(cobRid, oid), change),
  ).toBe(true);
}
modified playwright.config.ts
@@ -87,7 +87,7 @@ const config: PlaywrightTestConfig = {
      command: "npm run start -- --strictPort --port 3001",
      port: 3001,
    },
-
    // Required by test tests/e2e/project/commits.spec.ts "loading more commits, adds them to the commits list"
+
    // Required by test tests/e2e/repo/commits.spec.ts "loading more commits, adds them to the commits list"
    {
      command: "npm run start -- --strictPort --port 3002",
      port: 3002,
modified radicle-httpd/src/api.rs
@@ -1,3 +1,4 @@
+
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;

@@ -6,11 +7,12 @@ use axum::http::Method;
use axum::response::{IntoResponse, Json};
use axum::routing::get;
use axum::Router;
+
use radicle::identity::doc::PayloadId;
use radicle::issue::cache::Issues as _;
use radicle::patch::cache::Patches as _;
use radicle::storage::git::Repository;
use serde::{Deserialize, Serialize};
-
use serde_json::json;
+
use serde_json::{json, Value};
use tower_http::cors::{self, CorsLayer};

use radicle::cob::{issue, patch, Author};
@@ -47,36 +49,48 @@ impl Context {
        }
    }

-
    pub fn project_info<R: ReadRepository + radicle::cob::Store>(
+
    pub fn repo_info<R: ReadRepository + radicle::cob::Store>(
        &self,
        repo: &R,
        doc: DocAt,
-
    ) -> Result<project::Info, error::Error> {
-
        let (_, head) = repo.head()?;
+
    ) -> Result<repo::Info, error::Error> {
        let DocAt { doc, .. } = doc;
-
        let id = repo.id();
+
        let rid = repo.id();

-
        let payload = doc.project()?;
        let aliases = self.profile.aliases();
        let delegates = doc
            .delegates
            .into_iter()
            .map(|did| json::author(&Author::new(did), aliases.alias(did.as_key())))
            .collect::<Vec<_>>();
-
        let issues = self.profile.issues(repo)?.counts()?;
-
        let patches = self.profile.patches(repo)?.counts()?;
        let db = &self.profile.database()?;
-
        let seeding = db.count(&id).unwrap_or_default();
-

-
        Ok(project::Info {
-
            payload,
+
        let seeding = db.count(&rid).unwrap_or_default();
+

+
        let payloads: BTreeMap<PayloadId, Value> = doc.payload.into_iter()
+
            .filter_map(|(id, payload)| match id  {
+
                id if id == PayloadId::project() => {
+
                    let Ok((_, head)) = repo.head() else {
+
                        return None
+
                    };
+
                    let (Ok(patches), Ok(issues)) = (self.profile.patches(repo), self.profile.issues(repo)) else {
+
                        return None
+
                    };
+
                    let (Ok(patches), Ok(issues)) = (patches.counts(), issues.counts()) else {
+
                        return None
+
                    };
+

+
                    Some((id, json!({ "data": payload, "meta": { "head": head, "issues": issues, "patches": patches } } )))
+
                },
+
                _ => Some((id, json!({ "data": payload })))
+
              })
+
            .collect();
+

+
        Ok(repo::Info {
+
            payloads,
            delegates,
            threshold: doc.threshold,
            visibility: doc.visibility,
-
            head,
-
            issues,
-
            patches,
-
            id,
+
            rid,
            seeding,
        })
    }
@@ -130,14 +144,14 @@ async fn root_handler() -> impl IntoResponse {
#[serde(rename_all = "camelCase")]
pub struct PaginationQuery {
    #[serde(default)]
-
    pub show: ProjectQuery,
+
    pub show: RepoQuery,
    pub page: Option<usize>,
    pub per_page: Option<usize>,
}

#[derive(Serialize, Deserialize, Clone, Default)]
#[serde(rename_all = "camelCase")]
-
pub enum ProjectQuery {
+
pub enum RepoQuery {
    All,
    #[default]
    Pinned,
@@ -209,16 +223,17 @@ impl PatchStatus {

mod search {
    use std::cmp::Ordering;
+
    use std::collections::BTreeMap;

    use nonempty::NonEmpty;
    use serde::{Deserialize, Serialize};
    use serde_json::json;

    use radicle::crypto::Verified;
-
    use radicle::identity::{Project, RepoId};
+
    use radicle::identity::doc::{Payload, PayloadId};
+
    use radicle::identity::RepoId;
    use radicle::node::routing::Store;
-
    use radicle::node::AliasStore;
-
    use radicle::node::Database;
+
    use radicle::node::{AliasStore, Database};
    use radicle::profile::Aliases;
    use radicle::storage::RepositoryInfo;

@@ -233,8 +248,7 @@ mod search {
    #[derive(Serialize, Deserialize, Eq, Debug)]
    pub struct SearchResult {
        pub rid: RepoId,
-
        #[serde(flatten)]
-
        pub payload: Project,
+
        pub payloads: BTreeMap<PayloadId, Payload>,
        pub delegates: NonEmpty<serde_json::Value>,
        pub seeds: usize,
        #[serde(skip)]
@@ -251,8 +265,9 @@ mod search {
            if info.doc.visibility.is_private() {
                return None;
            }
-
            let payload = info.doc.project().ok()?;
-
            let index = payload.name().find(q)?;
+
            let Ok(Some(index)) = info.doc.project().map(|p| p.name().find(q)) else {
+
                return None;
+
            };
            let seeds = db.count(&info.rid).unwrap_or_default();
            let delegates = info.doc.delegates.map(|did| match aliases.alias(&did) {
                Some(alias) => json!({
@@ -266,7 +281,7 @@ mod search {

            Some(SearchResult {
                rid: info.rid,
-
                payload,
+
                payloads: info.doc.payload,
                delegates,
                seeds,
                index,
@@ -299,29 +314,24 @@ mod search {
    }
}

-
mod project {
+
mod repo {
+
    use std::collections::BTreeMap;
+

    use serde::Serialize;
    use serde_json::Value;

-
    use radicle::cob;
-
    use radicle::git::Oid;
-
    use radicle::identity::project::Project;
+
    use radicle::identity::doc::PayloadId;
    use radicle::identity::{RepoId, Visibility};

-
    /// Project info.
+
    /// Repos info.
    #[derive(Serialize)]
    #[serde(rename_all = "camelCase")]
    pub struct Info {
-
        /// Project metadata.
-
        #[serde(flatten)]
-
        pub payload: Project,
+
        pub payloads: BTreeMap<PayloadId, Value>,
        pub delegates: Vec<Value>,
        pub threshold: usize,
        pub visibility: Visibility,
-
        pub head: Oid,
-
        pub patches: cob::patch::PatchCounts,
-
        pub issues: cob::issue::IssueCounts,
-
        pub id: RepoId,
+
        pub rid: RepoId,
        pub seeding: usize,
    }
}
modified radicle-httpd/src/api/v1.rs
@@ -1,6 +1,6 @@
mod delegates;
mod node;
-
mod projects;
+
mod repos;
mod stats;

use axum::extract::State;
@@ -20,7 +20,7 @@ pub fn router(ctx: Context) -> Router {
        .merge(root_router)
        .merge(node::router(ctx.clone()))
        .merge(delegates::router(ctx.clone()))
-
        .merge(projects::router(ctx.clone()))
+
        .merge(repos::router(ctx.clone()))
        .merge(stats::router(ctx));

    Router::new().nest("/v1", routes)
@@ -36,8 +36,13 @@ async fn root_handler(State(ctx): State<Context>) -> impl IntoResponse {
        "path": "/api/v1",
        "links": [
            {
-
                "href": "/projects",
-
                "rel": "projects",
+
                "href": "/repos",
+
                "rel": "repos",
+
                "type": "GET"
+
            },
+
            {
+
                "href": "/repos/:rid",
+
                "rel": "repo",
                "type": "GET"
            },
            {
@@ -51,8 +56,8 @@ async fn root_handler(State(ctx): State<Context>) -> impl IntoResponse {
                "type": "GET"
            },
            {
-
                "href": "/delegates/:did/projects",
-
                "rel": "projects",
+
                "href": "/delegates/:did/repos",
+
                "rel": "repos",
                "type": "GET"
            },
            {
modified radicle-httpd/src/api/v1/delegates.rs
@@ -3,33 +3,22 @@ use axum::response::IntoResponse;
use axum::routing::get;
use axum::{Json, Router};

-
use radicle::cob::Author;
use radicle::identity::Did;
-
use radicle::issue::cache::Issues as _;
-
use radicle::node::routing::Store;
-
use radicle::node::AliasStore;
-
use radicle::patch::cache::Patches as _;
-
use radicle::storage::{ReadRepository, ReadStorage};
+
use radicle::storage::ReadStorage;

use crate::api::error::Error;
-
use crate::api::json;
-
use crate::api::project::Info;
-
use crate::api::Context;
-
use crate::api::{PaginationQuery, ProjectQuery};
+
use crate::api::{Context, PaginationQuery, RepoQuery};
use crate::axum_extra::{Path, Query};

pub fn router(ctx: Context) -> Router {
    Router::new()
-
        .route(
-
            "/delegates/:delegate/projects",
-
            get(delegates_projects_handler),
-
        )
+
        .route("/delegates/:delegate/repos", get(delegates_repos_handler))
        .with_state(ctx)
}

-
/// List all projects which delegate is a part of.
-
/// `GET /delegates/:delegate/projects`
-
async fn delegates_projects_handler(
+
/// List all repos which delegate is a part of.
+
/// `GET /delegates/:delegate/repos`
+
async fn delegates_repos_handler(
    State(ctx): State<Context>,
    Path(delegate): Path<Did>,
    Query(qs): Query<PaginationQuery>,
@@ -42,66 +31,35 @@ async fn delegates_projects_handler(
    let page = page.unwrap_or(0);
    let per_page = per_page.unwrap_or(10);
    let storage = &ctx.profile.storage;
-
    let db = &ctx.profile.database()?;
    let pinned = &ctx.profile.config.web.pinned;
-
    let mut projects = match show {
-
        ProjectQuery::All => storage
+
    let mut repos = match show {
+
        RepoQuery::All => storage
            .repositories()?
            .into_iter()
            .filter(|repo| repo.doc.visibility.is_public())
            .collect::<Vec<_>>(),
-
        ProjectQuery::Pinned => storage.repositories_by_id(pinned.repositories.iter())?,
+
        RepoQuery::Pinned => storage
+
            .repositories_by_id(pinned.repositories.iter())?
+
            .into_iter()
+
            .filter(|repo| repo.doc.visibility.is_public())
+
            .collect::<Vec<_>>(),
    };
-
    projects.sort_by_key(|p| p.rid);
+
    repos.sort_by_key(|p| p.rid);

-
    let infos = projects
+
    let infos = repos
        .into_iter()
        .filter_map(|id| {
            if !id.doc.delegates.iter().any(|d| *d == delegate) {
                return None;
            }
-
            let Ok(repo) = storage.repository(id.rid) else {
-
                return None;
-
            };
-
            let Ok((_, head)) = repo.head() else {
-
                return None;
-
            };
-
            let Ok(payload) = id.doc.project() else {
+
            let Ok((repo, doc)) = ctx.repo(id.rid) else {
                return None;
            };
-
            let Ok(issues) = ctx.profile.issues(&repo) else {
-
                return None;
-
            };
-
            let Ok(issues) = issues.counts() else {
-
                return None;
-
            };
-
            let Ok(patches) = ctx.profile.patches(&repo) else {
-
                return None;
-
            };
-
            let Ok(patches) = patches.counts() else {
+
            let Ok(repo_info) = ctx.repo_info(&repo, doc) else {
                return None;
            };

-
            let aliases = ctx.profile.aliases();
-
            let delegates = id
-
                .doc
-
                .delegates
-
                .into_iter()
-
                .map(|did| json::author(&Author::new(did), aliases.alias(did.as_key())))
-
                .collect::<Vec<_>>();
-
            let seeding = db.count(&id.rid).unwrap_or_default();
-

-
            Some(Info {
-
                payload,
-
                delegates,
-
                threshold: id.doc.threshold,
-
                visibility: id.doc.visibility,
-
                head,
-
                issues,
-
                patches,
-
                id: id.rid,
-
                seeding,
-
            })
+
            Some(repo_info)
        })
        .skip(page * per_page)
        .take(per_page)
@@ -121,14 +79,14 @@ mod routes {
    use crate::test::{self, get, CONTRIBUTOR_ALIAS, DID, HEAD, RID};

    #[tokio::test]
-
    async fn test_delegates_projects() {
+
    async fn test_delegates_repos() {
        let tmp = tempfile::tempdir().unwrap();
        let seed = test::seed(tmp.path());
        let app = super::router(seed.clone())
            .layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))));
        let response = get(
            &app,
-
            "/delegates/did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/projects?show=all",
+
            "/delegates/did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/repos?show=all",
        )
        .await;

@@ -142,9 +100,28 @@ mod routes {
            response.json().await,
            json!([
              {
-
                "name": "hello-world",
-
                "description": "Rad repository for tests",
-
                "defaultBranch": "master",
+
                "payloads": {
+
                  "xyz.radicle.project": {
+
                    "data": {
+
                      "defaultBranch": "master",
+
                      "description": "Rad repository for tests",
+
                      "name": "hello-world",
+
                    },
+
                    "meta": {
+
                      "head": HEAD,
+
                      "patches": {
+
                        "open": 1,
+
                        "draft": 0,
+
                        "archived": 0,
+
                        "merged": 0,
+
                      },
+
                      "issues": {
+
                        "open": 1,
+
                        "closed": 0,
+
                      },
+
                    }
+
                  }
+
                },
                "delegates": [
                  {
                    "id": DID,
@@ -155,24 +132,32 @@ mod routes {
                "visibility": {
                  "type": "public"
                },
-
                "head": HEAD,
-
                "patches": {
-
                  "open": 1,
-
                  "draft": 0,
-
                  "archived": 0,
-
                  "merged": 0,
-
                },
-
                "issues": {
-
                  "open": 1,
-
                  "closed": 0,
-
                },
-
                "id": RID,
+
                "rid": RID,
                "seeding": 1,
              },
              {
-
                "name": "again-hello-world",
-
                "description": "Rad repository for sorting",
-
                "defaultBranch": "master",
+
                "payloads": {
+
                  "xyz.radicle.project": {
+
                    "data": {
+
                      "defaultBranch": "master",
+
                      "description": "Rad repository for sorting",
+
                      "name": "again-hello-world",
+
                    },
+
                    "meta": {
+
                      "head": "344dcd184df5bf37aab6c107fa9371a1c5b3321a",
+
                      "patches": {
+
                        "open": 0,
+
                        "draft": 0,
+
                        "archived": 0,
+
                        "merged": 0,
+
                      },
+
                      "issues": {
+
                        "open": 0,
+
                        "closed": 0,
+
                      },
+
                    }
+
                  }
+
                },
                "delegates": [
                  {
                    "id": DID,
@@ -183,18 +168,7 @@ mod routes {
                "visibility": {
                  "type": "public"
                },
-
                "head": "344dcd184df5bf37aab6c107fa9371a1c5b3321a",
-
                "patches": {
-
                  "open": 0,
-
                  "draft": 0,
-
                  "archived": 0,
-
                  "merged": 0,
-
                },
-
                "issues": {
-
                  "open": 0,
-
                  "closed": 0,
-
                },
-
                "id": "rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE",
+
                "rid": "rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE",
                "seeding": 1,
              }
            ])
@@ -206,7 +180,7 @@ mod routes {
        ))));
        let response = get(
            &app,
-
            "/delegates/did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/projects?show=all",
+
            "/delegates/did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/repos?show=all",
        )
        .await;

@@ -220,9 +194,28 @@ mod routes {
            response.json().await,
            json!([
              {
-
                "name": "hello-world",
-
                "description": "Rad repository for tests",
-
                "defaultBranch": "master",
+
                "payloads": {
+
                  "xyz.radicle.project": {
+
                    "data": {
+
                      "defaultBranch": "master",
+
                      "description": "Rad repository for tests",
+
                      "name": "hello-world",
+
                    },
+
                    "meta": {
+
                      "head": HEAD,
+
                      "patches": {
+
                        "open": 1,
+
                        "draft": 0,
+
                        "archived": 0,
+
                        "merged": 0,
+
                      },
+
                      "issues": {
+
                        "open": 1,
+
                        "closed": 0,
+
                      },
+
                    }
+
                  }
+
                },
                "delegates": [
                  {
                    "id": DID,
@@ -233,24 +226,32 @@ mod routes {
                "visibility": {
                  "type": "public"
                },
-
                "head": HEAD,
-
                "patches": {
-
                  "open": 1,
-
                  "draft": 0,
-
                  "archived": 0,
-
                  "merged": 0,
-
                },
-
                "issues": {
-
                  "open": 1,
-
                  "closed": 0,
-
                },
-
                "id": RID,
+
                "rid": RID,
                "seeding": 1,
              },
              {
-
                "name": "again-hello-world",
-
                "description": "Rad repository for sorting",
-
                "defaultBranch": "master",
+
                "payloads": {
+
                  "xyz.radicle.project": {
+
                    "data": {
+
                      "defaultBranch": "master",
+
                      "description": "Rad repository for sorting",
+
                      "name": "again-hello-world",
+
                    },
+
                    "meta": {
+
                      "head": "344dcd184df5bf37aab6c107fa9371a1c5b3321a",
+
                      "patches": {
+
                        "open": 0,
+
                        "draft": 0,
+
                        "archived": 0,
+
                        "merged": 0,
+
                      },
+
                      "issues": {
+
                        "open": 0,
+
                        "closed": 0,
+
                      },
+
                    }
+
                  }
+
                },
                "delegates": [
                  {
                    "id": DID,
@@ -261,18 +262,7 @@ mod routes {
                "visibility": {
                  "type": "public"
                },
-
                "head": "344dcd184df5bf37aab6c107fa9371a1c5b3321a",
-
                "patches": {
-
                  "open": 0,
-
                  "draft": 0,
-
                  "archived": 0,
-
                  "merged": 0,
-
                },
-
                "issues": {
-
                  "open": 0,
-
                  "closed": 0,
-
                },
-
                "id": "rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE",
+
                "rid": "rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE",
                "seeding": 1,
              }
            ])
deleted radicle-httpd/src/api/v1/projects.rs
@@ -1,1836 +0,0 @@
-
use std::collections::{BTreeMap, BTreeSet, HashMap};
-

-
use axum::extract::{DefaultBodyLimit, State};
-
use axum::http::header;
-
use axum::response::IntoResponse;
-
use axum::routing::get;
-
use axum::{Json, Router};
-
use hyper::StatusCode;
-
use radicle_surf::blob::BlobRef;
-
use radicle_surf::{diff, Glob, Oid, Repository};
-
use serde::{Deserialize, Serialize};
-
use serde_json::json;
-

-
use radicle::cob::{issue::cache::Issues as _, patch::cache::Patches as _, Author};
-
use radicle::identity::RepoId;
-
use radicle::node::routing::Store;
-
use radicle::node::{AliasStore, NodeId};
-
use radicle::storage::{ReadRepository, ReadStorage, RemoteRepository};
-

-
use crate::api::error::Error;
-
use crate::api::project::Info;
-
use crate::api::search::{SearchQueryString, SearchResult};
-
use crate::api::{self, CobsQuery, Context, PaginationQuery, ProjectQuery};
-
use crate::axum_extra::{cached_response, immutable_response, Path, Query};
-

-
const MAX_BODY_LIMIT: usize = 4_194_304;
-

-
pub fn router(ctx: Context) -> Router {
-
    Router::new()
-
        .route("/projects", get(project_root_handler))
-
        .route("/projects/search", get(project_search_handler))
-
        .route("/projects/:project", get(project_handler))
-
        .route("/projects/:project/commits", get(history_handler))
-
        .route("/projects/:project/commits/:sha", get(commit_handler))
-
        .route("/projects/:project/diff/:base/:oid", get(diff_handler))
-
        .route("/projects/:project/activity", get(activity_handler))
-
        .route("/projects/:project/tree/:sha/", get(tree_handler_root))
-
        .route("/projects/:project/tree/:sha/*path", get(tree_handler))
-
        .route(
-
            "/projects/:project/stats/tree/:sha",
-
            get(stats_tree_handler),
-
        )
-
        .route("/projects/:project/remotes", get(remotes_handler))
-
        .route("/projects/:project/remotes/:peer", get(remote_handler))
-
        .route("/projects/:project/blob/:sha/*path", get(blob_handler))
-
        .route("/projects/:project/readme/:sha", get(readme_handler))
-
        .route("/projects/:project/issues", get(issues_handler))
-
        .route("/projects/:project/issues/:id", get(issue_handler))
-
        .route("/projects/:project/patches", get(patches_handler))
-
        .route("/projects/:project/patches/:id", get(patch_handler))
-
        .with_state(ctx)
-
        .layer(DefaultBodyLimit::max(MAX_BODY_LIMIT))
-
}
-

-
/// List all projects.
-
/// `GET /projects`
-
async fn project_root_handler(
-
    State(ctx): State<Context>,
-
    Query(qs): Query<PaginationQuery>,
-
) -> impl IntoResponse {
-
    let PaginationQuery {
-
        show,
-
        page,
-
        per_page,
-
    } = qs;
-
    let page = page.unwrap_or(0);
-
    let per_page = per_page.unwrap_or_else(|| match show {
-
        ProjectQuery::Pinned => ctx.profile.config.web.pinned.repositories.len(),
-
        _ => 10,
-
    });
-
    let storage = &ctx.profile.storage;
-
    let db = &ctx.profile.database()?;
-
    let pinned = &ctx.profile.config.web.pinned;
-
    let policies = ctx.profile.policies()?;
-

-
    let mut projects = match show {
-
        ProjectQuery::All => storage
-
            .repositories()?
-
            .into_iter()
-
            .filter(|repo| repo.doc.visibility.is_public())
-
            .collect::<Vec<_>>(),
-
        ProjectQuery::Pinned => storage
-
            .repositories_by_id(pinned.repositories.iter())?
-
            .into_iter()
-
            .filter(|repo| repo.doc.visibility.is_public())
-
            .collect::<Vec<_>>(),
-
    };
-
    projects.sort_by_key(|p| p.rid);
-

-
    let infos = projects
-
        .into_iter()
-
        .filter_map(|info| {
-
            if !policies.is_seeding(&info.rid).unwrap_or_default() {
-
                return None;
-
            }
-
            let Ok(repo) = storage.repository(info.rid) else {
-
                return None;
-
            };
-
            let Ok((_, head)) = repo.head() else {
-
                return None;
-
            };
-
            let Ok(payload) = info.doc.project() else {
-
                return None;
-
            };
-
            let Ok(issues) = ctx.profile.issues(&repo) else {
-
                return None;
-
            };
-
            let Ok(issues) = issues.counts() else {
-
                return None;
-
            };
-
            let Ok(patches) = ctx.profile.patches(&repo) else {
-
                return None;
-
            };
-
            let Ok(patches) = patches.counts() else {
-
                return None;
-
            };
-
            let aliases = ctx.profile.aliases();
-
            let delegates = info
-
                .doc
-
                .delegates
-
                .into_iter()
-
                .map(|did| api::json::author(&Author::new(did), aliases.alias(did.as_key())))
-
                .collect::<Vec<_>>();
-
            let seeding = db.count(&info.rid).unwrap_or_default();
-

-
            Some(Info {
-
                payload,
-
                delegates,
-
                head,
-
                threshold: info.doc.threshold,
-
                visibility: info.doc.visibility,
-
                issues,
-
                patches,
-
                id: info.rid,
-
                seeding,
-
            })
-
        })
-
        .skip(page * per_page)
-
        .take(per_page)
-
        .collect::<Vec<_>>();
-

-
    Ok::<_, Error>(Json(infos))
-
}
-

-
/// Search repositories by name.
-
/// `GET /projects/search?q=<query>`
-
///
-
/// We obtain the byte index of the first character of the query that matches the repo name.
-
/// And skip if the query doesn't match the repo name.
-
///
-
/// Sorting algorithm:
-
/// If both byte indices are 0, compare by seeding count.
-
/// A repo name with a byte index of 0 should come before non-zero indices.
-
/// If both indices are non-zero and equal, then compare by seeding count.
-
/// If none of the above, all non-zero indices are compared by their seeding count primarily.
-
async fn project_search_handler(
-
    State(ctx): State<Context>,
-
    Query(SearchQueryString { q, per_page, page }): Query<SearchQueryString>,
-
) -> impl IntoResponse {
-
    let q = q.unwrap_or_default();
-
    let page = page.unwrap_or(0);
-
    let per_page = per_page.unwrap_or(10);
-
    let storage = &ctx.profile.storage;
-
    let aliases = &ctx.profile.aliases();
-
    let db = &ctx.profile.database()?;
-
    let found_repos = storage
-
        .repositories()?
-
        .into_iter()
-
        .filter_map(|info| SearchResult::new(&q, info, db, aliases))
-
        .collect::<BTreeSet<SearchResult>>();
-

-
    let found_repos = found_repos
-
        .into_iter()
-
        .skip(page * per_page)
-
        .take(per_page)
-
        .collect::<Vec<_>>();
-

-
    Ok::<_, Error>(cached_response(found_repos, 600).into_response())
-
}
-

-
/// Get project metadata.
-
/// `GET /projects/:project`
-
async fn project_handler(State(ctx): State<Context>, Path(rid): Path<RepoId>) -> impl IntoResponse {
-
    let (repo, doc) = ctx.repo(rid)?;
-
    let info = ctx.project_info(&repo, doc)?;
-

-
    Ok::<_, Error>(Json(info))
-
}
-

-
#[derive(Serialize, Deserialize, Clone)]
-
#[serde(rename_all = "camelCase")]
-
pub struct CommitsQueryString {
-
    pub parent: Option<String>,
-
    pub since: Option<i64>,
-
    pub until: Option<i64>,
-
    pub page: Option<usize>,
-
    pub per_page: Option<usize>,
-
}
-

-
/// Get project commit range.
-
/// `GET /projects/:project/commits?parent=<sha>`
-
async fn history_handler(
-
    State(ctx): State<Context>,
-
    Path(rid): Path<RepoId>,
-
    Query(qs): Query<CommitsQueryString>,
-
) -> impl IntoResponse {
-
    let (repo, doc) = ctx.repo(rid)?;
-
    let CommitsQueryString {
-
        since,
-
        until,
-
        parent,
-
        page,
-
        per_page,
-
    } = qs;
-

-
    // If the parent commit is provided, the response depends only on the query
-
    // string and not on the state of the repository. This means we can instruct
-
    // the caches to treat the response as immutable.
-
    let is_immutable = parent.is_some();
-

-
    let sha = match parent {
-
        Some(commit) => commit,
-
        None => ctx.project_info(&repo, doc)?.head.to_string(),
-
    };
-
    let repo = Repository::open(repo.path())?;
-

-
    // If a pagination is defined, we do not want to paginate the commits, and we return all of them on the first page.
-
    let page = page.unwrap_or(0);
-
    let per_page = if per_page.is_none() && (since.is_some() || until.is_some()) {
-
        usize::MAX
-
    } else {
-
        per_page.unwrap_or(30)
-
    };
-

-
    let commits = repo
-
        .history(&sha)?
-
        .filter_map(|commit| {
-
            let commit = commit.ok()?;
-
            let time = commit.committer.time.seconds();
-
            let commit = api::json::commit(&commit);
-
            match (since, until) {
-
                (Some(since), Some(until)) if time >= since && time < until => Some(commit),
-
                (Some(since), None) if time >= since => Some(commit),
-
                (None, Some(until)) if time < until => Some(commit),
-
                (None, None) => Some(commit),
-
                _ => None,
-
            }
-
        })
-
        .skip(page * per_page)
-
        .take(per_page)
-
        .collect::<Vec<_>>();
-

-
    if is_immutable {
-
        Ok::<_, Error>(immutable_response(commits).into_response())
-
    } else {
-
        Ok::<_, Error>(Json(commits).into_response())
-
    }
-
}
-

-
/// Get project commit.
-
/// `GET /projects/:project/commits/:sha`
-
async fn commit_handler(
-
    State(ctx): State<Context>,
-
    Path((project, sha)): Path<(RepoId, Oid)>,
-
) -> impl IntoResponse {
-
    let (repo, _) = ctx.repo(project)?;
-
    let repo = Repository::open(repo.path())?;
-
    let commit = repo.commit(sha)?;
-

-
    let diff = repo.diff_commit(commit.id)?;
-
    let glob = Glob::all_heads().branches().and(Glob::all_remotes());
-
    let branches: Vec<String> = repo
-
        .revision_branches(commit.id, glob)?
-
        .iter()
-
        .map(|b| b.refname().to_string())
-
        .collect();
-

-
    let mut files: HashMap<Oid, BlobRef<'_>> = HashMap::new();
-
    diff.files().for_each(|file_diff| match file_diff {
-
        diff::FileDiff::Added(added) => {
-
            if let Ok(blob) = repo.blob_ref(added.new.oid) {
-
                files.insert(blob.id(), blob);
-
            }
-
        }
-
        diff::FileDiff::Deleted(deleted) => {
-
            if let Ok(old_blob) = repo.blob_ref(deleted.old.oid) {
-
                files.insert(old_blob.id(), old_blob);
-
            }
-
        }
-
        diff::FileDiff::Modified(modified) => {
-
            if let (Ok(old_blob), Ok(new_blob)) = (
-
                repo.blob_ref(modified.old.oid),
-
                repo.blob_ref(modified.new.oid),
-
            ) {
-
                files.insert(old_blob.id(), old_blob);
-
                files.insert(new_blob.id(), new_blob);
-
            }
-
        }
-
        diff::FileDiff::Moved(moved) => {
-
            if let (Ok(old_blob), Ok(new_blob)) =
-
                (repo.blob_ref(moved.old.oid), repo.blob_ref(moved.new.oid))
-
            {
-
                files.insert(old_blob.id(), old_blob);
-
                files.insert(new_blob.id(), new_blob);
-
            }
-
        }
-
        diff::FileDiff::Copied(copied) => {
-
            if let (Ok(old_blob), Ok(new_blob)) =
-
                (repo.blob_ref(copied.old.oid), repo.blob_ref(copied.new.oid))
-
            {
-
                files.insert(old_blob.id(), old_blob);
-
                files.insert(new_blob.id(), new_blob);
-
            }
-
        }
-
    });
-

-
    let response: serde_json::Value = json!({
-
      "commit": api::json::commit(&commit),
-
      "diff": diff,
-
      "files": files,
-
      "branches": branches
-
    });
-
    Ok::<_, Error>(immutable_response(response))
-
}
-

-
/// Get diff between two commits
-
/// `GET /projects/:project/diff/:base/:oid`
-
async fn diff_handler(
-
    State(ctx): State<Context>,
-
    Path((project, base, oid)): Path<(RepoId, Oid, Oid)>,
-
) -> impl IntoResponse {
-
    let (repo, _) = ctx.repo(project)?;
-
    let repo = Repository::open(repo.path())?;
-
    let base = repo.commit(base)?;
-
    let commit = repo.commit(oid)?;
-
    let diff = repo.diff(base.id, commit.id)?;
-
    let mut files: HashMap<Oid, BlobRef<'_>> = HashMap::new();
-
    diff.files().for_each(|file_diff| match file_diff {
-
        diff::FileDiff::Added(added) => {
-
            if let Ok(new_blob) = repo.blob_ref(added.new.oid) {
-
                files.insert(new_blob.id(), new_blob);
-
            }
-
        }
-
        diff::FileDiff::Deleted(deleted) => {
-
            if let Ok(old_blob) = repo.blob_ref(deleted.old.oid) {
-
                files.insert(old_blob.id(), old_blob);
-
            }
-
        }
-
        diff::FileDiff::Modified(modified) => {
-
            if let (Ok(new_blob), Ok(old_blob)) = (
-
                repo.blob_ref(modified.old.oid),
-
                repo.blob_ref(modified.new.oid),
-
            ) {
-
                files.insert(new_blob.id(), new_blob);
-
                files.insert(old_blob.id(), old_blob);
-
            }
-
        }
-
        diff::FileDiff::Moved(moved) => {
-
            if let (Ok(new_blob), Ok(old_blob)) =
-
                (repo.blob_ref(moved.new.oid), repo.blob_ref(moved.old.oid))
-
            {
-
                files.insert(new_blob.id(), new_blob);
-
                files.insert(old_blob.id(), old_blob);
-
            }
-
        }
-
        diff::FileDiff::Copied(copied) => {
-
            if let (Ok(new_blob), Ok(old_blob)) =
-
                (repo.blob_ref(copied.new.oid), repo.blob_ref(copied.old.oid))
-
            {
-
                files.insert(new_blob.id(), new_blob);
-
                files.insert(old_blob.id(), old_blob);
-
            }
-
        }
-
    });
-

-
    let commits = repo
-
        .history(commit.id)?
-
        .take_while(|c| {
-
            if let Ok(c) = c {
-
                c.id != base.id
-
            } else {
-
                false
-
            }
-
        })
-
        .map(|r| r.map(|c| api::json::commit(&c)))
-
        .collect::<Result<Vec<_>, _>>()?;
-

-
    let response = json!({ "diff": diff, "files": files, "commits": commits });
-

-
    Ok::<_, Error>(immutable_response(response))
-
}
-

-
/// Get project activity for the past year.
-
/// `GET /projects/:project/activity`
-
async fn activity_handler(
-
    State(ctx): State<Context>,
-
    Path(project): Path<RepoId>,
-
) -> impl IntoResponse {
-
    let (repo, _) = ctx.repo(project)?;
-
    let current_date = chrono::Utc::now().timestamp();
-
    // SAFETY: The number of weeks is static and not out of bounds.
-
    #[allow(clippy::unwrap_used)]
-
    let one_year_ago = chrono::Duration::try_weeks(52).unwrap();
-
    let repo = Repository::open(repo.path())?;
-
    let head = repo.head()?;
-
    let timestamps = repo
-
        .history(head)?
-
        .filter_map(|a| {
-
            if let Ok(a) = a {
-
                let seconds = a.committer.time.seconds();
-
                if seconds > current_date - one_year_ago.num_seconds() {
-
                    return Some(seconds);
-
                }
-
            }
-
            None
-
        })
-
        .collect::<Vec<i64>>();
-

-
    Ok::<_, Error>(cached_response(json!({ "activity": timestamps }), 3600))
-
}
-

-
/// Get project source tree for '/' path.
-
/// `GET /projects/:project/tree/:sha/`
-
async fn tree_handler_root(
-
    State(ctx): State<Context>,
-
    Path((rid, sha)): Path<(RepoId, Oid)>,
-
) -> impl IntoResponse {
-
    tree_handler(State(ctx), Path((rid, sha, String::new()))).await
-
}
-

-
/// Get project source tree.
-
/// `GET /projects/:project/tree/:sha/*path`
-
async fn tree_handler(
-
    State(ctx): State<Context>,
-
    Path((project, sha, path)): Path<(RepoId, Oid, String)>,
-
) -> impl IntoResponse {
-
    let (repo, _) = ctx.repo(project)?;
-

-
    if let Some(ref cache) = ctx.cache {
-
        let cache = &mut cache.tree.lock().await;
-
        if let Some(response) = cache.get(&(project, sha, path.clone())) {
-
            return Ok::<_, Error>(immutable_response(response.clone()));
-
        }
-
    }
-

-
    let repo = Repository::open(repo.path())?;
-
    let tree = repo.tree(sha, &path)?;
-
    let response = api::json::tree(&tree, &path);
-

-
    if let Some(cache) = &ctx.cache {
-
        let cache = &mut cache.tree.lock().await;
-
        cache.put((project, sha, path.clone()), response.clone());
-
    }
-

-
    Ok::<_, Error>(immutable_response(response))
-
}
-

-
/// Get project source tree stats.
-
/// `GET /projects/:project/stats/tree/:sha`
-
async fn stats_tree_handler(
-
    State(ctx): State<Context>,
-
    Path((project, sha)): Path<(RepoId, Oid)>,
-
) -> impl IntoResponse {
-
    let (repo, _) = ctx.repo(project)?;
-
    let repo = Repository::open(repo.path())?;
-
    let stats = repo.stats_from(&sha)?;
-

-
    Ok::<_, Error>(immutable_response(stats))
-
}
-

-
/// Get all project remotes.
-
/// `GET /projects/:project/remotes`
-
async fn remotes_handler(
-
    State(ctx): State<Context>,
-
    Path(project): Path<RepoId>,
-
) -> impl IntoResponse {
-
    let (repo, doc) = ctx.repo(project)?;
-
    let delegates = &doc.delegates;
-
    let aliases = &ctx.profile.aliases();
-
    let remotes = repo
-
        .remotes()?
-
        .filter_map(|r| r.map(|r| r.1).ok())
-
        .map(|remote| {
-
            let refs = remote
-
                .refs
-
                .iter()
-
                .filter_map(|(r, oid)| {
-
                    r.as_str()
-
                        .strip_prefix("refs/heads/")
-
                        .map(|head| (head.to_string(), oid))
-
                })
-
                .collect::<BTreeMap<String, &Oid>>();
-

-
            match aliases.alias(&remote.id) {
-
                Some(alias) => json!({
-
                    "id": remote.id,
-
                    "alias": alias,
-
                    "heads": refs,
-
                    "delegate": delegates.contains(&remote.id.into()),
-
                }),
-
                None => json!({
-
                    "id": remote.id,
-
                    "heads": refs,
-
                    "delegate": delegates.contains(&remote.id.into()),
-
                }),
-
            }
-
        })
-
        .collect::<Vec<_>>();
-

-
    Ok::<_, Error>(Json(remotes))
-
}
-

-
/// Get project remote.
-
/// `GET /projects/:project/remotes/:peer`
-
async fn remote_handler(
-
    State(ctx): State<Context>,
-
    Path((project, node_id)): Path<(RepoId, NodeId)>,
-
) -> impl IntoResponse {
-
    let (repo, doc) = ctx.repo(project)?;
-
    let delegates = &doc.delegates;
-
    let remote = repo.remote(&node_id)?;
-
    let refs = remote
-
        .refs
-
        .iter()
-
        .filter_map(|(r, oid)| {
-
            r.as_str()
-
                .strip_prefix("refs/heads/")
-
                .map(|head| (head.to_string(), oid))
-
        })
-
        .collect::<BTreeMap<String, &Oid>>();
-
    let remote = json!({
-
        "id": remote.id,
-
        "heads": refs,
-
        "delegate": delegates.contains(&remote.id.into()),
-
    });
-

-
    Ok::<_, Error>(Json(remote))
-
}
-

-
/// Get project source file.
-
/// `GET /projects/:project/blob/:sha/*path`
-
async fn blob_handler(
-
    State(ctx): State<Context>,
-
    Path((project, sha, path)): Path<(RepoId, Oid, String)>,
-
) -> impl IntoResponse {
-
    let (repo, _) = ctx.repo(project)?;
-
    let repo = Repository::open(repo.path())?;
-
    let blob = repo.blob(sha, &path)?;
-

-
    if blob.size() > MAX_BODY_LIMIT {
-
        return Ok::<_, Error>(
-
            (
-
                StatusCode::PAYLOAD_TOO_LARGE,
-
                [(header::CACHE_CONTROL, "no-cache")],
-
                Json(json!([])),
-
            )
-
                .into_response(),
-
        );
-
    }
-
    Ok::<_, Error>(immutable_response(api::json::blob(&blob, &path)).into_response())
-
}
-

-
/// Get project readme.
-
/// `GET /projects/:project/readme/:sha`
-
async fn readme_handler(
-
    State(ctx): State<Context>,
-
    Path((project, sha)): Path<(RepoId, Oid)>,
-
) -> impl IntoResponse {
-
    let (repo, _) = ctx.repo(project)?;
-
    let repo = Repository::open(repo.path())?;
-
    let paths = [
-
        "README",
-
        "README.md",
-
        "README.markdown",
-
        "README.txt",
-
        "README.rst",
-
        "README.org",
-
        "Readme.md",
-
    ];
-

-
    for path in paths
-
        .iter()
-
        .map(ToString::to_string)
-
        .chain(paths.iter().map(|p| p.to_lowercase()))
-
    {
-
        if let Ok(blob) = repo.blob(sha, &path) {
-
            if blob.size() > MAX_BODY_LIMIT {
-
                return Ok::<_, Error>(
-
                    (
-
                        StatusCode::PAYLOAD_TOO_LARGE,
-
                        [(header::CACHE_CONTROL, "no-cache")],
-
                        Json(json!([])),
-
                    )
-
                        .into_response(),
-
                );
-
            }
-

-
            return Ok::<_, Error>(
-
                immutable_response(api::json::blob(&blob, &path)).into_response(),
-
            );
-
        }
-
    }
-

-
    Err(Error::NotFound)
-
}
-

-
/// Get project issues list.
-
/// `GET /projects/:project/issues`
-
async fn issues_handler(
-
    State(ctx): State<Context>,
-
    Path(project): Path<RepoId>,
-
    Query(qs): Query<CobsQuery<api::IssueStatus>>,
-
) -> impl IntoResponse {
-
    let (repo, _) = ctx.repo(project)?;
-
    let CobsQuery {
-
        page,
-
        per_page,
-
        status,
-
    } = qs;
-
    let page = page.unwrap_or(0);
-
    let per_page = per_page.unwrap_or(10);
-
    let status = status.unwrap_or_default();
-
    let issues = ctx.profile.issues(&repo)?;
-
    let mut issues: Vec<_> = issues
-
        .list()?
-
        .filter_map(|r| {
-
            let (id, issue) = r.ok()?;
-
            (status.matches(issue.state())).then_some((id, issue))
-
        })
-
        .collect::<Vec<_>>();
-

-
    issues.sort_by(|(_, a), (_, b)| b.timestamp().cmp(&a.timestamp()));
-
    let aliases = &ctx.profile.aliases();
-
    let issues = issues
-
        .into_iter()
-
        .map(|(id, issue)| api::json::issue(id, issue, aliases))
-
        .skip(page * per_page)
-
        .take(per_page)
-
        .collect::<Vec<_>>();
-

-
    Ok::<_, Error>(Json(issues))
-
}
-

-
/// Get project issue.
-
/// `GET /projects/:project/issues/:id`
-
async fn issue_handler(
-
    State(ctx): State<Context>,
-
    Path((project, issue_id)): Path<(RepoId, Oid)>,
-
) -> impl IntoResponse {
-
    let (repo, _) = ctx.repo(project)?;
-
    let issue = ctx
-
        .profile
-
        .issues(&repo)?
-
        .get(&issue_id.into())?
-
        .ok_or(Error::NotFound)?;
-
    let aliases = ctx.profile.aliases();
-

-
    Ok::<_, Error>(Json(api::json::issue(issue_id.into(), issue, &aliases)))
-
}
-

-
/// Get project patches list.
-
/// `GET /projects/:project/patches`
-
async fn patches_handler(
-
    State(ctx): State<Context>,
-
    Path(rid): Path<RepoId>,
-
    Query(qs): Query<CobsQuery<api::PatchStatus>>,
-
) -> impl IntoResponse {
-
    let (repo, _) = ctx.repo(rid)?;
-
    let CobsQuery {
-
        page,
-
        per_page,
-
        status,
-
    } = qs;
-
    let page = page.unwrap_or(0);
-
    let per_page = per_page.unwrap_or(10);
-
    let status = status.unwrap_or_default();
-
    let patches = ctx.profile.patches(&repo)?;
-
    let mut patches = patches
-
        .list()?
-
        .filter_map(|r| {
-
            let (id, patch) = r.ok()?;
-
            (status.matches(patch.state())).then_some((id, patch))
-
        })
-
        .collect::<Vec<_>>();
-
    patches.sort_by(|(_, a), (_, b)| b.timestamp().cmp(&a.timestamp()));
-
    let aliases = ctx.profile.aliases();
-
    let patches = patches
-
        .into_iter()
-
        .map(|(id, patch)| api::json::patch(id, patch, &repo, &aliases))
-
        .skip(page * per_page)
-
        .take(per_page)
-
        .collect::<Vec<_>>();
-

-
    Ok::<_, Error>(Json(patches))
-
}
-

-
/// Get project patch.
-
/// `GET /projects/:project/patches/:id`
-
async fn patch_handler(
-
    State(ctx): State<Context>,
-
    Path((rid, patch_id)): Path<(RepoId, Oid)>,
-
) -> impl IntoResponse {
-
    let (repo, _) = ctx.repo(rid)?;
-
    let patches = ctx.profile.patches(&repo)?;
-
    let patch = patches.get(&patch_id.into())?.ok_or(Error::NotFound)?;
-
    let aliases = ctx.profile.aliases();
-

-
    Ok::<_, Error>(Json(api::json::patch(
-
        patch_id.into(),
-
        patch,
-
        &repo,
-
        &aliases,
-
    )))
-
}
-

-
#[cfg(test)]
-
mod routes {
-
    use std::net::SocketAddr;
-

-
    use axum::extract::connect_info::MockConnectInfo;
-
    use axum::http::StatusCode;
-
    use pretty_assertions::assert_eq;
-
    use radicle::storage::ReadStorage;
-
    use serde_json::json;
-

-
    use crate::test::*;
-

-
    #[tokio::test]
-
    async fn test_projects_root() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let seed = seed(tmp.path());
-
        let app = super::router(seed.clone())
-
            .layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))));
-
        let response = get(&app, "/projects?show=all").await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(
-
            response.json().await,
-
            json!([
-
              {
-
                "name": "hello-world",
-
                "description": "Rad repository for tests",
-
                "defaultBranch": "master",
-
                "delegates": [
-
                  {
-
                    "id": DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                ],
-
                "threshold": 1,
-
                "visibility": {
-
                  "type": "public"
-
                },
-
                "head": HEAD,
-
                "patches": {
-
                  "open": 1,
-
                  "draft": 0,
-
                  "archived": 0,
-
                  "merged": 0,
-
                },
-
                "issues": {
-
                  "open": 1,
-
                  "closed": 0,
-
                },
-
                "id": RID,
-
                "seeding": 1,
-
              },
-
              {
-
                "name": "again-hello-world",
-
                "description": "Rad repository for sorting",
-
                "defaultBranch": "master",
-
                "delegates": [
-
                  {
-
                    "id": DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  }
-
                ],
-
                "threshold": 1,
-
                "visibility": {
-
                  "type": "public"
-
                },
-
                "head": "344dcd184df5bf37aab6c107fa9371a1c5b3321a",
-
                "patches": {
-
                  "open": 0,
-
                  "draft": 0,
-
                  "archived": 0,
-
                  "merged": 0,
-
                },
-
                "issues": {
-
                  "open": 0,
-
                  "closed": 0,
-
                },
-
                "id": "rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE",
-
                "seeding": 1,
-
              },
-
            ])
-
        );
-

-
        let app = super::router(seed).layer(MockConnectInfo(SocketAddr::from((
-
            [192, 168, 13, 37],
-
            8080,
-
        ))));
-
        let response = get(&app, "/projects?show=all").await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(
-
            response.json().await,
-
            json!([
-
              {
-
                "name": "hello-world",
-
                "description": "Rad repository for tests",
-
                "defaultBranch": "master",
-
                "delegates": [
-
                  {
-
                    "id": DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  }
-
                ],
-
                "threshold": 1,
-
                "visibility": {
-
                  "type": "public"
-
                },
-
                "head": HEAD,
-
                "patches": {
-
                  "open": 1,
-
                  "draft": 0,
-
                  "archived": 0,
-
                  "merged": 0,
-
                },
-
                "issues": {
-
                  "open": 1,
-
                  "closed": 0,
-
                },
-
                "id": RID,
-
                "seeding": 1,
-
              },
-
              {
-
                "name": "again-hello-world",
-
                "description": "Rad repository for sorting",
-
                "defaultBranch": "master",
-
                "delegates": [
-
                  {
-
                    "id": DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                ],
-
                "threshold": 1,
-
                "visibility": {
-
                  "type": "public"
-
                },
-
                "head": "344dcd184df5bf37aab6c107fa9371a1c5b3321a",
-
                "patches": {
-
                  "open": 0,
-
                  "draft": 0,
-
                  "archived": 0,
-
                  "merged": 0,
-
                },
-
                "issues": {
-
                  "open": 0,
-
                  "closed": 0,
-
                },
-
                "id": "rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE",
-
                "seeding": 1,
-
              },
-
            ])
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let app = super::router(seed(tmp.path()));
-
        let response = get(&app, format!("/projects/{RID}")).await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
               "name": "hello-world",
-
               "description": "Rad repository for tests",
-
               "defaultBranch": "master",
-
               "delegates": [
-
                 {
-
                   "id": DID,
-
                   "alias": CONTRIBUTOR_ALIAS,
-
                 }
-
               ],
-
               "threshold": 1,
-
               "visibility": {
-
                 "type": "public"
-
               },
-
               "head": HEAD,
-
               "patches": {
-
                 "open": 1,
-
                 "draft": 0,
-
                 "archived": 0,
-
                 "merged": 0,
-
               },
-
               "issues": {
-
                 "open": 1,
-
                 "closed": 0,
-
               },
-
               "id": RID,
-
               "seeding": 1,
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_search_projects() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let app = super::router(seed(tmp.path()));
-
        let response = get(&app, "/projects/search?q=hello").await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(
-
            response.json().await,
-
            json!([
-
              {
-
                "rid": "rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp",
-
                "name": "hello-world",
-
                "description": "Rad repository for tests",
-
                "defaultBranch": "master",
-
                "delegates": [
-
                  {
-
                    "id": DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  }
-
                ],
-
                "seeds": 1,
-
              },
-
              {
-
                "rid": "rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE",
-
                "name": "again-hello-world",
-
                "description": "Rad repository for sorting",
-
                "defaultBranch": "master",
-
                "delegates": [
-
                  {
-
                    "id": DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                ],
-
                "seeds": 1,
-
              },
-
            ])
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_search_projects_pagination() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let app = super::router(seed(tmp.path()));
-
        let response = get(&app, "/projects/search?q=hello&perPage=1").await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(
-
            response.json().await,
-
            json!([
-
              {
-
                "rid": "rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp",
-
                "name": "hello-world",
-
                "description": "Rad repository for tests",
-
                "defaultBranch": "master",
-
                "delegates": [
-
                  {
-
                    "id": DID,
-
                    "alias": CONTRIBUTOR_ALIAS,
-
                  }
-
                ],
-
                "seeds": 1,
-
              },
-
            ])
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_not_found() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let app = super::router(seed(tmp.path()));
-
        let response = get(&app, "/projects/rad:z2u2CP3ZJzB7ZqE8jHrau19yjcfCQ").await;
-

-
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_commits_root() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let app = super::router(seed(tmp.path()));
-
        let response = get(&app, format!("/projects/{RID}/commits")).await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(
-
            response.json().await,
-
            json!([
-
                {
-
                  "id": HEAD,
-
                  "author": {
-
                    "name": "Alice Liddell",
-
                    "email": "alice@radicle.xyz"
-
                  },
-
                  "summary": "Add another folder",
-
                  "description": "",
-
                  "parents": [
-
                    "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
-
                  ],
-
                  "committer": {
-
                    "name": "Alice Liddell",
-
                    "email": "alice@radicle.xyz",
-
                    "time": 1673003014
-
                  },
-
                },
-
                {
-
                  "id": PARENT,
-
                  "author": {
-
                    "name": "Alice Liddell",
-
                    "email": "alice@radicle.xyz"
-
                  },
-
                  "summary": "Add contributing file",
-
                  "description": "",
-
                  "parents": [
-
                    "f604ce9fd5b7cc77b7609beda45ea8760bee78f7",
-
                  ],
-
                  "committer": {
-
                    "name": "Alice Liddell",
-
                    "email": "alice@radicle.xyz",
-
                    "time": 1673002014,
-
                  },
-
                },
-
                {
-
                  "id": INITIAL_COMMIT,
-
                  "author": {
-
                    "name": "Alice Liddell",
-
                    "email": "alice@radicle.xyz",
-
                  },
-
                  "summary": "Initial commit",
-
                  "description": "",
-
                  "parents": [],
-
                  "committer": {
-
                    "name": "Alice Liddell",
-
                    "email": "alice@radicle.xyz",
-
                    "time": 1673001014,
-
                  },
-
                },
-
            ])
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_commits() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let app = super::router(seed(tmp.path()));
-
        let response = get(&app, format!("/projects/{RID}/commits/{HEAD}")).await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
              "commit": {
-
                "id": HEAD,
-
                "author": {
-
                  "name": "Alice Liddell",
-
                  "email": "alice@radicle.xyz"
-
                },
-
                "summary": "Add another folder",
-
                "description": "",
-
                "parents": [
-
                  "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
-
                ],
-
                "committer": {
-
                  "name": "Alice Liddell",
-
                  "email": "alice@radicle.xyz",
-
                  "time": 1673003014
-
                },
-
              },
-
              "diff": {
-
                "files": [
-
                  {
-
                    "state": "deleted",
-
                    "path": "CONTRIBUTING",
-
                    "diff": {
-
                      "type": "plain",
-
                      "hunks": [
-
                        {
-
                          "header": "@@ -1 +0,0 @@\n",
-
                          "lines": [
-
                            {
-
                              "line": "Thank you very much!\n",
-
                              "lineNo": 1,
-
                              "type": "deletion",
-
                            },
-
                          ],
-
                          "old":  {
-
                            "start": 1,
-
                            "end": 2,
-
                          },
-
                          "new": {
-
                            "start": 0,
-
                            "end": 0,
-
                          },
-
                        },
-
                      ],
-
                      "stats": {
-
                        "additions": 0,
-
                        "deletions": 1,
-
                      },
-
                      "eof": "noneMissing",
-
                    },
-
                    "old": {
-
                      "oid": "82eb77880c693655bce074e3dbbd9fa711dc018b",
-
                      "mode": "blob",
-
                    },
-
                  },
-
                  {
-
                    "state": "added",
-
                    "path": "README",
-
                    "diff": {
-
                      "type": "plain",
-
                      "hunks": [
-
                        {
-
                          "header": "@@ -0,0 +1 @@\n",
-
                          "lines": [
-
                            {
-
                              "line": "Hello World!\n",
-
                              "lineNo": 1,
-
                              "type": "addition",
-
                            },
-
                          ],
-
                          "old":  {
-
                            "start": 0,
-
                            "end": 0,
-
                          },
-
                          "new": {
-
                            "start": 1,
-
                            "end": 2,
-
                          },
-
                        },
-
                      ],
-
                      "stats": {
-
                        "additions": 1,
-
                        "deletions": 0,
-
                      },
-
                      "eof": "noneMissing",
-
                    },
-
                    "new": {
-
                      "oid": "980a0d5f19a64b4b30a87d4206aade58726b60e3",
-
                      "mode": "blob",
-
                    },
-
                  },
-
                  {
-
                    "state": "added",
-
                    "path": "dir1/README",
-
                    "diff": {
-
                      "type": "plain",
-
                      "hunks": [
-
                        {
-
                          "header": "@@ -0,0 +1 @@\n",
-
                          "lines": [
-
                            {
-
                              "line": "Hello World from dir1!\n",
-
                              "lineNo": 1,
-
                              "type": "addition"
-
                            }
-
                          ],
-
                          "old":  {
-
                            "start": 0,
-
                            "end": 0,
-
                          },
-
                          "new": {
-
                            "start": 1,
-
                            "end": 2,
-
                          },
-
                        }
-
                      ],
-
                      "stats": {
-
                        "additions": 1,
-
                        "deletions": 0,
-
                      },
-
                      "eof": "noneMissing",
-
                    },
-
                    "new": {
-
                      "oid": "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1",
-
                      "mode": "blob",
-
                    },
-
                  },
-
                ],
-
                "stats": {
-
                  "filesChanged": 3,
-
                  "insertions": 2,
-
                  "deletions": 1
-
                }
-
              },
-
              "files": {
-
                "82eb77880c693655bce074e3dbbd9fa711dc018b": {
-
                  "id": "82eb77880c693655bce074e3dbbd9fa711dc018b",
-
                  "binary": false,
-
                  "content": "Thank you very much!\n",
-
                },
-
                "980a0d5f19a64b4b30a87d4206aade58726b60e3": {
-
                  "id": "980a0d5f19a64b4b30a87d4206aade58726b60e3",
-
                  "binary": false,
-
                  "content": "Hello World!\n",
-
                },
-
                "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1": {
-
                  "id": "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1",
-
                  "binary": false,
-
                  "content": "Hello World from dir1!\n",
-
                },
-
              },
-
              "branches": [
-
                "refs/heads/master"
-
              ]
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_commits_not_found() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let app = super::router(seed(tmp.path()));
-
        let response = get(
-
            &app,
-
            format!("/projects/{RID}/commits/ffffffffffffffffffffffffffffffffffffffff"),
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_stats() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let app = super::router(seed(tmp.path()));
-
        let response = get(&app, format!("/projects/{RID}/stats/tree/{HEAD}")).await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(
-
            response.json().await,
-
            json!(
-
              {
-
                "commits": 3,
-
                "branches": 1,
-
                "contributors": 1
-
              }
-
            )
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_tree() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let app = super::router(seed(tmp.path()));
-
        let response = get(&app, format!("/projects/{RID}/tree/{HEAD}/")).await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
                "entries": [
-
                  {
-
                    "path": "dir1",
-
                    "oid": "2d1c3cbfcf1d190d7fc77ac8f9e53db0e91a9ad3",
-
                    "name": "dir1",
-
                    "kind": "tree"
-
                  },
-
                  {
-
                    "path": "README",
-
                    "oid": "980a0d5f19a64b4b30a87d4206aade58726b60e3",
-
                    "name": "README",
-
                    "kind": "blob"
-
                  }
-
                ],
-
                "lastCommit": {
-
                  "id": HEAD,
-
                  "author": {
-
                    "name": "Alice Liddell",
-
                    "email": "alice@radicle.xyz"
-
                  },
-
                  "summary": "Add another folder",
-
                  "description": "",
-
                  "parents": [
-
                    "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
-
                  ],
-
                  "committer": {
-
                    "name": "Alice Liddell",
-
                    "email": "alice@radicle.xyz",
-
                    "time": 1673003014
-
                  },
-
                },
-
                "name": "",
-
                "path": "",
-
              }
-
            )
-
        );
-

-
        let response = get(&app, format!("/projects/{RID}/tree/{HEAD}/dir1")).await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
              "entries": [
-
                {
-
                  "path": "dir1/README",
-
                  "oid": "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1",
-
                  "name": "README",
-
                  "kind": "blob"
-
                }
-
              ],
-
              "lastCommit": {
-
                "id": HEAD,
-
                "author": {
-
                  "name": "Alice Liddell",
-
                  "email": "alice@radicle.xyz"
-
                },
-
                "summary": "Add another folder",
-
                "description": "",
-
                "parents": [
-
                  "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
-
                ],
-
                "committer": {
-
                  "name": "Alice Liddell",
-
                  "email": "alice@radicle.xyz",
-
                  "time": 1673003014
-
                },
-
              },
-
              "name": "dir1",
-
              "path": "dir1",
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_tree_not_found() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let app = super::router(seed(tmp.path()));
-
        let response = get(
-
            &app,
-
            format!("/projects/{RID}/tree/ffffffffffffffffffffffffffffffffffffffff"),
-
        )
-
        .await;
-
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
-

-
        let response = get(&app, format!("/projects/{RID}/tree/{HEAD}/unknown")).await;
-
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_remotes_root() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let app = super::router(seed(tmp.path()));
-
        let response = get(&app, format!("/projects/{RID}/remotes")).await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(
-
            response.json().await,
-
            json!([
-
              {
-
                "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
-
                "alias": CONTRIBUTOR_ALIAS,
-
                "heads": {
-
                  "master": HEAD
-
                },
-
                "delegate": true
-
              }
-
            ])
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_remotes() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let app = super::router(seed(tmp.path()));
-
        let response = get(
-
            &app,
-
            format!("/projects/{RID}/remotes/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"),
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
                "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
-
                "heads": {
-
                    "master": HEAD
-
                },
-
                "delegate": true
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_remotes_not_found() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let app = super::router(seed(tmp.path()));
-
        let response = get(
-
            &app,
-
            format!("/projects/{RID}/remotes/z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT"),
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_blob() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let app = super::router(seed(tmp.path()));
-
        let response = get(&app, format!("/projects/{RID}/blob/{HEAD}/README")).await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
                "binary": false,
-
                "name": "README",
-
                "path": "README",
-
                "lastCommit": {
-
                  "id": HEAD,
-
                  "author": {
-
                    "name": "Alice Liddell",
-
                    "email": "alice@radicle.xyz"
-
                  },
-
                  "summary": "Add another folder",
-
                  "description": "",
-
                  "parents": [
-
                    "ee8d6a29304623a78ebfa5eeed5af674d0e58f83"
-
                  ],
-
                  "committer": {
-
                    "name": "Alice Liddell",
-
                    "email": "alice@radicle.xyz",
-
                    "time": 1673003014
-
                  },
-
                },
-
                "content": "Hello World!\n",
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_blob_not_found() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let app = super::router(seed(tmp.path()));
-
        let response = get(&app, format!("/projects/{RID}/blob/{HEAD}/unknown")).await;
-

-
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_readme() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let app = super::router(seed(tmp.path()));
-
        let response = get(&app, format!("/projects/{RID}/readme/{INITIAL_COMMIT}")).await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
                "binary": false,
-
                "name": "README",
-
                "path": "README",
-
                "lastCommit": {
-
                  "id": INITIAL_COMMIT,
-
                  "author": {
-
                    "name": "Alice Liddell",
-
                    "email": "alice@radicle.xyz"
-
                  },
-
                  "summary": "Initial commit",
-
                  "description": "",
-
                  "parents": [],
-
                  "committer": {
-
                    "name": "Alice Liddell",
-
                    "email": "alice@radicle.xyz",
-
                    "time": 1673001014
-
                  },
-
                },
-
                "content": "Hello World!\n"
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_diff() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let app = super::router(seed(tmp.path()));
-
        let response = get(
-
            &app,
-
            format!("/projects/{RID}/diff/{INITIAL_COMMIT}/{HEAD}"),
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
                "diff": {
-
                  "files": [
-
                    {
-
                      "state": "added",
-
                      "path": "dir1/README",
-
                      "diff": {
-
                        "type": "plain",
-
                        "hunks": [
-
                          {
-
                            "header": "@@ -0,0 +1 @@\n",
-
                            "lines": [
-
                              {
-
                                "line": "Hello World from dir1!\n",
-
                                "lineNo": 1,
-
                                "type": "addition",
-
                              },
-
                            ],
-
                            "old":  {
-
                              "start": 0,
-
                              "end": 0,
-
                            },
-
                            "new": {
-
                              "start": 1,
-
                              "end": 2,
-
                            },
-
                          },
-
                        ],
-
                        "stats": {
-
                          "additions": 1,
-
                          "deletions": 0,
-
                        },
-
                        "eof": "noneMissing",
-
                      },
-
                      "new": {
-
                        "oid": "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1",
-
                        "mode": "blob",
-
                      },
-
                    },
-
                  ],
-
                  "stats": {
-
                    "filesChanged": 1,
-
                    "insertions": 1,
-
                    "deletions": 0,
-
                  },
-
                },
-
                "files": {
-
                  "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1": {
-
                    "id": "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1",
-
                    "binary": false,
-
                    "content": "Hello World from dir1!\n",
-
                  },
-
                },
-
                "commits": [
-
                  {
-
                    "id": HEAD,
-
                    "author": {
-
                      "name": "Alice Liddell",
-
                      "email": "alice@radicle.xyz",
-
                    },
-
                    "summary": "Add another folder",
-
                    "description": "",
-
                    "parents": [
-
                      "ee8d6a29304623a78ebfa5eeed5af674d0e58f83"
-
                    ],
-
                    "committer": {
-
                      "name": "Alice Liddell",
-
                      "email": "alice@radicle.xyz",
-
                      "time": 1673003014,
-
                    },
-
                  },
-
                  {
-
                    "id": PARENT,
-
                    "author": {
-
                      "name": "Alice Liddell",
-
                      "email": "alice@radicle.xyz",
-
                    },
-
                    "summary": "Add contributing file",
-
                    "description": "",
-
                    "parents": [
-
                      "f604ce9fd5b7cc77b7609beda45ea8760bee78f7",
-
                    ],
-
                    "committer": {
-
                      "name": "Alice Liddell",
-
                      "email": "alice@radicle.xyz",
-
                      "time": 1673002014,
-
                    }
-
                  }
-
                ],
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_issues_root() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let app = super::router(seed(tmp.path()));
-
        let response = get(&app, format!("/projects/{RID}/issues")).await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(
-
            response.json().await,
-
            json!([
-
              {
-
                "id": ISSUE_ID,
-
                "author": {
-
                  "id": DID,
-
                  "alias": CONTRIBUTOR_ALIAS
-
                },
-
                "title": "Issue #1",
-
                "state": {
-
                  "status": "open"
-
                },
-
                "assignees": [],
-
                "discussion": [
-
                  {
-
                    "id": ISSUE_ID,
-
                    "author": {
-
                      "id": DID,
-
                      "alias": CONTRIBUTOR_ALIAS
-
                    },
-
                    "body": "Change 'hello world' to 'hello everyone'",
-
                    "edits": [
-
                      {
-
                        "author": {
-
                          "id": DID,
-
                          "alias": CONTRIBUTOR_ALIAS
-
                        },
-
                        "body": "Change 'hello world' to 'hello everyone'",
-
                        "timestamp": TIMESTAMP,
-
                        "embeds": [],
-
                      },
-
                    ],
-
                    "embeds": [],
-
                    "reactions": [],
-
                    "timestamp": TIMESTAMP,
-
                    "replyTo": null,
-
                    "resolved": false,
-
                  }
-
                ],
-
                "labels": []
-
              }
-
            ])
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_issue() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let app = super::router(seed(tmp.path()));
-
        let response = get(&app, format!("/projects/{RID}/issues/{ISSUE_ID}")).await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
                "id": ISSUE_ID,
-
                "author": {
-
                  "id": DID,
-
                  "alias": CONTRIBUTOR_ALIAS
-
                },
-
                "title": "Issue #1",
-
                "state": {
-
                  "status": "open"
-
                },
-
                "assignees": [],
-
                "discussion": [
-
                  {
-
                    "id": ISSUE_ID,
-
                    "author": {
-
                      "id": DID,
-
                      "alias": CONTRIBUTOR_ALIAS
-
                    },
-
                    "body": "Change 'hello world' to 'hello everyone'",
-
                    "edits": [
-
                      {
-
                        "author": {
-
                          "id": DID,
-
                          "alias": CONTRIBUTOR_ALIAS
-
                        },
-
                        "body": "Change 'hello world' to 'hello everyone'",
-
                        "timestamp": TIMESTAMP,
-
                        "embeds": [],
-
                      },
-
                    ],
-
                    "embeds": [],
-
                    "reactions": [],
-
                    "timestamp": TIMESTAMP,
-
                    "replyTo": null,
-
                    "resolved": false,
-
                  }
-
                ],
-
                "labels": []
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_patches_root() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let app = super::router(seed(tmp.path()));
-
        let response = get(&app, format!("/projects/{RID}/patches")).await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(
-
            response.json().await,
-
            json!([
-
                {
-
                    "id": PATCH_ID,
-
                    "author": {
-
                        "id": DID,
-
                        "alias": CONTRIBUTOR_ALIAS,
-
                    },
-
                    "title": "A new `hello world`",
-
                    "state": {
-
                        "status": "open",
-
                    },
-
                    "target": "delegates",
-
                    "labels": [],
-
                    "merges": [],
-
                    "assignees": [],
-
                    "revisions": [
-
                        {
-
                            "id": PATCH_ID,
-
                            "author": {
-
                                "id": DID,
-
                                "alias": CONTRIBUTOR_ALIAS,
-
                            },
-
                            "description": "change `hello world` in README to something else",
-
                            "edits": [
-
                                {
-
                                    "author": {
-
                                        "id": DID,
-
                                        "alias": CONTRIBUTOR_ALIAS,
-
                                    },
-
                                    "body": "change `hello world` in README to something else",
-
                                    "timestamp": TIMESTAMP,
-
                                    "embeds": [],
-
                                },
-
                            ],
-
                            "reactions": [],
-
                            "base": "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
-
                            "oid": "e8c676b9e3b42308dc9d218b70faa5408f8e58ca",
-
                            "refs": [
-
                                "refs/heads/master",
-
                            ],
-
                            "discussions": [],
-
                            "timestamp": TIMESTAMP,
-
                            "reviews": [],
-
                        },
-
                    ],
-
                },
-
                ]
-
            )
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_patch() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let app = super::router(seed(tmp.path()));
-
        let response = get(&app, format!("/projects/{RID}/patches/{PATCH_ID}")).await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
                "id": PATCH_ID,
-
                "author": {
-
                    "id": DID,
-
                    "alias": CONTRIBUTOR_ALIAS,
-
                },
-
                "title": "A new `hello world`",
-
                "state": {
-
                    "status": "open",
-
                },
-
                "target": "delegates",
-
                "labels": [],
-
                "merges": [],
-
                "assignees": [],
-
                "revisions": [
-
                    {
-
                        "id": PATCH_ID,
-
                        "author": {
-
                            "id": DID,
-
                            "alias": CONTRIBUTOR_ALIAS,
-
                        },
-
                        "description": "change `hello world` in README to something else",
-
                        "edits": [
-
                            {
-
                                "author": {
-
                                    "id": DID,
-
                                    "alias": CONTRIBUTOR_ALIAS,
-
                                },
-
                                "body": "change `hello world` in README to something else",
-
                                "timestamp": TIMESTAMP,
-
                                "embeds": [],
-
                            },
-
                        ],
-
                        "reactions": [],
-
                        "base": "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
-
                        "oid": "e8c676b9e3b42308dc9d218b70faa5408f8e58ca",
-
                        "refs": [
-
                            "refs/heads/master",
-
                        ],
-
                        "discussions": [],
-
                        "timestamp": TIMESTAMP,
-
                        "reviews": [],
-
                    },
-
                ],
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_private() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = seed(tmp.path());
-
        let app = super::router(ctx.to_owned());
-

-
        // Check that the repo exists.
-
        ctx.profile()
-
            .storage
-
            .repository(RID_PRIVATE.parse().unwrap())
-
            .unwrap();
-

-
        let response = get(&app, format!("/projects/{RID_PRIVATE}")).await;
-
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
-

-
        let response = get(&app, format!("/projects/{RID_PRIVATE}/patches")).await;
-
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
-

-
        let response = get(&app, format!("/projects/{RID_PRIVATE}/issues")).await;
-
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
-

-
        let response = get(&app, format!("/projects/{RID_PRIVATE}/commits")).await;
-
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
-

-
        let response = get(&app, format!("/projects/{RID_PRIVATE}/remotes")).await;
-
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
-
    }
-
}
added radicle-httpd/src/api/v1/repos.rs
@@ -0,0 +1,1844 @@
+
use std::collections::{BTreeMap, BTreeSet, HashMap};
+

+
use axum::extract::{DefaultBodyLimit, State};
+
use axum::http::header;
+
use axum::response::IntoResponse;
+
use axum::routing::get;
+
use axum::{Json, Router};
+
use hyper::StatusCode;
+
use radicle_surf::blob::BlobRef;
+
use radicle_surf::{diff, Glob, Oid, Repository};
+
use serde::{Deserialize, Serialize};
+
use serde_json::json;
+

+
use radicle::cob::{issue::cache::Issues as _, patch::cache::Patches as _};
+
use radicle::identity::RepoId;
+
use radicle::node::{AliasStore, NodeId};
+
use radicle::storage::{ReadRepository, ReadStorage, RemoteRepository};
+

+
use crate::api;
+
use crate::api::error::Error;
+
use crate::api::search::{SearchQueryString, SearchResult};
+
use crate::api::{CobsQuery, Context, PaginationQuery, RepoQuery};
+
use crate::axum_extra::{cached_response, immutable_response, Path, Query};
+

+
const MAX_BODY_LIMIT: usize = 4_194_304;
+

+
pub fn router(ctx: Context) -> Router {
+
    Router::new()
+
        .route("/repos", get(repo_root_handler))
+
        .route("/repos/search", get(repo_search_handler))
+
        .route("/repos/:rid", get(repo_handler))
+
        .route("/repos/:rid/commits", get(history_handler))
+
        .route("/repos/:rid/commits/:sha", get(commit_handler))
+
        .route("/repos/:rid/diff/:base/:oid", get(diff_handler))
+
        .route("/repos/:rid/activity", get(activity_handler))
+
        .route("/repos/:rid/tree/:sha/", get(tree_handler_root))
+
        .route("/repos/:rid/tree/:sha/*path", get(tree_handler))
+
        .route("/repos/:rid/stats/tree/:sha", get(stats_tree_handler))
+
        .route("/repos/:rid/remotes", get(remotes_handler))
+
        .route("/repos/:rid/remotes/:peer", get(remote_handler))
+
        .route("/repos/:rid/blob/:sha/*path", get(blob_handler))
+
        .route("/repos/:rid/readme/:sha", get(readme_handler))
+
        .route("/repos/:rid/issues", get(issues_handler))
+
        .route("/repos/:rid/issues/:id", get(issue_handler))
+
        .route("/repos/:rid/patches", get(patches_handler))
+
        .route("/repos/:rid/patches/:id", get(patch_handler))
+
        .with_state(ctx)
+
        .layer(DefaultBodyLimit::max(MAX_BODY_LIMIT))
+
}
+

+
/// List all repos.
+
/// `GET /repos`
+
async fn repo_root_handler(
+
    State(ctx): State<Context>,
+
    Query(qs): Query<PaginationQuery>,
+
) -> impl IntoResponse {
+
    let PaginationQuery {
+
        show,
+
        page,
+
        per_page,
+
    } = qs;
+
    let page = page.unwrap_or(0);
+
    let per_page = per_page.unwrap_or_else(|| match show {
+
        RepoQuery::Pinned => ctx.profile.config.web.pinned.repositories.len(),
+
        _ => 10,
+
    });
+
    let storage = &ctx.profile.storage;
+
    let pinned = &ctx.profile.config.web.pinned;
+
    let policies = ctx.profile.policies()?;
+

+
    let mut repos = match show {
+
        RepoQuery::All => storage
+
            .repositories()?
+
            .into_iter()
+
            .filter(|repo| repo.doc.visibility.is_public())
+
            .collect::<Vec<_>>(),
+
        RepoQuery::Pinned => storage
+
            .repositories_by_id(pinned.repositories.iter())?
+
            .into_iter()
+
            .filter(|repo| repo.doc.visibility.is_public())
+
            .collect::<Vec<_>>(),
+
    };
+
    repos.sort_by_key(|p| p.rid);
+

+
    let infos = repos
+
        .into_iter()
+
        .filter_map(|info| {
+
            if !policies.is_seeding(&info.rid).unwrap_or_default() {
+
                return None;
+
            }
+
            let Ok((repo, doc)) = ctx.repo(info.rid) else {
+
                return None;
+
            };
+
            let Ok(repo_info) = ctx.repo_info(&repo, doc) else {
+
                return None;
+
            };
+

+
            Some(repo_info)
+
        })
+
        .skip(page * per_page)
+
        .take(per_page)
+
        .collect::<Vec<_>>();
+

+
    Ok::<_, Error>(Json(infos))
+
}
+

+
/// Search repositories by name.
+
/// `GET /repos/search?q=<query>`
+
///
+
/// We obtain the byte index of the first character of the query that matches the repo name.
+
/// And skip if the query doesn't match the repo name.
+
///
+
/// Sorting algorithm:
+
/// If both byte indices are 0, compare by seeding count.
+
/// A repo name with a byte index of 0 should come before non-zero indices.
+
/// If both indices are non-zero and equal, then compare by seeding count.
+
/// If none of the above, all non-zero indices are compared by their seeding count primarily.
+
async fn repo_search_handler(
+
    State(ctx): State<Context>,
+
    Query(SearchQueryString { q, per_page, page }): Query<SearchQueryString>,
+
) -> impl IntoResponse {
+
    let q = q.unwrap_or_default();
+
    let page = page.unwrap_or(0);
+
    let per_page = per_page.unwrap_or(10);
+
    let storage = &ctx.profile.storage;
+
    let aliases = &ctx.profile.aliases();
+
    let db = &ctx.profile.database()?;
+
    let found_repos = storage
+
        .repositories()?
+
        .into_iter()
+
        .filter_map(|info| SearchResult::new(&q, info, db, aliases))
+
        .collect::<BTreeSet<SearchResult>>();
+

+
    let found_repos = found_repos
+
        .into_iter()
+
        .skip(page * per_page)
+
        .take(per_page)
+
        .collect::<Vec<_>>();
+

+
    Ok::<_, Error>(cached_response(found_repos, 600).into_response())
+
}
+

+
/// Get repo metadata.
+
/// `GET /repos/:rid`
+
async fn repo_handler(State(ctx): State<Context>, Path(rid): Path<RepoId>) -> impl IntoResponse {
+
    let (repo, doc) = ctx.repo(rid)?;
+
    let info = ctx.repo_info(&repo, doc)?;
+

+
    Ok::<_, Error>(Json(info))
+
}
+

+
#[derive(Serialize, Deserialize, Clone)]
+
#[serde(rename_all = "camelCase")]
+
pub struct CommitsQueryString {
+
    pub parent: Option<String>,
+
    pub since: Option<i64>,
+
    pub until: Option<i64>,
+
    pub page: Option<usize>,
+
    pub per_page: Option<usize>,
+
}
+

+
/// Get repo commit range.
+
/// `GET /repos/:rid/commits?parent=<sha>`
+
async fn history_handler(
+
    State(ctx): State<Context>,
+
    Path(rid): Path<RepoId>,
+
    Query(qs): Query<CommitsQueryString>,
+
) -> impl IntoResponse {
+
    let (repo, _) = ctx.repo(rid)?;
+
    let (_, head) = repo.head()?;
+
    let CommitsQueryString {
+
        since,
+
        until,
+
        parent,
+
        page,
+
        per_page,
+
    } = qs;
+

+
    // If the parent commit is provided, the response depends only on the query
+
    // string and not on the state of the repository. This means we can instruct
+
    // the caches to treat the response as immutable.
+
    let is_immutable = parent.is_some();
+

+
    let sha = match parent {
+
        Some(commit) => commit,
+
        None => head.to_string(),
+
    };
+
    let repo = Repository::open(repo.path())?;
+

+
    // If a pagination is defined, we do not want to paginate the commits, and we return all of them on the first page.
+
    let page = page.unwrap_or(0);
+
    let per_page = if per_page.is_none() && (since.is_some() || until.is_some()) {
+
        usize::MAX
+
    } else {
+
        per_page.unwrap_or(30)
+
    };
+

+
    let commits = repo
+
        .history(&sha)?
+
        .filter_map(|commit| {
+
            let commit = commit.ok()?;
+
            let time = commit.committer.time.seconds();
+
            let commit = api::json::commit(&commit);
+
            match (since, until) {
+
                (Some(since), Some(until)) if time >= since && time < until => Some(commit),
+
                (Some(since), None) if time >= since => Some(commit),
+
                (None, Some(until)) if time < until => Some(commit),
+
                (None, None) => Some(commit),
+
                _ => None,
+
            }
+
        })
+
        .skip(page * per_page)
+
        .take(per_page)
+
        .collect::<Vec<_>>();
+

+
    if is_immutable {
+
        Ok::<_, Error>(immutable_response(commits).into_response())
+
    } else {
+
        Ok::<_, Error>(Json(commits).into_response())
+
    }
+
}
+

+
/// Get repo commit.
+
/// `GET /repos/:rid/commits/:sha`
+
async fn commit_handler(
+
    State(ctx): State<Context>,
+
    Path((rid, sha)): Path<(RepoId, Oid)>,
+
) -> impl IntoResponse {
+
    let (repo, _) = ctx.repo(rid)?;
+
    let repo = Repository::open(repo.path())?;
+
    let commit = repo.commit(sha)?;
+

+
    let diff = repo.diff_commit(commit.id)?;
+
    let glob = Glob::all_heads().branches().and(Glob::all_remotes());
+
    let branches: Vec<String> = repo
+
        .revision_branches(commit.id, glob)?
+
        .iter()
+
        .map(|b| b.refname().to_string())
+
        .collect();
+

+
    let mut files: HashMap<Oid, BlobRef<'_>> = HashMap::new();
+
    diff.files().for_each(|file_diff| match file_diff {
+
        diff::FileDiff::Added(added) => {
+
            if let Ok(blob) = repo.blob_ref(added.new.oid) {
+
                files.insert(blob.id(), blob);
+
            }
+
        }
+
        diff::FileDiff::Deleted(deleted) => {
+
            if let Ok(old_blob) = repo.blob_ref(deleted.old.oid) {
+
                files.insert(old_blob.id(), old_blob);
+
            }
+
        }
+
        diff::FileDiff::Modified(modified) => {
+
            if let (Ok(old_blob), Ok(new_blob)) = (
+
                repo.blob_ref(modified.old.oid),
+
                repo.blob_ref(modified.new.oid),
+
            ) {
+
                files.insert(old_blob.id(), old_blob);
+
                files.insert(new_blob.id(), new_blob);
+
            }
+
        }
+
        diff::FileDiff::Moved(moved) => {
+
            if let (Ok(old_blob), Ok(new_blob)) =
+
                (repo.blob_ref(moved.old.oid), repo.blob_ref(moved.new.oid))
+
            {
+
                files.insert(old_blob.id(), old_blob);
+
                files.insert(new_blob.id(), new_blob);
+
            }
+
        }
+
        diff::FileDiff::Copied(copied) => {
+
            if let (Ok(old_blob), Ok(new_blob)) =
+
                (repo.blob_ref(copied.old.oid), repo.blob_ref(copied.new.oid))
+
            {
+
                files.insert(old_blob.id(), old_blob);
+
                files.insert(new_blob.id(), new_blob);
+
            }
+
        }
+
    });
+

+
    let response: serde_json::Value = json!({
+
      "commit": api::json::commit(&commit),
+
      "diff": diff,
+
      "files": files,
+
      "branches": branches
+
    });
+
    Ok::<_, Error>(immutable_response(response))
+
}
+

+
/// Get diff between two commits
+
/// `GET /repos/:rid/diff/:base/:oid`
+
async fn diff_handler(
+
    State(ctx): State<Context>,
+
    Path((rid, base, oid)): Path<(RepoId, Oid, Oid)>,
+
) -> impl IntoResponse {
+
    let (repo, _) = ctx.repo(rid)?;
+
    let repo = Repository::open(repo.path())?;
+
    let base = repo.commit(base)?;
+
    let commit = repo.commit(oid)?;
+
    let diff = repo.diff(base.id, commit.id)?;
+
    let mut files: HashMap<Oid, BlobRef<'_>> = HashMap::new();
+
    diff.files().for_each(|file_diff| match file_diff {
+
        diff::FileDiff::Added(added) => {
+
            if let Ok(new_blob) = repo.blob_ref(added.new.oid) {
+
                files.insert(new_blob.id(), new_blob);
+
            }
+
        }
+
        diff::FileDiff::Deleted(deleted) => {
+
            if let Ok(old_blob) = repo.blob_ref(deleted.old.oid) {
+
                files.insert(old_blob.id(), old_blob);
+
            }
+
        }
+
        diff::FileDiff::Modified(modified) => {
+
            if let (Ok(new_blob), Ok(old_blob)) = (
+
                repo.blob_ref(modified.old.oid),
+
                repo.blob_ref(modified.new.oid),
+
            ) {
+
                files.insert(new_blob.id(), new_blob);
+
                files.insert(old_blob.id(), old_blob);
+
            }
+
        }
+
        diff::FileDiff::Moved(moved) => {
+
            if let (Ok(new_blob), Ok(old_blob)) =
+
                (repo.blob_ref(moved.new.oid), repo.blob_ref(moved.old.oid))
+
            {
+
                files.insert(new_blob.id(), new_blob);
+
                files.insert(old_blob.id(), old_blob);
+
            }
+
        }
+
        diff::FileDiff::Copied(copied) => {
+
            if let (Ok(new_blob), Ok(old_blob)) =
+
                (repo.blob_ref(copied.new.oid), repo.blob_ref(copied.old.oid))
+
            {
+
                files.insert(new_blob.id(), new_blob);
+
                files.insert(old_blob.id(), old_blob);
+
            }
+
        }
+
    });
+

+
    let commits = repo
+
        .history(commit.id)?
+
        .take_while(|c| {
+
            if let Ok(c) = c {
+
                c.id != base.id
+
            } else {
+
                false
+
            }
+
        })
+
        .map(|r| r.map(|c| api::json::commit(&c)))
+
        .collect::<Result<Vec<_>, _>>()?;
+

+
    let response = json!({ "diff": diff, "files": files, "commits": commits });
+

+
    Ok::<_, Error>(immutable_response(response))
+
}
+

+
/// Get repo activity for the past year.
+
/// `GET /repos/:rid/activity`
+
async fn activity_handler(
+
    State(ctx): State<Context>,
+
    Path(rid): Path<RepoId>,
+
) -> impl IntoResponse {
+
    let (repo, _) = ctx.repo(rid)?;
+
    let current_date = chrono::Utc::now().timestamp();
+
    // SAFETY: The number of weeks is static and not out of bounds.
+
    #[allow(clippy::unwrap_used)]
+
    let one_year_ago = chrono::Duration::try_weeks(52).unwrap();
+
    let repo = Repository::open(repo.path())?;
+
    let head = repo.head()?;
+
    let timestamps = repo
+
        .history(head)?
+
        .filter_map(|a| {
+
            if let Ok(a) = a {
+
                let seconds = a.committer.time.seconds();
+
                if seconds > current_date - one_year_ago.num_seconds() {
+
                    return Some(seconds);
+
                }
+
            }
+
            None
+
        })
+
        .collect::<Vec<i64>>();
+

+
    Ok::<_, Error>(cached_response(json!({ "activity": timestamps }), 3600))
+
}
+

+
/// Get repo source tree for '/' path.
+
/// `GET /repos/:rid/tree/:sha/`
+
async fn tree_handler_root(
+
    State(ctx): State<Context>,
+
    Path((rid, sha)): Path<(RepoId, Oid)>,
+
) -> impl IntoResponse {
+
    tree_handler(State(ctx), Path((rid, sha, String::new()))).await
+
}
+

+
/// Get repo source tree.
+
/// `GET /repos/:rid/tree/:sha/*path`
+
async fn tree_handler(
+
    State(ctx): State<Context>,
+
    Path((rid, sha, path)): Path<(RepoId, Oid, String)>,
+
) -> impl IntoResponse {
+
    let (repo, _) = ctx.repo(rid)?;
+

+
    if let Some(ref cache) = ctx.cache {
+
        let cache = &mut cache.tree.lock().await;
+
        if let Some(response) = cache.get(&(rid, sha, path.clone())) {
+
            return Ok::<_, Error>(immutable_response(response.clone()));
+
        }
+
    }
+

+
    let repo = Repository::open(repo.path())?;
+
    let tree = repo.tree(sha, &path)?;
+
    let response = api::json::tree(&tree, &path);
+

+
    if let Some(cache) = &ctx.cache {
+
        let cache = &mut cache.tree.lock().await;
+
        cache.put((rid, sha, path.clone()), response.clone());
+
    }
+

+
    Ok::<_, Error>(immutable_response(response))
+
}
+

+
/// Get repo source tree stats.
+
/// `GET /repos/:rid/stats/tree/:sha`
+
async fn stats_tree_handler(
+
    State(ctx): State<Context>,
+
    Path((rid, sha)): Path<(RepoId, Oid)>,
+
) -> impl IntoResponse {
+
    let (repo, _) = ctx.repo(rid)?;
+
    let repo = Repository::open(repo.path())?;
+
    let stats = repo.stats_from(&sha)?;
+

+
    Ok::<_, Error>(immutable_response(stats))
+
}
+

+
/// Get all repo remotes.
+
/// `GET /repos/:rid/remotes`
+
async fn remotes_handler(State(ctx): State<Context>, Path(rid): Path<RepoId>) -> impl IntoResponse {
+
    let (repo, doc) = ctx.repo(rid)?;
+
    let delegates = &doc.delegates;
+
    let aliases = &ctx.profile.aliases();
+
    let remotes = repo
+
        .remotes()?
+
        .filter_map(|r| r.map(|r| r.1).ok())
+
        .map(|remote| {
+
            let refs = remote
+
                .refs
+
                .iter()
+
                .filter_map(|(r, oid)| {
+
                    r.as_str()
+
                        .strip_prefix("refs/heads/")
+
                        .map(|head| (head.to_string(), oid))
+
                })
+
                .collect::<BTreeMap<String, &Oid>>();
+

+
            match aliases.alias(&remote.id) {
+
                Some(alias) => json!({
+
                    "id": remote.id,
+
                    "alias": alias,
+
                    "heads": refs,
+
                    "delegate": delegates.contains(&remote.id.into()),
+
                }),
+
                None => json!({
+
                    "id": remote.id,
+
                    "heads": refs,
+
                    "delegate": delegates.contains(&remote.id.into()),
+
                }),
+
            }
+
        })
+
        .collect::<Vec<_>>();
+

+
    Ok::<_, Error>(Json(remotes))
+
}
+

+
/// Get repo remote.
+
/// `GET /repos/:rid/remotes/:peer`
+
async fn remote_handler(
+
    State(ctx): State<Context>,
+
    Path((rid, node_id)): Path<(RepoId, NodeId)>,
+
) -> impl IntoResponse {
+
    let (repo, doc) = ctx.repo(rid)?;
+
    let delegates = &doc.delegates;
+
    let remote = repo.remote(&node_id)?;
+
    let refs = remote
+
        .refs
+
        .iter()
+
        .filter_map(|(r, oid)| {
+
            r.as_str()
+
                .strip_prefix("refs/heads/")
+
                .map(|head| (head.to_string(), oid))
+
        })
+
        .collect::<BTreeMap<String, &Oid>>();
+
    let remote = json!({
+
        "id": remote.id,
+
        "heads": refs,
+
        "delegate": delegates.contains(&remote.id.into()),
+
    });
+

+
    Ok::<_, Error>(Json(remote))
+
}
+

+
/// Get repo source file.
+
/// `GET /repos/:rid/blob/:sha/*path`
+
async fn blob_handler(
+
    State(ctx): State<Context>,
+
    Path((rid, sha, path)): Path<(RepoId, Oid, String)>,
+
) -> impl IntoResponse {
+
    let (repo, _) = ctx.repo(rid)?;
+
    let repo = Repository::open(repo.path())?;
+
    let blob = repo.blob(sha, &path)?;
+

+
    if blob.size() > MAX_BODY_LIMIT {
+
        return Ok::<_, Error>(
+
            (
+
                StatusCode::PAYLOAD_TOO_LARGE,
+
                [(header::CACHE_CONTROL, "no-cache")],
+
                Json(json!([])),
+
            )
+
                .into_response(),
+
        );
+
    }
+
    Ok::<_, Error>(immutable_response(api::json::blob(&blob, &path)).into_response())
+
}
+

+
/// Get repo readme.
+
/// `GET /repos/:rid/readme/:sha`
+
async fn readme_handler(
+
    State(ctx): State<Context>,
+
    Path((rid, sha)): Path<(RepoId, Oid)>,
+
) -> impl IntoResponse {
+
    let (repo, _) = ctx.repo(rid)?;
+
    let repo = Repository::open(repo.path())?;
+
    let paths = [
+
        "README",
+
        "README.md",
+
        "README.markdown",
+
        "README.txt",
+
        "README.rst",
+
        "README.org",
+
        "Readme.md",
+
    ];
+

+
    for path in paths
+
        .iter()
+
        .map(ToString::to_string)
+
        .chain(paths.iter().map(|p| p.to_lowercase()))
+
    {
+
        if let Ok(blob) = repo.blob(sha, &path) {
+
            if blob.size() > MAX_BODY_LIMIT {
+
                return Ok::<_, Error>(
+
                    (
+
                        StatusCode::PAYLOAD_TOO_LARGE,
+
                        [(header::CACHE_CONTROL, "no-cache")],
+
                        Json(json!([])),
+
                    )
+
                        .into_response(),
+
                );
+
            }
+

+
            return Ok::<_, Error>(
+
                immutable_response(api::json::blob(&blob, &path)).into_response(),
+
            );
+
        }
+
    }
+

+
    Err(Error::NotFound)
+
}
+

+
/// Get repo issues list.
+
/// `GET /repos/:rid/issues`
+
async fn issues_handler(
+
    State(ctx): State<Context>,
+
    Path(rid): Path<RepoId>,
+
    Query(qs): Query<CobsQuery<api::IssueStatus>>,
+
) -> impl IntoResponse {
+
    let (repo, _) = ctx.repo(rid)?;
+
    let CobsQuery {
+
        page,
+
        per_page,
+
        status,
+
    } = qs;
+
    let page = page.unwrap_or(0);
+
    let per_page = per_page.unwrap_or(10);
+
    let status = status.unwrap_or_default();
+
    let issues = ctx.profile.issues(&repo)?;
+
    let mut issues: Vec<_> = issues
+
        .list()?
+
        .filter_map(|r| {
+
            let (id, issue) = r.ok()?;
+
            (status.matches(issue.state())).then_some((id, issue))
+
        })
+
        .collect::<Vec<_>>();
+

+
    issues.sort_by(|(_, a), (_, b)| b.timestamp().cmp(&a.timestamp()));
+
    let aliases = &ctx.profile.aliases();
+
    let issues = issues
+
        .into_iter()
+
        .map(|(id, issue)| api::json::issue(id, issue, aliases))
+
        .skip(page * per_page)
+
        .take(per_page)
+
        .collect::<Vec<_>>();
+

+
    Ok::<_, Error>(Json(issues))
+
}
+

+
/// Get repo issue.
+
/// `GET /repos/:rid/issues/:id`
+
async fn issue_handler(
+
    State(ctx): State<Context>,
+
    Path((rid, issue_id)): Path<(RepoId, Oid)>,
+
) -> impl IntoResponse {
+
    let (repo, _) = ctx.repo(rid)?;
+
    let issue = ctx
+
        .profile
+
        .issues(&repo)?
+
        .get(&issue_id.into())?
+
        .ok_or(Error::NotFound)?;
+
    let aliases = ctx.profile.aliases();
+

+
    Ok::<_, Error>(Json(api::json::issue(issue_id.into(), issue, &aliases)))
+
}
+

+
/// Get repo patches list.
+
/// `GET /repos/:rid/patches`
+
async fn patches_handler(
+
    State(ctx): State<Context>,
+
    Path(rid): Path<RepoId>,
+
    Query(qs): Query<CobsQuery<api::PatchStatus>>,
+
) -> impl IntoResponse {
+
    let (repo, _) = ctx.repo(rid)?;
+
    let CobsQuery {
+
        page,
+
        per_page,
+
        status,
+
    } = qs;
+
    let page = page.unwrap_or(0);
+
    let per_page = per_page.unwrap_or(10);
+
    let status = status.unwrap_or_default();
+
    let patches = ctx.profile.patches(&repo)?;
+
    let mut patches = patches
+
        .list()?
+
        .filter_map(|r| {
+
            let (id, patch) = r.ok()?;
+
            (status.matches(patch.state())).then_some((id, patch))
+
        })
+
        .collect::<Vec<_>>();
+
    patches.sort_by(|(_, a), (_, b)| b.timestamp().cmp(&a.timestamp()));
+
    let aliases = ctx.profile.aliases();
+
    let patches = patches
+
        .into_iter()
+
        .map(|(id, patch)| api::json::patch(id, patch, &repo, &aliases))
+
        .skip(page * per_page)
+
        .take(per_page)
+
        .collect::<Vec<_>>();
+

+
    Ok::<_, Error>(Json(patches))
+
}
+

+
/// Get repo patch.
+
/// `GET /repos/:rid/patches/:id`
+
async fn patch_handler(
+
    State(ctx): State<Context>,
+
    Path((rid, patch_id)): Path<(RepoId, Oid)>,
+
) -> impl IntoResponse {
+
    let (repo, _) = ctx.repo(rid)?;
+
    let patches = ctx.profile.patches(&repo)?;
+
    let patch = patches.get(&patch_id.into())?.ok_or(Error::NotFound)?;
+
    let aliases = ctx.profile.aliases();
+

+
    Ok::<_, Error>(Json(api::json::patch(
+
        patch_id.into(),
+
        patch,
+
        &repo,
+
        &aliases,
+
    )))
+
}
+

+
#[cfg(test)]
+
mod routes {
+
    use std::net::SocketAddr;
+

+
    use axum::extract::connect_info::MockConnectInfo;
+
    use axum::http::StatusCode;
+
    use pretty_assertions::assert_eq;
+
    use radicle::storage::ReadStorage;
+
    use serde_json::json;
+

+
    use crate::test::*;
+

+
    #[tokio::test]
+
    async fn test_repos_root() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let seed = seed(tmp.path());
+
        let app = super::router(seed.clone())
+
            .layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))));
+
        let response = get(&app, "/repos?show=all").await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!([
+
              {
+
                "payloads": {
+
                  "xyz.radicle.project": {
+
                    "data": {
+
                      "defaultBranch": "master",
+
                      "description": "Rad repository for tests",
+
                      "name": "hello-world",
+
                    },
+
                    "meta": {
+
                      "head": HEAD,
+
                      "patches": {
+
                        "open": 1,
+
                        "draft": 0,
+
                        "archived": 0,
+
                        "merged": 0,
+
                      },
+
                      "issues": {
+
                        "open": 1,
+
                        "closed": 0,
+
                      },
+
                    }
+
                  }
+
                },
+
                "delegates": [
+
                  {
+
                    "id": DID,
+
                    "alias": CONTRIBUTOR_ALIAS
+
                  },
+
                ],
+
                "threshold": 1,
+
                "visibility": {
+
                  "type": "public"
+
                },
+
                "rid": RID,
+
                "seeding": 1,
+
              },
+
              {
+
                "payloads": {
+
                  "xyz.radicle.project": {
+
                    "data": {
+
                      "defaultBranch": "master",
+
                      "description": "Rad repository for sorting",
+
                      "name": "again-hello-world",
+
                    },
+
                    "meta": {
+
                      "head": "344dcd184df5bf37aab6c107fa9371a1c5b3321a",
+
                      "patches": {
+
                        "open": 0,
+
                        "draft": 0,
+
                        "archived": 0,
+
                        "merged": 0,
+
                      },
+
                      "issues": {
+
                        "open": 0,
+
                        "closed": 0,
+
                      },
+
                    }
+
                  }
+
                },
+
                "delegates": [
+
                  {
+
                    "id": DID,
+
                    "alias": CONTRIBUTOR_ALIAS
+
                  }
+
                ],
+
                "threshold": 1,
+
                "visibility": {
+
                  "type": "public"
+
                },
+
                "rid": "rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE",
+
                "seeding": 1,
+
              },
+
            ])
+
        );
+

+
        let app = super::router(seed).layer(MockConnectInfo(SocketAddr::from((
+
            [192, 168, 13, 37],
+
            8080,
+
        ))));
+
        let response = get(&app, "/repos?show=all").await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!([
+
              {
+
                "payloads": {
+
                  "xyz.radicle.project": {
+
                    "data": {
+
                      "defaultBranch": "master",
+
                      "description": "Rad repository for tests",
+
                      "name": "hello-world",
+
                    },
+
                    "meta": {
+
                      "head": HEAD,
+
                      "patches": {
+
                        "open": 1,
+
                        "draft": 0,
+
                        "archived": 0,
+
                        "merged": 0,
+
                      },
+
                      "issues": {
+
                        "open": 1,
+
                        "closed": 0,
+
                      },
+
                    }
+
                  }
+
                },
+
                "delegates": [
+
                  {
+
                    "id": DID,
+
                    "alias": CONTRIBUTOR_ALIAS
+
                  }
+
                ],
+
                "threshold": 1,
+
                "visibility": {
+
                  "type": "public"
+
                },
+
                "rid": RID,
+
                "seeding": 1,
+
              },
+
              {
+
                "payloads": {
+
                  "xyz.radicle.project": {
+
                    "data": {
+
                      "name": "again-hello-world",
+
                      "description": "Rad repository for sorting",
+
                      "defaultBranch": "master",
+
                    },
+
                    "meta": {
+
                      "head": "344dcd184df5bf37aab6c107fa9371a1c5b3321a",
+
                      "patches": {
+
                        "open": 0,
+
                        "draft": 0,
+
                        "archived": 0,
+
                        "merged": 0,
+
                      },
+
                      "issues": {
+
                        "open": 0,
+
                        "closed": 0,
+
                      },
+
                    }
+
                  }
+
                },
+
                "delegates": [
+
                  {
+
                    "id": DID,
+
                    "alias": CONTRIBUTOR_ALIAS
+
                  },
+
                ],
+
                "threshold": 1,
+
                "visibility": {
+
                  "type": "public"
+
                },
+
                "rid": "rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE",
+
                "seeding": 1,
+
              },
+
            ])
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_repos() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, format!("/repos/{RID}")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
                "payloads": {
+
                  "xyz.radicle.project": {
+
                    "data": {
+
                      "defaultBranch": "master",
+
                      "description": "Rad repository for tests",
+
                      "name": "hello-world",
+
                    },
+
                    "meta": {
+
                      "head": HEAD,
+
                      "patches": {
+
                        "open": 1,
+
                        "draft": 0,
+
                        "archived": 0,
+
                        "merged": 0,
+
                      },
+
                      "issues": {
+
                        "open": 1,
+
                        "closed": 0,
+
                      },
+
                    }
+
                  }
+
                },
+
               "delegates": [
+
                 {
+
                   "id": DID,
+
                   "alias": CONTRIBUTOR_ALIAS,
+
                 }
+
               ],
+
               "threshold": 1,
+
               "visibility": {
+
                 "type": "public"
+
               },
+
               "rid": RID,
+
               "seeding": 1,
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_search_repos() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, "/repos/search?q=hello").await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!([
+
              {
+
                "payloads": {
+
                  "xyz.radicle.project": {
+
                    "name": "hello-world",
+
                    "description": "Rad repository for tests",
+
                    "defaultBranch": "master",
+
                  }
+
                },
+
                "rid": "rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp",
+
                "delegates": [
+
                  {
+
                    "id": DID,
+
                    "alias": CONTRIBUTOR_ALIAS
+
                  }
+
                ],
+
                "seeds": 1,
+
              },
+
              {
+
                "payloads": {
+
                  "xyz.radicle.project": {
+
                    "name": "again-hello-world",
+
                    "description": "Rad repository for sorting",
+
                    "defaultBranch": "master",
+
                  },
+
                },
+
                "rid": "rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE",
+
                "delegates": [
+
                  {
+
                    "id": DID,
+
                    "alias": CONTRIBUTOR_ALIAS
+
                  },
+
                ],
+
                "seeds": 1,
+
              },
+
            ])
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_search_repos_pagination() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, "/repos/search?q=hello&perPage=1").await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!([
+
              {
+
                "rid": "rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp",
+
                "payloads": {
+
                  "xyz.radicle.project": {
+
                    "defaultBranch": "master",
+
                    "description": "Rad repository for tests",
+
                    "name": "hello-world",
+
                  },
+
                },
+
                "delegates": [
+
                  {
+
                    "id": DID,
+
                    "alias": CONTRIBUTOR_ALIAS,
+
                  }
+
                ],
+
                "seeds": 1,
+
              },
+
            ])
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_repos_not_found() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, "/repos/rad:z2u2CP3ZJzB7ZqE8jHrau19yjcfCQ").await;
+

+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+
    }
+

+
    #[tokio::test]
+
    async fn test_repos_commits_root() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, format!("/repos/{RID}/commits")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!([
+
                {
+
                  "id": HEAD,
+
                  "author": {
+
                    "name": "Alice Liddell",
+
                    "email": "alice@radicle.xyz"
+
                  },
+
                  "summary": "Add another folder",
+
                  "description": "",
+
                  "parents": [
+
                    "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
+
                  ],
+
                  "committer": {
+
                    "name": "Alice Liddell",
+
                    "email": "alice@radicle.xyz",
+
                    "time": 1673003014
+
                  },
+
                },
+
                {
+
                  "id": PARENT,
+
                  "author": {
+
                    "name": "Alice Liddell",
+
                    "email": "alice@radicle.xyz"
+
                  },
+
                  "summary": "Add contributing file",
+
                  "description": "",
+
                  "parents": [
+
                    "f604ce9fd5b7cc77b7609beda45ea8760bee78f7",
+
                  ],
+
                  "committer": {
+
                    "name": "Alice Liddell",
+
                    "email": "alice@radicle.xyz",
+
                    "time": 1673002014,
+
                  },
+
                },
+
                {
+
                  "id": INITIAL_COMMIT,
+
                  "author": {
+
                    "name": "Alice Liddell",
+
                    "email": "alice@radicle.xyz",
+
                  },
+
                  "summary": "Initial commit",
+
                  "description": "",
+
                  "parents": [],
+
                  "committer": {
+
                    "name": "Alice Liddell",
+
                    "email": "alice@radicle.xyz",
+
                    "time": 1673001014,
+
                  },
+
                },
+
            ])
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_repos_commits() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, format!("/repos/{RID}/commits/{HEAD}")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
              "commit": {
+
                "id": HEAD,
+
                "author": {
+
                  "name": "Alice Liddell",
+
                  "email": "alice@radicle.xyz"
+
                },
+
                "summary": "Add another folder",
+
                "description": "",
+
                "parents": [
+
                  "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
+
                ],
+
                "committer": {
+
                  "name": "Alice Liddell",
+
                  "email": "alice@radicle.xyz",
+
                  "time": 1673003014
+
                },
+
              },
+
              "diff": {
+
                "files": [
+
                  {
+
                    "state": "deleted",
+
                    "path": "CONTRIBUTING",
+
                    "diff": {
+
                      "type": "plain",
+
                      "hunks": [
+
                        {
+
                          "header": "@@ -1 +0,0 @@\n",
+
                          "lines": [
+
                            {
+
                              "line": "Thank you very much!\n",
+
                              "lineNo": 1,
+
                              "type": "deletion",
+
                            },
+
                          ],
+
                          "old":  {
+
                            "start": 1,
+
                            "end": 2,
+
                          },
+
                          "new": {
+
                            "start": 0,
+
                            "end": 0,
+
                          },
+
                        },
+
                      ],
+
                      "stats": {
+
                        "additions": 0,
+
                        "deletions": 1,
+
                      },
+
                      "eof": "noneMissing",
+
                    },
+
                    "old": {
+
                      "oid": "82eb77880c693655bce074e3dbbd9fa711dc018b",
+
                      "mode": "blob",
+
                    },
+
                  },
+
                  {
+
                    "state": "added",
+
                    "path": "README",
+
                    "diff": {
+
                      "type": "plain",
+
                      "hunks": [
+
                        {
+
                          "header": "@@ -0,0 +1 @@\n",
+
                          "lines": [
+
                            {
+
                              "line": "Hello World!\n",
+
                              "lineNo": 1,
+
                              "type": "addition",
+
                            },
+
                          ],
+
                          "old":  {
+
                            "start": 0,
+
                            "end": 0,
+
                          },
+
                          "new": {
+
                            "start": 1,
+
                            "end": 2,
+
                          },
+
                        },
+
                      ],
+
                      "stats": {
+
                        "additions": 1,
+
                        "deletions": 0,
+
                      },
+
                      "eof": "noneMissing",
+
                    },
+
                    "new": {
+
                      "oid": "980a0d5f19a64b4b30a87d4206aade58726b60e3",
+
                      "mode": "blob",
+
                    },
+
                  },
+
                  {
+
                    "state": "added",
+
                    "path": "dir1/README",
+
                    "diff": {
+
                      "type": "plain",
+
                      "hunks": [
+
                        {
+
                          "header": "@@ -0,0 +1 @@\n",
+
                          "lines": [
+
                            {
+
                              "line": "Hello World from dir1!\n",
+
                              "lineNo": 1,
+
                              "type": "addition"
+
                            }
+
                          ],
+
                          "old":  {
+
                            "start": 0,
+
                            "end": 0,
+
                          },
+
                          "new": {
+
                            "start": 1,
+
                            "end": 2,
+
                          },
+
                        }
+
                      ],
+
                      "stats": {
+
                        "additions": 1,
+
                        "deletions": 0,
+
                      },
+
                      "eof": "noneMissing",
+
                    },
+
                    "new": {
+
                      "oid": "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1",
+
                      "mode": "blob",
+
                    },
+
                  },
+
                ],
+
                "stats": {
+
                  "filesChanged": 3,
+
                  "insertions": 2,
+
                  "deletions": 1
+
                }
+
              },
+
              "files": {
+
                "82eb77880c693655bce074e3dbbd9fa711dc018b": {
+
                  "id": "82eb77880c693655bce074e3dbbd9fa711dc018b",
+
                  "binary": false,
+
                  "content": "Thank you very much!\n",
+
                },
+
                "980a0d5f19a64b4b30a87d4206aade58726b60e3": {
+
                  "id": "980a0d5f19a64b4b30a87d4206aade58726b60e3",
+
                  "binary": false,
+
                  "content": "Hello World!\n",
+
                },
+
                "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1": {
+
                  "id": "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1",
+
                  "binary": false,
+
                  "content": "Hello World from dir1!\n",
+
                },
+
              },
+
              "branches": [
+
                "refs/heads/master"
+
              ]
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_repos_commits_not_found() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(
+
            &app,
+
            format!("/repos/{RID}/commits/ffffffffffffffffffffffffffffffffffffffff"),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+
    }
+

+
    #[tokio::test]
+
    async fn test_repos_stats() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, format!("/repos/{RID}/stats/tree/{HEAD}")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!(
+
              {
+
                "commits": 3,
+
                "branches": 1,
+
                "contributors": 1
+
              }
+
            )
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_repos_tree() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, format!("/repos/{RID}/tree/{HEAD}/")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
                "entries": [
+
                  {
+
                    "path": "dir1",
+
                    "oid": "2d1c3cbfcf1d190d7fc77ac8f9e53db0e91a9ad3",
+
                    "name": "dir1",
+
                    "kind": "tree"
+
                  },
+
                  {
+
                    "path": "README",
+
                    "oid": "980a0d5f19a64b4b30a87d4206aade58726b60e3",
+
                    "name": "README",
+
                    "kind": "blob"
+
                  }
+
                ],
+
                "lastCommit": {
+
                  "id": HEAD,
+
                  "author": {
+
                    "name": "Alice Liddell",
+
                    "email": "alice@radicle.xyz"
+
                  },
+
                  "summary": "Add another folder",
+
                  "description": "",
+
                  "parents": [
+
                    "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
+
                  ],
+
                  "committer": {
+
                    "name": "Alice Liddell",
+
                    "email": "alice@radicle.xyz",
+
                    "time": 1673003014
+
                  },
+
                },
+
                "name": "",
+
                "path": "",
+
              }
+
            )
+
        );
+

+
        let response = get(&app, format!("/repos/{RID}/tree/{HEAD}/dir1")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
              "entries": [
+
                {
+
                  "path": "dir1/README",
+
                  "oid": "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1",
+
                  "name": "README",
+
                  "kind": "blob"
+
                }
+
              ],
+
              "lastCommit": {
+
                "id": HEAD,
+
                "author": {
+
                  "name": "Alice Liddell",
+
                  "email": "alice@radicle.xyz"
+
                },
+
                "summary": "Add another folder",
+
                "description": "",
+
                "parents": [
+
                  "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
+
                ],
+
                "committer": {
+
                  "name": "Alice Liddell",
+
                  "email": "alice@radicle.xyz",
+
                  "time": 1673003014
+
                },
+
              },
+
              "name": "dir1",
+
              "path": "dir1",
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_repos_tree_not_found() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(
+
            &app,
+
            format!("/repos/{RID}/tree/ffffffffffffffffffffffffffffffffffffffff"),
+
        )
+
        .await;
+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+

+
        let response = get(&app, format!("/repos/{RID}/tree/{HEAD}/unknown")).await;
+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+
    }
+

+
    #[tokio::test]
+
    async fn test_repos_remotes_root() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, format!("/repos/{RID}/remotes")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!([
+
              {
+
                "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
                "alias": CONTRIBUTOR_ALIAS,
+
                "heads": {
+
                  "master": HEAD
+
                },
+
                "delegate": true
+
              }
+
            ])
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_repos_remotes() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(
+
            &app,
+
            format!("/repos/{RID}/remotes/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
                "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
                "heads": {
+
                    "master": HEAD
+
                },
+
                "delegate": true
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_repos_remotes_not_found() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(
+
            &app,
+
            format!("/repos/{RID}/remotes/z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT"),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+
    }
+

+
    #[tokio::test]
+
    async fn test_repos_blob() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, format!("/repos/{RID}/blob/{HEAD}/README")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
                "binary": false,
+
                "name": "README",
+
                "path": "README",
+
                "lastCommit": {
+
                  "id": HEAD,
+
                  "author": {
+
                    "name": "Alice Liddell",
+
                    "email": "alice@radicle.xyz"
+
                  },
+
                  "summary": "Add another folder",
+
                  "description": "",
+
                  "parents": [
+
                    "ee8d6a29304623a78ebfa5eeed5af674d0e58f83"
+
                  ],
+
                  "committer": {
+
                    "name": "Alice Liddell",
+
                    "email": "alice@radicle.xyz",
+
                    "time": 1673003014
+
                  },
+
                },
+
                "content": "Hello World!\n",
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_repos_blob_not_found() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, format!("/repos/{RID}/blob/{HEAD}/unknown")).await;
+

+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+
    }
+

+
    #[tokio::test]
+
    async fn test_repos_readme() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, format!("/repos/{RID}/readme/{INITIAL_COMMIT}")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
                "binary": false,
+
                "name": "README",
+
                "path": "README",
+
                "lastCommit": {
+
                  "id": INITIAL_COMMIT,
+
                  "author": {
+
                    "name": "Alice Liddell",
+
                    "email": "alice@radicle.xyz"
+
                  },
+
                  "summary": "Initial commit",
+
                  "description": "",
+
                  "parents": [],
+
                  "committer": {
+
                    "name": "Alice Liddell",
+
                    "email": "alice@radicle.xyz",
+
                    "time": 1673001014
+
                  },
+
                },
+
                "content": "Hello World!\n"
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_repos_diff() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, format!("/repos/{RID}/diff/{INITIAL_COMMIT}/{HEAD}")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
                "diff": {
+
                  "files": [
+
                    {
+
                      "state": "added",
+
                      "path": "dir1/README",
+
                      "diff": {
+
                        "type": "plain",
+
                        "hunks": [
+
                          {
+
                            "header": "@@ -0,0 +1 @@\n",
+
                            "lines": [
+
                              {
+
                                "line": "Hello World from dir1!\n",
+
                                "lineNo": 1,
+
                                "type": "addition",
+
                              },
+
                            ],
+
                            "old":  {
+
                              "start": 0,
+
                              "end": 0,
+
                            },
+
                            "new": {
+
                              "start": 1,
+
                              "end": 2,
+
                            },
+
                          },
+
                        ],
+
                        "stats": {
+
                          "additions": 1,
+
                          "deletions": 0,
+
                        },
+
                        "eof": "noneMissing",
+
                      },
+
                      "new": {
+
                        "oid": "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1",
+
                        "mode": "blob",
+
                      },
+
                    },
+
                  ],
+
                  "stats": {
+
                    "filesChanged": 1,
+
                    "insertions": 1,
+
                    "deletions": 0,
+
                  },
+
                },
+
                "files": {
+
                  "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1": {
+
                    "id": "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1",
+
                    "binary": false,
+
                    "content": "Hello World from dir1!\n",
+
                  },
+
                },
+
                "commits": [
+
                  {
+
                    "id": HEAD,
+
                    "author": {
+
                      "name": "Alice Liddell",
+
                      "email": "alice@radicle.xyz",
+
                    },
+
                    "summary": "Add another folder",
+
                    "description": "",
+
                    "parents": [
+
                      "ee8d6a29304623a78ebfa5eeed5af674d0e58f83"
+
                    ],
+
                    "committer": {
+
                      "name": "Alice Liddell",
+
                      "email": "alice@radicle.xyz",
+
                      "time": 1673003014,
+
                    },
+
                  },
+
                  {
+
                    "id": PARENT,
+
                    "author": {
+
                      "name": "Alice Liddell",
+
                      "email": "alice@radicle.xyz",
+
                    },
+
                    "summary": "Add contributing file",
+
                    "description": "",
+
                    "parents": [
+
                      "f604ce9fd5b7cc77b7609beda45ea8760bee78f7",
+
                    ],
+
                    "committer": {
+
                      "name": "Alice Liddell",
+
                      "email": "alice@radicle.xyz",
+
                      "time": 1673002014,
+
                    }
+
                  }
+
                ],
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_repos_issues_root() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, format!("/repos/{RID}/issues")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!([
+
              {
+
                "id": ISSUE_ID,
+
                "author": {
+
                  "id": DID,
+
                  "alias": CONTRIBUTOR_ALIAS
+
                },
+
                "title": "Issue #1",
+
                "state": {
+
                  "status": "open"
+
                },
+
                "assignees": [],
+
                "discussion": [
+
                  {
+
                    "id": ISSUE_ID,
+
                    "author": {
+
                      "id": DID,
+
                      "alias": CONTRIBUTOR_ALIAS
+
                    },
+
                    "body": "Change 'hello world' to 'hello everyone'",
+
                    "edits": [
+
                      {
+
                        "author": {
+
                          "id": DID,
+
                          "alias": CONTRIBUTOR_ALIAS
+
                        },
+
                        "body": "Change 'hello world' to 'hello everyone'",
+
                        "timestamp": TIMESTAMP,
+
                        "embeds": [],
+
                      },
+
                    ],
+
                    "embeds": [],
+
                    "reactions": [],
+
                    "timestamp": TIMESTAMP,
+
                    "replyTo": null,
+
                    "resolved": false,
+
                  }
+
                ],
+
                "labels": []
+
              }
+
            ])
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_repos_issue() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, format!("/repos/{RID}/issues/{ISSUE_ID}")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
                "id": ISSUE_ID,
+
                "author": {
+
                  "id": DID,
+
                  "alias": CONTRIBUTOR_ALIAS
+
                },
+
                "title": "Issue #1",
+
                "state": {
+
                  "status": "open"
+
                },
+
                "assignees": [],
+
                "discussion": [
+
                  {
+
                    "id": ISSUE_ID,
+
                    "author": {
+
                      "id": DID,
+
                      "alias": CONTRIBUTOR_ALIAS
+
                    },
+
                    "body": "Change 'hello world' to 'hello everyone'",
+
                    "edits": [
+
                      {
+
                        "author": {
+
                          "id": DID,
+
                          "alias": CONTRIBUTOR_ALIAS
+
                        },
+
                        "body": "Change 'hello world' to 'hello everyone'",
+
                        "timestamp": TIMESTAMP,
+
                        "embeds": [],
+
                      },
+
                    ],
+
                    "embeds": [],
+
                    "reactions": [],
+
                    "timestamp": TIMESTAMP,
+
                    "replyTo": null,
+
                    "resolved": false,
+
                  }
+
                ],
+
                "labels": []
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_repos_patches_root() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, format!("/repos/{RID}/patches")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!([
+
                {
+
                    "id": PATCH_ID,
+
                    "author": {
+
                        "id": DID,
+
                        "alias": CONTRIBUTOR_ALIAS,
+
                    },
+
                    "title": "A new `hello world`",
+
                    "state": {
+
                        "status": "open",
+
                    },
+
                    "target": "delegates",
+
                    "labels": [],
+
                    "merges": [],
+
                    "assignees": [],
+
                    "revisions": [
+
                        {
+
                            "id": PATCH_ID,
+
                            "author": {
+
                                "id": DID,
+
                                "alias": CONTRIBUTOR_ALIAS,
+
                            },
+
                            "description": "change `hello world` in README to something else",
+
                            "edits": [
+
                                {
+
                                    "author": {
+
                                        "id": DID,
+
                                        "alias": CONTRIBUTOR_ALIAS,
+
                                    },
+
                                    "body": "change `hello world` in README to something else",
+
                                    "timestamp": TIMESTAMP,
+
                                    "embeds": [],
+
                                },
+
                            ],
+
                            "reactions": [],
+
                            "base": "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
+
                            "oid": "e8c676b9e3b42308dc9d218b70faa5408f8e58ca",
+
                            "refs": [
+
                                "refs/heads/master",
+
                            ],
+
                            "discussions": [],
+
                            "timestamp": TIMESTAMP,
+
                            "reviews": [],
+
                        },
+
                    ],
+
                },
+
                ]
+
            )
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_repos_patch() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, format!("/repos/{RID}/patches/{PATCH_ID}")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
                "id": PATCH_ID,
+
                "author": {
+
                    "id": DID,
+
                    "alias": CONTRIBUTOR_ALIAS,
+
                },
+
                "title": "A new `hello world`",
+
                "state": {
+
                    "status": "open",
+
                },
+
                "target": "delegates",
+
                "labels": [],
+
                "merges": [],
+
                "assignees": [],
+
                "revisions": [
+
                    {
+
                        "id": PATCH_ID,
+
                        "author": {
+
                            "id": DID,
+
                            "alias": CONTRIBUTOR_ALIAS,
+
                        },
+
                        "description": "change `hello world` in README to something else",
+
                        "edits": [
+
                            {
+
                                "author": {
+
                                    "id": DID,
+
                                    "alias": CONTRIBUTOR_ALIAS,
+
                                },
+
                                "body": "change `hello world` in README to something else",
+
                                "timestamp": TIMESTAMP,
+
                                "embeds": [],
+
                            },
+
                        ],
+
                        "reactions": [],
+
                        "base": "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
+
                        "oid": "e8c676b9e3b42308dc9d218b70faa5408f8e58ca",
+
                        "refs": [
+
                            "refs/heads/master",
+
                        ],
+
                        "discussions": [],
+
                        "timestamp": TIMESTAMP,
+
                        "reviews": [],
+
                    },
+
                ],
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_repos_private() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = seed(tmp.path());
+
        let app = super::router(ctx.to_owned());
+

+
        // Check that the repo exists.
+
        ctx.profile()
+
            .storage
+
            .repository(RID_PRIVATE.parse().unwrap())
+
            .unwrap();
+

+
        let response = get(&app, format!("/repos/{RID_PRIVATE}")).await;
+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+

+
        let response = get(&app, format!("/repos/{RID_PRIVATE}/patches")).await;
+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+

+
        let response = get(&app, format!("/repos/{RID_PRIVATE}/issues")).await;
+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+

+
        let response = get(&app, format!("/repos/{RID_PRIVATE}/commits")).await;
+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+

+
        let response = get(&app, format!("/repos/{RID_PRIVATE}/remotes")).await;
+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+
    }
+
}
modified radicle-httpd/src/git.rs
@@ -24,13 +24,13 @@ use crate::error::GitError as Error;

pub fn router(profile: Arc<Profile>, aliases: HashMap<String, RepoId>) -> Router {
    Router::new()
-
        .route("/:project/*request", any(git_handler))
+
        .route("/:rid/*request", any(git_handler))
        .with_state((profile, aliases))
}

async fn git_handler(
    State((profile, aliases)): State<(Arc<Profile>, HashMap<String, RepoId>)>,
-
    AxumPath((project, request)): AxumPath<(String, String)>,
+
    AxumPath((repository, request)): AxumPath<(String, String)>,
    method: Method,
    headers: HeaderMap,
    ConnectInfo(remote): ConnectInfo<SocketAddr>,
@@ -38,7 +38,7 @@ async fn git_handler(
    body: Bytes,
) -> impl IntoResponse {
    let query = query.0.unwrap_or_default();
-
    let name = project.strip_suffix(".git").unwrap_or(&project);
+
    let name = repository.strip_suffix(".git").unwrap_or(&repository);
    let rid: RepoId = match name.parse() {
        Ok(rid) => rid,
        Err(_) => {
modified radicle-httpd/src/lib.rs
@@ -150,7 +150,7 @@ async fn root_index_handler() -> impl IntoResponse {
                "type": "GET"
            },
            {
-
                "href": "/:project/*request",
+
                "href": "/:rid/*request",
                "rel": "git",
                "type": "GET"
            }
modified src/App.svelte
@@ -10,15 +10,15 @@
  import Hotkeys from "./App/Hotkeys.svelte";
  import LoadingBar from "./App/LoadingBar.svelte";

-
  import Commit from "@app/views/projects/Commit.svelte";
-
  import History from "@app/views/projects/History.svelte";
-
  import Issue from "@app/views/projects/Issue.svelte";
-
  import Issues from "@app/views/projects/Issues.svelte";
+
  import Commit from "@app/views/repos/Commit.svelte";
+
  import History from "@app/views/repos/History.svelte";
+
  import Issue from "@app/views/repos/Issue.svelte";
+
  import Issues from "@app/views/repos/Issues.svelte";
  import Nodes from "@app/views/nodes/View.svelte";
  import NotFound from "@app/views/NotFound.svelte";
-
  import Patch from "@app/views/projects/Patch.svelte";
-
  import Patches from "@app/views/projects/Patches.svelte";
-
  import Source from "@app/views/projects/Source.svelte";
+
  import Patch from "@app/views/repos/Patch.svelte";
+
  import Patches from "@app/views/repos/Patches.svelte";
+
  import Source from "@app/views/repos/Source.svelte";
  import Users from "@app/views/users/View.svelte";

  import Error from "@app/views/error/View.svelte";
@@ -63,19 +63,19 @@
  <Nodes {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "users"}
  <Users {...$activeRouteStore.params} />
-
{:else if $activeRouteStore.resource === "project.source"}
+
{:else if $activeRouteStore.resource === "repo.source"}
  <Source {...$activeRouteStore.params} />
-
{:else if $activeRouteStore.resource === "project.history"}
+
{:else if $activeRouteStore.resource === "repo.history"}
  <History {...$activeRouteStore.params} />
-
{:else if $activeRouteStore.resource === "project.commit"}
+
{:else if $activeRouteStore.resource === "repo.commit"}
  <Commit {...$activeRouteStore.params} />
-
{:else if $activeRouteStore.resource === "project.issues"}
+
{:else if $activeRouteStore.resource === "repo.issues"}
  <Issues {...$activeRouteStore.params} />
-
{:else if $activeRouteStore.resource === "project.issue"}
+
{:else if $activeRouteStore.resource === "repo.issue"}
  <Issue {...$activeRouteStore.params} />
-
{:else if $activeRouteStore.resource === "project.patches"}
+
{:else if $activeRouteStore.resource === "repo.patches"}
  <Patches {...$activeRouteStore.params} />
-
{:else if $activeRouteStore.resource === "project.patch"}
+
{:else if $activeRouteStore.resource === "repo.patch"}
  <Patch {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "error"}
  <Error {...$activeRouteStore.params} />
modified src/components/Markdown.svelte
@@ -134,7 +134,7 @@
    }

    // Iterate over all images, and replace the source with a canonicalized URL
-
    // pointing at the projects /raw endpoint.
+
    // pointing at the repos /raw endpoint.
    for (const i of container.querySelectorAll("img")) {
      const imagePath = i.getAttribute("src");

deleted src/components/ProjectCard.svelte
@@ -1,193 +0,0 @@
-
<script lang="ts">
-
  import type { ProjectInfo } from "./ProjectCard";
-

-
  import {
-
    absoluteTimestamp,
-
    formatTimestamp,
-
    formatRepositoryId,
-
    twemoji,
-
  } from "@app/lib/utils";
-

-
  import ActivityDiagram from "@app/components/ActivityDiagram.svelte";
-
  import Badge from "@app/components/Badge.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Link from "@app/components/Link.svelte";
-

-
  export let compact = false;
-
  export let projectInfo: ProjectInfo;
-

-
  $: project = projectInfo.project;
-
  $: baseUrl = projectInfo.baseUrl;
-
  $: isPrivate = project.visibility?.type === "private";
-
</script>
-

-
<style>
-
  .project-card {
-
    height: 10rem;
-
    border: 1px solid var(--color-border-default);
-
    border-radius: var(--border-radius-small);
-
    background-color: var(--color-background-float);
-
    padding: 0.75rem 1rem;
-
    position: relative;
-
    display: flex;
-
    flex-direction: column;
-
    justify-content: space-between;
-
    overflow: hidden;
-
  }
-

-
  .project-card.compact {
-
    height: 8rem;
-
  }
-

-
  .project-card:hover {
-
    background-color: var(--color-fill-float-hover);
-
  }
-

-
  .activity {
-
    position: absolute;
-
    bottom: 1.5rem;
-
    right: 0;
-
    width: calc(100% - 3rem);
-
    max-width: 24rem;
-
  }
-

-
  .activity > .fadeout-overlay {
-
    position: absolute;
-
    bottom: 0;
-
    right: 0;
-
    width: 100%;
-
    height: 100%;
-
    background: linear-gradient(
-
      to right,
-
      var(--color-background-float) 20%,
-
      rgba(255, 255, 255, 0) 100%
-
    );
-
  }
-

-
  .project-card:hover .fadeout-overlay {
-
    background: linear-gradient(
-
      to right,
-
      var(--color-fill-float-hover) 20%,
-
      rgba(255, 255, 255, 0) 100%
-
    );
-
  }
-

-
  .title {
-
    display: flex;
-
    flex-direction: column;
-
    gap: 0.125rem;
-
    position: relative;
-
  }
-

-
  .title * {
-
    line-clamp: 1;
-
    white-space: nowrap;
-
    overflow: hidden;
-
    text-overflow: ellipsis;
-
  }
-

-
  .title p {
-
    color: var(--color-foreground-dim);
-
  }
-

-
  .headline-and-badges {
-
    display: flex;
-
    justify-content: space-between;
-
    gap: 0.5rem;
-
  }
-

-
  .badges {
-
    display: flex;
-
    gap: 0.25rem;
-
    flex-shrink: 0;
-
  }
-

-
  .badge {
-
    display: flex;
-
    align-items: center;
-
    justify-content: center;
-
    border-radius: 50%;
-
    overflow: hidden;
-
    position: relative;
-
    padding: 0.25rem;
-
  }
-

-
  h4,
-
  p {
-
    margin: 0;
-
  }
-

-
  .stats-row {
-
    position: relative;
-
    display: flex;
-
    gap: 0.25rem;
-
    height: 1.5rem;
-
    align-items: center;
-
    white-space: nowrap;
-
  }
-
</style>
-

-
<Link
-
  route={{
-
    resource: "project.source",
-
    project: project.id,
-
    node: baseUrl,
-
  }}>
-
  <div class="project-card" class:compact>
-
    <div class="activity">
-
      <div class="fadeout-overlay" />
-
      <ActivityDiagram
-
        id={project.id}
-
        viewBoxHeight={200}
-
        styleColor="var(--color-foreground-primary"
-
        activity={projectInfo.activity} />
-
    </div>
-
    <div class="title">
-
      <div class="headline-and-badges">
-
        <h4 use:twemoji>{project.name}</h4>
-
        <div class="badges">
-
          {#if isPrivate}
-
            <div
-
              title="Private"
-
              class="badge"
-
              style="background-color: var(--color-fill-private); color: var(--color-foreground-yellow)">
-
              <Icon name="lock" />
-
            </div>
-
          {/if}
-
          <slot name="delegate" />
-
          <Badge
-
            variant="neutral"
-
            size="tiny"
-
            style="padding: 0 0.372rem; gap: 0.125rem;">
-
            <Icon name="seedling" />
-
            {projectInfo.project.seeding}
-
          </Badge>
-
        </div>
-
      </div>
-
      <p class="txt-small" use:twemoji>{project.description}</p>
-
    </div>
-
    <div>
-
      <div class="stats-row txt-tiny" style:color="var(--color-foreground-dim)">
-
        <Icon name="issue" />
-
        {project.issues.open} ·
-
        <Icon name="patch" />
-
        <span
-
          style:overflow="hidden"
-
          style:text-overflow="ellipsis"
-
          title={absoluteTimestamp(
-
            projectInfo.lastCommit.commit.committer.time,
-
          )}>
-
          {project.patches.open} · Updated {formatTimestamp(
-
            projectInfo.lastCommit.commit.committer.time,
-
          )}
-
        </span>
-
        <span
-
          title={project.id}
-
          style:color="var(--color-foreground-emphasized)"
-
          style:margin-left="auto">
-
          {formatRepositoryId(project.id)}
-
        </span>
-
      </div>
-
    </div>
-
  </div>
-
</Link>
deleted src/components/ProjectCard.ts
@@ -1,47 +0,0 @@
-
import type { ProjectListQuery } from "@http-client";
-

-
import { loadProjectActivity, type WeeklyActivity } from "@app/lib/commit";
-
import {
-
  HttpdClient,
-
  type BaseUrl,
-
  type Commit,
-
  type Project,
-
} from "@http-client";
-

-
export interface ProjectInfo {
-
  project: Project;
-
  baseUrl: BaseUrl;
-
  activity: WeeklyActivity[];
-
  lastCommit: Commit;
-
}
-

-
export async function fetchProjectInfos(
-
  baseUrl: BaseUrl,
-
  query?: ProjectListQuery,
-
  delegate?: string,
-
): Promise<ProjectInfo[]> {
-
  const api = new HttpdClient(baseUrl);
-
  let projects: Project[];
-

-
  if (delegate) {
-
    projects = await api.project.getByDelegate(delegate, query);
-
  } else {
-
    projects = await api.project.getAll(query);
-
  }
-
  const info = await Promise.all(
-
    projects.map(async project => {
-
      const [activity, lastCommit] = await Promise.all([
-
        loadProjectActivity(project.id, baseUrl),
-
        api.project.getCommitBySha(project.id, project.head),
-
      ]);
-
      return { project, activity, lastCommit, baseUrl };
-
    }),
-
  );
-

-
  return info.sort((a, b) => {
-
    const aLastCommit = a.lastCommit.commit.committer.time;
-
    const bLastCommit = b.lastCommit.commit.committer.time;
-

-
    return bLastCommit - aLastCommit;
-
  });
-
}
added src/components/RepoCard.svelte
@@ -0,0 +1,194 @@
+
<script lang="ts">
+
  import type { RepoInfo } from "./RepoCard";
+

+
  import {
+
    absoluteTimestamp,
+
    formatTimestamp,
+
    formatRepositoryId,
+
    twemoji,
+
  } from "@app/lib/utils";
+

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

+
  export let compact = false;
+
  export let repoInfo: RepoInfo;
+

+
  $: repo = repoInfo.repo;
+
  $: project = repoInfo.repo.payloads["xyz.radicle.project"];
+
  $: baseUrl = repoInfo.baseUrl;
+
  $: isPrivate = repo.visibility.type === "private";
+
</script>
+

+
<style>
+
  .repo-card {
+
    height: 10rem;
+
    border: 1px solid var(--color-border-default);
+
    border-radius: var(--border-radius-small);
+
    background-color: var(--color-background-float);
+
    padding: 0.75rem 1rem;
+
    position: relative;
+
    display: flex;
+
    flex-direction: column;
+
    justify-content: space-between;
+
    overflow: hidden;
+
  }
+

+
  .repo-card.compact {
+
    height: 8rem;
+
  }
+

+
  .repo-card:hover {
+
    background-color: var(--color-fill-float-hover);
+
  }
+

+
  .activity {
+
    position: absolute;
+
    bottom: 1.5rem;
+
    right: 0;
+
    width: calc(100% - 3rem);
+
    max-width: 24rem;
+
  }
+

+
  .activity > .fadeout-overlay {
+
    position: absolute;
+
    bottom: 0;
+
    right: 0;
+
    width: 100%;
+
    height: 100%;
+
    background: linear-gradient(
+
      to right,
+
      var(--color-background-float) 20%,
+
      rgba(255, 255, 255, 0) 100%
+
    );
+
  }
+

+
  .repo-card:hover .fadeout-overlay {
+
    background: linear-gradient(
+
      to right,
+
      var(--color-fill-float-hover) 20%,
+
      rgba(255, 255, 255, 0) 100%
+
    );
+
  }
+

+
  .title {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 0.125rem;
+
    position: relative;
+
  }
+

+
  .title * {
+
    line-clamp: 1;
+
    white-space: nowrap;
+
    overflow: hidden;
+
    text-overflow: ellipsis;
+
  }
+

+
  .title p {
+
    color: var(--color-foreground-dim);
+
  }
+

+
  .headline-and-badges {
+
    display: flex;
+
    justify-content: space-between;
+
    gap: 0.5rem;
+
  }
+

+
  .badges {
+
    display: flex;
+
    gap: 0.25rem;
+
    flex-shrink: 0;
+
  }
+

+
  .badge {
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
+
    border-radius: 50%;
+
    overflow: hidden;
+
    position: relative;
+
    padding: 0.25rem;
+
  }
+

+
  h4,
+
  p {
+
    margin: 0;
+
  }
+

+
  .stats-row {
+
    position: relative;
+
    display: flex;
+
    gap: 0.25rem;
+
    height: 1.5rem;
+
    align-items: center;
+
    white-space: nowrap;
+
  }
+
</style>
+

+
<Link
+
  route={{
+
    resource: "repo.source",
+
    repo: repo.rid,
+
    node: baseUrl,
+
  }}>
+
  <div class="repo-card" class:compact>
+
    <div class="activity">
+
      <div class="fadeout-overlay" />
+
      <ActivityDiagram
+
        id={repo.rid}
+
        viewBoxHeight={200}
+
        styleColor="var(--color-foreground-primary"
+
        activity={repoInfo.activity} />
+
    </div>
+
    <div class="title">
+
      <div class="headline-and-badges">
+
        <h4 use:twemoji>{project.data.name}</h4>
+
        <div class="badges">
+
          {#if isPrivate}
+
            <div
+
              title="Private"
+
              class="badge"
+
              style="background-color: var(--color-fill-private); color: var(--color-foreground-yellow)">
+
              <Icon name="lock" />
+
            </div>
+
          {/if}
+
          <slot name="delegate" />
+
          <Badge
+
            variant="neutral"
+
            size="tiny"
+
            style="padding: 0 0.372rem; gap: 0.125rem;">
+
            <Icon name="seedling" />
+
            {repoInfo.repo.seeding}
+
          </Badge>
+
        </div>
+
      </div>
+
      <p class="txt-small" use:twemoji>
+
        {project.data.description}
+
      </p>
+
    </div>
+
    <div>
+
      <div class="stats-row txt-tiny" style:color="var(--color-foreground-dim)">
+
        <Icon name="issue" />
+
        {project.meta.issues.open} ·
+
        <Icon name="patch" />
+
        <span
+
          style:overflow="hidden"
+
          style:text-overflow="ellipsis"
+
          title={absoluteTimestamp(repoInfo.lastCommit.commit.committer.time)}>
+
          {project.meta.patches.open} · Updated {formatTimestamp(
+
            repoInfo.lastCommit.commit.committer.time,
+
          )}
+
        </span>
+
        <span
+
          title={repo.rid}
+
          style:color="var(--color-foreground-emphasized)"
+
          style:margin-left="auto">
+
          {formatRepositoryId(repo.rid)}
+
        </span>
+
      </div>
+
    </div>
+
  </div>
+
</Link>
added src/components/RepoCard.ts
@@ -0,0 +1,47 @@
+
import type { Repo, RepoListQuery } from "@http-client";
+

+
import { loadRepoActivity, type WeeklyActivity } from "@app/lib/commit";
+
import { HttpdClient, type BaseUrl, type Commit } from "@http-client";
+

+
export interface RepoInfo {
+
  repo: Repo;
+
  baseUrl: BaseUrl;
+
  activity: WeeklyActivity[];
+
  lastCommit: Commit;
+
}
+

+
export async function fetchRepoInfos(
+
  baseUrl: BaseUrl,
+
  query?: RepoListQuery,
+
  delegate?: string,
+
): Promise<RepoInfo[]> {
+
  const api = new HttpdClient(baseUrl);
+
  let repos: Repo[];
+

+
  if (delegate) {
+
    repos = await api.repo.getByDelegate(delegate, query);
+
  } else {
+
    repos = await api.repo.getAll(query);
+
  }
+
  const info = await Promise.all(
+
    repos
+
      .filter(r => Boolean(r.payloads["xyz.radicle.project"]))
+
      .map(async repo => {
+
        const [activity, lastCommit] = await Promise.all([
+
          loadRepoActivity(repo.rid, baseUrl),
+
          api.repo.getCommitBySha(
+
            repo.rid,
+
            repo["payloads"]["xyz.radicle.project"].meta.head,
+
          ),
+
        ]);
+
        return { repo, activity, lastCommit, baseUrl };
+
      }),
+
  );
+

+
  return info.sort((a, b) => {
+
    const aLastCommit = a.lastCommit.commit.committer.time;
+
    const bLastCommit = b.lastCommit.commit.committer.time;
+

+
    return bLastCommit - aLastCommit;
+
  });
+
}
modified src/lib/commit.ts
@@ -110,9 +110,9 @@ function groupCommitsByWeek(commits: number[]): WeeklyActivity[] {
  return groupedCommits;
}

-
export async function loadProjectActivity(id: string, baseUrl: BaseUrl) {
+
export async function loadRepoActivity(id: string, baseUrl: BaseUrl) {
  const api = new HttpdClient(baseUrl);
-
  const commits = await api.project.getActivity(id);
+
  const commits = await api.repo.getActivity(id);

  return groupCommitsByWeek(commits.activity);
}
modified src/lib/markdown.ts
@@ -71,7 +71,7 @@ export class Renderer extends BaseRenderer {
      return `<a ${title ? `title="${title}"` : ""} href="${href.toLowerCase()}">${text}</a>`;
    }

-
    if (this.#route.resource === "project.source" && !isUrl(href)) {
+
    if (this.#route.resource === "repo.source" && !isUrl(href)) {
      href = routeToPath({
        ...this.#route,
        path: canonicalize(href, this.#route.path || "README.md"),
modified src/lib/router.ts
@@ -7,10 +7,10 @@ import * as mutexExecutor from "@app/lib/mutexExecutor";
import * as utils from "@app/lib/utils";
import config from "virtual:config";
import {
-
  projectRouteToPath,
-
  projectTitle,
-
  resolveProjectRoute,
-
} from "@app/views/projects/router";
+
  repoRouteToPath,
+
  repoTitle,
+
  resolveRepoRoute,
+
} from "@app/views/repos/router";
import { loadRoute } from "@app/lib/router/definitions";
import { nodePath } from "@app/views/nodes/router";
import { userRouteToPath, userTitle } from "@app/views/users/router";
@@ -119,15 +119,15 @@ function setTitle(loadedRoute: LoadedRoute) {
    title.push("Page not found");
    title.push("Radicle");
  } else if (
-
    loadedRoute.resource === "project.source" ||
-
    loadedRoute.resource === "project.history" ||
-
    loadedRoute.resource === "project.commit" ||
-
    loadedRoute.resource === "project.issue" ||
-
    loadedRoute.resource === "project.issues" ||
-
    loadedRoute.resource === "project.patches" ||
-
    loadedRoute.resource === "project.patch"
+
    loadedRoute.resource === "repo.source" ||
+
    loadedRoute.resource === "repo.history" ||
+
    loadedRoute.resource === "repo.commit" ||
+
    loadedRoute.resource === "repo.issue" ||
+
    loadedRoute.resource === "repo.issues" ||
+
    loadedRoute.resource === "repo.patches" ||
+
    loadedRoute.resource === "repo.patch"
  ) {
-
    title.push(...projectTitle(loadedRoute));
+
    title.push(...repoTitle(loadedRoute));
  } else if (loadedRoute.resource === "nodes") {
    title.push(loadedRoute.params.baseUrl.hostname);
  } else {
@@ -196,11 +196,11 @@ function urlToRoute(url: URL): Route | null {
          }
          return null;
        } else if (id) {
-
          return resolveProjectRoute(baseUrl, id, segments, url.search);
+
          return resolveRepoRoute(baseUrl, id, segments, url.search);
        } else {
          return {
            resource: "nodes",
-
            params: { baseUrl, projectPageIndex: 0 },
+
            params: { baseUrl, repoPageIndex: 0 },
          };
        }
      } else {
@@ -229,15 +229,15 @@ export function routeToPath(route: Route): string {
  } else if (route.resource === "users") {
    return userRouteToPath(route);
  } else if (
-
    route.resource === "project.source" ||
-
    route.resource === "project.history" ||
-
    route.resource === "project.commit" ||
-
    route.resource === "project.issues" ||
-
    route.resource === "project.issue" ||
-
    route.resource === "project.patches" ||
-
    route.resource === "project.patch"
+
    route.resource === "repo.source" ||
+
    route.resource === "repo.history" ||
+
    route.resource === "repo.commit" ||
+
    route.resource === "repo.issues" ||
+
    route.resource === "repo.issue" ||
+
    route.resource === "repo.patches" ||
+
    route.resource === "repo.patch"
  ) {
-
    return projectRouteToPath(route);
+
    return repoRouteToPath(route);
  } else if (
    route.resource === "booting" ||
    route.resource === "notFound" ||
modified src/lib/router/definitions.ts
@@ -2,16 +2,13 @@ import type {
  ResponseError,
  ResponseParseError,
} from "@http-client/lib/fetcher";
-
import type {
-
  ProjectLoadedRoute,
-
  ProjectRoute,
-
} from "@app/views/projects/router";
+
import type { RepoLoadedRoute, RepoRoute } from "@app/views/repos/router";
import type { UserLoadedRoute, UserRoute } from "@app/views/users/router";
import type { NodesRoute, NodesLoadedRoute } from "@app/views/nodes/router";
import type { ComponentProps } from "svelte";
import type IconLarge from "@app/components/IconLarge.svelte";

-
import { loadProjectRoute } from "@app/views/projects/router";
+
import { loadRepoRoute } from "@app/views/repos/router";
import { loadUserRoute } from "@app/views/users/router";
import { loadNodeRoute } from "@app/views/nodes/router";

@@ -41,7 +38,7 @@ export type Route =
  | UserRoute
  | ErrorRoute
  | NotFoundRoute
-
  | ProjectRoute
+
  | RepoRoute
  | NodesRoute;

export type LoadedRoute =
@@ -49,7 +46,7 @@ export type LoadedRoute =
  | UserLoadedRoute
  | ErrorRoute
  | NotFoundRoute
-
  | ProjectLoadedRoute
+
  | RepoLoadedRoute
  | NodesLoadedRoute;

export async function loadRoute(
@@ -61,15 +58,15 @@ export async function loadRoute(
  } else if (route.resource === "users") {
    return await loadUserRoute(route);
  } else if (
-
    route.resource === "project.source" ||
-
    route.resource === "project.history" ||
-
    route.resource === "project.commit" ||
-
    route.resource === "project.issues" ||
-
    route.resource === "project.issue" ||
-
    route.resource === "project.patches" ||
-
    route.resource === "project.patch"
+
    route.resource === "repo.source" ||
+
    route.resource === "repo.history" ||
+
    route.resource === "repo.commit" ||
+
    route.resource === "repo.issues" ||
+
    route.resource === "repo.issue" ||
+
    route.resource === "repo.patches" ||
+
    route.resource === "repo.patch"
  ) {
-
    return await loadProjectRoute(route, previousLoaded);
+
    return await loadRepoRoute(route, previousLoaded);
  } else {
    return route;
  }
modified src/lib/utils.ts
@@ -1,4 +1,4 @@
-
import type { BaseUrl } from "@http-client";
+
import type { Author, BaseUrl } from "@http-client";

import md5 from "md5";
import bs58 from "bs58";
@@ -80,10 +80,7 @@ export function formatCommit(oid: string): string {
  return oid.substring(0, 7);
}

-
export function formatEditedCaption(
-
  author: { id: string; alias?: string },
-
  timestamp: number,
-
) {
+
export function formatEditedCaption(author: Author, timestamp: number) {
  return `${
    author.alias ? author.alias : formatNodeId(author.id)
  } edited ${absoluteTimestamp(timestamp)}`;
modified src/views/nodes/SeedSelector.svelte
@@ -59,7 +59,7 @@
      }
      void push({
        resource: "nodes",
-
        params: { baseUrl: seed, projectPageIndex: 0 },
+
        params: { baseUrl: seed, repoPageIndex: 0 },
      });
      selectedSeed.set(seed);
    }
@@ -72,7 +72,7 @@
    seedAddressInput = seed.hostname;
    void push({
      resource: "nodes",
-
      params: { baseUrl: seed, projectPageIndex: 0 },
+
      params: { baseUrl: seed, repoPageIndex: 0 },
    });
  }
</script>
modified src/views/nodes/View.svelte
@@ -3,7 +3,7 @@

  import * as router from "@app/lib/router";
  import { baseUrlToString } from "@app/lib/utils";
-
  import { fetchProjectInfos } from "@app/components/ProjectCard";
+
  import { fetchRepoInfos } from "@app/components/RepoCard";
  import { handleError } from "@app/views/nodes/error";

  import Settings from "@app/App/Settings.svelte";
@@ -18,7 +18,7 @@
  import MobileFooter from "@app/App/MobileFooter.svelte";
  import Placeholder from "@app/components/Placeholder.svelte";
  import Popover from "@app/components/Popover.svelte";
-
  import ProjectCard from "@app/components/ProjectCard.svelte";
+
  import RepoCard from "@app/components/RepoCard.svelte";

  import PolicyExplainer from "./PolicyExplainer.svelte";
  import SeedSelector from "./SeedSelector.svelte";
@@ -135,10 +135,10 @@
    width: 100%;
    margin-top: 1rem;
  }
-
  .projects {
+
  .repos {
    margin-top: 0;
  }
-
  .project-grid {
+
  .repo-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(21rem, 1fr));
    gap: 1rem;
@@ -208,7 +208,7 @@
    .container {
      padding: 0;
    }
-
    .projects {
+
    .repos {
      margin-top: 3rem;
    }
    .mobile-footer {
@@ -406,21 +406,21 @@
            </div>
          {/if}

-
          <div class="projects">
-
            {#await fetchProjectInfos( baseUrl, { show: "pinned", perPage: stats.repos.total }, )}
+
          <div class="repos">
+
            {#await fetchRepoInfos( baseUrl, { show: "pinned", perPage: stats.repos.total }, )}
              <div style:height="35vh">
                <Loading small center />
              </div>
-
            {:then projectInfos}
-
              {#if projectInfos.length > 0}
-
                <div class="project-grid">
-
                  {#each projectInfos as projectInfo}
-
                    <ProjectCard {projectInfo} />
+
            {:then repoInfos}
+
              {#if repoInfos.length > 0}
+
                <div class="repo-grid">
+
                  {#each repoInfos as repoInfo}
+
                    <RepoCard {repoInfo} />
                  {/each}
                </div>
                <div class="subtitle">
-
                  {projectInfos.length}
-
                  pinned {projectInfos.length === 1
+
                  {repoInfos.length}
+
                  pinned {repoInfos.length === 1
                    ? "repository"
                    : "repositories"}
                </div>
modified src/views/nodes/router.ts
@@ -6,13 +6,13 @@ import { HttpdClient } from "@http-client";
import { ResponseError, ResponseParseError } from "@http-client/lib/fetcher";
import { baseUrlToString, isLocal } from "@app/lib/utils";
import { handleError } from "@app/views/nodes/error";
-
import { unreachableError } from "@app/views/projects/error";
+
import { unreachableError } from "@app/views/repos/error";
import { determineSeed } from "./SeedSelector";

export type NodesRouteParams =
  | {
      baseUrl: BaseUrl;
-
      projectPageIndex: number;
+
      repoPageIndex: number;
    }
  | undefined;

deleted src/views/projects/Changeset.svelte
@@ -1,130 +0,0 @@
-
<script lang="ts">
-
  import type { BaseUrl, CommitBlob, Diff } from "@http-client";
-

-
  import FileDiff from "@app/views/projects/Changeset/FileDiff.svelte";
-
  import FileLocationChange from "@app/views/projects/Changeset/FileLocationChange.svelte";
-
  import IconButton from "@app/components/IconButton.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Observer, { intersection } from "@app/components/Observer.svelte";
-

-
  export let diff: Diff;
-
  export let files: Record<string, CommitBlob>;
-
  export let baseUrl: BaseUrl;
-
  export let projectId: string;
-
  export let revision: string;
-

-
  let expanded = true;
-

-
  function pluralize(singular: string, count: number): string {
-
    return count === 1 ? singular : `${singular}s`;
-
  }
-

-
  const diffDescription = (diffFiles: Diff["files"]): string =>
-
    Object.entries(
-
      diffFiles.reduce(
-
        (acc, { state }) => {
-
          acc[state]++;
-
          return acc;
-
        },
-
        { added: 0, modified: 0, deleted: 0, copied: 0, moved: 0 },
-
      ),
-
    )
-
      .filter(([, count]) => count > 0)
-
      .map(([state, count]) => `${count} ${pluralize("file", count)} ${state}`)
-
      .join(", ");
-
</script>
-

-
<style>
-
  .header {
-
    display: flex;
-
    flex-direction: row;
-
    align-items: center;
-
    justify-content: space-between;
-
    padding: 1rem 1rem 0.5rem 1rem;
-
    background-color: var(--color-background-default);
-
  }
-
  .additions {
-
    color: var(--color-foreground-success);
-
    white-space: nowrap;
-
  }
-
  .deletions {
-
    color: var(--color-foreground-red);
-
    white-space: nowrap;
-
  }
-
  .diff-list {
-
    display: flex;
-
    flex-direction: column;
-
    gap: 1.5rem;
-
    background-color: var(--color-background-default);
-
    padding: 1rem;
-
  }
-
  .summary {
-
    font-size: var(--font-size-small);
-
  }
-
  @media (max-width: 719.98px) {
-
    .diff-list {
-
      padding: 1rem 0;
-
    }
-
  }
-
</style>
-

-
<div class="header">
-
  <div class="summary">
-
    <span>{diffDescription(diff.files)}</span>
-
    with
-
    <span class:additions={diff.stats.insertions > 0}>
-
      {diff.stats.insertions}
-
      {pluralize("insertion", diff.stats.insertions)}
-
    </span>
-
    and
-
    <span class:deletions={diff.stats.deletions > 0}>
-
      {diff.stats.deletions}
-
      {pluralize("deletion", diff.stats.deletions)}
-
    </span>
-
  </div>
-
  {#if diff.stats.filesChanged > 1}
-
    <IconButton on:click={() => (expanded = !expanded)}>
-
      {#if expanded === true}
-
        <Icon name="collapse" />
-
        <span class="global-hide-on-mobile-down">Collapse all</span>
-
      {:else}
-
        <Icon name="expand" />
-
        <span class="global-hide-on-mobile-down">Expand all</span>
-
      {/if}
-
    </IconButton>
-
  {/if}
-
</div>
-

-
<div class="diff-list">
-
  <Observer let:filesVisibility let:observer>
-
    {#each diff.files as file}
-
      {@const path = "path" in file ? file.path : file.newPath}
-
      <div use:intersection={observer} id={"observer:" + path}>
-
        {#if "diff" in file}
-
          <FileDiff
-
            {projectId}
-
            {baseUrl}
-
            {revision}
-
            {expanded}
-
            filePath={path}
-
            oldFilePath={"oldPath" in file ? file.oldPath : undefined}
-
            fileDiff={file.diff}
-
            headerBadgeCaption={file.state}
-
            content={"new" in file ? files[file.new.oid]?.content : undefined}
-
            oldContent={"old" in file
-
              ? files[file.old.oid]?.content
-
              : undefined}
-
            visible={filesVisibility.has(path)} />
-
        {:else}
-
          <FileLocationChange
-
            headerBadgeCaption={file.state}
-
            oldPath={file.oldPath}
-
            newPath={file.newPath}
-
            {projectId}
-
            {baseUrl}
-
            {revision} />
-
        {/if}
-
      </div>
-
    {/each}
-
  </Observer>
-
</div>
deleted src/views/projects/Changeset/FileDiff.svelte
@@ -1,538 +0,0 @@
-
<script lang="ts">
-
  import type {
-
    BaseUrl,
-
    ChangesetWithDiff,
-
    DiffContent,
-
    HunkLine,
-
  } from "@http-client";
-

-
  import { onDestroy, onMount } from "svelte";
-
  import { toHtml } from "hast-util-to-html";
-

-
  import * as Syntax from "@app/lib/syntax";
-
  import { isImagePath, isSvgPath } from "@app/lib/utils";
-

-
  import Badge from "@app/components/Badge.svelte";
-
  import File from "@app/components/File.svelte";
-
  import FilePath from "@app/components/FilePath.svelte";
-
  import IconButton from "@app/components/IconButton.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import Loading from "@app/components/Loading.svelte";
-
  import Placeholder from "@app/components/Placeholder.svelte";
-
  import Radio from "@app/components/Radio.svelte";
-
  import Button from "@app/components/Button.svelte";
-

-
  export let filePath: string;
-
  export let oldContent: string | undefined = undefined;
-
  export let content: string | undefined = undefined;
-
  export let oldFilePath: string | undefined = undefined;
-
  export let fileDiff: DiffContent;
-
  export let headerBadgeCaption: ChangesetWithDiff["state"];
-
  export let revision: string | undefined = undefined;
-
  export let baseUrl: BaseUrl;
-
  export let projectId: string;
-
  export let visible: boolean = false;
-
  export let expanded: boolean = true;
-

-
  let selection: Selection | undefined = undefined;
-
  let highlighting: { new?: string[]; old?: string[] } | undefined = undefined;
-
  let syntaxHighlightingLoading: boolean = false;
-
  let preview = false;
-
  $: extension = filePath.split(".").pop();
-

-
  onMount(() => {
-
    window.addEventListener("click", deselectHandler);
-
    window.addEventListener("hashchange", updateSelection);
-

-
    updateSelection();
-

-
    if (selection) {
-
      document
-
        .getElementById(
-
          [filePath, "H" + selection.startHunk, "L" + selection.startLine].join(
-
            "-",
-
          ),
-
        )
-
        ?.scrollIntoView({ block: "center" });
-
    }
-
  });
-

-
  $: if (visible) {
-
    syntaxHighlightingLoading = true;
-
    void highlightContent().then(output => {
-
      highlighting = output;
-
      syntaxHighlightingLoading = false;
-
    });
-
  }
-

-
  onDestroy(() => {
-
    window.removeEventListener("click", deselectHandler);
-
    window.removeEventListener("hashchange", updateSelection);
-
  });
-

-
  function deselectHandler(e: MouseEvent) {
-
    if (
-
      !(
-
        e.target instanceof HTMLElement &&
-
        e.target.closest("[data-file-diff-select]")
-
      )
-
    ) {
-
      updateHash("");
-
    }
-
  }
-

-
  async function highlightContent() {
-
    const extension = filePath.split(".").pop();
-
    const highlighted: { new?: string[]; old?: string[] } = {};
-
    if (extension) {
-
      if (content) {
-
        highlighted["new"] = toHtml(
-
          await Syntax.highlight(content, extension),
-
        ).split("\n");
-
      }
-
      if (oldContent) {
-
        highlighted["old"] = toHtml(
-
          await Syntax.highlight(oldContent, extension),
-
        ).split("\n");
-
      }
-
    }
-
    return Object.entries(highlighted).length > 0 ? highlighted : undefined;
-
  }
-

-
  function updateSelection() {
-
    const fragment = window.location.hash.substring(1);
-
    const match = fragment.match(/(.+):H(\d+)L(\d+)(H(\d+)L(\d+))?/);
-
    if (match && match[1] === filePath) {
-
      selection = {
-
        startHunk: parseInt(match[2]),
-
        startLine: parseInt(match[3]),
-
        endHunk: match[4] ? parseInt(match[5]) : undefined,
-
        endLine: match[4] ? parseInt(match[6]) : undefined,
-
      };
-
    } else {
-
      selection = undefined;
-
    }
-
  }
-

-
  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 "-";
-
      }
-
    }
-
  }
-

-
  function isLineSelected(
-
    selection: Selection | undefined,
-
    hunkIdx: number,
-
    lineIdx: number,
-
  ): boolean {
-
    if (!selection) {
-
      return false;
-
    }
-

-
    if (selection.endHunk !== undefined && selection.endLine !== undefined) {
-
      return (
-
        hunkIdx >= selection.startHunk &&
-
        hunkIdx <= selection.endHunk &&
-
        (hunkIdx === selection.startHunk
-
          ? lineIdx >= selection.startLine
-
          : true) &&
-
        (hunkIdx === selection.endHunk ? lineIdx <= selection.endLine : true)
-
      );
-
    } else {
-
      return hunkIdx === selection.startHunk && lineIdx === selection.startLine;
-
    }
-
  }
-

-
  function hashFromSelection(
-
    hunkIdx: number,
-
    lineIdx: number,
-
    event: MouseEvent,
-
  ): string {
-
    const path = filePath;
-
    // single line selection
-
    if (!event.shiftKey) {
-
      return path + ":H" + hunkIdx + "L" + lineIdx;
-
    }
-

-
    if (!selection) {
-
      return "";
-
    }
-

-
    // range selection
-
    if (hunkIdx === selection.startHunk) {
-
      if (lineIdx >= selection.startLine) {
-
        return `${path}:H${hunkIdx}L${selection.startLine}H${hunkIdx}L${lineIdx}`;
-
      } else {
-
        return `${path}:H${hunkIdx}L${lineIdx}H${hunkIdx}L${selection.startLine}`;
-
      }
-
    } else if (hunkIdx < selection.startHunk) {
-
      return `${path}:H${hunkIdx}L${lineIdx}H${selection.startHunk}L${selection.startLine}`;
-
    } else {
-
      return `${path}:H${selection.startHunk}L${selection.startLine}H${hunkIdx}L${lineIdx}`;
-
    }
-
  }
-

-
  function selectLine(hunkIdx: number, lineIdx: number, event: MouseEvent) {
-
    updateHash(hashFromSelection(hunkIdx, lineIdx, event));
-
  }
-

-
  function updateHash(newHash: string) {
-
    if (newHash !== "") {
-
      window.location.hash = newHash;
-
    } else {
-
      window.history.replaceState(
-
        window.history.state,
-
        "",
-
        window.location.pathname + window.location.search,
-
      );
-
      selection = undefined;
-
    }
-
  }
-

-
  function hunkHeaderSelected(selection: Selection | undefined, hunk: number) {
-
    return (
-
      selection &&
-
      selection.endHunk !== undefined &&
-
      hunk > selection.startHunk &&
-
      hunk <= selection.endHunk
-
    );
-
  }
-

-
  interface Selection {
-
    startHunk: number;
-
    startLine: number;
-
    endHunk: number | undefined;
-
    endLine: number | undefined;
-
  }
-
</script>
-

-
<style>
-
  .container {
-
    font-size: var(--font-size-small);
-
    background: var(--color-background-float);
-
    border-radius: 0 0 var(--border-radius-small) var(--border-radius-small);
-
    overflow-x: auto;
-
  }
-
  .actions {
-
    display: flex;
-
    flex-direction: row;
-
    align-items: center;
-
    gap: 1rem;
-
  }
-
  .browse {
-
    margin-left: auto;
-
  }
-
  .expand-button {
-
    cursor: pointer;
-
    user-select: none;
-
    margin-right: 0.5rem;
-
  }
-
  .diff {
-
    font-family: var(--font-family-monospace);
-
    table-layout: fixed;
-
    border-collapse: collapse;
-
    margin: 0.5rem 0;
-
  }
-
  .diff-line {
-
    vertical-align: top;
-
  }
-
  .diff-line.type-addition > * {
-
    background-color: var(--color-fill-diff-green-light);
-
  }
-
  .diff-line.type-deletion > * {
-
    background-color: var(--color-fill-diff-red-light);
-
  }
-

-
  .diff-line.selected > * {
-
    background-color: var(--color-fill-float-hover);
-
  }
-
  .diff-line.selected.type-addition > * {
-
    background-color: var(--color-fill-diff-green);
-
  }
-
  .diff-line.selected.type-deletion > * {
-
    background-color: var(--color-fill-diff-red);
-
  }
-

-
  .type-addition > .diff-line-number,
-
  .type-addition > .diff-line-type {
-
    color: var(--color-foreground-success);
-
  }
-
  .type-deletion > .diff-line-number,
-
  .type-deletion > .diff-line-type {
-
    color: var(--color-foreground-red);
-
  }
-

-
  .diff-line.selected .selection-indicator-left {
-
    background-color: var(--color-fill-secondary);
-
  }
-
  .type-addition.diff-line.selected .selection-indicator-left {
-
    background-color: var(--color-fill-secondary);
-
  }
-
  .type-deletion.diff-line.selected .selection-indicator-left {
-
    background-color: var(--color-fill-secondary);
-
  }
-

-
  .diff-line.selected .selection-indicator-right {
-
    background-color: var(--color-fill-secondary);
-
  }
-
  .type-addition.diff-line.selected .selection-indicator-right {
-
    background-color: var(--color-fill-secondary);
-
  }
-
  .type-deletion.diff-line.selected .selection-indicator-right {
-
    background-color: var(--color-fill-secondary);
-
  }
-

-
  .selection-start {
-
    box-shadow: 0 -1px 0 0 var(--color-fill-secondary);
-
    z-index: 1;
-
  }
-
  .selection-end {
-
    box-shadow: 0 1px 0 0 var(--color-fill-secondary);
-
    z-index: 1;
-
  }
-

-
  .selection-start.selection-end {
-
    box-shadow: 0 0 0 1px var(--color-fill-secondary);
-
    z-index: 1;
-
  }
-

-
  .diff-line-number {
-
    font-family: var(--font-family-monospace);
-
    text-align: right;
-
    user-select: none;
-
    line-height: 1.5rem;
-
    min-width: 3rem;
-
    cursor: pointer;
-
    color: var(--color-foreground-disabled);
-
  }
-
  .diff-line-number.left {
-
    position: relative;
-
    padding: 0 0.5rem 0 0.75rem;
-
  }
-
  .selection-indicator-left {
-
    position: absolute;
-
    left: 0;
-
    top: 0;
-
    bottom: 0;
-
    width: 1px;
-
  }
-
  .selection-indicator-right {
-
    position: absolute;
-
    right: 0;
-
    top: 0;
-
    bottom: 0;
-
    width: 1px;
-
  }
-
  .diff-line-number.right {
-
    padding: 0 0.75rem 0 0.5rem;
-
  }
-
  .diff-line-content {
-
    color: unset !important;
-
    white-space: pre-wrap;
-
    overflow-wrap: anywhere;
-
    width: 100%;
-
    padding-right: 0.5rem;
-
  }
-
  .diff-line-type {
-
    text-align: center;
-
    padding-left: 0.75rem;
-
    padding-right: 0.75rem;
-
    user-select: none;
-
  }
-
  .diff-expand-header {
-
    padding-left: 0.5rem;
-
    color: var(--color-foreground-dim);
-
  }
-
</style>
-

-
<File collapsable {expanded}>
-
  <svelte:fragment slot="left-header">
-
    {#if (headerBadgeCaption === "moved" || headerBadgeCaption === "copied") && oldFilePath}
-
      <span style="display: flex; align-items: center; flex-wrap: wrap;">
-
        <FilePath filenameWithPath={oldFilePath} />
-
        <span style:padding="0 0.5rem">→</span>
-
        <FilePath filenameWithPath={filePath} />
-
      </span>
-
    {:else}
-
      <FilePath filenameWithPath={filePath} />
-
    {/if}
-

-
    {#if headerBadgeCaption === "added"}
-
      <Badge variant="positive">added</Badge>
-
    {:else if headerBadgeCaption === "deleted"}
-
      <Badge variant="negative">deleted</Badge>
-
    {:else if headerBadgeCaption === "moved"}
-
      <Badge variant="foreground">moved</Badge>
-
    {:else if headerBadgeCaption === "copied"}
-
      <Badge variant="foreground">copied</Badge>
-
    {/if}
-
  </svelte:fragment>
-

-
  <svelte:fragment slot="right-header" let:expanded>
-
    {#if revision}
-
      {#if syntaxHighlightingLoading}
-
        <Loading small />
-
      {/if}
-
      <div style:display="flex" style:align-items="center" style:gap="0.5rem">
-
        {#if isSvgPath(filePath) && expanded}
-
          <Radio ariaLabel="Toggle render method">
-
            <Button
-
              styleBorderRadius="0"
-
              variant={!preview ? "selected" : "not-selected"}
-
              on:click={() => {
-
                preview = false;
-
              }}>
-
              <Icon name="chevron-left-right" />Code
-
            </Button>
-
            <Button
-
              styleBorderRadius="0"
-
              variant={preview ? "selected" : "not-selected"}
-
              on:click={() => {
-
                window.location.hash = "";
-
                preview = true;
-
              }}>
-
              <Icon name="eye-open" />Preview
-
            </Button>
-
          </Radio>
-
        {/if}
-
        <Link
-
          route={{
-
            resource: "project.source",
-
            project: projectId,
-
            node: baseUrl,
-
            path: filePath,
-
            revision,
-
          }}>
-
          <IconButton title="View file at this commit">
-
            <Icon name="chevron-left-right" />
-
          </IconButton>
-
        </Link>
-
      </div>
-
    {/if}
-
  </svelte:fragment>
-

-
  <div class="container">
-
    {#if fileDiff.type === "plain"}
-
      {#if fileDiff.hunks.length > 0 && !preview}
-
        <table class="diff" data-file-diff-select>
-
          {#each fileDiff.hunks as hunk, hunkIdx}
-
            <tr
-
              class="diff-line hunk-header"
-
              class:selected={hunkHeaderSelected(selection, hunkIdx)}>
-
              <td colspan={2} style:position="relative">
-
                <div class="selection-indicator-left" />
-
              </td>
-
              <td
-
                colspan={6}
-
                class="diff-expand-header"
-
                style:position="relative">
-
                {hunk.header}
-
                <div class="selection-indicator-right" />
-
              </td>
-
            </tr>
-
            {#each hunk.lines as line, lineIdx}
-
              <tr
-
                style:position="relative"
-
                class={`diff-line type-${line.type}`}
-
                class:selection-start={selection?.startHunk === hunkIdx &&
-
                  selection.startLine === lineIdx}
-
                class:selection-end={(selection?.endHunk === hunkIdx &&
-
                  selection.endLine === lineIdx) ||
-
                  (selection?.startHunk === hunkIdx &&
-
                    selection.startLine === lineIdx &&
-
                    selection?.endHunk === undefined)}
-
                class:selected={isLineSelected(selection, hunkIdx, lineIdx)}>
-
                <td
-
                  id={[filePath, "H" + hunkIdx, "L" + lineIdx].join("-")}
-
                  class="diff-line-number left"
-
                  on:click={e => selectLine(hunkIdx, lineIdx, e)}>
-
                  <div class="selection-indicator-left" />
-
                  {lineNumberL(line)}
-
                </td>
-
                <td
-
                  class="diff-line-number right"
-
                  on:click={e => selectLine(hunkIdx, lineIdx, e)}>
-
                  {lineNumberR(line)}
-
                </td>
-
                <td class="diff-line-type" data-line-type={line.type}>
-
                  {lineSign(line)}
-
                </td>
-
                <td class="diff-line-content">
-
                  {#if highlighting}
-
                    {#if line.type === "addition" && highlighting.new}
-
                      {@html highlighting.new[line.lineNo - 1]}
-
                    {:else if line.type === "context" && highlighting.new}
-
                      {@html highlighting.new[line.lineNoNew - 1]}
-
                    {:else if line.type === "deletion" && highlighting.old}
-
                      {@html highlighting.old[line.lineNo - 1]}
-
                    {/if}
-
                  {:else}
-
                    {line.line}
-
                  {/if}
-
                </td>
-
                <div class="selection-indicator-right" />
-
              </tr>
-
            {/each}
-
          {/each}
-
        </table>
-
      {:else if isImagePath(filePath) && extension && content}
-
        <div style:margin="1rem 0" style:text-align="center">
-
          <img
-
            src={`data:image/${extension};base64,${content}`}
-
            alt={filePath} />
-
        </div>
-
      {:else if preview && content}
-
        <div style:margin="1rem 0" style:text-align="center">
-
          <img
-
            src={`data:image/svg+xml;base64,${btoa(content)}`}
-
            alt={filePath} />
-
        </div>
-
      {:else}
-
        <div style:margin="1rem 0">
-
          <Placeholder iconName="empty-file" caption="Empty file" inline />
-
        </div>
-
      {/if}
-
    {:else}
-
      <div style:margin="1rem 0">
-
        <Placeholder iconName="binary-file" caption="Binary file" inline />
-
      </div>
-
    {/if}
-
  </div>
-
</File>
deleted src/views/projects/Changeset/FileLocationChange.svelte
@@ -1,69 +0,0 @@
-
<script lang="ts">
-
  import type { BaseUrl, ChangesetWithoutDiff } from "@http-client";
-

-
  import Badge from "@app/components/Badge.svelte";
-
  import IconButton from "@app/components/IconButton.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import FilePath from "@app/components/FilePath.svelte";
-

-
  export let headerBadgeCaption: ChangesetWithoutDiff["state"];
-
  export let newPath: string;
-
  export let oldPath: string;
-
  export let revision: string | undefined = undefined;
-
  export let baseUrl: BaseUrl;
-
  export let projectId: string;
-
</script>
-

-
<style>
-
  .wrapper {
-
    border: 1px solid var(--color-border-default);
-
    border-radius: var(--border-radius-small);
-
    line-height: 1.5rem;
-
  }
-
  .header {
-
    align-items: center;
-
    background: none;
-
    border-radius: 0;
-
    display: flex;
-
    flex-direction: row;
-
    height: 3rem;
-
    padding: 1rem;
-
  }
-
  .actions {
-
    display: flex;
-
    flex-direction: row;
-
    align-items: center;
-
    gap: 1rem;
-
  }
-
</style>
-

-
<div id={newPath} class="wrapper">
-
  <header class="header">
-
    <div class="actions">
-
      <span>
-
        <FilePath filenameWithPath={oldPath} /> → <FilePath
-
          filenameWithPath={newPath} />
-
      </span>
-
      {#if headerBadgeCaption === "moved"}
-
        <Badge variant="foreground">moved</Badge>
-
      {:else if headerBadgeCaption === "copied"}
-
        <Badge variant="foreground">copied</Badge>
-
      {/if}
-
    </div>
-
    <div style:margin-left="auto">
-
      <Link
-
        route={{
-
          resource: "project.source",
-
          project: projectId,
-
          node: baseUrl,
-
          path: newPath,
-
          revision,
-
        }}>
-
        <IconButton title="View file at this commit">
-
          <Icon name="chevron-left-right" />
-
        </IconButton>
-
      </Link>
-
    </div>
-
  </header>
-
</div>
deleted src/views/projects/Cob/Assignees.svelte
@@ -1,68 +0,0 @@
-
<script lang="ts">
-
  import type { Reaction } from "@http-client";
-

-
  import { formatNodeId } from "@app/lib/utils";
-

-
  import Avatar from "@app/components/Avatar.svelte";
-
  import Badge from "@app/components/Badge.svelte";
-

-
  export let assignees: Reaction["authors"] = [];
-
</script>
-

-
<style>
-
  .header {
-
    font-size: var(--font-size-small);
-
    margin-bottom: 0.75rem;
-
  }
-
  .body {
-
    display: flex;
-
    flex-wrap: wrap;
-
    flex-direction: row;
-
    gap: 0.5rem;
-
    font-size: var(--font-size-small);
-
  }
-
  .assignee {
-
    display: flex;
-
    align-items: center;
-
    width: 100%;
-
    gap: 0.25rem;
-
  }
-
  @media (max-width: 1349.98px) {
-
    .wrapper {
-
      display: flex;
-
      flex-direction: row;
-
      gap: 1rem;
-
      align-items: flex-start;
-
    }
-
    .header {
-
      margin-bottom: 0;
-
      height: 2rem;
-
      display: flex;
-
      align-items: center;
-
    }
-
    .body {
-
      align-items: flex-start;
-
    }
-
    .no-assignees {
-
      height: 2rem;
-
      display: flex;
-
      align-items: center;
-
    }
-
  }
-
</style>
-

-
<div class="wrapper">
-
  <div class="header">Assignees</div>
-
  <div class="body">
-
    {#each assignees as { id }}
-
      <Badge variant="neutral" size="small">
-
        <div class="assignee">
-
          <Avatar variant="small" nodeId={id} />
-
          <span>{formatNodeId(id)}</span>
-
        </div>
-
      </Badge>
-
    {:else}
-
      <div class="txt-missing no-assignees">No assignees</div>
-
    {/each}
-
  </div>
-
</div>
deleted src/views/projects/Cob/CobCommitTeaser.svelte
@@ -1,115 +0,0 @@
-
<script lang="ts">
-
  import type { BaseUrl, CommitHeader } from "@http-client";
-

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

-
  import CompactCommitAuthorship from "@app/components/CompactCommitAuthorship.svelte";
-
  import ExpandButton from "@app/components/ExpandButton.svelte";
-
  import IconButton from "@app/components/IconButton.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Id from "@app/components/Id.svelte";
-
  import InlineTitle from "@app/views/projects/components/InlineTitle.svelte";
-
  import Link from "@app/components/Link.svelte";
-

-
  export let baseUrl: BaseUrl;
-
  export let commit: CommitHeader;
-
  export let projectId: string;
-

-
  let commitMessageVisible = false;
-
</script>
-

-
<style>
-
  .teaser {
-
    display: flex;
-
    font-size: var(--font-size-small);
-
    align-items: start;
-
    padding: 0.125rem 0;
-
  }
-
  .message {
-
    align-items: center;
-
    display: flex;
-
    flex-wrap: wrap;
-
    gap: 0.5rem;
-
  }
-
  .left {
-
    display: flex;
-
    gap: 0.5rem;
-
    padding: 0 0.5rem;
-
    flex-direction: column;
-
  }
-
  .right {
-
    display: flex;
-
    align-items: center;
-
    gap: 1rem;
-
    margin-left: auto;
-
    height: 21px;
-
  }
-
  .summary:hover {
-
    text-decoration: underline;
-
  }
-
  .commit-message {
-
    margin: 0.5rem 0;
-
    font-size: var(--font-size-tiny);
-
  }
-
  pre {
-
    white-space: pre-wrap;
-
    word-wrap: break-word;
-
  }
-
</style>
-

-
<div class="teaser" aria-label="commit-teaser">
-
  <div class="left">
-
    <div class="message">
-
      <Link
-
        route={{
-
          resource: "project.commit",
-
          project: projectId,
-
          node: baseUrl,
-
          commit: commit.id,
-
        }}>
-
        <div class="summary" use:twemoji>
-
          <InlineTitle fontSize="small" content={commit.summary} />
-
        </div>
-
      </Link>
-
      {#if commit.description}
-
        <div style="height: 21px; display: flex; align-items: center;">
-
          <ExpandButton
-
            variant="inline"
-
            on:toggle={() => {
-
              commitMessageVisible = !commitMessageVisible;
-
            }} />
-
        </div>
-
      {/if}
-
    </div>
-
    {#if commitMessageVisible}
-
      <div class="commit-message" style:margin="0.5rem 0">
-
        <pre>{commit.description.trim()}</pre>
-
      </div>
-
    {/if}
-
    <div class="global-hide-on-small-desktop-up">
-
      <CompactCommitAuthorship {commit}>
-
        <Id id={commit.id} style="commit" />
-
      </CompactCommitAuthorship>
-
    </div>
-
  </div>
-
  <div class="right">
-
    <div style="display: flex; gap: 0.5rem; height: 21px; align-items: center;">
-
      <div class="global-hide-on-mobile-down">
-
        <CompactCommitAuthorship {commit}>
-
          <Id id={commit.id} style="commit" />
-
        </CompactCommitAuthorship>
-
      </div>
-
      <IconButton title="Browse repo at this commit">
-
        <Link
-
          route={{
-
            resource: "project.source",
-
            project: projectId,
-
            node: baseUrl,
-
            revision: commit.id,
-
          }}>
-
          <Icon name="chevron-left-right" />
-
        </Link>
-
      </IconButton>
-
    </div>
-
  </div>
-
</div>
deleted src/views/projects/Cob/CobHeader.svelte
@@ -1,43 +0,0 @@
-
<style>
-
  .header {
-
    display: flex;
-
    padding: 1rem;
-
    flex-direction: column;
-
  }
-
  .subtitle {
-
    display: flex;
-
    flex-direction: row;
-
    align-items: center;
-
    flex-wrap: wrap;
-
    gap: 0.5rem;
-
    font-size: var(--font-size-small);
-
  }
-
  .summary {
-
    display: flex;
-
    align-items: flex-start;
-
    justify-content: space-between;
-
    gap: 0.5rem;
-
    margin-bottom: 0.5rem;
-
  }
-
  .description {
-
    display: flex;
-
    flex-direction: column;
-
    gap: 0.5rem;
-
    font-size: var(--font-size-small);
-
    margin-top: 2rem;
-
    word-break: break-word;
-
  }
-
</style>
-

-
<div class="header">
-
  <div role="heading" aria-level={2} class="summary">
-
    <slot name="title" />
-
  </div>
-
  <div class="subtitle">
-
    <slot name="state" />
-
  </div>
-
  <slot name="subtitle" />
-
  <div class="description">
-
    <slot name="description" />
-
  </div>
-
</div>
deleted src/views/projects/Cob/Embeds.svelte
@@ -1,56 +0,0 @@
-
<script lang="ts">
-
  import type { Embed } from "@http-client";
-

-
  import Badge from "@app/components/Badge.svelte";
-
  import Clipboard from "@app/components/Clipboard.svelte";
-

-
  export let embeds: Embed[] = [];
-
</script>
-

-
<style>
-
  .header {
-
    font-size: var(--font-size-small);
-
    margin-bottom: 0.75rem;
-
  }
-
  .body {
-
    display: flex;
-
    flex-wrap: wrap;
-
    flex-direction: row;
-
    gap: 0.5rem;
-
    font-size: var(--font-size-small);
-
  }
-

-
  @media (max-width: 1349.98px) {
-
    .wrapper {
-
      display: flex;
-
      flex-direction: row;
-
      gap: 1rem;
-
      align-items: flex;
-
    }
-
    .header {
-
      margin-bottom: 0;
-
      height: 2rem;
-
      display: flex;
-
      align-items: center;
-
    }
-
    .no-attachments {
-
      height: 2rem;
-
      display: flex;
-
      align-items: center;
-
    }
-
  }
-
</style>
-

-
<div class="wrapper">
-
  <div class="header">Attachments</div>
-
  <div class="body">
-
    {#each embeds as embed}
-
      <Badge variant="neutral" size="small" style="max-width: 14rem;">
-
        <span class="txt-overflow">{embed.name}</span>
-
        <Clipboard text={`![${embed.name}](${embed.content.substring(4)})`} />
-
      </Badge>
-
    {:else}
-
      <div class="txt-missing no-attachments">No attachments</div>
-
    {/each}
-
  </div>
-
</div>
deleted src/views/projects/Cob/InlineLabels.svelte
@@ -1,25 +0,0 @@
-
<script lang="ts">
-
  import Badge from "@app/components/Badge.svelte";
-

-
  export let labels: string[];
-
</script>
-

-
<style>
-
  .label {
-
    overflow: hidden;
-
    text-overflow: ellipsis;
-
  }
-
</style>
-

-
{#each labels.slice(0, 2) as label}
-
  <Badge style="max-width:7rem" variant="neutral">
-
    <span class="label">{label}</span>
-
  </Badge>
-
{/each}
-
{#if labels.length > 2}
-
  <Badge title={labels.slice(2, undefined).join(" ")} variant="neutral">
-
    <span class="label">
-
      +{labels.length - 2} more
-
    </span>
-
  </Badge>
-
{/if}
deleted src/views/projects/Cob/Labels.svelte
@@ -1,55 +0,0 @@
-
<script lang="ts">
-
  import Badge from "@app/components/Badge.svelte";
-

-
  export let labels: string[] = [];
-
</script>
-

-
<style>
-
  .header {
-
    font-size: var(--font-size-small);
-
    margin-bottom: 0.75rem;
-
  }
-
  .body {
-
    display: flex;
-
    align-items: center;
-
    flex-wrap: wrap;
-
    flex-direction: row;
-
    gap: 0.5rem;
-
    font-size: var(--font-size-small);
-
  }
-
  @media (max-width: 1349.98px) {
-
    .wrapper {
-
      display: flex;
-
      flex-direction: row;
-
      gap: 1rem;
-
      align-items: flex-start;
-
    }
-
    .header {
-
      margin-bottom: 0;
-
      height: 2rem;
-
      display: flex;
-
      align-items: center;
-
    }
-
    .body {
-
      align-items: flex-start;
-
    }
-
    .no-labels {
-
      height: 2rem;
-
      display: flex;
-
      align-items: center;
-
    }
-
  }
-
</style>
-

-
<div class="wrapper">
-
  <div class="header">Labels</div>
-
  <div class="body">
-
    {#each labels as label}
-
      <Badge variant="neutral" size="small">
-
        {label}
-
      </Badge>
-
    {:else}
-
      <div class="txt-missing no-labels">No labels</div>
-
    {/each}
-
  </div>
-
</div>
deleted src/views/projects/Cob/Reviews.svelte
@@ -1,83 +0,0 @@
-
<script lang="ts">
-
  import type { BaseUrl } from "@http-client";
-
  import type { PatchReviews } from "../Patch.svelte";
-

-
  import Icon from "@app/components/Icon.svelte";
-
  import NodeId from "@app/components/NodeId.svelte";
-

-
  export let baseUrl: BaseUrl;
-
  export let reviews: PatchReviews;
-
</script>
-

-
<style>
-
  .header {
-
    font-size: var(--font-size-small);
-
    margin-bottom: 0.75rem;
-
  }
-
  .body {
-
    display: flex;
-
    flex-direction: column;
-
    gap: 0.5rem;
-
    font-size: var(--font-size-small);
-
  }
-
  .review {
-
    color: var(--color-fill-gray);
-
    display: inline-flex;
-
    align-items: center;
-
    gap: 0.5rem;
-
  }
-
  .review-accept {
-
    color: var(--color-foreground-success);
-
  }
-
  .review-reject {
-
    color: var(--color-foreground-red);
-
  }
-
  @media (max-width: 1349.98px) {
-
    .wrapper {
-
      display: flex;
-
      flex-direction: row;
-
      gap: 1rem;
-
      align-items: center;
-
    }
-
    .header {
-
      margin-bottom: 0;
-
      height: 2rem;
-
      display: flex;
-
      align-items: center;
-
    }
-
    .no-reviews {
-
      display: flex;
-
      align-items: center;
-
    }
-
    .body {
-
      flex-direction: row;
-
    }
-
  }
-
</style>
-

-
<div class="wrapper">
-
  <div class="header">Reviews</div>
-
  <div class="body">
-
    {#each Object.values(reviews) as { latest, review }}
-
      <div class="review" class:txt-missing={!latest}>
-
        <span
-
          class:review-accept={review.verdict === "accept"}
-
          class:review-reject={review.verdict === "reject"}>
-
          {#if review.verdict === "accept"}
-
            <Icon name="checkmark" />
-
          {:else if review.verdict === "reject"}
-
            <Icon name="cross" />
-
          {:else}
-
            <Icon name="chat" />
-
          {/if}
-
        </span>
-
        <NodeId
-
          {baseUrl}
-
          nodeId={review.author.id}
-
          alias={review.author.alias} />
-
      </div>
-
    {:else}
-
      <div class="txt-missing no-reviews">No reviews</div>
-
    {/each}
-
  </div>
-
</div>
deleted src/views/projects/Cob/Revision.svelte
@@ -1,532 +0,0 @@
-
<script lang="ts">
-
  import type {
-
    BaseUrl,
-
    Comment,
-
    DiffResponse,
-
    PatchState,
-
    Revision,
-
    Verdict,
-
  } from "@http-client";
-
  import type { Timeline } from "@app/views/projects/Patch.svelte";
-

-
  import * as utils from "@app/lib/utils";
-
  import { HttpdClient } from "@http-client";
-
  import { cachedGetDiff } from "@app/views/projects/router";
-
  import { onMount } from "svelte";
-

-
  import CobCommitTeaser from "@app/views/projects/Cob/CobCommitTeaser.svelte";
-
  import CommentComponent from "@app/components/Comment.svelte";
-
  import DiffStatBadge from "@app/components/DiffStatBadge.svelte";
-
  import DropdownList from "@app/components/DropdownList.svelte";
-
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
-
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
-
  import ExpandButton from "@app/components/ExpandButton.svelte";
-
  import IconButton from "@app/components/IconButton.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import Loading from "@app/components/Loading.svelte";
-
  import Markdown from "@app/components/Markdown.svelte";
-
  import NodeId from "@app/components/NodeId.svelte";
-
  import Popover from "@app/components/Popover.svelte";
-
  import Reactions from "@app/components/Reactions.svelte";
-
  import Thread from "@app/components/Thread.svelte";
-
  import Id from "@app/components/Id.svelte";
-

-
  export let baseUrl: BaseUrl;
-
  export let initiallyExpanded: boolean = false;
-
  export let rawPath: (commit?: string) => string;
-
  export let patchId: string;
-
  export let patchState: PatchState;
-
  export let projectId: string;
-
  export let revisionBase: string;
-
  export let revisionId: string;
-
  export let revisionEdits: Revision["edits"];
-
  export let revisionOid: string;
-
  export let revisionTimestamp: number;
-
  export let revisionReactions: Comment["reactions"];
-
  export let revisionAuthor: { id: string; alias?: string | undefined };
-
  export let revisionDescription: string;
-
  export let timelines: Timeline[];
-
  export let previousRevBase: string | undefined = undefined;
-
  export let previousRevId: string | undefined = undefined;
-
  export let previousRevOid: string | undefined = undefined;
-
  export let first: boolean;
-

-
  let expanded = initiallyExpanded;
-
  const api = new HttpdClient(baseUrl);
-
  const lastEdit = revisionEdits.at(-1);
-

-
  function formatVerdict(verdict?: Verdict | null) {
-
    switch (verdict) {
-
      case "accept":
-
        return "accepted revision";
-
      case "reject":
-
        return "rejected revision";
-
      default:
-
        return "reviewed revision";
-
    }
-
  }
-

-
  function verdictIconColor(verdict?: Verdict | null) {
-
    switch (verdict) {
-
      case "accept":
-
        return "var(--color-foreground-success)";
-
      case "reject":
-
        return "var(--color-foreground-red)";
-
      default:
-
        return "var(--color-fill-gray)";
-
    }
-
  }
-

-
  function badgeColor({ status }: PatchState): string | undefined {
-
    if (status === "draft") {
-
      return "var(--color-fill-gray)";
-
    } else if (status === "open") {
-
      return "var(--color-foreground-success)";
-
    } else if (status === "archived") {
-
      return "var(--color-foreground-yellow)";
-
    } else if (status === "merged") {
-
      return "var(--color-fill-primary)";
-
    } else {
-
      return "var(--color-foreground-success)";
-
    }
-
  }
-

-
  let response: DiffResponse | undefined = undefined;
-
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-
  let error: any | undefined = undefined;
-
  let loading: boolean = false;
-

-
  $: fromCommit =
-
    previousRevBase !== revisionBase
-
      ? revisionBase
-
      : (previousRevBase ?? revisionBase);
-

-
  onMount(async () => {
-
    try {
-
      loading = true;
-
      response = await cachedGetDiff(
-
        api.baseUrl,
-
        projectId,
-
        fromCommit,
-
        revisionOid,
-
      );
-
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
-
    } catch (err: any) {
-
      error = err;
-
    } finally {
-
      loading = false;
-
    }
-
  });
-
</script>
-

-
<style>
-
  .action {
-
    border-radius: var(--border-radius-small);
-
    min-height: 2.5rem;
-
    display: flex;
-
    align-items: center;
-
  }
-
  .merge {
-
    border: 1px solid var(--color-border-merged);
-
    background-color: var(--color-fill-merged);
-
  }
-
  .positive-review {
-
    border: 1px solid var(--color-fill-diff-green);
-
    background-color: var(--color-fill-diff-green-light);
-
  }
-
  .comment-review {
-
    border: 1px solid var(--color-border-hint);
-
    background-color: var(--color-fill-float);
-
  }
-
  .negative-review {
-
    border: 1px solid var(--color-fill-diff-red);
-
    background-color: var(--color-fill-diff-red-light);
-
  }
-

-
  .diff-error {
-
    margin: 1rem 1.5rem;
-
  }
-
  .revision {
-
    display: flex;
-
    flex-direction: column;
-
    border-radius: var(--border-radius-small);
-
  }
-
  .revision-box {
-
    border-radius: var(--border-radius-small);
-
  }
-
  .revision-header {
-
    display: flex;
-
    align-items: center;
-
    justify-content: center;
-
    background: none;
-
    padding: 0.5rem;
-
    font-size: var(--font-size-small);
-
    height: 3rem;
-
  }
-
  .revision-name {
-
    display: flex;
-
    align-items: center;
-
    gap: 0.5rem;
-
    font-weight: var(--font-weight-medium);
-
  }
-
  .revision-data {
-
    gap: 0.5rem;
-
    display: flex;
-
    align-items: center;
-
    margin-left: auto;
-
    color: var(--color-foreground-dim);
-
  }
-
  .revision-description {
-
    margin-left: 2.75rem;
-
    padding-right: 0.5rem;
-
    max-width: fit-content;
-
  }
-
  .author-metadata {
-
    color: var(--color-fill-gray);
-
    font-size: var(--font-size-small);
-
  }
-
  .compare-dropdown-item {
-
    font-weight: var(--font-weight-regular);
-
  }
-
  .patch-header {
-
    background-color: var(--color-fill-float);
-
    border-bottom: 1px solid var(--color-fill-separator);
-
    border-top: 1px solid var(--color-fill-separator);
-
    display: flex;
-
    flex-direction: column;
-
    justify-content: center;
-
    min-height: 2.5rem;
-
    padding: 0.5rem 0;
-
    font-size: var(--font-size-small);
-
    gap: 0.5rem;
-
  }
-
  .authorship-header {
-
    display: inline-flex;
-
    white-space: nowrap;
-
    flex-wrap: wrap;
-
    align-items: center;
-
    padding: 0 0.5rem;
-
    min-height: 1.5rem;
-
    gap: 0.5rem;
-
    font-size: var(--font-size-small);
-
  }
-
  .timestamp {
-
    font-size: var(--font-size-small);
-
    color: var(--color-fill-gray);
-
  }
-
  .actions {
-
    display: flex;
-
    flex-direction: row;
-
    align-items: center;
-
    padding-left: 2.5rem;
-
    gap: 0.5rem;
-
  }
-
  .commits {
-
    position: relative;
-
    display: flex;
-
    flex-direction: column;
-
    font-size: 0.875rem;
-
    margin-left: 1.25rem;
-
    gap: 0.5rem;
-
    padding: 1rem 0.5rem 1rem 1rem;
-
    border-left: 1px solid var(--color-fill-separator);
-
  }
-
  .commit:last-of-type::after {
-
    content: "";
-
    position: absolute;
-
    left: -18.5px;
-
    top: 14px;
-
    bottom: -1rem;
-
    border-left: 4px solid var(--color-background-default);
-
  }
-
  .expanded {
-
    box-shadow: 0 0 0 1px var(--color-border-hint);
-
  }
-
  .commit-dot {
-
    border-radius: var(--border-radius-round);
-
    width: 4px;
-
    height: 4px;
-
    position: absolute;
-
    top: 0.625rem;
-
    left: -18.5px;
-
    background-color: var(--color-fill-separator);
-
  }
-
  .connector {
-
    width: 1px;
-
    height: 1.5rem;
-
    margin-left: 1.25rem;
-
    background-color: var(--color-fill-separator);
-
  }
-
  @media (max-width: 719.98px) {
-
    .revision-box {
-
      border-radius: 0;
-
    }
-
    .action {
-
      border-radius: 0;
-
    }
-
  }
-
</style>
-

-
<div class="revision" style:margin-bottom={expanded ? "2rem" : "0.5rem"}>
-
  <div class="revision-box" class:expanded>
-
    <div class="revision-header">
-
      <div class="revision-name">
-
        <ExpandButton {expanded} on:toggle={() => (expanded = !expanded)} />
-
        <span>
-
          Revision
-
          <Id id={revisionId} />
-
        </span>
-
      </div>
-
      <div class="revision-data">
-
        <span
-
          class="global-hide-on-mobile-down"
-
          title={utils.absoluteTimestamp(revisionTimestamp)}>
-
          {utils.formatTimestamp(revisionTimestamp)}
-
        </span>
-
        {#if loading}
-
          <Loading small />
-
        {/if}
-
        {#if response?.diff.stats}
-
          <Link
-
            title="Compare {utils.formatCommit(
-
              fromCommit,
-
            )}..{utils.formatCommit(revisionOid)}"
-
            route={{
-
              resource: "project.patch",
-
              project: projectId,
-
              node: baseUrl,
-
              patch: patchId,
-
              view: { name: "diff", fromCommit, toCommit: revisionOid },
-
            }}>
-
            {@const { insertions, deletions } = response.diff.stats}
-
            <DiffStatBadge hoverable {insertions} {deletions} />
-
          </Link>
-
        {/if}
-
        <Popover
-
          popoverPadding="0"
-
          popoverPositionTop={expanded ? "3rem" : "2.5rem"}
-
          popoverPositionRight="0"
-
          popoverBorderRadius="var(--border-radius-small)">
-
          <IconButton
-
            slot="toggle"
-
            let:toggle
-
            on:click={toggle}
-
            title="toggle-context-menu">
-
            <Icon name="more" />
-
          </IconButton>
-
          <DropdownList
-
            slot="popover"
-
            items={previousRevOid && previousRevId
-
              ? [revisionBase, previousRevOid]
-
              : [revisionBase]}>
-
            {@const baseMismatch = previousRevBase !== revisionBase}
-
            <Link
-
              let:item
-
              disabled={item !== revisionBase && baseMismatch}
-
              slot="item"
-
              title="Compare {utils.formatCommit(item)}..{utils.formatCommit(
-
                revisionOid,
-
              )}"
-
              route={{
-
                resource: "project.patch",
-
                project: projectId,
-
                node: baseUrl,
-
                patch: patchId,
-
                view: {
-
                  name: "diff",
-
                  fromCommit: item,
-
                  toCommit: revisionOid,
-
                },
-
              }}>
-
              {#if item === revisionBase}
-
                <DropdownListItem selected={false}>
-
                  <span class="compare-dropdown-item">
-
                    Compare to base:
-
                    <span
-
                      style:color="var(--color-fill-gray)"
-
                      style:font-weight="var(--font-weight-bold)"
-
                      style:font-family="var(--font-family-monospace)">
-
                      {utils.formatObjectId(revisionBase)}
-
                    </span>
-
                  </span>
-
                </DropdownListItem>
-
              {:else if previousRevId}
-
                <DropdownListItem
-
                  selected={false}
-
                  disabled={baseMismatch}
-
                  title={baseMismatch
-
                    ? "Previous revision has different base"
-
                    : `${utils.formatCommit(item)}..${utils.formatCommit(
-
                        revisionOid,
-
                      )}`}>
-
                  <span class="compare-dropdown-item">
-
                    Compare to previous revision: <span
-
                      style:color="var(--color-fill-secondary)"
-
                      style:font-weight="var(--font-weight-bold)"
-
                      style:font-family="var(--font-family-monospace)">
-
                      {utils.formatObjectId(previousRevId)}
-
                    </span>
-
                  </span>
-
                </DropdownListItem>
-
              {/if}
-
            </Link>
-
          </DropdownList>
-
        </Popover>
-
      </div>
-
    </div>
-
    {#if expanded}
-
      <div>
-
        <div class="patch-header">
-
          <div class="authorship-header">
-
            <div
-
              style:color={badgeColor(patchState)}
-
              style:padding="0 0.375rem">
-
              <Icon name="patch" />
-
            </div>
-
            <NodeId
-
              {baseUrl}
-
              nodeId={revisionAuthor.id}
-
              alias={revisionAuthor.alias} />
-
            {#if patchId === revisionId}
-
              opened this patch on base
-
              <Id id={revisionBase} style="commit" />
-
            {:else}
-
              updated to
-
              <Id id={revisionId} />
-
              {#if previousRevBase && previousRevBase !== revisionBase}
-
                with base
-
                <Id id={revisionBase} style="commit" />
-
              {/if}
-
            {/if}
-
            <span
-
              class="timestamp"
-
              title={utils.absoluteTimestamp(revisionTimestamp)}>
-
              {utils.formatTimestamp(revisionTimestamp)}
-
            </span>
-
            {#if revisionEdits.length > 1 && lastEdit}
-
              <div
-
                class="author-metadata"
-
                title={utils.formatEditedCaption(
-
                  lastEdit.author,
-
                  lastEdit.timestamp,
-
                )}>
-
                • edited
-
              </div>
-
            {/if}
-
          </div>
-
          {#if revisionDescription && !first}
-
            <div class="revision-description txt-small">
-
              <Markdown
-
                breaks
-
                rawPath={rawPath(revisionBase)}
-
                content={revisionDescription} />
-
            </div>
-
          {/if}
-
          {#if revisionReactions && revisionReactions.length > 0}
-
            <div class="actions">
-
              <Reactions reactions={revisionReactions} />
-
            </div>
-
          {/if}
-
        </div>
-
        {#if loading}
-
          <div style:height="3.5rem">
-
            <Loading small />
-
          </div>
-
        {/if}
-
        {#if response?.commits}
-
          <div class="commits">
-
            {#each response.commits.reverse() as commit}
-
              <div class="commit" style:position="relative">
-
                <div class="commit-dot" />
-
                <CobCommitTeaser {commit} {baseUrl} {projectId} />
-
              </div>
-
            {/each}
-
          </div>
-
        {/if}
-
      </div>
-
      {#if error}
-
        <div
-
          class="diff-error txt-monospace txt-small"
-
          style:border-radius="var(--border-radius-small)">
-
          <ErrorMessage
-
            title="Failed to load diff for this revision"
-
            description="Make sure you are able to connect to the seed <code>${utils.baseUrlToString(
-
              api.baseUrl,
-
            )}</code>"
-
            {error} />
-
        </div>
-
      {/if}
-
    {/if}
-
  </div>
-
  {#if expanded}
-
    {#if timelines.length > 0}
-
      {#each timelines as element}
-
        {#if element.type === "thread"}
-
          <div class="connector" />
-
          <Thread
-
            {baseUrl}
-
            thread={element.inner}
-
            rawPath={rawPath(revisionBase)} />
-
        {:else if element.type === "merge"}
-
          <div class="connector" />
-
          <div class="action merge">
-
            <div class="authorship-header">
-
              <div style:color="var(--color-fill-primary)">
-
                <Icon name="patch" />
-
              </div>
-

-
              <NodeId
-
                {baseUrl}
-
                nodeId={element.inner.author.id}
-
                alias={element.inner.author.alias}>
-
              </NodeId>
-

-
              merged revision
-
              <Id id={element.inner.revision} />
-
              at commit
-
              <Id id={element.inner.commit} style="commit" />
-
              <span
-
                class="timestamp"
-
                title={utils.absoluteTimestamp(revisionTimestamp)}>
-
                {utils.formatTimestamp(revisionTimestamp)}
-
              </span>
-
            </div>
-
          </div>
-
        {:else if element.type === "review"}
-
          {@const [author, review] = element.inner}
-
          <div class="connector" />
-
          <div
-
            class="action"
-
            class:comment-review={review.verdict === null}
-
            class:positive-review={review.verdict === "accept"}
-
            class:negative-review={review.verdict === "reject"}>
-
            <CommentComponent
-
              {baseUrl}
-
              id={review.id}
-
              rawPath={rawPath(revisionBase)}
-
              authorId={author}
-
              authorAlias={review.author.alias}
-
              timestamp={review.timestamp}
-
              body={review.summary ?? ""}>
-
              <div slot="caption">
-
                {formatVerdict(review.verdict)}
-
                <Id id={revisionId} />
-
              </div>
-
              <div slot="icon" style:color={verdictIconColor(review.verdict)}>
-
                {#if review.verdict === "accept"}
-
                  <Icon name="checkmark" />
-
                {:else if review.verdict === "reject"}
-
                  <Icon name="cross" />
-
                {:else}
-
                  <Icon name="chat" />
-
                {/if}
-
              </div>
-
            </CommentComponent>
-
          </div>
-
        {/if}
-
      {/each}
-
    {/if}
-
    <slot />
-
  {/if}
-
</div>
deleted src/views/projects/CommentCounter.svelte
@@ -1,21 +0,0 @@
-
<script lang="ts">
-
  import Icon from "@app/components/Icon.svelte";
-

-
  export let commentCount: number;
-
</script>
-

-
<style>
-
  .comments {
-
    color: var(--color-foreground-dim);
-
    font-size: var(--font-size-tiny);
-
    display: flex;
-
    align-items: center;
-
    gap: 0.25rem;
-
    white-space: nowrap;
-
  }
-
</style>
-

-
<div class="comments">
-
  <Icon name="chat" />
-
  <span>{commentCount}</span>
-
</div>
deleted src/views/projects/Commit.svelte
@@ -1,109 +0,0 @@
-
<script lang="ts">
-
  import type { BaseUrl, Commit, Project, SeedingPolicy } from "@http-client";
-

-
  import { formatObjectId } from "@app/lib/utils";
-

-
  import Button from "@app/components/Button.svelte";
-
  import Changeset from "@app/views/projects/Changeset.svelte";
-
  import CommitAuthorship from "@app/views/projects/Commit/CommitAuthorship.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Id from "@app/components/Id.svelte";
-
  import InlineTitle from "@app/views/projects/components/InlineTitle.svelte";
-
  import Layout from "./Layout.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import Separator from "./Separator.svelte";
-
  import Share from "./Share.svelte";
-

-
  export let baseUrl: BaseUrl;
-
  export let seedingPolicy: SeedingPolicy;
-
  export let commit: Commit;
-
  export let project: Project;
-
  export let nodeAvatarUrl: string | undefined;
-

-
  $: header = commit.commit;
-
</script>
-

-
<style>
-
  .commit {
-
    background-color: var(--color-background-float);
-
  }
-
  .header {
-
    padding: 1rem;
-
    border-radius: var(--border-radius-small);
-
    border-bottom: 1px solid var(--color-border-hint);
-
  }
-
  .title {
-
    display: flex;
-
    align-items: center;
-
    font-weight: var(--font-weight-semibold);
-
  }
-
  .description {
-
    font-family: var(--font-family-monospace);
-
    white-space: pre-wrap;
-
    margin-top: 1.5rem;
-
  }
-
  .button-container {
-
    margin-left: auto;
-
    display: flex;
-
    gap: 0.5rem;
-
  }
-
</style>
-

-
<Layout {nodeAvatarUrl} {seedingPolicy} {baseUrl} {project}>
-
  <svelte:fragment slot="breadcrumb">
-
    <Separator />
-
    <Link
-
      route={{
-
        resource: "project.history",
-
        project: project.id,
-
        node: baseUrl,
-
      }}>
-
      Commits
-
    </Link>
-
    <Separator />
-
    <span class="id">
-
      <div class="global-hide-on-small-desktop-down">
-
        {commit.commit.id}
-
      </div>
-
      <div class="global-hide-on-medium-desktop-up">
-
        {formatObjectId(commit.commit.id)}
-
      </div>
-
    </span>
-
  </svelte:fragment>
-
  <div class="commit">
-
    <div class="header">
-
      <div style="display:flex; flex-direction: column; gap: 0.5rem;">
-
        <span class="title">
-
          <InlineTitle fontSize="large" content={header.summary} />
-
          <div class="button-container">
-
            <Link
-
              route={{
-
                resource: "project.source",
-
                project: project.id,
-
                node: baseUrl,
-
                path: "/",
-
                revision: commit.commit.id,
-
              }}>
-
              <Button variant="outline" title="Browse repo at this commit">
-
                <Icon name="chevron-left-right" />
-
              </Button>
-
            </Link>
-
            <Share />
-
          </div>
-
        </span>
-
        <CommitAuthorship {header}>
-
          <Id id={header.id} style="commit" ariaLabel="commit-id" />
-
        </CommitAuthorship>
-
      </div>
-
      {#if header.description}
-
        <pre class="description txt-small">{header.description}</pre>
-
      {/if}
-
    </div>
-
    <Changeset
-
      {baseUrl}
-
      projectId={project.id}
-
      files={commit.files}
-
      diff={commit.diff}
-
      revision={commit.commit.id} />
-
  </div>
-
</Layout>
deleted src/views/projects/Commit/CommitAuthorship.svelte
@@ -1,70 +0,0 @@
-
<script lang="ts">
-
  import type { CommitHeader } from "@http-client";
-

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

-
  export let header: CommitHeader;
-
</script>
-

-
<style>
-
  .authorship {
-
    display: flex;
-
    font-size: var(--font-size-small);
-
    gap: 0.5rem;
-
    flex-wrap: wrap;
-
    align-items: center;
-
  }
-
  .person {
-
    display: flex;
-
    align-items: center;
-
    flex-wrap: nowrap;
-
    white-space: nowrap;
-
    gap: 0.5rem;
-
    font-family: var(--font-family-monospace);
-
    font-weight: var(--font-weight-semibold);
-
  }
-
  .avatar {
-
    width: 1rem;
-
    height: 1rem;
-
    border-radius: var(--border-radius-round);
-
  }
-
</style>
-

-
<span class="authorship">
-
  {#if header.author.email === header.committer.email}
-
    <div class="person">
-
      <img
-
        class="avatar"
-
        alt="avatar"
-
        src={gravatarURL(header.committer.email)} />
-
      {header.committer.name}
-
    </div>
-
    committed
-
    <slot />
-
    <span title={absoluteTimestamp(header.committer.time)}>
-
      {formatTimestamp(header.committer.time)}
-
    </span>
-
  {:else}
-
    <div class="person">
-
      <img class="avatar" alt="avatar" src={gravatarURL(header.author.email)} />
-
      {header.author.name}
-
    </div>
-
    authored and
-
    <div class="person">
-
      <img
-
        class="avatar"
-
        alt="avatar"
-
        src={gravatarURL(header.committer.email)} />
-
      {header.committer.name}
-
    </div>
-
    committed
-
    <slot />
-
    <span title={absoluteTimestamp(header.committer.time)}>
-
      {formatTimestamp(header.committer.time)}
-
    </span>
-
  {/if}
-
</span>
deleted src/views/projects/Commit/CommitTeaser.svelte
@@ -1,125 +0,0 @@
-
<script lang="ts">
-
  import type { BaseUrl, CommitHeader } from "@http-client";
-

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

-
  import CommitAuthorship from "./CommitAuthorship.svelte";
-
  import ExpandButton from "@app/components/ExpandButton.svelte";
-
  import IconButton from "@app/components/IconButton.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import InlineTitle from "@app/views/projects/components/InlineTitle.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import Id from "@app/components/Id.svelte";
-

-
  export let baseUrl: BaseUrl;
-
  export let commit: CommitHeader;
-
  export let projectId: string;
-

-
  let commitMessageVisible = false;
-
</script>
-

-
<style>
-
  .teaser {
-
    display: flex;
-
    padding: 1.25rem;
-
    background-color: var(--color-background-float);
-
  }
-
  .teaser:hover {
-
    background-color: var(--color-fill-float-hover);
-
  }
-
  .message {
-
    align-items: center;
-
    display: flex;
-
    flex-direction: row;
-
    flex-wrap: wrap;
-
    gap: 0.5rem;
-
  }
-
  .left {
-
    display: flex;
-
    flex-direction: column;
-
    gap: 0.5rem;
-
  }
-
  .right {
-
    display: flex;
-
    align-items: flex-start;
-
    gap: 1rem;
-
    margin-left: auto;
-
    color: var(--color-foreground-dim);
-
    font-size: var(--font-size-tiny);
-
  }
-
  .summary {
-
    font-size: var(--font-size-small);
-
  }
-
  .summary::after {
-
    content: "";
-
    position: absolute;
-
    top: -10px;
-
    right: 0px;
-
    bottom: -10px;
-
    left: -10px;
-
  }
-
  .summary:hover {
-
    text-decoration: underline;
-
    text-decoration-thickness: 1px;
-
    text-underline-offset: 2px;
-
    cursor: pointer;
-
  }
-
  .commit-message {
-
    margin: 0.5rem 0;
-
    font-size: var(--font-size-small);
-
  }
-
  pre {
-
    white-space: pre-wrap;
-
    word-wrap: break-word;
-
  }
-
</style>
-

-
<div class="teaser">
-
  <div class="left">
-
    <div class="message">
-
      <Link
-
        route={{
-
          resource: "project.commit",
-
          project: projectId,
-
          node: baseUrl,
-
          commit: commit.id,
-
        }}>
-
        <div style="position: relative;">
-
          <div class="summary" use:twemoji>
-
            <InlineTitle fontSize="regular" content={commit.summary} />
-
          </div>
-
        </div>
-
      </Link>
-
      {#if commit.description}
-
        <ExpandButton
-
          variant="inline"
-
          on:toggle={() => {
-
            commitMessageVisible = !commitMessageVisible;
-
          }} />
-
      {/if}
-
    </div>
-
    {#if commitMessageVisible}
-
      <div class="commit-message">
-
        <pre>{commit.description.trim()}</pre>
-
      </div>
-
    {/if}
-
    <CommitAuthorship header={commit}>
-
      <Id id={commit.id} style="commit" />
-
    </CommitAuthorship>
-
  </div>
-
  <div class="right">
-
    <div style:display="flex" style:gap="1rem" style:height="1.5rem">
-
      <IconButton title="Browse repo at this commit">
-
        <Link
-
          route={{
-
            resource: "project.source",
-
            project: projectId,
-
            node: baseUrl,
-
            revision: commit.id,
-
          }}>
-
          <Icon name="chevron-left-right" />
-
        </Link>
-
      </IconButton>
-
    </div>
-
  </div>
-
</div>
deleted src/views/projects/DiffStatBadgeLoader.svelte
@@ -1,40 +0,0 @@
-
<script lang="ts">
-
  import type { BaseUrl, Patch, Revision } from "@http-client";
-

-
  import { cachedGetDiff } from "@app/views/projects/router";
-
  import { formatCommit } from "@app/lib/utils";
-

-
  import DiffStatBadge from "@app/components/DiffStatBadge.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import Loading from "@app/components/Loading.svelte";
-

-
  export let projectId: string;
-
  export let baseUrl: BaseUrl;
-
  export let patch: Patch;
-
  export let latestRevision: Revision;
-
</script>
-

-
{#await cachedGetDiff(baseUrl, projectId, latestRevision.base, latestRevision.oid)}
-
  <Loading small />
-
{:then { diff }}
-
  <Link
-
    title="Compare {formatCommit(latestRevision.base)}..{formatCommit(
-
      latestRevision.oid,
-
    )}"
-
    route={{
-
      resource: "project.patch",
-
      project: projectId,
-
      node: baseUrl,
-
      patch: patch.id,
-
      view: {
-
        name: "diff",
-
        fromCommit: latestRevision.base,
-
        toCommit: latestRevision.oid,
-
      },
-
    }}>
-
    <DiffStatBadge
-
      hoverable
-
      insertions={diff.stats.insertions}
-
      deletions={diff.stats.deletions} />
-
  </Link>
-
{/await}
deleted src/views/projects/Header.svelte
@@ -1,115 +0,0 @@
-
<script lang="ts" context="module">
-
  export type ActiveTab = "source" | "issues" | "patches" | undefined;
-
</script>
-

-
<script lang="ts">
-
  import type { BaseUrl, Project } from "@http-client";
-

-
  import Link from "@app/components/Link.svelte";
-
  import Button from "@app/components/Button.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-

-
  export let baseUrl: BaseUrl;
-
  export let activeTab: ActiveTab = undefined;
-
  export let project: Project;
-
</script>
-

-
<style>
-
  .container {
-
    display: flex;
-
    flex-direction: column;
-
    gap: 0.5rem;
-
  }
-

-
  .counter {
-
    border-radius: var(--border-radius-tiny);
-
    background-color: var(--color-fill-ghost);
-
    color: var(--color-foreground-dim);
-
    padding: 0 0.25rem;
-
  }
-

-
  .selected {
-
    background-color: var(--color-fill-counter);
-
    color: var(--color-foreground-contrast);
-
  }
-

-
  .hover {
-
    background-color: var(--color-fill-ghost-hover);
-
    color: var(--color-foreground-contrast);
-
  }
-

-
  .title-counter {
-
    display: flex;
-
    gap: 0.5rem;
-
    justify-content: space-between;
-
    width: 100%;
-
  }
-
</style>
-

-
<div class="container">
-
  <Link
-
    route={{
-
      resource: "project.source",
-
      project: project.id,
-
      node: baseUrl,
-
      path: "/",
-
    }}>
-
    <Button
-
      size="large"
-
      styleWidth="100%"
-
      styleJustifyContent="flex-start"
-
      variant={activeTab === "source" ? "gray" : "background"}>
-
      <Icon name="chevron-left-right" />
-
      Source
-
    </Button>
-
  </Link>
-
  <Link
-
    route={{
-
      resource: "project.issues",
-
      project: project.id,
-
      node: baseUrl,
-
    }}>
-
    <Button
-
      let:hover
-
      size="large"
-
      styleJustifyContent="flex-start"
-
      styleWidth="100%"
-
      variant={activeTab === "issues" ? "gray" : "background"}>
-
      <Icon name="issue" />
-
      <div class="title-counter">
-
        Issues
-
        <span
-
          class="counter"
-
          class:selected={activeTab === "issues"}
-
          class:hover={hover && activeTab !== "issues"}>
-
          {project.issues.open}
-
        </span>
-
      </div>
-
    </Button>
-
  </Link>
-

-
  <Link
-
    route={{
-
      resource: "project.patches",
-
      project: project.id,
-
      node: baseUrl,
-
    }}>
-
    <Button
-
      let:hover
-
      size="large"
-
      styleWidth="100%"
-
      styleJustifyContent="flex-start"
-
      variant={activeTab === "patches" ? "gray" : "background"}>
-
      <Icon name="patch" />
-
      <div class="title-counter">
-
        Patches
-
        <span
-
          class="counter"
-
          class:hover={hover && activeTab !== "patches"}
-
          class:selected={activeTab === "patches"}>
-
          {project.patches.open}
-
        </span>
-
      </div>
-
    </Button>
-
  </Link>
-
</div>
deleted src/views/projects/Header/CloneButton.svelte
@@ -1,96 +0,0 @@
-
<script lang="ts">
-
  import type { BaseUrl } from "@http-client";
-

-
  import config from "virtual:config";
-
  import { parseRepositoryId } from "@app/lib/utils";
-

-
  import Button from "@app/components/Button.svelte";
-
  import Command from "@app/components/Command.svelte";
-
  import ExternalLink from "@app/components/ExternalLink.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Popover from "@app/components/Popover.svelte";
-
  import Radio from "@app/components/Radio.svelte";
-

-
  export let baseUrl: BaseUrl;
-
  export let id: string;
-
  export let name: string;
-

-
  let radicle: boolean = true;
-

-
  $: radCloneUrl = `rad clone ${id}`;
-
  $: portFragment =
-
    baseUrl.scheme === config.nodes.defaultHttpdScheme &&
-
    baseUrl.port === config.nodes.defaultHttpdPort
-
      ? ""
-
      : `:${baseUrl.port}`;
-
  $: gitCloneUrl = `git clone ${baseUrl.scheme}://${
-
    baseUrl.hostname
-
  }${portFragment}/${parseRepositoryId(id)?.pubkey ?? id}.git ${name}`;
-
</script>
-

-
<style>
-
  .popover {
-
    font-size: var(--font-size-small);
-
    font-weight: var(--font-weight-regular);
-
  }
-
  label {
-
    display: block;
-
    margin-bottom: 0.75rem;
-
  }
-
</style>
-

-
<Popover popoverPositionTop="2.5rem" popoverPositionRight="0">
-
  <Button slot="toggle" let:toggle on:click={toggle} variant="outline">
-
    <Icon name="download" />
-
    <span class="global-hide-on-small-desktop-down">Clone</span>
-
  </Button>
-

-
  <div slot="popover" style:width="24rem" class="popover">
-
    <div style:margin-bottom="1.5rem">
-
      <Radio ariaLabel="Toggle render method">
-
        <Button
-
          styleWidth="100%"
-
          styleBorderRadius="0"
-
          variant={radicle ? "selected" : "not-selected"}
-
          on:click={() => {
-
            radicle = true;
-
          }}>
-
          <Icon name="logo" />
-
          Radicle
-
        </Button>
-
        <div class="global-spacer" />
-
        <Button
-
          styleWidth="100%"
-
          styleBorderRadius="0"
-
          variant={!radicle ? "selected" : "not-selected"}
-
          on:click={() => {
-
            radicle = false;
-
          }}>
-
          <Icon name="git" />
-
          Git
-
        </Button>
-
      </Radio>
-
    </div>
-

-
    {#if radicle}
-
      <label for="rad-clone-url">
-
        Use the <ExternalLink href="https://radicle.xyz">
-
          Radicle CLI
-
        </ExternalLink> to clone this repository.
-
      </label>
-
      <Command command={radCloneUrl} />
-
    {:else}
-
      <div>
-
        <label for="git-clone-url">
-
          If you don't have Radicle installed, you can still clone the
-
          repository via Git.
-
        </label>
-
        <Command command={gitCloneUrl} />
-
        <div style:margin-top="1.5rem">
-
          Note that a Git clone does not include any of the social artifacts
-
          such as issues or patches.
-
        </div>
-
      </div>
-
    {/if}
-
  </div>
-
</Popover>
deleted src/views/projects/Header/SeedButton.svelte
@@ -1,71 +0,0 @@
-
<script lang="ts">
-
  import Button from "@app/components/Button.svelte";
-
  import Command from "@app/components/Command.svelte";
-
  import ExternalLink from "@app/components/ExternalLink.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Popover from "@app/components/Popover.svelte";
-

-
  export let projectId: string;
-
  export let seedCount: number;
-
  export let disabled: boolean = false;
-
</script>
-

-
<style>
-
  .seed-label {
-
    display: block;
-
    font-size: var(--font-size-small);
-
    font-weight: var(--font-weight-regular);
-
    margin-bottom: 0.75rem;
-
  }
-
  .title-counter {
-
    display: flex;
-
    gap: 0.5rem;
-
  }
-
  .counter {
-
    font-weight: var(--font-weight-regular);
-
    border-radius: var(--border-radius-tiny);
-
    background-color: var(--color-fill-ghost-hover);
-
    border: 1px solid var(--color-border-secondary-counter);
-
    color: var(--color-foreground-contrast);
-
    padding: 0 0.25rem;
-
  }
-
  .not-seeding {
-
    background-color: var(--color-fill-secondary-counter);
-
    color: var(--color-foreground-match-background);
-
  }
-
  .disabled {
-
    background-color: var(--color-fill-float-hover);
-
    color: var(--color-foreground-disabled);
-
  }
-
</style>
-

-
<Popover popoverPositionTop="2.5rem" popoverPositionRight="0">
-
  <Button
-
    slot="toggle"
-
    {disabled}
-
    let:toggle
-
    on:click={() => {
-
      toggle();
-
    }}
-
    variant="secondary-toggle-off">
-
    <Icon name="seedling" />
-
    <span class="title-counter">
-
      <span class="global-hide-on-mobile-down">Seed</span>
-
      <span
-
        class="counter not-seeding"
-
        class:disabled
-
        style:font-weight="var(--font-weight-regular)">
-
        {seedCount}
-
      </span>
-
    </span>
-
  </Button>
-

-
  <div slot="popover" style:width="auto">
-
    <span class="seed-label">
-
      Use the <ExternalLink href="https://radicle.xyz">
-
        Radicle CLI
-
      </ExternalLink> to start seeding this repository.
-
    </span>
-
    <Command command={`rad seed ${projectId}`} />
-
  </div>
-
</Popover>
deleted src/views/projects/History.svelte
@@ -1,174 +0,0 @@
-
<script lang="ts">
-
  import type {
-
    BaseUrl,
-
    CommitHeader,
-
    Project,
-
    Remote,
-
    SeedingPolicy,
-
    Tree,
-
  } from "@http-client";
-
  import type { ProjectRoute } from "./router";
-

-
  import config from "virtual:config";
-
  import { HttpdClient } from "@http-client";
-
  import { baseUrlToString } from "@app/lib/utils";
-
  import { groupCommits } from "@app/lib/commit";
-

-
  import Button from "@app/components/Button.svelte";
-
  import CommitTeaser from "./Commit/CommitTeaser.svelte";
-
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
-
  import Header from "./Source/Header.svelte";
-
  import Layout from "./Layout.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import List from "@app/components/List.svelte";
-
  import Loading from "@app/components/Loading.svelte";
-
  import ProjectNameHeader from "./Source/ProjectNameHeader.svelte";
-
  import Separator from "./Separator.svelte";
-

-
  export let baseUrl: BaseUrl;
-
  export let seedingPolicy: SeedingPolicy;
-
  export let commit: string;
-
  export let commitHeaders: CommitHeader[];
-
  export let peer: string | undefined;
-
  export let peers: Remote[];
-
  export let project: Project;
-
  export let revision: string | undefined;
-
  export let tree: Tree;
-
  export let nodeAvatarUrl: string | undefined;
-

-
  const api = new HttpdClient(baseUrl);
-

-
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-
  let error: any;
-
  let page = 0;
-
  let loading = false;
-
  let allCommitHeaders: CommitHeader[];
-

-
  $: baseRoute = {
-
    resource: "project.history",
-
    node: baseUrl,
-
    project: project.id,
-
  } as Extract<ProjectRoute, { resource: "project.history" }>;
-
  $: {
-
    allCommitHeaders = commitHeaders;
-
    page = 0;
-
  }
-

-
  async function loadMore() {
-
    loading = true;
-
    page += 1;
-
    try {
-
      const response = await api.project.getAllCommits(project.id, {
-
        parent: allCommitHeaders[0].id,
-
        page,
-
        perPage: config.source.commitsPerPage,
-
      });
-
      allCommitHeaders = [...allCommitHeaders, ...response];
-
    } catch (e) {
-
      error = e;
-
    }
-
    loading = false;
-
  }
-
</script>
-

-
<style>
-
  .more {
-
    margin-top: 2rem;
-
    min-height: 3rem;
-
    display: flex;
-
    align-items: center;
-
    justify-content: center;
-
  }
-
  .group-header {
-
    margin-left: 1rem;
-
    margin-top: 3rem;
-
    margin-bottom: 1rem;
-
    font-size: var(--font-size-small);
-
    font-weight: var(--font-weight-medium);
-
    color: var(--color-foreground-dim);
-
  }
-
  .group-header:first-child {
-
    margin-top: 0;
-
  }
-
</style>
-

-
<Layout {nodeAvatarUrl} {seedingPolicy} {baseUrl} {project} activeTab="source">
-
  <svelte:fragment slot="breadcrumb">
-
    <Separator />
-
    <Link
-
      route={{
-
        resource: "project.history",
-
        project: project.id,
-
        node: baseUrl,
-
      }}>
-
      Commits
-
    </Link>
-
  </svelte:fragment>
-
  <ProjectNameHeader {project} {baseUrl} slot="header" />
-

-
  <div style:margin="1rem" slot="subheader">
-
    <Header
-
      {baseRoute}
-
      {commit}
-
      {peers}
-
      {peer}
-
      {project}
-
      {revision}
-
      {tree}
-
      node={baseUrl}
-
      filesLinkActive={false}
-
      historyLinkActive={true} />
-
  </div>
-

-
  <div>
-
    {#each groupCommits(allCommitHeaders) as group (group.time)}
-
      <div class="group-header">{group.date}</div>
-
      <List items={group.commits}>
-
        <CommitTeaser
-
          slot="item"
-
          let:item
-
          projectId={project.id}
-
          {baseUrl}
-
          commit={item} />
-
      </List>
-
    {/each}
-
  </div>
-

-
  {#await api.project.getTreeStatsBySha(project.id, commit)}
-
    <div class="more">
-
      <Loading small center />
-
    </div>
-
  {:then stats}
-
    {#if loading || allCommitHeaders.length < stats.commits}
-
      <div class="more">
-
        {#if loading}
-
          <Loading small={page !== 0} center />
-
        {:else if allCommitHeaders.length < stats.commits}
-
          <Button size="large" variant="outline" on:click={loadMore}>
-
            More
-
          </Button>
-
        {/if}
-
      </div>
-
    {/if}
-

-
    {#if error}
-
      <div class="message">
-
        <ErrorMessage
-
          title="Couldn't load commits"
-
          description="Make sure you are able to connect to the seed <code>${baseUrlToString(
-
            api.baseUrl,
-
          )}</code>"
-
          {error} />
-
      </div>
-
    {/if}
-
  {:catch error}
-
    <div class="message">
-
      <ErrorMessage
-
        title="Couldn't load repo stats"
-
        description="Make sure you are able to connect to the seed <code>${baseUrlToString(
-
          api.baseUrl,
-
        )}</code>"
-
        {error} />
-
    </div>
-
  {/await}
-
</Layout>
deleted src/views/projects/Issue.svelte
@@ -1,252 +0,0 @@
-
<script lang="ts">
-
  import type { BaseUrl, Issue, Project, SeedingPolicy } from "@http-client";
-

-
  import capitalize from "lodash/capitalize";
-
  import uniqBy from "lodash/uniqBy";
-

-
  import * as utils from "@app/lib/utils";
-

-
  import Assignees from "@app/views/projects/Cob/Assignees.svelte";
-
  import Badge from "@app/components/Badge.svelte";
-
  import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
-
  import Embeds from "@app/views/projects/Cob/Embeds.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Id from "@app/components/Id.svelte";
-
  import InlineTitle from "@app/views/projects/components/InlineTitle.svelte";
-
  import Labels from "@app/views/projects/Cob/Labels.svelte";
-
  import Layout from "./Layout.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import Markdown from "@app/components/Markdown.svelte";
-
  import NodeId from "@app/components/NodeId.svelte";
-
  import Reactions from "@app/components/Reactions.svelte";
-
  import Separator from "./Separator.svelte";
-
  import Share from "@app/views/projects/Share.svelte";
-
  import ThreadComponent from "@app/components/Thread.svelte";
-

-
  export let baseUrl: BaseUrl;
-
  export let seedingPolicy: SeedingPolicy;
-
  export let issue: Issue;
-
  export let project: Project;
-
  export let rawPath: (commit?: string) => string;
-
  export let nodeAvatarUrl: string | undefined;
-

-
  $: uniqueEmbeds = uniqBy(
-
    issue.discussion.flatMap(comment => comment.embeds),
-
    "content",
-
  );
-
  $: threads = issue.discussion
-
    .filter(
-
      comment =>
-
        (comment.id !== issue.discussion[0].id && !comment.replyTo) ||
-
        comment.replyTo === issue.discussion[0].id,
-
    )
-
    .map(thread => {
-
      return {
-
        root: thread,
-
        replies: issue.discussion
-
          .filter(comment => comment.replyTo === thread.id)
-
          .sort((a, b) => a.timestamp - b.timestamp),
-
      };
-
    }, []);
-
  $: lastDescriptionEdit =
-
    issue.discussion[0].edits.length > 1
-
      ? issue.discussion[0].edits.at(-1)
-
      : undefined;
-
</script>
-

-
<style>
-
  .issue {
-
    display: flex;
-
    flex: 1;
-
    min-height: 100%;
-
  }
-
  .main {
-
    display: flex;
-
    flex: 1;
-
    flex-direction: column;
-
    min-width: 0;
-
    background-color: var(--color-background-float);
-
  }
-
  .bottom {
-
    padding: 0 1rem 2.5rem 1rem;
-
    background-color: var(--color-background-default);
-
    height: 100%;
-
    border-top: 1px solid var(--color-border-hint);
-
  }
-
  .connector {
-
    width: 1px;
-
    height: 1.5rem;
-
    margin-left: 1.25rem;
-
    background-color: var(--color-fill-separator);
-
  }
-
  .metadata {
-
    display: flex;
-
    flex-direction: column;
-
    padding: 1rem;
-
    border-left: 1px solid var(--color-border-hint);
-
    gap: 1.5rem;
-
    width: 20rem;
-
  }
-

-
  .threads {
-
    display: flex;
-
    flex-direction: column;
-
  }
-

-
  .author-metadata {
-
    color: var(--color-fill-gray);
-
    font-size: var(--font-size-small);
-
  }
-
  .title {
-
    overflow: hidden;
-
    text-overflow: ellipsis;
-
    display: flex;
-
    align-items: center;
-
    gap: 0.5rem;
-
    font-weight: var(--font-weight-semibold);
-
    font-size: var(--font-size-large);
-
    word-break: break-word;
-
  }
-
  .reactions {
-
    display: flex;
-
    gap: 0.5rem;
-
    align-items: center;
-
    margin-left: -0.25rem;
-
  }
-
  .id {
-
    font-size: var(--font-size-small);
-
    font-family: var(--font-family-monospace);
-
    font-weight: var(--font-weight-semibold);
-
  }
-
  @media (max-width: 719.98px) {
-
    .bottom {
-
      padding: 0;
-
    }
-
  }
-
</style>
-

-
<Layout
-
  {baseUrl}
-
  {nodeAvatarUrl}
-
  {project}
-
  {seedingPolicy}
-
  activeTab="issues"
-
  stylePaddingBottom="0">
-
  <svelte:fragment slot="breadcrumb">
-
    <Separator />
-
    <Link
-
      route={{
-
        resource: "project.issues",
-
        project: project.id,
-
        node: baseUrl,
-
      }}>
-
      Issues
-
    </Link>
-
    <Separator />
-
    <span class="id">
-
      <div class="global-hide-on-small-desktop-down">
-
        {issue.id}
-
      </div>
-
      <div class="global-hide-on-medium-desktop-up">
-
        {utils.formatObjectId(issue.id)}
-
      </div>
-
    </span>
-
  </svelte:fragment>
-

-
  <div class="issue">
-
    <div class="main">
-
      <CobHeader>
-
        <svelte:fragment slot="title">
-
          <div style="display: flex; gap: 1rem; width: 100%;">
-
            {#if issue.title}
-
              <div class="title">
-
                <InlineTitle fontSize="large" content={issue.title} />
-
              </div>
-
            {:else}
-
              <span class="txt-missing">No title</span>
-
            {/if}
-
          </div>
-
          <Share />
-
        </svelte:fragment>
-
        <svelte:fragment slot="state">
-
          {#if issue.state.status === "open"}
-
            <Badge size="tiny" variant="positive">
-
              <Icon name="issue" />
-
              {capitalize(issue.state.status)}
-
            </Badge>
-
          {:else}
-
            <Badge size="tiny" variant="negative">
-
              <Icon name="issue" />
-
              {capitalize(issue.state.status)} as
-
              {issue.state.reason}
-
            </Badge>
-
          {/if}
-
          <NodeId
-
            {baseUrl}
-
            nodeId={issue.author.id}
-
            alias={issue.author.alias} />
-
          opened
-
          <Id id={issue.id} />
-
          <span title={utils.absoluteTimestamp(issue.discussion[0].timestamp)}>
-
            {utils.formatTimestamp(issue.discussion[0].timestamp)}
-
          </span>
-
          {#if lastDescriptionEdit}
-
            <div
-
              class="author-metadata"
-
              title={utils.formatEditedCaption(
-
                lastDescriptionEdit.author,
-
                lastDescriptionEdit.timestamp,
-
              )}>
-
              • edited
-
            </div>
-
          {/if}
-
        </svelte:fragment>
-
        <div slot="subtitle" class="global-hide-on-desktop-up">
-
          <div
-
            style:margin-top="2rem"
-
            style="display: flex; flex-direction: column; gap: 0.5rem;">
-
            <Assignees assignees={issue.assignees} />
-
            <Labels labels={issue.labels} />
-
            <Embeds embeds={uniqueEmbeds} />
-
          </div>
-
        </div>
-
        <svelte:fragment slot="description">
-
          {#if issue.discussion[0].body}
-
            <Markdown
-
              breaks
-
              content={issue.discussion[0].body}
-
              rawPath={rawPath(project.head)} />
-
          {:else}
-
            <span class="txt-missing">No description</span>
-
          {/if}
-
          <div class="reactions">
-
            {#if issue.discussion[0].reactions.length > 0}
-
              <Reactions reactions={issue.discussion[0].reactions} />
-
            {/if}
-
          </div>
-
        </svelte:fragment>
-
      </CobHeader>
-
      <div class="bottom">
-
        {#if threads.length > 0}
-
          <div class="connector" />
-
          <div class="threads">
-
            {#each threads as thread, i (thread.root.id)}
-
              <ThreadComponent
-
                {baseUrl}
-
                {thread}
-
                rawPath={rawPath(project.head)} />
-
              {#if i < threads.length - 1}
-
                <div class="connector" />
-
              {/if}
-
            {/each}
-
          </div>
-
        {/if}
-
      </div>
-
    </div>
-
    <div class="metadata global-hide-on-medium-desktop-down">
-
      <Assignees assignees={issue.assignees} />
-
      <Labels labels={issue.labels} />
-
      <Embeds embeds={uniqueEmbeds} />
-
    </div>
-
  </div>
-
</Layout>
deleted src/views/projects/Issue/IssueTeaser.svelte
@@ -1,131 +0,0 @@
-
<script lang="ts">
-
  import type { BaseUrl, Issue } from "@http-client";
-

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

-
  import CommentCounter from "../CommentCounter.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Id from "@app/components/Id.svelte";
-
  import InlineLabels from "../Cob/InlineLabels.svelte";
-
  import InlineTitle from "@app/views/projects/components/InlineTitle.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import NodeId from "@app/components/NodeId.svelte";
-

-
  export let baseUrl: BaseUrl;
-
  export let issue: Issue;
-
  export let projectId: string;
-

-
  $: commentCount = issue.discussion.reduce((acc, _curr, index) => {
-
    if (index !== 0) {
-
      return acc + 1;
-
    }
-
    return acc;
-
  }, 0);
-
</script>
-

-
<style>
-
  .issue-teaser {
-
    display: flex;
-
    padding: 1.25rem;
-
    background-color: var(--color-background-float);
-
  }
-
  .issue-teaser:hover {
-
    background-color: var(--color-fill-float-hover);
-
  }
-
  .content {
-
    gap: 0.5rem;
-
    display: flex;
-
    flex-direction: column;
-
    flex: 1;
-
  }
-
  .subtitle {
-
    display: flex;
-
    flex-direction: column;
-
    flex-wrap: wrap;
-
    font-size: var(--font-size-small);
-
    gap: 0.5rem;
-
  }
-
  .summary {
-
    display: flex;
-
    align-items: flex-start;
-
    gap: 0.5rem;
-
    word-break: break-word;
-
  }
-
  .right {
-
    display: flex;
-
    margin-left: auto;
-
    min-height: 1.5rem;
-
    align-items: center;
-
  }
-
  .state {
-
    justify-self: center;
-
    align-self: flex-start;
-
    margin-right: 0.5rem;
-
    padding: 0.25rem 0;
-
  }
-
  .open {
-
    color: var(--color-fill-success);
-
  }
-
  .closed {
-
    color: var(--color-foreground-red);
-
  }
-
</style>
-

-
<div role="button" tabindex="0" class="issue-teaser">
-
  <div
-
    class="state"
-
    class:closed={issue.state.status === "closed"}
-
    class:open={issue.state.status === "open"}>
-
    <Icon name="issue" />
-
  </div>
-
  <div class="content">
-
    <div class="summary">
-
      <span class="issue-title">
-
        <Link
-
          styleHoverState
-
          route={{
-
            resource: "project.issue",
-
            project: projectId,
-
            node: baseUrl,
-
            issue: issue.id,
-
          }}>
-
          {#if !issue.title}
-
            <span class="txt-missing">No title</span>
-
          {:else}
-
            <InlineTitle fontSize="regular" content={issue.title} />
-
          {/if}
-
        </Link>
-
      </span>
-
      {#if issue.labels.length > 0}
-
        <span
-
          class="global-hide-on-small-desktop-down"
-
          style="display: inline-flex; gap: 0.5rem;">
-
          <InlineLabels labels={issue.labels} />
-
        </span>
-
      {/if}
-
      <div class="right">
-
        {#if commentCount > 0}
-
          <CommentCounter {commentCount} />
-
        {/if}
-
      </div>
-
    </div>
-
    <div class="subtitle">
-
      {#if issue.labels.length > 0}
-
        <div
-
          class="global-hide-on-medium-desktop-up"
-
          style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
-
          <InlineLabels labels={issue.labels} />
-
        </div>
-
      {/if}
-
      <div
-
        style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;">
-
        <NodeId {baseUrl} nodeId={issue.author.id} alias={issue.author.alias} />
-
        opened
-
        <Id id={issue.id} />
-
        <span title={absoluteTimestamp(issue.discussion[0].timestamp)}>
-
          {formatTimestamp(issue.discussion[0].timestamp)}
-
        </span>
-
      </div>
-
    </div>
-
  </div>
-
</div>
deleted src/views/projects/Issues.svelte
@@ -1,226 +0,0 @@
-
<script lang="ts">
-
  import type {
-
    BaseUrl,
-
    Issue,
-
    IssueState,
-
    Project,
-
    SeedingPolicy,
-
  } from "@http-client";
-

-
  import capitalize from "lodash/capitalize";
-
  import { HttpdClient } from "@http-client";
-
  import { ISSUES_PER_PAGE } from "./router";
-
  import { baseUrlToString } from "@app/lib/utils";
-
  import { closeFocused } from "@app/components/Popover.svelte";
-

-
  import Button from "@app/components/Button.svelte";
-
  import DropdownList from "@app/components/DropdownList.svelte";
-
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
-
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import IssueTeaser from "@app/views/projects/Issue/IssueTeaser.svelte";
-
  import Layout from "./Layout.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import List from "@app/components/List.svelte";
-
  import Loading from "@app/components/Loading.svelte";
-
  import Placeholder from "@app/components/Placeholder.svelte";
-
  import Popover from "@app/components/Popover.svelte";
-
  import Separator from "./Separator.svelte";
-
  import Share from "./Share.svelte";
-

-
  export let baseUrl: BaseUrl;
-
  export let seedingPolicy: SeedingPolicy;
-
  export let issues: Issue[];
-
  export let project: Project;
-
  export let status: IssueState["status"];
-
  export let nodeAvatarUrl: string | undefined;
-

-
  let loading = false;
-
  let page = 0;
-
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-
  let error: any;
-
  let allIssues: Issue[];
-

-
  $: {
-
    allIssues = issues;
-
    page = 0;
-
  }
-

-
  const api = new HttpdClient(baseUrl);
-

-
  async function loadIssues(status: IssueState["status"]): Promise<void> {
-
    loading = true;
-
    page += 1;
-
    try {
-
      const response = await api.project.getAllIssues(project.id, {
-
        status,
-
        page,
-
        perPage: ISSUES_PER_PAGE,
-
      });
-
      allIssues = [...allIssues, ...response];
-
    } catch (e) {
-
      error = e;
-
    } finally {
-
      loading = false;
-
    }
-
  }
-

-
  const stateOptions: IssueState["status"][] = ["open", "closed"];
-
  const stateColor: Record<IssueState["status"], string> = {
-
    open: "var(--color-fill-success)",
-
    closed: "var(--color-foreground-red)",
-
  };
-

-
  $: showMoreButton =
-
    !loading && !error && allIssues.length < project.issues[status];
-
</script>
-

-
<style>
-
  .header {
-
    display: flex;
-
    justify-content: space-between;
-
    padding: 1rem;
-
  }
-
  .more {
-
    margin-top: 2rem;
-
    min-height: 3rem;
-
    display: flex;
-
    align-items: center;
-
    justify-content: center;
-
  }
-
  .dropdown-button-counter {
-
    border-radius: var(--border-radius-tiny);
-
    background-color: var(--color-fill-counter);
-
    color: var(--color-foreground-contrast);
-
    padding: 0 0.25rem;
-
  }
-
  .dropdown-list-counter {
-
    border-radius: var(--border-radius-tiny);
-
    background-color: var(--color-fill-ghost);
-
    color: var(--color-foreground-dim);
-
    padding: 0 0.25rem;
-
  }
-
  .selected {
-
    background-color: var(--color-fill-counter);
-
    color: var(--color-foreground-dim);
-
  }
-
  .placeholder {
-
    height: calc(100% - 4rem);
-
    display: flex;
-
    align-items: center;
-
    justify-content: center;
-
  }
-
  @media (max-width: 719.98px) {
-
    .placeholder {
-
      height: calc(100vh - 10rem);
-
    }
-
  }
-
</style>
-

-
<Layout {nodeAvatarUrl} {seedingPolicy} {baseUrl} {project} activeTab="issues">
-
  <svelte:fragment slot="breadcrumb">
-
    <Separator />
-
    <Link
-
      route={{
-
        resource: "project.issues",
-
        project: project.id,
-
        node: baseUrl,
-
      }}>
-
      Issues
-
    </Link>
-
  </svelte:fragment>
-
  <div slot="header" class="header">
-
    <Popover
-
      popoverPadding="0"
-
      popoverPositionTop="2.5rem"
-
      popoverBorderRadius="var(--border-radius-small)">
-
      <Button
-
        let:expanded
-
        slot="toggle"
-
        let:toggle
-
        on:click={toggle}
-
        ariaLabel="filter-dropdown"
-
        title="Filter issues by state">
-
        <div style:color={stateColor[status]}>
-
          <Icon name="issue" />
-
        </div>
-
        {capitalize(status)}
-
        <div class="dropdown-button-counter">
-
          {project.issues[status]}
-
        </div>
-
        <Icon name={expanded ? "chevron-up" : "chevron-down"} />
-
      </Button>
-

-
      <DropdownList slot="popover" items={stateOptions}>
-
        <Link
-
          on:afterNavigate={() => closeFocused()}
-
          slot="item"
-
          let:item
-
          route={{
-
            resource: "project.issues",
-
            project: project.id,
-
            node: baseUrl,
-
            status: item,
-
          }}>
-
          <DropdownListItem selected={item === status}>
-
            <div style:color={stateColor[item]}>
-
              <Icon name="issue" />
-
            </div>
-
            <div
-
              style="display: flex; gap: 1rem;justify-content: space-between; width: 100%;">
-
              {capitalize(item)}
-
              <div
-
                class="dropdown-list-counter"
-
                class:selected={item === status}>
-
                {project.issues[item]}
-
              </div>
-
            </div>
-
          </DropdownListItem>
-
        </Link>
-
      </DropdownList>
-
    </Popover>
-

-
    <Share />
-
  </div>
-

-
  <List items={allIssues}>
-
    <IssueTeaser
-
      slot="item"
-
      let:item
-
      {baseUrl}
-
      projectId={project.id}
-
      issue={item} />
-
  </List>
-

-
  {#if error}
-
    <ErrorMessage
-
      title="Couldn't load issues"
-
      description="Please make sure you are able to connect to the seed <code>${baseUrlToString(
-
        api.baseUrl,
-
      )}</code>"
-
      {error} />
-
  {/if}
-

-
  {#if project.issues[status] === 0}
-
    <div class="placeholder">
-
      <Placeholder iconName="no-issues" caption={`No ${status} issues`} />
-
    </div>
-
  {/if}
-

-
  {#if loading || showMoreButton}
-
    <div class="more">
-
      {#if loading}
-
        <Loading noDelay small={page !== 0} center />
-
      {/if}
-

-
      {#if showMoreButton}
-
        <Button
-
          size="large"
-
          variant="outline"
-
          on:click={() => loadIssues(status)}>
-
          More
-
        </Button>
-
      {/if}
-
    </div>
-
  {/if}
-
</Layout>
deleted src/views/projects/Layout.svelte
@@ -1,225 +0,0 @@
-
<script lang="ts">
-
  import type { ActiveTab } from "./Header.svelte";
-
  import type { BaseUrl, Project, SeedingPolicy } from "@http-client";
-

-
  import Button from "@app/components/Button.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import MobileFooter from "@app/App/MobileFooter.svelte";
-
  import Separator from "./Separator.svelte";
-
  import Sidebar from "@app/views/projects/Sidebar.svelte";
-

-
  export let activeTab: ActiveTab | undefined = undefined;
-
  export let seedingPolicy: SeedingPolicy;
-
  export let baseUrl: BaseUrl;
-
  export let project: Project;
-
  export let stylePaddingBottom: string = "2.5rem";
-
  export let nodeAvatarUrl: string | undefined;
-
</script>
-

-
<style>
-
  .layout {
-
    display: grid;
-
    grid-template: auto 1fr auto / auto 1fr auto;
-
    height: 100%;
-
  }
-

-
  .desktop-header {
-
    grid-column: 1 / 4;
-
    border-bottom: 1px solid var(--color-fill-separator);
-
  }
-

-
  header {
-
    display: flex;
-
    align-items: center;
-
    gap: 0.5rem;
-
    margin: 0;
-
    padding: 0.5rem 0.5rem 0.5rem 1rem;
-
    height: 3.5rem;
-
    justify-content: space-between;
-
  }
-

-
  .logo {
-
    height: var(--button-regular-height);
-
    margin: 0 0.5rem;
-
  }
-

-
  .sidebar {
-
    grid-column: 1 / 2;
-
    border-right: 1px solid var(--color-fill-separator);
-
  }
-

-
  .content {
-
    grid-column: 2 / 3;
-
    overflow: scroll;
-
  }
-

-
  .mobile-footer {
-
    display: none;
-
  }
-

-
  .breadcrumbs {
-
    display: flex;
-
    align-items: center;
-
    column-gap: 0.25rem;
-
    line-height: 1rem;
-
    font-weight: var(--font-weight-semibold);
-
    font-size: var(--font-size-small);
-
    white-space: nowrap;
-
    flex-wrap: wrap;
-
  }
-
  .breadcrumb {
-
    display: flex;
-
    align-items: center;
-
    gap: 0.25rem;
-
  }
-
  .breadcrumb :global(a:hover) {
-
    color: var(--color-fill-secondary);
-
  }
-
  .avatar {
-
    border-radius: var(--border-radius-tiny);
-
    margin-right: 0.5rem;
-
  }
-

-
  @media (max-width: 719.98px) {
-
    .desktop-header {
-
      display: none;
-
    }
-
    .content {
-
      overflow-y: scroll;
-
      overflow-x: hidden;
-
    }
-
    .mobile-footer {
-
      margin-top: auto;
-
      display: grid;
-
      grid-column: 1 / 4;
-
      background-color: pink;
-
    }
-
  }
-
</style>
-

-
<div class="layout">
-
  <div class="desktop-header">
-
    <header>
-
      <div class="breadcrumbs">
-
        <span class="breadcrumb">
-
          <Link
-
            style="display: flex; align-items: center; gap: 0.25rem;"
-
            route={{
-
              resource: "nodes",
-
              params: {
-
                baseUrl,
-
                projectPageIndex: 0,
-
              },
-
            }}>
-
            <img
-
              width="24"
-
              height="24"
-
              class="avatar"
-
              alt="Radicle logo"
-
              src={nodeAvatarUrl
-
                ? nodeAvatarUrl
-
                : "/images/default-seed-avatar.png"} />
-
            {baseUrl.hostname}
-
          </Link>
-
        </span>
-

-
        <Separator />
-

-
        <span class="breadcrumb" title={project.id}>
-
          <Link
-
            route={{
-
              resource: "project.source",
-
              project: project.id,
-
              node: baseUrl,
-
            }}>
-
            <div class="breadcrumb">
-
              {project.name}
-
            </div>
-
          </Link>
-
        </span>
-

-
        <div class="breadcrumb">
-
          <slot name="breadcrumb" />
-
        </div>
-
      </div>
-
      <Link
-
        style="display: flex; align-items: center;"
-
        route={{ resource: "nodes", params: undefined }}>
-
        <img
-
          width="24"
-
          height="24"
-
          class="logo"
-
          alt="Radicle logo"
-
          src="/radicle.svg" />
-
      </Link>
-
    </header>
-
  </div>
-

-
  <div class="sidebar global-hide-on-medium-desktop-down">
-
    <Sidebar {seedingPolicy} {activeTab} {baseUrl} {project} />
-
  </div>
-

-
  <div class="sidebar global-hide-on-mobile-down global-hide-on-desktop-up">
-
    <Sidebar {seedingPolicy} {activeTab} {baseUrl} {project} collapsedOnly />
-
  </div>
-

-
  <div class="content" style:padding-bottom={stylePaddingBottom}>
-
    <slot name="header" />
-
    <slot name="subheader" />
-
    <slot />
-
  </div>
-

-
  <div class="mobile-footer">
-
    <MobileFooter>
-
      <div style:width="100%">
-
        <Link
-
          title="Home"
-
          route={{
-
            resource: "project.source",
-
            project: project.id,
-
            node: baseUrl,
-
            path: "/",
-
          }}>
-
          <Button
-
            variant={activeTab === "source" ? "secondary" : "secondary-mobile"}
-
            styleWidth="100%">
-
            <Icon name="chevron-left-right" />
-
          </Button>
-
        </Link>
-
      </div>
-

-
      <div style:width="100%">
-
        <Link
-
          title={`${project.issues.open} Issues`}
-
          route={{
-
            resource: "project.issues",
-
            project: project.id,
-
            node: baseUrl,
-
          }}>
-
          <Button
-
            variant={activeTab === "issues" ? "secondary" : "secondary-mobile"}
-
            styleWidth="100%">
-
            <Icon name="issue" />
-
          </Button>
-
        </Link>
-
      </div>
-

-
      <div style:width="100%">
-
        <Link
-
          title={`${project.patches.open} Patches`}
-
          route={{
-
            resource: "project.patches",
-
            project: project.id,
-
            node: baseUrl,
-
          }}>
-
          <Button
-
            variant={activeTab === "patches" ? "secondary" : "secondary-mobile"}
-
            styleWidth="100%">
-
            <Icon name="patch" />
-
          </Button>
-
        </Link>
-
      </div>
-
    </MobileFooter>
-
  </div>
-
</div>
deleted src/views/projects/Patch.svelte
@@ -1,511 +0,0 @@
-
<script lang="ts" context="module">
-
  import type {
-
    Comment,
-
    Review,
-
    Merge,
-
    Project,
-
    Revision,
-
    Diff,
-
    SeedingPolicy,
-
  } from "@http-client";
-

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

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

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

-
  interface TimelineThread {
-
    inner: Thread;
-
    type: "thread";
-
    timestamp: number;
-
  }
-

-
  export type Timeline = TimelineMerge | TimelineReview | TimelineThread;
-
  export type PatchReviews = Record<
-
    string,
-
    { latest: boolean; review: Review }
-
  >;
-
</script>
-

-
<script lang="ts">
-
  import type { BaseUrl, Patch } from "@http-client";
-
  import type { PatchView } from "./router";
-
  import type { Route } from "@app/lib/router";
-
  import type { ComponentProps } from "svelte";
-

-
  import * as utils from "@app/lib/utils";
-
  import capitalize from "lodash/capitalize";
-
  import uniqBy from "lodash/uniqBy";
-

-
  import Badge from "@app/components/Badge.svelte";
-
  import Button from "@app/components/Button.svelte";
-
  import Changeset from "@app/views/projects/Changeset.svelte";
-
  import CheckoutButton from "@app/views/projects/Patch/CheckoutButton.svelte";
-
  import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
-
  import CompareButton from "@app/views/projects/Patch/CompareButton.svelte";
-
  import DiffStatBadge from "@app/components/DiffStatBadge.svelte";
-
  import Embeds from "@app/views/projects/Cob/Embeds.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Id from "@app/components/Id.svelte";
-
  import InlineTitle from "@app/views/projects/components/InlineTitle.svelte";
-
  import Labels from "@app/views/projects/Cob/Labels.svelte";
-
  import Layout from "@app/views/projects/Layout.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import Markdown from "@app/components/Markdown.svelte";
-
  import NodeId from "@app/components/NodeId.svelte";
-
  import Placeholder from "@app/components/Placeholder.svelte";
-
  import Radio from "@app/components/Radio.svelte";
-
  import Reactions from "@app/components/Reactions.svelte";
-
  import Reviews from "@app/views/projects/Cob/Reviews.svelte";
-
  import RevisionComponent from "@app/views/projects/Cob/Revision.svelte";
-
  import RevisionSelector from "@app/views/projects/Patch/RevisionSelector.svelte";
-
  import Separator from "./Separator.svelte";
-
  import Share from "@app/views/projects/Share.svelte";
-

-
  export let baseUrl: BaseUrl;
-
  export let seedingPolicy: SeedingPolicy;
-
  export let patch: Patch;
-
  export let stats: Diff["stats"];
-
  export let rawPath: (commit?: string) => string;
-
  export let project: Project;
-
  export let view: PatchView;
-
  export let nodeAvatarUrl: string | undefined;
-

-
  function badgeColor(status: string): ComponentProps<Badge>["variant"] {
-
    if (status === "draft") {
-
      return "foreground";
-
    } else if (status === "open") {
-
      return "positive";
-
    } else if (status === "archived") {
-
      return "caution";
-
    } else if (status === "merged") {
-
      return "primary";
-
    } else {
-
      return "foreground";
-
    }
-
  }
-

-
  type Tab = "activity" | "changes";
-

-
  let tabs: Record<Tab, { icon: ComponentProps<Icon>["name"]; route: Route }>;
-
  $: {
-
    const baseRoute = {
-
      resource: "project.patch",
-
      project: project.id,
-
      node: baseUrl,
-
      patch: patch.id,
-
    } as const;
-
    // For cleaner URLs, we omit the the revision part when we link to the
-
    // latest revision.
-
    const latestRevisionId = patch.revisions[patch.revisions.length - 1].id;
-
    const revision = latestRevisionId === revisionId ? undefined : revisionId;
-
    tabs = {
-
      activity: {
-
        route: {
-
          ...baseRoute,
-
          view: { name: "activity" },
-
        },
-
        icon: "activity",
-
      },
-
      changes: {
-
        route: {
-
          ...baseRoute,
-
          view: { name: "changes", revision },
-
        },
-
        icon: "diff",
-
      },
-
    };
-
  }
-

-
  function computeReviews(patch: Patch) {
-
    const patchReviews: Record<string, { latest: boolean; review: Review }> =
-
      {};
-

-
    patch.revisions.forEach((rev, i) => {
-
      const latest = i === patch.revisions.length - 1;
-
      for (const review of rev.reviews) {
-
        patchReviews[review.author.id] = { latest, review };
-
      }
-
    });
-

-
    return patchReviews;
-
  }
-

-
  let revisionId: string;
-
  $: if (view.name === "diff") {
-
    revisionId = patch.revisions[patch.revisions.length - 1].id;
-
  } else {
-
    revisionId = view.revision;
-
  }
-

-
  $: uniqueEmbeds = uniqBy(
-
    patch.revisions.flatMap(({ discussions }) =>
-
      discussions.flatMap(comment => comment.embeds),
-
    ),
-
    "content",
-
  );
-
  $: description = patch.revisions[0].description;
-
  $: lastEdit = patch.revisions[0].edits.at(-1);
-
  $: reviews = computeReviews(patch);
-
  $: timelineTuple = patch.revisions.map<
-
    [
-
      {
-
        revisionId: string;
-
        revisionTimestamp: number;
-
        revisionBase: string;
-
        revisionOid: string;
-
        revisionEdits: Revision["edits"];
-
        revisionReactions: Revision["reactions"];
-
        revisionAuthor: { id: string; alias?: string | undefined };
-
        revisionDescription: string;
-
      },
-
      Timeline[],
-
    ]
-
  >(rev => [
-
    {
-
      revisionId: rev.id,
-
      revisionTimestamp: rev.timestamp,
-
      revisionBase: rev.base,
-
      revisionOid: rev.oid,
-
      revisionEdits: rev.edits,
-
      revisionReactions: rev.reactions,
-
      revisionAuthor: rev.author,
-
      revisionDescription: rev.description,
-
    },
-
    [
-
      ...rev.reviews.map<TimelineReview>(review => ({
-
        timestamp: review.timestamp,
-
        type: "review",
-
        inner: [review.author.id, review],
-
      })),
-
      ...patch.merges
-
        .filter(merge => merge.revision === rev.id)
-
        .map<TimelineMerge>(inner => ({
-
          timestamp: inner.timestamp,
-
          type: "merge",
-
          inner,
-
        })),
-
      ...rev.discussions
-
        .filter(comment => !comment.replyTo)
-
        .map<TimelineThread>(thread => ({
-
          timestamp: thread.timestamp,
-
          type: "thread",
-
          inner: {
-
            root: thread,
-
            replies: rev.discussions
-
              .filter(comment => comment.replyTo === thread.id)
-
              .sort((a, b) => a.timestamp - b.timestamp),
-
          },
-
        })),
-
    ].sort((a, b) => a.timestamp - b.timestamp),
-
  ]);
-
  $: firstRevision = timelineTuple[0][0];
-
  $: latestRevision = patch.revisions[patch.revisions.length - 1];
-
</script>
-

-
<style>
-
  .patch {
-
    display: flex;
-
    flex: 1;
-
    min-height: 100%;
-
  }
-
  .main {
-
    display: flex;
-
    flex: 1;
-
    flex-direction: column;
-
    min-width: 0;
-
    background-color: var(--color-background-float);
-
  }
-
  .metadata {
-
    display: flex;
-
    flex-direction: column;
-
    gap: 1.5rem;
-
    font-size: var(--font-size-small);
-
    padding: 1rem;
-
    border-left: 1px solid var(--color-border-hint);
-
    border-left: 1px solid var(--color-border-hint);
-
    width: 20rem;
-
  }
-
  .title {
-
    overflow: hidden;
-
    text-overflow: ellipsis;
-
    display: flex;
-
    align-items: center;
-
    gap: 0.5rem;
-
    font-weight: var(--font-weight-semibold);
-
    font-size: var(--font-size-large);
-
    word-break: break-word;
-
  }
-
  .bottom {
-
    background-color: var(--color-background-default);
-
    padding: 1rem 1rem 0.5rem 1rem;
-
    height: 100%;
-
  }
-
  .tabs {
-
    font-size: var(--font-size-tiny);
-
    display: flex;
-
    align-items: center;
-
    justify-content: left;
-
    flex-wrap: wrap;
-
    position: relative;
-
    margin-top: 1rem;
-
    box-shadow: inset 0 -1px 0 var(--color-border-hint);
-
  }
-
  .tabs-spacer {
-
    width: 1rem;
-
    height: 100%;
-
  }
-
  .author-metadata {
-
    color: var(--color-fill-gray);
-
    font-size: var(--font-size-small);
-
  }
-
  .revision-description {
-
    display: flex;
-
    flex-direction: column;
-
    gap: 0.5rem;
-
    width: 100%;
-
  }
-
  .id {
-
    font-size: var(--font-size-small);
-
    font-family: var(--font-family-monospace);
-
    font-weight: var(--font-weight-semibold);
-
  }
-
  @media (max-width: 719.98px) {
-
    .patch {
-
      display: block;
-
    }
-
    .bottom {
-
      padding: 1rem 0 0 0;
-
    }
-
  }
-
</style>
-

-
<Layout
-
  {baseUrl}
-
  {project}
-
  {nodeAvatarUrl}
-
  {seedingPolicy}
-
  activeTab="patches"
-
  stylePaddingBottom="0">
-
  <svelte:fragment slot="breadcrumb">
-
    <Separator />
-
    <Link
-
      route={{
-
        resource: "project.patches",
-
        project: project.id,
-
        node: baseUrl,
-
      }}>
-
      Patches
-
    </Link>
-
    <Separator />
-
    <span class="id">
-
      <div class="global-hide-on-small-desktop-down">
-
        {patch.id}
-
      </div>
-
      <div class="global-hide-on-medium-desktop-up">
-
        {utils.formatObjectId(patch.id)}
-
      </div>
-
    </span>
-
  </svelte:fragment>
-
  <div class="patch">
-
    <div class="main">
-
      <CobHeader>
-
        <svelte:fragment slot="title">
-
          {#if patch.title}
-
            <div class="title">
-
              <InlineTitle fontSize="large" content={patch.title} />
-
            </div>
-
          {:else}
-
            <span class="txt-missing">No title</span>
-
          {/if}
-
          <div class="global-flex-item">
-
            <Share />
-
            <div class="global-hide-on-mobile-down">
-
              <CheckoutButton id={patch.id} />
-
            </div>
-
          </div>
-
        </svelte:fragment>
-
        <svelte:fragment slot="state">
-
          <Badge size="tiny" variant={badgeColor(patch.state.status)}>
-
            <Icon name="patch" />
-
            {capitalize(patch.state.status)}
-
          </Badge>
-
          <Link
-
            route={{
-
              resource: "project.patch",
-
              project: project.id,
-
              node: baseUrl,
-
              patch: patch.id,
-
              view: { name: "changes", revision: latestRevision.id },
-
            }}>
-
            <DiffStatBadge
-
              hoverable
-
              insertions={stats.insertions}
-
              deletions={stats.deletions} />
-
          </Link>
-
          <NodeId
-
            {baseUrl}
-
            nodeId={patch.author.id}
-
            alias={patch.author.alias} />
-
          opened
-
          <Id id={patch.id} />
-
          <span title={utils.absoluteTimestamp(patch.revisions[0].timestamp)}>
-
            {utils.formatTimestamp(patch.revisions[0].timestamp)}
-
          </span>
-
          {#if patch.revisions[0].edits.length > 1 && lastEdit}
-
            <div
-
              class="author-metadata"
-
              title={utils.formatEditedCaption(
-
                lastEdit.author,
-
                lastEdit.timestamp,
-
              )}>
-
              • edited
-
            </div>
-
          {/if}
-
        </svelte:fragment>
-
        <div slot="subtitle" class="global-hide-on-desktop-up">
-
          <div
-
            style:margin-top="2rem"
-
            style="display: flex; flex-direction: column; gap: 0.5rem;">
-
            <Reviews {baseUrl} {reviews} />
-
            <Labels labels={patch.labels} />
-
            <Embeds embeds={uniqueEmbeds} />
-
          </div>
-
        </div>
-
        <svelte:fragment slot="description">
-
          <div class="revision-description">
-
            {#if description}
-
              <Markdown
-
                breaks
-
                content={description}
-
                rawPath={rawPath(patch.id)} />
-
            {:else}
-
              <span class="txt-missing">No description available</span>
-
            {/if}
-
            {#if firstRevision.revisionReactions.length > 0}
-
              <Reactions reactions={firstRevision.revisionReactions} />
-
            {/if}
-
          </div>
-
        </svelte:fragment>
-
      </CobHeader>
-

-
      <div class="tabs">
-
        <div class="tabs-spacer" />
-
        <Radio styleGap="0.375rem">
-
          {#each Object.entries(tabs) as [name, { route, icon }]}
-
            <Link {route}>
-
              <Button
-
                size="large"
-
                variant={name === view.name ||
-
                (view.name === "diff" && name === "changes")
-
                  ? "tab-active"
-
                  : "tab"}>
-
                <Icon name={icon} />
-
                {capitalize(name)}
-
              </Button>
-
            </Link>
-
          {/each}
-
        </Radio>
-

-
        {#if view.name === "changes"}
-
          <div
-
            class="global-hide-on-mobile-down"
-
            style="margin-left: auto; margin-top: -0.5rem;">
-
            <RevisionSelector {view} {baseUrl} {patch} {project} />
-
          </div>
-
        {/if}
-
        {#if view.name === "diff"}
-
          <div
-
            class="global-hide-on-mobile-down"
-
            style="margin-left: auto; margin-top: -0.5rem;">
-
            <div style:margin-left="auto">
-
              <CompareButton
-
                fromCommit={view.fromCommit}
-
                toCommit={view.toCommit} />
-
            </div>
-
          </div>
-
        {/if}
-
        <div class="tabs-spacer" />
-
      </div>
-
      <div class="bottom">
-
        {#if view.name === "changes"}
-
          <div
-
            style:width="100%"
-
            style:padding="0 1rem"
-
            style:display="flex"
-
            class="global-hide-on-small-desktop-up">
-
            <RevisionSelector {view} {baseUrl} {patch} {project} />
-
          </div>
-
        {/if}
-
        {#if view.name === "diff"}
-
          <div
-
            style:width="100%"
-
            style:padding="0 1rem"
-
            style:display="flex"
-
            class="global-hide-on-small-desktop-up">
-
            <CompareButton
-
              fromCommit={view.fromCommit}
-
              toCommit={view.toCommit} />
-
          </div>
-
          <Changeset
-
            {baseUrl}
-
            projectId={project.id}
-
            revision={view.toCommit}
-
            files={view.files}
-
            diff={view.diff} />
-
        {:else if view.name === "activity"}
-
          {#each timelineTuple as [revision, timelines], index}
-
            {@const previousRevision =
-
              index > 0 ? patch.revisions[index - 1] : undefined}
-
            <RevisionComponent
-
              {baseUrl}
-
              {rawPath}
-
              projectId={project.id}
-
              {timelines}
-
              {...revision}
-
              first={index === 0}
-
              patchId={patch.id}
-
              patchState={patch.state}
-
              initiallyExpanded={index === patch.revisions.length - 1}
-
              previousRevId={previousRevision?.id}
-
              previousRevBase={previousRevision?.base}
-
              previousRevOid={previousRevision?.oid} />
-
          {:else}
-
            <div style:margin="4rem 0">
-
              <Placeholder
-
                iconName="no-patches"
-
                caption="No activity on this patch yet" />
-
            </div>
-
          {/each}
-
        {:else if view.name === "changes"}
-
          <Changeset
-
            {baseUrl}
-
            projectId={project.id}
-
            revision={view.oid}
-
            files={view.files}
-
            diff={view.diff} />
-
        {:else}
-
          {utils.unreachable(view)}
-
        {/if}
-
      </div>
-
    </div>
-

-
    <div class="metadata global-hide-on-medium-desktop-down">
-
      <Reviews {baseUrl} {reviews} />
-
      <Labels labels={patch.labels} />
-
      <Embeds embeds={uniqueEmbeds} />
-
    </div>
-
  </div>
-
</Layout>
deleted src/views/projects/Patch/CheckoutButton.svelte
@@ -1,37 +0,0 @@
-
<script lang="ts">
-
  import Button from "@app/components/Button.svelte";
-
  import Command from "@app/components/Command.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Popover from "@app/components/Popover.svelte";
-

-
  export let id: string;
-
</script>
-

-
<style>
-
  .label {
-
    display: block;
-
    font-size: var(--font-size-small);
-
    font-weight: var(--font-weight-regular);
-
    margin-bottom: 0.75rem;
-
  }
-
</style>
-

-
<Popover popoverPositionTop="2.5rem" popoverPositionRight="0">
-
  <Button
-
    slot="toggle"
-
    let:toggle
-
    variant="secondary-toggle-off"
-
    on:click={() => {
-
      toggle();
-
    }}>
-
    <Icon name="branch" />
-
    <span class="global-hide-on-small-desktop-down">Checkout</span>
-
  </Button>
-

-
  <div slot="popover" style:width="20rem">
-
    <span class="label">
-
      Run this command from a Radicle working copy to checkout this patch.
-
    </span>
-
    <Command command={`rad patch checkout ${id}`} />
-
  </div>
-
</Popover>
deleted src/views/projects/Patch/CompareButton.svelte
@@ -1,17 +0,0 @@
-
<script lang="ts">
-
  import Button from "@app/components/Button.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-

-
  export let fromCommit: string;
-
  export let toCommit: string;
-
</script>
-

-
<Button size="regular" disabled>
-
  <span style:color="var(--color-foregroung-disabled)">Compare</span>
-
  <span
-
    style:color="var(--color-foregroung-disabled)"
-
    style:font-family="var(--font-family-monospace)">
-
    {fromCommit.substring(0, 6)}..{toCommit.substring(0, 6)}
-
  </span>
-
  <Icon name={"chevron-down"} />
-
</Button>
deleted src/views/projects/Patch/PatchTeaser.svelte
@@ -1,154 +0,0 @@
-
<script lang="ts">
-
  import type { BaseUrl } from "@http-client";
-
  import type { Patch } from "@http-client";
-

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

-
  import CommentCounter from "../CommentCounter.svelte";
-
  import DiffStatBadgeLoader from "../DiffStatBadgeLoader.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Id from "@app/components/Id.svelte";
-
  import InlineLabels from "@app/views/projects/Cob/InlineLabels.svelte";
-
  import InlineTitle from "@app/views/projects/components/InlineTitle.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import NodeId from "@app/components/NodeId.svelte";
-

-
  export let projectId: string;
-
  export let baseUrl: BaseUrl;
-
  export let patch: Patch;
-

-
  $: latestRevisionIndex = patch.revisions.length - 1;
-
  $: latestRevision = patch.revisions[latestRevisionIndex];
-

-
  $: commentCount = patch.revisions.reduce(
-
    (acc, curr) => acc + curr.discussions.reduce(acc => acc + 1, 0),
-
    0,
-
  );
-
</script>
-

-
<style>
-
  .patch-teaser {
-
    display: flex;
-
    padding: 1.25rem;
-
    background-color: var(--color-background-float);
-
  }
-
  .patch-teaser:hover {
-
    background-color: var(--color-fill-float-hover);
-
  }
-
  .content {
-
    width: 100%;
-
    gap: 0.5rem;
-
    display: flex;
-
    flex-direction: column;
-
  }
-
  .subtitle {
-
    display: flex;
-
    flex-direction: column;
-
    flex-wrap: wrap;
-
    font-size: var(--font-size-small);
-
    gap: 0.5rem;
-
  }
-
  .summary {
-
    display: flex;
-
    align-items: flex-start;
-
    gap: 0.5rem;
-
    word-break: break-word;
-
  }
-
  .right {
-
    margin-left: auto;
-
    display: flex;
-
    align-items: flex-start;
-
  }
-
  .state {
-
    justify-self: center;
-
    align-self: flex-start;
-
    margin-right: 0.5rem;
-
    padding: 0.25rem 0;
-
  }
-
  .draft {
-
    color: var(--color-foreground-dim);
-
  }
-
  .open {
-
    color: var(--color-fill-success);
-
  }
-
  .archived {
-
    color: var(--color-foreground-yellow);
-
  }
-
  .merged {
-
    color: var(--color-fill-primary);
-
  }
-
  .diff-comment {
-
    display: flex;
-
    flex-direction: row;
-
    gap: 0.5rem;
-
    min-height: 1.5rem;
-
  }
-
</style>
-

-
<div role="button" tabindex="0" class="patch-teaser">
-
  <div
-
    class="state"
-
    class:draft={patch.state.status === "draft"}
-
    class:open={patch.state.status === "open"}
-
    class:merged={patch.state.status === "merged"}
-
    class:archived={patch.state.status === "archived"}>
-
    <Icon name="patch" />
-
  </div>
-
  <div class="content">
-
    <div class="summary">
-
      <Link
-
        styleHoverState
-
        route={{
-
          resource: "project.patch",
-
          project: projectId,
-
          node: baseUrl,
-
          patch: patch.id,
-
        }}>
-
        <InlineTitle fontSize="regular" content={patch.title} />
-
      </Link>
-
      {#if patch.labels.length > 0}
-
        <span
-
          class="global-hide-on-small-desktop-down"
-
          style="display: inline-flex; gap: 0.5rem;">
-
          <InlineLabels labels={patch.labels} />
-
        </span>
-
      {/if}
-
      <div class="right">
-
        <div class="diff-comment">
-
          {#if commentCount > 0}
-
            <CommentCounter {commentCount} />
-
          {/if}
-
          <DiffStatBadgeLoader {projectId} {baseUrl} {patch} {latestRevision} />
-
        </div>
-
      </div>
-
    </div>
-
    <div class="summary">
-
      <span class="subtitle">
-
        {#if patch.labels.length > 0}
-
          <div
-
            class="global-hide-on-medium-desktop-up"
-
            style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
-
            <InlineLabels labels={patch.labels} />
-
          </div>
-
        {/if}
-
        <div
-
          style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;">
-
          <NodeId
-
            {baseUrl}
-
            nodeId={patch.author.id}
-
            alias={patch.author.alias} />
-
          {patch.revisions.length > 1 ? "updated" : "opened"}
-
          <Id id={patch.id} />
-
          {#if patch.revisions.length > 1}
-
            <span class="global-hide-on-mobile-down">
-
              to <Id id={patch.revisions[patch.revisions.length - 1].id} />
-
            </span>
-
          {/if}
-
          <span title={absoluteTimestamp(latestRevision.timestamp)}>
-
            {formatTimestamp(latestRevision.timestamp)}
-
          </span>
-
        </div>
-
      </span>
-
    </div>
-
  </div>
-
</div>
deleted src/views/projects/Patch/RevisionSelector.svelte
@@ -1,76 +0,0 @@
-
<script lang="ts">
-
  import type { PatchView } from "../router";
-
  import type { BaseUrl, Patch, Project } from "@http-client";
-
  import * as utils from "@app/lib/utils";
-

-
  import Button from "@app/components/Button.svelte";
-
  import DropdownList from "@app/components/DropdownList.svelte";
-
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import Popover from "@app/components/Popover.svelte";
-
  import { closeFocused } from "@app/components/Popover.svelte";
-

-
  export let view: Extract<PatchView, { name: "changes" }>;
-
  export let baseUrl: BaseUrl;
-
  export let patch: Patch;
-
  export let project: Project;
-
</script>
-

-
<Popover
-
  popoverPadding="0"
-
  popoverPositionTop="3rem"
-
  popoverBorderRadius="var(--border-radius-small)">
-
  <Button
-
    let:expanded
-
    slot="toggle"
-
    let:toggle
-
    on:click={toggle}
-
    size="regular"
-
    disabled={patch.revisions.length === 1}>
-
    <span
-
      style:color={patch.revisions.length > 1
-
        ? "var(--color-foreground-contrast)"
-
        : "var(--color-foregroung-disabled)"}>
-
      Revision
-
    </span>
-
    <span
-
      style:color={patch.revisions.length > 1
-
        ? "var(--color-fill-secondary)"
-
        : "var(--color-foregroung-disabled)"}
-
      style:font-family="var(--font-family-monospace)">
-
      {utils.formatObjectId(view.revision)}
-
    </span>
-
    <Icon name={expanded ? "chevron-up" : "chevron-down"} />
-
  </Button>
-
  <DropdownList slot="popover" items={patch.revisions}>
-
    <svelte:fragment slot="item" let:item>
-
      <Link
-
        on:afterNavigate={closeFocused}
-
        route={{
-
          resource: "project.patch",
-
          project: project.id,
-
          node: baseUrl,
-
          patch: patch.id,
-
          view: {
-
            name: view.name,
-
            revision: item.id,
-
          },
-
        }}>
-
        <DropdownListItem selected={item.id === view.revision}>
-
          <span
-
            style:color={item.id === view.revision
-
              ? "var(--color-foreground-contrast)"
-
              : "var(--color-fill-gray)"}>
-
            Revision
-
          </span>
-
          <span
-
            style:color="var(--color-fill-secondary)"
-
            style:font-family="var(--font-family-monospace)">
-
            {utils.formatObjectId(item.id)}
-
          </span>
-
        </DropdownListItem>
-
      </Link>
-
    </svelte:fragment>
-
  </DropdownList>
-
</Popover>
deleted src/views/projects/Patches.svelte
@@ -1,235 +0,0 @@
-
<script lang="ts">
-
  import type {
-
    BaseUrl,
-
    Patch,
-
    PatchState,
-
    Project,
-
    SeedingPolicy,
-
  } from "@http-client";
-

-
  import { HttpdClient } from "@http-client";
-
  import capitalize from "lodash/capitalize";
-

-
  import { PATCHES_PER_PAGE } from "./router";
-
  import { baseUrlToString } from "@app/lib/utils";
-

-
  import Button from "@app/components/Button.svelte";
-
  import DropdownList from "@app/components/DropdownList.svelte";
-
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
-
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Layout from "./Layout.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import List from "@app/components/List.svelte";
-
  import Loading from "@app/components/Loading.svelte";
-
  import PatchTeaser from "./Patch/PatchTeaser.svelte";
-
  import Placeholder from "@app/components/Placeholder.svelte";
-
  import Popover, { closeFocused } from "@app/components/Popover.svelte";
-
  import Separator from "./Separator.svelte";
-
  import Share from "./Share.svelte";
-

-
  export let baseUrl: BaseUrl;
-
  export let seedingPolicy: SeedingPolicy;
-
  export let patches: Patch[];
-
  export let project: Project;
-
  export let status: PatchState["status"];
-
  export let nodeAvatarUrl: string | undefined;
-

-
  let loading = false;
-
  let page = 0;
-
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-
  let error: any;
-
  let allPatches: Patch[];
-

-
  $: {
-
    allPatches = patches;
-
    page = 0;
-
  }
-

-
  const api = new HttpdClient(baseUrl);
-

-
  async function loadMore(status: PatchState["status"]): Promise<void> {
-
    loading = true;
-
    page += 1;
-
    try {
-
      const response = await api.project.getAllPatches(project.id, {
-
        status,
-
        page,
-
        perPage: PATCHES_PER_PAGE,
-
      });
-
      allPatches = [...allPatches, ...response];
-
    } catch (e) {
-
      error = e;
-
    } finally {
-
      loading = false;
-
    }
-
  }
-

-
  const stateOptions: PatchState["status"][] = [
-
    "draft",
-
    "open",
-
    "archived",
-
    "merged",
-
  ];
-

-
  const stateColor: Record<PatchState["status"], string> = {
-
    draft: "var(--color-fill-gray)",
-
    open: "var(--color-fill-success)",
-
    archived: "var(--color-foreground-yellow)",
-
    merged: "var(--color-fill-primary)",
-
  };
-

-
  $: showMoreButton =
-
    !loading && !error && allPatches.length < project.patches[status];
-
</script>
-

-
<style>
-
  .header {
-
    display: flex;
-
    justify-content: space-between;
-
    padding: 1rem;
-
  }
-
  .more {
-
    margin-top: 2rem;
-
    min-height: 3rem;
-
    display: flex;
-
    align-items: center;
-
    justify-content: center;
-
  }
-
  .dropdown-button-counter {
-
    border-radius: var(--border-radius-tiny);
-
    background-color: var(--color-fill-counter);
-
    color: var(--color-foreground-contrast);
-
    padding: 0 0.25rem;
-
  }
-
  .dropdown-list-counter {
-
    border-radius: var(--border-radius-tiny);
-
    background-color: var(--color-fill-ghost);
-
    color: var(--color-foreground-dim);
-
    padding: 0 0.25rem;
-
  }
-
  .selected {
-
    background-color: var(--color-fill-counter);
-
    color: var(--color-foreground-dim);
-
  }
-
  .placeholder {
-
    height: calc(100% - 4rem);
-
    display: flex;
-
    align-items: center;
-
    justify-content: center;
-
  }
-
  @media (max-width: 719.98px) {
-
    .placeholder {
-
      height: calc(100vh - 10rem);
-
    }
-
  }
-
</style>
-

-
<Layout {nodeAvatarUrl} {seedingPolicy} {baseUrl} {project} activeTab="patches">
-
  <svelte:fragment slot="breadcrumb">
-
    <Separator />
-
    <Link
-
      route={{
-
        resource: "project.patches",
-
        project: project.id,
-
        node: baseUrl,
-
      }}>
-
      Patches
-
    </Link>
-
  </svelte:fragment>
-
  <div slot="header" class="header">
-
    <Popover
-
      popoverPadding="0"
-
      popoverPositionTop="2.5rem"
-
      popoverBorderRadius="var(--border-radius-small)">
-
      <Button
-
        let:expanded
-
        slot="toggle"
-
        let:toggle
-
        on:click={toggle}
-
        ariaLabel="filter-dropdown"
-
        title="Filter patches by state">
-
        <div style:color={stateColor[status]}>
-
          <Icon name="patch" />
-
        </div>
-
        {capitalize(status)}
-
        <div class="dropdown-button-counter">
-
          {project.patches[status]}
-
        </div>
-
        <Icon name={expanded ? "chevron-up" : "chevron-down"} />
-
      </Button>
-
      <DropdownList slot="popover" items={stateOptions}>
-
        <Link
-
          slot="item"
-
          let:item
-
          on:afterNavigate={() => closeFocused()}
-
          route={{
-
            resource: "project.patches",
-
            project: project.id,
-
            node: baseUrl,
-
            search: `status=${item}`,
-
          }}>
-
          <DropdownListItem selected={item === status}>
-
            <div style:color={stateColor[item]}>
-
              <Icon name="patch" />
-
            </div>
-
            <div
-
              style="display: flex; gap: 1rem;justify-content: space-between; width: 100%;">
-
              {capitalize(item)}
-
              <div
-
                class="dropdown-list-counter"
-
                class:selected={item === status}>
-
                {project.patches[item]}
-
              </div>
-
            </div>
-
          </DropdownListItem>
-
        </Link>
-
      </DropdownList>
-
    </Popover>
-

-
    <Share />
-
  </div>
-

-
  <List items={allPatches}>
-
    <PatchTeaser
-
      slot="item"
-
      let:item
-
      {baseUrl}
-
      projectId={project.id}
-
      patch={item} />
-
  </List>
-

-
  {#if error}
-
    <ErrorMessage
-
      title="Couldn't load patches"
-
      description="Please make sure you are able to connect to the seed <code>${baseUrlToString(
-
        api.baseUrl,
-
      )}</code>"
-
      {error} />
-
  {/if}
-

-
  {#if project.patches[status] === 0}
-
    <div class="placeholder">
-
      <Placeholder iconName="no-patches" caption={`No ${status} patches`} />
-
    </div>
-
  {/if}
-

-
  {#if loading || showMoreButton}
-
    <div class="more">
-
      {#if loading}
-
        <div style:margin-top={page === 0 ? "8rem" : ""}>
-
          <Loading noDelay small={page !== 0} center />
-
        </div>
-
      {/if}
-

-
      {#if showMoreButton}
-
        <Button
-
          size="large"
-
          variant="outline"
-
          on:click={() => loadMore(status)}>
-
          More
-
        </Button>
-
      {/if}
-
    </div>
-
  {/if}
-
</Layout>
deleted src/views/projects/Separator.svelte
@@ -1,7 +0,0 @@
-
<script lang="ts">
-
  import Icon from "@app/components/Icon.svelte";
-
</script>
-

-
<span style:color="var(--color-foreground-dim)">
-
  <Icon name="chevron-right" />
-
</span>
deleted src/views/projects/Share.svelte
@@ -1,28 +0,0 @@
-
<script lang="ts">
-
  import config from "virtual:config";
-
  import debounce from "lodash/debounce";
-
  import { toClipboard } from "@app/lib/utils";
-

-
  import Button from "@app/components/Button.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-

-
  let shareIcon: "link" | "checkmark" = "link";
-

-
  const restoreIcon = debounce(() => {
-
    shareIcon = "link";
-
  }, 1000);
-

-
  async function copy() {
-
    const text = new URL(config.nodes.fallbackPublicExplorer).origin.concat(
-
      window.location.pathname,
-
    );
-
    await toClipboard(text);
-
    shareIcon = "checkmark";
-
    restoreIcon();
-
  }
-
</script>
-

-
<Button variant="outline" size="regular" on:click={copy}>
-
  <Icon name={shareIcon} />
-
  <span class="global-hide-on-small-desktop-down">Copy link</span>
-
</Button>
deleted src/views/projects/Sidebar.svelte
@@ -1,342 +0,0 @@
-
<script lang="ts">
-
  import type { ActiveTab } from "./Header.svelte";
-
  import type { BaseUrl, Project, SeedingPolicy } from "@http-client";
-

-
  import Button from "@app/components/Button.svelte";
-
  import ContextRepo from "@app/views/projects/Sidebar/ContextRepo.svelte";
-
  import Help from "@app/App/Help.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import Popover from "@app/components/Popover.svelte";
-
  import Settings from "@app/App/Settings.svelte";
-

-
  const SIDEBAR_STATE_KEY = "sidebarState";
-

-
  export let activeTab: ActiveTab | undefined = undefined;
-
  export let seedingPolicy: SeedingPolicy;
-
  export let baseUrl: BaseUrl;
-
  export let project: Project;
-
  export let collapsedOnly = false;
-

-
  let expanded = collapsedOnly ? false : loadSidebarState();
-

-
  export function storeSidebarState(expanded: boolean): void {
-
    if (localStorage) {
-
      localStorage.setItem(
-
        SIDEBAR_STATE_KEY,
-
        expanded ? "expanded" : "collapsed",
-
      );
-
    } else {
-
      console.warn(
-
        "localStorage isn't available, not able to persist the sidebar state without it.",
-
      );
-
    }
-
  }
-

-
  function loadSidebarState(): boolean {
-
    const storedSidebarState = localStorage
-
      ? localStorage.getItem(SIDEBAR_STATE_KEY)
-
      : null;
-

-
    if (storedSidebarState === null) {
-
      return true;
-
    } else {
-
      return storedSidebarState === "expanded" ? true : false;
-
    }
-
  }
-

-
  function toggleSidebar() {
-
    expanded = !expanded;
-
    storeSidebarState(expanded);
-
  }
-
</script>
-

-
<style>
-
  .sidebar {
-
    padding: 1rem;
-
    height: 100%;
-
    display: flex;
-
    flex-direction: column;
-
    justify-content: space-between;
-
    transition: width 150ms ease-in-out;
-
    width: 4.5rem;
-
  }
-
  .sidebar.expanded {
-
    width: 22.5rem;
-
  }
-
  .project-navigation {
-
    display: flex;
-
    flex-direction: column;
-
    gap: 0.25rem;
-
    flex: 1;
-
  }
-

-
  .counter {
-
    border-radius: var(--border-radius-tiny);
-
    background-color: var(--color-fill-ghost);
-
    color: var(--color-foreground-dim);
-
    padding: 0 0.25rem;
-
  }
-
  .selected {
-
    background-color: var(--color-fill-counter);
-
    color: var(--color-foreground-contrast);
-
  }
-
  .hover {
-
    background-color: var(--color-fill-ghost-hover);
-
    color: var(--color-foreground-contrast);
-
  }
-
  .title-counter {
-
    display: flex;
-
    overflow: hidden;
-
    gap: 0.5rem;
-
    justify-content: space-between;
-
    width: 100%;
-
    opacity: 0;
-
    transition: opacity 150ms ease-in-out;
-
  }
-
  .title-counter.expanded {
-
    opacity: 1;
-
  }
-
  .sidebar-footer {
-
    display: flex;
-
    justify-content: space-between;
-
    width: 100%;
-
  }
-
  .repo {
-
    z-index: 10;
-
    opacity: 0;
-
    height: 0;
-
    overflow: hidden;
-
  }
-
  .box {
-
    padding: 1rem;
-
    margin-bottom: 0.5rem;
-
    background-color: var(--color-background-float);
-
    border: 1px solid var(--color-border-hint);
-
    font-size: var(--font-size-small);
-
    border-radius: var(--border-radius-small);
-
  }
-
  .repo.expanded {
-
    opacity: 1;
-
    height: initial;
-
    overflow: initial;
-
    transition: opacity 150ms;
-
    transition-delay: 150ms;
-
  }
-
  .vertical-buttons {
-
    opacity: 1;
-
    height: fit-content;
-
    display: flex;
-
    flex-direction: column-reverse;
-
    transition: opacity 150ms ease-in-out;
-
    transition-delay: 60ms;
-
    margin-bottom: 0.5rem;
-
  }
-
  .vertical-buttons.expanded {
-
    opacity: 0;
-
    height: 0;
-
    overflow: hidden;
-
  }
-
  .horizontal-buttons {
-
    display: flex;
-
    gap: 0.5rem;
-
    opacity: 0;
-
    transition: opacity 30ms ease-in-out;
-
  }
-
  .horizontal-buttons.expanded {
-
    opacity: 1;
-
    transition: opacity 150ms ease-in-out;
-
  }
-
  .icon {
-
    transform: rotate(180deg);
-
    transition: transform 150ms ease-in-out;
-
  }
-
  .icon.expanded {
-
    transform: rotate(0deg);
-
  }
-
  .bottom {
-
    display: flex;
-
    flex-direction: column;
-
    justify-items: flex-end;
-
  }
-
</style>
-

-
<div class="sidebar" class:expanded>
-
  <!-- Top Navigation Items -->
-
  <div class="project-navigation">
-
    <Link
-
      title="Source"
-
      route={{
-
        resource: "project.source",
-
        project: project.id,
-
        node: baseUrl,
-
        path: "/",
-
      }}>
-
      <Button
-
        stylePadding="0.5rem 0.75rem"
-
        size="large"
-
        styleWidth="100%"
-
        styleJustifyContent="flex-start"
-
        variant={activeTab === "source" ? "gray" : "background"}>
-
        <Icon name="chevron-left-right" />
-
        <span class="title-counter" class:expanded>Source</span>
-
      </Button>
-
    </Link>
-
    <Link
-
      title={`${project.issues.open} Issues`}
-
      route={{
-
        resource: "project.issues",
-
        project: project.id,
-
        node: baseUrl,
-
      }}>
-
      <Button
-
        stylePadding="0.5rem 0.75rem"
-
        let:hover
-
        size="large"
-
        styleJustifyContent="flex-start"
-
        styleWidth="100%"
-
        variant={activeTab === "issues" ? "gray" : "background"}>
-
        <Icon name="issue" />
-
        <div class="title-counter" class:expanded>
-
          Issues
-
          <span
-
            class="counter"
-
            class:selected={activeTab === "issues"}
-
            class:hover={hover && activeTab !== "issues"}>
-
            {project.issues.open}
-
          </span>
-
        </div>
-
      </Button>
-
    </Link>
-

-
    <Link
-
      title={`${project.patches.open} Patches`}
-
      route={{
-
        resource: "project.patches",
-
        project: project.id,
-
        node: baseUrl,
-
      }}>
-
      <Button
-
        stylePadding="0.5rem 0.75rem"
-
        let:hover
-
        size="large"
-
        styleWidth="100%"
-
        styleJustifyContent="flex-start"
-
        variant={activeTab === "patches" ? "gray" : "background"}>
-
        <Icon name="patch" />
-
        <div class="title-counter" class:expanded>
-
          Patches
-
          <span
-
            class="counter"
-
            class:hover={hover && activeTab !== "patches"}
-
            class:selected={activeTab === "patches"}>
-
            {project.patches.open}
-
          </span>
-
        </div>
-
      </Button>
-
    </Link>
-
  </div>
-
  <!-- Context and other information section -->
-
  <div class="bottom">
-
    <div class="repo box" class:expanded>
-
      <ContextRepo
-
        {baseUrl}
-
        projectThreshold={project.threshold}
-
        projectDelegates={project.delegates}
-
        {seedingPolicy} />
-
    </div>
-
    <div class="vertical-buttons" class:expanded style:gap="0.5rem">
-
      <Popover popoverPositionBottom="0" popoverPositionLeft="3rem">
-
        <Button
-
          stylePadding="0 0.75rem"
-
          variant="background"
-
          title="Settings"
-
          slot="toggle"
-
          let:toggle
-
          on:click={toggle}>
-
          <Icon name="settings" />
-
        </Button>
-

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

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

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

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

-
        <div slot="popover" class="txt-small" style:width="18rem">
-
          <ContextRepo
-
            {baseUrl}
-
            projectThreshold={project.threshold}
-
            projectDelegates={project.delegates}
-
            {seedingPolicy} />
-
        </div>
-
      </Popover>
-
    </div>
-
    <!-- Footer -->
-
    {#if !collapsedOnly}
-
      <div class="sidebar-footer" style:flex-direction="row">
-
        <Button
-
          title={"Collapse"}
-
          on:click={toggleSidebar}
-
          variant="background">
-
          <div class="icon" class:expanded>
-
            <Icon name="chevron-left" />
-
          </div>
-
        </Button>
-
        <div class="global-flex-item">
-
          <div class="horizontal-buttons" class:expanded>
-
            <Popover popoverPositionBottom="2.5rem" popoverPositionLeft="0">
-
              <Button
-
                variant="outline"
-
                title="Settings"
-
                slot="toggle"
-
                let:toggle
-
                on:click={toggle}>
-
                <Icon name="settings" />
-
                Settings
-
              </Button>
-

-
              <Settings slot="popover" />
-
            </Popover>
-
          </div>
-
          <div class="horizontal-buttons" class:expanded>
-
            <Popover popoverPositionBottom="2.5rem" popoverPositionLeft="0">
-
              <Button
-
                variant="outline"
-
                title="Help"
-
                slot="toggle"
-
                let:toggle
-
                on:click={toggle}>
-
                <Icon name="help" />
-
                Help
-
              </Button>
-
              <Help slot="popover" />
-
            </Popover>
-
          </div>
-
        </div>
-
      </div>
-
    {/if}
-
  </div>
-
</div>
deleted src/views/projects/Sidebar/ContextRepo.svelte
@@ -1,92 +0,0 @@
-
<script lang="ts">
-
  import type { BaseUrl, Project, SeedingPolicy } from "@http-client";
-

-
  import capitalize from "lodash/capitalize";
-

-
  import IconButton from "@app/components/IconButton.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import NodeId from "@app/components/NodeId.svelte";
-

-
  export let baseUrl: BaseUrl;
-
  export let projectThreshold: number;
-
  export let projectDelegates: Project["delegates"];
-
  export let seedingPolicy: SeedingPolicy;
-

-
  let delegateExpanded = false;
-
  let policyExpanded = false;
-
</script>
-

-
<style>
-
  .item-header {
-
    gap: 2rem;
-
    display: flex;
-
    align-items: center;
-
    justify-content: space-between;
-
    margin: 0.2rem 0;
-
  }
-
  .item-header:first-child {
-
    margin-top: 0;
-
  }
-
  .item-header:last-child {
-
    margin-bottom: 0;
-
  }
-
  .nid {
-
    height: 21.5px;
-
    margin: 0.5rem 0;
-
  }
-
</style>
-

-
<div class="item-header">
-
  <span>Delegates</span>
-
  <div class="global-flex-item">
-
    <span class="txt-bold">
-
      {projectThreshold}/{projectDelegates.length}
-
    </span>
-
    <IconButton on:click={() => (delegateExpanded = !delegateExpanded)}>
-
      <Icon name={delegateExpanded ? "chevron-up" : "chevron-down"} />
-
    </IconButton>
-
  </div>
-
</div>
-
{#if delegateExpanded}
-
  <div style:color="var(--color-foreground-dim" style:margin-bottom="1rem">
-
    {#if projectDelegates.length === 1}
-
      Any changes accepted by the sole delegate will be included in the
-
      canonical branch.
-
    {:else}
-
      {projectThreshold} out of {projectDelegates.length} delegates have to accept
-
      changes to be included in the canonical branch.
-
    {/if}
-
  </div>
-
  <div class="delegates">
-
    {#each projectDelegates as delegate}
-
      <div class="nid">
-
        <NodeId {baseUrl} nodeId={delegate.id} alias={delegate.alias} />
-
      </div>
-
    {/each}
-
  </div>
-
{/if}
-
<div class="item-header">
-
  <span style:text-wrap="nowrap">Seeding Scope</span>
-
  <div class="global-flex-item">
-
    <span class="txt-bold">
-
      {capitalize(
-
        "scope" in seedingPolicy ? seedingPolicy.scope : "not defined",
-
      )}
-
    </span>
-
    <IconButton on:click={() => (policyExpanded = !policyExpanded)}>
-
      <Icon name={policyExpanded ? "chevron-up" : "chevron-down"} />
-
    </IconButton>
-
  </div>
-
</div>
-
{#if policyExpanded}
-
  <div style:color="var(--color-foreground-dim)">
-
    {#if seedingPolicy.policy === "block"}
-
      Seeding scope only has an effect when a repository is seeded. This repo
-
      isn't seeded by the seed node.
-
    {:else if seedingPolicy.scope === "all"}
-
      This repository tracks changes by any peer.
-
    {:else}
-
      This repository tracks only peers followed by the seed node.
-
    {/if}
-
  </div>
-
{/if}
deleted src/views/projects/Source.svelte
@@ -1,228 +0,0 @@
-
<script lang="ts">
-
  import type {
-
    BaseUrl,
-
    Project,
-
    Remote,
-
    SeedingPolicy,
-
    Tree,
-
  } from "@http-client";
-
  import type { BlobResult, ProjectRoute } from "./router";
-

-
  import { HttpdClient } from "@http-client";
-

-
  import Button from "@app/components/Button.svelte";
-
  import Header from "./Source/Header.svelte";
-
  import Layout from "./Layout.svelte";
-
  import Placeholder from "@app/components/Placeholder.svelte";
-

-
  import BlobComponent from "./Source/Blob.svelte";
-
  import FilePath from "@app/components/FilePath.svelte";
-
  import ProjectNameHeader from "./Source/ProjectNameHeader.svelte";
-
  import Separator from "./Separator.svelte";
-
  import TreeComponent from "./Source/Tree.svelte";
-

-
  export let baseUrl: BaseUrl;
-
  export let blobResult: BlobResult;
-
  export let commit: string;
-
  export let path: string;
-
  export let peer: string | undefined;
-
  export let peers: Remote[];
-
  export let project: Project;
-
  export let rawPath: (commit?: string) => string;
-
  export let revision: string | undefined;
-
  export let seedingPolicy: SeedingPolicy;
-
  export let tree: Tree;
-
  export let nodeAvatarUrl: string | undefined;
-

-
  let mobileFileTree = false;
-

-
  const api = new HttpdClient(baseUrl);
-

-
  const fetchTree = async (path: string) => {
-
    return api.project
-
      .getTree(project.id, tree.lastCommit.id, path)
-
      .catch(() => {
-
        blobResult = {
-
          ok: false,
-
          error: {
-
            message: "Not able to expand directory",
-
            path,
-
          },
-
        };
-
        return undefined;
-
      });
-
  };
-

-
  $: baseRoute = {
-
    resource: "project.source",
-
    node: baseUrl,
-
    project: project.id,
-
    path: "/",
-
  } as Extract<ProjectRoute, { resource: "project.source" }>;
-
</script>
-

-
<style>
-
  .center-content {
-
    margin: 0 auto;
-
  }
-

-
  .container {
-
    display: flex;
-
    width: inherit;
-
    padding: 0 1rem 1rem 1rem;
-
  }
-

-
  .column-left {
-
    display: flex;
-
    flex-direction: column;
-
    padding-right: 0.5rem;
-
  }
-

-
  .column-right {
-
    display: flex;
-
    flex-direction: column;
-
    width: 100%;
-
    padding-bottom: 2.5rem;
-
    max-width: 75rem;
-
    margin: 0 auto;
-
    /* To allow pre elements to shrink when overflowing */
-
    min-width: 0;
-
  }
-
  .placeholder {
-
    width: 100%;
-
    padding: 4rem 0;
-
    border: 1px solid var(--color-border-hint);
-
    border-radius: var(--border-radius-small);
-
  }
-

-
  .source-tree {
-
    overflow-x: hidden;
-
    width: 17.5rem;
-
    padding-right: 0.25rem;
-
  }
-
  .sticky {
-
    position: sticky;
-
    top: 0rem;
-
    max-height: calc(100vh - 5.5rem);
-
  }
-
  @media (max-width: 719.98px) {
-
    .container {
-
      display: flex;
-
      width: inherit;
-
      padding: 0;
-
    }
-
    .placeholder {
-
      border-radius: 0;
-
      border-left: 0;
-
      border-right: 0;
-
    }
-
  }
-
</style>
-

-
<Layout
-
  {baseUrl}
-
  {nodeAvatarUrl}
-
  {project}
-
  {seedingPolicy}
-
  activeTab="source"
-
  stylePaddingBottom="0">
-
  <svelte:fragment slot="breadcrumb">
-
    {#if path !== "/"}
-
      <Separator />
-
      <FilePath filenameWithPath={path} />
-
    {/if}
-
  </svelte:fragment>
-
  <ProjectNameHeader {project} {baseUrl} slot="header" />
-

-
  <div style:margin="1rem" slot="subheader">
-
    <Header
-
      filesLinkActive={true}
-
      historyLinkActive={false}
-
      node={baseUrl}
-
      {commit}
-
      {baseRoute}
-
      {peers}
-
      {peer}
-
      {project}
-
      {revision}
-
      {tree} />
-
  </div>
-
  <div class="global-hide-on-medium-desktop-up">
-
    {#if tree.entries.length > 0}
-
      <div style:margin="1rem">
-
        <Button
-
          styleWidth="100%"
-
          size="large"
-
          variant="outline"
-
          on:click={() => {
-
            mobileFileTree = !mobileFileTree;
-
          }}>
-
          Browse
-
        </Button>
-
      </div>
-

-
      {#if mobileFileTree}
-
        <div class="layout-mobile" style:margin="1rem">
-
          <TreeComponent
-
            projectId={project.id}
-
            {revision}
-
            {baseUrl}
-
            {fetchTree}
-
            {path}
-
            {peer}
-
            {tree}
-
            on:select={() => {
-
              mobileFileTree = false;
-
            }} />
-
        </div>
-
      {/if}
-
    {/if}
-
  </div>
-

-
  <div class="container center-content">
-
    {#if tree.entries.length > 0}
-
      <div class="column-left global-hide-on-small-desktop-down">
-
        <div class="source-tree sticky">
-
          <TreeComponent
-
            projectId={project.id}
-
            {revision}
-
            {baseUrl}
-
            {fetchTree}
-
            {path}
-
            {peer}
-
            {tree} />
-
        </div>
-
      </div>
-
      <div class="column-right">
-
        {#if blobResult.ok}
-
          <BlobComponent
-
            {path}
-
            {baseUrl}
-
            projectId={project.id}
-
            blob={blobResult.blob}
-
            highlighted={blobResult.highlighted}
-
            rawPath={rawPath(tree.lastCommit.id)} />
-
        {:else if blobResult.error.status === 413}
-
          <div class="placeholder">
-
            <Placeholder
-
              iconName="exclamation-circle"
-
              caption="This file is too big to be displayed.
-
              If you want to view this file, clone this repository locally." />
-
          </div>
-
        {:else if path === "/"}
-
          <div class="placeholder">
-
            <Placeholder iconName="no-file" caption="No README found." />
-
          </div>
-
        {:else}
-
          <div class="placeholder">
-
            <Placeholder iconName="no-file" caption="File not found." />
-
          </div>
-
        {/if}
-
      </div>
-
    {:else}
-
      <div class="placeholder">
-
        <Placeholder iconName="no-file" caption="No files at this revision." />
-
      </div>
-
    {/if}
-
  </div>
-
</Layout>
deleted src/views/projects/Source/Blob.svelte
@@ -1,219 +0,0 @@
-
<script lang="ts">
-
  import type { BaseUrl, Blob } from "@http-client";
-

-
  import { afterUpdate, onDestroy, onMount } from "svelte";
-
  import { toHtml } from "hast-util-to-html";
-

-
  import * as Syntax from "@app/lib/syntax";
-
  import { isImagePath, isMarkdownPath, isSvgPath } from "@app/lib/utils";
-
  import { lineNumbersGutter } from "@app/lib/syntax";
-

-
  import Button from "@app/components/Button.svelte";
-
  import CommitButton from "@app/views/projects/components/CommitButton.svelte";
-
  import File from "@app/components/File.svelte";
-
  import FilePath from "@app/components/FilePath.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Markdown from "@app/components/Markdown.svelte";
-
  import Placeholder from "@app/components/Placeholder.svelte";
-
  import Radio from "@app/components/Radio.svelte";
-

-
  export let baseUrl: BaseUrl;
-
  export let projectId: string;
-
  export let path: string;
-
  export let blob: Blob;
-
  export let highlighted: Syntax.Root | undefined;
-
  export let rawPath: string;
-

-
  $: lastCommit = blob.lastCommit;
-

-
  $: content = highlighted ? lineNumbersGutter(highlighted) : undefined;
-
  $: extension = path.split(".").pop();
-

-
  let selectedLineId: string | undefined = undefined;
-
  $: {
-
    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
-
    content;
-
    updateSelectedLineId();
-
  }
-

-
  function updateSelectedLineId() {
-
    const fragmentId = window.location.hash.substring(1);
-
    if (fragmentId && fragmentId.match(/^L\d+$/)) {
-
      selectedLineId = fragmentId;
-
    } else {
-
      selectedLineId = undefined;
-
    }
-
  }
-

-
  $: isMarkdown = isMarkdownPath(blob.path);
-
  $: isImage = isImagePath(blob.path);
-
  $: isSvg = isSvgPath(blob.path);
-
  $: enablePreview = isMarkdown || isSvg;
-
  $: preview = enablePreview && selectedLineId === undefined;
-

-
  afterUpdate(() => {
-
    for (const item of document.getElementsByClassName("highlight")) {
-
      item.classList.remove("highlight");
-
    }
-
    if (selectedLineId) {
-
      const target = document.getElementById(selectedLineId);
-
      if (target) {
-
        target.classList.add("highlight");
-
        target.scrollIntoView({ block: "center" });
-
      }
-
    }
-
  });
-

-
  onMount(async () => {
-
    window.addEventListener("hashchange", updateSelectedLineId);
-
  });
-

-
  onDestroy(() => {
-
    window.removeEventListener("hashchange", updateSelectedLineId);
-
  });
-
</script>
-

-
<style>
-
  .code :global(.line-number) {
-
    font-family: var(--font-family-monospace);
-
    color: var(--color-foreground-disabled);
-
    text-align: right;
-
    padding: 0;
-
    user-select: none;
-
  }
-
  .code :global(.line-number a) {
-
    display: block;
-
    padding: 0 1rem;
-
  }
-
  .code :global(.line-number:hover) {
-
    cursor: pointer;
-
    color: var(--color-foreground-dim);
-
  }
-

-
  .code :global(.content) {
-
    display: inline;
-
    font-family: var(--font-family-monospace);
-
    margin: 0;
-
  }
-

-
  .code :global(.line) {
-
    line-height: 22px; /* This seems to be the line-height of a pre code block */
-
  }
-
  .code :global(.highlight) {
-
    background-color: var(--color-fill-float-hover);
-
    box-shadow: 0 0 0 1px var(--color-fill-secondary);
-
  }
-
  .code :global(.highlight td:first-child) {
-
    background-color: var(--color-fill-float-hover);
-
    border-left: 1px solid var(--color-fill-secondary);
-
  }
-
  .code :global(.highlight td:last-child) {
-
    background-color: var(--color-fill-float-hover);
-
    border-right: 1px solid var(--color-fill-secondary);
-
  }
-

-
  .code :global(.line-content) {
-
    padding: 0;
-
    width: 100%;
-
  }
-

-
  .code {
-
    width: 100%;
-
    border-spacing: 0;
-
    overflow-x: auto;
-
    font-size: var(--font-size-small);
-
    padding-top: 1rem;
-
    margin-bottom: 1.5rem;
-
  }
-

-
  .teaser-buttons {
-
    display: flex;
-
    gap: 0.5rem;
-
  }
-

-
  .no-scrollbar {
-
    scrollbar-width: none;
-
  }
-

-
  .no-scrollbar::-webkit-scrollbar {
-
    display: none;
-
  }
-
  .markdown-wrapper {
-
    padding: 2rem;
-
  }
-
  @media (max-width: 719.98px) {
-
    .markdown-wrapper {
-
      padding: 1rem;
-
    }
-
  }
-
</style>
-

-
<File sticky={false}>
-
  <FilePath slot="left-header" filenameWithPath={blob.path} />
-
  <svelte:fragment slot="right-header">
-
    <CommitButton {projectId} {baseUrl} commit={lastCommit} />
-
    <div class="global-hide-on-mobile-down teaser-buttons">
-
      {#if enablePreview}
-
        <Radio ariaLabel="Toggle render method">
-
          <Button
-
            styleBorderRadius="0"
-
            variant={!preview ? "selected" : "not-selected"}
-
            on:click={() => {
-
              preview = false;
-
            }}>
-
            <Icon name="chevron-left-right" />Code
-
          </Button>
-
          <Button
-
            styleBorderRadius="0"
-
            variant={preview ? "selected" : "not-selected"}
-
            on:click={() => {
-
              window.location.hash = "";
-
              preview = true;
-
            }}>
-
            <Icon name="eye-open" />Preview
-
          </Button>
-
          <div class="global-spacer" />
-
        </Radio>
-
      {/if}
-
      <a href="{rawPath}/{blob.path}" target="_blank" rel="noreferrer">
-
        <Button variant="gray-white">
-
          Raw <Icon name="arrow-box-up-right" />
-
        </Button>
-
      </a>
-
    </div>
-
  </svelte:fragment>
-

-
  {#if blob.binary && blob.content}
-
    {#if isImage && extension}
-
      <div style:margin="1rem 0" style:text-align="center">
-
        <img
-
          src={`data:image/${extension};base64,${blob.content}`}
-
          alt={path} />
-
      </div>
-
    {:else}
-
      <div style:margin="4rem 0" style:width="100%">
-
        <Placeholder iconName="binary-file" caption="Binary file" />
-
      </div>
-
    {/if}
-
  {:else if preview && blob.content}
-
    {#if isMarkdown}
-
      <div class="markdown-wrapper">
-
        <Markdown content={blob.content} {rawPath} {path} />
-
      </div>
-
    {:else if isSvg}
-
      <div style:margin="1rem 0" style:text-align="center">
-
        <img
-
          src={`data:image/svg+xml;base64,${btoa(blob.content)}`}
-
          alt={path} />
-
      </div>
-
    {/if}
-
  {:else if content}
-
    <table class="code no-scrollbar">
-
      {@html toHtml(content)}
-
    </table>
-
  {:else}
-
    <div style:margin="4rem 0" style:width="100%">
-
      <Placeholder iconName="empty-file" caption="Empty file" />
-
    </div>
-
  {/if}
-
</File>
deleted src/views/projects/Source/Header.svelte
@@ -1,171 +0,0 @@
-
<script lang="ts">
-
  import type { ProjectRoute } from "../router";
-
  import type { BaseUrl, Project, Remote, Tree } from "@http-client";
-
  import type { ComponentProps } from "svelte";
-

-
  import { HttpdClient } from "@http-client";
-

-
  import Button from "@app/components/Button.svelte";
-
  import CommitButton from "../components/CommitButton.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import Loading from "@app/components/Loading.svelte";
-
  import PeerBranchSelector from "./PeerBranchSelector.svelte";
-

-
  export let commit: string;
-
  export let filesLinkActive: boolean;
-
  export let historyLinkActive: boolean;
-
  export let node: BaseUrl;
-
  export let peer: string | undefined;
-
  export let peers: Remote[];
-
  export let project: Project;
-
  export let baseRoute: Extract<
-
    ProjectRoute,
-
    { resource: "project.source" } | { resource: "project.history" }
-
  >;
-
  export let revision: string | undefined;
-
  export let tree: Tree;
-

-
  const api = new HttpdClient(node);
-
  let selectedBranch: string | undefined;
-
  let commitButtonVariant: ComponentProps<CommitButton>["variant"] | undefined =
-
    undefined;
-

-
  // Revision may be a commit ID, a branch name or `undefined` which means the
-
  // default branch. We assign `selectedBranch` accordingly.
-
  $: if (revision === lastCommit.id) {
-
    selectedBranch = undefined;
-
  } else {
-
    selectedBranch = revision || project.defaultBranch;
-
  }
-

-
  $: lastCommit = tree.lastCommit;
-
  $: onCanonical = Boolean(!peer && selectedBranch === project.defaultBranch);
-
  $: if (onCanonical) {
-
    commitButtonVariant = "right";
-
  } else if (!selectedBranch) {
-
    commitButtonVariant = "left";
-
  } else {
-
    commitButtonVariant = "center";
-
  }
-
</script>
-

-
<style>
-
  .top-header {
-
    display: flex;
-
    align-items: center;
-
    justify-content: left;
-
    row-gap: 0.5rem;
-
    gap: 1px;
-
    flex-wrap: wrap;
-
    margin-bottom: 2rem;
-
  }
-

-
  .header {
-
    font-size: var(--font-size-tiny);
-
    display: flex;
-
    gap: 0.375rem;
-
    align-items: center;
-
    justify-content: left;
-
    flex-wrap: wrap;
-
    position: relative;
-
  }
-
  .header::after {
-
    content: "";
-
    position: absolute;
-
    left: -1rem;
-
    bottom: 0;
-
    border-bottom: 1px solid var(--color-fill-separator);
-
    width: calc(100% + 1rem);
-
    z-index: -1;
-
  }
-

-
  .counter {
-
    border-radius: var(--border-radius-tiny);
-
    background-color: var(--color-fill-ghost);
-
    color: var(--color-foreground-dim);
-
    padding: 0 0.25rem;
-
  }
-

-
  .title-counter {
-
    display: flex;
-
    align-items: center;
-
    gap: 0.5rem;
-
  }
-

-
  .selected {
-
    background-color: var(--color-fill-ghost);
-
    color: var(--color-foreground-contrast);
-
  }
-
</style>
-

-
<div class="top-header">
-
  {#if selectedBranch}
-
    <PeerBranchSelector
-
      {peers}
-
      {peer}
-
      {baseRoute}
-
      {onCanonical}
-
      {project}
-
      {selectedBranch} />
-
  {/if}
-
  <div class="global-flex-item txt-overflow" style:gap="1px">
-
    <CommitButton
-
      variant={commitButtonVariant}
-
      styleMinWidth="0"
-
      styleWidth="100%"
-
      hideSummaryOnMobile={false}
-
      projectId={project.id}
-
      commit={lastCommit}
-
      baseUrl={node} />
-
    {#if !onCanonical}
-
      <Link route={baseRoute}>
-
        <Button
-
          variant="not-selected"
-
          styleBorderRadius="0 var(--border-radius-tiny) var(--border-radius-tiny) 0">
-
          <Icon name="cross" />
-
        </Button>
-
      </Link>
-
    {/if}
-
  </div>
-
</div>
-

-
<div class="header">
-
  <div style="display: flex; gap: 0.375rem;">
-
    <Link
-
      route={{
-
        resource: "project.source",
-
        project: project.id,
-
        node: node,
-
        peer,
-
        revision,
-
      }}>
-
      <Button size="large" variant={filesLinkActive ? "tab-active" : "tab"}>
-
        <Icon name="file" />Files
-
      </Button>
-
    </Link>
-

-
    <Link
-
      route={{
-
        resource: "project.history",
-
        project: project.id,
-
        node: node,
-
        peer,
-
        revision,
-
      }}>
-
      <Button size="large" variant={historyLinkActive ? "tab-active" : "tab"}>
-
        <Icon name="commit" />
-
        <div class="title-counter">
-
          Commits
-
          {#await api.project.getTreeStatsBySha(project.id, commit)}
-
            <Loading small center noDelay grayscale />
-
          {:then stats}
-
            <div class="counter" class:selected={historyLinkActive}>
-
              {stats.commits}
-
            </div>
-
          {/await}
-
        </div>
-
      </Button>
-
    </Link>
-
  </div>
-
</div>
deleted src/views/projects/Source/PeerBranchSelector.svelte
@@ -1,277 +0,0 @@
-
<script lang="ts">
-
  import type { ProjectRoute } from "@app/views/projects/router";
-
  import type { Project, Remote } from "@http-client";
-

-
  import fuzzysort from "fuzzysort";
-
  import orderBy from "lodash/orderBy";
-
  import { formatCommit, formatNodeId } from "@app/lib/utils";
-

-
  import Badge from "@app/components/Badge.svelte";
-
  import Button from "@app/components/Button.svelte";
-
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import Peer from "./PeerBranchSelector/Peer.svelte";
-
  import Popover from "@app/components/Popover.svelte";
-
  import TextInput from "@app/components/TextInput.svelte";
-
  import Avatar from "@app/components/Avatar.svelte";
-

-
  export let baseRoute: Extract<
-
    ProjectRoute,
-
    { resource: "project.source" } | { resource: "project.history" }
-
  >;
-
  export let onCanonical: boolean;
-
  export let peer: string | undefined;
-
  export let peers: Remote[];
-
  export let project: Project;
-
  export let selectedBranch: string | undefined;
-

-
  const subgridStyle =
-
    "display: grid; grid-template-columns: subgrid; grid-column: span 2;";
-
  const highlightSearchStyle = [
-
    '<span style="background: var(--color-fill-yellow-iconic); color: var(--color-foreground-black);">',
-
    "</span>",
-
  ];
-
  let searchInput = "";
-

-
  const searchElements = [
-
    {
-
      peer: undefined,
-
      revision: project.defaultBranch,
-
      head: project.head,
-
    },
-
    ...peers.flatMap(peer =>
-
      Object.entries(peer.heads).map(([name, head]) => ({
-
        peer: { id: peer.id, alias: peer.alias, delegate: peer.delegate },
-
        revision: name,
-
        head,
-
      })),
-
    ),
-
  ];
-

-
  $: selectedPeer = peers.find(p => p.id === peer);
-
  $: searchResults = fuzzysort.go(searchInput, searchElements, {
-
    keys: ["peer.alias", "revision"],
-
    scoreFn: r =>
-
      r.score *
-
      (r.obj.peer?.delegate ? 2 : 1) *
-
      (r.obj.peer === undefined ? 10 : 1) *
-
      (r.obj.peer?.alias ? 2 : 1),
-
  });
-
</script>
-

-
<style>
-
  .dropdown {
-
    border-radius: var(--border-radius-small);
-
    width: 40rem;
-
    max-height: 60vh;
-
    overflow-y: auto;
-
    padding: 0.25rem;
-
  }
-
  .subgrid-item {
-
    display: grid;
-
    grid-template-columns: subgrid;
-
    grid-column: span 2;
-
  }
-
  .dropdown-grid {
-
    display: grid;
-
    column-gap: 2rem;
-
    grid-template-columns: [branch] minmax(20ch, 1fr) [commit] 7ch;
-
  }
-
  .dropdown-header {
-
    display: grid;
-
    grid-template-columns: subgrid;
-
    font-size: var(--font-size-tiny);
-
    padding: 0.5rem;
-
    color: var(--color-foreground-dim);
-
  }
-
  .container {
-
    display: flex;
-
    gap: 1px;
-
    min-width: 0;
-
    flex-wrap: nowrap;
-
  }
-
  .node-id {
-
    display: flex;
-
    align-items: center;
-
    justify-content: center;
-
    gap: 0.375rem;
-
    height: 1rem;
-
    font-family: var(--font-family-monospace);
-
    font-weight: var(--font-weight-semibold);
-
    font-size: var(--font-size-small);
-
  }
-
  @media (max-width: 719.98px) {
-
    .dropdown {
-
      width: 100%;
-
    }
-
  }
-
</style>
-

-
<div class="container">
-
  <Popover
-
    popoverContainerMinWidth="0"
-
    popoverPadding="0"
-
    popoverPositionTop="2.5rem"
-
    popoverBorderRadius="var(--border-radius-small)">
-
    <Button
-
      slot="toggle"
-
      let:expanded
-
      let:toggle
-
      styleBorderRadius={"var(--border-radius-tiny) 0 0 var(--border-radius-tiny)"}
-
      styleWidth="100%"
-
      on:click={toggle}
-
      title="Change branch"
-
      disabled={!peers}>
-
      {#if selectedPeer}
-
        <div class="global-flex-item">
-
          <div class="node-id">
-
            <Avatar nodeId={selectedPeer.id} variant="small" />
-
            {selectedPeer.alias || formatNodeId(selectedPeer.id)}
-
          </div>
-

-
          {#if selectedPeer.delegate}
-
            <Badge size="tiny" variant="delegate">
-
              <Icon name="badge" />
-
              <span class="global-hide-on-small-desktop-down">Delegate</span>
-
            </Badge>
-
          {/if}
-
        </div>
-
      {/if}
-
      {#if selectedPeer && selectedBranch}
-
        <span>/</span>
-
      {/if}
-
      {#if selectedBranch}
-
        <Icon name="branch" />
-
        <span class="txt-overflow">
-
          {selectedBranch}
-
        </span>
-
        {#if onCanonical}
-
          <Badge title="Canonical branch" variant="foreground-emphasized">
-
            Canonical
-
          </Badge>
-
        {/if}
-
      {/if}
-
      <Icon name={expanded ? "chevron-up" : "chevron-down"} />
-
    </Button>
-

-
    <div slot="popover" class="dropdown" let:toggle>
-
      <TextInput
-
        showKeyHint={false}
-
        placeholder="Search"
-
        bind:value={searchInput} />
-
      <div class="dropdown-grid">
-
        <div class="dropdown-header">Branch</div>
-
        <div class="dropdown-header" style="padding-left: 0;">Head</div>
-

-
        {#if searchInput}
-
          {#each searchResults as result}
-
            {@const { revision, peer, head } = result.obj}
-
            <Link
-
              style={subgridStyle}
-
              route={{
-
                ...baseRoute,
-
                peer: peer?.id,
-
                revision: peer ? revision : undefined,
-
              }}
-
              on:afterNavigate={() => {
-
                searchInput = "";
-
                toggle();
-
              }}>
-
              <DropdownListItem
-
                selected={selectedPeer?.id === peer?.id &&
-
                  selectedBranch === revision}
-
                style={`${subgridStyle} gap: inherit;`}>
-
                <div class="global-flex-item">
-
                  <Icon name="branch" />
-
                  <span class="txt-overflow">
-
                    {#if peer?.id}
-
                      <span class="global-flex-item">
-
                        {#if result[0].target}
-
                          <span>
-
                            {@html result[0].highlight(...highlightSearchStyle)}
-
                          </span>
-
                        {:else if peer.alias}
-
                          {peer.alias}
-
                        {:else}
-
                          {formatNodeId(peer.id)}
-
                        {/if}
-
                        {#if peer.delegate}
-
                          <Badge variant="delegate" round>
-
                            <Icon name="badge" />
-
                          </Badge>
-
                        {/if} /
-
                        <span class="txt-overflow">
-
                          {#if result[1].target}
-
                            <span>
-
                              {@html result[1].highlight(
-
                                ...highlightSearchStyle,
-
                              )}
-
                            </span>
-
                          {:else}
-
                            {revision}
-
                          {/if}
-
                        </span>
-
                      </span>
-
                    {:else}
-
                      <div class="global-flex-item">
-
                        {revision}
-
                        <Badge
-
                          title="Canonical branch"
-
                          variant="foreground-emphasized">
-
                          Canonical
-
                        </Badge>
-
                      </div>
-
                    {/if}
-
                  </span>
-
                </div>
-
                <div
-
                  class="txt-monospace"
-
                  style="color: var(--color-foreground-dim);">
-
                  {formatCommit(head)}
-
                </div>
-
              </DropdownListItem>
-
            </Link>
-
          {:else}
-
            <div
-
              style="gap: inherit; padding: 0.5rem 0.375rem;"
-
              class="subgrid-item txt-missing txt-small">
-
              No entries found
-
            </div>
-
          {/each}
-
        {:else}
-
          <Link
-
            style={subgridStyle}
-
            route={{ ...baseRoute, revision: undefined }}
-
            on:afterNavigate={() => {
-
              searchInput = "";
-
              toggle();
-
            }}>
-
            <DropdownListItem
-
              selected={onCanonical}
-
              style={`${subgridStyle} gap: inherit;`}>
-
              <div class="global-flex-item">
-
                <Icon name="branch" />
-
                {project.defaultBranch}
-
                <Badge title="Canonical branch" variant="foreground-emphasized">
-
                  Canonical
-
                </Badge>
-
              </div>
-
              <div
-
                class="txt-monospace"
-
                style="color: var(--color-foreground-dim);">
-
                {formatCommit(project.head)}
-
              </div>
-
            </DropdownListItem>
-
          </Link>
-
          {#each orderBy(peers, ["delegate", o => o.alias?.toLowerCase()], ["desc", "asc"]) as peer}
-
            <Peer
-
              {baseRoute}
-
              revision={selectedBranch}
-
              peer={{ remote: peer, selected: selectedPeer?.id === peer.id }} />
-
          {/each}
-
        {/if}
-
      </div>
-
    </div>
-
  </Popover>
-
</div>
deleted src/views/projects/Source/PeerBranchSelector/Peer.svelte
@@ -1,88 +0,0 @@
-
<script lang="ts">
-
  import type { ProjectRoute } from "@app/views/projects/router";
-
  import type { Remote } from "@http-client";
-

-
  import { closeFocused } from "@app/components/Popover.svelte";
-
  import { formatCommit } from "@app/lib/utils";
-
  import { replace } from "@app/lib/router";
-

-
  import Badge from "@app/components/Badge.svelte";
-
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
-
  import IconButton from "@app/components/IconButton.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import NodeId from "@app/components/NodeId.svelte";
-

-
  export let baseRoute: Extract<
-
    ProjectRoute,
-
    { resource: "project.source" } | { resource: "project.history" }
-
  >;
-
  export let peer: { remote: Remote; selected: boolean };
-
  export let revision: string | undefined = undefined;
-

-
  const subgridStyle =
-
    "display: grid; grid-template-columns: subgrid; grid-column: span 2;";
-
  let expanded = false;
-
</script>
-

-
<style>
-
  .subgrid-item {
-
    display: grid;
-
    grid-template-columns: subgrid;
-
    grid-column: span 2;
-
  }
-
</style>
-

-
<div class="subgrid-item" aria-label="peer-item">
-
  <div class="global-flex-item" style="padding: 0.5rem 0">
-
    <IconButton title="Expand peer" on:click={() => (expanded = !expanded)}>
-
      <Icon name={expanded ? "chevron-down" : "chevron-right"} />
-
    </IconButton>
-
    <NodeId
-
      baseUrl={baseRoute.node}
-
      nodeId={peer.remote.id}
-
      alias={peer.remote.alias} />
-
    {#if peer.remote.delegate}
-
      <Badge size="tiny" variant="delegate">
-
        <Icon name="badge" />
-
        <span class="global-hide-on-small-desktop-down">Delegate</span>
-
      </Badge>
-
    {/if}
-
  </div>
-
</div>
-
{#if expanded}
-
  {#each Object.entries(peer.remote.heads) as [name, head]}
-
    <Link
-
      style={subgridStyle}
-
      route={{
-
        ...baseRoute,
-
        peer: peer.remote.id,
-
        revision: name,
-
      }}
-
      on:afterNavigate={() => closeFocused()}>
-
      <DropdownListItem
-
        selected={peer.selected && revision === name}
-
        on:click={() =>
-
          replace({
-
            ...baseRoute,
-
            peer: peer.remote.id,
-
            revision: name,
-
          })}
-
        style={`${subgridStyle} padding-left: 2.3rem; gap: inherit;`}>
-
        <div class="global-flex-item">
-
          <Icon name="branch" />
-
          <span class="txt-overflow">
-
            {name}
-
          </span>
-
        </div>
-
        <div class="global-flex-item">
-
          <span
-
            class="txt-monospace"
-
            style="color: var(--color-foreground-dim);">
-
            {formatCommit(head)}
-
          </span>
-
        </div>
-
      </DropdownListItem>
-
    </Link>
-
  {/each}
-
{/if}
deleted src/views/projects/Source/ProjectNameHeader.svelte
@@ -1,110 +0,0 @@
-
<script lang="ts">
-
  import type { BaseUrl, Project } from "@http-client";
-

-
  import dompurify from "dompurify";
-
  import { markdownWithExtensions } from "@app/lib/markdown";
-
  import { twemoji } from "@app/lib/utils";
-

-
  import Badge from "@app/components/Badge.svelte";
-
  import CloneButton from "@app/views/projects/Header/CloneButton.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Id from "@app/components/Id.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import SeedButton from "@app/views/projects/Header/SeedButton.svelte";
-
  import Share from "@app/views/projects/Share.svelte";
-

-
  export let project: Project;
-
  export let baseUrl: BaseUrl;
-

-
  function render(content: string): string {
-
    return dompurify.sanitize(
-
      markdownWithExtensions.parseInline(content) as string,
-
    );
-
  }
-
</script>
-

-
<style>
-
  .title {
-
    align-items: center;
-
    gap: 0.5rem;
-
    color: var(--color-foreground-contrast);
-
    display: flex;
-
    font-size: var(--font-size-large);
-
    justify-content: left;
-
    text-align: left;
-
    text-overflow: ellipsis;
-
    padding: 1rem 1rem 0 1rem;
-
  }
-
  .description {
-
    padding: 0 1rem 1rem 1rem;
-
  }
-
  .project-name {
-
    font-weight: var(--font-weight-semibold);
-
  }
-
  .project-name:hover {
-
    color: inherit;
-
  }
-
  .description :global(a) {
-
    border-bottom: 1px solid var(--color-foreground-dim);
-
  }
-
  .description :global(a:hover) {
-
    border-bottom: 1px solid var(--color-foreground-contrast);
-
  }
-
  .id {
-
    padding-left: 1rem;
-
  }
-
  .title-container {
-
    display: flex;
-
    flex-direction: column;
-
    gap: 0rem;
-
    margin-bottom: 1rem;
-
  }
-
</style>
-

-
<div class="title-container">
-
  <div class="title">
-
    <span class="txt-overflow">
-
      <Link
-
        route={{
-
          resource: "project.source",
-
          project: project.id,
-
          node: baseUrl,
-
        }}>
-
        <span class="project-name">
-
          {project.name}
-
        </span>
-
      </Link>
-
    </span>
-
    {#if project.visibility && project.visibility.type === "private"}
-
      <Badge variant="yellow" size="tiny">
-
        <Icon name="lock" />
-
        Private
-
      </Badge>
-
    {/if}
-
    <div style="margin-left: auto; display: flex; gap: 0.5rem;">
-
      <Share />
-
      <div
-
        style:display="flex"
-
        style:gap="0.5rem"
-
        class="global-hide-on-mobile-down">
-
        <CloneButton {baseUrl} id={project.id} name={project.name} />
-
        <SeedButton seedCount={project.seeding} projectId={project.id} />
-
      </div>
-
      <div
-
        style:display="flex"
-
        style:gap="0.5rem"
-
        class="global-hide-on-small-desktop-up">
-
        <SeedButton
-
          disabled
-
          seedCount={project.seeding}
-
          projectId={project.id} />
-
      </div>
-
    </div>
-
  </div>
-
  <div class="id">
-
    <Id shorten={false} id={project.id} ariaLabel="project-id" />
-
  </div>
-
</div>
-
<div class="description" use:twemoji>
-
  {@html render(project.description)}
-
</div>
deleted src/views/projects/Source/Tree.svelte
@@ -1,53 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import type { BaseUrl, Tree } from "@http-client";
-

-
  import { createEventDispatcher } from "svelte";
-

-
  import File from "./Tree/File.svelte";
-
  import Folder from "./Tree/Folder.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import Submodule from "./Tree/Submodule.svelte";
-

-
  export let baseUrl: BaseUrl;
-
  export let fetchTree: (path: string) => Promise<Tree | undefined>;
-
  export let path: string;
-
  export let peer: string | undefined;
-
  export let projectId: string;
-
  export let revision: string | undefined;
-
  export let tree: Tree;
-

-
  const dispatch = createEventDispatcher<{ select: string }>();
-
  const onSelect = ({ detail: path }: { detail: string }): void => {
-
    dispatch("select", path);
-
  };
-
</script>
-

-
{#each tree.entries as entry (entry.path)}
-
  {#if entry.kind === "tree"}
-
    <Folder
-
      currentPath={path}
-
      name={entry.name}
-
      on:select={onSelect}
-
      prefix={`${entry.path}/`}
-
      {baseUrl}
-
      {fetchTree}
-
      {peer}
-
      {projectId}
-
      {revision} />
-
  {:else if entry.kind === "submodule"}
-
    <Submodule name={entry.name} oid={entry.oid} />
-
  {:else}
-
    <Link
-
      route={{
-
        resource: "project.source",
-
        project: projectId,
-
        node: baseUrl,
-
        path: entry.path,
-
        peer,
-
        revision,
-
      }}
-
      on:afterNavigate={() => onSelect({ detail: entry.path })}>
-
      <File active={entry.path === path} name={entry.name} />
-
    </Link>
-
  {/if}
-
{/each}
deleted src/views/projects/Source/Tree/File.svelte
@@ -1,59 +0,0 @@
-
<script lang="ts">
-
  import Icon from "@app/components/Icon.svelte";
-

-
  export let active: boolean;
-
  export let name: string;
-
</script>
-

-
<style>
-
  .file {
-
    border-radius: var(--border-radius-tiny);
-
    cursor: pointer;
-
    display: flex;
-
    line-height: 1.5em;
-
    margin: 0.25rem 0;
-
    padding: 0.25rem 0.875rem;
-
    width: 100%;
-
    gap: 0.25rem;
-
    font-weight: var(--font-weight-regular);
-
  }
-

-
  .file:hover {
-
    background-color: var(--color-fill-ghost);
-
  }
-

-
  .file.active {
-
    color: var(--color-foreground-contrast) !important;
-
    background-color: var(--color-fill-ghost);
-
    font-weight: var(--font-weight-medium);
-
  }
-

-
  .file.active:hover {
-
    background-color: var(--color-fill-ghost-hover);
-
  }
-

-
  .name {
-
    user-select: none;
-
    white-space: nowrap;
-
    text-overflow: ellipsis !important;
-
    overflow: hidden;
-
    font-size: var(--font-size-small);
-
  }
-
  .icon-container {
-
    color: var(--color-foreground-dim);
-
    display: flex;
-
    justify-content: center;
-
    align-items: center;
-
    margin-right: 0.125rem;
-
  }
-
  .active .icon-container {
-
    color: var(--color-foreground-contrast);
-
  }
-
</style>
-

-
<div class="file" class:active>
-
  <div class="icon-container">
-
    <Icon name="file" />
-
  </div>
-
  <span class="name">{name}</span>
-
</div>
deleted src/views/projects/Source/Tree/Folder.svelte
@@ -1,136 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import type { BaseUrl, Tree } from "@http-client";
-

-
  import { createEventDispatcher } from "svelte";
-

-
  import Loading from "@app/components/Loading.svelte";
-
  import Link from "@app/components/Link.svelte";
-

-
  import File from "./File.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Submodule from "./Submodule.svelte";
-

-
  export let baseUrl: BaseUrl;
-
  export let currentPath: string;
-
  export let fetchTree: (path: string) => Promise<Tree | undefined>;
-
  export let name: string;
-
  export let peer: string | undefined;
-
  export let prefix: string;
-
  export let projectId: string;
-
  export let revision: string | undefined;
-

-
  $: expanded = currentPath.indexOf(prefix) === 0;
-
  $: tree = expanded
-
    ? fetchTree(prefix).then(tree => {
-
        return tree;
-
      })
-
    : Promise.resolve(undefined);
-

-
  const dispatch = createEventDispatcher<{ select: string }>();
-
  const onSelectFile = ({ detail: path }: { detail: string }) =>
-
    dispatch("select", path);
-
</script>
-

-
<style>
-
  .folder {
-
    display: flex;
-
    cursor: pointer;
-
    padding: 0.25rem 0.875rem;
-
    margin: 0.25rem 0;
-
    user-select: none;
-
    line-height: 1.5rem;
-
    white-space: nowrap;
-
  }
-
  .folder:hover {
-
    background-color: var(--color-fill-ghost);
-
    border-radius: var(--border-radius-tiny);
-
  }
-

-
  .folder-name {
-
    margin-left: 0.25rem;
-
    font-size: var(--font-size-small);
-
    font-weight: var(--font-weight-regular);
-
  }
-

-
  .container {
-
    padding-left: 1rem;
-
    margin-left: 0.5rem;
-
  }
-

-
  .loading {
-
    display: inline-block;
-
    padding: 0.5rem 0;
-
  }
-
  .icon-container {
-
    display: flex;
-
    justify-content: center;
-
    align-items: center;
-
    color: var(--color-foreground-dim);
-
    margin-right: 0.125rem;
-
  }
-

-
  .expanded {
-
    font-weight: var(--font-weight-medium);
-
    color: var(--color-foreground-contrast);
-
  }
-
</style>
-

-
<!-- svelte-ignore a11y-click-events-have-key-events -->
-
<div
-
  role="button"
-
  tabindex="0"
-
  class="folder"
-
  on:click={() => {
-
    expanded = !expanded;
-
  }}>
-
  <div class="icon-container" class:expanded>
-
    {#if expanded}
-
      <Icon name="folder-open" />
-
    {:else}
-
      <Icon name="folder" />
-
    {/if}
-
  </div>
-
  <span class="folder-name" class:expanded>{name}</span>
-
</div>
-

-
{#if expanded}
-
  <div class="container">
-
    {#await tree}
-
      <span class="loading"><Loading grayscale noDelay small margins /></span>
-
    {:then tree}
-
      {#if tree}
-
        {#each tree.entries as entry (entry.path)}
-
          {#if entry.kind === "tree"}
-
            <!-- svelte:self doesn't check types, make sure to pass in all
-
            required props! -->
-
            <svelte:self
-
              name={entry.name}
-
              on:select={onSelectFile}
-
              prefix={`${entry.path}/`}
-
              {baseUrl}
-
              {currentPath}
-
              {fetchTree}
-
              {peer}
-
              {projectId}
-
              {revision} />
-
          {:else if entry.kind === "submodule"}
-
            <Submodule name={entry.name} oid={entry.oid} />
-
          {:else}
-
            <Link
-
              route={{
-
                resource: "project.source",
-
                project: projectId,
-
                node: baseUrl,
-
                path: entry.path,
-
                peer,
-
                revision,
-
              }}
-
              on:afterNavigate={() => onSelectFile({ detail: entry.path })}>
-
              <File active={entry.path === currentPath} name={entry.name} />
-
            </Link>
-
          {/if}
-
        {/each}
-
      {/if}
-
    {/await}
-
  </div>
-
{/if}
deleted src/views/projects/Source/Tree/Submodule.svelte
@@ -1,44 +0,0 @@
-
<script lang="ts">
-
  import Icon from "@app/components/Icon.svelte";
-
  import { formatCommit } from "@app/lib/utils";
-

-
  export let name: string;
-
  export let oid: string;
-
</script>
-

-
<style>
-
  .submodule {
-
    color: var(--color-foreground-dim);
-
    border-radius: var(--border-radius-tiny);
-
    display: flex;
-
    line-height: 1.5em;
-
    margin: 0.25rem 0;
-
    padding: 0.25rem 0.875rem;
-
    width: 100%;
-
  }
-

-
  .name {
-
    margin-left: 0.25rem;
-
    user-select: none;
-
    white-space: nowrap;
-
    text-overflow: ellipsis !important;
-
    overflow: hidden;
-
    font-size: var(--font-size-small);
-
    font-weight: var(--font-weight-regular);
-
  }
-
  .icon-container {
-
    display: flex;
-
    justify-content: center;
-
    align-items: center;
-
    margin-right: 0.125rem;
-
  }
-
</style>
-

-
<div
-
  class="submodule"
-
  title="This is a git submodule, for more information look at the nearest .gitmodules file">
-
  <div class="icon-container">
-
    <Icon name="repo" />
-
  </div>
-
  <span class="name">{name} @ {formatCommit(oid)}</span>
-
</div>
deleted src/views/projects/components/CommitButton.svelte
@@ -1,72 +0,0 @@
-
<script lang="ts">
-
  import type { BaseUrl, Commit } from "@http-client";
-

-
  import Button from "@app/components/Button.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import { formatCommit, unreachable } from "@app/lib/utils";
-

-
  export let variant: "standalone" | "right" | "center" | "left" = "standalone";
-
  export let styleMinWidth: string | undefined = undefined;
-
  export let styleWidth: "100%" | undefined = undefined;
-
  export let projectId: string;
-
  export let baseUrl: BaseUrl;
-
  export let hideSummaryOnMobile: boolean = true;
-
  export let commit: Commit["commit"];
-

-
  let styleBorderRadius: string | undefined = undefined;
-

-
  $: commitShortId = formatCommit(commit.id);
-
  $: if (variant === "right") {
-
    styleBorderRadius =
-
      "0 var(--border-radius-tiny) var(--border-radius-tiny) 0";
-
  } else if (variant === "standalone") {
-
    styleBorderRadius = "var(--border-radius-tiny)";
-
  } else if (variant === "left") {
-
    styleBorderRadius =
-
      "var(--border-radius-tiny) 0 0 var(--border-radius-tiny)";
-
  } else if (variant === "center") {
-
    styleBorderRadius = "0";
-
  } else {
-
    unreachable(variant);
-
  }
-
</script>
-

-
<style>
-
  .commit {
-
    display: flex;
-
    align-items: center;
-
    gap: 0.5rem;
-
  }
-
  .identifier {
-
    display: flex;
-
    align-items: center;
-
    gap: 0.5rem;
-
  }
-
</style>
-

-
<Link
-
  styleTextOverflow
-
  route={{
-
    resource: "project.commit",
-
    project: projectId,
-
    node: baseUrl,
-
    commit: commit.id,
-
  }}>
-
  <Button
-
    title="Current HEAD"
-
    variant="not-selected"
-
    {styleWidth}
-
    {styleMinWidth}
-
    {styleBorderRadius}>
-
    <div class="txt-overflow commit">
-
      <div class="identifier global-commit">
-
        {commitShortId}
-
      </div>
-
      <span
-
        class="txt-overflow"
-
        class:global-hide-on-small-desktop-down={hideSummaryOnMobile}>
-
        {commit.summary}
-
      </span>
-
    </div>
-
  </Button>
-
</Link>
deleted src/views/projects/components/InlineTitle.svelte
@@ -1,28 +0,0 @@
-
<script lang="ts">
-
  import dompurify from "dompurify";
-
  import escape from "lodash/escape";
-
  import { formatInlineTitle } from "@app/lib/utils";
-

-
  export let content: string;
-
  export let fontSize: "tiny" | "small" | "regular" | "medium" | "large" =
-
    "small";
-
</script>
-

-
<style>
-
  .content :global(code) {
-
    font-family: var(--font-family-monospace);
-
    background-color: var(--color-fill-ghost);
-
    border-radius: var(--border-radius-tiny);
-
    padding: 0.125rem 0.25rem;
-
  }
-
</style>
-

-
<span
-
  class="content"
-
  class:txt-large={fontSize === "large"}
-
  class:txt-medium={fontSize === "medium"}
-
  class:txt-regular={fontSize === "regular"}
-
  class:txt-small={fontSize === "small"}
-
  class:txt-tiny={fontSize === "tiny"}>
-
  {@html dompurify.sanitize(formatInlineTitle(escape(content)))}
-
</span>
deleted src/views/projects/error.ts
@@ -1,70 +0,0 @@
-
import type { ErrorRoute, NotFoundRoute } from "@app/lib/router/definitions";
-
import type { ProjectRoute } from "@app/views/projects/router";
-

-
import { baseUrlToString } from "@app/lib/utils";
-
import { ResponseParseError, ResponseError } from "@http-client/lib/fetcher";
-

-
export function handleError(
-
  error: Error | ResponseParseError | ResponseError,
-
  route: ProjectRoute,
-
): NotFoundRoute | ErrorRoute {
-
  const url = baseUrlToString(route.node);
-
  if (error instanceof ResponseError && error.status === 404) {
-
    let subject;
-

-
    if (route.resource === "project.commit") {
-
      subject = "Commit";
-
    } else if (route.resource === "project.issue") {
-
      subject = "Issue";
-
    } else if (route.resource === "project.patch") {
-
      subject = "Patch";
-
    } else {
-
      subject = "Repository";
-
    }
-

-
    return {
-
      resource: "notFound",
-
      params: { title: `${subject} not found` },
-
    };
-
  } else if (error instanceof ResponseError) {
-
    return {
-
      resource: "error",
-
      params: {
-
        error,
-
        title: "Could not load this repository",
-
        description: `Make sure you are able to connect to the seed <a href="${url}">${url}</a>.`,
-
      },
-
    };
-
  } else if (error instanceof ResponseParseError) {
-
    return {
-
      resource: "error",
-
      params: {
-
        error,
-
        title: "Could not parse the request",
-
        description: error.description,
-
      },
-
    };
-
  } else {
-
    return {
-
      resource: "error",
-
      params: {
-
        error,
-
        title: "Could not load this repository",
-
        description:
-
          "You stumbled on an unknown error, we aren't exactly sure what happened.",
-
      },
-
    };
-
  }
-
}
-

-
export function unreachableError(): NotFoundRoute | ErrorRoute {
-
  return {
-
    resource: "error",
-
    params: {
-
      error: undefined,
-
      title: "Could not load this route",
-
      description:
-
        "You stumbled on an unknown error, we aren't exactly sure what happened.",
-
    },
-
  };
-
}
deleted src/views/projects/router.ts
@@ -1,1054 +0,0 @@
-
import type {
-
  ErrorRoute,
-
  LoadedRoute,
-
  NotFoundRoute,
-
} from "@app/lib/router/definitions";
-
import type {
-
  BaseUrl,
-
  Blob,
-
  Commit,
-
  CommitBlob,
-
  CommitHeader,
-
  Diff,
-
  DiffBlob,
-
  Issue,
-
  IssueState,
-
  Node,
-
  Patch,
-
  PatchState,
-
  Project,
-
  Remote,
-
  Revision,
-
  SeedingPolicy,
-
  Tree,
-
} from "@http-client";
-

-
import * as Syntax from "@app/lib/syntax";
-
import config from "virtual:config";
-
import { HttpdClient } from "@http-client";
-
import { ResponseError, ResponseParseError } from "@http-client/lib/fetcher";
-
import { cached } from "@app/lib/cache";
-
import { handleError, unreachableError } from "@app/views/projects/error";
-
import { isLocal, unreachable } from "@app/lib/utils";
-
import { nodePath } from "@app/views/nodes/router";
-

-
export const PATCHES_PER_PAGE = 10;
-
export const ISSUES_PER_PAGE = 10;
-

-
export type ProjectRoute =
-
  | ProjectTreeRoute
-
  | ProjectHistoryRoute
-
  | {
-
      resource: "project.commit";
-
      node: BaseUrl;
-
      project: string;
-
      commit: string;
-
    }
-
  | ProjectIssuesRoute
-
  | ProjectIssueRoute
-
  | ProjectPatchesRoute
-
  | ProjectPatchRoute;
-

-
interface ProjectIssuesRoute {
-
  resource: "project.issues";
-
  node: BaseUrl;
-
  project: string;
-
  status?: "open" | "closed";
-
}
-

-
interface ProjectIssueRoute {
-
  resource: "project.issue";
-
  node: BaseUrl;
-
  project: string;
-
  issue: string;
-
}
-

-
interface ProjectTreeRoute {
-
  resource: "project.source";
-
  node: BaseUrl;
-
  project: string;
-
  path?: string;
-
  peer?: string;
-
  revision?: string;
-
  route?: string;
-
}
-

-
interface ProjectHistoryRoute {
-
  resource: "project.history";
-
  node: BaseUrl;
-
  project: string;
-
  peer?: string;
-
  revision?: string;
-
}
-

-
interface ProjectPatchRoute {
-
  resource: "project.patch";
-
  node: BaseUrl;
-
  project: string;
-
  patch: string;
-
  view?:
-
    | {
-
        name: "activity";
-
      }
-
    | {
-
        name: "changes";
-
        revision?: string;
-
      }
-
    | {
-
        name: "diff";
-
        fromCommit: string;
-
        toCommit: string;
-
      };
-
}
-

-
interface ProjectPatchesRoute {
-
  resource: "project.patches";
-
  node: BaseUrl;
-
  project: string;
-
  search?: string;
-
}
-

-
export type ProjectLoadedRoute =
-
  | {
-
      resource: "project.source";
-
      params: {
-
        baseUrl: BaseUrl;
-
        seedingPolicy: SeedingPolicy;
-
        commit: string;
-
        project: Project;
-
        peers: Remote[];
-
        peer: string | undefined;
-
        revision: string | undefined;
-
        tree: Tree;
-
        path: string;
-
        rawPath: (commit?: string) => string;
-
        blobResult: BlobResult;
-
        nodeAvatarUrl: string | undefined;
-
      };
-
    }
-
  | {
-
      resource: "project.history";
-
      params: {
-
        baseUrl: BaseUrl;
-
        seedingPolicy: SeedingPolicy;
-
        commit: string;
-
        project: Project;
-
        peers: Remote[];
-
        peer: string | undefined;
-
        revision: string | undefined;
-
        tree: Tree;
-
        commitHeaders: CommitHeader[];
-
        nodeAvatarUrl: string | undefined;
-
      };
-
    }
-
  | {
-
      resource: "project.commit";
-
      params: {
-
        baseUrl: BaseUrl;
-
        seedingPolicy: SeedingPolicy;
-
        project: Project;
-
        commit: Commit;
-
        nodeAvatarUrl: string | undefined;
-
      };
-
    }
-
  | {
-
      resource: "project.issue";
-
      params: {
-
        baseUrl: BaseUrl;
-
        seedingPolicy: SeedingPolicy;
-
        project: Project;
-
        rawPath: (commit?: string) => string;
-
        issue: Issue;
-
        nodeAvatarUrl: string | undefined;
-
      };
-
    }
-
  | {
-
      resource: "project.issues";
-
      params: {
-
        baseUrl: BaseUrl;
-
        seedingPolicy: SeedingPolicy;
-
        project: Project;
-
        issues: Issue[];
-
        status: IssueState["status"];
-
        nodeAvatarUrl: string | undefined;
-
      };
-
    }
-
  | {
-
      resource: "project.patches";
-
      params: {
-
        baseUrl: BaseUrl;
-
        seedingPolicy: SeedingPolicy;
-
        project: Project;
-
        patches: Patch[];
-
        status: PatchState["status"];
-
        nodeAvatarUrl: string | undefined;
-
      };
-
    }
-
  | {
-
      resource: "project.patch";
-
      params: {
-
        baseUrl: BaseUrl;
-
        seedingPolicy: SeedingPolicy;
-
        project: Project;
-
        rawPath: (commit?: string) => string;
-
        patch: Patch;
-
        stats: Diff["stats"];
-
        view: PatchView;
-
        nodeAvatarUrl: string | undefined;
-
      };
-
    };
-

-
export type BlobResult =
-
  | { ok: true; blob: Blob; highlighted: Syntax.Root | undefined }
-
  | { ok: false; error: { status?: number; message: string; path: string } };
-

-
export type PatchView =
-
  | {
-
      name: "activity";
-
      revision: string;
-
    }
-
  | {
-
      name: "changes";
-
      revision: string;
-
      oid: string;
-
      diff: Diff;
-
      commits: CommitHeader[];
-
      files: Record<string, CommitBlob>;
-
    }
-
  | {
-
      name: "diff";
-
      diff: Diff;
-
      files: Record<string, DiffBlob>;
-
      fromCommit: string;
-
      toCommit: string;
-
    };
-

-
// Check whether the input is a SHA1 commit.
-
function isOid(input: string): boolean {
-
  return /^[a-fA-F0-9]{40}$/.test(input);
-
}
-

-
export const cachedGetDiff = cached(
-
  async (baseUrl: BaseUrl, rid: string, base: string, oid: string) => {
-
    const api = new HttpdClient(baseUrl);
-
    return await api.project.getDiff(rid, base, oid);
-
  },
-
  (...args) => JSON.stringify(args),
-
  { max: 200 },
-
);
-

-
function parseRevisionToOid(
-
  revision: string | undefined,
-
  defaultBranch: string,
-
  branches: Record<string, string>,
-
): string {
-
  if (revision) {
-
    if (isOid(revision)) {
-
      return revision;
-
    } else {
-
      const oid = branches[revision];
-
      if (oid) {
-
        return oid;
-
      } else {
-
        throw new Error(`Revision ${revision} not found`);
-
      }
-
    }
-
  } else {
-
    return branches[defaultBranch];
-
  }
-
}
-

-
export async function loadProjectRoute(
-
  route: ProjectRoute,
-
  previousLoaded: LoadedRoute,
-
): Promise<ProjectLoadedRoute | ErrorRoute | NotFoundRoute> {
-
  if (
-
    import.meta.env.PROD &&
-
    isLocal(`${route.node.hostname}:${route.node.port}`)
-
  ) {
-
    return {
-
      resource: "error",
-
      params: {
-
        icon: "device",
-
        title: "Local node browsing not supported",
-
        description: `You're trying to access a repository on a local node from your browser, we are currently working on a desktop app specific for this use case. Join our <strong>#desktop</strong> channel on <radicle-external-link href="${config.supportWebsite}">${config.supportWebsite}</radicle-external-link> for more information.`,
-
      },
-
    };
-
  }
-
  const api = new HttpdClient(route.node);
-

-
  try {
-
    if (route.resource === "project.source") {
-
      return await loadTreeView(route, previousLoaded);
-
    } else if (route.resource === "project.history") {
-
      return await loadHistoryView(route, previousLoaded);
-
    } else if (route.resource === "project.commit") {
-
      const [project, commit, seedingPolicy, node] = await Promise.all([
-
        api.project.getById(route.project),
-
        api.project.getCommitBySha(route.project, route.commit),
-
        api.getPolicyById(route.project),
-
        api.getNode(),
-
      ]);
-

-
      return {
-
        resource: "project.commit",
-
        params: {
-
          baseUrl: route.node,
-
          seedingPolicy,
-
          project,
-
          commit,
-
          nodeAvatarUrl: node.avatarUrl,
-
        },
-
      };
-
    } else if (route.resource === "project.issue") {
-
      return await loadIssueView(route);
-
    } else if (route.resource === "project.patch") {
-
      return await loadPatchView(route, previousLoaded);
-
    } else if (route.resource === "project.issues") {
-
      return await loadIssuesView(route);
-
    } else if (route.resource === "project.patches") {
-
      return await loadPatchesView(route);
-
    } else {
-
      return unreachable(route);
-
    }
-
  } catch (error) {
-
    if (
-
      error instanceof Error ||
-
      error instanceof ResponseError ||
-
      error instanceof ResponseParseError
-
    ) {
-
      return handleError(error, route);
-
    } else {
-
      return unreachableError();
-
    }
-
  }
-
}
-

-
async function loadPatchesView(
-
  route: ProjectPatchesRoute,
-
): Promise<ProjectLoadedRoute> {
-
  const api = new HttpdClient(route.node);
-
  const searchParams = new URLSearchParams(route.search || "");
-
  const status = (searchParams.get("status") as PatchState["status"]) || "open";
-

-
  const [project, patches, seedingPolicy, node] = await Promise.all([
-
    api.project.getById(route.project),
-
    api.project.getAllPatches(route.project, {
-
      status,
-
      page: 0,
-
      perPage: PATCHES_PER_PAGE,
-
    }),
-
    api.getPolicyById(route.project),
-
    api.getNode(),
-
  ]);
-

-
  return {
-
    resource: "project.patches",
-
    params: {
-
      baseUrl: route.node,
-
      seedingPolicy,
-
      patches,
-
      status,
-
      project,
-
      nodeAvatarUrl: node.avatarUrl,
-
    },
-
  };
-
}
-

-
async function loadIssuesView(
-
  route: ProjectIssuesRoute,
-
): Promise<ProjectLoadedRoute> {
-
  const api = new HttpdClient(route.node);
-
  const status = route.status || "open";
-

-
  const [project, issues, seedingPolicy, node] = await Promise.all([
-
    api.project.getById(route.project),
-
    api.project.getAllIssues(route.project, {
-
      status,
-
      page: 0,
-
      perPage: ISSUES_PER_PAGE,
-
    }),
-
    api.getPolicyById(route.project),
-
    api.getNode(),
-
  ]);
-

-
  return {
-
    resource: "project.issues",
-
    params: {
-
      baseUrl: route.node,
-
      seedingPolicy,
-
      issues,
-
      status,
-
      project,
-
      nodeAvatarUrl: node.avatarUrl,
-
    },
-
  };
-
}
-

-
async function loadTreeView(
-
  route: ProjectTreeRoute,
-
  previousLoaded: LoadedRoute,
-
): Promise<ProjectLoadedRoute | NotFoundRoute> {
-
  const api = new HttpdClient(route.node);
-
  const rawPath = (commit?: string) =>
-
    `${route.node.scheme}://${route.node.hostname}:${route.node.port}/raw/${
-
      route.project
-
    }${commit ? `/${commit}` : ""}`;
-

-
  let projectPromise: Promise<Project>;
-
  let seedingPolicyPromise: Promise<SeedingPolicy>;
-
  let peersPromise: Promise<Remote[]>;
-
  let nodePromise: Promise<Partial<Node>>;
-
  if (
-
    (previousLoaded.resource === "project.source" ||
-
      previousLoaded.resource === "project.history") &&
-
    previousLoaded.params.project.id === route.project &&
-
    previousLoaded.params.peer === route.peer
-
  ) {
-
    projectPromise = Promise.resolve(previousLoaded.params.project);
-
    peersPromise = Promise.resolve(previousLoaded.params.peers);
-
    seedingPolicyPromise = Promise.resolve(previousLoaded.params.seedingPolicy);
-
    nodePromise = Promise.resolve({
-
      avatarUrl: previousLoaded.params.nodeAvatarUrl,
-
    });
-
  } else {
-
    projectPromise = api.project.getById(route.project);
-
    peersPromise = api.project.getAllRemotes(route.project);
-
    seedingPolicyPromise = api.getPolicyById(route.project);
-
    nodePromise = api.getNode();
-
  }
-

-
  const [project, peers, seedingPolicy, node] = await Promise.all([
-
    projectPromise,
-
    peersPromise,
-
    seedingPolicyPromise,
-
    nodePromise,
-
  ]);
-

-
  let branchMap: Record<string, string> = {
-
    [project.defaultBranch]: project.head,
-
  };
-
  if (route.peer) {
-
    const peer = peers.find(peer => peer.id === route.peer);
-
    if (!peer) {
-
      return {
-
        resource: "notFound",
-
        params: { title: `Peer ${route.peer} could not be found` },
-
      };
-
    } else {
-
      branchMap = peer.heads;
-
    }
-
  }
-

-
  if (route.route) {
-
    const { revision, path } = detectRevision(route.route, branchMap);
-
    route.revision = revision;
-
    route.path = path;
-
  }
-

-
  const commit = parseRevisionToOid(
-
    route.revision,
-
    project.defaultBranch,
-
    branchMap,
-
  );
-
  const path = route.path || "/";
-
  const [tree, blobResult] = await Promise.all([
-
    api.project.getTree(route.project, commit),
-
    loadBlob(api, project.id, commit, path),
-
  ]);
-
  return {
-
    resource: "project.source",
-
    params: {
-
      baseUrl: route.node,
-
      seedingPolicy,
-
      commit,
-
      project,
-
      peers: peers.filter(remote => Object.keys(remote.heads).length > 0),
-
      peer: route.peer,
-
      rawPath,
-
      revision: route.revision,
-
      tree,
-
      path,
-
      blobResult,
-
      nodeAvatarUrl: node.avatarUrl,
-
    },
-
  };
-
}
-

-
async function loadBlob(
-
  api: HttpdClient,
-
  project: string,
-
  commit: string,
-
  path: string,
-
): Promise<BlobResult> {
-
  try {
-
    let blob: Blob;
-
    if (path === "" || path === "/") {
-
      blob = await api.project.getReadme(project, commit);
-
    } else {
-
      blob = await api.project.getBlob(project, commit, path);
-
    }
-
    return {
-
      ok: true,
-
      blob,
-
      highlighted: blob.content
-
        ? await Syntax.highlight(blob.content, blob.path.split(".").pop() ?? "")
-
        : undefined,
-
    };
-
  } catch (e: unknown) {
-
    if (e instanceof ResponseError) {
-
      return {
-
        ok: false,
-
        error: {
-
          status: e.status,
-
          message: "Not able to load file",
-
          path,
-
        },
-
      };
-
    } else if (path === "/") {
-
      return {
-
        ok: false,
-
        error: {
-
          message: "The README could not be loaded",
-
          path,
-
        },
-
      };
-
    } else {
-
      return {
-
        ok: false,
-
        error: {
-
          message: "Not able to load file",
-
          path,
-
        },
-
      };
-
    }
-
  }
-
}
-
async function loadHistoryView(
-
  route: ProjectHistoryRoute,
-
  previousLoaded: LoadedRoute,
-
): Promise<ProjectLoadedRoute> {
-
  const api = new HttpdClient(route.node);
-

-
  let projectPromise: Promise<Project>;
-
  let seedingPolicyPromise: Promise<SeedingPolicy>;
-
  let peersPromise: Promise<Remote[]>;
-
  let nodePromise: Promise<Partial<Node>>;
-
  if (
-
    (previousLoaded.resource === "project.source" ||
-
      previousLoaded.resource === "project.history") &&
-
    previousLoaded.params.project.id === route.project &&
-
    previousLoaded.params.peer === route.peer
-
  ) {
-
    projectPromise = Promise.resolve(previousLoaded.params.project);
-
    peersPromise = Promise.resolve(previousLoaded.params.peers);
-
    seedingPolicyPromise = Promise.resolve(previousLoaded.params.seedingPolicy);
-
    nodePromise = Promise.resolve({
-
      avatarUrl: previousLoaded.params.nodeAvatarUrl,
-
    });
-
  } else {
-
    projectPromise = api.project.getById(route.project);
-
    peersPromise = api.project.getAllRemotes(route.project);
-
    seedingPolicyPromise = api.getPolicyById(route.project);
-
    nodePromise = api.getNode();
-
  }
-

-
  const [project, peers, seedingPolicy, branchMap, node] = await Promise.all([
-
    projectPromise,
-
    peersPromise,
-
    seedingPolicyPromise,
-
    getPeerBranches(api, route.project, route.peer),
-
    nodePromise,
-
  ]);
-

-
  let commitId;
-
  if (route.revision && isOid(route.revision)) {
-
    commitId = route.revision;
-
  } else if (branchMap) {
-
    commitId = branchMap[route.revision || project.defaultBranch];
-
  } else if (!route.revision) {
-
    commitId = project.head;
-
  }
-

-
  if (!commitId) {
-
    throw new Error(
-
      `Revision ${route.revision} not found for project ${project.id}`,
-
    );
-
  }
-

-
  let treePromise: Promise<Tree>;
-

-
  if (
-
    (previousLoaded.resource === "project.source" ||
-
      previousLoaded.resource === "project.history") &&
-
    previousLoaded.params.project.id === route.project &&
-
    previousLoaded.params.commit === commitId
-
  ) {
-
    treePromise = Promise.resolve(previousLoaded.params.tree);
-
  } else {
-
    treePromise = api.project.getTree(route.project, commitId);
-
  }
-

-
  const [tree, commitHeaders] = await Promise.all([
-
    treePromise,
-
    api.project.getAllCommits(project.id, {
-
      parent: commitId,
-
      page: 0,
-
      perPage: config.source.commitsPerPage,
-
    }),
-
  ]);
-

-
  return {
-
    resource: "project.history",
-
    params: {
-
      baseUrl: route.node,
-
      seedingPolicy,
-
      commit: commitId,
-
      project,
-
      peers: peers.filter(remote => Object.keys(remote.heads).length > 0),
-
      peer: route.peer,
-
      revision: route.revision,
-
      tree,
-
      commitHeaders,
-
      nodeAvatarUrl: node.avatarUrl,
-
    },
-
  };
-
}
-

-
async function loadIssueView(
-
  route: ProjectIssueRoute,
-
): Promise<ProjectLoadedRoute> {
-
  const api = new HttpdClient(route.node);
-
  const rawPath = (commit?: string) =>
-
    `${route.node.scheme}://${route.node.hostname}:${route.node.port}/raw/${
-
      route.project
-
    }${commit ? `/${commit}` : ""}`;
-

-
  const [project, issue, seedingPolicy, node] = await Promise.all([
-
    api.project.getById(route.project),
-
    api.project.getIssueById(route.project, route.issue),
-
    api.getPolicyById(route.project),
-
    api.getNode(),
-
  ]);
-
  return {
-
    resource: "project.issue",
-
    params: {
-
      baseUrl: route.node,
-
      seedingPolicy,
-
      project,
-
      rawPath,
-
      issue,
-
      nodeAvatarUrl: node.avatarUrl,
-
    },
-
  };
-
}
-

-
async function loadPatchView(
-
  route: ProjectPatchRoute,
-
  previousLoaded: LoadedRoute,
-
): Promise<ProjectLoadedRoute> {
-
  const api = new HttpdClient(route.node);
-
  const rawPath = (commit?: string) =>
-
    `${route.node.scheme}://${route.node.hostname}:${route.node.port}/raw/${
-
      route.project
-
    }${commit ? `/${commit}` : ""}`;
-

-
  let projectPromise: Promise<Project>;
-
  let patchPromise: Promise<Patch>;
-
  let nodePromise: Promise<Partial<Node>>;
-
  let seedingPolicyPromise: Promise<SeedingPolicy>;
-

-
  if (
-
    previousLoaded.resource === "project.patch" &&
-
    previousLoaded.params.project.id === route.project &&
-
    previousLoaded.params.patch.id === route.patch
-
  ) {
-
    projectPromise = Promise.resolve(previousLoaded.params.project);
-
    patchPromise = Promise.resolve(previousLoaded.params.patch);
-
    seedingPolicyPromise = Promise.resolve(previousLoaded.params.seedingPolicy);
-
    nodePromise = Promise.resolve({
-
      avatarUrl: previousLoaded.params.nodeAvatarUrl,
-
    });
-
  } else {
-
    projectPromise = api.project.getById(route.project);
-
    patchPromise = api.project.getPatchById(route.project, route.patch);
-
    seedingPolicyPromise = api.getPolicyById(route.project);
-
    nodePromise = api.getNode();
-
  }
-
  const [project, patch, seedingPolicy, { avatarUrl }] = await Promise.all([
-
    projectPromise,
-
    patchPromise,
-
    seedingPolicyPromise,
-
    nodePromise,
-
  ]);
-

-
  const latestRevision = patch.revisions.at(-1) as Revision;
-
  const {
-
    diff: { stats },
-
  } = await cachedGetDiff(
-
    api.baseUrl,
-
    route.project,
-
    latestRevision.base,
-
    latestRevision.oid,
-
  );
-

-
  let view: PatchView;
-
  switch (route.view?.name) {
-
    case "activity":
-
    case undefined: {
-
      view = { name: "activity", revision: latestRevision.id };
-
      break;
-
    }
-
    case "changes": {
-
      const revisionId = route.view.revision;
-
      const revision =
-
        patch.revisions.find(r => r.id === revisionId) || latestRevision;
-
      if (!revision) {
-
        throw new Error(
-
          `revision ${revisionId} of patch ${route.patch} not found`,
-
        );
-
      }
-
      const { diff, commits, files } = await cachedGetDiff(
-
        api.baseUrl,
-
        route.project,
-
        revision.base,
-
        revision.oid,
-
      );
-
      view = {
-
        name: route.view?.name,
-
        revision: revision.id,
-
        oid: revision.oid,
-
        diff,
-
        commits,
-
        files,
-
      };
-
      break;
-
    }
-
    case "diff": {
-
      const { fromCommit, toCommit } = route.view;
-
      const { diff, files } = await cachedGetDiff(
-
        api.baseUrl,
-
        route.project,
-
        fromCommit,
-
        toCommit,
-
      );
-

-
      view = { name: "diff", fromCommit, toCommit, files, diff };
-
      break;
-
    }
-
  }
-
  return {
-
    resource: "project.patch",
-
    params: {
-
      baseUrl: route.node,
-
      seedingPolicy,
-
      project,
-
      rawPath,
-
      patch,
-
      stats,
-
      view,
-
      nodeAvatarUrl: avatarUrl,
-
    },
-
  };
-
}
-

-
async function getPeerBranches(
-
  api: HttpdClient,
-
  project: string,
-
  peer?: string,
-
) {
-
  if (peer) {
-
    return (await api.project.getRemoteByPeer(project, peer)).heads;
-
  } else {
-
    return undefined;
-
  }
-
}
-

-
// Detects branch names and commit IDs at the start of `input` and extract it.
-
function detectRevision(
-
  input: string,
-
  branches: Record<string, string>,
-
): { path: string; revision?: string } {
-
  const commitPath = [input.slice(0, 40), input.slice(41)];
-
  const branch = Object.entries(branches).find(([branchName]) =>
-
    input.startsWith(branchName),
-
  );
-

-
  if (branch) {
-
    const [revision, path] = [
-
      input.slice(0, branch[0].length),
-
      input.slice(branch[0].length + 1),
-
    ];
-
    return {
-
      revision,
-
      path: path || "/",
-
    };
-
  } else if (isOid(commitPath[0])) {
-
    return {
-
      revision: commitPath[0],
-
      path: commitPath[1] || "/",
-
    };
-
  } else {
-
    return { path: input };
-
  }
-
}
-

-
function sanitizeQueryString(queryString: string): string {
-
  return queryString.startsWith("?") ? queryString.substring(1) : queryString;
-
}
-

-
export function resolveProjectRoute(
-
  node: BaseUrl,
-
  project: string,
-
  segments: string[],
-
  urlSearch: string,
-
): ProjectRoute | null {
-
  let content = segments.shift();
-
  let peer;
-
  if (content === "remotes") {
-
    peer = segments.shift();
-
    content = segments.shift();
-
  }
-

-
  if (!content || content === "tree") {
-
    return {
-
      resource: "project.source",
-
      node,
-
      project,
-
      peer,
-
      path: undefined,
-
      revision: undefined,
-
      route: segments.join("/"),
-
    };
-
  } else if (content === "history") {
-
    return {
-
      resource: "project.history",
-
      node,
-
      project,
-
      peer,
-
      revision: segments.join("/"),
-
    };
-
  } else if (content === "commits") {
-
    return {
-
      resource: "project.commit",
-
      node,
-
      project,
-
      commit: segments[0],
-
    };
-
  } else if (content === "issues") {
-
    const issueOrAction = segments.shift();
-
    if (issueOrAction) {
-
      return {
-
        resource: "project.issue",
-
        node,
-
        project,
-
        issue: issueOrAction,
-
      };
-
    } else {
-
      const rawStatus = new URLSearchParams(sanitizeQueryString(urlSearch)).get(
-
        "status",
-
      );
-
      let status: "open" | "closed" | undefined;
-
      if (rawStatus === "open" || rawStatus === "closed") {
-
        status = rawStatus;
-
      }
-
      return {
-
        resource: "project.issues",
-
        node,
-
        project,
-
        status,
-
      };
-
    }
-
  } else if (content === "patches") {
-
    return resolvePatchesRoute(node, project, segments, urlSearch);
-
  } else {
-
    return null;
-
  }
-
}
-

-
function resolvePatchesRoute(
-
  node: BaseUrl,
-
  project: string,
-
  segments: string[],
-
  urlSearch: string,
-
): ProjectPatchRoute | ProjectPatchesRoute {
-
  const patch = segments.shift();
-
  const revision = segments.shift();
-
  if (patch) {
-
    const searchParams = new URLSearchParams(sanitizeQueryString(urlSearch));
-
    const tab = searchParams.get("tab");
-
    const base = {
-
      resource: "project.patch",
-
      node,
-
      project,
-
      patch,
-
    } as const;
-
    const diff = searchParams.get("diff");
-
    if (diff) {
-
      const [fromCommit, toCommit] = diff.split("..");
-
      if (isOid(fromCommit) && isOid(toCommit)) {
-
        return {
-
          ...base,
-
          view: { name: "diff", fromCommit, toCommit },
-
        };
-
      }
-
    }
-

-
    if (tab === "changes") {
-
      return {
-
        ...base,
-
        view: { name: tab, revision },
-
      };
-
    } else if (tab === "activity") {
-
      return {
-
        ...base,
-
        view: { name: tab },
-
      };
-
    } else {
-
      return base;
-
    }
-
  } else {
-
    return {
-
      resource: "project.patches",
-
      node,
-
      project,
-
      search: sanitizeQueryString(urlSearch),
-
    };
-
  }
-
}
-

-
export function projectRouteToPath(route: ProjectRoute): string {
-
  const node = nodePath(route.node);
-

-
  const pathSegments = [node, route.project];
-

-
  if (route.resource === "project.source") {
-
    if (route.peer) {
-
      pathSegments.push("remotes", route.peer);
-
    }
-

-
    pathSegments.push("tree");
-
    let omitTree = true;
-

-
    if (route.route && route.route !== "/") {
-
      pathSegments.push(route.route);
-
      omitTree = false;
-
    } else {
-
      if (route.revision) {
-
        pathSegments.push(route.revision);
-
        omitTree = false;
-
      }
-

-
      if (route.path && route.path !== "/") {
-
        pathSegments.push(route.path);
-
        omitTree = false;
-
      }
-
    }
-
    if (omitTree) {
-
      pathSegments.pop();
-
    }
-

-
    return pathSegments.join("/");
-
  } else if (route.resource === "project.history") {
-
    if (route.peer) {
-
      pathSegments.push("remotes", route.peer);
-
    }
-

-
    pathSegments.push("history");
-

-
    if (route.revision) {
-
      pathSegments.push(route.revision);
-
    }
-
    return pathSegments.join("/");
-
  } else if (route.resource === "project.commit") {
-
    return [...pathSegments, "commits", route.commit].join("/");
-
  } else if (route.resource === "project.issues") {
-
    let url = [...pathSegments, "issues"].join("/");
-
    const searchParams = new URLSearchParams();
-
    if (route.status) {
-
      searchParams.set("status", route.status);
-
    }
-
    if (searchParams.size > 0) {
-
      url += `?${searchParams}`;
-
    }
-
    return url;
-
  } else if (route.resource === "project.issue") {
-
    return [...pathSegments, "issues", route.issue].join("/");
-
  } else if (route.resource === "project.patches") {
-
    let url = [...pathSegments, "patches"].join("/");
-
    if (route.search) {
-
      url += `?${route.search}`;
-
    }
-
    return url;
-
  } else if (route.resource === "project.patch") {
-
    return patchRouteToPath(route);
-
  } else {
-
    return unreachable(route);
-
  }
-
}
-

-
function patchRouteToPath(route: ProjectPatchRoute): string {
-
  const node = nodePath(route.node);
-

-
  const pathSegments = [node, route.project];
-

-
  pathSegments.push("patches", route.patch);
-
  if (route.view?.name === "changes") {
-
    if (route.view.revision) {
-
      pathSegments.push(route.view.revision);
-
    }
-
  }
-

-
  let url = pathSegments.join("/");
-
  if (!route.view) {
-
    return url;
-
  } else {
-
    const searchParams = new URLSearchParams();
-

-
    if (route.view.name === "diff") {
-
      searchParams.set(
-
        "diff",
-
        `${route.view.fromCommit}..${route.view.toCommit}`,
-
      );
-
    } else {
-
      searchParams.set("tab", route.view.name);
-
    }
-
    url += `?${searchParams.toString()}`;
-
    return url;
-
  }
-
}
-

-
export function projectTitle(loadedRoute: ProjectLoadedRoute) {
-
  const title: string[] = [];
-

-
  if (loadedRoute.resource === "project.source") {
-
    title.push(loadedRoute.params.project.name);
-
    if (loadedRoute.params.project.description.length > 0) {
-
      title.push(loadedRoute.params.project.description);
-
    }
-
  } else if (loadedRoute.resource === "project.commit") {
-
    title.push(loadedRoute.params.commit.commit.summary);
-
    title.push("commit");
-
  } else if (loadedRoute.resource === "project.history") {
-
    title.push(loadedRoute.params.project.name);
-
    title.push("history");
-
  } else if (loadedRoute.resource === "project.issue") {
-
    title.push(loadedRoute.params.issue.title);
-
    title.push("issue");
-
  } else if (loadedRoute.resource === "project.issues") {
-
    title.push(loadedRoute.params.project.name);
-
    title.push("issues");
-
  } else if (loadedRoute.resource === "project.patch") {
-
    title.push(loadedRoute.params.patch.title);
-
    title.push("patch");
-
  } else if (loadedRoute.resource === "project.patches") {
-
    title.push(loadedRoute.params.project.name);
-
    title.push("patches");
-
  } else {
-
    return unreachable(loadedRoute);
-
  }
-

-
  return title;
-
}
-

-
export const testExports = { isOid };
added src/views/repos/Changeset.svelte
@@ -0,0 +1,130 @@
+
<script lang="ts">
+
  import type { BaseUrl, CommitBlob, Diff } from "@http-client";
+

+
  import FileDiff from "@app/views/repos/Changeset/FileDiff.svelte";
+
  import FileLocationChange from "@app/views/repos/Changeset/FileLocationChange.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Observer, { intersection } from "@app/components/Observer.svelte";
+

+
  export let diff: Diff;
+
  export let files: Record<string, CommitBlob>;
+
  export let baseUrl: BaseUrl;
+
  export let repoId: string;
+
  export let revision: string;
+

+
  let expanded = true;
+

+
  function pluralize(singular: string, count: number): string {
+
    return count === 1 ? singular : `${singular}s`;
+
  }
+

+
  const diffDescription = (diffFiles: Diff["files"]): string =>
+
    Object.entries(
+
      diffFiles.reduce(
+
        (acc, { state }) => {
+
          acc[state]++;
+
          return acc;
+
        },
+
        { added: 0, modified: 0, deleted: 0, copied: 0, moved: 0 },
+
      ),
+
    )
+
      .filter(([, count]) => count > 0)
+
      .map(([state, count]) => `${count} ${pluralize("file", count)} ${state}`)
+
      .join(", ");
+
</script>
+

+
<style>
+
  .header {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
    justify-content: space-between;
+
    padding: 1rem 1rem 0.5rem 1rem;
+
    background-color: var(--color-background-default);
+
  }
+
  .additions {
+
    color: var(--color-foreground-success);
+
    white-space: nowrap;
+
  }
+
  .deletions {
+
    color: var(--color-foreground-red);
+
    white-space: nowrap;
+
  }
+
  .diff-list {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 1.5rem;
+
    background-color: var(--color-background-default);
+
    padding: 1rem;
+
  }
+
  .summary {
+
    font-size: var(--font-size-small);
+
  }
+
  @media (max-width: 719.98px) {
+
    .diff-list {
+
      padding: 1rem 0;
+
    }
+
  }
+
</style>
+

+
<div class="header">
+
  <div class="summary">
+
    <span>{diffDescription(diff.files)}</span>
+
    with
+
    <span class:additions={diff.stats.insertions > 0}>
+
      {diff.stats.insertions}
+
      {pluralize("insertion", diff.stats.insertions)}
+
    </span>
+
    and
+
    <span class:deletions={diff.stats.deletions > 0}>
+
      {diff.stats.deletions}
+
      {pluralize("deletion", diff.stats.deletions)}
+
    </span>
+
  </div>
+
  {#if diff.stats.filesChanged > 1}
+
    <IconButton on:click={() => (expanded = !expanded)}>
+
      {#if expanded === true}
+
        <Icon name="collapse" />
+
        <span class="global-hide-on-mobile-down">Collapse all</span>
+
      {:else}
+
        <Icon name="expand" />
+
        <span class="global-hide-on-mobile-down">Expand all</span>
+
      {/if}
+
    </IconButton>
+
  {/if}
+
</div>
+

+
<div class="diff-list">
+
  <Observer let:filesVisibility let:observer>
+
    {#each diff.files as file}
+
      {@const path = "path" in file ? file.path : file.newPath}
+
      <div use:intersection={observer} id={"observer:" + path}>
+
        {#if "diff" in file}
+
          <FileDiff
+
            {repoId}
+
            {baseUrl}
+
            {revision}
+
            {expanded}
+
            filePath={path}
+
            oldFilePath={"oldPath" in file ? file.oldPath : undefined}
+
            fileDiff={file.diff}
+
            headerBadgeCaption={file.state}
+
            content={"new" in file ? files[file.new.oid]?.content : undefined}
+
            oldContent={"old" in file
+
              ? files[file.old.oid]?.content
+
              : undefined}
+
            visible={filesVisibility.has(path)} />
+
        {:else}
+
          <FileLocationChange
+
            headerBadgeCaption={file.state}
+
            oldPath={file.oldPath}
+
            newPath={file.newPath}
+
            {repoId}
+
            {baseUrl}
+
            {revision} />
+
        {/if}
+
      </div>
+
    {/each}
+
  </Observer>
+
</div>
added src/views/repos/Changeset/FileDiff.svelte
@@ -0,0 +1,538 @@
+
<script lang="ts">
+
  import type {
+
    BaseUrl,
+
    ChangesetWithDiff,
+
    DiffContent,
+
    HunkLine,
+
  } from "@http-client";
+

+
  import { onDestroy, onMount } from "svelte";
+
  import { toHtml } from "hast-util-to-html";
+

+
  import * as Syntax from "@app/lib/syntax";
+
  import { isImagePath, isSvgPath } from "@app/lib/utils";
+

+
  import Badge from "@app/components/Badge.svelte";
+
  import File from "@app/components/File.svelte";
+
  import FilePath from "@app/components/FilePath.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import Loading from "@app/components/Loading.svelte";
+
  import Placeholder from "@app/components/Placeholder.svelte";
+
  import Radio from "@app/components/Radio.svelte";
+
  import Button from "@app/components/Button.svelte";
+

+
  export let filePath: string;
+
  export let oldContent: string | undefined = undefined;
+
  export let content: string | undefined = undefined;
+
  export let oldFilePath: string | undefined = undefined;
+
  export let fileDiff: DiffContent;
+
  export let headerBadgeCaption: ChangesetWithDiff["state"];
+
  export let revision: string | undefined = undefined;
+
  export let baseUrl: BaseUrl;
+
  export let repoId: string;
+
  export let visible: boolean = false;
+
  export let expanded: boolean = true;
+

+
  let selection: Selection | undefined = undefined;
+
  let highlighting: { new?: string[]; old?: string[] } | undefined = undefined;
+
  let syntaxHighlightingLoading: boolean = false;
+
  let preview = false;
+
  $: extension = filePath.split(".").pop();
+

+
  onMount(() => {
+
    window.addEventListener("click", deselectHandler);
+
    window.addEventListener("hashchange", updateSelection);
+

+
    updateSelection();
+

+
    if (selection) {
+
      document
+
        .getElementById(
+
          [filePath, "H" + selection.startHunk, "L" + selection.startLine].join(
+
            "-",
+
          ),
+
        )
+
        ?.scrollIntoView({ block: "center" });
+
    }
+
  });
+

+
  $: if (visible) {
+
    syntaxHighlightingLoading = true;
+
    void highlightContent().then(output => {
+
      highlighting = output;
+
      syntaxHighlightingLoading = false;
+
    });
+
  }
+

+
  onDestroy(() => {
+
    window.removeEventListener("click", deselectHandler);
+
    window.removeEventListener("hashchange", updateSelection);
+
  });
+

+
  function deselectHandler(e: MouseEvent) {
+
    if (
+
      !(
+
        e.target instanceof HTMLElement &&
+
        e.target.closest("[data-file-diff-select]")
+
      )
+
    ) {
+
      updateHash("");
+
    }
+
  }
+

+
  async function highlightContent() {
+
    const extension = filePath.split(".").pop();
+
    const highlighted: { new?: string[]; old?: string[] } = {};
+
    if (extension) {
+
      if (content) {
+
        highlighted["new"] = toHtml(
+
          await Syntax.highlight(content, extension),
+
        ).split("\n");
+
      }
+
      if (oldContent) {
+
        highlighted["old"] = toHtml(
+
          await Syntax.highlight(oldContent, extension),
+
        ).split("\n");
+
      }
+
    }
+
    return Object.entries(highlighted).length > 0 ? highlighted : undefined;
+
  }
+

+
  function updateSelection() {
+
    const fragment = window.location.hash.substring(1);
+
    const match = fragment.match(/(.+):H(\d+)L(\d+)(H(\d+)L(\d+))?/);
+
    if (match && match[1] === filePath) {
+
      selection = {
+
        startHunk: parseInt(match[2]),
+
        startLine: parseInt(match[3]),
+
        endHunk: match[4] ? parseInt(match[5]) : undefined,
+
        endLine: match[4] ? parseInt(match[6]) : undefined,
+
      };
+
    } else {
+
      selection = undefined;
+
    }
+
  }
+

+
  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 "-";
+
      }
+
    }
+
  }
+

+
  function isLineSelected(
+
    selection: Selection | undefined,
+
    hunkIdx: number,
+
    lineIdx: number,
+
  ): boolean {
+
    if (!selection) {
+
      return false;
+
    }
+

+
    if (selection.endHunk !== undefined && selection.endLine !== undefined) {
+
      return (
+
        hunkIdx >= selection.startHunk &&
+
        hunkIdx <= selection.endHunk &&
+
        (hunkIdx === selection.startHunk
+
          ? lineIdx >= selection.startLine
+
          : true) &&
+
        (hunkIdx === selection.endHunk ? lineIdx <= selection.endLine : true)
+
      );
+
    } else {
+
      return hunkIdx === selection.startHunk && lineIdx === selection.startLine;
+
    }
+
  }
+

+
  function hashFromSelection(
+
    hunkIdx: number,
+
    lineIdx: number,
+
    event: MouseEvent,
+
  ): string {
+
    const path = filePath;
+
    // single line selection
+
    if (!event.shiftKey) {
+
      return path + ":H" + hunkIdx + "L" + lineIdx;
+
    }
+

+
    if (!selection) {
+
      return "";
+
    }
+

+
    // range selection
+
    if (hunkIdx === selection.startHunk) {
+
      if (lineIdx >= selection.startLine) {
+
        return `${path}:H${hunkIdx}L${selection.startLine}H${hunkIdx}L${lineIdx}`;
+
      } else {
+
        return `${path}:H${hunkIdx}L${lineIdx}H${hunkIdx}L${selection.startLine}`;
+
      }
+
    } else if (hunkIdx < selection.startHunk) {
+
      return `${path}:H${hunkIdx}L${lineIdx}H${selection.startHunk}L${selection.startLine}`;
+
    } else {
+
      return `${path}:H${selection.startHunk}L${selection.startLine}H${hunkIdx}L${lineIdx}`;
+
    }
+
  }
+

+
  function selectLine(hunkIdx: number, lineIdx: number, event: MouseEvent) {
+
    updateHash(hashFromSelection(hunkIdx, lineIdx, event));
+
  }
+

+
  function updateHash(newHash: string) {
+
    if (newHash !== "") {
+
      window.location.hash = newHash;
+
    } else {
+
      window.history.replaceState(
+
        window.history.state,
+
        "",
+
        window.location.pathname + window.location.search,
+
      );
+
      selection = undefined;
+
    }
+
  }
+

+
  function hunkHeaderSelected(selection: Selection | undefined, hunk: number) {
+
    return (
+
      selection &&
+
      selection.endHunk !== undefined &&
+
      hunk > selection.startHunk &&
+
      hunk <= selection.endHunk
+
    );
+
  }
+

+
  interface Selection {
+
    startHunk: number;
+
    startLine: number;
+
    endHunk: number | undefined;
+
    endLine: number | undefined;
+
  }
+
</script>
+

+
<style>
+
  .container {
+
    font-size: var(--font-size-small);
+
    background: var(--color-background-float);
+
    border-radius: 0 0 var(--border-radius-small) var(--border-radius-small);
+
    overflow-x: auto;
+
  }
+
  .actions {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
    gap: 1rem;
+
  }
+
  .browse {
+
    margin-left: auto;
+
  }
+
  .expand-button {
+
    cursor: pointer;
+
    user-select: none;
+
    margin-right: 0.5rem;
+
  }
+
  .diff {
+
    font-family: var(--font-family-monospace);
+
    table-layout: fixed;
+
    border-collapse: collapse;
+
    margin: 0.5rem 0;
+
  }
+
  .diff-line {
+
    vertical-align: top;
+
  }
+
  .diff-line.type-addition > * {
+
    background-color: var(--color-fill-diff-green-light);
+
  }
+
  .diff-line.type-deletion > * {
+
    background-color: var(--color-fill-diff-red-light);
+
  }
+

+
  .diff-line.selected > * {
+
    background-color: var(--color-fill-float-hover);
+
  }
+
  .diff-line.selected.type-addition > * {
+
    background-color: var(--color-fill-diff-green);
+
  }
+
  .diff-line.selected.type-deletion > * {
+
    background-color: var(--color-fill-diff-red);
+
  }
+

+
  .type-addition > .diff-line-number,
+
  .type-addition > .diff-line-type {
+
    color: var(--color-foreground-success);
+
  }
+
  .type-deletion > .diff-line-number,
+
  .type-deletion > .diff-line-type {
+
    color: var(--color-foreground-red);
+
  }
+

+
  .diff-line.selected .selection-indicator-left {
+
    background-color: var(--color-fill-secondary);
+
  }
+
  .type-addition.diff-line.selected .selection-indicator-left {
+
    background-color: var(--color-fill-secondary);
+
  }
+
  .type-deletion.diff-line.selected .selection-indicator-left {
+
    background-color: var(--color-fill-secondary);
+
  }
+

+
  .diff-line.selected .selection-indicator-right {
+
    background-color: var(--color-fill-secondary);
+
  }
+
  .type-addition.diff-line.selected .selection-indicator-right {
+
    background-color: var(--color-fill-secondary);
+
  }
+
  .type-deletion.diff-line.selected .selection-indicator-right {
+
    background-color: var(--color-fill-secondary);
+
  }
+

+
  .selection-start {
+
    box-shadow: 0 -1px 0 0 var(--color-fill-secondary);
+
    z-index: 1;
+
  }
+
  .selection-end {
+
    box-shadow: 0 1px 0 0 var(--color-fill-secondary);
+
    z-index: 1;
+
  }
+

+
  .selection-start.selection-end {
+
    box-shadow: 0 0 0 1px var(--color-fill-secondary);
+
    z-index: 1;
+
  }
+

+
  .diff-line-number {
+
    font-family: var(--font-family-monospace);
+
    text-align: right;
+
    user-select: none;
+
    line-height: 1.5rem;
+
    min-width: 3rem;
+
    cursor: pointer;
+
    color: var(--color-foreground-disabled);
+
  }
+
  .diff-line-number.left {
+
    position: relative;
+
    padding: 0 0.5rem 0 0.75rem;
+
  }
+
  .selection-indicator-left {
+
    position: absolute;
+
    left: 0;
+
    top: 0;
+
    bottom: 0;
+
    width: 1px;
+
  }
+
  .selection-indicator-right {
+
    position: absolute;
+
    right: 0;
+
    top: 0;
+
    bottom: 0;
+
    width: 1px;
+
  }
+
  .diff-line-number.right {
+
    padding: 0 0.75rem 0 0.5rem;
+
  }
+
  .diff-line-content {
+
    color: unset !important;
+
    white-space: pre-wrap;
+
    overflow-wrap: anywhere;
+
    width: 100%;
+
    padding-right: 0.5rem;
+
  }
+
  .diff-line-type {
+
    text-align: center;
+
    padding-left: 0.75rem;
+
    padding-right: 0.75rem;
+
    user-select: none;
+
  }
+
  .diff-expand-header {
+
    padding-left: 0.5rem;
+
    color: var(--color-foreground-dim);
+
  }
+
</style>
+

+
<File collapsable {expanded}>
+
  <svelte:fragment slot="left-header">
+
    {#if (headerBadgeCaption === "moved" || headerBadgeCaption === "copied") && oldFilePath}
+
      <span style="display: flex; align-items: center; flex-wrap: wrap;">
+
        <FilePath filenameWithPath={oldFilePath} />
+
        <span style:padding="0 0.5rem">→</span>
+
        <FilePath filenameWithPath={filePath} />
+
      </span>
+
    {:else}
+
      <FilePath filenameWithPath={filePath} />
+
    {/if}
+

+
    {#if headerBadgeCaption === "added"}
+
      <Badge variant="positive">added</Badge>
+
    {:else if headerBadgeCaption === "deleted"}
+
      <Badge variant="negative">deleted</Badge>
+
    {:else if headerBadgeCaption === "moved"}
+
      <Badge variant="foreground">moved</Badge>
+
    {:else if headerBadgeCaption === "copied"}
+
      <Badge variant="foreground">copied</Badge>
+
    {/if}
+
  </svelte:fragment>
+

+
  <svelte:fragment slot="right-header" let:expanded>
+
    {#if revision}
+
      {#if syntaxHighlightingLoading}
+
        <Loading small />
+
      {/if}
+
      <div style:display="flex" style:align-items="center" style:gap="0.5rem">
+
        {#if isSvgPath(filePath) && expanded}
+
          <Radio ariaLabel="Toggle render method">
+
            <Button
+
              styleBorderRadius="0"
+
              variant={!preview ? "selected" : "not-selected"}
+
              on:click={() => {
+
                preview = false;
+
              }}>
+
              <Icon name="chevron-left-right" />Code
+
            </Button>
+
            <Button
+
              styleBorderRadius="0"
+
              variant={preview ? "selected" : "not-selected"}
+
              on:click={() => {
+
                window.location.hash = "";
+
                preview = true;
+
              }}>
+
              <Icon name="eye-open" />Preview
+
            </Button>
+
          </Radio>
+
        {/if}
+
        <Link
+
          route={{
+
            resource: "repo.source",
+
            repo: repoId,
+
            node: baseUrl,
+
            path: filePath,
+
            revision,
+
          }}>
+
          <IconButton title="View file at this commit">
+
            <Icon name="chevron-left-right" />
+
          </IconButton>
+
        </Link>
+
      </div>
+
    {/if}
+
  </svelte:fragment>
+

+
  <div class="container">
+
    {#if fileDiff.type === "plain"}
+
      {#if fileDiff.hunks.length > 0 && !preview}
+
        <table class="diff" data-file-diff-select>
+
          {#each fileDiff.hunks as hunk, hunkIdx}
+
            <tr
+
              class="diff-line hunk-header"
+
              class:selected={hunkHeaderSelected(selection, hunkIdx)}>
+
              <td colspan={2} style:position="relative">
+
                <div class="selection-indicator-left" />
+
              </td>
+
              <td
+
                colspan={6}
+
                class="diff-expand-header"
+
                style:position="relative">
+
                {hunk.header}
+
                <div class="selection-indicator-right" />
+
              </td>
+
            </tr>
+
            {#each hunk.lines as line, lineIdx}
+
              <tr
+
                style:position="relative"
+
                class={`diff-line type-${line.type}`}
+
                class:selection-start={selection?.startHunk === hunkIdx &&
+
                  selection.startLine === lineIdx}
+
                class:selection-end={(selection?.endHunk === hunkIdx &&
+
                  selection.endLine === lineIdx) ||
+
                  (selection?.startHunk === hunkIdx &&
+
                    selection.startLine === lineIdx &&
+
                    selection?.endHunk === undefined)}
+
                class:selected={isLineSelected(selection, hunkIdx, lineIdx)}>
+
                <td
+
                  id={[filePath, "H" + hunkIdx, "L" + lineIdx].join("-")}
+
                  class="diff-line-number left"
+
                  on:click={e => selectLine(hunkIdx, lineIdx, e)}>
+
                  <div class="selection-indicator-left" />
+
                  {lineNumberL(line)}
+
                </td>
+
                <td
+
                  class="diff-line-number right"
+
                  on:click={e => selectLine(hunkIdx, lineIdx, e)}>
+
                  {lineNumberR(line)}
+
                </td>
+
                <td class="diff-line-type" data-line-type={line.type}>
+
                  {lineSign(line)}
+
                </td>
+
                <td class="diff-line-content">
+
                  {#if highlighting}
+
                    {#if line.type === "addition" && highlighting.new}
+
                      {@html highlighting.new[line.lineNo - 1]}
+
                    {:else if line.type === "context" && highlighting.new}
+
                      {@html highlighting.new[line.lineNoNew - 1]}
+
                    {:else if line.type === "deletion" && highlighting.old}
+
                      {@html highlighting.old[line.lineNo - 1]}
+
                    {/if}
+
                  {:else}
+
                    {line.line}
+
                  {/if}
+
                </td>
+
                <div class="selection-indicator-right" />
+
              </tr>
+
            {/each}
+
          {/each}
+
        </table>
+
      {:else if isImagePath(filePath) && extension && content}
+
        <div style:margin="1rem 0" style:text-align="center">
+
          <img
+
            src={`data:image/${extension};base64,${content}`}
+
            alt={filePath} />
+
        </div>
+
      {:else if preview && content}
+
        <div style:margin="1rem 0" style:text-align="center">
+
          <img
+
            src={`data:image/svg+xml;base64,${btoa(content)}`}
+
            alt={filePath} />
+
        </div>
+
      {:else}
+
        <div style:margin="1rem 0">
+
          <Placeholder iconName="empty-file" caption="Empty file" inline />
+
        </div>
+
      {/if}
+
    {:else}
+
      <div style:margin="1rem 0">
+
        <Placeholder iconName="binary-file" caption="Binary file" inline />
+
      </div>
+
    {/if}
+
  </div>
+
</File>
added src/views/repos/Changeset/FileLocationChange.svelte
@@ -0,0 +1,69 @@
+
<script lang="ts">
+
  import type { BaseUrl, ChangesetWithoutDiff } from "@http-client";
+

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

+
  export let headerBadgeCaption: ChangesetWithoutDiff["state"];
+
  export let newPath: string;
+
  export let oldPath: string;
+
  export let revision: string | undefined = undefined;
+
  export let baseUrl: BaseUrl;
+
  export let repoId: string;
+
</script>
+

+
<style>
+
  .wrapper {
+
    border: 1px solid var(--color-border-default);
+
    border-radius: var(--border-radius-small);
+
    line-height: 1.5rem;
+
  }
+
  .header {
+
    align-items: center;
+
    background: none;
+
    border-radius: 0;
+
    display: flex;
+
    flex-direction: row;
+
    height: 3rem;
+
    padding: 1rem;
+
  }
+
  .actions {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
    gap: 1rem;
+
  }
+
</style>
+

+
<div id={newPath} class="wrapper">
+
  <header class="header">
+
    <div class="actions">
+
      <span>
+
        <FilePath filenameWithPath={oldPath} /> → <FilePath
+
          filenameWithPath={newPath} />
+
      </span>
+
      {#if headerBadgeCaption === "moved"}
+
        <Badge variant="foreground">moved</Badge>
+
      {:else if headerBadgeCaption === "copied"}
+
        <Badge variant="foreground">copied</Badge>
+
      {/if}
+
    </div>
+
    <div style:margin-left="auto">
+
      <Link
+
        route={{
+
          resource: "repo.source",
+
          repo: repoId,
+
          node: baseUrl,
+
          path: newPath,
+
          revision,
+
        }}>
+
        <IconButton title="View file at this commit">
+
          <Icon name="chevron-left-right" />
+
        </IconButton>
+
      </Link>
+
    </div>
+
  </header>
+
</div>
added src/views/repos/Cob/Assignees.svelte
@@ -0,0 +1,68 @@
+
<script lang="ts">
+
  import type { Reaction } from "@http-client";
+

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

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

+
  export let assignees: Reaction["authors"] = [];
+
</script>
+

+
<style>
+
  .header {
+
    font-size: var(--font-size-small);
+
    margin-bottom: 0.75rem;
+
  }
+
  .body {
+
    display: flex;
+
    flex-wrap: wrap;
+
    flex-direction: row;
+
    gap: 0.5rem;
+
    font-size: var(--font-size-small);
+
  }
+
  .assignee {
+
    display: flex;
+
    align-items: center;
+
    width: 100%;
+
    gap: 0.25rem;
+
  }
+
  @media (max-width: 1349.98px) {
+
    .wrapper {
+
      display: flex;
+
      flex-direction: row;
+
      gap: 1rem;
+
      align-items: flex-start;
+
    }
+
    .header {
+
      margin-bottom: 0;
+
      height: 2rem;
+
      display: flex;
+
      align-items: center;
+
    }
+
    .body {
+
      align-items: flex-start;
+
    }
+
    .no-assignees {
+
      height: 2rem;
+
      display: flex;
+
      align-items: center;
+
    }
+
  }
+
</style>
+

+
<div class="wrapper">
+
  <div class="header">Assignees</div>
+
  <div class="body">
+
    {#each assignees as { id }}
+
      <Badge variant="neutral" size="small">
+
        <div class="assignee">
+
          <Avatar variant="small" nodeId={id} />
+
          <span>{formatNodeId(id)}</span>
+
        </div>
+
      </Badge>
+
    {:else}
+
      <div class="txt-missing no-assignees">No assignees</div>
+
    {/each}
+
  </div>
+
</div>
added src/views/repos/Cob/CobCommitTeaser.svelte
@@ -0,0 +1,115 @@
+
<script lang="ts">
+
  import type { BaseUrl, CommitHeader } from "@http-client";
+

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

+
  import CompactCommitAuthorship from "@app/components/CompactCommitAuthorship.svelte";
+
  import ExpandButton from "@app/components/ExpandButton.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Id from "@app/components/Id.svelte";
+
  import InlineTitle from "@app/views/repos/components/InlineTitle.svelte";
+
  import Link from "@app/components/Link.svelte";
+

+
  export let baseUrl: BaseUrl;
+
  export let commit: CommitHeader;
+
  export let repoId: string;
+

+
  let commitMessageVisible = false;
+
</script>
+

+
<style>
+
  .teaser {
+
    display: flex;
+
    font-size: var(--font-size-small);
+
    align-items: start;
+
    padding: 0.125rem 0;
+
  }
+
  .message {
+
    align-items: center;
+
    display: flex;
+
    flex-wrap: wrap;
+
    gap: 0.5rem;
+
  }
+
  .left {
+
    display: flex;
+
    gap: 0.5rem;
+
    padding: 0 0.5rem;
+
    flex-direction: column;
+
  }
+
  .right {
+
    display: flex;
+
    align-items: center;
+
    gap: 1rem;
+
    margin-left: auto;
+
    height: 21px;
+
  }
+
  .summary:hover {
+
    text-decoration: underline;
+
  }
+
  .commit-message {
+
    margin: 0.5rem 0;
+
    font-size: var(--font-size-tiny);
+
  }
+
  pre {
+
    white-space: pre-wrap;
+
    word-wrap: break-word;
+
  }
+
</style>
+

+
<div class="teaser" aria-label="commit-teaser">
+
  <div class="left">
+
    <div class="message">
+
      <Link
+
        route={{
+
          resource: "repo.commit",
+
          repo: repoId,
+
          node: baseUrl,
+
          commit: commit.id,
+
        }}>
+
        <div class="summary" use:twemoji>
+
          <InlineTitle fontSize="small" content={commit.summary} />
+
        </div>
+
      </Link>
+
      {#if commit.description}
+
        <div style="height: 21px; display: flex; align-items: center;">
+
          <ExpandButton
+
            variant="inline"
+
            on:toggle={() => {
+
              commitMessageVisible = !commitMessageVisible;
+
            }} />
+
        </div>
+
      {/if}
+
    </div>
+
    {#if commitMessageVisible}
+
      <div class="commit-message" style:margin="0.5rem 0">
+
        <pre>{commit.description.trim()}</pre>
+
      </div>
+
    {/if}
+
    <div class="global-hide-on-small-desktop-up">
+
      <CompactCommitAuthorship {commit}>
+
        <Id id={commit.id} style="commit" />
+
      </CompactCommitAuthorship>
+
    </div>
+
  </div>
+
  <div class="right">
+
    <div style="display: flex; gap: 0.5rem; height: 21px; align-items: center;">
+
      <div class="global-hide-on-mobile-down">
+
        <CompactCommitAuthorship {commit}>
+
          <Id id={commit.id} style="commit" />
+
        </CompactCommitAuthorship>
+
      </div>
+
      <IconButton title="Browse repo at this commit">
+
        <Link
+
          route={{
+
            resource: "repo.source",
+
            repo: repoId,
+
            node: baseUrl,
+
            revision: commit.id,
+
          }}>
+
          <Icon name="chevron-left-right" />
+
        </Link>
+
      </IconButton>
+
    </div>
+
  </div>
+
</div>
added src/views/repos/Cob/CobHeader.svelte
@@ -0,0 +1,43 @@
+
<style>
+
  .header {
+
    display: flex;
+
    padding: 1rem;
+
    flex-direction: column;
+
  }
+
  .subtitle {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
    flex-wrap: wrap;
+
    gap: 0.5rem;
+
    font-size: var(--font-size-small);
+
  }
+
  .summary {
+
    display: flex;
+
    align-items: flex-start;
+
    justify-content: space-between;
+
    gap: 0.5rem;
+
    margin-bottom: 0.5rem;
+
  }
+
  .description {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 0.5rem;
+
    font-size: var(--font-size-small);
+
    margin-top: 2rem;
+
    word-break: break-word;
+
  }
+
</style>
+

+
<div class="header">
+
  <div role="heading" aria-level={2} class="summary">
+
    <slot name="title" />
+
  </div>
+
  <div class="subtitle">
+
    <slot name="state" />
+
  </div>
+
  <slot name="subtitle" />
+
  <div class="description">
+
    <slot name="description" />
+
  </div>
+
</div>
added src/views/repos/Cob/Embeds.svelte
@@ -0,0 +1,56 @@
+
<script lang="ts">
+
  import type { Embed } from "@http-client";
+

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

+
  export let embeds: Embed[] = [];
+
</script>
+

+
<style>
+
  .header {
+
    font-size: var(--font-size-small);
+
    margin-bottom: 0.75rem;
+
  }
+
  .body {
+
    display: flex;
+
    flex-wrap: wrap;
+
    flex-direction: row;
+
    gap: 0.5rem;
+
    font-size: var(--font-size-small);
+
  }
+

+
  @media (max-width: 1349.98px) {
+
    .wrapper {
+
      display: flex;
+
      flex-direction: row;
+
      gap: 1rem;
+
      align-items: flex;
+
    }
+
    .header {
+
      margin-bottom: 0;
+
      height: 2rem;
+
      display: flex;
+
      align-items: center;
+
    }
+
    .no-attachments {
+
      height: 2rem;
+
      display: flex;
+
      align-items: center;
+
    }
+
  }
+
</style>
+

+
<div class="wrapper">
+
  <div class="header">Attachments</div>
+
  <div class="body">
+
    {#each embeds as embed}
+
      <Badge variant="neutral" size="small" style="max-width: 14rem;">
+
        <span class="txt-overflow">{embed.name}</span>
+
        <Clipboard text={`![${embed.name}](${embed.content.substring(4)})`} />
+
      </Badge>
+
    {:else}
+
      <div class="txt-missing no-attachments">No attachments</div>
+
    {/each}
+
  </div>
+
</div>
added src/views/repos/Cob/InlineLabels.svelte
@@ -0,0 +1,25 @@
+
<script lang="ts">
+
  import Badge from "@app/components/Badge.svelte";
+

+
  export let labels: string[];
+
</script>
+

+
<style>
+
  .label {
+
    overflow: hidden;
+
    text-overflow: ellipsis;
+
  }
+
</style>
+

+
{#each labels.slice(0, 2) as label}
+
  <Badge style="max-width:7rem" variant="neutral">
+
    <span class="label">{label}</span>
+
  </Badge>
+
{/each}
+
{#if labels.length > 2}
+
  <Badge title={labels.slice(2, undefined).join(" ")} variant="neutral">
+
    <span class="label">
+
      +{labels.length - 2} more
+
    </span>
+
  </Badge>
+
{/if}
added src/views/repos/Cob/Labels.svelte
@@ -0,0 +1,55 @@
+
<script lang="ts">
+
  import Badge from "@app/components/Badge.svelte";
+

+
  export let labels: string[] = [];
+
</script>
+

+
<style>
+
  .header {
+
    font-size: var(--font-size-small);
+
    margin-bottom: 0.75rem;
+
  }
+
  .body {
+
    display: flex;
+
    align-items: center;
+
    flex-wrap: wrap;
+
    flex-direction: row;
+
    gap: 0.5rem;
+
    font-size: var(--font-size-small);
+
  }
+
  @media (max-width: 1349.98px) {
+
    .wrapper {
+
      display: flex;
+
      flex-direction: row;
+
      gap: 1rem;
+
      align-items: flex-start;
+
    }
+
    .header {
+
      margin-bottom: 0;
+
      height: 2rem;
+
      display: flex;
+
      align-items: center;
+
    }
+
    .body {
+
      align-items: flex-start;
+
    }
+
    .no-labels {
+
      height: 2rem;
+
      display: flex;
+
      align-items: center;
+
    }
+
  }
+
</style>
+

+
<div class="wrapper">
+
  <div class="header">Labels</div>
+
  <div class="body">
+
    {#each labels as label}
+
      <Badge variant="neutral" size="small">
+
        {label}
+
      </Badge>
+
    {:else}
+
      <div class="txt-missing no-labels">No labels</div>
+
    {/each}
+
  </div>
+
</div>
added src/views/repos/Cob/Reviews.svelte
@@ -0,0 +1,83 @@
+
<script lang="ts">
+
  import type { BaseUrl } from "@http-client";
+
  import type { PatchReviews } from "../Patch.svelte";
+

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

+
  export let baseUrl: BaseUrl;
+
  export let reviews: PatchReviews;
+
</script>
+

+
<style>
+
  .header {
+
    font-size: var(--font-size-small);
+
    margin-bottom: 0.75rem;
+
  }
+
  .body {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 0.5rem;
+
    font-size: var(--font-size-small);
+
  }
+
  .review {
+
    color: var(--color-fill-gray);
+
    display: inline-flex;
+
    align-items: center;
+
    gap: 0.5rem;
+
  }
+
  .review-accept {
+
    color: var(--color-foreground-success);
+
  }
+
  .review-reject {
+
    color: var(--color-foreground-red);
+
  }
+
  @media (max-width: 1349.98px) {
+
    .wrapper {
+
      display: flex;
+
      flex-direction: row;
+
      gap: 1rem;
+
      align-items: center;
+
    }
+
    .header {
+
      margin-bottom: 0;
+
      height: 2rem;
+
      display: flex;
+
      align-items: center;
+
    }
+
    .no-reviews {
+
      display: flex;
+
      align-items: center;
+
    }
+
    .body {
+
      flex-direction: row;
+
    }
+
  }
+
</style>
+

+
<div class="wrapper">
+
  <div class="header">Reviews</div>
+
  <div class="body">
+
    {#each Object.values(reviews) as { latest, review }}
+
      <div class="review" class:txt-missing={!latest}>
+
        <span
+
          class:review-accept={review.verdict === "accept"}
+
          class:review-reject={review.verdict === "reject"}>
+
          {#if review.verdict === "accept"}
+
            <Icon name="checkmark" />
+
          {:else if review.verdict === "reject"}
+
            <Icon name="cross" />
+
          {:else}
+
            <Icon name="chat" />
+
          {/if}
+
        </span>
+
        <NodeId
+
          {baseUrl}
+
          nodeId={review.author.id}
+
          alias={review.author.alias} />
+
      </div>
+
    {:else}
+
      <div class="txt-missing no-reviews">No reviews</div>
+
    {/each}
+
  </div>
+
</div>
added src/views/repos/Cob/Revision.svelte
@@ -0,0 +1,533 @@
+
<script lang="ts">
+
  import type {
+
    Author,
+
    BaseUrl,
+
    Comment,
+
    DiffResponse,
+
    PatchState,
+
    Revision,
+
    Verdict,
+
  } from "@http-client";
+
  import type { Timeline } from "@app/views/repos/Patch.svelte";
+

+
  import * as utils from "@app/lib/utils";
+
  import { HttpdClient } from "@http-client";
+
  import { cachedGetDiff } from "@app/views/repos/router";
+
  import { onMount } from "svelte";
+

+
  import CobCommitTeaser from "@app/views/repos/Cob/CobCommitTeaser.svelte";
+
  import CommentComponent from "@app/components/Comment.svelte";
+
  import DiffStatBadge from "@app/components/DiffStatBadge.svelte";
+
  import DropdownList from "@app/components/DropdownList.svelte";
+
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
+
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
+
  import ExpandButton from "@app/components/ExpandButton.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import Loading from "@app/components/Loading.svelte";
+
  import Markdown from "@app/components/Markdown.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+
  import Reactions from "@app/components/Reactions.svelte";
+
  import Thread from "@app/components/Thread.svelte";
+
  import Id from "@app/components/Id.svelte";
+

+
  export let baseUrl: BaseUrl;
+
  export let initiallyExpanded: boolean = false;
+
  export let rawPath: (commit?: string) => string;
+
  export let patchId: string;
+
  export let patchState: PatchState;
+
  export let repoId: string;
+
  export let revisionBase: string;
+
  export let revisionId: string;
+
  export let revisionEdits: Revision["edits"];
+
  export let revisionOid: string;
+
  export let revisionTimestamp: number;
+
  export let revisionReactions: Comment["reactions"];
+
  export let revisionAuthor: Author;
+
  export let revisionDescription: string;
+
  export let timelines: Timeline[];
+
  export let previousRevBase: string | undefined = undefined;
+
  export let previousRevId: string | undefined = undefined;
+
  export let previousRevOid: string | undefined = undefined;
+
  export let first: boolean;
+

+
  let expanded = initiallyExpanded;
+
  const api = new HttpdClient(baseUrl);
+
  const lastEdit = revisionEdits.at(-1);
+

+
  function formatVerdict(verdict?: Verdict | null) {
+
    switch (verdict) {
+
      case "accept":
+
        return "accepted revision";
+
      case "reject":
+
        return "rejected revision";
+
      default:
+
        return "reviewed revision";
+
    }
+
  }
+

+
  function verdictIconColor(verdict?: Verdict | null) {
+
    switch (verdict) {
+
      case "accept":
+
        return "var(--color-foreground-success)";
+
      case "reject":
+
        return "var(--color-foreground-red)";
+
      default:
+
        return "var(--color-fill-gray)";
+
    }
+
  }
+

+
  function badgeColor({ status }: PatchState): string | undefined {
+
    if (status === "draft") {
+
      return "var(--color-fill-gray)";
+
    } else if (status === "open") {
+
      return "var(--color-foreground-success)";
+
    } else if (status === "archived") {
+
      return "var(--color-foreground-yellow)";
+
    } else if (status === "merged") {
+
      return "var(--color-fill-primary)";
+
    } else {
+
      return "var(--color-foreground-success)";
+
    }
+
  }
+

+
  let response: DiffResponse | undefined = undefined;
+
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+
  let error: any | undefined = undefined;
+
  let loading: boolean = false;
+

+
  $: fromCommit =
+
    previousRevBase !== revisionBase
+
      ? revisionBase
+
      : (previousRevBase ?? revisionBase);
+

+
  onMount(async () => {
+
    try {
+
      loading = true;
+
      response = await cachedGetDiff(
+
        api.baseUrl,
+
        repoId,
+
        fromCommit,
+
        revisionOid,
+
      );
+
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+
    } catch (err: any) {
+
      error = err;
+
    } finally {
+
      loading = false;
+
    }
+
  });
+
</script>
+

+
<style>
+
  .action {
+
    border-radius: var(--border-radius-small);
+
    min-height: 2.5rem;
+
    display: flex;
+
    align-items: center;
+
  }
+
  .merge {
+
    border: 1px solid var(--color-border-merged);
+
    background-color: var(--color-fill-merged);
+
  }
+
  .positive-review {
+
    border: 1px solid var(--color-fill-diff-green);
+
    background-color: var(--color-fill-diff-green-light);
+
  }
+
  .comment-review {
+
    border: 1px solid var(--color-border-hint);
+
    background-color: var(--color-fill-float);
+
  }
+
  .negative-review {
+
    border: 1px solid var(--color-fill-diff-red);
+
    background-color: var(--color-fill-diff-red-light);
+
  }
+

+
  .diff-error {
+
    margin: 1rem 1.5rem;
+
  }
+
  .revision {
+
    display: flex;
+
    flex-direction: column;
+
    border-radius: var(--border-radius-small);
+
  }
+
  .revision-box {
+
    border-radius: var(--border-radius-small);
+
  }
+
  .revision-header {
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
+
    background: none;
+
    padding: 0.5rem;
+
    font-size: var(--font-size-small);
+
    height: 3rem;
+
  }
+
  .revision-name {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.5rem;
+
    font-weight: var(--font-weight-medium);
+
  }
+
  .revision-data {
+
    gap: 0.5rem;
+
    display: flex;
+
    align-items: center;
+
    margin-left: auto;
+
    color: var(--color-foreground-dim);
+
  }
+
  .revision-description {
+
    margin-left: 2.75rem;
+
    padding-right: 0.5rem;
+
    max-width: fit-content;
+
  }
+
  .author-metadata {
+
    color: var(--color-fill-gray);
+
    font-size: var(--font-size-small);
+
  }
+
  .compare-dropdown-item {
+
    font-weight: var(--font-weight-regular);
+
  }
+
  .patch-header {
+
    background-color: var(--color-fill-float);
+
    border-bottom: 1px solid var(--color-fill-separator);
+
    border-top: 1px solid var(--color-fill-separator);
+
    display: flex;
+
    flex-direction: column;
+
    justify-content: center;
+
    min-height: 2.5rem;
+
    padding: 0.5rem 0;
+
    font-size: var(--font-size-small);
+
    gap: 0.5rem;
+
  }
+
  .authorship-header {
+
    display: inline-flex;
+
    white-space: nowrap;
+
    flex-wrap: wrap;
+
    align-items: center;
+
    padding: 0 0.5rem;
+
    min-height: 1.5rem;
+
    gap: 0.5rem;
+
    font-size: var(--font-size-small);
+
  }
+
  .timestamp {
+
    font-size: var(--font-size-small);
+
    color: var(--color-fill-gray);
+
  }
+
  .actions {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
    padding-left: 2.5rem;
+
    gap: 0.5rem;
+
  }
+
  .commits {
+
    position: relative;
+
    display: flex;
+
    flex-direction: column;
+
    font-size: 0.875rem;
+
    margin-left: 1.25rem;
+
    gap: 0.5rem;
+
    padding: 1rem 0.5rem 1rem 1rem;
+
    border-left: 1px solid var(--color-fill-separator);
+
  }
+
  .commit:last-of-type::after {
+
    content: "";
+
    position: absolute;
+
    left: -18.5px;
+
    top: 14px;
+
    bottom: -1rem;
+
    border-left: 4px solid var(--color-background-default);
+
  }
+
  .expanded {
+
    box-shadow: 0 0 0 1px var(--color-border-hint);
+
  }
+
  .commit-dot {
+
    border-radius: var(--border-radius-round);
+
    width: 4px;
+
    height: 4px;
+
    position: absolute;
+
    top: 0.625rem;
+
    left: -18.5px;
+
    background-color: var(--color-fill-separator);
+
  }
+
  .connector {
+
    width: 1px;
+
    height: 1.5rem;
+
    margin-left: 1.25rem;
+
    background-color: var(--color-fill-separator);
+
  }
+
  @media (max-width: 719.98px) {
+
    .revision-box {
+
      border-radius: 0;
+
    }
+
    .action {
+
      border-radius: 0;
+
    }
+
  }
+
</style>
+

+
<div class="revision" style:margin-bottom={expanded ? "2rem" : "0.5rem"}>
+
  <div class="revision-box" class:expanded>
+
    <div class="revision-header">
+
      <div class="revision-name">
+
        <ExpandButton {expanded} on:toggle={() => (expanded = !expanded)} />
+
        <span>
+
          Revision
+
          <Id id={revisionId} />
+
        </span>
+
      </div>
+
      <div class="revision-data">
+
        <span
+
          class="global-hide-on-mobile-down"
+
          title={utils.absoluteTimestamp(revisionTimestamp)}>
+
          {utils.formatTimestamp(revisionTimestamp)}
+
        </span>
+
        {#if loading}
+
          <Loading small />
+
        {/if}
+
        {#if response?.diff.stats}
+
          <Link
+
            title="Compare {utils.formatCommit(
+
              fromCommit,
+
            )}..{utils.formatCommit(revisionOid)}"
+
            route={{
+
              resource: "repo.patch",
+
              repo: repoId,
+
              node: baseUrl,
+
              patch: patchId,
+
              view: { name: "diff", fromCommit, toCommit: revisionOid },
+
            }}>
+
            {@const { insertions, deletions } = response.diff.stats}
+
            <DiffStatBadge hoverable {insertions} {deletions} />
+
          </Link>
+
        {/if}
+
        <Popover
+
          popoverPadding="0"
+
          popoverPositionTop={expanded ? "3rem" : "2.5rem"}
+
          popoverPositionRight="0"
+
          popoverBorderRadius="var(--border-radius-small)">
+
          <IconButton
+
            slot="toggle"
+
            let:toggle
+
            on:click={toggle}
+
            title="toggle-context-menu">
+
            <Icon name="more" />
+
          </IconButton>
+
          <DropdownList
+
            slot="popover"
+
            items={previousRevOid && previousRevId
+
              ? [revisionBase, previousRevOid]
+
              : [revisionBase]}>
+
            {@const baseMismatch = previousRevBase !== revisionBase}
+
            <Link
+
              let:item
+
              disabled={item !== revisionBase && baseMismatch}
+
              slot="item"
+
              title="Compare {utils.formatCommit(item)}..{utils.formatCommit(
+
                revisionOid,
+
              )}"
+
              route={{
+
                resource: "repo.patch",
+
                repo: repoId,
+
                node: baseUrl,
+
                patch: patchId,
+
                view: {
+
                  name: "diff",
+
                  fromCommit: item,
+
                  toCommit: revisionOid,
+
                },
+
              }}>
+
              {#if item === revisionBase}
+
                <DropdownListItem selected={false}>
+
                  <span class="compare-dropdown-item">
+
                    Compare to base:
+
                    <span
+
                      style:color="var(--color-fill-gray)"
+
                      style:font-weight="var(--font-weight-bold)"
+
                      style:font-family="var(--font-family-monospace)">
+
                      {utils.formatObjectId(revisionBase)}
+
                    </span>
+
                  </span>
+
                </DropdownListItem>
+
              {:else if previousRevId}
+
                <DropdownListItem
+
                  selected={false}
+
                  disabled={baseMismatch}
+
                  title={baseMismatch
+
                    ? "Previous revision has different base"
+
                    : `${utils.formatCommit(item)}..${utils.formatCommit(
+
                        revisionOid,
+
                      )}`}>
+
                  <span class="compare-dropdown-item">
+
                    Compare to previous revision: <span
+
                      style:color="var(--color-fill-secondary)"
+
                      style:font-weight="var(--font-weight-bold)"
+
                      style:font-family="var(--font-family-monospace)">
+
                      {utils.formatObjectId(previousRevId)}
+
                    </span>
+
                  </span>
+
                </DropdownListItem>
+
              {/if}
+
            </Link>
+
          </DropdownList>
+
        </Popover>
+
      </div>
+
    </div>
+
    {#if expanded}
+
      <div>
+
        <div class="patch-header">
+
          <div class="authorship-header">
+
            <div
+
              style:color={badgeColor(patchState)}
+
              style:padding="0 0.375rem">
+
              <Icon name="patch" />
+
            </div>
+
            <NodeId
+
              {baseUrl}
+
              nodeId={revisionAuthor.id}
+
              alias={revisionAuthor.alias} />
+
            {#if patchId === revisionId}
+
              opened this patch on base
+
              <Id id={revisionBase} style="commit" />
+
            {:else}
+
              updated to
+
              <Id id={revisionId} />
+
              {#if previousRevBase && previousRevBase !== revisionBase}
+
                with base
+
                <Id id={revisionBase} style="commit" />
+
              {/if}
+
            {/if}
+
            <span
+
              class="timestamp"
+
              title={utils.absoluteTimestamp(revisionTimestamp)}>
+
              {utils.formatTimestamp(revisionTimestamp)}
+
            </span>
+
            {#if revisionEdits.length > 1 && lastEdit}
+
              <div
+
                class="author-metadata"
+
                title={utils.formatEditedCaption(
+
                  lastEdit.author,
+
                  lastEdit.timestamp,
+
                )}>
+
                • edited
+
              </div>
+
            {/if}
+
          </div>
+
          {#if revisionDescription && !first}
+
            <div class="revision-description txt-small">
+
              <Markdown
+
                breaks
+
                rawPath={rawPath(revisionBase)}
+
                content={revisionDescription} />
+
            </div>
+
          {/if}
+
          {#if revisionReactions && revisionReactions.length > 0}
+
            <div class="actions">
+
              <Reactions reactions={revisionReactions} />
+
            </div>
+
          {/if}
+
        </div>
+
        {#if loading}
+
          <div style:height="3.5rem">
+
            <Loading small />
+
          </div>
+
        {/if}
+
        {#if response?.commits}
+
          <div class="commits">
+
            {#each response.commits.reverse() as commit}
+
              <div class="commit" style:position="relative">
+
                <div class="commit-dot" />
+
                <CobCommitTeaser {commit} {baseUrl} {repoId} />
+
              </div>
+
            {/each}
+
          </div>
+
        {/if}
+
      </div>
+
      {#if error}
+
        <div
+
          class="diff-error txt-monospace txt-small"
+
          style:border-radius="var(--border-radius-small)">
+
          <ErrorMessage
+
            title="Failed to load diff for this revision"
+
            description="Make sure you are able to connect to the seed <code>${utils.baseUrlToString(
+
              api.baseUrl,
+
            )}</code>"
+
            {error} />
+
        </div>
+
      {/if}
+
    {/if}
+
  </div>
+
  {#if expanded}
+
    {#if timelines.length > 0}
+
      {#each timelines as element}
+
        {#if element.type === "thread"}
+
          <div class="connector" />
+
          <Thread
+
            {baseUrl}
+
            thread={element.inner}
+
            rawPath={rawPath(revisionBase)} />
+
        {:else if element.type === "merge"}
+
          <div class="connector" />
+
          <div class="action merge">
+
            <div class="authorship-header">
+
              <div style:color="var(--color-fill-primary)">
+
                <Icon name="patch" />
+
              </div>
+

+
              <NodeId
+
                {baseUrl}
+
                nodeId={element.inner.author.id}
+
                alias={element.inner.author.alias}>
+
              </NodeId>
+

+
              merged revision
+
              <Id id={element.inner.revision} />
+
              at commit
+
              <Id id={element.inner.commit} style="commit" />
+
              <span
+
                class="timestamp"
+
                title={utils.absoluteTimestamp(revisionTimestamp)}>
+
                {utils.formatTimestamp(revisionTimestamp)}
+
              </span>
+
            </div>
+
          </div>
+
        {:else if element.type === "review"}
+
          {@const [author, review] = element.inner}
+
          <div class="connector" />
+
          <div
+
            class="action"
+
            class:comment-review={review.verdict === null}
+
            class:positive-review={review.verdict === "accept"}
+
            class:negative-review={review.verdict === "reject"}>
+
            <CommentComponent
+
              {baseUrl}
+
              id={review.id}
+
              rawPath={rawPath(revisionBase)}
+
              authorId={author}
+
              authorAlias={review.author.alias}
+
              timestamp={review.timestamp}
+
              body={review.summary ?? ""}>
+
              <div slot="caption">
+
                {formatVerdict(review.verdict)}
+
                <Id id={revisionId} />
+
              </div>
+
              <div slot="icon" style:color={verdictIconColor(review.verdict)}>
+
                {#if review.verdict === "accept"}
+
                  <Icon name="checkmark" />
+
                {:else if review.verdict === "reject"}
+
                  <Icon name="cross" />
+
                {:else}
+
                  <Icon name="chat" />
+
                {/if}
+
              </div>
+
            </CommentComponent>
+
          </div>
+
        {/if}
+
      {/each}
+
    {/if}
+
    <slot />
+
  {/if}
+
</div>
added src/views/repos/CommentCounter.svelte
@@ -0,0 +1,21 @@
+
<script lang="ts">
+
  import Icon from "@app/components/Icon.svelte";
+

+
  export let commentCount: number;
+
</script>
+

+
<style>
+
  .comments {
+
    color: var(--color-foreground-dim);
+
    font-size: var(--font-size-tiny);
+
    display: flex;
+
    align-items: center;
+
    gap: 0.25rem;
+
    white-space: nowrap;
+
  }
+
</style>
+

+
<div class="comments">
+
  <Icon name="chat" />
+
  <span>{commentCount}</span>
+
</div>
added src/views/repos/Commit.svelte
@@ -0,0 +1,109 @@
+
<script lang="ts">
+
  import type { BaseUrl, Commit, Repo, SeedingPolicy } from "@http-client";
+

+
  import { formatObjectId } from "@app/lib/utils";
+

+
  import Button from "@app/components/Button.svelte";
+
  import Changeset from "@app/views/repos/Changeset.svelte";
+
  import CommitAuthorship from "@app/views/repos/Commit/CommitAuthorship.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Id from "@app/components/Id.svelte";
+
  import InlineTitle from "@app/views/repos/components/InlineTitle.svelte";
+
  import Layout from "./Layout.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import Separator from "./Separator.svelte";
+
  import Share from "./Share.svelte";
+

+
  export let baseUrl: BaseUrl;
+
  export let seedingPolicy: SeedingPolicy;
+
  export let commit: Commit;
+
  export let repo: Repo;
+
  export let nodeAvatarUrl: string | undefined;
+

+
  $: header = commit.commit;
+
</script>
+

+
<style>
+
  .commit {
+
    background-color: var(--color-background-float);
+
  }
+
  .header {
+
    padding: 1rem;
+
    border-radius: var(--border-radius-small);
+
    border-bottom: 1px solid var(--color-border-hint);
+
  }
+
  .title {
+
    display: flex;
+
    align-items: center;
+
    font-weight: var(--font-weight-semibold);
+
  }
+
  .description {
+
    font-family: var(--font-family-monospace);
+
    white-space: pre-wrap;
+
    margin-top: 1.5rem;
+
  }
+
  .button-container {
+
    margin-left: auto;
+
    display: flex;
+
    gap: 0.5rem;
+
  }
+
</style>
+

+
<Layout {nodeAvatarUrl} {seedingPolicy} {baseUrl} {repo}>
+
  <svelte:fragment slot="breadcrumb">
+
    <Separator />
+
    <Link
+
      route={{
+
        resource: "repo.history",
+
        repo: repo.rid,
+
        node: baseUrl,
+
      }}>
+
      Commits
+
    </Link>
+
    <Separator />
+
    <span class="id">
+
      <div class="global-hide-on-small-desktop-down">
+
        {commit.commit.id}
+
      </div>
+
      <div class="global-hide-on-medium-desktop-up">
+
        {formatObjectId(commit.commit.id)}
+
      </div>
+
    </span>
+
  </svelte:fragment>
+
  <div class="commit">
+
    <div class="header">
+
      <div style="display:flex; flex-direction: column; gap: 0.5rem;">
+
        <span class="title">
+
          <InlineTitle fontSize="large" content={header.summary} />
+
          <div class="button-container">
+
            <Link
+
              route={{
+
                resource: "repo.source",
+
                repo: repo.rid,
+
                node: baseUrl,
+
                path: "/",
+
                revision: commit.commit.id,
+
              }}>
+
              <Button variant="outline" title="Browse repo at this commit">
+
                <Icon name="chevron-left-right" />
+
              </Button>
+
            </Link>
+
            <Share />
+
          </div>
+
        </span>
+
        <CommitAuthorship {header}>
+
          <Id id={header.id} style="commit" ariaLabel="commit-id" />
+
        </CommitAuthorship>
+
      </div>
+
      {#if header.description}
+
        <pre class="description txt-small">{header.description}</pre>
+
      {/if}
+
    </div>
+
    <Changeset
+
      {baseUrl}
+
      repoId={repo.rid}
+
      files={commit.files}
+
      diff={commit.diff}
+
      revision={commit.commit.id} />
+
  </div>
+
</Layout>
added src/views/repos/Commit/CommitAuthorship.svelte
@@ -0,0 +1,70 @@
+
<script lang="ts">
+
  import type { CommitHeader } from "@http-client";
+

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

+
  export let header: CommitHeader;
+
</script>
+

+
<style>
+
  .authorship {
+
    display: flex;
+
    font-size: var(--font-size-small);
+
    gap: 0.5rem;
+
    flex-wrap: wrap;
+
    align-items: center;
+
  }
+
  .person {
+
    display: flex;
+
    align-items: center;
+
    flex-wrap: nowrap;
+
    white-space: nowrap;
+
    gap: 0.5rem;
+
    font-family: var(--font-family-monospace);
+
    font-weight: var(--font-weight-semibold);
+
  }
+
  .avatar {
+
    width: 1rem;
+
    height: 1rem;
+
    border-radius: var(--border-radius-round);
+
  }
+
</style>
+

+
<span class="authorship">
+
  {#if header.author.email === header.committer.email}
+
    <div class="person">
+
      <img
+
        class="avatar"
+
        alt="avatar"
+
        src={gravatarURL(header.committer.email)} />
+
      {header.committer.name}
+
    </div>
+
    committed
+
    <slot />
+
    <span title={absoluteTimestamp(header.committer.time)}>
+
      {formatTimestamp(header.committer.time)}
+
    </span>
+
  {:else}
+
    <div class="person">
+
      <img class="avatar" alt="avatar" src={gravatarURL(header.author.email)} />
+
      {header.author.name}
+
    </div>
+
    authored and
+
    <div class="person">
+
      <img
+
        class="avatar"
+
        alt="avatar"
+
        src={gravatarURL(header.committer.email)} />
+
      {header.committer.name}
+
    </div>
+
    committed
+
    <slot />
+
    <span title={absoluteTimestamp(header.committer.time)}>
+
      {formatTimestamp(header.committer.time)}
+
    </span>
+
  {/if}
+
</span>
added src/views/repos/Commit/CommitTeaser.svelte
@@ -0,0 +1,125 @@
+
<script lang="ts">
+
  import type { BaseUrl, CommitHeader } from "@http-client";
+

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

+
  import CommitAuthorship from "./CommitAuthorship.svelte";
+
  import ExpandButton from "@app/components/ExpandButton.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import InlineTitle from "@app/views/repos/components/InlineTitle.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import Id from "@app/components/Id.svelte";
+

+
  export let baseUrl: BaseUrl;
+
  export let commit: CommitHeader;
+
  export let repoId: string;
+

+
  let commitMessageVisible = false;
+
</script>
+

+
<style>
+
  .teaser {
+
    display: flex;
+
    padding: 1.25rem;
+
    background-color: var(--color-background-float);
+
  }
+
  .teaser:hover {
+
    background-color: var(--color-fill-float-hover);
+
  }
+
  .message {
+
    align-items: center;
+
    display: flex;
+
    flex-direction: row;
+
    flex-wrap: wrap;
+
    gap: 0.5rem;
+
  }
+
  .left {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 0.5rem;
+
  }
+
  .right {
+
    display: flex;
+
    align-items: flex-start;
+
    gap: 1rem;
+
    margin-left: auto;
+
    color: var(--color-foreground-dim);
+
    font-size: var(--font-size-tiny);
+
  }
+
  .summary {
+
    font-size: var(--font-size-small);
+
  }
+
  .summary::after {
+
    content: "";
+
    position: absolute;
+
    top: -10px;
+
    right: 0px;
+
    bottom: -10px;
+
    left: -10px;
+
  }
+
  .summary:hover {
+
    text-decoration: underline;
+
    text-decoration-thickness: 1px;
+
    text-underline-offset: 2px;
+
    cursor: pointer;
+
  }
+
  .commit-message {
+
    margin: 0.5rem 0;
+
    font-size: var(--font-size-small);
+
  }
+
  pre {
+
    white-space: pre-wrap;
+
    word-wrap: break-word;
+
  }
+
</style>
+

+
<div class="teaser">
+
  <div class="left">
+
    <div class="message">
+
      <Link
+
        route={{
+
          resource: "repo.commit",
+
          repo: repoId,
+
          node: baseUrl,
+
          commit: commit.id,
+
        }}>
+
        <div style="position: relative;">
+
          <div class="summary" use:twemoji>
+
            <InlineTitle fontSize="regular" content={commit.summary} />
+
          </div>
+
        </div>
+
      </Link>
+
      {#if commit.description}
+
        <ExpandButton
+
          variant="inline"
+
          on:toggle={() => {
+
            commitMessageVisible = !commitMessageVisible;
+
          }} />
+
      {/if}
+
    </div>
+
    {#if commitMessageVisible}
+
      <div class="commit-message">
+
        <pre>{commit.description.trim()}</pre>
+
      </div>
+
    {/if}
+
    <CommitAuthorship header={commit}>
+
      <Id id={commit.id} style="commit" />
+
    </CommitAuthorship>
+
  </div>
+
  <div class="right">
+
    <div style:display="flex" style:gap="1rem" style:height="1.5rem">
+
      <IconButton title="Browse repo at this commit">
+
        <Link
+
          route={{
+
            resource: "repo.source",
+
            repo: repoId,
+
            node: baseUrl,
+
            revision: commit.id,
+
          }}>
+
          <Icon name="chevron-left-right" />
+
        </Link>
+
      </IconButton>
+
    </div>
+
  </div>
+
</div>
added src/views/repos/DiffStatBadgeLoader.svelte
@@ -0,0 +1,40 @@
+
<script lang="ts">
+
  import type { BaseUrl, Patch, Revision } from "@http-client";
+

+
  import { cachedGetDiff } from "@app/views/repos/router";
+
  import { formatCommit } from "@app/lib/utils";
+

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

+
  export let repoId: string;
+
  export let baseUrl: BaseUrl;
+
  export let patch: Patch;
+
  export let latestRevision: Revision;
+
</script>
+

+
{#await cachedGetDiff(baseUrl, repoId, latestRevision.base, latestRevision.oid)}
+
  <Loading small />
+
{:then { diff }}
+
  <Link
+
    title="Compare {formatCommit(latestRevision.base)}..{formatCommit(
+
      latestRevision.oid,
+
    )}"
+
    route={{
+
      resource: "repo.patch",
+
      repo: repoId,
+
      node: baseUrl,
+
      patch: patch.id,
+
      view: {
+
        name: "diff",
+
        fromCommit: latestRevision.base,
+
        toCommit: latestRevision.oid,
+
      },
+
    }}>
+
    <DiffStatBadge
+
      hoverable
+
      insertions={diff.stats.insertions}
+
      deletions={diff.stats.deletions} />
+
  </Link>
+
{/await}
added src/views/repos/Header.svelte
@@ -0,0 +1,115 @@
+
<script lang="ts" context="module">
+
  export type ActiveTab = "source" | "issues" | "patches" | undefined;
+
</script>
+

+
<script lang="ts">
+
  import type { BaseUrl, Repo } from "@http-client";
+

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

+
  export let baseUrl: BaseUrl;
+
  export let activeTab: ActiveTab = undefined;
+
  export let repo: Repo;
+
</script>
+

+
<style>
+
  .container {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 0.5rem;
+
  }
+

+
  .counter {
+
    border-radius: var(--border-radius-tiny);
+
    background-color: var(--color-fill-ghost);
+
    color: var(--color-foreground-dim);
+
    padding: 0 0.25rem;
+
  }
+

+
  .selected {
+
    background-color: var(--color-fill-counter);
+
    color: var(--color-foreground-contrast);
+
  }
+

+
  .hover {
+
    background-color: var(--color-fill-ghost-hover);
+
    color: var(--color-foreground-contrast);
+
  }
+

+
  .title-counter {
+
    display: flex;
+
    gap: 0.5rem;
+
    justify-content: space-between;
+
    width: 100%;
+
  }
+
</style>
+

+
<div class="container">
+
  <Link
+
    route={{
+
      resource: "repo.source",
+
      repo: repo.rid,
+
      node: baseUrl,
+
      path: "/",
+
    }}>
+
    <Button
+
      size="large"
+
      styleWidth="100%"
+
      styleJustifyContent="flex-start"
+
      variant={activeTab === "source" ? "gray" : "background"}>
+
      <Icon name="chevron-left-right" />
+
      Source
+
    </Button>
+
  </Link>
+
  <Link
+
    route={{
+
      resource: "repo.issues",
+
      repo: repo.rid,
+
      node: baseUrl,
+
    }}>
+
    <Button
+
      let:hover
+
      size="large"
+
      styleJustifyContent="flex-start"
+
      styleWidth="100%"
+
      variant={activeTab === "issues" ? "gray" : "background"}>
+
      <Icon name="issue" />
+
      <div class="title-counter">
+
        Issues
+
        <span
+
          class="counter"
+
          class:selected={activeTab === "issues"}
+
          class:hover={hover && activeTab !== "issues"}>
+
          {repo.payloads["xyz.radicle.project"].meta.issues.open}
+
        </span>
+
      </div>
+
    </Button>
+
  </Link>
+

+
  <Link
+
    route={{
+
      resource: "repo.patches",
+
      repo: repo.rid,
+
      node: baseUrl,
+
    }}>
+
    <Button
+
      let:hover
+
      size="large"
+
      styleWidth="100%"
+
      styleJustifyContent="flex-start"
+
      variant={activeTab === "patches" ? "gray" : "background"}>
+
      <Icon name="patch" />
+
      <div class="title-counter">
+
        Patches
+
        <span
+
          class="counter"
+
          class:hover={hover && activeTab !== "patches"}
+
          class:selected={activeTab === "patches"}>
+
          {repo.payloads["xyz.radicle.project"].meta.patches.open}
+
        </span>
+
      </div>
+
    </Button>
+
  </Link>
+
</div>
added src/views/repos/Header/CloneButton.svelte
@@ -0,0 +1,96 @@
+
<script lang="ts">
+
  import type { BaseUrl } from "@http-client";
+

+
  import config from "virtual:config";
+
  import { parseRepositoryId } from "@app/lib/utils";
+

+
  import Button from "@app/components/Button.svelte";
+
  import Command from "@app/components/Command.svelte";
+
  import ExternalLink from "@app/components/ExternalLink.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+
  import Radio from "@app/components/Radio.svelte";
+

+
  export let baseUrl: BaseUrl;
+
  export let id: string;
+
  export let name: string;
+

+
  let radicle: boolean = true;
+

+
  $: radCloneUrl = `rad clone ${id}`;
+
  $: portFragment =
+
    baseUrl.scheme === config.nodes.defaultHttpdScheme &&
+
    baseUrl.port === config.nodes.defaultHttpdPort
+
      ? ""
+
      : `:${baseUrl.port}`;
+
  $: gitCloneUrl = `git clone ${baseUrl.scheme}://${
+
    baseUrl.hostname
+
  }${portFragment}/${parseRepositoryId(id)?.pubkey ?? id}.git ${name}`;
+
</script>
+

+
<style>
+
  .popover {
+
    font-size: var(--font-size-small);
+
    font-weight: var(--font-weight-regular);
+
  }
+
  label {
+
    display: block;
+
    margin-bottom: 0.75rem;
+
  }
+
</style>
+

+
<Popover popoverPositionTop="2.5rem" popoverPositionRight="0">
+
  <Button slot="toggle" let:toggle on:click={toggle} variant="outline">
+
    <Icon name="download" />
+
    <span class="global-hide-on-small-desktop-down">Clone</span>
+
  </Button>
+

+
  <div slot="popover" style:width="24rem" class="popover">
+
    <div style:margin-bottom="1.5rem">
+
      <Radio ariaLabel="Toggle render method">
+
        <Button
+
          styleWidth="100%"
+
          styleBorderRadius="0"
+
          variant={radicle ? "selected" : "not-selected"}
+
          on:click={() => {
+
            radicle = true;
+
          }}>
+
          <Icon name="logo" />
+
          Radicle
+
        </Button>
+
        <div class="global-spacer" />
+
        <Button
+
          styleWidth="100%"
+
          styleBorderRadius="0"
+
          variant={!radicle ? "selected" : "not-selected"}
+
          on:click={() => {
+
            radicle = false;
+
          }}>
+
          <Icon name="git" />
+
          Git
+
        </Button>
+
      </Radio>
+
    </div>
+

+
    {#if radicle}
+
      <label for="rad-clone-url">
+
        Use the <ExternalLink href="https://radicle.xyz">
+
          Radicle CLI
+
        </ExternalLink> to clone this repository.
+
      </label>
+
      <Command command={radCloneUrl} />
+
    {:else}
+
      <div>
+
        <label for="git-clone-url">
+
          If you don't have Radicle installed, you can still clone the
+
          repository via Git.
+
        </label>
+
        <Command command={gitCloneUrl} />
+
        <div style:margin-top="1.5rem">
+
          Note that a Git clone does not include any of the social artifacts
+
          such as issues or patches.
+
        </div>
+
      </div>
+
    {/if}
+
  </div>
+
</Popover>
added src/views/repos/Header/SeedButton.svelte
@@ -0,0 +1,71 @@
+
<script lang="ts">
+
  import Button from "@app/components/Button.svelte";
+
  import Command from "@app/components/Command.svelte";
+
  import ExternalLink from "@app/components/ExternalLink.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+

+
  export let repoId: string;
+
  export let seedCount: number;
+
  export let disabled: boolean = false;
+
</script>
+

+
<style>
+
  .seed-label {
+
    display: block;
+
    font-size: var(--font-size-small);
+
    font-weight: var(--font-weight-regular);
+
    margin-bottom: 0.75rem;
+
  }
+
  .title-counter {
+
    display: flex;
+
    gap: 0.5rem;
+
  }
+
  .counter {
+
    font-weight: var(--font-weight-regular);
+
    border-radius: var(--border-radius-tiny);
+
    background-color: var(--color-fill-ghost-hover);
+
    border: 1px solid var(--color-border-secondary-counter);
+
    color: var(--color-foreground-contrast);
+
    padding: 0 0.25rem;
+
  }
+
  .not-seeding {
+
    background-color: var(--color-fill-secondary-counter);
+
    color: var(--color-foreground-match-background);
+
  }
+
  .disabled {
+
    background-color: var(--color-fill-float-hover);
+
    color: var(--color-foreground-disabled);
+
  }
+
</style>
+

+
<Popover popoverPositionTop="2.5rem" popoverPositionRight="0">
+
  <Button
+
    slot="toggle"
+
    {disabled}
+
    let:toggle
+
    on:click={() => {
+
      toggle();
+
    }}
+
    variant="secondary-toggle-off">
+
    <Icon name="seedling" />
+
    <span class="title-counter">
+
      <span class="global-hide-on-mobile-down">Seed</span>
+
      <span
+
        class="counter not-seeding"
+
        class:disabled
+
        style:font-weight="var(--font-weight-regular)">
+
        {seedCount}
+
      </span>
+
    </span>
+
  </Button>
+

+
  <div slot="popover" style:width="auto">
+
    <span class="seed-label">
+
      Use the <ExternalLink href="https://radicle.xyz">
+
        Radicle CLI
+
      </ExternalLink> to start seeding this repository.
+
    </span>
+
    <Command command={`rad seed ${repoId}`} />
+
  </div>
+
</Popover>
added src/views/repos/History.svelte
@@ -0,0 +1,174 @@
+
<script lang="ts">
+
  import type {
+
    BaseUrl,
+
    CommitHeader,
+
    Repo,
+
    Remote,
+
    SeedingPolicy,
+
    Tree,
+
  } from "@http-client";
+
  import type { RepoRoute } from "./router";
+

+
  import config from "virtual:config";
+
  import { HttpdClient } from "@http-client";
+
  import { baseUrlToString } from "@app/lib/utils";
+
  import { groupCommits } from "@app/lib/commit";
+

+
  import Button from "@app/components/Button.svelte";
+
  import CommitTeaser from "./Commit/CommitTeaser.svelte";
+
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
+
  import Header from "./Source/Header.svelte";
+
  import Layout from "./Layout.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import List from "@app/components/List.svelte";
+
  import Loading from "@app/components/Loading.svelte";
+
  import RepoNameHeader from "./Source/RepoNameHeader.svelte";
+
  import Separator from "./Separator.svelte";
+

+
  export let baseUrl: BaseUrl;
+
  export let seedingPolicy: SeedingPolicy;
+
  export let commit: string;
+
  export let commitHeaders: CommitHeader[];
+
  export let peer: string | undefined;
+
  export let peers: Remote[];
+
  export let repo: Repo;
+
  export let revision: string | undefined;
+
  export let tree: Tree;
+
  export let nodeAvatarUrl: string | undefined;
+

+
  const api = new HttpdClient(baseUrl);
+

+
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+
  let error: any;
+
  let page = 0;
+
  let loading = false;
+
  let allCommitHeaders: CommitHeader[];
+

+
  $: baseRoute = {
+
    resource: "repo.history",
+
    node: baseUrl,
+
    repo: repo.rid,
+
  } as Extract<RepoRoute, { resource: "repo.history" }>;
+
  $: {
+
    allCommitHeaders = commitHeaders;
+
    page = 0;
+
  }
+

+
  async function loadMore() {
+
    loading = true;
+
    page += 1;
+
    try {
+
      const response = await api.repo.getAllCommits(repo.rid, {
+
        parent: allCommitHeaders[0].id,
+
        page,
+
        perPage: config.source.commitsPerPage,
+
      });
+
      allCommitHeaders = [...allCommitHeaders, ...response];
+
    } catch (e) {
+
      error = e;
+
    }
+
    loading = false;
+
  }
+
</script>
+

+
<style>
+
  .more {
+
    margin-top: 2rem;
+
    min-height: 3rem;
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
+
  }
+
  .group-header {
+
    margin-left: 1rem;
+
    margin-top: 3rem;
+
    margin-bottom: 1rem;
+
    font-size: var(--font-size-small);
+
    font-weight: var(--font-weight-medium);
+
    color: var(--color-foreground-dim);
+
  }
+
  .group-header:first-child {
+
    margin-top: 0;
+
  }
+
</style>
+

+
<Layout {nodeAvatarUrl} {seedingPolicy} {baseUrl} {repo} activeTab="source">
+
  <svelte:fragment slot="breadcrumb">
+
    <Separator />
+
    <Link
+
      route={{
+
        resource: "repo.history",
+
        repo: repo.rid,
+
        node: baseUrl,
+
      }}>
+
      Commits
+
    </Link>
+
  </svelte:fragment>
+
  <RepoNameHeader {repo} {baseUrl} slot="header" />
+

+
  <div style:margin="1rem" slot="subheader">
+
    <Header
+
      {baseRoute}
+
      {commit}
+
      {peers}
+
      {peer}
+
      {repo}
+
      {revision}
+
      {tree}
+
      node={baseUrl}
+
      filesLinkActive={false}
+
      historyLinkActive={true} />
+
  </div>
+

+
  <div>
+
    {#each groupCommits(allCommitHeaders) as group (group.time)}
+
      <div class="group-header">{group.date}</div>
+
      <List items={group.commits}>
+
        <CommitTeaser
+
          slot="item"
+
          let:item
+
          repoId={repo.rid}
+
          {baseUrl}
+
          commit={item} />
+
      </List>
+
    {/each}
+
  </div>
+

+
  {#await api.repo.getTreeStatsBySha(repo.rid, commit)}
+
    <div class="more">
+
      <Loading small center />
+
    </div>
+
  {:then stats}
+
    {#if loading || allCommitHeaders.length < stats.commits}
+
      <div class="more">
+
        {#if loading}
+
          <Loading small={page !== 0} center />
+
        {:else if allCommitHeaders.length < stats.commits}
+
          <Button size="large" variant="outline" on:click={loadMore}>
+
            More
+
          </Button>
+
        {/if}
+
      </div>
+
    {/if}
+

+
    {#if error}
+
      <div class="message">
+
        <ErrorMessage
+
          title="Couldn't load commits"
+
          description="Make sure you are able to connect to the seed <code>${baseUrlToString(
+
            api.baseUrl,
+
          )}</code>"
+
          {error} />
+
      </div>
+
    {/if}
+
  {:catch error}
+
    <div class="message">
+
      <ErrorMessage
+
        title="Couldn't load repo stats"
+
        description="Make sure you are able to connect to the seed <code>${baseUrlToString(
+
          api.baseUrl,
+
        )}</code>"
+
        {error} />
+
    </div>
+
  {/await}
+
</Layout>
added src/views/repos/Issue.svelte
@@ -0,0 +1,256 @@
+
<script lang="ts">
+
  import type { BaseUrl, Issue, Repo, SeedingPolicy } from "@http-client";
+

+
  import capitalize from "lodash/capitalize";
+
  import uniqBy from "lodash/uniqBy";
+

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

+
  import Assignees from "@app/views/repos/Cob/Assignees.svelte";
+
  import Badge from "@app/components/Badge.svelte";
+
  import CobHeader from "@app/views/repos/Cob/CobHeader.svelte";
+
  import Embeds from "@app/views/repos/Cob/Embeds.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Id from "@app/components/Id.svelte";
+
  import InlineTitle from "@app/views/repos/components/InlineTitle.svelte";
+
  import Labels from "@app/views/repos/Cob/Labels.svelte";
+
  import Layout from "./Layout.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import Markdown from "@app/components/Markdown.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
+
  import Reactions from "@app/components/Reactions.svelte";
+
  import Separator from "./Separator.svelte";
+
  import Share from "@app/views/repos/Share.svelte";
+
  import ThreadComponent from "@app/components/Thread.svelte";
+

+
  export let baseUrl: BaseUrl;
+
  export let seedingPolicy: SeedingPolicy;
+
  export let issue: Issue;
+
  export let repo: Repo;
+
  export let rawPath: (commit?: string) => string;
+
  export let nodeAvatarUrl: string | undefined;
+

+
  $: uniqueEmbeds = uniqBy(
+
    issue.discussion.flatMap(comment => comment.embeds),
+
    "content",
+
  );
+
  $: threads = issue.discussion
+
    .filter(
+
      comment =>
+
        (comment.id !== issue.discussion[0].id && !comment.replyTo) ||
+
        comment.replyTo === issue.discussion[0].id,
+
    )
+
    .map(thread => {
+
      return {
+
        root: thread,
+
        replies: issue.discussion
+
          .filter(comment => comment.replyTo === thread.id)
+
          .sort((a, b) => a.timestamp - b.timestamp),
+
      };
+
    }, []);
+
  $: lastDescriptionEdit =
+
    issue.discussion[0].edits.length > 1
+
      ? issue.discussion[0].edits.at(-1)
+
      : undefined;
+
</script>
+

+
<style>
+
  .issue {
+
    display: flex;
+
    flex: 1;
+
    min-height: 100%;
+
  }
+
  .main {
+
    display: flex;
+
    flex: 1;
+
    flex-direction: column;
+
    min-width: 0;
+
    background-color: var(--color-background-float);
+
  }
+
  .bottom {
+
    padding: 0 1rem 2.5rem 1rem;
+
    background-color: var(--color-background-default);
+
    height: 100%;
+
    border-top: 1px solid var(--color-border-hint);
+
  }
+
  .connector {
+
    width: 1px;
+
    height: 1.5rem;
+
    margin-left: 1.25rem;
+
    background-color: var(--color-fill-separator);
+
  }
+
  .metadata {
+
    display: flex;
+
    flex-direction: column;
+
    padding: 1rem;
+
    border-left: 1px solid var(--color-border-hint);
+
    gap: 1.5rem;
+
    width: 20rem;
+
  }
+

+
  .threads {
+
    display: flex;
+
    flex-direction: column;
+
  }
+

+
  .author-metadata {
+
    color: var(--color-fill-gray);
+
    font-size: var(--font-size-small);
+
  }
+
  .title {
+
    overflow: hidden;
+
    text-overflow: ellipsis;
+
    display: flex;
+
    align-items: center;
+
    gap: 0.5rem;
+
    font-weight: var(--font-weight-semibold);
+
    font-size: var(--font-size-large);
+
    word-break: break-word;
+
  }
+
  .reactions {
+
    display: flex;
+
    gap: 0.5rem;
+
    align-items: center;
+
    margin-left: -0.25rem;
+
  }
+
  .id {
+
    font-size: var(--font-size-small);
+
    font-family: var(--font-family-monospace);
+
    font-weight: var(--font-weight-semibold);
+
  }
+
  @media (max-width: 719.98px) {
+
    .bottom {
+
      padding: 0;
+
    }
+
  }
+
</style>
+

+
<Layout
+
  {baseUrl}
+
  {nodeAvatarUrl}
+
  {repo}
+
  {seedingPolicy}
+
  activeTab="issues"
+
  stylePaddingBottom="0">
+
  <svelte:fragment slot="breadcrumb">
+
    <Separator />
+
    <Link
+
      route={{
+
        resource: "repo.issues",
+
        repo: repo.rid,
+
        node: baseUrl,
+
      }}>
+
      Issues
+
    </Link>
+
    <Separator />
+
    <span class="id">
+
      <div class="global-hide-on-small-desktop-down">
+
        {issue.id}
+
      </div>
+
      <div class="global-hide-on-medium-desktop-up">
+
        {utils.formatObjectId(issue.id)}
+
      </div>
+
    </span>
+
  </svelte:fragment>
+

+
  <div class="issue">
+
    <div class="main">
+
      <CobHeader>
+
        <svelte:fragment slot="title">
+
          <div style="display: flex; gap: 1rem; width: 100%;">
+
            {#if issue.title}
+
              <div class="title">
+
                <InlineTitle fontSize="large" content={issue.title} />
+
              </div>
+
            {:else}
+
              <span class="txt-missing">No title</span>
+
            {/if}
+
          </div>
+
          <Share />
+
        </svelte:fragment>
+
        <svelte:fragment slot="state">
+
          {#if issue.state.status === "open"}
+
            <Badge size="tiny" variant="positive">
+
              <Icon name="issue" />
+
              {capitalize(issue.state.status)}
+
            </Badge>
+
          {:else}
+
            <Badge size="tiny" variant="negative">
+
              <Icon name="issue" />
+
              {capitalize(issue.state.status)} as
+
              {issue.state.reason}
+
            </Badge>
+
          {/if}
+
          <NodeId
+
            {baseUrl}
+
            nodeId={issue.author.id}
+
            alias={issue.author.alias} />
+
          opened
+
          <Id id={issue.id} />
+
          <span title={utils.absoluteTimestamp(issue.discussion[0].timestamp)}>
+
            {utils.formatTimestamp(issue.discussion[0].timestamp)}
+
          </span>
+
          {#if lastDescriptionEdit}
+
            <div
+
              class="author-metadata"
+
              title={utils.formatEditedCaption(
+
                lastDescriptionEdit.author,
+
                lastDescriptionEdit.timestamp,
+
              )}>
+
              • edited
+
            </div>
+
          {/if}
+
        </svelte:fragment>
+
        <div slot="subtitle" class="global-hide-on-desktop-up">
+
          <div
+
            style:margin-top="2rem"
+
            style="display: flex; flex-direction: column; gap: 0.5rem;">
+
            <Assignees assignees={issue.assignees} />
+
            <Labels labels={issue.labels} />
+
            <Embeds embeds={uniqueEmbeds} />
+
          </div>
+
        </div>
+
        <svelte:fragment slot="description">
+
          {#if issue.discussion[0].body}
+
            <Markdown
+
              breaks
+
              content={issue.discussion[0].body}
+
              rawPath={rawPath(
+
                repo.payloads["xyz.radicle.project"].meta.head,
+
              )} />
+
          {:else}
+
            <span class="txt-missing">No description</span>
+
          {/if}
+
          <div class="reactions">
+
            {#if issue.discussion[0].reactions.length > 0}
+
              <Reactions reactions={issue.discussion[0].reactions} />
+
            {/if}
+
          </div>
+
        </svelte:fragment>
+
      </CobHeader>
+
      <div class="bottom">
+
        {#if threads.length > 0}
+
          <div class="connector" />
+
          <div class="threads">
+
            {#each threads as thread, i (thread.root.id)}
+
              <ThreadComponent
+
                {baseUrl}
+
                {thread}
+
                rawPath={rawPath(
+
                  repo.payloads["xyz.radicle.project"].meta.head,
+
                )} />
+
              {#if i < threads.length - 1}
+
                <div class="connector" />
+
              {/if}
+
            {/each}
+
          </div>
+
        {/if}
+
      </div>
+
    </div>
+
    <div class="metadata global-hide-on-medium-desktop-down">
+
      <Assignees assignees={issue.assignees} />
+
      <Labels labels={issue.labels} />
+
      <Embeds embeds={uniqueEmbeds} />
+
    </div>
+
  </div>
+
</Layout>
added src/views/repos/Issue/IssueTeaser.svelte
@@ -0,0 +1,131 @@
+
<script lang="ts">
+
  import type { BaseUrl, Issue } from "@http-client";
+

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

+
  import CommentCounter from "../CommentCounter.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Id from "@app/components/Id.svelte";
+
  import InlineLabels from "../Cob/InlineLabels.svelte";
+
  import InlineTitle from "@app/views/repos/components/InlineTitle.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
+

+
  export let baseUrl: BaseUrl;
+
  export let issue: Issue;
+
  export let repoId: string;
+

+
  $: commentCount = issue.discussion.reduce((acc, _curr, index) => {
+
    if (index !== 0) {
+
      return acc + 1;
+
    }
+
    return acc;
+
  }, 0);
+
</script>
+

+
<style>
+
  .issue-teaser {
+
    display: flex;
+
    padding: 1.25rem;
+
    background-color: var(--color-background-float);
+
  }
+
  .issue-teaser:hover {
+
    background-color: var(--color-fill-float-hover);
+
  }
+
  .content {
+
    gap: 0.5rem;
+
    display: flex;
+
    flex-direction: column;
+
    flex: 1;
+
  }
+
  .subtitle {
+
    display: flex;
+
    flex-direction: column;
+
    flex-wrap: wrap;
+
    font-size: var(--font-size-small);
+
    gap: 0.5rem;
+
  }
+
  .summary {
+
    display: flex;
+
    align-items: flex-start;
+
    gap: 0.5rem;
+
    word-break: break-word;
+
  }
+
  .right {
+
    display: flex;
+
    margin-left: auto;
+
    min-height: 1.5rem;
+
    align-items: center;
+
  }
+
  .state {
+
    justify-self: center;
+
    align-self: flex-start;
+
    margin-right: 0.5rem;
+
    padding: 0.25rem 0;
+
  }
+
  .open {
+
    color: var(--color-fill-success);
+
  }
+
  .closed {
+
    color: var(--color-foreground-red);
+
  }
+
</style>
+

+
<div role="button" tabindex="0" class="issue-teaser">
+
  <div
+
    class="state"
+
    class:closed={issue.state.status === "closed"}
+
    class:open={issue.state.status === "open"}>
+
    <Icon name="issue" />
+
  </div>
+
  <div class="content">
+
    <div class="summary">
+
      <span class="issue-title">
+
        <Link
+
          styleHoverState
+
          route={{
+
            resource: "repo.issue",
+
            repo: repoId,
+
            node: baseUrl,
+
            issue: issue.id,
+
          }}>
+
          {#if !issue.title}
+
            <span class="txt-missing">No title</span>
+
          {:else}
+
            <InlineTitle fontSize="regular" content={issue.title} />
+
          {/if}
+
        </Link>
+
      </span>
+
      {#if issue.labels.length > 0}
+
        <span
+
          class="global-hide-on-small-desktop-down"
+
          style="display: inline-flex; gap: 0.5rem;">
+
          <InlineLabels labels={issue.labels} />
+
        </span>
+
      {/if}
+
      <div class="right">
+
        {#if commentCount > 0}
+
          <CommentCounter {commentCount} />
+
        {/if}
+
      </div>
+
    </div>
+
    <div class="subtitle">
+
      {#if issue.labels.length > 0}
+
        <div
+
          class="global-hide-on-medium-desktop-up"
+
          style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
+
          <InlineLabels labels={issue.labels} />
+
        </div>
+
      {/if}
+
      <div
+
        style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;">
+
        <NodeId {baseUrl} nodeId={issue.author.id} alias={issue.author.alias} />
+
        opened
+
        <Id id={issue.id} />
+
        <span title={absoluteTimestamp(issue.discussion[0].timestamp)}>
+
          {formatTimestamp(issue.discussion[0].timestamp)}
+
        </span>
+
      </div>
+
    </div>
+
  </div>
+
</div>
added src/views/repos/Issues.svelte
@@ -0,0 +1,228 @@
+
<script lang="ts">
+
  import type {
+
    BaseUrl,
+
    Issue,
+
    IssueState,
+
    Repo,
+
    SeedingPolicy,
+
  } from "@http-client";
+

+
  import capitalize from "lodash/capitalize";
+
  import { HttpdClient } from "@http-client";
+
  import { ISSUES_PER_PAGE } from "./router";
+
  import { baseUrlToString } from "@app/lib/utils";
+
  import { closeFocused } from "@app/components/Popover.svelte";
+

+
  import Button from "@app/components/Button.svelte";
+
  import DropdownList from "@app/components/DropdownList.svelte";
+
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
+
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import IssueTeaser from "@app/views/repos/Issue/IssueTeaser.svelte";
+
  import Layout from "./Layout.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import List from "@app/components/List.svelte";
+
  import Loading from "@app/components/Loading.svelte";
+
  import Placeholder from "@app/components/Placeholder.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+
  import Separator from "./Separator.svelte";
+
  import Share from "./Share.svelte";
+

+
  export let baseUrl: BaseUrl;
+
  export let seedingPolicy: SeedingPolicy;
+
  export let issues: Issue[];
+
  export let repo: Repo;
+
  export let status: IssueState["status"];
+
  export let nodeAvatarUrl: string | undefined;
+

+
  let loading = false;
+
  let page = 0;
+
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+
  let error: any;
+
  let allIssues: Issue[];
+

+
  $: {
+
    allIssues = issues;
+
    page = 0;
+
  }
+

+
  const api = new HttpdClient(baseUrl);
+

+
  async function loadIssues(status: IssueState["status"]): Promise<void> {
+
    loading = true;
+
    page += 1;
+
    try {
+
      const response = await api.repo.getAllIssues(repo.rid, {
+
        status,
+
        page,
+
        perPage: ISSUES_PER_PAGE,
+
      });
+
      allIssues = [...allIssues, ...response];
+
    } catch (e) {
+
      error = e;
+
    } finally {
+
      loading = false;
+
    }
+
  }
+

+
  const stateOptions: IssueState["status"][] = ["open", "closed"];
+
  const stateColor: Record<IssueState["status"], string> = {
+
    open: "var(--color-fill-success)",
+
    closed: "var(--color-foreground-red)",
+
  };
+

+
  $: showMoreButton =
+
    !loading &&
+
    !error &&
+
    allIssues.length < repo.payloads["xyz.radicle.project"].meta.issues[status];
+
</script>
+

+
<style>
+
  .header {
+
    display: flex;
+
    justify-content: space-between;
+
    padding: 1rem;
+
  }
+
  .more {
+
    margin-top: 2rem;
+
    min-height: 3rem;
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
+
  }
+
  .dropdown-button-counter {
+
    border-radius: var(--border-radius-tiny);
+
    background-color: var(--color-fill-counter);
+
    color: var(--color-foreground-contrast);
+
    padding: 0 0.25rem;
+
  }
+
  .dropdown-list-counter {
+
    border-radius: var(--border-radius-tiny);
+
    background-color: var(--color-fill-ghost);
+
    color: var(--color-foreground-dim);
+
    padding: 0 0.25rem;
+
  }
+
  .selected {
+
    background-color: var(--color-fill-counter);
+
    color: var(--color-foreground-dim);
+
  }
+
  .placeholder {
+
    height: calc(100% - 4rem);
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
+
  }
+
  @media (max-width: 719.98px) {
+
    .placeholder {
+
      height: calc(100vh - 10rem);
+
    }
+
  }
+
</style>
+

+
<Layout {nodeAvatarUrl} {seedingPolicy} {baseUrl} {repo} activeTab="issues">
+
  <svelte:fragment slot="breadcrumb">
+
    <Separator />
+
    <Link
+
      route={{
+
        resource: "repo.issues",
+
        repo: repo.rid,
+
        node: baseUrl,
+
      }}>
+
      Issues
+
    </Link>
+
  </svelte:fragment>
+
  <div slot="header" class="header">
+
    <Popover
+
      popoverPadding="0"
+
      popoverPositionTop="2.5rem"
+
      popoverBorderRadius="var(--border-radius-small)">
+
      <Button
+
        let:expanded
+
        slot="toggle"
+
        let:toggle
+
        on:click={toggle}
+
        ariaLabel="filter-dropdown"
+
        title="Filter issues by state">
+
        <div style:color={stateColor[status]}>
+
          <Icon name="issue" />
+
        </div>
+
        {capitalize(status)}
+
        <div class="dropdown-button-counter">
+
          {repo.payloads["xyz.radicle.project"].meta.issues[status]}
+
        </div>
+
        <Icon name={expanded ? "chevron-up" : "chevron-down"} />
+
      </Button>
+

+
      <DropdownList slot="popover" items={stateOptions}>
+
        <Link
+
          on:afterNavigate={() => closeFocused()}
+
          slot="item"
+
          let:item
+
          route={{
+
            resource: "repo.issues",
+
            repo: repo.rid,
+
            node: baseUrl,
+
            status: item,
+
          }}>
+
          <DropdownListItem selected={item === status}>
+
            <div style:color={stateColor[item]}>
+
              <Icon name="issue" />
+
            </div>
+
            <div
+
              style="display: flex; gap: 1rem;justify-content: space-between; width: 100%;">
+
              {capitalize(item)}
+
              <div
+
                class="dropdown-list-counter"
+
                class:selected={item === status}>
+
                {repo.payloads["xyz.radicle.project"].meta.issues[item]}
+
              </div>
+
            </div>
+
          </DropdownListItem>
+
        </Link>
+
      </DropdownList>
+
    </Popover>
+

+
    <Share />
+
  </div>
+

+
  <List items={allIssues}>
+
    <IssueTeaser
+
      slot="item"
+
      let:item
+
      {baseUrl}
+
      repoId={repo.rid}
+
      issue={item} />
+
  </List>
+

+
  {#if error}
+
    <ErrorMessage
+
      title="Couldn't load issues"
+
      description="Please make sure you are able to connect to the seed <code>${baseUrlToString(
+
        api.baseUrl,
+
      )}</code>"
+
      {error} />
+
  {/if}
+

+
  {#if repo.payloads["xyz.radicle.project"].meta.issues[status] === 0}
+
    <div class="placeholder">
+
      <Placeholder iconName="no-issues" caption={`No ${status} issues`} />
+
    </div>
+
  {/if}
+

+
  {#if loading || showMoreButton}
+
    <div class="more">
+
      {#if loading}
+
        <Loading noDelay small={page !== 0} center />
+
      {/if}
+

+
      {#if showMoreButton}
+
        <Button
+
          size="large"
+
          variant="outline"
+
          on:click={() => loadIssues(status)}>
+
          More
+
        </Button>
+
      {/if}
+
    </div>
+
  {/if}
+
</Layout>
added src/views/repos/Layout.svelte
@@ -0,0 +1,225 @@
+
<script lang="ts">
+
  import type { ActiveTab } from "./Header.svelte";
+
  import type { BaseUrl, Repo, SeedingPolicy } from "@http-client";
+

+
  import Button from "@app/components/Button.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import MobileFooter from "@app/App/MobileFooter.svelte";
+
  import Separator from "./Separator.svelte";
+
  import Sidebar from "@app/views/repos/Sidebar.svelte";
+

+
  export let activeTab: ActiveTab | undefined = undefined;
+
  export let seedingPolicy: SeedingPolicy;
+
  export let baseUrl: BaseUrl;
+
  export let repo: Repo;
+
  export let stylePaddingBottom: string = "2.5rem";
+
  export let nodeAvatarUrl: string | undefined;
+
</script>
+

+
<style>
+
  .layout {
+
    display: grid;
+
    grid-template: auto 1fr auto / auto 1fr auto;
+
    height: 100%;
+
  }
+

+
  .desktop-header {
+
    grid-column: 1 / 4;
+
    border-bottom: 1px solid var(--color-fill-separator);
+
  }
+

+
  header {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.5rem;
+
    margin: 0;
+
    padding: 0.5rem 0.5rem 0.5rem 1rem;
+
    height: 3.5rem;
+
    justify-content: space-between;
+
  }
+

+
  .logo {
+
    height: var(--button-regular-height);
+
    margin: 0 0.5rem;
+
  }
+

+
  .sidebar {
+
    grid-column: 1 / 2;
+
    border-right: 1px solid var(--color-fill-separator);
+
  }
+

+
  .content {
+
    grid-column: 2 / 3;
+
    overflow: scroll;
+
  }
+

+
  .mobile-footer {
+
    display: none;
+
  }
+

+
  .breadcrumbs {
+
    display: flex;
+
    align-items: center;
+
    column-gap: 0.25rem;
+
    line-height: 1rem;
+
    font-weight: var(--font-weight-semibold);
+
    font-size: var(--font-size-small);
+
    white-space: nowrap;
+
    flex-wrap: wrap;
+
  }
+
  .breadcrumb {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.25rem;
+
  }
+
  .breadcrumb :global(a:hover) {
+
    color: var(--color-fill-secondary);
+
  }
+
  .avatar {
+
    border-radius: var(--border-radius-tiny);
+
    margin-right: 0.5rem;
+
  }
+

+
  @media (max-width: 719.98px) {
+
    .desktop-header {
+
      display: none;
+
    }
+
    .content {
+
      overflow-y: scroll;
+
      overflow-x: hidden;
+
    }
+
    .mobile-footer {
+
      margin-top: auto;
+
      display: grid;
+
      grid-column: 1 / 4;
+
      background-color: pink;
+
    }
+
  }
+
</style>
+

+
<div class="layout">
+
  <div class="desktop-header">
+
    <header>
+
      <div class="breadcrumbs">
+
        <span class="breadcrumb">
+
          <Link
+
            style="display: flex; align-items: center; gap: 0.25rem;"
+
            route={{
+
              resource: "nodes",
+
              params: {
+
                baseUrl,
+
                repoPageIndex: 0,
+
              },
+
            }}>
+
            <img
+
              width="24"
+
              height="24"
+
              class="avatar"
+
              alt="Radicle logo"
+
              src={nodeAvatarUrl
+
                ? nodeAvatarUrl
+
                : "/images/default-seed-avatar.png"} />
+
            {baseUrl.hostname}
+
          </Link>
+
        </span>
+

+
        <Separator />
+

+
        <span class="breadcrumb" title={repo.rid}>
+
          <Link
+
            route={{
+
              resource: "repo.source",
+
              repo: repo.rid,
+
              node: baseUrl,
+
            }}>
+
            <div class="breadcrumb">
+
              {repo.payloads["xyz.radicle.project"].data.name}
+
            </div>
+
          </Link>
+
        </span>
+

+
        <div class="breadcrumb">
+
          <slot name="breadcrumb" />
+
        </div>
+
      </div>
+
      <Link
+
        style="display: flex; align-items: center;"
+
        route={{ resource: "nodes", params: undefined }}>
+
        <img
+
          width="24"
+
          height="24"
+
          class="logo"
+
          alt="Radicle logo"
+
          src="/radicle.svg" />
+
      </Link>
+
    </header>
+
  </div>
+

+
  <div class="sidebar global-hide-on-medium-desktop-down">
+
    <Sidebar {seedingPolicy} {activeTab} {baseUrl} {repo} />
+
  </div>
+

+
  <div class="sidebar global-hide-on-mobile-down global-hide-on-desktop-up">
+
    <Sidebar {seedingPolicy} {activeTab} {baseUrl} {repo} collapsedOnly />
+
  </div>
+

+
  <div class="content" style:padding-bottom={stylePaddingBottom}>
+
    <slot name="header" />
+
    <slot name="subheader" />
+
    <slot />
+
  </div>
+

+
  <div class="mobile-footer">
+
    <MobileFooter>
+
      <div style:width="100%">
+
        <Link
+
          title="Home"
+
          route={{
+
            resource: "repo.source",
+
            repo: repo.rid,
+
            node: baseUrl,
+
            path: "/",
+
          }}>
+
          <Button
+
            variant={activeTab === "source" ? "secondary" : "secondary-mobile"}
+
            styleWidth="100%">
+
            <Icon name="chevron-left-right" />
+
          </Button>
+
        </Link>
+
      </div>
+

+
      <div style:width="100%">
+
        <Link
+
          title={`${repo.payloads["xyz.radicle.project"].meta.issues.open} Issues`}
+
          route={{
+
            resource: "repo.issues",
+
            repo: repo.rid,
+
            node: baseUrl,
+
          }}>
+
          <Button
+
            variant={activeTab === "issues" ? "secondary" : "secondary-mobile"}
+
            styleWidth="100%">
+
            <Icon name="issue" />
+
          </Button>
+
        </Link>
+
      </div>
+

+
      <div style:width="100%">
+
        <Link
+
          title={`${repo.payloads["xyz.radicle.project"].meta.patches.open} Patches`}
+
          route={{
+
            resource: "repo.patches",
+
            repo: repo.rid,
+
            node: baseUrl,
+
          }}>
+
          <Button
+
            variant={activeTab === "patches" ? "secondary" : "secondary-mobile"}
+
            styleWidth="100%">
+
            <Icon name="patch" />
+
          </Button>
+
        </Link>
+
      </div>
+
    </MobileFooter>
+
  </div>
+
</div>
added src/views/repos/Patch.svelte
@@ -0,0 +1,512 @@
+
<script lang="ts" context="module">
+
  import type {
+
    Author,
+
    Comment,
+
    Review,
+
    Merge,
+
    Repo,
+
    Revision,
+
    Diff,
+
    SeedingPolicy,
+
  } from "@http-client";
+

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

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

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

+
  interface TimelineThread {
+
    inner: Thread;
+
    type: "thread";
+
    timestamp: number;
+
  }
+

+
  export type Timeline = TimelineMerge | TimelineReview | TimelineThread;
+
  export type PatchReviews = Record<
+
    string,
+
    { latest: boolean; review: Review }
+
  >;
+
</script>
+

+
<script lang="ts">
+
  import type { BaseUrl, Patch } from "@http-client";
+
  import type { PatchView } from "./router";
+
  import type { Route } from "@app/lib/router";
+
  import type { ComponentProps } from "svelte";
+

+
  import * as utils from "@app/lib/utils";
+
  import capitalize from "lodash/capitalize";
+
  import uniqBy from "lodash/uniqBy";
+

+
  import Badge from "@app/components/Badge.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import Changeset from "@app/views/repos/Changeset.svelte";
+
  import CheckoutButton from "@app/views/repos/Patch/CheckoutButton.svelte";
+
  import CobHeader from "@app/views/repos/Cob/CobHeader.svelte";
+
  import CompareButton from "@app/views/repos/Patch/CompareButton.svelte";
+
  import DiffStatBadge from "@app/components/DiffStatBadge.svelte";
+
  import Embeds from "@app/views/repos/Cob/Embeds.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Id from "@app/components/Id.svelte";
+
  import InlineTitle from "@app/views/repos/components/InlineTitle.svelte";
+
  import Labels from "@app/views/repos/Cob/Labels.svelte";
+
  import Layout from "@app/views/repos/Layout.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import Markdown from "@app/components/Markdown.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
+
  import Placeholder from "@app/components/Placeholder.svelte";
+
  import Radio from "@app/components/Radio.svelte";
+
  import Reactions from "@app/components/Reactions.svelte";
+
  import Reviews from "@app/views/repos/Cob/Reviews.svelte";
+
  import RevisionComponent from "@app/views/repos/Cob/Revision.svelte";
+
  import RevisionSelector from "@app/views/repos/Patch/RevisionSelector.svelte";
+
  import Separator from "./Separator.svelte";
+
  import Share from "@app/views/repos/Share.svelte";
+

+
  export let baseUrl: BaseUrl;
+
  export let seedingPolicy: SeedingPolicy;
+
  export let patch: Patch;
+
  export let stats: Diff["stats"];
+
  export let rawPath: (commit?: string) => string;
+
  export let repo: Repo;
+
  export let view: PatchView;
+
  export let nodeAvatarUrl: string | undefined;
+

+
  function badgeColor(status: string): ComponentProps<Badge>["variant"] {
+
    if (status === "draft") {
+
      return "foreground";
+
    } else if (status === "open") {
+
      return "positive";
+
    } else if (status === "archived") {
+
      return "caution";
+
    } else if (status === "merged") {
+
      return "primary";
+
    } else {
+
      return "foreground";
+
    }
+
  }
+

+
  type Tab = "activity" | "changes";
+

+
  let tabs: Record<Tab, { icon: ComponentProps<Icon>["name"]; route: Route }>;
+
  $: {
+
    const baseRoute = {
+
      resource: "repo.patch",
+
      repo: repo.rid,
+
      node: baseUrl,
+
      patch: patch.id,
+
    } as const;
+
    // For cleaner URLs, we omit the the revision part when we link to the
+
    // latest revision.
+
    const latestRevisionId = patch.revisions[patch.revisions.length - 1].id;
+
    const revision = latestRevisionId === revisionId ? undefined : revisionId;
+
    tabs = {
+
      activity: {
+
        route: {
+
          ...baseRoute,
+
          view: { name: "activity" },
+
        },
+
        icon: "activity",
+
      },
+
      changes: {
+
        route: {
+
          ...baseRoute,
+
          view: { name: "changes", revision },
+
        },
+
        icon: "diff",
+
      },
+
    };
+
  }
+

+
  function computeReviews(patch: Patch) {
+
    const patchReviews: Record<string, { latest: boolean; review: Review }> =
+
      {};
+

+
    patch.revisions.forEach((rev, i) => {
+
      const latest = i === patch.revisions.length - 1;
+
      for (const review of rev.reviews) {
+
        patchReviews[review.author.id] = { latest, review };
+
      }
+
    });
+

+
    return patchReviews;
+
  }
+

+
  let revisionId: string;
+
  $: if (view.name === "diff") {
+
    revisionId = patch.revisions[patch.revisions.length - 1].id;
+
  } else {
+
    revisionId = view.revision;
+
  }
+

+
  $: uniqueEmbeds = uniqBy(
+
    patch.revisions.flatMap(({ discussions }) =>
+
      discussions.flatMap(comment => comment.embeds),
+
    ),
+
    "content",
+
  );
+
  $: description = patch.revisions[0].description;
+
  $: lastEdit = patch.revisions[0].edits.at(-1);
+
  $: reviews = computeReviews(patch);
+
  $: timelineTuple = patch.revisions.map<
+
    [
+
      {
+
        revisionId: string;
+
        revisionTimestamp: number;
+
        revisionBase: string;
+
        revisionOid: string;
+
        revisionEdits: Revision["edits"];
+
        revisionReactions: Revision["reactions"];
+
        revisionAuthor: Author;
+
        revisionDescription: string;
+
      },
+
      Timeline[],
+
    ]
+
  >(rev => [
+
    {
+
      revisionId: rev.id,
+
      revisionTimestamp: rev.timestamp,
+
      revisionBase: rev.base,
+
      revisionOid: rev.oid,
+
      revisionEdits: rev.edits,
+
      revisionReactions: rev.reactions,
+
      revisionAuthor: rev.author,
+
      revisionDescription: rev.description,
+
    },
+
    [
+
      ...rev.reviews.map<TimelineReview>(review => ({
+
        timestamp: review.timestamp,
+
        type: "review",
+
        inner: [review.author.id, review],
+
      })),
+
      ...patch.merges
+
        .filter(merge => merge.revision === rev.id)
+
        .map<TimelineMerge>(inner => ({
+
          timestamp: inner.timestamp,
+
          type: "merge",
+
          inner,
+
        })),
+
      ...rev.discussions
+
        .filter(comment => !comment.replyTo)
+
        .map<TimelineThread>(thread => ({
+
          timestamp: thread.timestamp,
+
          type: "thread",
+
          inner: {
+
            root: thread,
+
            replies: rev.discussions
+
              .filter(comment => comment.replyTo === thread.id)
+
              .sort((a, b) => a.timestamp - b.timestamp),
+
          },
+
        })),
+
    ].sort((a, b) => a.timestamp - b.timestamp),
+
  ]);
+
  $: firstRevision = timelineTuple[0][0];
+
  $: latestRevision = patch.revisions[patch.revisions.length - 1];
+
</script>
+

+
<style>
+
  .patch {
+
    display: flex;
+
    flex: 1;
+
    min-height: 100%;
+
  }
+
  .main {
+
    display: flex;
+
    flex: 1;
+
    flex-direction: column;
+
    min-width: 0;
+
    background-color: var(--color-background-float);
+
  }
+
  .metadata {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 1.5rem;
+
    font-size: var(--font-size-small);
+
    padding: 1rem;
+
    border-left: 1px solid var(--color-border-hint);
+
    border-left: 1px solid var(--color-border-hint);
+
    width: 20rem;
+
  }
+
  .title {
+
    overflow: hidden;
+
    text-overflow: ellipsis;
+
    display: flex;
+
    align-items: center;
+
    gap: 0.5rem;
+
    font-weight: var(--font-weight-semibold);
+
    font-size: var(--font-size-large);
+
    word-break: break-word;
+
  }
+
  .bottom {
+
    background-color: var(--color-background-default);
+
    padding: 1rem 1rem 0.5rem 1rem;
+
    height: 100%;
+
  }
+
  .tabs {
+
    font-size: var(--font-size-tiny);
+
    display: flex;
+
    align-items: center;
+
    justify-content: left;
+
    flex-wrap: wrap;
+
    position: relative;
+
    margin-top: 1rem;
+
    box-shadow: inset 0 -1px 0 var(--color-border-hint);
+
  }
+
  .tabs-spacer {
+
    width: 1rem;
+
    height: 100%;
+
  }
+
  .author-metadata {
+
    color: var(--color-fill-gray);
+
    font-size: var(--font-size-small);
+
  }
+
  .revision-description {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 0.5rem;
+
    width: 100%;
+
  }
+
  .id {
+
    font-size: var(--font-size-small);
+
    font-family: var(--font-family-monospace);
+
    font-weight: var(--font-weight-semibold);
+
  }
+
  @media (max-width: 719.98px) {
+
    .patch {
+
      display: block;
+
    }
+
    .bottom {
+
      padding: 1rem 0 0 0;
+
    }
+
  }
+
</style>
+

+
<Layout
+
  {baseUrl}
+
  {repo}
+
  {nodeAvatarUrl}
+
  {seedingPolicy}
+
  activeTab="patches"
+
  stylePaddingBottom="0">
+
  <svelte:fragment slot="breadcrumb">
+
    <Separator />
+
    <Link
+
      route={{
+
        resource: "repo.patches",
+
        repo: repo.rid,
+
        node: baseUrl,
+
      }}>
+
      Patches
+
    </Link>
+
    <Separator />
+
    <span class="id">
+
      <div class="global-hide-on-small-desktop-down">
+
        {patch.id}
+
      </div>
+
      <div class="global-hide-on-medium-desktop-up">
+
        {utils.formatObjectId(patch.id)}
+
      </div>
+
    </span>
+
  </svelte:fragment>
+
  <div class="patch">
+
    <div class="main">
+
      <CobHeader>
+
        <svelte:fragment slot="title">
+
          {#if patch.title}
+
            <div class="title">
+
              <InlineTitle fontSize="large" content={patch.title} />
+
            </div>
+
          {:else}
+
            <span class="txt-missing">No title</span>
+
          {/if}
+
          <div class="global-flex-item">
+
            <Share />
+
            <div class="global-hide-on-mobile-down">
+
              <CheckoutButton id={patch.id} />
+
            </div>
+
          </div>
+
        </svelte:fragment>
+
        <svelte:fragment slot="state">
+
          <Badge size="tiny" variant={badgeColor(patch.state.status)}>
+
            <Icon name="patch" />
+
            {capitalize(patch.state.status)}
+
          </Badge>
+
          <Link
+
            route={{
+
              resource: "repo.patch",
+
              repo: repo.rid,
+
              node: baseUrl,
+
              patch: patch.id,
+
              view: { name: "changes", revision: latestRevision.id },
+
            }}>
+
            <DiffStatBadge
+
              hoverable
+
              insertions={stats.insertions}
+
              deletions={stats.deletions} />
+
          </Link>
+
          <NodeId
+
            {baseUrl}
+
            nodeId={patch.author.id}
+
            alias={patch.author.alias} />
+
          opened
+
          <Id id={patch.id} />
+
          <span title={utils.absoluteTimestamp(patch.revisions[0].timestamp)}>
+
            {utils.formatTimestamp(patch.revisions[0].timestamp)}
+
          </span>
+
          {#if patch.revisions[0].edits.length > 1 && lastEdit}
+
            <div
+
              class="author-metadata"
+
              title={utils.formatEditedCaption(
+
                lastEdit.author,
+
                lastEdit.timestamp,
+
              )}>
+
              • edited
+
            </div>
+
          {/if}
+
        </svelte:fragment>
+
        <div slot="subtitle" class="global-hide-on-desktop-up">
+
          <div
+
            style:margin-top="2rem"
+
            style="display: flex; flex-direction: column; gap: 0.5rem;">
+
            <Reviews {baseUrl} {reviews} />
+
            <Labels labels={patch.labels} />
+
            <Embeds embeds={uniqueEmbeds} />
+
          </div>
+
        </div>
+
        <svelte:fragment slot="description">
+
          <div class="revision-description">
+
            {#if description}
+
              <Markdown
+
                breaks
+
                content={description}
+
                rawPath={rawPath(patch.id)} />
+
            {:else}
+
              <span class="txt-missing">No description available</span>
+
            {/if}
+
            {#if firstRevision.revisionReactions.length > 0}
+
              <Reactions reactions={firstRevision.revisionReactions} />
+
            {/if}
+
          </div>
+
        </svelte:fragment>
+
      </CobHeader>
+

+
      <div class="tabs">
+
        <div class="tabs-spacer" />
+
        <Radio styleGap="0.375rem">
+
          {#each Object.entries(tabs) as [name, { route, icon }]}
+
            <Link {route}>
+
              <Button
+
                size="large"
+
                variant={name === view.name ||
+
                (view.name === "diff" && name === "changes")
+
                  ? "tab-active"
+
                  : "tab"}>
+
                <Icon name={icon} />
+
                {capitalize(name)}
+
              </Button>
+
            </Link>
+
          {/each}
+
        </Radio>
+

+
        {#if view.name === "changes"}
+
          <div
+
            class="global-hide-on-mobile-down"
+
            style="margin-left: auto; margin-top: -0.5rem;">
+
            <RevisionSelector {view} {baseUrl} {patch} {repo} />
+
          </div>
+
        {/if}
+
        {#if view.name === "diff"}
+
          <div
+
            class="global-hide-on-mobile-down"
+
            style="margin-left: auto; margin-top: -0.5rem;">
+
            <div style:margin-left="auto">
+
              <CompareButton
+
                fromCommit={view.fromCommit}
+
                toCommit={view.toCommit} />
+
            </div>
+
          </div>
+
        {/if}
+
        <div class="tabs-spacer" />
+
      </div>
+
      <div class="bottom">
+
        {#if view.name === "changes"}
+
          <div
+
            style:width="100%"
+
            style:padding="0 1rem"
+
            style:display="flex"
+
            class="global-hide-on-small-desktop-up">
+
            <RevisionSelector {view} {baseUrl} {patch} {repo} />
+
          </div>
+
        {/if}
+
        {#if view.name === "diff"}
+
          <div
+
            style:width="100%"
+
            style:padding="0 1rem"
+
            style:display="flex"
+
            class="global-hide-on-small-desktop-up">
+
            <CompareButton
+
              fromCommit={view.fromCommit}
+
              toCommit={view.toCommit} />
+
          </div>
+
          <Changeset
+
            {baseUrl}
+
            repoId={repo.rid}
+
            revision={view.toCommit}
+
            files={view.files}
+
            diff={view.diff} />
+
        {:else if view.name === "activity"}
+
          {#each timelineTuple as [revision, timelines], index}
+
            {@const previousRevision =
+
              index > 0 ? patch.revisions[index - 1] : undefined}
+
            <RevisionComponent
+
              {baseUrl}
+
              {rawPath}
+
              repoId={repo.rid}
+
              {timelines}
+
              {...revision}
+
              first={index === 0}
+
              patchId={patch.id}
+
              patchState={patch.state}
+
              initiallyExpanded={index === patch.revisions.length - 1}
+
              previousRevId={previousRevision?.id}
+
              previousRevBase={previousRevision?.base}
+
              previousRevOid={previousRevision?.oid} />
+
          {:else}
+
            <div style:margin="4rem 0">
+
              <Placeholder
+
                iconName="no-patches"
+
                caption="No activity on this patch yet" />
+
            </div>
+
          {/each}
+
        {:else if view.name === "changes"}
+
          <Changeset
+
            {baseUrl}
+
            repoId={repo.rid}
+
            revision={view.oid}
+
            files={view.files}
+
            diff={view.diff} />
+
        {:else}
+
          {utils.unreachable(view)}
+
        {/if}
+
      </div>
+
    </div>
+

+
    <div class="metadata global-hide-on-medium-desktop-down">
+
      <Reviews {baseUrl} {reviews} />
+
      <Labels labels={patch.labels} />
+
      <Embeds embeds={uniqueEmbeds} />
+
    </div>
+
  </div>
+
</Layout>
added src/views/repos/Patch/CheckoutButton.svelte
@@ -0,0 +1,37 @@
+
<script lang="ts">
+
  import Button from "@app/components/Button.svelte";
+
  import Command from "@app/components/Command.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+

+
  export let id: string;
+
</script>
+

+
<style>
+
  .label {
+
    display: block;
+
    font-size: var(--font-size-small);
+
    font-weight: var(--font-weight-regular);
+
    margin-bottom: 0.75rem;
+
  }
+
</style>
+

+
<Popover popoverPositionTop="2.5rem" popoverPositionRight="0">
+
  <Button
+
    slot="toggle"
+
    let:toggle
+
    variant="secondary-toggle-off"
+
    on:click={() => {
+
      toggle();
+
    }}>
+
    <Icon name="branch" />
+
    <span class="global-hide-on-small-desktop-down">Checkout</span>
+
  </Button>
+

+
  <div slot="popover" style:width="20rem">
+
    <span class="label">
+
      Run this command from a Radicle working copy to checkout this patch.
+
    </span>
+
    <Command command={`rad patch checkout ${id}`} />
+
  </div>
+
</Popover>
added src/views/repos/Patch/CompareButton.svelte
@@ -0,0 +1,17 @@
+
<script lang="ts">
+
  import Button from "@app/components/Button.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+

+
  export let fromCommit: string;
+
  export let toCommit: string;
+
</script>
+

+
<Button size="regular" disabled>
+
  <span style:color="var(--color-foregroung-disabled)">Compare</span>
+
  <span
+
    style:color="var(--color-foregroung-disabled)"
+
    style:font-family="var(--font-family-monospace)">
+
    {fromCommit.substring(0, 6)}..{toCommit.substring(0, 6)}
+
  </span>
+
  <Icon name={"chevron-down"} />
+
</Button>
added src/views/repos/Patch/PatchTeaser.svelte
@@ -0,0 +1,154 @@
+
<script lang="ts">
+
  import type { BaseUrl } from "@http-client";
+
  import type { Patch } from "@http-client";
+

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

+
  import CommentCounter from "../CommentCounter.svelte";
+
  import DiffStatBadgeLoader from "../DiffStatBadgeLoader.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Id from "@app/components/Id.svelte";
+
  import InlineLabels from "@app/views/repos/Cob/InlineLabels.svelte";
+
  import InlineTitle from "@app/views/repos/components/InlineTitle.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
+

+
  export let repoId: string;
+
  export let baseUrl: BaseUrl;
+
  export let patch: Patch;
+

+
  $: latestRevisionIndex = patch.revisions.length - 1;
+
  $: latestRevision = patch.revisions[latestRevisionIndex];
+

+
  $: commentCount = patch.revisions.reduce(
+
    (acc, curr) => acc + curr.discussions.reduce(acc => acc + 1, 0),
+
    0,
+
  );
+
</script>
+

+
<style>
+
  .patch-teaser {
+
    display: flex;
+
    padding: 1.25rem;
+
    background-color: var(--color-background-float);
+
  }
+
  .patch-teaser:hover {
+
    background-color: var(--color-fill-float-hover);
+
  }
+
  .content {
+
    width: 100%;
+
    gap: 0.5rem;
+
    display: flex;
+
    flex-direction: column;
+
  }
+
  .subtitle {
+
    display: flex;
+
    flex-direction: column;
+
    flex-wrap: wrap;
+
    font-size: var(--font-size-small);
+
    gap: 0.5rem;
+
  }
+
  .summary {
+
    display: flex;
+
    align-items: flex-start;
+
    gap: 0.5rem;
+
    word-break: break-word;
+
  }
+
  .right {
+
    margin-left: auto;
+
    display: flex;
+
    align-items: flex-start;
+
  }
+
  .state {
+
    justify-self: center;
+
    align-self: flex-start;
+
    margin-right: 0.5rem;
+
    padding: 0.25rem 0;
+
  }
+
  .draft {
+
    color: var(--color-foreground-dim);
+
  }
+
  .open {
+
    color: var(--color-fill-success);
+
  }
+
  .archived {
+
    color: var(--color-foreground-yellow);
+
  }
+
  .merged {
+
    color: var(--color-fill-primary);
+
  }
+
  .diff-comment {
+
    display: flex;
+
    flex-direction: row;
+
    gap: 0.5rem;
+
    min-height: 1.5rem;
+
  }
+
</style>
+

+
<div role="button" tabindex="0" class="patch-teaser">
+
  <div
+
    class="state"
+
    class:draft={patch.state.status === "draft"}
+
    class:open={patch.state.status === "open"}
+
    class:merged={patch.state.status === "merged"}
+
    class:archived={patch.state.status === "archived"}>
+
    <Icon name="patch" />
+
  </div>
+
  <div class="content">
+
    <div class="summary">
+
      <Link
+
        styleHoverState
+
        route={{
+
          resource: "repo.patch",
+
          repo: repoId,
+
          node: baseUrl,
+
          patch: patch.id,
+
        }}>
+
        <InlineTitle fontSize="regular" content={patch.title} />
+
      </Link>
+
      {#if patch.labels.length > 0}
+
        <span
+
          class="global-hide-on-small-desktop-down"
+
          style="display: inline-flex; gap: 0.5rem;">
+
          <InlineLabels labels={patch.labels} />
+
        </span>
+
      {/if}
+
      <div class="right">
+
        <div class="diff-comment">
+
          {#if commentCount > 0}
+
            <CommentCounter {commentCount} />
+
          {/if}
+
          <DiffStatBadgeLoader {repoId} {baseUrl} {patch} {latestRevision} />
+
        </div>
+
      </div>
+
    </div>
+
    <div class="summary">
+
      <span class="subtitle">
+
        {#if patch.labels.length > 0}
+
          <div
+
            class="global-hide-on-medium-desktop-up"
+
            style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
+
            <InlineLabels labels={patch.labels} />
+
          </div>
+
        {/if}
+
        <div
+
          style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;">
+
          <NodeId
+
            {baseUrl}
+
            nodeId={patch.author.id}
+
            alias={patch.author.alias} />
+
          {patch.revisions.length > 1 ? "updated" : "opened"}
+
          <Id id={patch.id} />
+
          {#if patch.revisions.length > 1}
+
            <span class="global-hide-on-mobile-down">
+
              to <Id id={patch.revisions[patch.revisions.length - 1].id} />
+
            </span>
+
          {/if}
+
          <span title={absoluteTimestamp(latestRevision.timestamp)}>
+
            {formatTimestamp(latestRevision.timestamp)}
+
          </span>
+
        </div>
+
      </span>
+
    </div>
+
  </div>
+
</div>
added src/views/repos/Patch/RevisionSelector.svelte
@@ -0,0 +1,76 @@
+
<script lang="ts">
+
  import type { PatchView } from "../router";
+
  import type { BaseUrl, Patch, Repo } from "@http-client";
+
  import * as utils from "@app/lib/utils";
+

+
  import Button from "@app/components/Button.svelte";
+
  import DropdownList from "@app/components/DropdownList.svelte";
+
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+
  import { closeFocused } from "@app/components/Popover.svelte";
+

+
  export let view: Extract<PatchView, { name: "changes" }>;
+
  export let baseUrl: BaseUrl;
+
  export let patch: Patch;
+
  export let repo: Repo;
+
</script>
+

+
<Popover
+
  popoverPadding="0"
+
  popoverPositionTop="3rem"
+
  popoverBorderRadius="var(--border-radius-small)">
+
  <Button
+
    let:expanded
+
    slot="toggle"
+
    let:toggle
+
    on:click={toggle}
+
    size="regular"
+
    disabled={patch.revisions.length === 1}>
+
    <span
+
      style:color={patch.revisions.length > 1
+
        ? "var(--color-foreground-contrast)"
+
        : "var(--color-foregroung-disabled)"}>
+
      Revision
+
    </span>
+
    <span
+
      style:color={patch.revisions.length > 1
+
        ? "var(--color-fill-secondary)"
+
        : "var(--color-foregroung-disabled)"}
+
      style:font-family="var(--font-family-monospace)">
+
      {utils.formatObjectId(view.revision)}
+
    </span>
+
    <Icon name={expanded ? "chevron-up" : "chevron-down"} />
+
  </Button>
+
  <DropdownList slot="popover" items={patch.revisions}>
+
    <svelte:fragment slot="item" let:item>
+
      <Link
+
        on:afterNavigate={closeFocused}
+
        route={{
+
          resource: "repo.patch",
+
          repo: repo.rid,
+
          node: baseUrl,
+
          patch: patch.id,
+
          view: {
+
            name: view.name,
+
            revision: item.id,
+
          },
+
        }}>
+
        <DropdownListItem selected={item.id === view.revision}>
+
          <span
+
            style:color={item.id === view.revision
+
              ? "var(--color-foreground-contrast)"
+
              : "var(--color-fill-gray)"}>
+
            Revision
+
          </span>
+
          <span
+
            style:color="var(--color-fill-secondary)"
+
            style:font-family="var(--font-family-monospace)">
+
            {utils.formatObjectId(item.id)}
+
          </span>
+
        </DropdownListItem>
+
      </Link>
+
    </svelte:fragment>
+
  </DropdownList>
+
</Popover>
added src/views/repos/Patches.svelte
@@ -0,0 +1,238 @@
+
<script lang="ts">
+
  import type {
+
    BaseUrl,
+
    Patch,
+
    PatchState,
+
    Repo,
+
    SeedingPolicy,
+
  } from "@http-client";
+

+
  import { HttpdClient } from "@http-client";
+
  import capitalize from "lodash/capitalize";
+

+
  import { PATCHES_PER_PAGE } from "./router";
+
  import { baseUrlToString } from "@app/lib/utils";
+

+
  import Button from "@app/components/Button.svelte";
+
  import DropdownList from "@app/components/DropdownList.svelte";
+
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
+
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Layout from "./Layout.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import List from "@app/components/List.svelte";
+
  import Loading from "@app/components/Loading.svelte";
+
  import PatchTeaser from "./Patch/PatchTeaser.svelte";
+
  import Placeholder from "@app/components/Placeholder.svelte";
+
  import Popover, { closeFocused } from "@app/components/Popover.svelte";
+
  import Separator from "./Separator.svelte";
+
  import Share from "./Share.svelte";
+

+
  export let baseUrl: BaseUrl;
+
  export let seedingPolicy: SeedingPolicy;
+
  export let patches: Patch[];
+
  export let repo: Repo;
+
  export let status: PatchState["status"];
+
  export let nodeAvatarUrl: string | undefined;
+

+
  let loading = false;
+
  let page = 0;
+
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+
  let error: any;
+
  let allPatches: Patch[];
+

+
  $: {
+
    allPatches = patches;
+
    page = 0;
+
  }
+

+
  const api = new HttpdClient(baseUrl);
+

+
  async function loadMore(status: PatchState["status"]): Promise<void> {
+
    loading = true;
+
    page += 1;
+
    try {
+
      const response = await api.repo.getAllPatches(repo.rid, {
+
        status,
+
        page,
+
        perPage: PATCHES_PER_PAGE,
+
      });
+
      allPatches = [...allPatches, ...response];
+
    } catch (e) {
+
      error = e;
+
    } finally {
+
      loading = false;
+
    }
+
  }
+

+
  const stateOptions: PatchState["status"][] = [
+
    "draft",
+
    "open",
+
    "archived",
+
    "merged",
+
  ];
+

+
  const stateColor: Record<PatchState["status"], string> = {
+
    draft: "var(--color-fill-gray)",
+
    open: "var(--color-fill-success)",
+
    archived: "var(--color-foreground-yellow)",
+
    merged: "var(--color-fill-primary)",
+
  };
+

+
  $: showMoreButton =
+
    !loading &&
+
    !error &&
+
    allPatches.length <
+
      repo.payloads["xyz.radicle.project"].meta.patches[status];
+
</script>
+

+
<style>
+
  .header {
+
    display: flex;
+
    justify-content: space-between;
+
    padding: 1rem;
+
  }
+
  .more {
+
    margin-top: 2rem;
+
    min-height: 3rem;
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
+
  }
+
  .dropdown-button-counter {
+
    border-radius: var(--border-radius-tiny);
+
    background-color: var(--color-fill-counter);
+
    color: var(--color-foreground-contrast);
+
    padding: 0 0.25rem;
+
  }
+
  .dropdown-list-counter {
+
    border-radius: var(--border-radius-tiny);
+
    background-color: var(--color-fill-ghost);
+
    color: var(--color-foreground-dim);
+
    padding: 0 0.25rem;
+
  }
+
  .selected {
+
    background-color: var(--color-fill-counter);
+
    color: var(--color-foreground-dim);
+
  }
+
  .placeholder {
+
    height: calc(100% - 4rem);
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
+
  }
+
  @media (max-width: 719.98px) {
+
    .placeholder {
+
      height: calc(100vh - 10rem);
+
    }
+
  }
+
</style>
+

+
<Layout {nodeAvatarUrl} {seedingPolicy} {baseUrl} {repo} activeTab="patches">
+
  <svelte:fragment slot="breadcrumb">
+
    <Separator />
+
    <Link
+
      route={{
+
        resource: "repo.patches",
+
        repo: repo.rid,
+
        node: baseUrl,
+
      }}>
+
      Patches
+
    </Link>
+
  </svelte:fragment>
+
  <div slot="header" class="header">
+
    <Popover
+
      popoverPadding="0"
+
      popoverPositionTop="2.5rem"
+
      popoverBorderRadius="var(--border-radius-small)">
+
      <Button
+
        let:expanded
+
        slot="toggle"
+
        let:toggle
+
        on:click={toggle}
+
        ariaLabel="filter-dropdown"
+
        title="Filter patches by state">
+
        <div style:color={stateColor[status]}>
+
          <Icon name="patch" />
+
        </div>
+
        {capitalize(status)}
+
        <div class="dropdown-button-counter">
+
          {repo.payloads["xyz.radicle.project"].meta.patches[status]}
+
        </div>
+
        <Icon name={expanded ? "chevron-up" : "chevron-down"} />
+
      </Button>
+
      <DropdownList slot="popover" items={stateOptions}>
+
        <Link
+
          slot="item"
+
          let:item
+
          on:afterNavigate={() => closeFocused()}
+
          route={{
+
            resource: "repo.patches",
+
            repo: repo.rid,
+
            node: baseUrl,
+
            search: `status=${item}`,
+
          }}>
+
          <DropdownListItem selected={item === status}>
+
            <div style:color={stateColor[item]}>
+
              <Icon name="patch" />
+
            </div>
+
            <div
+
              style="display: flex; gap: 1rem;justify-content: space-between; width: 100%;">
+
              {capitalize(item)}
+
              <div
+
                class="dropdown-list-counter"
+
                class:selected={item === status}>
+
                {repo.payloads["xyz.radicle.project"].meta.patches[item]}
+
              </div>
+
            </div>
+
          </DropdownListItem>
+
        </Link>
+
      </DropdownList>
+
    </Popover>
+

+
    <Share />
+
  </div>
+

+
  <List items={allPatches}>
+
    <PatchTeaser
+
      slot="item"
+
      let:item
+
      {baseUrl}
+
      repoId={repo.rid}
+
      patch={item} />
+
  </List>
+

+
  {#if error}
+
    <ErrorMessage
+
      title="Couldn't load patches"
+
      description="Please make sure you are able to connect to the seed <code>${baseUrlToString(
+
        api.baseUrl,
+
      )}</code>"
+
      {error} />
+
  {/if}
+

+
  {#if repo.payloads["xyz.radicle.project"].meta.patches[status] === 0}
+
    <div class="placeholder">
+
      <Placeholder iconName="no-patches" caption={`No ${status} patches`} />
+
    </div>
+
  {/if}
+

+
  {#if loading || showMoreButton}
+
    <div class="more">
+
      {#if loading}
+
        <div style:margin-top={page === 0 ? "8rem" : ""}>
+
          <Loading noDelay small={page !== 0} center />
+
        </div>
+
      {/if}
+

+
      {#if showMoreButton}
+
        <Button
+
          size="large"
+
          variant="outline"
+
          on:click={() => loadMore(status)}>
+
          More
+
        </Button>
+
      {/if}
+
    </div>
+
  {/if}
+
</Layout>
added src/views/repos/Separator.svelte
@@ -0,0 +1,7 @@
+
<script lang="ts">
+
  import Icon from "@app/components/Icon.svelte";
+
</script>
+

+
<span style:color="var(--color-foreground-dim)">
+
  <Icon name="chevron-right" />
+
</span>
added src/views/repos/Share.svelte
@@ -0,0 +1,28 @@
+
<script lang="ts">
+
  import config from "virtual:config";
+
  import debounce from "lodash/debounce";
+
  import { toClipboard } from "@app/lib/utils";
+

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

+
  let shareIcon: "link" | "checkmark" = "link";
+

+
  const restoreIcon = debounce(() => {
+
    shareIcon = "link";
+
  }, 1000);
+

+
  async function copy() {
+
    const text = new URL(config.nodes.fallbackPublicExplorer).origin.concat(
+
      window.location.pathname,
+
    );
+
    await toClipboard(text);
+
    shareIcon = "checkmark";
+
    restoreIcon();
+
  }
+
</script>
+

+
<Button variant="outline" size="regular" on:click={copy}>
+
  <Icon name={shareIcon} />
+
  <span class="global-hide-on-small-desktop-down">Copy link</span>
+
</Button>
added src/views/repos/Sidebar.svelte
@@ -0,0 +1,342 @@
+
<script lang="ts">
+
  import type { ActiveTab } from "./Header.svelte";
+
  import type { BaseUrl, Repo, SeedingPolicy } from "@http-client";
+

+
  import Button from "@app/components/Button.svelte";
+
  import ContextRepo from "@app/views/repos/Sidebar/ContextRepo.svelte";
+
  import Help from "@app/App/Help.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+
  import Settings from "@app/App/Settings.svelte";
+

+
  const SIDEBAR_STATE_KEY = "sidebarState";
+

+
  export let activeTab: ActiveTab | undefined = undefined;
+
  export let seedingPolicy: SeedingPolicy;
+
  export let baseUrl: BaseUrl;
+
  export let repo: Repo;
+
  export let collapsedOnly = false;
+

+
  let expanded = collapsedOnly ? false : loadSidebarState();
+

+
  export function storeSidebarState(expanded: boolean): void {
+
    if (localStorage) {
+
      localStorage.setItem(
+
        SIDEBAR_STATE_KEY,
+
        expanded ? "expanded" : "collapsed",
+
      );
+
    } else {
+
      console.warn(
+
        "localStorage isn't available, not able to persist the sidebar state without it.",
+
      );
+
    }
+
  }
+

+
  function loadSidebarState(): boolean {
+
    const storedSidebarState = localStorage
+
      ? localStorage.getItem(SIDEBAR_STATE_KEY)
+
      : null;
+

+
    if (storedSidebarState === null) {
+
      return true;
+
    } else {
+
      return storedSidebarState === "expanded" ? true : false;
+
    }
+
  }
+

+
  function toggleSidebar() {
+
    expanded = !expanded;
+
    storeSidebarState(expanded);
+
  }
+
</script>
+

+
<style>
+
  .sidebar {
+
    padding: 1rem;
+
    height: 100%;
+
    display: flex;
+
    flex-direction: column;
+
    justify-content: space-between;
+
    transition: width 150ms ease-in-out;
+
    width: 4.5rem;
+
  }
+
  .sidebar.expanded {
+
    width: 22.5rem;
+
  }
+
  .repo-navigation {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 0.25rem;
+
    flex: 1;
+
  }
+

+
  .counter {
+
    border-radius: var(--border-radius-tiny);
+
    background-color: var(--color-fill-ghost);
+
    color: var(--color-foreground-dim);
+
    padding: 0 0.25rem;
+
  }
+
  .selected {
+
    background-color: var(--color-fill-counter);
+
    color: var(--color-foreground-contrast);
+
  }
+
  .hover {
+
    background-color: var(--color-fill-ghost-hover);
+
    color: var(--color-foreground-contrast);
+
  }
+
  .title-counter {
+
    display: flex;
+
    overflow: hidden;
+
    gap: 0.5rem;
+
    justify-content: space-between;
+
    width: 100%;
+
    opacity: 0;
+
    transition: opacity 150ms ease-in-out;
+
  }
+
  .title-counter.expanded {
+
    opacity: 1;
+
  }
+
  .sidebar-footer {
+
    display: flex;
+
    justify-content: space-between;
+
    width: 100%;
+
  }
+
  .repo {
+
    z-index: 10;
+
    opacity: 0;
+
    height: 0;
+
    overflow: hidden;
+
  }
+
  .box {
+
    padding: 1rem;
+
    margin-bottom: 0.5rem;
+
    background-color: var(--color-background-float);
+
    border: 1px solid var(--color-border-hint);
+
    font-size: var(--font-size-small);
+
    border-radius: var(--border-radius-small);
+
  }
+
  .repo.expanded {
+
    opacity: 1;
+
    height: initial;
+
    overflow: initial;
+
    transition: opacity 150ms;
+
    transition-delay: 150ms;
+
  }
+
  .vertical-buttons {
+
    opacity: 1;
+
    height: fit-content;
+
    display: flex;
+
    flex-direction: column-reverse;
+
    transition: opacity 150ms ease-in-out;
+
    transition-delay: 60ms;
+
    margin-bottom: 0.5rem;
+
  }
+
  .vertical-buttons.expanded {
+
    opacity: 0;
+
    height: 0;
+
    overflow: hidden;
+
  }
+
  .horizontal-buttons {
+
    display: flex;
+
    gap: 0.5rem;
+
    opacity: 0;
+
    transition: opacity 30ms ease-in-out;
+
  }
+
  .horizontal-buttons.expanded {
+
    opacity: 1;
+
    transition: opacity 150ms ease-in-out;
+
  }
+
  .icon {
+
    transform: rotate(180deg);
+
    transition: transform 150ms ease-in-out;
+
  }
+
  .icon.expanded {
+
    transform: rotate(0deg);
+
  }
+
  .bottom {
+
    display: flex;
+
    flex-direction: column;
+
    justify-items: flex-end;
+
  }
+
</style>
+

+
<div class="sidebar" class:expanded>
+
  <!-- Top Navigation Items -->
+
  <div class="repo-navigation">
+
    <Link
+
      title="Source"
+
      route={{
+
        resource: "repo.source",
+
        repo: repo.rid,
+
        node: baseUrl,
+
        path: "/",
+
      }}>
+
      <Button
+
        stylePadding="0.5rem 0.75rem"
+
        size="large"
+
        styleWidth="100%"
+
        styleJustifyContent="flex-start"
+
        variant={activeTab === "source" ? "gray" : "background"}>
+
        <Icon name="chevron-left-right" />
+
        <span class="title-counter" class:expanded>Source</span>
+
      </Button>
+
    </Link>
+
    <Link
+
      title={`${repo.payloads["xyz.radicle.project"].meta.issues.open} Issues`}
+
      route={{
+
        resource: "repo.issues",
+
        repo: repo.rid,
+
        node: baseUrl,
+
      }}>
+
      <Button
+
        stylePadding="0.5rem 0.75rem"
+
        let:hover
+
        size="large"
+
        styleJustifyContent="flex-start"
+
        styleWidth="100%"
+
        variant={activeTab === "issues" ? "gray" : "background"}>
+
        <Icon name="issue" />
+
        <div class="title-counter" class:expanded>
+
          Issues
+
          <span
+
            class="counter"
+
            class:selected={activeTab === "issues"}
+
            class:hover={hover && activeTab !== "issues"}>
+
            {repo.payloads["xyz.radicle.project"].meta.issues.open}
+
          </span>
+
        </div>
+
      </Button>
+
    </Link>
+

+
    <Link
+
      title={`${repo.payloads["xyz.radicle.project"].meta.patches.open} Patches`}
+
      route={{
+
        resource: "repo.patches",
+
        repo: repo.rid,
+
        node: baseUrl,
+
      }}>
+
      <Button
+
        stylePadding="0.5rem 0.75rem"
+
        let:hover
+
        size="large"
+
        styleWidth="100%"
+
        styleJustifyContent="flex-start"
+
        variant={activeTab === "patches" ? "gray" : "background"}>
+
        <Icon name="patch" />
+
        <div class="title-counter" class:expanded>
+
          Patches
+
          <span
+
            class="counter"
+
            class:hover={hover && activeTab !== "patches"}
+
            class:selected={activeTab === "patches"}>
+
            {repo.payloads["xyz.radicle.project"].meta.patches.open}
+
          </span>
+
        </div>
+
      </Button>
+
    </Link>
+
  </div>
+
  <!-- Context and other information section -->
+
  <div class="bottom">
+
    <div class="repo box" class:expanded>
+
      <ContextRepo
+
        {baseUrl}
+
        repoThreshold={repo.threshold}
+
        repoDelegates={repo.delegates}
+
        {seedingPolicy} />
+
    </div>
+
    <div class="vertical-buttons" class:expanded style:gap="0.5rem">
+
      <Popover popoverPositionBottom="0" popoverPositionLeft="3rem">
+
        <Button
+
          stylePadding="0 0.75rem"
+
          variant="background"
+
          title="Settings"
+
          slot="toggle"
+
          let:toggle
+
          on:click={toggle}>
+
          <Icon name="settings" />
+
        </Button>
+

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

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

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

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

+
        <div slot="popover" class="txt-small" style:width="18rem">
+
          <ContextRepo
+
            {baseUrl}
+
            repoThreshold={repo.threshold}
+
            repoDelegates={repo.delegates}
+
            {seedingPolicy} />
+
        </div>
+
      </Popover>
+
    </div>
+
    <!-- Footer -->
+
    {#if !collapsedOnly}
+
      <div class="sidebar-footer" style:flex-direction="row">
+
        <Button
+
          title={"Collapse"}
+
          on:click={toggleSidebar}
+
          variant="background">
+
          <div class="icon" class:expanded>
+
            <Icon name="chevron-left" />
+
          </div>
+
        </Button>
+
        <div class="global-flex-item">
+
          <div class="horizontal-buttons" class:expanded>
+
            <Popover popoverPositionBottom="2.5rem" popoverPositionLeft="0">
+
              <Button
+
                variant="outline"
+
                title="Settings"
+
                slot="toggle"
+
                let:toggle
+
                on:click={toggle}>
+
                <Icon name="settings" />
+
                Settings
+
              </Button>
+

+
              <Settings slot="popover" />
+
            </Popover>
+
          </div>
+
          <div class="horizontal-buttons" class:expanded>
+
            <Popover popoverPositionBottom="2.5rem" popoverPositionLeft="0">
+
              <Button
+
                variant="outline"
+
                title="Help"
+
                slot="toggle"
+
                let:toggle
+
                on:click={toggle}>
+
                <Icon name="help" />
+
                Help
+
              </Button>
+
              <Help slot="popover" />
+
            </Popover>
+
          </div>
+
        </div>
+
      </div>
+
    {/if}
+
  </div>
+
</div>
added src/views/repos/Sidebar/ContextRepo.svelte
@@ -0,0 +1,92 @@
+
<script lang="ts">
+
  import type { BaseUrl, Repo, SeedingPolicy } from "@http-client";
+

+
  import capitalize from "lodash/capitalize";
+

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

+
  export let baseUrl: BaseUrl;
+
  export let repoThreshold: number;
+
  export let repoDelegates: Repo["delegates"];
+
  export let seedingPolicy: SeedingPolicy;
+

+
  let delegateExpanded = false;
+
  let policyExpanded = false;
+
</script>
+

+
<style>
+
  .item-header {
+
    gap: 2rem;
+
    display: flex;
+
    align-items: center;
+
    justify-content: space-between;
+
    margin: 0.2rem 0;
+
  }
+
  .item-header:first-child {
+
    margin-top: 0;
+
  }
+
  .item-header:last-child {
+
    margin-bottom: 0;
+
  }
+
  .nid {
+
    height: 21.5px;
+
    margin: 0.5rem 0;
+
  }
+
</style>
+

+
<div class="item-header">
+
  <span>Delegates</span>
+
  <div class="global-flex-item">
+
    <span class="txt-bold">
+
      {repoThreshold}/{repoDelegates.length}
+
    </span>
+
    <IconButton on:click={() => (delegateExpanded = !delegateExpanded)}>
+
      <Icon name={delegateExpanded ? "chevron-up" : "chevron-down"} />
+
    </IconButton>
+
  </div>
+
</div>
+
{#if delegateExpanded}
+
  <div style:color="var(--color-foreground-dim" style:margin-bottom="1rem">
+
    {#if repoDelegates.length === 1}
+
      Any changes accepted by the sole delegate will be included in the
+
      canonical branch.
+
    {:else}
+
      {repoThreshold} out of {repoDelegates.length} delegates have to accept changes
+
      to be included in the canonical branch.
+
    {/if}
+
  </div>
+
  <div class="delegates">
+
    {#each repoDelegates as delegate}
+
      <div class="nid">
+
        <NodeId {baseUrl} nodeId={delegate.id} alias={delegate.alias} />
+
      </div>
+
    {/each}
+
  </div>
+
{/if}
+
<div class="item-header">
+
  <span style:text-wrap="nowrap">Seeding Scope</span>
+
  <div class="global-flex-item">
+
    <span class="txt-bold">
+
      {capitalize(
+
        "scope" in seedingPolicy ? seedingPolicy.scope : "not defined",
+
      )}
+
    </span>
+
    <IconButton on:click={() => (policyExpanded = !policyExpanded)}>
+
      <Icon name={policyExpanded ? "chevron-up" : "chevron-down"} />
+
    </IconButton>
+
  </div>
+
</div>
+
{#if policyExpanded}
+
  <div style:color="var(--color-foreground-dim)">
+
    {#if seedingPolicy.policy === "block"}
+
      Seeding scope only has an effect when a repository is seeded. This repo
+
      isn't seeded by the seed node.
+
    {:else if seedingPolicy.scope === "all"}
+
      This repository tracks changes by any peer.
+
    {:else}
+
      This repository tracks only peers followed by the seed node.
+
    {/if}
+
  </div>
+
{/if}
added src/views/repos/Source.svelte
@@ -0,0 +1,226 @@
+
<script lang="ts">
+
  import type {
+
    BaseUrl,
+
    Repo,
+
    Remote,
+
    SeedingPolicy,
+
    Tree,
+
  } from "@http-client";
+
  import type { BlobResult, RepoRoute } from "./router";
+

+
  import { HttpdClient } from "@http-client";
+

+
  import Button from "@app/components/Button.svelte";
+
  import Header from "./Source/Header.svelte";
+
  import Layout from "./Layout.svelte";
+
  import Placeholder from "@app/components/Placeholder.svelte";
+

+
  import BlobComponent from "./Source/Blob.svelte";
+
  import FilePath from "@app/components/FilePath.svelte";
+
  import RepoNameHeader from "./Source/RepoNameHeader.svelte";
+
  import Separator from "./Separator.svelte";
+
  import TreeComponent from "./Source/Tree.svelte";
+

+
  export let baseUrl: BaseUrl;
+
  export let blobResult: BlobResult;
+
  export let commit: string;
+
  export let path: string;
+
  export let peer: string | undefined;
+
  export let peers: Remote[];
+
  export let repo: Repo;
+
  export let rawPath: (commit?: string) => string;
+
  export let revision: string | undefined;
+
  export let seedingPolicy: SeedingPolicy;
+
  export let tree: Tree;
+
  export let nodeAvatarUrl: string | undefined;
+

+
  let mobileFileTree = false;
+

+
  const api = new HttpdClient(baseUrl);
+

+
  const fetchTree = async (path: string) => {
+
    return api.repo.getTree(repo.rid, tree.lastCommit.id, path).catch(() => {
+
      blobResult = {
+
        ok: false,
+
        error: {
+
          message: "Not able to expand directory",
+
          path,
+
        },
+
      };
+
      return undefined;
+
    });
+
  };
+

+
  $: baseRoute = {
+
    resource: "repo.source",
+
    node: baseUrl,
+
    repo: repo.rid,
+
    path: "/",
+
  } as Extract<RepoRoute, { resource: "repo.source" }>;
+
</script>
+

+
<style>
+
  .center-content {
+
    margin: 0 auto;
+
  }
+

+
  .container {
+
    display: flex;
+
    width: inherit;
+
    padding: 0 1rem 1rem 1rem;
+
  }
+

+
  .column-left {
+
    display: flex;
+
    flex-direction: column;
+
    padding-right: 0.5rem;
+
  }
+

+
  .column-right {
+
    display: flex;
+
    flex-direction: column;
+
    width: 100%;
+
    padding-bottom: 2.5rem;
+
    max-width: 75rem;
+
    margin: 0 auto;
+
    /* To allow pre elements to shrink when overflowing */
+
    min-width: 0;
+
  }
+
  .placeholder {
+
    width: 100%;
+
    padding: 4rem 0;
+
    border: 1px solid var(--color-border-hint);
+
    border-radius: var(--border-radius-small);
+
  }
+

+
  .source-tree {
+
    overflow-x: hidden;
+
    width: 17.5rem;
+
    padding-right: 0.25rem;
+
  }
+
  .sticky {
+
    position: sticky;
+
    top: 0rem;
+
    max-height: calc(100vh - 5.5rem);
+
  }
+
  @media (max-width: 719.98px) {
+
    .container {
+
      display: flex;
+
      width: inherit;
+
      padding: 0;
+
    }
+
    .placeholder {
+
      border-radius: 0;
+
      border-left: 0;
+
      border-right: 0;
+
    }
+
  }
+
</style>
+

+
<Layout
+
  {baseUrl}
+
  {nodeAvatarUrl}
+
  {repo}
+
  {seedingPolicy}
+
  activeTab="source"
+
  stylePaddingBottom="0">
+
  <svelte:fragment slot="breadcrumb">
+
    {#if path !== "/"}
+
      <Separator />
+
      <FilePath filenameWithPath={path} />
+
    {/if}
+
  </svelte:fragment>
+
  <RepoNameHeader {repo} {baseUrl} slot="header" />
+

+
  <div style:margin="1rem" slot="subheader">
+
    <Header
+
      filesLinkActive={true}
+
      historyLinkActive={false}
+
      node={baseUrl}
+
      {commit}
+
      {baseRoute}
+
      {peers}
+
      {peer}
+
      {repo}
+
      {revision}
+
      {tree} />
+
  </div>
+
  <div class="global-hide-on-medium-desktop-up">
+
    {#if tree.entries.length > 0}
+
      <div style:margin="1rem">
+
        <Button
+
          styleWidth="100%"
+
          size="large"
+
          variant="outline"
+
          on:click={() => {
+
            mobileFileTree = !mobileFileTree;
+
          }}>
+
          Browse
+
        </Button>
+
      </div>
+

+
      {#if mobileFileTree}
+
        <div class="layout-mobile" style:margin="1rem">
+
          <TreeComponent
+
            repoId={repo.rid}
+
            {revision}
+
            {baseUrl}
+
            {fetchTree}
+
            {path}
+
            {peer}
+
            {tree}
+
            on:select={() => {
+
              mobileFileTree = false;
+
            }} />
+
        </div>
+
      {/if}
+
    {/if}
+
  </div>
+

+
  <div class="container center-content">
+
    {#if tree.entries.length > 0}
+
      <div class="column-left global-hide-on-small-desktop-down">
+
        <div class="source-tree sticky">
+
          <TreeComponent
+
            repoId={repo.rid}
+
            {revision}
+
            {baseUrl}
+
            {fetchTree}
+
            {path}
+
            {peer}
+
            {tree} />
+
        </div>
+
      </div>
+
      <div class="column-right">
+
        {#if blobResult.ok}
+
          <BlobComponent
+
            {path}
+
            {baseUrl}
+
            repoId={repo.rid}
+
            blob={blobResult.blob}
+
            highlighted={blobResult.highlighted}
+
            rawPath={rawPath(tree.lastCommit.id)} />
+
        {:else if blobResult.error.status === 413}
+
          <div class="placeholder">
+
            <Placeholder
+
              iconName="exclamation-circle"
+
              caption="This file is too big to be displayed.
+
              If you want to view this file, clone this repository locally." />
+
          </div>
+
        {:else if path === "/"}
+
          <div class="placeholder">
+
            <Placeholder iconName="no-file" caption="No README found." />
+
          </div>
+
        {:else}
+
          <div class="placeholder">
+
            <Placeholder iconName="no-file" caption="File not found." />
+
          </div>
+
        {/if}
+
      </div>
+
    {:else}
+
      <div class="placeholder">
+
        <Placeholder iconName="no-file" caption="No files at this revision." />
+
      </div>
+
    {/if}
+
  </div>
+
</Layout>
added src/views/repos/Source/Blob.svelte
@@ -0,0 +1,219 @@
+
<script lang="ts">
+
  import type { BaseUrl, Blob } from "@http-client";
+

+
  import { afterUpdate, onDestroy, onMount } from "svelte";
+
  import { toHtml } from "hast-util-to-html";
+

+
  import * as Syntax from "@app/lib/syntax";
+
  import { isImagePath, isMarkdownPath, isSvgPath } from "@app/lib/utils";
+
  import { lineNumbersGutter } from "@app/lib/syntax";
+

+
  import Button from "@app/components/Button.svelte";
+
  import CommitButton from "@app/views/repos/components/CommitButton.svelte";
+
  import File from "@app/components/File.svelte";
+
  import FilePath from "@app/components/FilePath.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Markdown from "@app/components/Markdown.svelte";
+
  import Placeholder from "@app/components/Placeholder.svelte";
+
  import Radio from "@app/components/Radio.svelte";
+

+
  export let baseUrl: BaseUrl;
+
  export let repoId: string;
+
  export let path: string;
+
  export let blob: Blob;
+
  export let highlighted: Syntax.Root | undefined;
+
  export let rawPath: string;
+

+
  $: lastCommit = blob.lastCommit;
+

+
  $: content = highlighted ? lineNumbersGutter(highlighted) : undefined;
+
  $: extension = path.split(".").pop();
+

+
  let selectedLineId: string | undefined = undefined;
+
  $: {
+
    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
+
    content;
+
    updateSelectedLineId();
+
  }
+

+
  function updateSelectedLineId() {
+
    const fragmentId = window.location.hash.substring(1);
+
    if (fragmentId && fragmentId.match(/^L\d+$/)) {
+
      selectedLineId = fragmentId;
+
    } else {
+
      selectedLineId = undefined;
+
    }
+
  }
+

+
  $: isMarkdown = isMarkdownPath(blob.path);
+
  $: isImage = isImagePath(blob.path);
+
  $: isSvg = isSvgPath(blob.path);
+
  $: enablePreview = isMarkdown || isSvg;
+
  $: preview = enablePreview && selectedLineId === undefined;
+

+
  afterUpdate(() => {
+
    for (const item of document.getElementsByClassName("highlight")) {
+
      item.classList.remove("highlight");
+
    }
+
    if (selectedLineId) {
+
      const target = document.getElementById(selectedLineId);
+
      if (target) {
+
        target.classList.add("highlight");
+
        target.scrollIntoView({ block: "center" });
+
      }
+
    }
+
  });
+

+
  onMount(async () => {
+
    window.addEventListener("hashchange", updateSelectedLineId);
+
  });
+

+
  onDestroy(() => {
+
    window.removeEventListener("hashchange", updateSelectedLineId);
+
  });
+
</script>
+

+
<style>
+
  .code :global(.line-number) {
+
    font-family: var(--font-family-monospace);
+
    color: var(--color-foreground-disabled);
+
    text-align: right;
+
    padding: 0;
+
    user-select: none;
+
  }
+
  .code :global(.line-number a) {
+
    display: block;
+
    padding: 0 1rem;
+
  }
+
  .code :global(.line-number:hover) {
+
    cursor: pointer;
+
    color: var(--color-foreground-dim);
+
  }
+

+
  .code :global(.content) {
+
    display: inline;
+
    font-family: var(--font-family-monospace);
+
    margin: 0;
+
  }
+

+
  .code :global(.line) {
+
    line-height: 22px; /* This seems to be the line-height of a pre code block */
+
  }
+
  .code :global(.highlight) {
+
    background-color: var(--color-fill-float-hover);
+
    box-shadow: 0 0 0 1px var(--color-fill-secondary);
+
  }
+
  .code :global(.highlight td:first-child) {
+
    background-color: var(--color-fill-float-hover);
+
    border-left: 1px solid var(--color-fill-secondary);
+
  }
+
  .code :global(.highlight td:last-child) {
+
    background-color: var(--color-fill-float-hover);
+
    border-right: 1px solid var(--color-fill-secondary);
+
  }
+

+
  .code :global(.line-content) {
+
    padding: 0;
+
    width: 100%;
+
  }
+

+
  .code {
+
    width: 100%;
+
    border-spacing: 0;
+
    overflow-x: auto;
+
    font-size: var(--font-size-small);
+
    padding-top: 1rem;
+
    margin-bottom: 1.5rem;
+
  }
+

+
  .teaser-buttons {
+
    display: flex;
+
    gap: 0.5rem;
+
  }
+

+
  .no-scrollbar {
+
    scrollbar-width: none;
+
  }
+

+
  .no-scrollbar::-webkit-scrollbar {
+
    display: none;
+
  }
+
  .markdown-wrapper {
+
    padding: 2rem;
+
  }
+
  @media (max-width: 719.98px) {
+
    .markdown-wrapper {
+
      padding: 1rem;
+
    }
+
  }
+
</style>
+

+
<File sticky={false}>
+
  <FilePath slot="left-header" filenameWithPath={blob.path} />
+
  <svelte:fragment slot="right-header">
+
    <CommitButton {repoId} {baseUrl} commit={lastCommit} />
+
    <div class="global-hide-on-mobile-down teaser-buttons">
+
      {#if enablePreview}
+
        <Radio ariaLabel="Toggle render method">
+
          <Button
+
            styleBorderRadius="0"
+
            variant={!preview ? "selected" : "not-selected"}
+
            on:click={() => {
+
              preview = false;
+
            }}>
+
            <Icon name="chevron-left-right" />Code
+
          </Button>
+
          <Button
+
            styleBorderRadius="0"
+
            variant={preview ? "selected" : "not-selected"}
+
            on:click={() => {
+
              window.location.hash = "";
+
              preview = true;
+
            }}>
+
            <Icon name="eye-open" />Preview
+
          </Button>
+
          <div class="global-spacer" />
+
        </Radio>
+
      {/if}
+
      <a href="{rawPath}/{blob.path}" target="_blank" rel="noreferrer">
+
        <Button variant="gray-white">
+
          Raw <Icon name="arrow-box-up-right" />
+
        </Button>
+
      </a>
+
    </div>
+
  </svelte:fragment>
+

+
  {#if blob.binary && blob.content}
+
    {#if isImage && extension}
+
      <div style:margin="1rem 0" style:text-align="center">
+
        <img
+
          src={`data:image/${extension};base64,${blob.content}`}
+
          alt={path} />
+
      </div>
+
    {:else}
+
      <div style:margin="4rem 0" style:width="100%">
+
        <Placeholder iconName="binary-file" caption="Binary file" />
+
      </div>
+
    {/if}
+
  {:else if preview && blob.content}
+
    {#if isMarkdown}
+
      <div class="markdown-wrapper">
+
        <Markdown content={blob.content} {rawPath} {path} />
+
      </div>
+
    {:else if isSvg}
+
      <div style:margin="1rem 0" style:text-align="center">
+
        <img
+
          src={`data:image/svg+xml;base64,${btoa(blob.content)}`}
+
          alt={path} />
+
      </div>
+
    {/if}
+
  {:else if content}
+
    <table class="code no-scrollbar">
+
      {@html toHtml(content)}
+
    </table>
+
  {:else}
+
    <div style:margin="4rem 0" style:width="100%">
+
      <Placeholder iconName="empty-file" caption="Empty file" />
+
    </div>
+
  {/if}
+
</File>
added src/views/repos/Source/Header.svelte
@@ -0,0 +1,176 @@
+
<script lang="ts">
+
  import type { RepoRoute } from "../router";
+
  import type { BaseUrl, Repo, Remote, Tree } from "@http-client";
+
  import type { ComponentProps } from "svelte";
+

+
  import { HttpdClient } from "@http-client";
+

+
  import Button from "@app/components/Button.svelte";
+
  import CommitButton from "../components/CommitButton.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import Loading from "@app/components/Loading.svelte";
+
  import PeerBranchSelector from "./PeerBranchSelector.svelte";
+

+
  export let commit: string;
+
  export let filesLinkActive: boolean;
+
  export let historyLinkActive: boolean;
+
  export let node: BaseUrl;
+
  export let peer: string | undefined;
+
  export let peers: Remote[];
+
  export let repo: Repo;
+
  export let baseRoute: Extract<
+
    RepoRoute,
+
    { resource: "repo.source" } | { resource: "repo.history" }
+
  >;
+
  export let revision: string | undefined;
+
  export let tree: Tree;
+

+
  const api = new HttpdClient(node);
+
  let selectedBranch: string | undefined;
+
  let commitButtonVariant: ComponentProps<CommitButton>["variant"] | undefined =
+
    undefined;
+

+
  // Revision may be a commit ID, a branch name or `undefined` which means the
+
  // default branch. We assign `selectedBranch` accordingly.
+
  $: if (revision === lastCommit.id) {
+
    selectedBranch = undefined;
+
  } else {
+
    selectedBranch =
+
      revision || repo.payloads["xyz.radicle.project"].data.defaultBranch;
+
  }
+

+
  $: lastCommit = tree.lastCommit;
+
  $: onCanonical = Boolean(
+
    !peer &&
+
      selectedBranch ===
+
        repo.payloads["xyz.radicle.project"].data.defaultBranch,
+
  );
+
  $: if (onCanonical) {
+
    commitButtonVariant = "right";
+
  } else if (!selectedBranch) {
+
    commitButtonVariant = "left";
+
  } else {
+
    commitButtonVariant = "center";
+
  }
+
</script>
+

+
<style>
+
  .top-header {
+
    display: flex;
+
    align-items: center;
+
    justify-content: left;
+
    row-gap: 0.5rem;
+
    gap: 1px;
+
    flex-wrap: wrap;
+
    margin-bottom: 2rem;
+
  }
+

+
  .header {
+
    font-size: var(--font-size-tiny);
+
    display: flex;
+
    gap: 0.375rem;
+
    align-items: center;
+
    justify-content: left;
+
    flex-wrap: wrap;
+
    position: relative;
+
  }
+
  .header::after {
+
    content: "";
+
    position: absolute;
+
    left: -1rem;
+
    bottom: 0;
+
    border-bottom: 1px solid var(--color-fill-separator);
+
    width: calc(100% + 1rem);
+
    z-index: -1;
+
  }
+

+
  .counter {
+
    border-radius: var(--border-radius-tiny);
+
    background-color: var(--color-fill-ghost);
+
    color: var(--color-foreground-dim);
+
    padding: 0 0.25rem;
+
  }
+

+
  .title-counter {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.5rem;
+
  }
+

+
  .selected {
+
    background-color: var(--color-fill-ghost);
+
    color: var(--color-foreground-contrast);
+
  }
+
</style>
+

+
<div class="top-header">
+
  {#if selectedBranch}
+
    <PeerBranchSelector
+
      {peers}
+
      {peer}
+
      {baseRoute}
+
      {onCanonical}
+
      {repo}
+
      {selectedBranch} />
+
  {/if}
+
  <div class="global-flex-item txt-overflow" style:gap="1px">
+
    <CommitButton
+
      variant={commitButtonVariant}
+
      styleMinWidth="0"
+
      styleWidth="100%"
+
      hideSummaryOnMobile={false}
+
      repoId={repo.rid}
+
      commit={lastCommit}
+
      baseUrl={node} />
+
    {#if !onCanonical}
+
      <Link route={baseRoute}>
+
        <Button
+
          variant="not-selected"
+
          styleBorderRadius="0 var(--border-radius-tiny) var(--border-radius-tiny) 0">
+
          <Icon name="cross" />
+
        </Button>
+
      </Link>
+
    {/if}
+
  </div>
+
</div>
+

+
<div class="header">
+
  <div style="display: flex; gap: 0.375rem;">
+
    <Link
+
      route={{
+
        resource: "repo.source",
+
        repo: repo.rid,
+
        node: node,
+
        peer,
+
        revision,
+
      }}>
+
      <Button size="large" variant={filesLinkActive ? "tab-active" : "tab"}>
+
        <Icon name="file" />Files
+
      </Button>
+
    </Link>
+

+
    <Link
+
      route={{
+
        resource: "repo.history",
+
        repo: repo.rid,
+
        node: node,
+
        peer,
+
        revision,
+
      }}>
+
      <Button size="large" variant={historyLinkActive ? "tab-active" : "tab"}>
+
        <Icon name="commit" />
+
        <div class="title-counter">
+
          Commits
+
          {#await api.repo.getTreeStatsBySha(repo.rid, commit)}
+
            <Loading small center noDelay grayscale />
+
          {:then stats}
+
            <div class="counter" class:selected={historyLinkActive}>
+
              {stats.commits}
+
            </div>
+
          {/await}
+
        </div>
+
      </Button>
+
    </Link>
+
  </div>
+
</div>
added src/views/repos/Source/PeerBranchSelector.svelte
@@ -0,0 +1,277 @@
+
<script lang="ts">
+
  import type { RepoRoute } from "@app/views/repos/router";
+
  import type { Repo, Remote } from "@http-client";
+

+
  import fuzzysort from "fuzzysort";
+
  import orderBy from "lodash/orderBy";
+
  import { formatCommit, formatNodeId } from "@app/lib/utils";
+

+
  import Badge from "@app/components/Badge.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import Peer from "./PeerBranchSelector/Peer.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";
+
  import Avatar from "@app/components/Avatar.svelte";
+

+
  export let baseRoute: Extract<
+
    RepoRoute,
+
    { resource: "repo.source" } | { resource: "repo.history" }
+
  >;
+
  export let onCanonical: boolean;
+
  export let peer: string | undefined;
+
  export let peers: Remote[];
+
  export let repo: Repo;
+
  export let selectedBranch: string | undefined;
+

+
  const subgridStyle =
+
    "display: grid; grid-template-columns: subgrid; grid-column: span 2;";
+
  const highlightSearchStyle = [
+
    '<span style="background: var(--color-fill-yellow-iconic); color: var(--color-foreground-black);">',
+
    "</span>",
+
  ];
+
  let searchInput = "";
+

+
  const searchElements = [
+
    {
+
      peer: undefined,
+
      revision: repo.payloads["xyz.radicle.project"].data.defaultBranch,
+
      head: repo.payloads["xyz.radicle.project"].meta.head,
+
    },
+
    ...peers.flatMap(peer =>
+
      Object.entries(peer.heads).map(([name, head]) => ({
+
        peer: { id: peer.id, alias: peer.alias, delegate: peer.delegate },
+
        revision: name,
+
        head,
+
      })),
+
    ),
+
  ];
+

+
  $: selectedPeer = peers.find(p => p.id === peer);
+
  $: searchResults = fuzzysort.go(searchInput, searchElements, {
+
    keys: ["peer.alias", "revision"],
+
    scoreFn: r =>
+
      r.score *
+
      (r.obj.peer?.delegate ? 2 : 1) *
+
      (r.obj.peer === undefined ? 10 : 1) *
+
      (r.obj.peer?.alias ? 2 : 1),
+
  });
+
</script>
+

+
<style>
+
  .dropdown {
+
    border-radius: var(--border-radius-small);
+
    width: 40rem;
+
    max-height: 60vh;
+
    overflow-y: auto;
+
    padding: 0.25rem;
+
  }
+
  .subgrid-item {
+
    display: grid;
+
    grid-template-columns: subgrid;
+
    grid-column: span 2;
+
  }
+
  .dropdown-grid {
+
    display: grid;
+
    column-gap: 2rem;
+
    grid-template-columns: [branch] minmax(20ch, 1fr) [commit] 7ch;
+
  }
+
  .dropdown-header {
+
    display: grid;
+
    grid-template-columns: subgrid;
+
    font-size: var(--font-size-tiny);
+
    padding: 0.5rem;
+
    color: var(--color-foreground-dim);
+
  }
+
  .container {
+
    display: flex;
+
    gap: 1px;
+
    min-width: 0;
+
    flex-wrap: nowrap;
+
  }
+
  .node-id {
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
+
    gap: 0.375rem;
+
    height: 1rem;
+
    font-family: var(--font-family-monospace);
+
    font-weight: var(--font-weight-semibold);
+
    font-size: var(--font-size-small);
+
  }
+
  @media (max-width: 719.98px) {
+
    .dropdown {
+
      width: 100%;
+
    }
+
  }
+
</style>
+

+
<div class="container">
+
  <Popover
+
    popoverContainerMinWidth="0"
+
    popoverPadding="0"
+
    popoverPositionTop="2.5rem"
+
    popoverBorderRadius="var(--border-radius-small)">
+
    <Button
+
      slot="toggle"
+
      let:expanded
+
      let:toggle
+
      styleBorderRadius={"var(--border-radius-tiny) 0 0 var(--border-radius-tiny)"}
+
      styleWidth="100%"
+
      on:click={toggle}
+
      title="Change branch"
+
      disabled={!peers}>
+
      {#if selectedPeer}
+
        <div class="global-flex-item">
+
          <div class="node-id">
+
            <Avatar nodeId={selectedPeer.id} variant="small" />
+
            {selectedPeer.alias || formatNodeId(selectedPeer.id)}
+
          </div>
+

+
          {#if selectedPeer.delegate}
+
            <Badge size="tiny" variant="delegate">
+
              <Icon name="badge" />
+
              <span class="global-hide-on-small-desktop-down">Delegate</span>
+
            </Badge>
+
          {/if}
+
        </div>
+
      {/if}
+
      {#if selectedPeer && selectedBranch}
+
        <span>/</span>
+
      {/if}
+
      {#if selectedBranch}
+
        <Icon name="branch" />
+
        <span class="txt-overflow">
+
          {selectedBranch}
+
        </span>
+
        {#if onCanonical}
+
          <Badge title="Canonical branch" variant="foreground-emphasized">
+
            Canonical
+
          </Badge>
+
        {/if}
+
      {/if}
+
      <Icon name={expanded ? "chevron-up" : "chevron-down"} />
+
    </Button>
+

+
    <div slot="popover" class="dropdown" let:toggle>
+
      <TextInput
+
        showKeyHint={false}
+
        placeholder="Search"
+
        bind:value={searchInput} />
+
      <div class="dropdown-grid">
+
        <div class="dropdown-header">Branch</div>
+
        <div class="dropdown-header" style="padding-left: 0;">Head</div>
+

+
        {#if searchInput}
+
          {#each searchResults as result}
+
            {@const { revision, peer, head } = result.obj}
+
            <Link
+
              style={subgridStyle}
+
              route={{
+
                ...baseRoute,
+
                peer: peer?.id,
+
                revision: peer ? revision : undefined,
+
              }}
+
              on:afterNavigate={() => {
+
                searchInput = "";
+
                toggle();
+
              }}>
+
              <DropdownListItem
+
                selected={selectedPeer?.id === peer?.id &&
+
                  selectedBranch === revision}
+
                style={`${subgridStyle} gap: inherit;`}>
+
                <div class="global-flex-item">
+
                  <Icon name="branch" />
+
                  <span class="txt-overflow">
+
                    {#if peer?.id}
+
                      <span class="global-flex-item">
+
                        {#if result[0].target}
+
                          <span>
+
                            {@html result[0].highlight(...highlightSearchStyle)}
+
                          </span>
+
                        {:else if peer.alias}
+
                          {peer.alias}
+
                        {:else}
+
                          {formatNodeId(peer.id)}
+
                        {/if}
+
                        {#if peer.delegate}
+
                          <Badge variant="delegate" round>
+
                            <Icon name="badge" />
+
                          </Badge>
+
                        {/if} /
+
                        <span class="txt-overflow">
+
                          {#if result[1].target}
+
                            <span>
+
                              {@html result[1].highlight(
+
                                ...highlightSearchStyle,
+
                              )}
+
                            </span>
+
                          {:else}
+
                            {revision}
+
                          {/if}
+
                        </span>
+
                      </span>
+
                    {:else}
+
                      <div class="global-flex-item">
+
                        {revision}
+
                        <Badge
+
                          title="Canonical branch"
+
                          variant="foreground-emphasized">
+
                          Canonical
+
                        </Badge>
+
                      </div>
+
                    {/if}
+
                  </span>
+
                </div>
+
                <div
+
                  class="txt-monospace"
+
                  style="color: var(--color-foreground-dim);">
+
                  {formatCommit(head)}
+
                </div>
+
              </DropdownListItem>
+
            </Link>
+
          {:else}
+
            <div
+
              style="gap: inherit; padding: 0.5rem 0.375rem;"
+
              class="subgrid-item txt-missing txt-small">
+
              No entries found
+
            </div>
+
          {/each}
+
        {:else}
+
          <Link
+
            style={subgridStyle}
+
            route={{ ...baseRoute, revision: undefined }}
+
            on:afterNavigate={() => {
+
              searchInput = "";
+
              toggle();
+
            }}>
+
            <DropdownListItem
+
              selected={onCanonical}
+
              style={`${subgridStyle} gap: inherit;`}>
+
              <div class="global-flex-item">
+
                <Icon name="branch" />
+
                {repo.payloads["xyz.radicle.project"].data.defaultBranch}
+
                <Badge title="Canonical branch" variant="foreground-emphasized">
+
                  Canonical
+
                </Badge>
+
              </div>
+
              <div
+
                class="txt-monospace"
+
                style="color: var(--color-foreground-dim);">
+
                {formatCommit(repo.payloads["xyz.radicle.project"].meta.head)}
+
              </div>
+
            </DropdownListItem>
+
          </Link>
+
          {#each orderBy(peers, ["delegate", o => o.alias?.toLowerCase()], ["desc", "asc"]) as peer}
+
            <Peer
+
              {baseRoute}
+
              revision={selectedBranch}
+
              peer={{ remote: peer, selected: selectedPeer?.id === peer.id }} />
+
          {/each}
+
        {/if}
+
      </div>
+
    </div>
+
  </Popover>
+
</div>
added src/views/repos/Source/PeerBranchSelector/Peer.svelte
@@ -0,0 +1,88 @@
+
<script lang="ts">
+
  import type { RepoRoute } from "@app/views/repos/router";
+
  import type { Remote } from "@http-client";
+

+
  import { closeFocused } from "@app/components/Popover.svelte";
+
  import { formatCommit } from "@app/lib/utils";
+
  import { replace } from "@app/lib/router";
+

+
  import Badge from "@app/components/Badge.svelte";
+
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
+

+
  export let baseRoute: Extract<
+
    RepoRoute,
+
    { resource: "repo.source" } | { resource: "repo.history" }
+
  >;
+
  export let peer: { remote: Remote; selected: boolean };
+
  export let revision: string | undefined = undefined;
+

+
  const subgridStyle =
+
    "display: grid; grid-template-columns: subgrid; grid-column: span 2;";
+
  let expanded = false;
+
</script>
+

+
<style>
+
  .subgrid-item {
+
    display: grid;
+
    grid-template-columns: subgrid;
+
    grid-column: span 2;
+
  }
+
</style>
+

+
<div class="subgrid-item" aria-label="peer-item">
+
  <div class="global-flex-item" style="padding: 0.5rem 0">
+
    <IconButton title="Expand peer" on:click={() => (expanded = !expanded)}>
+
      <Icon name={expanded ? "chevron-down" : "chevron-right"} />
+
    </IconButton>
+
    <NodeId
+
      baseUrl={baseRoute.node}
+
      nodeId={peer.remote.id}
+
      alias={peer.remote.alias} />
+
    {#if peer.remote.delegate}
+
      <Badge size="tiny" variant="delegate">
+
        <Icon name="badge" />
+
        <span class="global-hide-on-small-desktop-down">Delegate</span>
+
      </Badge>
+
    {/if}
+
  </div>
+
</div>
+
{#if expanded}
+
  {#each Object.entries(peer.remote.heads) as [name, head]}
+
    <Link
+
      style={subgridStyle}
+
      route={{
+
        ...baseRoute,
+
        peer: peer.remote.id,
+
        revision: name,
+
      }}
+
      on:afterNavigate={() => closeFocused()}>
+
      <DropdownListItem
+
        selected={peer.selected && revision === name}
+
        on:click={() =>
+
          replace({
+
            ...baseRoute,
+
            peer: peer.remote.id,
+
            revision: name,
+
          })}
+
        style={`${subgridStyle} padding-left: 2.3rem; gap: inherit;`}>
+
        <div class="global-flex-item">
+
          <Icon name="branch" />
+
          <span class="txt-overflow">
+
            {name}
+
          </span>
+
        </div>
+
        <div class="global-flex-item">
+
          <span
+
            class="txt-monospace"
+
            style="color: var(--color-foreground-dim);">
+
            {formatCommit(head)}
+
          </span>
+
        </div>
+
      </DropdownListItem>
+
    </Link>
+
  {/each}
+
{/if}
added src/views/repos/Source/RepoNameHeader.svelte
@@ -0,0 +1,109 @@
+
<script lang="ts">
+
  import type { BaseUrl, Repo } from "@http-client";
+

+
  import dompurify from "dompurify";
+
  import { markdownWithExtensions } from "@app/lib/markdown";
+
  import { twemoji } from "@app/lib/utils";
+

+
  import Badge from "@app/components/Badge.svelte";
+
  import CloneButton from "@app/views/repos/Header/CloneButton.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Id from "@app/components/Id.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import SeedButton from "@app/views/repos/Header/SeedButton.svelte";
+
  import Share from "@app/views/repos/Share.svelte";
+

+
  export let repo: Repo;
+
  export let baseUrl: BaseUrl;
+

+
  function render(content: string): string {
+
    return dompurify.sanitize(
+
      markdownWithExtensions.parseInline(content) as string,
+
    );
+
  }
+

+
  $: project = repo.payloads["xyz.radicle.project"];
+
</script>
+

+
<style>
+
  .title {
+
    align-items: center;
+
    gap: 0.5rem;
+
    color: var(--color-foreground-contrast);
+
    display: flex;
+
    font-size: var(--font-size-large);
+
    justify-content: left;
+
    text-align: left;
+
    text-overflow: ellipsis;
+
    padding: 1rem 1rem 0 1rem;
+
  }
+
  .description {
+
    padding: 0 1rem 1rem 1rem;
+
  }
+
  .repo-name {
+
    font-weight: var(--font-weight-semibold);
+
  }
+
  .repo-name:hover {
+
    color: inherit;
+
  }
+
  .description :global(a) {
+
    border-bottom: 1px solid var(--color-foreground-dim);
+
  }
+
  .description :global(a:hover) {
+
    border-bottom: 1px solid var(--color-foreground-contrast);
+
  }
+
  .id {
+
    padding-left: 1rem;
+
  }
+
  .title-container {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 0rem;
+
    margin-bottom: 1rem;
+
  }
+
</style>
+

+
<div class="title-container">
+
  <div class="title">
+
    <span class="txt-overflow">
+
      <Link
+
        route={{
+
          resource: "repo.source",
+
          repo: repo.rid,
+
          node: baseUrl,
+
        }}>
+
        <span class="repo-name">
+
          {project.data.name}
+
        </span>
+
      </Link>
+
    </span>
+
    {#if repo.visibility.type === "private"}
+
      <Badge variant="yellow" size="tiny">
+
        <Icon name="lock" />
+
        Private
+
      </Badge>
+
    {/if}
+
    <div style="margin-left: auto; display: flex; gap: 0.5rem;">
+
      <Share />
+
      <div
+
        style:display="flex"
+
        style:gap="0.5rem"
+
        class="global-hide-on-mobile-down">
+
        <CloneButton {baseUrl} id={repo.rid} name={project.data.name} />
+
        <SeedButton seedCount={repo.seeding} repoId={repo.rid} />
+
      </div>
+
      <div
+
        style:display="flex"
+
        style:gap="0.5rem"
+
        class="global-hide-on-small-desktop-up">
+
        <SeedButton disabled seedCount={repo.seeding} repoId={repo.rid} />
+
      </div>
+
    </div>
+
  </div>
+
  <div class="id">
+
    <Id shorten={false} id={repo.rid} ariaLabel="repo-id" />
+
  </div>
+
</div>
+
<div class="description" use:twemoji>
+
  {@html render(project.data.description)}
+
</div>
added src/views/repos/Source/Tree.svelte
@@ -0,0 +1,53 @@
+
<script lang="ts" strictEvents>
+
  import type { BaseUrl, Tree } from "@http-client";
+

+
  import { createEventDispatcher } from "svelte";
+

+
  import File from "./Tree/File.svelte";
+
  import Folder from "./Tree/Folder.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import Submodule from "./Tree/Submodule.svelte";
+

+
  export let baseUrl: BaseUrl;
+
  export let fetchTree: (path: string) => Promise<Tree | undefined>;
+
  export let path: string;
+
  export let peer: string | undefined;
+
  export let repoId: string;
+
  export let revision: string | undefined;
+
  export let tree: Tree;
+

+
  const dispatch = createEventDispatcher<{ select: string }>();
+
  const onSelect = ({ detail: path }: { detail: string }): void => {
+
    dispatch("select", path);
+
  };
+
</script>
+

+
{#each tree.entries as entry (entry.path)}
+
  {#if entry.kind === "tree"}
+
    <Folder
+
      currentPath={path}
+
      name={entry.name}
+
      on:select={onSelect}
+
      prefix={`${entry.path}/`}
+
      {baseUrl}
+
      {fetchTree}
+
      {peer}
+
      {repoId}
+
      {revision} />
+
  {:else if entry.kind === "submodule"}
+
    <Submodule name={entry.name} oid={entry.oid} />
+
  {:else}
+
    <Link
+
      route={{
+
        resource: "repo.source",
+
        repo: repoId,
+
        node: baseUrl,
+
        path: entry.path,
+
        peer,
+
        revision,
+
      }}
+
      on:afterNavigate={() => onSelect({ detail: entry.path })}>
+
      <File active={entry.path === path} name={entry.name} />
+
    </Link>
+
  {/if}
+
{/each}
added src/views/repos/Source/Tree/File.svelte
@@ -0,0 +1,59 @@
+
<script lang="ts">
+
  import Icon from "@app/components/Icon.svelte";
+

+
  export let active: boolean;
+
  export let name: string;
+
</script>
+

+
<style>
+
  .file {
+
    border-radius: var(--border-radius-tiny);
+
    cursor: pointer;
+
    display: flex;
+
    line-height: 1.5em;
+
    margin: 0.25rem 0;
+
    padding: 0.25rem 0.875rem;
+
    width: 100%;
+
    gap: 0.25rem;
+
    font-weight: var(--font-weight-regular);
+
  }
+

+
  .file:hover {
+
    background-color: var(--color-fill-ghost);
+
  }
+

+
  .file.active {
+
    color: var(--color-foreground-contrast) !important;
+
    background-color: var(--color-fill-ghost);
+
    font-weight: var(--font-weight-medium);
+
  }
+

+
  .file.active:hover {
+
    background-color: var(--color-fill-ghost-hover);
+
  }
+

+
  .name {
+
    user-select: none;
+
    white-space: nowrap;
+
    text-overflow: ellipsis !important;
+
    overflow: hidden;
+
    font-size: var(--font-size-small);
+
  }
+
  .icon-container {
+
    color: var(--color-foreground-dim);
+
    display: flex;
+
    justify-content: center;
+
    align-items: center;
+
    margin-right: 0.125rem;
+
  }
+
  .active .icon-container {
+
    color: var(--color-foreground-contrast);
+
  }
+
</style>
+

+
<div class="file" class:active>
+
  <div class="icon-container">
+
    <Icon name="file" />
+
  </div>
+
  <span class="name">{name}</span>
+
</div>
added src/views/repos/Source/Tree/Folder.svelte
@@ -0,0 +1,136 @@
+
<script lang="ts" strictEvents>
+
  import type { BaseUrl, Tree } from "@http-client";
+

+
  import { createEventDispatcher } from "svelte";
+

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

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

+
  export let baseUrl: BaseUrl;
+
  export let currentPath: string;
+
  export let fetchTree: (path: string) => Promise<Tree | undefined>;
+
  export let name: string;
+
  export let peer: string | undefined;
+
  export let prefix: string;
+
  export let repoId: string;
+
  export let revision: string | undefined;
+

+
  $: expanded = currentPath.indexOf(prefix) === 0;
+
  $: tree = expanded
+
    ? fetchTree(prefix).then(tree => {
+
        return tree;
+
      })
+
    : Promise.resolve(undefined);
+

+
  const dispatch = createEventDispatcher<{ select: string }>();
+
  const onSelectFile = ({ detail: path }: { detail: string }) =>
+
    dispatch("select", path);
+
</script>
+

+
<style>
+
  .folder {
+
    display: flex;
+
    cursor: pointer;
+
    padding: 0.25rem 0.875rem;
+
    margin: 0.25rem 0;
+
    user-select: none;
+
    line-height: 1.5rem;
+
    white-space: nowrap;
+
  }
+
  .folder:hover {
+
    background-color: var(--color-fill-ghost);
+
    border-radius: var(--border-radius-tiny);
+
  }
+

+
  .folder-name {
+
    margin-left: 0.25rem;
+
    font-size: var(--font-size-small);
+
    font-weight: var(--font-weight-regular);
+
  }
+

+
  .container {
+
    padding-left: 1rem;
+
    margin-left: 0.5rem;
+
  }
+

+
  .loading {
+
    display: inline-block;
+
    padding: 0.5rem 0;
+
  }
+
  .icon-container {
+
    display: flex;
+
    justify-content: center;
+
    align-items: center;
+
    color: var(--color-foreground-dim);
+
    margin-right: 0.125rem;
+
  }
+

+
  .expanded {
+
    font-weight: var(--font-weight-medium);
+
    color: var(--color-foreground-contrast);
+
  }
+
</style>
+

+
<!-- svelte-ignore a11y-click-events-have-key-events -->
+
<div
+
  role="button"
+
  tabindex="0"
+
  class="folder"
+
  on:click={() => {
+
    expanded = !expanded;
+
  }}>
+
  <div class="icon-container" class:expanded>
+
    {#if expanded}
+
      <Icon name="folder-open" />
+
    {:else}
+
      <Icon name="folder" />
+
    {/if}
+
  </div>
+
  <span class="folder-name" class:expanded>{name}</span>
+
</div>
+

+
{#if expanded}
+
  <div class="container">
+
    {#await tree}
+
      <span class="loading"><Loading grayscale noDelay small margins /></span>
+
    {:then tree}
+
      {#if tree}
+
        {#each tree.entries as entry (entry.path)}
+
          {#if entry.kind === "tree"}
+
            <!-- svelte:self doesn't check types, make sure to pass in all
+
            required props! -->
+
            <svelte:self
+
              name={entry.name}
+
              on:select={onSelectFile}
+
              prefix={`${entry.path}/`}
+
              {baseUrl}
+
              {currentPath}
+
              {fetchTree}
+
              {peer}
+
              {repoId}
+
              {revision} />
+
          {:else if entry.kind === "submodule"}
+
            <Submodule name={entry.name} oid={entry.oid} />
+
          {:else}
+
            <Link
+
              route={{
+
                resource: "repo.source",
+
                repo: repoId,
+
                node: baseUrl,
+
                path: entry.path,
+
                peer,
+
                revision,
+
              }}
+
              on:afterNavigate={() => onSelectFile({ detail: entry.path })}>
+
              <File active={entry.path === currentPath} name={entry.name} />
+
            </Link>
+
          {/if}
+
        {/each}
+
      {/if}
+
    {/await}
+
  </div>
+
{/if}
added src/views/repos/Source/Tree/Submodule.svelte
@@ -0,0 +1,44 @@
+
<script lang="ts">
+
  import Icon from "@app/components/Icon.svelte";
+
  import { formatCommit } from "@app/lib/utils";
+

+
  export let name: string;
+
  export let oid: string;
+
</script>
+

+
<style>
+
  .submodule {
+
    color: var(--color-foreground-dim);
+
    border-radius: var(--border-radius-tiny);
+
    display: flex;
+
    line-height: 1.5em;
+
    margin: 0.25rem 0;
+
    padding: 0.25rem 0.875rem;
+
    width: 100%;
+
  }
+

+
  .name {
+
    margin-left: 0.25rem;
+
    user-select: none;
+
    white-space: nowrap;
+
    text-overflow: ellipsis !important;
+
    overflow: hidden;
+
    font-size: var(--font-size-small);
+
    font-weight: var(--font-weight-regular);
+
  }
+
  .icon-container {
+
    display: flex;
+
    justify-content: center;
+
    align-items: center;
+
    margin-right: 0.125rem;
+
  }
+
</style>
+

+
<div
+
  class="submodule"
+
  title="This is a git submodule, for more information look at the nearest .gitmodules file">
+
  <div class="icon-container">
+
    <Icon name="repo" />
+
  </div>
+
  <span class="name">{name} @ {formatCommit(oid)}</span>
+
</div>
added src/views/repos/components/CommitButton.svelte
@@ -0,0 +1,72 @@
+
<script lang="ts">
+
  import type { BaseUrl, Commit } from "@http-client";
+

+
  import Button from "@app/components/Button.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import { formatCommit, unreachable } from "@app/lib/utils";
+

+
  export let variant: "standalone" | "right" | "center" | "left" = "standalone";
+
  export let styleMinWidth: string | undefined = undefined;
+
  export let styleWidth: "100%" | undefined = undefined;
+
  export let repoId: string;
+
  export let baseUrl: BaseUrl;
+
  export let hideSummaryOnMobile: boolean = true;
+
  export let commit: Commit["commit"];
+

+
  let styleBorderRadius: string | undefined = undefined;
+

+
  $: commitShortId = formatCommit(commit.id);
+
  $: if (variant === "right") {
+
    styleBorderRadius =
+
      "0 var(--border-radius-tiny) var(--border-radius-tiny) 0";
+
  } else if (variant === "standalone") {
+
    styleBorderRadius = "var(--border-radius-tiny)";
+
  } else if (variant === "left") {
+
    styleBorderRadius =
+
      "var(--border-radius-tiny) 0 0 var(--border-radius-tiny)";
+
  } else if (variant === "center") {
+
    styleBorderRadius = "0";
+
  } else {
+
    unreachable(variant);
+
  }
+
</script>
+

+
<style>
+
  .commit {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.5rem;
+
  }
+
  .identifier {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.5rem;
+
  }
+
</style>
+

+
<Link
+
  styleTextOverflow
+
  route={{
+
    resource: "repo.commit",
+
    repo: repoId,
+
    node: baseUrl,
+
    commit: commit.id,
+
  }}>
+
  <Button
+
    title="Current HEAD"
+
    variant="not-selected"
+
    {styleWidth}
+
    {styleMinWidth}
+
    {styleBorderRadius}>
+
    <div class="txt-overflow commit">
+
      <div class="identifier global-commit">
+
        {commitShortId}
+
      </div>
+
      <span
+
        class="txt-overflow"
+
        class:global-hide-on-small-desktop-down={hideSummaryOnMobile}>
+
        {commit.summary}
+
      </span>
+
    </div>
+
  </Button>
+
</Link>
added src/views/repos/components/InlineTitle.svelte
@@ -0,0 +1,28 @@
+
<script lang="ts">
+
  import dompurify from "dompurify";
+
  import escape from "lodash/escape";
+
  import { formatInlineTitle } from "@app/lib/utils";
+

+
  export let content: string;
+
  export let fontSize: "tiny" | "small" | "regular" | "medium" | "large" =
+
    "small";
+
</script>
+

+
<style>
+
  .content :global(code) {
+
    font-family: var(--font-family-monospace);
+
    background-color: var(--color-fill-ghost);
+
    border-radius: var(--border-radius-tiny);
+
    padding: 0.125rem 0.25rem;
+
  }
+
</style>
+

+
<span
+
  class="content"
+
  class:txt-large={fontSize === "large"}
+
  class:txt-medium={fontSize === "medium"}
+
  class:txt-regular={fontSize === "regular"}
+
  class:txt-small={fontSize === "small"}
+
  class:txt-tiny={fontSize === "tiny"}>
+
  {@html dompurify.sanitize(formatInlineTitle(escape(content)))}
+
</span>
added src/views/repos/error.ts
@@ -0,0 +1,70 @@
+
import type { ErrorRoute, NotFoundRoute } from "@app/lib/router/definitions";
+
import type { RepoRoute } from "@app/views/repos/router";
+

+
import { baseUrlToString } from "@app/lib/utils";
+
import { ResponseParseError, ResponseError } from "@http-client/lib/fetcher";
+

+
export function handleError(
+
  error: Error | ResponseParseError | ResponseError,
+
  route: RepoRoute,
+
): NotFoundRoute | ErrorRoute {
+
  const url = baseUrlToString(route.node);
+
  if (error instanceof ResponseError && error.status === 404) {
+
    let subject;
+

+
    if (route.resource === "repo.commit") {
+
      subject = "Commit";
+
    } else if (route.resource === "repo.issue") {
+
      subject = "Issue";
+
    } else if (route.resource === "repo.patch") {
+
      subject = "Patch";
+
    } else {
+
      subject = "Repository";
+
    }
+

+
    return {
+
      resource: "notFound",
+
      params: { title: `${subject} not found` },
+
    };
+
  } else if (error instanceof ResponseError) {
+
    return {
+
      resource: "error",
+
      params: {
+
        error,
+
        title: "Could not load this repository",
+
        description: `Make sure you are able to connect to the seed <a href="${url}">${url}</a>.`,
+
      },
+
    };
+
  } else if (error instanceof ResponseParseError) {
+
    return {
+
      resource: "error",
+
      params: {
+
        error,
+
        title: "Could not parse the request",
+
        description: error.description,
+
      },
+
    };
+
  } else {
+
    return {
+
      resource: "error",
+
      params: {
+
        error,
+
        title: "Could not load this repository",
+
        description:
+
          "You stumbled on an unknown error, we aren't exactly sure what happened.",
+
      },
+
    };
+
  }
+
}
+

+
export function unreachableError(): NotFoundRoute | ErrorRoute {
+
  return {
+
    resource: "error",
+
    params: {
+
      error: undefined,
+
      title: "Could not load this route",
+
      description:
+
        "You stumbled on an unknown error, we aren't exactly sure what happened.",
+
    },
+
  };
+
}
added src/views/repos/router.ts
@@ -0,0 +1,1069 @@
+
import type {
+
  ErrorRoute,
+
  LoadedRoute,
+
  NotFoundRoute,
+
} from "@app/lib/router/definitions";
+
import type {
+
  BaseUrl,
+
  Blob,
+
  Commit,
+
  CommitBlob,
+
  CommitHeader,
+
  Diff,
+
  DiffBlob,
+
  Issue,
+
  IssueState,
+
  Node,
+
  Patch,
+
  PatchState,
+
  Repo,
+
  Remote,
+
  Revision,
+
  SeedingPolicy,
+
  Tree,
+
} from "@http-client";
+

+
import * as Syntax from "@app/lib/syntax";
+
import config from "virtual:config";
+
import { HttpdClient } from "@http-client";
+
import { ResponseError, ResponseParseError } from "@http-client/lib/fetcher";
+
import { cached } from "@app/lib/cache";
+
import { handleError, unreachableError } from "@app/views/repos/error";
+
import { isLocal, unreachable } from "@app/lib/utils";
+
import { nodePath } from "@app/views/nodes/router";
+

+
export const PATCHES_PER_PAGE = 10;
+
export const ISSUES_PER_PAGE = 10;
+

+
export type RepoRoute =
+
  | RepoTreeRoute
+
  | RepoHistoryRoute
+
  | {
+
      resource: "repo.commit";
+
      node: BaseUrl;
+
      repo: string;
+
      commit: string;
+
    }
+
  | RepoIssuesRoute
+
  | RepoIssueRoute
+
  | RepoPatchesRoute
+
  | RepoPatchRoute;
+

+
interface RepoIssuesRoute {
+
  resource: "repo.issues";
+
  node: BaseUrl;
+
  repo: string;
+
  status?: "open" | "closed";
+
}
+

+
interface RepoIssueRoute {
+
  resource: "repo.issue";
+
  node: BaseUrl;
+
  repo: string;
+
  issue: string;
+
}
+

+
interface RepoTreeRoute {
+
  resource: "repo.source";
+
  node: BaseUrl;
+
  repo: string;
+
  path?: string;
+
  peer?: string;
+
  revision?: string;
+
  route?: string;
+
}
+

+
interface RepoHistoryRoute {
+
  resource: "repo.history";
+
  node: BaseUrl;
+
  repo: string;
+
  peer?: string;
+
  revision?: string;
+
}
+

+
interface RepoPatchRoute {
+
  resource: "repo.patch";
+
  node: BaseUrl;
+
  repo: string;
+
  patch: string;
+
  view?:
+
    | {
+
        name: "activity";
+
      }
+
    | {
+
        name: "changes";
+
        revision?: string;
+
      }
+
    | {
+
        name: "diff";
+
        fromCommit: string;
+
        toCommit: string;
+
      };
+
}
+

+
interface RepoPatchesRoute {
+
  resource: "repo.patches";
+
  node: BaseUrl;
+
  repo: string;
+
  search?: string;
+
}
+

+
export type RepoLoadedRoute =
+
  | {
+
      resource: "repo.source";
+
      params: {
+
        baseUrl: BaseUrl;
+
        seedingPolicy: SeedingPolicy;
+
        commit: string;
+
        repo: Repo;
+
        peers: Remote[];
+
        peer: string | undefined;
+
        revision: string | undefined;
+
        tree: Tree;
+
        path: string;
+
        rawPath: (commit?: string) => string;
+
        blobResult: BlobResult;
+
        nodeAvatarUrl: string | undefined;
+
      };
+
    }
+
  | {
+
      resource: "repo.history";
+
      params: {
+
        baseUrl: BaseUrl;
+
        seedingPolicy: SeedingPolicy;
+
        commit: string;
+
        repo: Repo;
+
        peers: Remote[];
+
        peer: string | undefined;
+
        revision: string | undefined;
+
        tree: Tree;
+
        commitHeaders: CommitHeader[];
+
        nodeAvatarUrl: string | undefined;
+
      };
+
    }
+
  | {
+
      resource: "repo.commit";
+
      params: {
+
        baseUrl: BaseUrl;
+
        seedingPolicy: SeedingPolicy;
+
        repo: Repo;
+
        commit: Commit;
+
        nodeAvatarUrl: string | undefined;
+
      };
+
    }
+
  | {
+
      resource: "repo.issue";
+
      params: {
+
        baseUrl: BaseUrl;
+
        seedingPolicy: SeedingPolicy;
+
        repo: Repo;
+
        rawPath: (commit?: string) => string;
+
        issue: Issue;
+
        nodeAvatarUrl: string | undefined;
+
      };
+
    }
+
  | {
+
      resource: "repo.issues";
+
      params: {
+
        baseUrl: BaseUrl;
+
        seedingPolicy: SeedingPolicy;
+
        repo: Repo;
+
        issues: Issue[];
+
        status: IssueState["status"];
+
        nodeAvatarUrl: string | undefined;
+
      };
+
    }
+
  | {
+
      resource: "repo.patches";
+
      params: {
+
        baseUrl: BaseUrl;
+
        seedingPolicy: SeedingPolicy;
+
        repo: Repo;
+
        patches: Patch[];
+
        status: PatchState["status"];
+
        nodeAvatarUrl: string | undefined;
+
      };
+
    }
+
  | {
+
      resource: "repo.patch";
+
      params: {
+
        baseUrl: BaseUrl;
+
        seedingPolicy: SeedingPolicy;
+
        repo: Repo;
+
        rawPath: (commit?: string) => string;
+
        patch: Patch;
+
        stats: Diff["stats"];
+
        view: PatchView;
+
        nodeAvatarUrl: string | undefined;
+
      };
+
    };
+

+
export type BlobResult =
+
  | { ok: true; blob: Blob; highlighted: Syntax.Root | undefined }
+
  | { ok: false; error: { status?: number; message: string; path: string } };
+

+
export type PatchView =
+
  | {
+
      name: "activity";
+
      revision: string;
+
    }
+
  | {
+
      name: "changes";
+
      revision: string;
+
      oid: string;
+
      diff: Diff;
+
      commits: CommitHeader[];
+
      files: Record<string, CommitBlob>;
+
    }
+
  | {
+
      name: "diff";
+
      diff: Diff;
+
      files: Record<string, DiffBlob>;
+
      fromCommit: string;
+
      toCommit: string;
+
    };
+

+
// Check whether the input is a SHA1 commit.
+
function isOid(input: string): boolean {
+
  return /^[a-fA-F0-9]{40}$/.test(input);
+
}
+

+
export const cachedGetDiff = cached(
+
  async (baseUrl: BaseUrl, rid: string, base: string, oid: string) => {
+
    const api = new HttpdClient(baseUrl);
+
    return await api.repo.getDiff(rid, base, oid);
+
  },
+
  (...args) => JSON.stringify(args),
+
  { max: 200 },
+
);
+

+
function parseRevisionToOid(
+
  revision: string | undefined,
+
  defaultBranch: string,
+
  branches: Record<string, string>,
+
): string {
+
  if (revision) {
+
    if (isOid(revision)) {
+
      return revision;
+
    } else {
+
      const oid = branches[revision];
+
      if (oid) {
+
        return oid;
+
      } else {
+
        throw new Error(`Revision ${revision} not found`);
+
      }
+
    }
+
  } else {
+
    return branches[defaultBranch];
+
  }
+
}
+

+
export async function loadRepoRoute(
+
  route: RepoRoute,
+
  previousLoaded: LoadedRoute,
+
): Promise<RepoLoadedRoute | ErrorRoute | NotFoundRoute> {
+
  if (
+
    import.meta.env.PROD &&
+
    isLocal(`${route.node.hostname}:${route.node.port}`)
+
  ) {
+
    return {
+
      resource: "error",
+
      params: {
+
        icon: "device",
+
        title: "Local node browsing not supported",
+
        description: `You're trying to access a repository on a local node from your browser, we are currently working on a desktop app specific for this use case. Join our <strong>#desktop</strong> channel on <radicle-external-link href="${config.supportWebsite}">${config.supportWebsite}</radicle-external-link> for more information.`,
+
      },
+
    };
+
  }
+
  const api = new HttpdClient(route.node);
+

+
  try {
+
    if (route.resource === "repo.source") {
+
      return await loadTreeView(route, previousLoaded);
+
    } else if (route.resource === "repo.history") {
+
      return await loadHistoryView(route, previousLoaded);
+
    } else if (route.resource === "repo.commit") {
+
      const [repo, commit, seedingPolicy, node] = await Promise.all([
+
        api.repo.getByRid(route.repo),
+
        api.repo.getCommitBySha(route.repo, route.commit),
+
        api.getPolicyByRid(route.repo),
+
        api.getNode(),
+
      ]);
+

+
      return {
+
        resource: "repo.commit",
+
        params: {
+
          baseUrl: route.node,
+
          seedingPolicy,
+
          repo,
+
          commit,
+
          nodeAvatarUrl: node.avatarUrl,
+
        },
+
      };
+
    } else if (route.resource === "repo.issue") {
+
      return await loadIssueView(route);
+
    } else if (route.resource === "repo.patch") {
+
      return await loadPatchView(route, previousLoaded);
+
    } else if (route.resource === "repo.issues") {
+
      return await loadIssuesView(route);
+
    } else if (route.resource === "repo.patches") {
+
      return await loadPatchesView(route);
+
    } else {
+
      return unreachable(route);
+
    }
+
  } catch (error) {
+
    if (
+
      error instanceof Error ||
+
      error instanceof ResponseError ||
+
      error instanceof ResponseParseError
+
    ) {
+
      return handleError(error, route);
+
    } else {
+
      return unreachableError();
+
    }
+
  }
+
}
+

+
async function loadPatchesView(
+
  route: RepoPatchesRoute,
+
): Promise<RepoLoadedRoute> {
+
  const api = new HttpdClient(route.node);
+
  const searchParams = new URLSearchParams(route.search || "");
+
  const status = (searchParams.get("status") as PatchState["status"]) || "open";
+

+
  const [repo, patches, seedingPolicy, node] = await Promise.all([
+
    api.repo.getByRid(route.repo),
+
    api.repo.getAllPatches(route.repo, {
+
      status,
+
      page: 0,
+
      perPage: PATCHES_PER_PAGE,
+
    }),
+
    api.getPolicyByRid(route.repo),
+
    api.getNode(),
+
  ]);
+

+
  return {
+
    resource: "repo.patches",
+
    params: {
+
      baseUrl: route.node,
+
      seedingPolicy,
+
      patches,
+
      status,
+
      repo,
+
      nodeAvatarUrl: node.avatarUrl,
+
    },
+
  };
+
}
+

+
async function loadIssuesView(
+
  route: RepoIssuesRoute,
+
): Promise<RepoLoadedRoute> {
+
  const api = new HttpdClient(route.node);
+
  const status = route.status || "open";
+

+
  const [repo, issues, seedingPolicy, node] = await Promise.all([
+
    api.repo.getByRid(route.repo),
+
    api.repo.getAllIssues(route.repo, {
+
      status,
+
      page: 0,
+
      perPage: ISSUES_PER_PAGE,
+
    }),
+
    api.getPolicyByRid(route.repo),
+
    api.getNode(),
+
  ]);
+

+
  return {
+
    resource: "repo.issues",
+
    params: {
+
      baseUrl: route.node,
+
      seedingPolicy,
+
      issues,
+
      status,
+
      repo,
+
      nodeAvatarUrl: node.avatarUrl,
+
    },
+
  };
+
}
+

+
async function loadTreeView(
+
  route: RepoTreeRoute,
+
  previousLoaded: LoadedRoute,
+
): Promise<RepoLoadedRoute | NotFoundRoute> {
+
  const api = new HttpdClient(route.node);
+
  const rawPath = (commit?: string) =>
+
    `${route.node.scheme}://${route.node.hostname}:${route.node.port}/raw/${
+
      route.repo
+
    }${commit ? `/${commit}` : ""}`;
+

+
  let repoPromise: Promise<Repo>;
+
  let seedingPolicyPromise: Promise<SeedingPolicy>;
+
  let peersPromise: Promise<Remote[]>;
+
  let nodePromise: Promise<Partial<Node>>;
+
  if (
+
    (previousLoaded.resource === "repo.source" ||
+
      previousLoaded.resource === "repo.history") &&
+
    previousLoaded.params.repo.rid === route.repo &&
+
    previousLoaded.params.peer === route.peer
+
  ) {
+
    repoPromise = Promise.resolve(previousLoaded.params.repo);
+
    peersPromise = Promise.resolve(previousLoaded.params.peers);
+
    seedingPolicyPromise = Promise.resolve(previousLoaded.params.seedingPolicy);
+
    nodePromise = Promise.resolve({
+
      avatarUrl: previousLoaded.params.nodeAvatarUrl,
+
    });
+
  } else {
+
    repoPromise = api.repo.getByRid(route.repo);
+
    peersPromise = api.repo.getAllRemotes(route.repo);
+
    seedingPolicyPromise = api.getPolicyByRid(route.repo);
+
    nodePromise = api.getNode();
+
  }
+

+
  const [repo, peers, seedingPolicy, node] = await Promise.all([
+
    repoPromise,
+
    peersPromise,
+
    seedingPolicyPromise,
+
    nodePromise,
+
  ]);
+

+
  if (!repo["payloads"]["xyz.radicle.project"]) {
+
    throw new Error(
+
      `Repository ${repo.rid} does not have a xyz.radicle.project payload.`,
+
    );
+
  }
+

+
  const project = repo["payloads"]["xyz.radicle.project"];
+
  let branchMap: Record<string, string> = {
+
    [project.data.defaultBranch]: project.meta.head,
+
  };
+
  if (route.peer) {
+
    const peer = peers.find(peer => peer.id === route.peer);
+
    if (!peer) {
+
      return {
+
        resource: "notFound",
+
        params: { title: `Peer ${route.peer} could not be found` },
+
      };
+
    } else {
+
      branchMap = peer.heads;
+
    }
+
  }
+

+
  if (route.route) {
+
    const { revision, path } = detectRevision(route.route, branchMap);
+
    route.revision = revision;
+
    route.path = path;
+
  }
+

+
  const commit = parseRevisionToOid(
+
    route.revision,
+
    project.data.defaultBranch,
+
    branchMap,
+
  );
+
  const path = route.path || "/";
+
  const [tree, blobResult] = await Promise.all([
+
    api.repo.getTree(route.repo, commit),
+
    loadBlob(api, repo.rid, commit, path),
+
  ]);
+
  return {
+
    resource: "repo.source",
+
    params: {
+
      baseUrl: route.node,
+
      seedingPolicy,
+
      commit,
+
      repo,
+
      peers: peers.filter(remote => Object.keys(remote.heads).length > 0),
+
      peer: route.peer,
+
      rawPath,
+
      revision: route.revision,
+
      tree,
+
      path,
+
      blobResult,
+
      nodeAvatarUrl: node.avatarUrl,
+
    },
+
  };
+
}
+

+
async function loadBlob(
+
  api: HttpdClient,
+
  repo: string,
+
  commit: string,
+
  path: string,
+
): Promise<BlobResult> {
+
  try {
+
    let blob: Blob;
+
    if (path === "" || path === "/") {
+
      blob = await api.repo.getReadme(repo, commit);
+
    } else {
+
      blob = await api.repo.getBlob(repo, commit, path);
+
    }
+
    return {
+
      ok: true,
+
      blob,
+
      highlighted: blob.content
+
        ? await Syntax.highlight(blob.content, blob.path.split(".").pop() ?? "")
+
        : undefined,
+
    };
+
  } catch (e: unknown) {
+
    if (e instanceof ResponseError) {
+
      return {
+
        ok: false,
+
        error: {
+
          status: e.status,
+
          message: "Not able to load file",
+
          path,
+
        },
+
      };
+
    } else if (path === "/") {
+
      return {
+
        ok: false,
+
        error: {
+
          message: "The README could not be loaded",
+
          path,
+
        },
+
      };
+
    } else {
+
      return {
+
        ok: false,
+
        error: {
+
          message: "Not able to load file",
+
          path,
+
        },
+
      };
+
    }
+
  }
+
}
+
async function loadHistoryView(
+
  route: RepoHistoryRoute,
+
  previousLoaded: LoadedRoute,
+
): Promise<RepoLoadedRoute> {
+
  const api = new HttpdClient(route.node);
+

+
  let repoPromise: Promise<Repo>;
+
  let seedingPolicyPromise: Promise<SeedingPolicy>;
+
  let peersPromise: Promise<Remote[]>;
+
  let nodePromise: Promise<Partial<Node>>;
+
  if (
+
    (previousLoaded.resource === "repo.source" ||
+
      previousLoaded.resource === "repo.history") &&
+
    previousLoaded.params.repo.rid === route.repo &&
+
    previousLoaded.params.peer === route.peer
+
  ) {
+
    repoPromise = Promise.resolve(previousLoaded.params.repo);
+
    peersPromise = Promise.resolve(previousLoaded.params.peers);
+
    seedingPolicyPromise = Promise.resolve(previousLoaded.params.seedingPolicy);
+
    nodePromise = Promise.resolve({
+
      avatarUrl: previousLoaded.params.nodeAvatarUrl,
+
    });
+
  } else {
+
    repoPromise = api.repo.getByRid(route.repo);
+
    peersPromise = api.repo.getAllRemotes(route.repo);
+
    seedingPolicyPromise = api.getPolicyByRid(route.repo);
+
    nodePromise = api.getNode();
+
  }
+

+
  const [repo, peers, seedingPolicy, branchMap, node] = await Promise.all([
+
    repoPromise,
+
    peersPromise,
+
    seedingPolicyPromise,
+
    getPeerBranches(api, route.repo, route.peer),
+
    nodePromise,
+
  ]);
+

+
  if (!repo["payloads"]["xyz.radicle.project"]) {
+
    throw new Error(
+
      `Repository ${repo.rid} does not have a xyz.radicle.project payload.`,
+
    );
+
  }
+

+
  const project = repo["payloads"]["xyz.radicle.project"];
+
  let commitId;
+
  if (route.revision && isOid(route.revision)) {
+
    commitId = route.revision;
+
  } else if (branchMap) {
+
    commitId = branchMap[route.revision || project.data.defaultBranch];
+
  } else if (!route.revision) {
+
    commitId = project.meta.head;
+
  }
+

+
  if (!commitId) {
+
    throw new Error(
+
      `Revision ${route.revision} not found for repo ${repo.rid}`,
+
    );
+
  }
+

+
  let treePromise: Promise<Tree>;
+

+
  if (
+
    (previousLoaded.resource === "repo.source" ||
+
      previousLoaded.resource === "repo.history") &&
+
    previousLoaded.params.repo.rid === route.repo &&
+
    previousLoaded.params.commit === commitId
+
  ) {
+
    treePromise = Promise.resolve(previousLoaded.params.tree);
+
  } else {
+
    treePromise = api.repo.getTree(route.repo, commitId);
+
  }
+

+
  const [tree, commitHeaders] = await Promise.all([
+
    treePromise,
+
    api.repo.getAllCommits(repo.rid, {
+
      parent: commitId,
+
      page: 0,
+
      perPage: config.source.commitsPerPage,
+
    }),
+
  ]);
+

+
  return {
+
    resource: "repo.history",
+
    params: {
+
      baseUrl: route.node,
+
      seedingPolicy,
+
      commit: commitId,
+
      repo,
+
      peers: peers.filter(remote => Object.keys(remote.heads).length > 0),
+
      peer: route.peer,
+
      revision: route.revision,
+
      tree,
+
      commitHeaders,
+
      nodeAvatarUrl: node.avatarUrl,
+
    },
+
  };
+
}
+

+
async function loadIssueView(route: RepoIssueRoute): Promise<RepoLoadedRoute> {
+
  const api = new HttpdClient(route.node);
+
  const rawPath = (commit?: string) =>
+
    `${route.node.scheme}://${route.node.hostname}:${route.node.port}/raw/${
+
      route.repo
+
    }${commit ? `/${commit}` : ""}`;
+

+
  const [repo, issue, seedingPolicy, node] = await Promise.all([
+
    api.repo.getByRid(route.repo),
+
    api.repo.getIssueById(route.repo, route.issue),
+
    api.getPolicyByRid(route.repo),
+
    api.getNode(),
+
  ]);
+
  return {
+
    resource: "repo.issue",
+
    params: {
+
      baseUrl: route.node,
+
      seedingPolicy,
+
      repo,
+
      rawPath,
+
      issue,
+
      nodeAvatarUrl: node.avatarUrl,
+
    },
+
  };
+
}
+

+
async function loadPatchView(
+
  route: RepoPatchRoute,
+
  previousLoaded: LoadedRoute,
+
): Promise<RepoLoadedRoute> {
+
  const api = new HttpdClient(route.node);
+
  const rawPath = (commit?: string) =>
+
    `${route.node.scheme}://${route.node.hostname}:${route.node.port}/raw/${
+
      route.repo
+
    }${commit ? `/${commit}` : ""}`;
+

+
  let repoPromise: Promise<Repo>;
+
  let patchPromise: Promise<Patch>;
+
  let nodePromise: Promise<Partial<Node>>;
+
  let seedingPolicyPromise: Promise<SeedingPolicy>;
+

+
  if (
+
    previousLoaded.resource === "repo.patch" &&
+
    previousLoaded.params.repo.rid === route.repo &&
+
    previousLoaded.params.patch.id === route.patch
+
  ) {
+
    repoPromise = Promise.resolve(previousLoaded.params.repo);
+
    patchPromise = Promise.resolve(previousLoaded.params.patch);
+
    seedingPolicyPromise = Promise.resolve(previousLoaded.params.seedingPolicy);
+
    nodePromise = Promise.resolve({
+
      avatarUrl: previousLoaded.params.nodeAvatarUrl,
+
    });
+
  } else {
+
    repoPromise = api.repo.getByRid(route.repo);
+
    patchPromise = api.repo.getPatchById(route.repo, route.patch);
+
    seedingPolicyPromise = api.getPolicyByRid(route.repo);
+
    nodePromise = api.getNode();
+
  }
+
  const [repo, patch, seedingPolicy, { avatarUrl }] = await Promise.all([
+
    repoPromise,
+
    patchPromise,
+
    seedingPolicyPromise,
+
    nodePromise,
+
  ]);
+

+
  const latestRevision = patch.revisions.at(-1) as Revision;
+
  const {
+
    diff: { stats },
+
  } = await cachedGetDiff(
+
    api.baseUrl,
+
    route.repo,
+
    latestRevision.base,
+
    latestRevision.oid,
+
  );
+

+
  let view: PatchView;
+
  switch (route.view?.name) {
+
    case "activity":
+
    case undefined: {
+
      view = { name: "activity", revision: latestRevision.id };
+
      break;
+
    }
+
    case "changes": {
+
      const revisionId = route.view.revision;
+
      const revision =
+
        patch.revisions.find(r => r.id === revisionId) || latestRevision;
+
      if (!revision) {
+
        throw new Error(
+
          `revision ${revisionId} of patch ${route.patch} not found`,
+
        );
+
      }
+
      const { diff, commits, files } = await cachedGetDiff(
+
        api.baseUrl,
+
        route.repo,
+
        revision.base,
+
        revision.oid,
+
      );
+
      view = {
+
        name: route.view?.name,
+
        revision: revision.id,
+
        oid: revision.oid,
+
        diff,
+
        commits,
+
        files,
+
      };
+
      break;
+
    }
+
    case "diff": {
+
      const { fromCommit, toCommit } = route.view;
+
      const { diff, files } = await cachedGetDiff(
+
        api.baseUrl,
+
        route.repo,
+
        fromCommit,
+
        toCommit,
+
      );
+

+
      view = { name: "diff", fromCommit, toCommit, files, diff };
+
      break;
+
    }
+
  }
+
  return {
+
    resource: "repo.patch",
+
    params: {
+
      baseUrl: route.node,
+
      seedingPolicy,
+
      repo,
+
      rawPath,
+
      patch,
+
      stats,
+
      view,
+
      nodeAvatarUrl: avatarUrl,
+
    },
+
  };
+
}
+

+
async function getPeerBranches(api: HttpdClient, repo: string, peer?: string) {
+
  if (peer) {
+
    return (await api.repo.getRemoteByPeer(repo, peer)).heads;
+
  } else {
+
    return undefined;
+
  }
+
}
+

+
// Detects branch names and commit IDs at the start of `input` and extract it.
+
function detectRevision(
+
  input: string,
+
  branches: Record<string, string>,
+
): { path: string; revision?: string } {
+
  const commitPath = [input.slice(0, 40), input.slice(41)];
+
  const branch = Object.entries(branches).find(([branchName]) =>
+
    input.startsWith(branchName),
+
  );
+

+
  if (branch) {
+
    const [revision, path] = [
+
      input.slice(0, branch[0].length),
+
      input.slice(branch[0].length + 1),
+
    ];
+
    return {
+
      revision,
+
      path: path || "/",
+
    };
+
  } else if (isOid(commitPath[0])) {
+
    return {
+
      revision: commitPath[0],
+
      path: commitPath[1] || "/",
+
    };
+
  } else {
+
    return { path: input };
+
  }
+
}
+

+
function sanitizeQueryString(queryString: string): string {
+
  return queryString.startsWith("?") ? queryString.substring(1) : queryString;
+
}
+

+
export function resolveRepoRoute(
+
  node: BaseUrl,
+
  repo: string,
+
  segments: string[],
+
  urlSearch: string,
+
): RepoRoute | null {
+
  let content = segments.shift();
+
  let peer;
+
  if (content === "remotes") {
+
    peer = segments.shift();
+
    content = segments.shift();
+
  }
+

+
  if (!content || content === "tree") {
+
    return {
+
      resource: "repo.source",
+
      node,
+
      repo,
+
      peer,
+
      path: undefined,
+
      revision: undefined,
+
      route: segments.join("/"),
+
    };
+
  } else if (content === "history") {
+
    return {
+
      resource: "repo.history",
+
      node,
+
      repo,
+
      peer,
+
      revision: segments.join("/"),
+
    };
+
  } else if (content === "commits") {
+
    return {
+
      resource: "repo.commit",
+
      node,
+
      repo,
+
      commit: segments[0],
+
    };
+
  } else if (content === "issues") {
+
    const issueOrAction = segments.shift();
+
    if (issueOrAction) {
+
      return {
+
        resource: "repo.issue",
+
        node,
+
        repo,
+
        issue: issueOrAction,
+
      };
+
    } else {
+
      const rawStatus = new URLSearchParams(sanitizeQueryString(urlSearch)).get(
+
        "status",
+
      );
+
      let status: "open" | "closed" | undefined;
+
      if (rawStatus === "open" || rawStatus === "closed") {
+
        status = rawStatus;
+
      }
+
      return {
+
        resource: "repo.issues",
+
        node,
+
        repo,
+
        status,
+
      };
+
    }
+
  } else if (content === "patches") {
+
    return resolvePatchesRoute(node, repo, segments, urlSearch);
+
  } else {
+
    return null;
+
  }
+
}
+

+
function resolvePatchesRoute(
+
  node: BaseUrl,
+
  repo: string,
+
  segments: string[],
+
  urlSearch: string,
+
): RepoPatchRoute | RepoPatchesRoute {
+
  const patch = segments.shift();
+
  const revision = segments.shift();
+
  if (patch) {
+
    const searchParams = new URLSearchParams(sanitizeQueryString(urlSearch));
+
    const tab = searchParams.get("tab");
+
    const base = {
+
      resource: "repo.patch",
+
      node,
+
      repo,
+
      patch,
+
    } as const;
+
    const diff = searchParams.get("diff");
+
    if (diff) {
+
      const [fromCommit, toCommit] = diff.split("..");
+
      if (isOid(fromCommit) && isOid(toCommit)) {
+
        return {
+
          ...base,
+
          view: { name: "diff", fromCommit, toCommit },
+
        };
+
      }
+
    }
+

+
    if (tab === "changes") {
+
      return {
+
        ...base,
+
        view: { name: tab, revision },
+
      };
+
    } else if (tab === "activity") {
+
      return {
+
        ...base,
+
        view: { name: tab },
+
      };
+
    } else {
+
      return base;
+
    }
+
  } else {
+
    return {
+
      resource: "repo.patches",
+
      node,
+
      repo,
+
      search: sanitizeQueryString(urlSearch),
+
    };
+
  }
+
}
+

+
export function repoRouteToPath(route: RepoRoute): string {
+
  const node = nodePath(route.node);
+

+
  const pathSegments = [node, route.repo];
+

+
  if (route.resource === "repo.source") {
+
    if (route.peer) {
+
      pathSegments.push("remotes", route.peer);
+
    }
+

+
    pathSegments.push("tree");
+
    let omitTree = true;
+

+
    if (route.route && route.route !== "/") {
+
      pathSegments.push(route.route);
+
      omitTree = false;
+
    } else {
+
      if (route.revision) {
+
        pathSegments.push(route.revision);
+
        omitTree = false;
+
      }
+

+
      if (route.path && route.path !== "/") {
+
        pathSegments.push(route.path);
+
        omitTree = false;
+
      }
+
    }
+
    if (omitTree) {
+
      pathSegments.pop();
+
    }
+

+
    return pathSegments.join("/");
+
  } else if (route.resource === "repo.history") {
+
    if (route.peer) {
+
      pathSegments.push("remotes", route.peer);
+
    }
+

+
    pathSegments.push("history");
+

+
    if (route.revision) {
+
      pathSegments.push(route.revision);
+
    }
+
    return pathSegments.join("/");
+
  } else if (route.resource === "repo.commit") {
+
    return [...pathSegments, "commits", route.commit].join("/");
+
  } else if (route.resource === "repo.issues") {
+
    let url = [...pathSegments, "issues"].join("/");
+
    const searchParams = new URLSearchParams();
+
    if (route.status) {
+
      searchParams.set("status", route.status);
+
    }
+
    if (searchParams.size > 0) {
+
      url += `?${searchParams}`;
+
    }
+
    return url;
+
  } else if (route.resource === "repo.issue") {
+
    return [...pathSegments, "issues", route.issue].join("/");
+
  } else if (route.resource === "repo.patches") {
+
    let url = [...pathSegments, "patches"].join("/");
+
    if (route.search) {
+
      url += `?${route.search}`;
+
    }
+
    return url;
+
  } else if (route.resource === "repo.patch") {
+
    return patchRouteToPath(route);
+
  } else {
+
    return unreachable(route);
+
  }
+
}
+

+
function patchRouteToPath(route: RepoPatchRoute): string {
+
  const node = nodePath(route.node);
+

+
  const pathSegments = [node, route.repo];
+

+
  pathSegments.push("patches", route.patch);
+
  if (route.view?.name === "changes") {
+
    if (route.view.revision) {
+
      pathSegments.push(route.view.revision);
+
    }
+
  }
+

+
  let url = pathSegments.join("/");
+
  if (!route.view) {
+
    return url;
+
  } else {
+
    const searchParams = new URLSearchParams();
+

+
    if (route.view.name === "diff") {
+
      searchParams.set(
+
        "diff",
+
        `${route.view.fromCommit}..${route.view.toCommit}`,
+
      );
+
    } else {
+
      searchParams.set("tab", route.view.name);
+
    }
+
    url += `?${searchParams.toString()}`;
+
    return url;
+
  }
+
}
+

+
export function repoTitle(loadedRoute: RepoLoadedRoute) {
+
  const title: string[] = [];
+

+
  if (!loadedRoute.params.repo["payloads"]["xyz.radicle.project"]) {
+
    throw new Error(
+
      `Repository ${loadedRoute.params.repo.rid} does not have a xyz.radicle.project payload.`,
+
    );
+
  }
+
  const project = loadedRoute.params.repo["payloads"]["xyz.radicle.project"];
+

+
  if (loadedRoute.resource === "repo.source") {
+
    title.push(project.data.name);
+
    if (project.data.description.length > 0) {
+
      title.push(project.data.description);
+
    }
+
  } else if (loadedRoute.resource === "repo.commit") {
+
    title.push(loadedRoute.params.commit.commit.summary);
+
    title.push("commit");
+
  } else if (loadedRoute.resource === "repo.history") {
+
    title.push(project.data.name);
+
    title.push("history");
+
  } else if (loadedRoute.resource === "repo.issue") {
+
    title.push(loadedRoute.params.issue.title);
+
    title.push("issue");
+
  } else if (loadedRoute.resource === "repo.issues") {
+
    title.push(project.data.name);
+
    title.push("issues");
+
  } else if (loadedRoute.resource === "repo.patch") {
+
    title.push(loadedRoute.params.patch.title);
+
    title.push("patch");
+
  } else if (loadedRoute.resource === "repo.patches") {
+
    title.push(project.data.name);
+
    title.push("patches");
+
  } else {
+
    return unreachable(loadedRoute);
+
  }
+

+
  return title;
+
}
+

+
export const testExports = { isOid };
modified src/views/users/View.svelte
@@ -3,7 +3,7 @@

  import * as router from "@app/lib/router";
  import * as utils from "@app/lib/utils";
-
  import { fetchProjectInfos } from "@app/components/ProjectCard";
+
  import { fetchRepoInfos } from "@app/components/RepoCard";
  import { handleError } from "@app/views/nodes/error";

  import Avatar from "@app/components/Avatar.svelte";
@@ -19,8 +19,8 @@
  import MobileFooter from "@app/App/MobileFooter.svelte";
  import Placeholder from "@app/components/Placeholder.svelte";
  import Popover from "@app/components/Popover.svelte";
-
  import ProjectCard from "@app/components/ProjectCard.svelte";
-
  import Separator from "@app/views/projects/Separator.svelte";
+
  import RepoCard from "@app/components/RepoCard.svelte";
+
  import Separator from "@app/views/repos/Separator.svelte";
  import Settings from "@app/App/Settings.svelte";
  import UserAddress from "@app/views/users/UserAddress.svelte";

@@ -216,7 +216,7 @@
            resource: "nodes",
            params: {
              baseUrl,
-
              projectPageIndex: 0,
+
              repoPageIndex: 0,
            },
          }}>
          <img
@@ -352,15 +352,15 @@
        </div>
      </div>

-
      {#await fetchProjectInfos(baseUrl, { show: "all", perPage: stats.repos.total }, utils.formatDid(did))}
+
      {#await fetchRepoInfos(baseUrl, { show: "all", perPage: stats.repos.total }, utils.formatDid(did))}
        <div class="loading">
          <Loading small center />
        </div>
      {:then repos}
        {#if repos.length > 0}
          <div class="repo-grid">
-
            {#each repos as projectInfo}
-
              <ProjectCard {projectInfo}>
+
            {#each repos as repoInfo}
+
              <RepoCard {repoInfo}>
                <svelte:fragment slot="delegate">
                  <Badge
                    title={`${node.alias || utils.formatNodeId(did.pubkey)} is a delegate of this repository`}
@@ -371,7 +371,7 @@
                    <Icon name="badge" />
                  </Badge>
                </svelte:fragment>
-
              </ProjectCard>
+
              </RepoCard>
            {/each}
          </div>
          <div class="subtitle">
modified src/views/users/router.ts
@@ -7,7 +7,7 @@ import { HttpdClient } from "@http-client";
import { ResponseError, ResponseParseError } from "@http-client/lib/fetcher";
import { handleError } from "@app/views/nodes/error";
import { nodePath } from "@app/views/nodes/router";
-
import { unreachableError } from "@app/views/projects/error";
+
import { unreachableError } from "@app/views/repos/error";

export interface UserRoute {
  resource: "users";
modified tests/e2e/clipboard.spec.ts
@@ -30,9 +30,9 @@ test("copy to clipboard", async ({ page, browserName, context }) => {
  // Reset system clipboard to a known state.
  await page.evaluate<string>("navigator.clipboard.writeText('')");

-
  // Project ID.
+
  // Repo ID.
  {
-
    await page.getByLabel("project-id").click();
+
    await page.getByLabel("repo-id").click();
    const clipboardContent = await page.evaluate<string>(
      "navigator.clipboard.readText()",
    );
modified tests/e2e/node.spec.ts
@@ -25,27 +25,27 @@ test("node metadata", async ({ page, peerManager }) => {
  await expect(page.getByText("/radicle:1.0.0-rc.13/")).toBeVisible();
});

-
test("node projects", async ({ page }) => {
+
test("node repos", async ({ page }) => {
  await page.goto("/nodes/radicle.local");
-
  const project = page
-
    .locator(".project-card", { hasText: "source-browsing" })
+
  const repo = page
+
    .locator(".repo-card", { hasText: "source-browsing" })
    .nth(0);

-
  // Project metadata.
+
  // Repo metadata.
  {
-
    await expect(project.getByText("source-browsing")).toBeVisible();
+
    await expect(repo.getByText("source-browsing")).toBeVisible();
    await expect(
-
      project.getByText("Git repository for source browsing tests"),
+
      repo.getByText("Git repository for source browsing tests"),
    ).toBeVisible();
  }
});

test("show pinned repositories", async ({ page }) => {
  await page.goto("/");
-
  // Shows pinned project name.
+
  // Shows pinned repo name.
  await expect(page.getByText("source-browsing")).toBeVisible();
  //
-
  // Shows pinned project description.
+
  // Shows pinned repo description.
  await expect(
    page.getByText("Git repository for source browsing tests"),
  ).toBeVisible();
deleted tests/e2e/project.spec.ts
@@ -1,561 +0,0 @@
-
import {
-
  aliceMainCommitCount,
-
  aliceMainCommitMessage,
-
  aliceMainHead,
-
  bobMainCommitCount,
-
  cobUrl,
-
  expect,
-
  markdownUrl,
-
  shortAliceHead,
-
  shortBobHead,
-
  sourceBrowsingRid,
-
  sourceBrowsingUrl,
-
  test,
-
} from "@tests/support/fixtures.js";
-
import { changeBranch, createProject } from "@tests/support/project";
-
import { expectUrlPersistsReload } from "@tests/support/router";
-

-
test("navigate to project", async ({ page }) => {
-
  await page.goto(sourceBrowsingUrl);
-

-
  // Header.
-
  {
-
    const name = page.getByRole("link", { name: "source-browsing" }).nth(1);
-
    const id = page.getByText(sourceBrowsingRid);
-
    const description = page.getByText(
-
      "Git repository for source browsing tests",
-
    );
-

-
    await expect(name).toBeVisible();
-
    await expect(id).toBeVisible();
-
    await expect(description).toBeVisible();
-
  }
-

-
  // Project menu shows default selected branch and commit and contributor counts.
-
  {
-
    await expect(page.getByTitle("Change branch")).toBeVisible();
-
    await expect(
-
      page
-
        .getByRole("button", {
-
          name: `${shortAliceHead} ${aliceMainCommitMessage}`,
-
        })
-
        .first(),
-
    ).toBeVisible();
-
    await expect(
-
      page.getByRole("link", {
-
        name: `Commits ${aliceMainCommitCount}`,
-
      }),
-
    ).toBeVisible();
-
  }
-

-
  // Navigate to the project README.md by default.
-
  await expect(page.locator(".filename")).toContainText("README.md");
-

-
  // Show a commit teaser.
-
  await expect(page.getByText("dd068e9 Add README.md")).toBeVisible();
-

-
  // Show rendered README.md contents.
-
  await expect(page.getByText("Git test repository")).toBeVisible();
-
});
-

-
test("project description", async ({ page, peer }) => {
-
  const { rid } = await createProject(peer, {
-
    name: "heartwood",
-
    description: "Radicle Heartwood Protocol & Stack",
-
  });
-
  await page.goto(peer.ridUrl(rid));
-
  await expect(
-
    page.getByText("Radicle Heartwood Protocol & Stack"),
-
  ).toBeVisible();
-
});
-

-
test("show source tree at specific revision", async ({ page }) => {
-
  await page.goto(sourceBrowsingUrl);
-
  await page
-
    .getByRole("link", { name: `Commits ${aliceMainCommitCount}` })
-
    .click();
-

-
  await page
-
    .locator(".teaser", { hasText: "335dd6d" })
-
    .getByRole("button", {
-
      name: "Browse repo at this commit",
-
    })
-
    .click();
-

-
  await expect(page.getByTitle("Current HEAD")).toContainText("335dd6d");
-
  await expect(page.locator(".source-tree")).toHaveText("bin src");
-
  await expect(
-
    page.getByRole("link", {
-
      name: "Commits 2",
-
    }),
-
  ).toBeVisible();
-
});
-

-
test("source file highlighting", async ({ page }) => {
-
  await page.goto(sourceBrowsingUrl);
-

-
  await page.getByText("src").click();
-
  await page.getByText("true.c").click();
-

-
  await expect(page.getByText("return")).toHaveCSS(
-
    "color",
-
    "rgb(255, 123, 114)",
-
  );
-
});
-

-
test("navigate line numbers", async ({ page }) => {
-
  await page.goto(`${markdownUrl}/tree/main/cheatsheet.md`);
-
  await page.getByRole("button", { name: "Code" }).click();
-

-
  await page.getByRole("link", { name: "5", exact: true }).click();
-
  await expect(page.locator("#L5")).toHaveClass("line highlight");
-
  await expect(page).toHaveURL(`${markdownUrl}/tree/main/cheatsheet.md#L5`);
-

-
  await expectUrlPersistsReload(page);
-
  await expect(page.locator("#L5")).toHaveClass("line highlight");
-

-
  await page.getByRole("link", { name: "30", exact: true }).click();
-
  await expect(page.locator("#L5")).not.toHaveClass("line highlight");
-
  await expect(page.locator("#L30")).toHaveClass("line highlight");
-
  await expect(page).toHaveURL(`${markdownUrl}/tree/main/cheatsheet.md#L30`);
-

-
  // Check that we go back to the Markdown view when navigating to a different
-
  // file.
-
  await page.getByRole("link", { name: "footnotes.md" }).click();
-
  await expect(page.getByRole("button", { name: "Preview" })).toHaveClass(
-
    /selected/,
-
  );
-
});
-

-
test("navigate deep file hierarchies", async ({ page }) => {
-
  await page.goto(sourceBrowsingUrl);
-

-
  const sourceTree = page.locator(".source-tree");
-

-
  await sourceTree.getByText("deep").click();
-
  await sourceTree.getByText("directory").click();
-
  await sourceTree.getByText("hierarchy").click();
-
  await sourceTree.getByText("is").click();
-
  await sourceTree.getByText("entirely").click();
-
  await sourceTree.getByText("possible").click();
-
  await sourceTree.getByText("in").nth(1).click();
-
  await sourceTree.getByRole("button", { name: "git" }).click();
-
  await sourceTree.getByText("repositories").click();
-
  await sourceTree.getByText(".gitkeep").click();
-
  await expect(
-
    page.getByText("0801ace Add a deeply nested directory tree"),
-
  ).toBeVisible();
-

-
  // After a page reload the tree browser is still expanded and we're still
-
  // showing the .gitkeep file.
-
  {
-
    await page.reload();
-

-
    const sourceTree = page.locator(".source-tree");
-

-
    await expect(sourceTree.getByText("deep")).toBeVisible();
-
    await expect(sourceTree.getByText("directory")).toBeVisible();
-
    await expect(sourceTree.getByText("hierarchy")).toBeVisible();
-
    await expect(sourceTree.getByText("is")).toBeVisible();
-
    await expect(sourceTree.getByText("entirely")).toBeVisible();
-
    await expect(sourceTree.getByText("possible")).toBeVisible();
-
    await expect(sourceTree.getByText("in").nth(1)).toBeVisible();
-
    await expect(sourceTree.getByText("git").nth(1)).toBeVisible();
-
    await expect(sourceTree.getByText("repositories")).toBeVisible();
-
    await expect(sourceTree.getByText(".gitkeep")).toBeVisible();
-

-
    await expect(
-
      page.getByText("0801ace Add a deeply nested directory tree"),
-
    ).toBeVisible();
-
  }
-
});
-

-
test("submodules", async ({ page }) => {
-
  await page.goto(sourceBrowsingUrl);
-
  await expect(page.getByText("rips @ 329dee9")).toBeVisible();
-
});
-

-
test("files with special characters in the filename", async ({ page }) => {
-
  await page.goto(sourceBrowsingUrl);
-

-
  const sourceTree = page.locator(".source-tree");
-
  await sourceTree.getByText("special").click();
-

-
  await sourceTree.getByText("+plus+").click();
-
  await expect(page.getByRole("banner")).toContainText("+plus");
-

-
  await sourceTree.getByText("-dash-").click();
-
  await expect(page.getByRole("banner")).toContainText("-dash-");
-

-
  await sourceTree.getByText(":colon:").click();
-
  await expect(page.getByRole("banner")).toContainText(":colon:");
-

-
  await sourceTree.getByText(";semicolon;").click();
-
  await expect(page.getByRole("banner")).toContainText(";semicolon;");
-

-
  await sourceTree.getByText("@at@").click();
-
  await expect(page.getByRole("banner")).toContainText("@at@");
-

-
  await sourceTree.getByText("_underscore_").click();
-
  await expect(page.getByRole("banner")).toContainText("_underscore_");
-

-
  // TODO: fix these errors in `radicle-httpd` for the following edge cases.
-
  //
-
  // await sourceTree.getByText("back\\slash").click();
-
  // await expect(page.locator(".filename")).toContainText("back\\slash");
-
  // await sourceTree.getByText("qs?param1=value?param2=value2#hash").click();
-
  // await expect(page.locator(".filename")).toContainText(
-
  //   "qs?param1=value?param2=value2#hash",
-
  // );
-

-
  await sourceTree.getByText("spaces are okay").click();
-
  await expect(page.getByRole("banner")).toContainText("spaces are okay");
-

-
  await sourceTree.getByText("~tilde~").click();
-
  await expect(page.getByRole("banner")).toContainText("~tilde~");
-

-
  await sourceTree.getByText("👹👹👹").click();
-
  await expect(page.getByRole("banner")).toContainText("👹👹👹");
-
});
-

-
test("binary files", async ({ page }) => {
-
  await page.goto(sourceBrowsingUrl);
-

-
  await page.getByText("bin").click();
-
  await page.getByText("true").click();
-

-
  await expect(page.getByText("Binary file")).toBeVisible();
-
});
-

-
test("empty files", async ({ page }) => {
-
  await page.goto(sourceBrowsingUrl);
-

-
  await page.getByText("special").click();
-
  await page.getByText("_underscore_").click();
-

-
  await expect(page.getByText("Empty file")).toBeVisible();
-
});
-

-
test("hidden files", async ({ page }) => {
-
  await page.goto(sourceBrowsingUrl);
-

-
  await page.getByText(".hidden").click();
-

-
  await expect(page.getByText("I'm a hidden file.")).toBeVisible();
-
});
-

-
test("markdown files", async ({ page }) => {
-
  await page.goto(`${markdownUrl}/tree/main/cheatsheet.md`);
-

-
  await expect(
-
    page.getByText("This is intended as a quick reference and showcase."),
-
  ).toBeVisible();
-

-
  // Switch between raw and rendered modes.
-
  {
-
    await expect(page.getByRole("button", { name: "Preview" })).toHaveClass(
-
      /selected/,
-
    );
-
    await expect(page.getByRole("button", { name: "Code" })).toHaveClass(
-
      /not-selected/,
-
    );
-
    await page.getByRole("button", { name: "Code" }).click();
-
    await expect(page.getByRole("button", { name: "Preview" })).toHaveClass(
-
      /not-selected/,
-
    );
-
    await expect(page.getByRole("button", { name: "Code" })).toHaveClass(
-
      /selected/,
-
    );
-
    await expect(page.getByText("##### Table of Contents")).toBeVisible();
-
    await page.getByRole("button", { name: "Preview" }).click();
-
  }
-

-
  // Internal links go to anchor.
-
  {
-
    await page.getByRole("link", { name: "YouTube Videos" }).click();
-
    await expect(page).toHaveURL(
-
      `${markdownUrl}/tree/main/cheatsheet.md#videos`,
-
    );
-
  }
-
});
-

-
test("clone modal", async ({ page }) => {
-
  await page.goto(sourceBrowsingUrl);
-

-
  await page.getByRole("button", { name: "Clone" }).click();
-
  await expect(page.getByText(`rad clone ${sourceBrowsingRid}`)).toBeVisible();
-
  await page.getByRole("button", { name: "Git" }).click();
-
  await expect(
-
    page.getByText(
-
      `http://127.0.0.1/${sourceBrowsingRid.replace("rad:", "")}.git`,
-
    ),
-
  ).toBeVisible();
-
});
-

-
test("peer and branch switching", async ({ page }) => {
-
  await page.goto(sourceBrowsingUrl);
-

-
  // Alice's peer.
-
  {
-
    await changeBranch("alice", `main ${shortAliceHead}`, page);
-
    await expect(page.getByTitle("Change branch")).toHaveText(/alice/);
-

-
    // Default `main` branch.
-
    {
-
      await expect(page.getByTitle("Change branch")).toHaveText(/main/);
-
      await expect(
-
        page
-
          .getByRole("button", {
-
            name: `${shortAliceHead} ${aliceMainCommitMessage}`,
-
          })
-
          .first(),
-
      ).toBeVisible();
-
      await expect(
-
        page.getByRole("link", {
-
          name: `Commits ${aliceMainCommitCount}`,
-
        }),
-
      ).toBeVisible();
-
    }
-

-
    // Feature branch with a slash in the name.
-
    {
-
      await changeBranch("alice", "feature/branch", page);
-
      await page.getByTitle("Change branch").click();
-
      await page.getByText("feature/branch").click();
-

-
      await expect(
-
        page.getByRole("button", { name: "feature/branch" }),
-
      ).toBeVisible();
-
      await expect(
-
        page.getByRole("button", { name: "1aded56 Add subconscious file" }),
-
      ).toBeVisible();
-
      await expect(
-
        page.getByRole("link", {
-
          name: "Commits 9",
-
        }),
-
      ).toBeVisible();
-
    }
-

-
    // Branch without a history or files in it.
-
    {
-
      await changeBranch("alice", "orphaned-branch", page);
-

-
      await expect(
-
        page.getByRole("button", { name: "orphaned-branch" }),
-
      ).toBeVisible();
-
      await expect(
-
        page.getByRole("button", { name: "af3641c Add empty orphaned" }),
-
      ).toBeVisible();
-
      await expect(
-
        page.getByRole("link", {
-
          name: "Commits 1",
-
        }),
-
      ).toBeVisible();
-

-
      await expect(page.getByText("No files at this revision")).toBeVisible();
-
    }
-
  }
-

-
  // Reset the source browser by clicking the project title.
-
  {
-
    await page.getByRole("link", { name: "source-browsing" }).nth(1).click();
-

-
    await expect(page.getByTitle("Change branch")).not.toContainText("alice");
-
    await expect(page.getByTitle("Change branch")).not.toContainText("bob");
-

-
    await expect(page.getByTitle("Change branch")).toBeVisible();
-
    await expect(
-
      page
-
        .getByRole("button", {
-
          name: `${shortAliceHead} ${aliceMainCommitMessage}`,
-
        })
-
        .first(),
-
    ).toBeVisible();
-
    await expect(page.getByText("Git test repository")).toBeVisible();
-
  }
-

-
  // Bob's peer.
-
  {
-
    await changeBranch("bob", `main ${shortBobHead}`, page);
-
    await expect(
-
      page.getByRole("button", { name: "avatar bob / main" }),
-
    ).toBeVisible();
-

-
    // Default `main` branch.
-
    {
-
      await expect(page.getByRole("button", { name: "main" })).toBeVisible();
-
      await expect(
-
        page
-
          .getByRole("button", { name: `${shortBobHead} Update readme` })
-
          .first(),
-
      ).toBeVisible();
-
      await expect(
-
        page.getByRole("link", {
-
          name: `Commits ${bobMainCommitCount}`,
-
        }),
-
      ).toBeVisible();
-
      await expect(
-
        page
-
          .getByRole("button", { name: `${shortBobHead} Update readme` })
-
          .first(),
-
      ).toBeVisible();
-
    }
-
  }
-
});
-

-
test("only one modal can be open at a time", async ({ page }) => {
-
  await page.goto(sourceBrowsingUrl);
-

-
  await changeBranch("alice", `main ${shortAliceHead}`, page);
-

-
  await page.getByText("Clone").click();
-
  await expect(page.getByText("Code font")).not.toBeVisible();
-
  await expect(page.getByText("Use the Radicle CLI")).toBeVisible();
-
  await expect(page.getByText("bob")).not.toBeVisible();
-

-
  await page.getByRole("button", { name: "Settings" }).click();
-
  await expect(page.getByText("Code font")).toBeVisible();
-
  await expect(page.getByText("Use the Radicle CLI")).not.toBeVisible();
-
  await expect(page.getByText("bob")).not.toBeVisible();
-

-
  await page.getByTitle("Change branch").click();
-
  await expect(page.getByText("Code font")).not.toBeVisible();
-
  await expect(page.getByText("Use the Radicle CLI")).not.toBeVisible();
-
  await expect(page.getByText("bob")).toBeVisible();
-
});
-

-
test.describe("browser error handling", () => {
-
  test("error appears when folder can't be loaded", async ({ page }) => {
-
    await page.route(
-
      ({ pathname }) =>
-
        pathname.startsWith(
-
          `/api/v1/projects/${sourceBrowsingRid}/tree/${aliceMainHead}/src`,
-
        ),
-
      route => route.fulfill({ status: 500 }),
-
    );
-

-
    await page.goto(sourceBrowsingUrl);
-

-
    const sourceTree = page.locator(".source-tree");
-
    await sourceTree.getByText("src").click();
-

-
    await expect(page.getByText("No README found.")).toBeVisible();
-
  });
-
  test("error appears when file can't be loaded", async ({ page }) => {
-
    await page.route(
-
      ({ pathname }) =>
-
        pathname ===
-
        `/api/v1/projects/${sourceBrowsingRid}/blob/${aliceMainHead}/.hidden`,
-
      route => route.fulfill({ status: 500 }),
-
    );
-

-
    await page.goto(sourceBrowsingUrl);
-
    await page.getByText(".hidden").click();
-

-
    await expect(page.getByText("File not found")).toBeVisible();
-
  });
-
  test("error appears when README can't be loaded", async ({ page }) => {
-
    await page.route(
-
      ({ pathname }) =>
-
        pathname ===
-
        `/api/v1/projects/${sourceBrowsingRid}/readme/${aliceMainHead}`,
-
      route => route.fulfill({ status: 500 }),
-
    );
-

-
    await page.goto(sourceBrowsingUrl);
-
    await expect(page.getByText("No README found.")).toBeVisible();
-
  });
-
  test("error appears when navigating to missing file", async ({ page }) => {
-
    await page.route(
-
      ({ pathname }) =>
-
        pathname ===
-
        `/api/v1/projects/${sourceBrowsingRid}/blob/${aliceMainHead}/.hidden`,
-
      route => route.fulfill({ status: 500 }),
-
    );
-

-
    await page.goto(`${sourceBrowsingUrl}/tree/master/.hidden`);
-

-
    await expect(page.getByText("File not found")).toBeVisible();
-
  });
-
});
-

-
test("external markdown link", async ({ context, page }) => {
-
  await context.route("https://example.com/**", route => {
-
    return route.fulfill({ body: "hello", contentType: "text/plain" });
-
  });
-
  await page.goto(`${markdownUrl}/tree/main/footnotes.md`);
-
  const pagePromise = context.waitForEvent("page");
-
  await page.getByRole("link", { name: "https://example.com" }).click();
-
  const newPage = await pagePromise;
-
  await expect(newPage).toHaveURL("https://example.com");
-
});
-

-
test("absolute markdown link", async ({ page }) => {
-
  await page.goto(markdownUrl);
-
  await page.getByRole("link", { name: "Nested Linked File" }).click();
-
  await expect(page).toHaveURL(
-
    `${markdownUrl}/tree/relative-files/linked-file.md`,
-
  );
-
  await page.goBack();
-
  await expect(page).toHaveURL(markdownUrl);
-
  await page.getByRole("link", { name: "Link Files" }).click();
-
  await page.getByRole("link", { name: "Absolute Link" }).click();
-
  await expect(page).toHaveURL(
-
    `${markdownUrl}/tree/relative-files/linked-file.md`,
-
  );
-
  await page.getByRole("link", { name: "nested file", exact: true }).click();
-
  await expect(page).toHaveURL(
-
    `${markdownUrl}/tree/relative-files/nested-file.md`,
-
  );
-
  await page.goBack();
-
  await page.getByRole("link", { name: "nested file with" }).click();
-
  await expect(page).toHaveURL(
-
    `${markdownUrl}/tree/relative-files/nested-file.md`,
-
  );
-
  await page.goBack();
-
  await page.getByRole("link", { name: "Back to link-files with" }).click();
-
  await expect(page).toHaveURL(`${markdownUrl}/tree/link-files.md`);
-
});
-

-
test("internal file markdown link", async ({ page }) => {
-
  await page.goto(`${markdownUrl}/tree/main/link-files.md`);
-
  await page.getByRole("link", { name: "Markdown Cheatsheet" }).click();
-
  await expect(page).toHaveURL(`${markdownUrl}/tree/main/cheatsheet.md`);
-
  await expect(page.getByText("cheatsheet.md").nth(2)).toBeVisible();
-

-
  await page.goto(markdownUrl);
-
  await page.getByRole("link", { name: "Link Files" }).click();
-
  await page.getByRole("button", { name: "Files", exact: true }).click();
-
  await page.getByRole("link", { name: "Link Files" }).click();
-
  await expect(page).toHaveURL(`${markdownUrl}/tree/link-files.md`);
-
  await expect(page.getByText("link-files.md").nth(2)).toBeVisible();
-

-
  await page.goto(`${markdownUrl}/tree/main/link-files.md`);
-
  await page.getByRole("link", { name: "black square" }).click();
-
  await expect(page).toHaveURL(
-
    `${markdownUrl}/tree/main/assets/black-square.png`,
-
  );
-
  await expect(page.getByText("assets/black-square.png").nth(1)).toBeVisible();
-
  await expect(
-
    page.getByRole("link", { name: "black-square.png" }),
-
  ).toBeVisible();
-
});
-

-
test("diff selection de-select", async ({ page }) => {
-
  await page.goto(`${cobUrl}/patches`);
-
  await page
-
    .getByRole("link", { name: "Taking another stab at the README" })
-
    .click();
-
  await page.getByRole("link", { name: "Changes" }).click();
-
  await page
-
    .getByRole("row", { name: "+ # Cobs Repo" })
-
    .locator("div")
-
    .first()
-
    .click();
-
  await expect(page).toHaveURL(new RegExp("tab=changes#README.md:H0L1$"));
-
  // Click outside.
-
  await page
-
    .getByText("1 file modified with 5 insertions and 1 deletion")
-
    .click();
-
  await expect(page).toHaveURL(new RegExp("tab=changes$"));
-
});
deleted tests/e2e/project/commit.spec.ts
@@ -1,128 +0,0 @@
-
import {
-
  aliceRemote,
-
  bobHead,
-
  expect,
-
  shortBobHead,
-
  sourceBrowsingUrl,
-
  test,
-
} from "@tests/support/fixtures.js";
-
import { changeBranch } from "@tests/support/project";
-
import sinon from "sinon";
-

-
const commitUrl = `${sourceBrowsingUrl}/commits/${bobHead}`;
-

-
test("navigation from commit list", async ({ page }) => {
-
  await page.goto(sourceBrowsingUrl);
-
  await changeBranch("bob", `main ${shortBobHead}`, page);
-

-
  await page.getByText("Update readme").first().click();
-
  await expect(page).toHaveURL(commitUrl);
-
});
-

-
test("relative timestamps", async ({ page }) => {
-
  await page.addInitScript(() => {
-
    sinon.useFakeTimers({
-
      now: new Date("December 21 2022 12:00:00").valueOf(),
-
      shouldClearNativeTimers: true,
-
      shouldAdvanceTime: false,
-
    });
-
  });
-
  await page.goto(commitUrl);
-
  await expect(
-
    page.getByText(`Bob Belcher committed ${shortBobHead}`),
-
  ).toBeVisible();
-
});
-

-
test("modified file", async ({ page }) => {
-
  await page.goto(commitUrl);
-

-
  // Commit header.
-
  {
-
    await expect(page.getByText("Update readme")).toBeVisible();
-
    await expect(page.getByLabel("commit-id")).toHaveText(shortBobHead);
-
  }
-

-
  // Diff header.
-
  await expect(
-
    page.getByText("1 file modified with 1 insertion and 4 deletions"),
-
  ).toBeVisible();
-

-
  // Diff.
-
  await expect(page.getByText("-	# Git test repository")).toBeVisible();
-
  await expect(page.getByText("+	Updated readme")).toBeVisible();
-
});
-

-
test("created file", async ({ page }) => {
-
  await page.goto(
-
    `${sourceBrowsingUrl}/remotes/${aliceRemote.substring(
-
      8,
-
    )}/commits/d87e27e38e244fb3346cb9e4df064c080d97647a`,
-
  );
-
  await expect(
-
    page.getByText("1 file added with 1 insertion and 0 deletions"),
-
  ).toBeVisible();
-
  await expect(page.getByText(".hidden added")).toBeVisible();
-
});
-

-
test("deleted file", async ({ page }) => {
-
  await page.goto(
-
    `${sourceBrowsingUrl}/remotes/${aliceRemote.substring(
-
      8,
-
    )}/commits/0e2db54dfd47d87202809917e2342655d9e76296`,
-
  );
-
  await expect(
-
    page.getByText("1 file deleted with 0 insertions and 1 deletion"),
-
  ).toBeVisible();
-
  await expect(page.getByText(".hidden deleted")).toBeVisible();
-
});
-

-
test("moved file", async ({ page }) => {
-
  await page.goto(
-
    `${sourceBrowsingUrl}/remotes/${aliceRemote}/commits/f48a1056a5bd02277978f6e8a00517a967546340`,
-
  );
-
  await expect(
-
    page.getByText("moves/111.txt → moves/222.txt moved"),
-
  ).toBeVisible();
-
  await expect(page.getByText("333")).toBeVisible();
-
});
-

-
test("copied file", async ({ page }) => {
-
  await page.goto(
-
    `${sourceBrowsingUrl}/remotes/${aliceRemote}/commits/f48a1056a5bd02277978f6e8a00517a967546340`,
-
  );
-
  await expect(
-
    page.getByText("copies/aaa.txt → copies/aaa_copy.txt copied"),
-
  ).toBeVisible();
-
});
-

-
test("binary file detection in diffs", async ({ page }) => {
-
  await page.goto(
-
    `${sourceBrowsingUrl}/commits/335dd6dc89b535a4a31e9422c803199bb6b0a09a`,
-
  );
-
  await expect(page.getByText("Binary file")).toBeVisible();
-
});
-

-
test("navigation to source tree at specific revision", async ({ page }) => {
-
  await page.goto(
-
    `${sourceBrowsingUrl}/commits/0801aceeab500033f8d608778218657bd626ef73`,
-
  );
-

-
  // Go to source tree at this revision.
-
  await page.getByTitle("View file at this commit").click();
-
  const branchSelectorCommitButton = page.getByTitle("Current HEAD").first();
-
  await expect(
-
    branchSelectorCommitButton.getByText("Add a deeply nested directory tree"),
-
  ).toBeVisible();
-
  await expect(page).toHaveURL(
-
    `${sourceBrowsingUrl}/tree/0801aceeab500033f8d608778218657bd626ef73/deep/directory/hierarchy/is/entirely/possible/in/git/repositories/.gitkeep`,
-
  );
-
  await expect(branchSelectorCommitButton).toContainText("0801ace");
-
  await expect(page.locator(".source-tree >> text=.gitkeep")).toBeVisible();
-
  await expect(
-
    page
-
      .locator(
-
        "text=deep/directory/hierarchy/is/entirely/possible/in/git/repositories/",
-
      )
-
      .nth(1),
-
  ).toBeVisible();
-
});
deleted tests/e2e/project/commits.spec.ts
@@ -1,239 +0,0 @@
-
import {
-
  aliceMainCommitCount,
-
  aliceMainCommitMessage,
-
  bobMainCommitCount,
-
  expect,
-
  gitOptions,
-
  shortAliceHead,
-
  shortBobHead,
-
  sourceBrowsingUrl,
-
  test,
-
} from "@tests/support/fixtures.js";
-
import { changeBranch, createProject } from "@tests/support/project";
-
import sinon from "sinon";
-

-
test("peer and branch switching", async ({ page }) => {
-
  await page.goto(sourceBrowsingUrl);
-
  await page
-
    .getByRole("link", { name: `Commits ${aliceMainCommitCount}` })
-
    .click();
-
  await expect(page.getByText("Thursday, December 15,")).toBeVisible();
-

-
  // Alice's peer.
-
  {
-
    await changeBranch("alice", `main ${shortAliceHead}`, page);
-

-
    await expect(page.getByTitle("Change branch")).toHaveText(
-
      "alice Delegate / main",
-
    );
-

-
    await expect(page.getByText("Thursday, November 17, 2022")).toBeVisible();
-
    await expect(page.locator(".list .teaser")).toHaveCount(
-
      aliceMainCommitCount,
-
    );
-

-
    const latestCommit = page.locator(".teaser").first();
-
    await expect(latestCommit).toContainText(aliceMainCommitMessage);
-
    await expect(latestCommit).toContainText(shortAliceHead);
-

-
    const earliestCommit = page.locator(".teaser").last();
-
    await expect(earliestCommit).toContainText(
-
      "Initialize an empty git repository",
-
    );
-
    await expect(earliestCommit).toContainText("36d5bbe");
-

-
    await changeBranch("alice", "feature/branch", page);
-

-
    await expect(
-
      page.getByRole("button", { name: "feature/branch" }),
-
    ).toBeVisible();
-
    await expect(page.getByText("Thursday, November 17, 2022")).toBeVisible();
-
    await expect(page.locator(".list .teaser")).toHaveCount(bobMainCommitCount);
-

-
    await changeBranch("alice", "orphaned-branch", page);
-

-
    await expect(
-
      page.getByRole("button", { name: "orphaned-branch" }),
-
    ).toBeVisible();
-
    await expect(page.getByText("Thursday, November 17, 2022")).toBeVisible();
-
    await expect(page.locator(".list")).toHaveCount(1);
-
  }
-

-
  // Bob's peer.
-
  {
-
    await changeBranch("bob", `main ${shortBobHead}`, page);
-

-
    await expect(page.getByTitle("Change branch")).toContainText("bob");
-

-
    await expect(page.getByText("Wednesday, December 21, 2022")).toBeVisible();
-
    await expect(page.locator(".list").first().locator(".teaser")).toHaveCount(
-
      1,
-
    );
-

-
    await expect(page.getByText("Thursday, November 17, 2022")).toBeVisible();
-
    await expect(page.locator(".list").last().locator(".teaser")).toHaveCount(
-
      7,
-
    );
-

-
    const latestCommit = page.locator(".teaser").first();
-
    await expect(latestCommit).toContainText("Update readme");
-
    await expect(latestCommit).toContainText(shortBobHead);
-

-
    const earliestCommit = page.locator(".teaser").last();
-
    await expect(earliestCommit).toContainText(
-
      "Initialize an empty git repository",
-
    );
-
    await expect(earliestCommit).toContainText("36d5bbe");
-
  }
-
});
-

-
test("loading more commits, adds them to the commits list", async ({
-
  page,
-
}) => {
-
  await page.goto(`http://localhost:3002${sourceBrowsingUrl}`);
-
  await page.getByRole("button", { name: "Commits" }).click();
-
  await expect(page.locator("div > div > .teaser")).toHaveCount(4);
-

-
  await page.getByRole("button", { name: "More" }).click();
-
  await expect(page.locator("div > div > .teaser")).toHaveCount(8);
-
});
-

-
test("commit messages with double colon not converted into single colon", async ({
-
  page,
-
}) => {
-
  const commitMessage = "Verify that crate::DoubleColon::should_work()";
-
  const shortCommit = "7babd25";
-
  await page.goto(sourceBrowsingUrl);
-
  await page
-
    .getByRole("link", { name: `Commits ${aliceMainCommitCount}` })
-
    .click();
-

-
  await expect(
-
    page.getByRole("button", {
-
      name: `${shortCommit} ${commitMessage}`,
-
    }),
-
  ).toBeVisible();
-
  await expect(
-
    page.getByRole("link", {
-
      name: commitMessage,
-
      exact: true,
-
    }),
-
  ).toBeVisible();
-

-
  await page
-
    .getByRole("link", {
-
      name: commitMessage,
-
      exact: true,
-
    })
-
    .click();
-
  await page.waitForLoadState("networkidle");
-
  await expect(page.getByText(commitMessage, { exact: true })).toBeVisible();
-
});
-

-
test("expand commit message", async ({ page }) => {
-
  await page.goto(sourceBrowsingUrl);
-
  await page
-
    .getByRole("link", { name: `Commits ${aliceMainCommitCount}` })
-
    .click();
-
  const commitToggle = page.getByRole("button", { name: "expand" }).first();
-

-
  await commitToggle.click();
-
  const expandedCommit = page.getByText(
-
    "This shouldn't replace double colons with simple colons",
-
  );
-

-
  await expect(expandedCommit).toBeVisible();
-
  await commitToggle.click();
-

-
  await expect(expandedCommit).toBeHidden();
-
});
-

-
test("relative timestamps", async ({ page }) => {
-
  await page.addInitScript(() => {
-
    sinon.useFakeTimers({
-
      now: new Date("December 21 2022 12:00:00").valueOf(),
-
      shouldClearNativeTimers: true,
-
      shouldAdvanceTime: false,
-
    });
-
  });
-

-
  await page.goto(sourceBrowsingUrl);
-
  await page
-
    .getByRole("link", { name: `Commits ${aliceMainCommitCount}` })
-
    .click();
-
  await expect(page.getByText("Thursday, December 15,")).toBeVisible();
-

-
  await changeBranch("bob", `main ${shortBobHead}`, page);
-
  await expect(page.getByTitle("Change branch")).toHaveText(/bob/);
-
  const latestCommit = page.locator(".teaser").first();
-
  await expect(latestCommit).toContainText(
-
    `Bob Belcher committed ${shortBobHead} now`,
-
  );
-
  await expect(latestCommit).toContainText(shortBobHead);
-
  const earliestCommit = page.locator(".teaser").last();
-
  await expect(earliestCommit).toContainText(
-
    "Alice Liddell committed 36d5bbe last month",
-
  );
-
});
-

-
test("pushing changes while viewing history", async ({ page, peerManager }) => {
-
  const alice = await peerManager.createPeer({
-
    name: "alice",
-
    gitOptions: gitOptions["alice"],
-
  });
-
  await alice.startNode();
-
  await alice.startHttpd();
-
  const { rid, projectFolder } = await createProject(alice, {
-
    name: "alice-project",
-
  });
-
  await page.goto(`${alice.uiUrl()}/${rid}`);
-
  await page.getByRole("link", { name: "Commits 1" }).click();
-
  await expect(page).toHaveURL(`${alice.uiUrl()}/${rid}/history`);
-

-
  await alice.git(["commit", "--allow-empty", "--message", "first change"], {
-
    cwd: projectFolder,
-
  });
-
  await alice.git(["push", "rad", "main"], {
-
    cwd: projectFolder,
-
  });
-
  await page.reload();
-
  await expect(page).toHaveURL(`${alice.uiUrl()}/${rid}/history`);
-
  await expect(page.getByRole("link", { name: "Commits 2" })).toBeVisible();
-

-
  await expect(page.getByTitle("Change branch")).toHaveText("main Canonical");
-
  const branchSelectorCommitButton = page.getByTitle("Current HEAD").first();
-
  await expect(branchSelectorCommitButton).toHaveText("516fa74 first change");
-

-
  await page
-
    .getByRole("banner")
-
    .getByRole("link", { name: "alice-project" })
-
    .click();
-
  await expect(page).toHaveURL(`${alice.uiUrl()}/${rid}`);
-
  await page.getByRole("link", { name: "Commits 2" }).click();
-

-
  await alice.git(
-
    [
-
      "commit",
-
      "--allow-empty",
-
      "--message",
-
      "after clicking the project title",
-
    ],
-
    {
-
      cwd: projectFolder,
-
    },
-
  );
-
  await alice.git(["push", "rad", "main"], {
-
    cwd: projectFolder,
-
  });
-
  await page.reload();
-
  await expect(page).toHaveURL(`${alice.uiUrl()}/${rid}/history`);
-
  await expect(page.getByRole("link", { name: "Commits 3" })).toHaveText(
-
    "Commits 3",
-
  );
-
  await expect(
-
    page.getByRole("button", { name: "main Canonical" }),
-
  ).toBeVisible();
-
  await expect(
-
    page.getByRole("link", { name: "bb9089a after clicking the" }),
-
  ).toBeVisible();
-
});
deleted tests/e2e/project/issue.spec.ts
@@ -1,8 +0,0 @@
-
import { test, cobUrl, expect } from "@tests/support/fixtures.js";
-

-
test("navigate single issue", async ({ page }) => {
-
  await page.goto(`${cobUrl}/issues`);
-
  await page.getByText("This title has **markdown**").click();
-

-
  await expect(page).toHaveURL(/\/issues\/[0-9a-f]{40}/);
-
});
deleted tests/e2e/project/issues.spec.ts
@@ -1,52 +0,0 @@
-
import { test, cobUrl, expect } from "@tests/support/fixtures.js";
-
import { createProject } from "@tests/support/project";
-

-
test("navigate issue listing", async ({ page }) => {
-
  await page.goto(cobUrl);
-
  await page.getByRole("link", { name: "Issues 1" }).click();
-
  await expect(page).toHaveURL(`${cobUrl}/issues`);
-

-
  await page.getByRole("button", { name: "filter-dropdown" }).first().click();
-
  await page.getByRole("link", { name: "Closed 2" }).click();
-
  await expect(page).toHaveURL(`${cobUrl}/issues?status=closed`);
-
});
-

-
test("issue counters", async ({ page, peer }) => {
-
  const { rid, projectFolder } = await createProject(peer, {
-
    name: "issue-counters",
-
  });
-
  await peer.rad(
-
    [
-
      "issue",
-
      "open",
-
      "--title",
-
      "First issue to test counters",
-
      "--description",
-
      "Let's see",
-
    ],
-
    { cwd: projectFolder },
-
  );
-
  await page.goto(`${peer.uiUrl()}/${rid}/issues`);
-
  await peer.rad(
-
    [
-
      "issue",
-
      "open",
-
      "--title",
-
      "Second issue to test counters",
-
      "--description",
-
      "Let's see",
-
    ],
-
    { cwd: projectFolder },
-
  );
-
  await page.getByRole("button", { name: "filter-dropdown" }).first().click();
-
  await page.locator(".dropdown-item").getByText("Open 1").click();
-
  await expect(page.getByRole("button", { name: "Issues 2" })).toBeVisible();
-
  await expect(
-
    page.getByRole("button", { name: "filter-dropdown" }).first(),
-
  ).toHaveText("Open 2");
-
  await expect(page.locator(".issue-teaser")).toHaveCount(2);
-

-
  await page
-
    .getByRole("link", { name: "First issue to test counters" })
-
    .click();
-
});
deleted tests/e2e/project/patch.spec.ts
@@ -1,119 +0,0 @@
-
import { test, cobUrl, expect } from "@tests/support/fixtures.js";
-

-
test("navigate patch details", async ({ page }) => {
-
  await page.goto(`${cobUrl}/patches`);
-
  await page.getByText("Add subtitle to README").click();
-
  await expect(page).toHaveURL(/patches\/[a-f0-9]{40}$/);
-
  await page.getByRole("link", { name: "Add subtitle to README" }).click();
-
  await expect(page).toHaveURL(/commits\/[a-f0-9]{40}$/);
-
  await page.goBack();
-
  await page.getByRole("link", { name: "Changes" }).click();
-
  await expect(page).toHaveURL(/patches\/[a-f0-9]{40}\?tab=changes$/);
-
});
-

-
test("use revision selector", async ({ page }) => {
-
  await page.goto(`${cobUrl}/patches`);
-
  await page
-
    .getByRole("link", { name: "Taking another stab at the README" })
-
    .click();
-
  await page.getByRole("link", { name: "Changes" }).click();
-

-
  // Validating the latest revision state
-
  await expect(
-
    page.getByRole("cell", { name: "Had to push a new revision" }),
-
  ).toBeVisible();
-
  await page.getByRole("link", { name: "Activity" }).click();
-
  await expect(page.getByLabel("commit-teaser")).toHaveCount(2);
-
  await expect(page.getByRole("link", { name: "Add more text" })).toBeVisible();
-

-
  // Open the first revision and close the latest one
-
  await page.getByLabel("expand").first().click();
-
  await page.getByLabel("expand").last().click();
-

-
  // Validating the initial revision
-
  await expect(page.getByLabel("commit-teaser")).toHaveCount(3);
-
  await expect(
-
    page.getByRole("link", { name: "Rewrite subtitle to README" }).first(),
-
  ).toBeVisible();
-

-
  await page.getByRole("link", { name: "Changes" }).click();
-
  // Switching to the initial revision
-

-
  await page.getByRole("button", { name: "Revision" }).first().click();
-
  await page.getByRole("button", { name: "Revision" }).nth(1).click();
-

-
  await expect(
-
    page.getByRole("cell", { name: "Had to push a new revision" }),
-
  ).toBeHidden();
-

-
  await expect(page).toHaveURL(
-
    /patches\/[a-f0-9]{40}\/[a-f0-9]{40}\?tab=changes$/,
-
  );
-
});
-

-
test("navigate through revision diffs", async ({ page }) => {
-
  await page.goto(`${cobUrl}/patches`);
-
  await page
-
    .getByRole("link", { name: "Taking another stab at the README" })
-
    .click();
-

-
  const firstRevision = page.locator(".revision").first();
-
  const firstRevisionId = "59a0821";
-
  const secondRevision = page.locator(".revision").nth(1);
-

-
  // Second revision
-
  {
-
    await secondRevision
-
      .getByRole("button", { name: "toggle-context-menu" })
-
      .first()
-
      .click();
-
    await secondRevision
-
      .getByRole("link", { name: "Compare to base: 38c225e" })
-
      .click();
-
    await expect(
-
      page.getByRole("button", { name: "Compare 38c225..9e4fea" }),
-
    ).toBeVisible();
-
    await expect(page).toHaveURL(
-
      /patches\/[a-f0-9]{40}\?diff=38c225e2a0b47ba59def211f4e4825c31d9463ec\.\.9e4feab1b2123dfa5f22bd0e4656060ec9296638$/,
-
    );
-
    await page.goBack();
-
    await secondRevision
-
      .getByRole("button", { name: "toggle-context-menu" })
-
      .first()
-
      .click();
-
    await secondRevision
-
      .getByRole("link", {
-
        name: `Compare to previous revision: ${firstRevisionId}`,
-
      })
-
      .click();
-
    await expect(
-
      page.getByRole("button", { name: "Compare 88b7fd..9e4fea" }),
-
    ).toBeVisible();
-

-
    await expect(page).toHaveURL(
-
      /patches\/[a-f0-9]{40}\?diff=88b7fd90389c1a629f91ed7bf838d4b947426622\.\.9e4feab1b2123dfa5f22bd0e4656060ec9296638$/,
-
    );
-
    await page.goBack();
-
  }
-
  // First revision and DiffStatBadge shortcut.
-
  {
-
    await firstRevision.getByTitle("Compare 38c225e..88b7fd9").click();
-
    await expect(
-
      page.getByRole("button", { name: "Compare 38c225..88b7fd" }),
-
    ).toBeVisible();
-
    await expect(page).toHaveURL(
-
      /patches\/[a-f0-9]{40}\?diff=38c225e2a0b47ba59def211f4e4825c31d9463ec\.\.88b7fd90389c1a629f91ed7bf838d4b947426622$/,
-
    );
-
  }
-
});
-

-
test("view file navigation from changes tab", async ({ page }) => {
-
  await page.goto(`${cobUrl}/patches`);
-
  await page.getByRole("link", { name: "Add subtitle to README" }).click();
-
  await page.getByRole("link", { name: "Changes" }).click();
-
  await page.getByRole("button", { name: "Changes" }).click();
-
  await page.getByRole("button", { name: "View file at this commit" }).click();
-
  await expect(page).toHaveURL(
-
    `${cobUrl}/tree/8c900d6cb38811e099efb3cbbdbfaba817bcf970/README.md`,
-
  );
-
});
deleted tests/e2e/project/patches.spec.ts
@@ -1,50 +0,0 @@
-
import { test, cobUrl, expect } from "@tests/support/fixtures.js";
-
import { createProject } from "@tests/support/project";
-

-
test("navigate patch listing", async ({ page }) => {
-
  await page.goto(cobUrl);
-
  await page.getByRole("link", { name: "Patches 2" }).click();
-
  await expect(page).toHaveURL(`${cobUrl}/patches`);
-

-
  await page.getByRole("button", { name: "filter-dropdown" }).first().click();
-
  await page.getByRole("link", { name: "Merged 1" }).click();
-
  await expect(page).toHaveURL(`${cobUrl}/patches?status=merged`);
-
  await expect(
-
    page.locator(".comments").filter({ hasText: "5" }),
-
  ).toBeVisible();
-
});
-

-
test("patches counters", async ({ page, peer }) => {
-
  const { rid, projectFolder, defaultBranch } = await createProject(peer, {
-
    name: "patch-counters",
-
  });
-
  await peer.git(["switch", "-c", "feature-1"], {
-
    cwd: projectFolder,
-
  });
-
  await peer.git(["commit", "--allow-empty", "-m", "1th"], {
-
    cwd: projectFolder,
-
  });
-
  await peer.git(["push", "rad", "HEAD:refs/patches"], {
-
    cwd: projectFolder,
-
  });
-
  await page.goto(`${peer.uiUrl()}/${rid}/patches`);
-
  await peer.git(["switch", defaultBranch], {
-
    cwd: projectFolder,
-
  });
-
  await peer.git(["switch", "-c", "feature-2"], {
-
    cwd: projectFolder,
-
  });
-
  await peer.git(["commit", "--allow-empty", "-m", "2nd"], {
-
    cwd: projectFolder,
-
  });
-
  await peer.git(["push", "rad", "HEAD:refs/patches"], {
-
    cwd: projectFolder,
-
  });
-
  await page.getByRole("button", { name: "filter-dropdown" }).first().click();
-
  await page.locator(".dropdown-item").getByText("Open 1").click();
-
  await expect(page.getByRole("button", { name: "Patches 2" })).toBeVisible();
-
  await expect(
-
    page.getByRole("button", { name: "filter-dropdown" }).first(),
-
  ).toHaveText("Open 2");
-
  await expect(page.locator(".patch-teaser")).toHaveCount(2);
-
});
added tests/e2e/repo.ts
@@ -0,0 +1,561 @@
+
import {
+
  aliceMainCommitCount,
+
  aliceMainCommitMessage,
+
  aliceMainHead,
+
  bobMainCommitCount,
+
  cobUrl,
+
  expect,
+
  markdownUrl,
+
  shortAliceHead,
+
  shortBobHead,
+
  sourceBrowsingRid,
+
  sourceBrowsingUrl,
+
  test,
+
} from "@tests/support/fixtures.js";
+
import { changeBranch, createRepo } from "@tests/support/repo";
+
import { expectUrlPersistsReload } from "@tests/support/router";
+

+
test("navigate to repo", async ({ page }) => {
+
  await page.goto(sourceBrowsingUrl);
+

+
  // Header.
+
  {
+
    const name = page.getByRole("link", { name: "source-browsing" }).nth(1);
+
    const id = page.getByText(sourceBrowsingRid);
+
    const description = page.getByText(
+
      "Git repository for source browsing tests",
+
    );
+

+
    await expect(name).toBeVisible();
+
    await expect(id).toBeVisible();
+
    await expect(description).toBeVisible();
+
  }
+

+
  // Repo menu shows default selected branch and commit and contributor counts.
+
  {
+
    await expect(page.getByTitle("Change branch")).toBeVisible();
+
    await expect(
+
      page
+
        .getByRole("button", {
+
          name: `${shortAliceHead} ${aliceMainCommitMessage}`,
+
        })
+
        .first(),
+
    ).toBeVisible();
+
    await expect(
+
      page.getByRole("link", {
+
        name: `Commits ${aliceMainCommitCount}`,
+
      }),
+
    ).toBeVisible();
+
  }
+

+
  // Navigate to the repo README.md by default.
+
  await expect(page.locator(".filename")).toContainText("README.md");
+

+
  // Show a commit teaser.
+
  await expect(page.getByText("dd068e9 Add README.md")).toBeVisible();
+

+
  // Show rendered README.md contents.
+
  await expect(page.getByText("Git test repository")).toBeVisible();
+
});
+

+
test("repo description", async ({ page, peer }) => {
+
  const { rid } = await createRepo(peer, {
+
    name: "heartwood",
+
    description: "Radicle Heartwood Protocol & Stack",
+
  });
+
  await page.goto(peer.ridUrl(rid));
+
  await expect(
+
    page.getByText("Radicle Heartwood Protocol & Stack"),
+
  ).toBeVisible();
+
});
+

+
test("show source tree at specific revision", async ({ page }) => {
+
  await page.goto(sourceBrowsingUrl);
+
  await page
+
    .getByRole("link", { name: `Commits ${aliceMainCommitCount}` })
+
    .click();
+

+
  await page
+
    .locator(".teaser", { hasText: "335dd6d" })
+
    .getByRole("button", {
+
      name: "Browse repo at this commit",
+
    })
+
    .click();
+

+
  await expect(page.getByTitle("Current HEAD")).toContainText("335dd6d");
+
  await expect(page.locator(".source-tree")).toHaveText("bin src");
+
  await expect(
+
    page.getByRole("link", {
+
      name: "Commits 2",
+
    }),
+
  ).toBeVisible();
+
});
+

+
test("source file highlighting", async ({ page }) => {
+
  await page.goto(sourceBrowsingUrl);
+

+
  await page.getByText("src").click();
+
  await page.getByText("true.c").click();
+

+
  await expect(page.getByText("return")).toHaveCSS(
+
    "color",
+
    "rgb(255, 123, 114)",
+
  );
+
});
+

+
test("navigate line numbers", async ({ page }) => {
+
  await page.goto(`${markdownUrl}/tree/main/cheatsheet.md`);
+
  await page.getByRole("button", { name: "Code" }).click();
+

+
  await page.getByRole("link", { name: "5", exact: true }).click();
+
  await expect(page.locator("#L5")).toHaveClass("line highlight");
+
  await expect(page).toHaveURL(`${markdownUrl}/tree/main/cheatsheet.md#L5`);
+

+
  await expectUrlPersistsReload(page);
+
  await expect(page.locator("#L5")).toHaveClass("line highlight");
+

+
  await page.getByRole("link", { name: "30", exact: true }).click();
+
  await expect(page.locator("#L5")).not.toHaveClass("line highlight");
+
  await expect(page.locator("#L30")).toHaveClass("line highlight");
+
  await expect(page).toHaveURL(`${markdownUrl}/tree/main/cheatsheet.md#L30`);
+

+
  // Check that we go back to the Markdown view when navigating to a different
+
  // file.
+
  await page.getByRole("link", { name: "footnotes.md" }).click();
+
  await expect(page.getByRole("button", { name: "Preview" })).toHaveClass(
+
    /selected/,
+
  );
+
});
+

+
test("navigate deep file hierarchies", async ({ page }) => {
+
  await page.goto(sourceBrowsingUrl);
+

+
  const sourceTree = page.locator(".source-tree");
+

+
  await sourceTree.getByText("deep").click();
+
  await sourceTree.getByText("directory").click();
+
  await sourceTree.getByText("hierarchy").click();
+
  await sourceTree.getByText("is").click();
+
  await sourceTree.getByText("entirely").click();
+
  await sourceTree.getByText("possible").click();
+
  await sourceTree.getByText("in").nth(1).click();
+
  await sourceTree.getByRole("button", { name: "git" }).click();
+
  await sourceTree.getByText("repositories").click();
+
  await sourceTree.getByText(".gitkeep").click();
+
  await expect(
+
    page.getByText("0801ace Add a deeply nested directory tree"),
+
  ).toBeVisible();
+

+
  // After a page reload the tree browser is still expanded and we're still
+
  // showing the .gitkeep file.
+
  {
+
    await page.reload();
+

+
    const sourceTree = page.locator(".source-tree");
+

+
    await expect(sourceTree.getByText("deep")).toBeVisible();
+
    await expect(sourceTree.getByText("directory")).toBeVisible();
+
    await expect(sourceTree.getByText("hierarchy")).toBeVisible();
+
    await expect(sourceTree.getByText("is")).toBeVisible();
+
    await expect(sourceTree.getByText("entirely")).toBeVisible();
+
    await expect(sourceTree.getByText("possible")).toBeVisible();
+
    await expect(sourceTree.getByText("in").nth(1)).toBeVisible();
+
    await expect(sourceTree.getByText("git").nth(1)).toBeVisible();
+
    await expect(sourceTree.getByText("repositories")).toBeVisible();
+
    await expect(sourceTree.getByText(".gitkeep")).toBeVisible();
+

+
    await expect(
+
      page.getByText("0801ace Add a deeply nested directory tree"),
+
    ).toBeVisible();
+
  }
+
});
+

+
test("submodules", async ({ page }) => {
+
  await page.goto(sourceBrowsingUrl);
+
  await expect(page.getByText("rips @ 329dee9")).toBeVisible();
+
});
+

+
test("files with special characters in the filename", async ({ page }) => {
+
  await page.goto(sourceBrowsingUrl);
+

+
  const sourceTree = page.locator(".source-tree");
+
  await sourceTree.getByText("special").click();
+

+
  await sourceTree.getByText("+plus+").click();
+
  await expect(page.getByRole("banner")).toContainText("+plus");
+

+
  await sourceTree.getByText("-dash-").click();
+
  await expect(page.getByRole("banner")).toContainText("-dash-");
+

+
  await sourceTree.getByText(":colon:").click();
+
  await expect(page.getByRole("banner")).toContainText(":colon:");
+

+
  await sourceTree.getByText(";semicolon;").click();
+
  await expect(page.getByRole("banner")).toContainText(";semicolon;");
+

+
  await sourceTree.getByText("@at@").click();
+
  await expect(page.getByRole("banner")).toContainText("@at@");
+

+
  await sourceTree.getByText("_underscore_").click();
+
  await expect(page.getByRole("banner")).toContainText("_underscore_");
+

+
  // TODO: fix these errors in `radicle-httpd` for the following edge cases.
+
  //
+
  // await sourceTree.getByText("back\\slash").click();
+
  // await expect(page.locator(".filename")).toContainText("back\\slash");
+
  // await sourceTree.getByText("qs?param1=value?param2=value2#hash").click();
+
  // await expect(page.locator(".filename")).toContainText(
+
  //   "qs?param1=value?param2=value2#hash",
+
  // );
+

+
  await sourceTree.getByText("spaces are okay").click();
+
  await expect(page.getByRole("banner")).toContainText("spaces are okay");
+

+
  await sourceTree.getByText("~tilde~").click();
+
  await expect(page.getByRole("banner")).toContainText("~tilde~");
+

+
  await sourceTree.getByText("👹👹👹").click();
+
  await expect(page.getByRole("banner")).toContainText("👹👹👹");
+
});
+

+
test("binary files", async ({ page }) => {
+
  await page.goto(sourceBrowsingUrl);
+

+
  await page.getByText("bin").click();
+
  await page.getByText("true").click();
+

+
  await expect(page.getByText("Binary file")).toBeVisible();
+
});
+

+
test("empty files", async ({ page }) => {
+
  await page.goto(sourceBrowsingUrl);
+

+
  await page.getByText("special").click();
+
  await page.getByText("_underscore_").click();
+

+
  await expect(page.getByText("Empty file")).toBeVisible();
+
});
+

+
test("hidden files", async ({ page }) => {
+
  await page.goto(sourceBrowsingUrl);
+

+
  await page.getByText(".hidden").click();
+

+
  await expect(page.getByText("I'm a hidden file.")).toBeVisible();
+
});
+

+
test("markdown files", async ({ page }) => {
+
  await page.goto(`${markdownUrl}/tree/main/cheatsheet.md`);
+

+
  await expect(
+
    page.getByText("This is intended as a quick reference and showcase."),
+
  ).toBeVisible();
+

+
  // Switch between raw and rendered modes.
+
  {
+
    await expect(page.getByRole("button", { name: "Preview" })).toHaveClass(
+
      /selected/,
+
    );
+
    await expect(page.getByRole("button", { name: "Code" })).toHaveClass(
+
      /not-selected/,
+
    );
+
    await page.getByRole("button", { name: "Code" }).click();
+
    await expect(page.getByRole("button", { name: "Preview" })).toHaveClass(
+
      /not-selected/,
+
    );
+
    await expect(page.getByRole("button", { name: "Code" })).toHaveClass(
+
      /selected/,
+
    );
+
    await expect(page.getByText("##### Table of Contents")).toBeVisible();
+
    await page.getByRole("button", { name: "Preview" }).click();
+
  }
+

+
  // Internal links go to anchor.
+
  {
+
    await page.getByRole("link", { name: "YouTube Videos" }).click();
+
    await expect(page).toHaveURL(
+
      `${markdownUrl}/tree/main/cheatsheet.md#videos`,
+
    );
+
  }
+
});
+

+
test("clone modal", async ({ page }) => {
+
  await page.goto(sourceBrowsingUrl);
+

+
  await page.getByRole("button", { name: "Clone" }).click();
+
  await expect(page.getByText(`rad clone ${sourceBrowsingRid}`)).toBeVisible();
+
  await page.getByRole("button", { name: "Git" }).click();
+
  await expect(
+
    page.getByText(
+
      `http://127.0.0.1/${sourceBrowsingRid.replace("rad:", "")}.git`,
+
    ),
+
  ).toBeVisible();
+
});
+

+
test("peer and branch switching", async ({ page }) => {
+
  await page.goto(sourceBrowsingUrl);
+

+
  // Alice's peer.
+
  {
+
    await changeBranch("alice", `main ${shortAliceHead}`, page);
+
    await expect(page.getByTitle("Change branch")).toHaveText(/alice/);
+

+
    // Default `main` branch.
+
    {
+
      await expect(page.getByTitle("Change branch")).toHaveText(/main/);
+
      await expect(
+
        page
+
          .getByRole("button", {
+
            name: `${shortAliceHead} ${aliceMainCommitMessage}`,
+
          })
+
          .first(),
+
      ).toBeVisible();
+
      await expect(
+
        page.getByRole("link", {
+
          name: `Commits ${aliceMainCommitCount}`,
+
        }),
+
      ).toBeVisible();
+
    }
+

+
    // Feature branch with a slash in the name.
+
    {
+
      await changeBranch("alice", "feature/branch", page);
+
      await page.getByTitle("Change branch").click();
+
      await page.getByText("feature/branch").click();
+

+
      await expect(
+
        page.getByRole("button", { name: "feature/branch" }),
+
      ).toBeVisible();
+
      await expect(
+
        page.getByRole("button", { name: "1aded56 Add subconscious file" }),
+
      ).toBeVisible();
+
      await expect(
+
        page.getByRole("link", {
+
          name: "Commits 9",
+
        }),
+
      ).toBeVisible();
+
    }
+

+
    // Branch without a history or files in it.
+
    {
+
      await changeBranch("alice", "orphaned-branch", page);
+

+
      await expect(
+
        page.getByRole("button", { name: "orphaned-branch" }),
+
      ).toBeVisible();
+
      await expect(
+
        page.getByRole("button", { name: "af3641c Add empty orphaned" }),
+
      ).toBeVisible();
+
      await expect(
+
        page.getByRole("link", {
+
          name: "Commits 1",
+
        }),
+
      ).toBeVisible();
+

+
      await expect(page.getByText("No files at this revision")).toBeVisible();
+
    }
+
  }
+

+
  // Reset the source browser by clicking the repo title.
+
  {
+
    await page.getByRole("link", { name: "source-browsing" }).nth(1).click();
+

+
    await expect(page.getByTitle("Change branch")).not.toContainText("alice");
+
    await expect(page.getByTitle("Change branch")).not.toContainText("bob");
+

+
    await expect(page.getByTitle("Change branch")).toBeVisible();
+
    await expect(
+
      page
+
        .getByRole("button", {
+
          name: `${shortAliceHead} ${aliceMainCommitMessage}`,
+
        })
+
        .first(),
+
    ).toBeVisible();
+
    await expect(page.getByText("Git test repository")).toBeVisible();
+
  }
+

+
  // Bob's peer.
+
  {
+
    await changeBranch("bob", `main ${shortBobHead}`, page);
+
    await expect(
+
      page.getByRole("button", { name: "avatar bob / main" }),
+
    ).toBeVisible();
+

+
    // Default `main` branch.
+
    {
+
      await expect(page.getByRole("button", { name: "main" })).toBeVisible();
+
      await expect(
+
        page
+
          .getByRole("button", { name: `${shortBobHead} Update readme` })
+
          .first(),
+
      ).toBeVisible();
+
      await expect(
+
        page.getByRole("link", {
+
          name: `Commits ${bobMainCommitCount}`,
+
        }),
+
      ).toBeVisible();
+
      await expect(
+
        page
+
          .getByRole("button", { name: `${shortBobHead} Update readme` })
+
          .first(),
+
      ).toBeVisible();
+
    }
+
  }
+
});
+

+
test("only one modal can be open at a time", async ({ page }) => {
+
  await page.goto(sourceBrowsingUrl);
+

+
  await changeBranch("alice", `main ${shortAliceHead}`, page);
+

+
  await page.getByText("Clone").click();
+
  await expect(page.getByText("Code font")).not.toBeVisible();
+
  await expect(page.getByText("Use the Radicle CLI")).toBeVisible();
+
  await expect(page.getByText("bob")).not.toBeVisible();
+

+
  await page.getByRole("button", { name: "Settings" }).click();
+
  await expect(page.getByText("Code font")).toBeVisible();
+
  await expect(page.getByText("Use the Radicle CLI")).not.toBeVisible();
+
  await expect(page.getByText("bob")).not.toBeVisible();
+

+
  await page.getByTitle("Change branch").click();
+
  await expect(page.getByText("Code font")).not.toBeVisible();
+
  await expect(page.getByText("Use the Radicle CLI")).not.toBeVisible();
+
  await expect(page.getByText("bob")).toBeVisible();
+
});
+

+
test.describe("browser error handling", () => {
+
  test("error appears when folder can't be loaded", async ({ page }) => {
+
    await page.route(
+
      ({ pathname }) =>
+
        pathname.startsWith(
+
          `/api/v1/repos/${sourceBrowsingRid}/tree/${aliceMainHead}/src`,
+
        ),
+
      route => route.fulfill({ status: 500 }),
+
    );
+

+
    await page.goto(sourceBrowsingUrl);
+

+
    const sourceTree = page.locator(".source-tree");
+
    await sourceTree.getByText("src").click();
+

+
    await expect(page.getByText("No README found.")).toBeVisible();
+
  });
+
  test("error appears when file can't be loaded", async ({ page }) => {
+
    await page.route(
+
      ({ pathname }) =>
+
        pathname ===
+
        `/api/v1/repos/${sourceBrowsingRid}/blob/${aliceMainHead}/.hidden`,
+
      route => route.fulfill({ status: 500 }),
+
    );
+

+
    await page.goto(sourceBrowsingUrl);
+
    await page.getByText(".hidden").click();
+

+
    await expect(page.getByText("File not found")).toBeVisible();
+
  });
+
  test("error appears when README can't be loaded", async ({ page }) => {
+
    await page.route(
+
      ({ pathname }) =>
+
        pathname ===
+
        `/api/v1/repos/${sourceBrowsingRid}/readme/${aliceMainHead}`,
+
      route => route.fulfill({ status: 500 }),
+
    );
+

+
    await page.goto(sourceBrowsingUrl);
+
    await expect(page.getByText("No README found.")).toBeVisible();
+
  });
+
  test("error appears when navigating to missing file", async ({ page }) => {
+
    await page.route(
+
      ({ pathname }) =>
+
        pathname ===
+
        `/api/v1/repos/${sourceBrowsingRid}/blob/${aliceMainHead}/.hidden`,
+
      route => route.fulfill({ status: 500 }),
+
    );
+

+
    await page.goto(`${sourceBrowsingUrl}/tree/master/.hidden`);
+

+
    await expect(page.getByText("File not found")).toBeVisible();
+
  });
+
});
+

+
test("external markdown link", async ({ context, page }) => {
+
  await context.route("https://example.com/**", route => {
+
    return route.fulfill({ body: "hello", contentType: "text/plain" });
+
  });
+
  await page.goto(`${markdownUrl}/tree/main/footnotes.md`);
+
  const pagePromise = context.waitForEvent("page");
+
  await page.getByRole("link", { name: "https://example.com" }).click();
+
  const newPage = await pagePromise;
+
  await expect(newPage).toHaveURL("https://example.com");
+
});
+

+
test("absolute markdown link", async ({ page }) => {
+
  await page.goto(markdownUrl);
+
  await page.getByRole("link", { name: "Nested Linked File" }).click();
+
  await expect(page).toHaveURL(
+
    `${markdownUrl}/tree/relative-files/linked-file.md`,
+
  );
+
  await page.goBack();
+
  await expect(page).toHaveURL(markdownUrl);
+
  await page.getByRole("link", { name: "Link Files" }).click();
+
  await page.getByRole("link", { name: "Absolute Link" }).click();
+
  await expect(page).toHaveURL(
+
    `${markdownUrl}/tree/relative-files/linked-file.md`,
+
  );
+
  await page.getByRole("link", { name: "nested file", exact: true }).click();
+
  await expect(page).toHaveURL(
+
    `${markdownUrl}/tree/relative-files/nested-file.md`,
+
  );
+
  await page.goBack();
+
  await page.getByRole("link", { name: "nested file with" }).click();
+
  await expect(page).toHaveURL(
+
    `${markdownUrl}/tree/relative-files/nested-file.md`,
+
  );
+
  await page.goBack();
+
  await page.getByRole("link", { name: "Back to link-files with" }).click();
+
  await expect(page).toHaveURL(`${markdownUrl}/tree/link-files.md`);
+
});
+

+
test("internal file markdown link", async ({ page }) => {
+
  await page.goto(`${markdownUrl}/tree/main/link-files.md`);
+
  await page.getByRole("link", { name: "Markdown Cheatsheet" }).click();
+
  await expect(page).toHaveURL(`${markdownUrl}/tree/main/cheatsheet.md`);
+
  await expect(page.getByText("cheatsheet.md").nth(2)).toBeVisible();
+

+
  await page.goto(markdownUrl);
+
  await page.getByRole("link", { name: "Link Files" }).click();
+
  await page.getByRole("button", { name: "Files", exact: true }).click();
+
  await page.getByRole("link", { name: "Link Files" }).click();
+
  await expect(page).toHaveURL(`${markdownUrl}/tree/link-files.md`);
+
  await expect(page.getByText("link-files.md").nth(2)).toBeVisible();
+

+
  await page.goto(`${markdownUrl}/tree/main/link-files.md`);
+
  await page.getByRole("link", { name: "black square" }).click();
+
  await expect(page).toHaveURL(
+
    `${markdownUrl}/tree/main/assets/black-square.png`,
+
  );
+
  await expect(page.getByText("assets/black-square.png").nth(1)).toBeVisible();
+
  await expect(
+
    page.getByRole("link", { name: "black-square.png" }),
+
  ).toBeVisible();
+
});
+

+
test("diff selection de-select", async ({ page }) => {
+
  await page.goto(`${cobUrl}/patches`);
+
  await page
+
    .getByRole("link", { name: "Taking another stab at the README" })
+
    .click();
+
  await page.getByRole("link", { name: "Changes" }).click();
+
  await page
+
    .getByRole("row", { name: "+ # Cobs Repo" })
+
    .locator("div")
+
    .first()
+
    .click();
+
  await expect(page).toHaveURL(new RegExp("tab=changes#README.md:H0L1$"));
+
  // Click outside.
+
  await page
+
    .getByText("1 file modified with 5 insertions and 1 deletion")
+
    .click();
+
  await expect(page).toHaveURL(new RegExp("tab=changes$"));
+
});
added tests/e2e/repo/commit.spec.ts
@@ -0,0 +1,128 @@
+
import {
+
  aliceRemote,
+
  bobHead,
+
  expect,
+
  shortBobHead,
+
  sourceBrowsingUrl,
+
  test,
+
} from "@tests/support/fixtures.js";
+
import { changeBranch } from "@tests/support/repo";
+
import sinon from "sinon";
+

+
const commitUrl = `${sourceBrowsingUrl}/commits/${bobHead}`;
+

+
test("navigation from commit list", async ({ page }) => {
+
  await page.goto(sourceBrowsingUrl);
+
  await changeBranch("bob", `main ${shortBobHead}`, page);
+

+
  await page.getByText("Update readme").first().click();
+
  await expect(page).toHaveURL(commitUrl);
+
});
+

+
test("relative timestamps", async ({ page }) => {
+
  await page.addInitScript(() => {
+
    sinon.useFakeTimers({
+
      now: new Date("December 21 2022 12:00:00").valueOf(),
+
      shouldClearNativeTimers: true,
+
      shouldAdvanceTime: false,
+
    });
+
  });
+
  await page.goto(commitUrl);
+
  await expect(
+
    page.getByText(`Bob Belcher committed ${shortBobHead}`),
+
  ).toBeVisible();
+
});
+

+
test("modified file", async ({ page }) => {
+
  await page.goto(commitUrl);
+

+
  // Commit header.
+
  {
+
    await expect(page.getByText("Update readme")).toBeVisible();
+
    await expect(page.getByLabel("commit-id")).toHaveText(shortBobHead);
+
  }
+

+
  // Diff header.
+
  await expect(
+
    page.getByText("1 file modified with 1 insertion and 4 deletions"),
+
  ).toBeVisible();
+

+
  // Diff.
+
  await expect(page.getByText("-	# Git test repository")).toBeVisible();
+
  await expect(page.getByText("+	Updated readme")).toBeVisible();
+
});
+

+
test("created file", async ({ page }) => {
+
  await page.goto(
+
    `${sourceBrowsingUrl}/remotes/${aliceRemote.substring(
+
      8,
+
    )}/commits/d87e27e38e244fb3346cb9e4df064c080d97647a`,
+
  );
+
  await expect(
+
    page.getByText("1 file added with 1 insertion and 0 deletions"),
+
  ).toBeVisible();
+
  await expect(page.getByText(".hidden added")).toBeVisible();
+
});
+

+
test("deleted file", async ({ page }) => {
+
  await page.goto(
+
    `${sourceBrowsingUrl}/remotes/${aliceRemote.substring(
+
      8,
+
    )}/commits/0e2db54dfd47d87202809917e2342655d9e76296`,
+
  );
+
  await expect(
+
    page.getByText("1 file deleted with 0 insertions and 1 deletion"),
+
  ).toBeVisible();
+
  await expect(page.getByText(".hidden deleted")).toBeVisible();
+
});
+

+
test("moved file", async ({ page }) => {
+
  await page.goto(
+
    `${sourceBrowsingUrl}/remotes/${aliceRemote}/commits/f48a1056a5bd02277978f6e8a00517a967546340`,
+
  );
+
  await expect(
+
    page.getByText("moves/111.txt → moves/222.txt moved"),
+
  ).toBeVisible();
+
  await expect(page.getByText("333")).toBeVisible();
+
});
+

+
test("copied file", async ({ page }) => {
+
  await page.goto(
+
    `${sourceBrowsingUrl}/remotes/${aliceRemote}/commits/f48a1056a5bd02277978f6e8a00517a967546340`,
+
  );
+
  await expect(
+
    page.getByText("copies/aaa.txt → copies/aaa_copy.txt copied"),
+
  ).toBeVisible();
+
});
+

+
test("binary file detection in diffs", async ({ page }) => {
+
  await page.goto(
+
    `${sourceBrowsingUrl}/commits/335dd6dc89b535a4a31e9422c803199bb6b0a09a`,
+
  );
+
  await expect(page.getByText("Binary file")).toBeVisible();
+
});
+

+
test("navigation to source tree at specific revision", async ({ page }) => {
+
  await page.goto(
+
    `${sourceBrowsingUrl}/commits/0801aceeab500033f8d608778218657bd626ef73`,
+
  );
+

+
  // Go to source tree at this revision.
+
  await page.getByTitle("View file at this commit").click();
+
  const branchSelectorCommitButton = page.getByTitle("Current HEAD").first();
+
  await expect(
+
    branchSelectorCommitButton.getByText("Add a deeply nested directory tree"),
+
  ).toBeVisible();
+
  await expect(page).toHaveURL(
+
    `${sourceBrowsingUrl}/tree/0801aceeab500033f8d608778218657bd626ef73/deep/directory/hierarchy/is/entirely/possible/in/git/repositories/.gitkeep`,
+
  );
+
  await expect(branchSelectorCommitButton).toContainText("0801ace");
+
  await expect(page.locator(".source-tree >> text=.gitkeep")).toBeVisible();
+
  await expect(
+
    page
+
      .locator(
+
        "text=deep/directory/hierarchy/is/entirely/possible/in/git/repositories/",
+
      )
+
      .nth(1),
+
  ).toBeVisible();
+
});
added tests/e2e/repo/commits.spec.ts
@@ -0,0 +1,239 @@
+
import {
+
  aliceMainCommitCount,
+
  aliceMainCommitMessage,
+
  bobMainCommitCount,
+
  expect,
+
  gitOptions,
+
  shortAliceHead,
+
  shortBobHead,
+
  sourceBrowsingUrl,
+
  test,
+
} from "@tests/support/fixtures.js";
+
import { changeBranch, createRepo } from "@tests/support/repo";
+
import sinon from "sinon";
+

+
test("peer and branch switching", async ({ page }) => {
+
  await page.goto(sourceBrowsingUrl);
+
  await page
+
    .getByRole("link", { name: `Commits ${aliceMainCommitCount}` })
+
    .click();
+
  await expect(page.getByText("Thursday, December 15,")).toBeVisible();
+

+
  // Alice's peer.
+
  {
+
    await changeBranch("alice", `main ${shortAliceHead}`, page);
+

+
    await expect(page.getByTitle("Change branch")).toHaveText(
+
      "alice Delegate / main",
+
    );
+

+
    await expect(page.getByText("Thursday, November 17, 2022")).toBeVisible();
+
    await expect(page.locator(".list .teaser")).toHaveCount(
+
      aliceMainCommitCount,
+
    );
+

+
    const latestCommit = page.locator(".teaser").first();
+
    await expect(latestCommit).toContainText(aliceMainCommitMessage);
+
    await expect(latestCommit).toContainText(shortAliceHead);
+

+
    const earliestCommit = page.locator(".teaser").last();
+
    await expect(earliestCommit).toContainText(
+
      "Initialize an empty git repository",
+
    );
+
    await expect(earliestCommit).toContainText("36d5bbe");
+

+
    await changeBranch("alice", "feature/branch", page);
+

+
    await expect(
+
      page.getByRole("button", { name: "feature/branch" }),
+
    ).toBeVisible();
+
    await expect(page.getByText("Thursday, November 17, 2022")).toBeVisible();
+
    await expect(page.locator(".list .teaser")).toHaveCount(bobMainCommitCount);
+

+
    await changeBranch("alice", "orphaned-branch", page);
+

+
    await expect(
+
      page.getByRole("button", { name: "orphaned-branch" }),
+
    ).toBeVisible();
+
    await expect(page.getByText("Thursday, November 17, 2022")).toBeVisible();
+
    await expect(page.locator(".list")).toHaveCount(1);
+
  }
+

+
  // Bob's peer.
+
  {
+
    await changeBranch("bob", `main ${shortBobHead}`, page);
+

+
    await expect(page.getByTitle("Change branch")).toContainText("bob");
+

+
    await expect(page.getByText("Wednesday, December 21, 2022")).toBeVisible();
+
    await expect(page.locator(".list").first().locator(".teaser")).toHaveCount(
+
      1,
+
    );
+

+
    await expect(page.getByText("Thursday, November 17, 2022")).toBeVisible();
+
    await expect(page.locator(".list").last().locator(".teaser")).toHaveCount(
+
      7,
+
    );
+

+
    const latestCommit = page.locator(".teaser").first();
+
    await expect(latestCommit).toContainText("Update readme");
+
    await expect(latestCommit).toContainText(shortBobHead);
+

+
    const earliestCommit = page.locator(".teaser").last();
+
    await expect(earliestCommit).toContainText(
+
      "Initialize an empty git repository",
+
    );
+
    await expect(earliestCommit).toContainText("36d5bbe");
+
  }
+
});
+

+
test("loading more commits, adds them to the commits list", async ({
+
  page,
+
}) => {
+
  await page.goto(`http://localhost:3002${sourceBrowsingUrl}`);
+
  await page.getByRole("button", { name: "Commits" }).click();
+
  await expect(page.locator("div > div > .teaser")).toHaveCount(4);
+

+
  await page.getByRole("button", { name: "More" }).click();
+
  await expect(page.locator("div > div > .teaser")).toHaveCount(8);
+
});
+

+
test("commit messages with double colon not converted into single colon", async ({
+
  page,
+
}) => {
+
  const commitMessage = "Verify that crate::DoubleColon::should_work()";
+
  const shortCommit = "7babd25";
+
  await page.goto(sourceBrowsingUrl);
+
  await page
+
    .getByRole("link", { name: `Commits ${aliceMainCommitCount}` })
+
    .click();
+

+
  await expect(
+
    page.getByRole("button", {
+
      name: `${shortCommit} ${commitMessage}`,
+
    }),
+
  ).toBeVisible();
+
  await expect(
+
    page.getByRole("link", {
+
      name: commitMessage,
+
      exact: true,
+
    }),
+
  ).toBeVisible();
+

+
  await page
+
    .getByRole("link", {
+
      name: commitMessage,
+
      exact: true,
+
    })
+
    .click();
+
  await page.waitForLoadState("networkidle");
+
  await expect(page.getByText(commitMessage, { exact: true })).toBeVisible();
+
});
+

+
test("expand commit message", async ({ page }) => {
+
  await page.goto(sourceBrowsingUrl);
+
  await page
+
    .getByRole("link", { name: `Commits ${aliceMainCommitCount}` })
+
    .click();
+
  const commitToggle = page.getByRole("button", { name: "expand" }).first();
+

+
  await commitToggle.click();
+
  const expandedCommit = page.getByText(
+
    "This shouldn't replace double colons with simple colons",
+
  );
+

+
  await expect(expandedCommit).toBeVisible();
+
  await commitToggle.click();
+

+
  await expect(expandedCommit).toBeHidden();
+
});
+

+
test("relative timestamps", async ({ page }) => {
+
  await page.addInitScript(() => {
+
    sinon.useFakeTimers({
+
      now: new Date("December 21 2022 12:00:00").valueOf(),
+
      shouldClearNativeTimers: true,
+
      shouldAdvanceTime: false,
+
    });
+
  });
+

+
  await page.goto(sourceBrowsingUrl);
+
  await page
+
    .getByRole("link", { name: `Commits ${aliceMainCommitCount}` })
+
    .click();
+
  await expect(page.getByText("Thursday, December 15,")).toBeVisible();
+

+
  await changeBranch("bob", `main ${shortBobHead}`, page);
+
  await expect(page.getByTitle("Change branch")).toHaveText(/bob/);
+
  const latestCommit = page.locator(".teaser").first();
+
  await expect(latestCommit).toContainText(
+
    `Bob Belcher committed ${shortBobHead} now`,
+
  );
+
  await expect(latestCommit).toContainText(shortBobHead);
+
  const earliestCommit = page.locator(".teaser").last();
+
  await expect(earliestCommit).toContainText(
+
    "Alice Liddell committed 36d5bbe last month",
+
  );
+
});
+

+
test("pushing changes while viewing history", async ({ page, peerManager }) => {
+
  const alice = await peerManager.createPeer({
+
    name: "alice",
+
    gitOptions: gitOptions["alice"],
+
  });
+
  await alice.startNode();
+
  await alice.startHttpd();
+
  const { rid, repoFolder } = await createRepo(alice, {
+
    name: "alice-project",
+
  });
+
  await page.goto(`${alice.uiUrl()}/${rid}`);
+
  await page.getByRole("link", { name: "Commits 1" }).click();
+
  await expect(page).toHaveURL(`${alice.uiUrl()}/${rid}/history`);
+

+
  await alice.git(["commit", "--allow-empty", "--message", "first change"], {
+
    cwd: repoFolder,
+
  });
+
  await alice.git(["push", "rad", "main"], {
+
    cwd: repoFolder,
+
  });
+
  await page.reload();
+
  await expect(page).toHaveURL(`${alice.uiUrl()}/${rid}/history`);
+
  await expect(page.getByRole("link", { name: "Commits 2" })).toBeVisible();
+

+
  await expect(page.getByTitle("Change branch")).toHaveText("main Canonical");
+
  const branchSelectorCommitButton = page.getByTitle("Current HEAD").first();
+
  await expect(branchSelectorCommitButton).toHaveText("516fa74 first change");
+

+
  await page
+
    .getByRole("banner")
+
    .getByRole("link", { name: "alice-project" })
+
    .click();
+
  await expect(page).toHaveURL(`${alice.uiUrl()}/${rid}`);
+
  await page.getByRole("link", { name: "Commits 2" }).click();
+

+
  await alice.git(
+
    [
+
      "commit",
+
      "--allow-empty",
+
      "--message",
+
      "after clicking the project title",
+
    ],
+
    {
+
      cwd: repoFolder,
+
    },
+
  );
+
  await alice.git(["push", "rad", "main"], {
+
    cwd: repoFolder,
+
  });
+
  await page.reload();
+
  await expect(page).toHaveURL(`${alice.uiUrl()}/${rid}/history`);
+
  await expect(page.getByRole("link", { name: "Commits 3" })).toHaveText(
+
    "Commits 3",
+
  );
+
  await expect(
+
    page.getByRole("button", { name: "main Canonical" }),
+
  ).toBeVisible();
+
  await expect(
+
    page.getByRole("link", { name: "bb9089a after clicking the" }),
+
  ).toBeVisible();
+
});
added tests/e2e/repo/issue.spec.ts
@@ -0,0 +1,8 @@
+
import { test, cobUrl, expect } from "@tests/support/fixtures.js";
+

+
test("navigate single issue", async ({ page }) => {
+
  await page.goto(`${cobUrl}/issues`);
+
  await page.getByText("This title has **markdown**").click();
+

+
  await expect(page).toHaveURL(/\/issues\/[0-9a-f]{40}/);
+
});
added tests/e2e/repo/issues.spec.ts
@@ -0,0 +1,52 @@
+
import { test, cobUrl, expect } from "@tests/support/fixtures.js";
+
import { createRepo } from "@tests/support/repo";
+

+
test("navigate issue listing", async ({ page }) => {
+
  await page.goto(cobUrl);
+
  await page.getByRole("link", { name: "Issues 1" }).click();
+
  await expect(page).toHaveURL(`${cobUrl}/issues`);
+

+
  await page.getByRole("button", { name: "filter-dropdown" }).first().click();
+
  await page.getByRole("link", { name: "Closed 2" }).click();
+
  await expect(page).toHaveURL(`${cobUrl}/issues?status=closed`);
+
});
+

+
test("issue counters", async ({ page, peer }) => {
+
  const { rid, repoFolder } = await createRepo(peer, {
+
    name: "issue-counters",
+
  });
+
  await peer.rad(
+
    [
+
      "issue",
+
      "open",
+
      "--title",
+
      "First issue to test counters",
+
      "--description",
+
      "Let's see",
+
    ],
+
    { cwd: repoFolder },
+
  );
+
  await page.goto(`${peer.uiUrl()}/${rid}/issues`);
+
  await peer.rad(
+
    [
+
      "issue",
+
      "open",
+
      "--title",
+
      "Second issue to test counters",
+
      "--description",
+
      "Let's see",
+
    ],
+
    { cwd: repoFolder },
+
  );
+
  await page.getByRole("button", { name: "filter-dropdown" }).first().click();
+
  await page.locator(".dropdown-item").getByText("Open 1").click();
+
  await expect(page.getByRole("button", { name: "Issues 2" })).toBeVisible();
+
  await expect(
+
    page.getByRole("button", { name: "filter-dropdown" }).first(),
+
  ).toHaveText("Open 2");
+
  await expect(page.locator(".issue-teaser")).toHaveCount(2);
+

+
  await page
+
    .getByRole("link", { name: "First issue to test counters" })
+
    .click();
+
});
added tests/e2e/repo/patch.spec.ts
@@ -0,0 +1,119 @@
+
import { test, cobUrl, expect } from "@tests/support/fixtures.js";
+

+
test("navigate patch details", async ({ page }) => {
+
  await page.goto(`${cobUrl}/patches`);
+
  await page.getByText("Add subtitle to README").click();
+
  await expect(page).toHaveURL(/patches\/[a-f0-9]{40}$/);
+
  await page.getByRole("link", { name: "Add subtitle to README" }).click();
+
  await expect(page).toHaveURL(/commits\/[a-f0-9]{40}$/);
+
  await page.goBack();
+
  await page.getByRole("link", { name: "Changes" }).click();
+
  await expect(page).toHaveURL(/patches\/[a-f0-9]{40}\?tab=changes$/);
+
});
+

+
test("use revision selector", async ({ page }) => {
+
  await page.goto(`${cobUrl}/patches`);
+
  await page
+
    .getByRole("link", { name: "Taking another stab at the README" })
+
    .click();
+
  await page.getByRole("link", { name: "Changes" }).click();
+

+
  // Validating the latest revision state
+
  await expect(
+
    page.getByRole("cell", { name: "Had to push a new revision" }),
+
  ).toBeVisible();
+
  await page.getByRole("link", { name: "Activity" }).click();
+
  await expect(page.getByLabel("commit-teaser")).toHaveCount(2);
+
  await expect(page.getByRole("link", { name: "Add more text" })).toBeVisible();
+

+
  // Open the first revision and close the latest one
+
  await page.getByLabel("expand").first().click();
+
  await page.getByLabel("expand").last().click();
+

+
  // Validating the initial revision
+
  await expect(page.getByLabel("commit-teaser")).toHaveCount(3);
+
  await expect(
+
    page.getByRole("link", { name: "Rewrite subtitle to README" }).first(),
+
  ).toBeVisible();
+

+
  await page.getByRole("link", { name: "Changes" }).click();
+
  // Switching to the initial revision
+

+
  await page.getByRole("button", { name: "Revision" }).first().click();
+
  await page.getByRole("button", { name: "Revision" }).nth(1).click();
+

+
  await expect(
+
    page.getByRole("cell", { name: "Had to push a new revision" }),
+
  ).toBeHidden();
+

+
  await expect(page).toHaveURL(
+
    /patches\/[a-f0-9]{40}\/[a-f0-9]{40}\?tab=changes$/,
+
  );
+
});
+

+
test("navigate through revision diffs", async ({ page }) => {
+
  await page.goto(`${cobUrl}/patches`);
+
  await page
+
    .getByRole("link", { name: "Taking another stab at the README" })
+
    .click();
+

+
  const firstRevision = page.locator(".revision").first();
+
  const firstRevisionId = "59a0821";
+
  const secondRevision = page.locator(".revision").nth(1);
+

+
  // Second revision
+
  {
+
    await secondRevision
+
      .getByRole("button", { name: "toggle-context-menu" })
+
      .first()
+
      .click();
+
    await secondRevision
+
      .getByRole("link", { name: "Compare to base: 38c225e" })
+
      .click();
+
    await expect(
+
      page.getByRole("button", { name: "Compare 38c225..9e4fea" }),
+
    ).toBeVisible();
+
    await expect(page).toHaveURL(
+
      /patches\/[a-f0-9]{40}\?diff=38c225e2a0b47ba59def211f4e4825c31d9463ec\.\.9e4feab1b2123dfa5f22bd0e4656060ec9296638$/,
+
    );
+
    await page.goBack();
+
    await secondRevision
+
      .getByRole("button", { name: "toggle-context-menu" })
+
      .first()
+
      .click();
+
    await secondRevision
+
      .getByRole("link", {
+
        name: `Compare to previous revision: ${firstRevisionId}`,
+
      })
+
      .click();
+
    await expect(
+
      page.getByRole("button", { name: "Compare 88b7fd..9e4fea" }),
+
    ).toBeVisible();
+

+
    await expect(page).toHaveURL(
+
      /patches\/[a-f0-9]{40}\?diff=88b7fd90389c1a629f91ed7bf838d4b947426622\.\.9e4feab1b2123dfa5f22bd0e4656060ec9296638$/,
+
    );
+
    await page.goBack();
+
  }
+
  // First revision and DiffStatBadge shortcut.
+
  {
+
    await firstRevision.getByTitle("Compare 38c225e..88b7fd9").click();
+
    await expect(
+
      page.getByRole("button", { name: "Compare 38c225..88b7fd" }),
+
    ).toBeVisible();
+
    await expect(page).toHaveURL(
+
      /patches\/[a-f0-9]{40}\?diff=38c225e2a0b47ba59def211f4e4825c31d9463ec\.\.88b7fd90389c1a629f91ed7bf838d4b947426622$/,
+
    );
+
  }
+
});
+

+
test("view file navigation from changes tab", async ({ page }) => {
+
  await page.goto(`${cobUrl}/patches`);
+
  await page.getByRole("link", { name: "Add subtitle to README" }).click();
+
  await page.getByRole("link", { name: "Changes" }).click();
+
  await page.getByRole("button", { name: "Changes" }).click();
+
  await page.getByRole("button", { name: "View file at this commit" }).click();
+
  await expect(page).toHaveURL(
+
    `${cobUrl}/tree/8c900d6cb38811e099efb3cbbdbfaba817bcf970/README.md`,
+
  );
+
});
added tests/e2e/repo/patches.spec.ts
@@ -0,0 +1,50 @@
+
import { test, cobUrl, expect } from "@tests/support/fixtures.js";
+
import { createRepo } from "@tests/support/repo";
+

+
test("navigate patch listing", async ({ page }) => {
+
  await page.goto(cobUrl);
+
  await page.getByRole("link", { name: "Patches 2" }).click();
+
  await expect(page).toHaveURL(`${cobUrl}/patches`);
+

+
  await page.getByRole("button", { name: "filter-dropdown" }).first().click();
+
  await page.getByRole("link", { name: "Merged 1" }).click();
+
  await expect(page).toHaveURL(`${cobUrl}/patches?status=merged`);
+
  await expect(
+
    page.locator(".comments").filter({ hasText: "5" }),
+
  ).toBeVisible();
+
});
+

+
test("patches counters", async ({ page, peer }) => {
+
  const { rid, repoFolder, defaultBranch } = await createRepo(peer, {
+
    name: "patch-counters",
+
  });
+
  await peer.git(["switch", "-c", "feature-1"], {
+
    cwd: repoFolder,
+
  });
+
  await peer.git(["commit", "--allow-empty", "-m", "1th"], {
+
    cwd: repoFolder,
+
  });
+
  await peer.git(["push", "rad", "HEAD:refs/patches"], {
+
    cwd: repoFolder,
+
  });
+
  await page.goto(`${peer.uiUrl()}/${rid}/patches`);
+
  await peer.git(["switch", defaultBranch], {
+
    cwd: repoFolder,
+
  });
+
  await peer.git(["switch", "-c", "feature-2"], {
+
    cwd: repoFolder,
+
  });
+
  await peer.git(["commit", "--allow-empty", "-m", "2nd"], {
+
    cwd: repoFolder,
+
  });
+
  await peer.git(["push", "rad", "HEAD:refs/patches"], {
+
    cwd: repoFolder,
+
  });
+
  await page.getByRole("button", { name: "filter-dropdown" }).first().click();
+
  await page.locator(".dropdown-item").getByText("Open 1").click();
+
  await expect(page.getByRole("button", { name: "Patches 2" })).toBeVisible();
+
  await expect(
+
    page.getByRole("button", { name: "filter-dropdown" }).first(),
+
  ).toHaveText("Open 2");
+
  await expect(page.locator(".patch-teaser")).toHaveCount(2);
+
});
modified tests/e2e/router.ts
@@ -6,13 +6,13 @@ import {
  sourceBrowsingUrl,
  test,
} from "@tests/support/fixtures.js";
-
import { createProject } from "@tests/support/project";
+
import { createRepo } from "@tests/support/repo";
import {
  expectBackAndForwardNavigationWorks,
  expectUrlPersistsReload,
} from "@tests/support/router.js";

-
test("navigate between landing and project page", async ({ page }) => {
+
test("navigate between landing and repo page", async ({ page }) => {
  await page.goto("/");
  await expect(page).toHaveURL("/");

@@ -23,13 +23,13 @@ test("navigate between landing and project page", async ({ page }) => {
  await expectUrlPersistsReload(page);
});

-
test("navigation between node and project pages", async ({ page }) => {
+
test("navigation between node and repo pages", async ({ page }) => {
  await page.goto("/nodes/radicle.local");

-
  const project = page
-
    .locator(".project-card", { hasText: "source-browsing" })
+
  const repo = page
+
    .locator(".repo-card", { hasText: "source-browsing" })
    .nth(0);
-
  await project.click();
+
  await repo.click();
  await expect(page).toHaveURL(sourceBrowsingUrl);

  await expectBackAndForwardNavigationWorks("/nodes/radicle.local", page);
@@ -39,30 +39,30 @@ test("navigation between node and project pages", async ({ page }) => {
  await expect(page).toHaveURL("/nodes/127.0.0.1");
});

-
test.describe("project page navigation", () => {
+
test.describe("repo page navigation", () => {
  test("navigation between commit history and single commit", async ({
    page,
  }) => {
-
    const projectHistoryURL = `${sourceBrowsingUrl}/history/${aliceMainHead}`;
-
    await page.goto(projectHistoryURL);
+
    const repoHistoryURL = `${sourceBrowsingUrl}/history/${aliceMainHead}`;
+
    await page.goto(repoHistoryURL);

    await page.getByText("Add README.md").click();
    await expect(page).toHaveURL(
      `${sourceBrowsingUrl}/commits/${aliceMainHead}`,
    );

-
    await expectBackAndForwardNavigationWorks(projectHistoryURL, page);
+
    await expectBackAndForwardNavigationWorks(repoHistoryURL, page);
    await expectUrlPersistsReload(page);
  });

  test("navigate between tree and commit history", async ({ page }) => {
-
    const projectTreeURL = `${sourceBrowsingUrl}/tree/${aliceMainHead}`;
+
    const repoTreeURL = `${sourceBrowsingUrl}/tree/${aliceMainHead}`;

-
    await page.goto(projectTreeURL);
+
    await page.goto(repoTreeURL);
    await page
      .getByRole("progressbar", { name: "Page loading" })
      .waitFor({ state: "hidden" });
-
    await expect(page).toHaveURL(projectTreeURL);
+
    await expect(page).toHaveURL(repoTreeURL);

    await page
      .getByRole("link", { name: `Commits ${aliceMainCommitCount}` })
@@ -72,23 +72,23 @@ test.describe("project page navigation", () => {
      `${sourceBrowsingUrl}/history/${aliceMainHead}`,
    );

-
    await expectBackAndForwardNavigationWorks(projectTreeURL, page);
+
    await expectBackAndForwardNavigationWorks(repoTreeURL, page);
    await expectUrlPersistsReload(page);
  });

  test("navigate between tree and commit history while a file is selected", async ({
    page,
  }) => {
-
    const projectTreeURL = `${sourceBrowsingUrl}`;
+
    const repoTreeURL = `${sourceBrowsingUrl}`;

-
    await page.goto(projectTreeURL);
+
    await page.goto(repoTreeURL);
    await page
      .getByRole("progressbar", { name: "Page loading" })
      .waitFor({ state: "hidden" });
-
    await expect(page).toHaveURL(projectTreeURL);
+
    await expect(page).toHaveURL(repoTreeURL);

    await page.getByText(".hidden").click();
-
    await expect(page).toHaveURL(`${projectTreeURL}/tree/.hidden`);
+
    await expect(page).toHaveURL(`${repoTreeURL}/tree/.hidden`);

    await page
      .getByRole("link", { name: `Commits ${aliceMainCommitCount}` })
@@ -96,23 +96,20 @@ test.describe("project page navigation", () => {
    await expect(page).toHaveURL(`${sourceBrowsingUrl}/history`);
  });

-
  test("navigate project paths", async ({ page }) => {
-
    const projectTreeURL = `${sourceBrowsingUrl}/tree/${aliceMainHead}`;
+
  test("navigate repo paths", async ({ page }) => {
+
    const repoTreeURL = `${sourceBrowsingUrl}/tree/${aliceMainHead}`;

-
    await page.goto(projectTreeURL);
-
    await expect(page).toHaveURL(projectTreeURL);
+
    await page.goto(repoTreeURL);
+
    await expect(page).toHaveURL(repoTreeURL);

    await page.getByText(".hidden").click();
-
    await expect(page).toHaveURL(`${projectTreeURL}/.hidden`);
+
    await expect(page).toHaveURL(`${repoTreeURL}/.hidden`);

    await page.getByText("bin").click();
    await page.getByText("true").click();
-
    await expect(page).toHaveURL(`${projectTreeURL}/bin/true`);
+
    await expect(page).toHaveURL(`${repoTreeURL}/bin/true`);

-
    await expectBackAndForwardNavigationWorks(
-
      `${projectTreeURL}/.hidden`,
-
      page,
-
    );
+
    await expectBackAndForwardNavigationWorks(`${repoTreeURL}/.hidden`, page);
    await expectUrlPersistsReload(page);
  });

@@ -126,68 +123,62 @@ test.describe("project page navigation", () => {
    );
  });

-
  test("page title on project with empty description", async ({
-
    page,
-
    peer,
-
  }) => {
-
    const { rid } = await createProject(peer, {
-
      name: "ProjectWithNoDescription",
+
  test("page title on repo with empty description", async ({ page, peer }) => {
+
    const { rid } = await createRepo(peer, {
+
      name: "RepoWithNoDescription",
    });
    await page.goto(peer.ridUrl(rid), {
      waitUntil: "networkidle",
    });
    const title = await page.title();
-
    expect(title).toBe("ProjectWithNoDescription");
+
    expect(title).toBe("RepoWithNoDescription");
  });

-
  test("navigate project paths with an explicitly selected peer", async ({
+
  test("navigate repo paths with an explicitly selected peer", async ({
    page,
  }) => {
-
    // If a branch isn't explicitly specified, the code assumes the project
+
    // If a branch isn't explicitly specified, the code assumes the repo
    // default branch is selected. We omit showing the default branch in the URL.

-
    const projectTreeURL = `${sourceBrowsingUrl}/remotes/${aliceRemote.substring(
+
    const repoTreeURL = `${sourceBrowsingUrl}/remotes/${aliceRemote.substring(
      8,
    )}`;

-
    await page.goto(projectTreeURL);
-
    await expect(page).toHaveURL(projectTreeURL);
+
    await page.goto(repoTreeURL);
+
    await expect(page).toHaveURL(repoTreeURL);

    await page.getByText(".hidden").click();
-
    await expect(page).toHaveURL(`${projectTreeURL}/tree/.hidden`);
+
    await expect(page).toHaveURL(`${repoTreeURL}/tree/.hidden`);

    await page.getByText("bin").click();
    await page.getByText("true").click();
-
    await expect(page).toHaveURL(`${projectTreeURL}/tree/bin/true`);
+
    await expect(page).toHaveURL(`${repoTreeURL}/tree/bin/true`);

    await expectBackAndForwardNavigationWorks(
-
      `${projectTreeURL}/tree/.hidden`,
+
      `${repoTreeURL}/tree/.hidden`,
      page,
    );
    await expectUrlPersistsReload(page);
  });

-
  test("navigate project paths with an explicitly selected peer and branch", async ({
+
  test("navigate repo paths with an explicitly selected peer and branch", async ({
    page,
  }) => {
-
    const projectTreeURL = `${sourceBrowsingUrl}/remotes/${aliceRemote.substring(
+
    const repoTreeURL = `${sourceBrowsingUrl}/remotes/${aliceRemote.substring(
      8,
    )}/tree/main`;

-
    await page.goto(projectTreeURL);
-
    await expect(page).toHaveURL(projectTreeURL);
+
    await page.goto(repoTreeURL);
+
    await expect(page).toHaveURL(repoTreeURL);

    await page.getByText(".hidden").click();
-
    await expect(page).toHaveURL(`${projectTreeURL}/.hidden`);
+
    await expect(page).toHaveURL(`${repoTreeURL}/.hidden`);

    await page.getByText("bin").click();
    await page.getByText("true").click();
-
    await expect(page).toHaveURL(`${projectTreeURL}/bin/true`);
+
    await expect(page).toHaveURL(`${repoTreeURL}/bin/true`);

-
    await expectBackAndForwardNavigationWorks(
-
      `${projectTreeURL}/.hidden`,
-
      page,
-
    );
+
    await expectBackAndForwardNavigationWorks(`${repoTreeURL}/.hidden`, page);
    await expectUrlPersistsReload(page);
  });
});
modified tests/support/fixtures.ts
@@ -14,7 +14,7 @@ import * as logLabel from "@tests/support/logPrefix.js";
import * as patch from "@tests/support/cobs/patch.js";
import { createOptions, supportDir, tmpDir } from "@tests/support/support.js";
import { createPeerManager } from "@tests/support/peerManager.js";
-
import { createProject } from "@tests/support/project.js";
+
import { createRepo } from "@tests/support/repo.js";
import { formatCommit } from "@app/lib/utils.js";

export { expect };
@@ -169,12 +169,12 @@ export async function createSourceBrowsingFixture(
  peerManager: PeerManager,
  palm: RadiclePeer,
) {
-
  const projectName = "source-browsing";
-
  const sourceBrowsingDir = Path.join(tmpDir, "repos", projectName);
+
  const repoName = "source-browsing";
+
  const sourceBrowsingDir = Path.join(tmpDir, "repos", repoName);
  await Fs.mkdir(sourceBrowsingDir, { recursive: true });
  await execa("tar", [
    "-xf",
-
    Path.join(fixturesDir, `repos/${projectName}.tar.bz2`),
+
    Path.join(fixturesDir, `repos/${repoName}.tar.bz2`),
    "-C",
    sourceBrowsingDir,
  ]);
@@ -183,12 +183,12 @@ export async function createSourceBrowsingFixture(
    name: "alice",
    gitOptions: gitOptions["alice"],
  });
-
  const aliceProjectPath = Path.join(alice.checkoutPath, "source-browsing");
+
  const aliceRepoPath = Path.join(alice.checkoutPath, "source-browsing");
  const bob = await peerManager.createPeer({
    name: "bob",
    gitOptions: gitOptions["bob"],
  });
-
  const bobProjectPath = Path.join(bob.checkoutPath, "source-browsing");
+
  const bobRepoPath = Path.join(bob.checkoutPath, "source-browsing");
  await alice.startNode({
    node: {
      ...defaultConfig.node,
@@ -203,24 +203,24 @@ export async function createSourceBrowsingFixture(
  await palm.waitForEvent({ type: "peerConnected", nid: bob.nodeId }, 1000);

  await alice.git(["clone", sourceBrowsingDir], { cwd: alice.checkoutPath });
-
  await alice.git(["checkout", "feature/branch"], { cwd: aliceProjectPath });
+
  await alice.git(["checkout", "feature/branch"], { cwd: aliceRepoPath });
  await alice.git(["checkout", "feature/move-copy-files"], {
-
    cwd: aliceProjectPath,
+
    cwd: aliceRepoPath,
  });
-
  await alice.git(["checkout", "orphaned-branch"], { cwd: aliceProjectPath });
-
  await alice.git(["checkout", "main"], { cwd: aliceProjectPath });
+
  await alice.git(["checkout", "orphaned-branch"], { cwd: aliceRepoPath });
+
  await alice.git(["checkout", "main"], { cwd: aliceRepoPath });
  await alice.rad(
    [
      "init",
      "--name",
-
      projectName,
+
      repoName,
      "--default-branch",
      "main",
      "--description",
      "Git repository for source browsing tests",
      "--public",
    ],
-
    { cwd: aliceProjectPath },
+
    { cwd: aliceRepoPath },
  );
  await alice.waitForEvent(
    {
@@ -232,7 +232,7 @@ export async function createSourceBrowsingFixture(
  );

  // Needed due to rad init not pushing all branches.
-
  await alice.git(["push", "rad", "--all"], { cwd: aliceProjectPath });
+
  await alice.git(["push", "rad", "--all"], { cwd: aliceRepoPath });
  await alice.stopNode();

  await bob.waitForEvent(
@@ -250,7 +250,7 @@ export async function createSourceBrowsingFixture(
    Path.join(bob.checkoutPath, "source-browsing", "README.md"),
    "Updated readme",
  );
-
  await bob.git(["add", "README.md"], { cwd: bobProjectPath });
+
  await bob.git(["add", "README.md"], { cwd: bobRepoPath });
  await bob.git(
    [
      "commit",
@@ -259,16 +259,16 @@ export async function createSourceBrowsingFixture(
      "--date",
      "Mon Dec 21 14:00 2022 +0100",
    ],
-
    { cwd: bobProjectPath },
+
    { cwd: bobRepoPath },
  );
-
  await bob.git(["push", "rad"], { cwd: bobProjectPath });
+
  await bob.git(["push", "rad"], { cwd: bobRepoPath });
  await bob.stopNode();
}

export async function createCobsFixture(peer: RadiclePeer) {
  await peer.rad(["follow", peer.nodeId, "--alias", "palm"]);
  await Fs.mkdir(Path.join(tmpDir, "repos", "cobs"));
-
  const { projectFolder, defaultBranch } = await createProject(peer, {
+
  const { repoFolder, defaultBranch } = await createRepo(peer, {
    name: "cobs",
  });
  const issueOne = await issue.create(
@@ -276,23 +276,23 @@ export async function createCobsFixture(peer: RadiclePeer) {
    "This `title` has **markdown**",
    "This is a description\nWith some multiline text.",
    ["bug", "feature-request"],
-
    { cwd: projectFolder },
+
    { cwd: repoFolder },
  );
  await peer.rad(
    ["issue", "react", issueOne, "--emoji", "👍", "--to", issueOne],
    {
-
      cwd: projectFolder,
+
      cwd: repoFolder,
    },
  );
  await peer.rad(
    ["issue", "react", issueOne, "--emoji", "🎉", "--to", issueOne],
    {
-
      cwd: projectFolder,
+
      cwd: repoFolder,
    },
  );
  await peer.rad(
    ["issue", "assign", issueOne, "--add", `did:key:${peer.nodeId}`],
-
    createOptions(projectFolder, 1),
+
    createOptions(repoFolder, 1),
  );
  const { stdout: commentIssueOne } = await peer.rad(
    [
@@ -303,12 +303,12 @@ export async function createCobsFixture(peer: RadiclePeer) {
      "This is a multiline comment\n\nWith some more text.",
      "--quiet",
    ],
-
    createOptions(projectFolder, 2),
+
    createOptions(repoFolder, 2),
  );
  await peer.rad(
    ["issue", "react", issueOne, "--emoji", "🙏", "--to", commentIssueOne],
    {
-
      cwd: projectFolder,
+
      cwd: repoFolder,
    },
  );
  const { stdout: replyIssueOne } = await peer.rad(
@@ -322,12 +322,12 @@ export async function createCobsFixture(peer: RadiclePeer) {
      commentIssueOne,
      "--quiet",
    ],
-
    createOptions(projectFolder, 3),
+
    createOptions(repoFolder, 3),
  );
  await peer.rad(
    ["issue", "react", issueOne, "--emoji", "🚀", "--to", replyIssueOne],
    {
-
      cwd: projectFolder,
+
      cwd: repoFolder,
    },
  );
  await peer.rad(
@@ -339,7 +339,7 @@ export async function createCobsFixture(peer: RadiclePeer) {
      "A root level comment after a reply, for margins sake.",
      "--quiet",
    ],
-
    createOptions(projectFolder, 4),
+
    createOptions(repoFolder, 4),
  );

  const issueTwo = await issue.create(
@@ -347,11 +347,11 @@ export async function createCobsFixture(peer: RadiclePeer) {
    "A closed issue",
    "This issue has been closed\n\nsource: [link](https://radicle.xyz)",
    [],
-
    { cwd: projectFolder },
+
    { cwd: repoFolder },
  );
  await peer.rad(
    ["issue", "state", issueTwo, "--closed"],
-
    createOptions(projectFolder, 1),
+
    createOptions(repoFolder, 1),
  );

  const issueThree = await issue.create(
@@ -359,20 +359,20 @@ export async function createCobsFixture(peer: RadiclePeer) {
    "A solved issue",
    "This issue has been solved\n\n```js\nconsole.log('hello world')\nconsole.log(\"\")\n```",
    [],
-
    { cwd: projectFolder },
+
    { cwd: repoFolder },
  );
  await peer.rad(
    ["issue", "state", issueThree, "--solved"],
-
    createOptions(projectFolder, 1),
+
    createOptions(repoFolder, 1),
  );

  const patchOne = await patch.create(
    peer,
    ["Add README", "This commit adds more information to the README"],
    "feature/add-readme",
-
    () => Fs.writeFile(Path.join(projectFolder, "README.md"), "# Cobs Repo"),
+
    () => Fs.writeFile(Path.join(repoFolder, "README.md"), "# Cobs Repo"),
    ["Let's add a README", "This repo needed a README"],
-
    { cwd: projectFolder },
+
    { cwd: repoFolder },
  );
  const { stdout: commentPatchOne } = await peer.rad(
    [
@@ -384,7 +384,7 @@ export async function createCobsFixture(peer: RadiclePeer) {
      "--quiet",
      "--no-announce",
    ],
-
    createOptions(projectFolder, 1),
+
    createOptions(repoFolder, 1),
  );
  await peer.rad(
    [
@@ -397,7 +397,7 @@ export async function createCobsFixture(peer: RadiclePeer) {
      commentPatchOne,
      "--quiet",
    ],
-
    createOptions(projectFolder, 2),
+
    createOptions(repoFolder, 2),
  );
  await peer.rad(
    [
@@ -410,7 +410,7 @@ export async function createCobsFixture(peer: RadiclePeer) {
      commentPatchOne,
      "--quiet",
    ],
-
    createOptions(projectFolder, 3),
+
    createOptions(repoFolder, 3),
  );
  const { stdout: commentTwo } = await peer.rad(
    [
@@ -422,7 +422,7 @@ export async function createCobsFixture(peer: RadiclePeer) {
      "--quiet",
      "--no-announce",
    ],
-
    createOptions(projectFolder, 4),
+
    createOptions(repoFolder, 4),
  );
  await peer.rad(
    [
@@ -435,27 +435,26 @@ export async function createCobsFixture(peer: RadiclePeer) {
      commentTwo,
      "--quiet",
    ],
-
    createOptions(projectFolder, 5),
+
    createOptions(repoFolder, 5),
  );
  await peer.rad(
    ["patch", "review", patchOne, "-m", "LGTM", "--accept"],
-
    createOptions(projectFolder, 6),
+
    createOptions(repoFolder, 6),
  );
  await patch.merge(
    peer,
    defaultBranch,
    "feature/add-readme",
-
    createOptions(projectFolder, 7),
+
    createOptions(repoFolder, 7),
  );

  const patchTwo = await patch.create(
    peer,
    ["Add subtitle to README"],
    "feature/add-more-text",
-
    () =>
-
      Fs.appendFile(Path.join(projectFolder, "README.md"), "\n\n## Subtitle"),
+
    () => Fs.appendFile(Path.join(repoFolder, "README.md"), "\n\n## Subtitle"),
    [],
-
    { cwd: projectFolder },
+
    { cwd: repoFolder },
  );
  await peer.rad(
    [
@@ -466,7 +465,7 @@ export async function createCobsFixture(peer: RadiclePeer) {
      "Not the README we are looking for",
      "--reject",
    ],
-
    createOptions(projectFolder, 1),
+
    createOptions(repoFolder, 1),
  );

  const patchThree = await patch.create(
@@ -477,29 +476,28 @@ export async function createCobsFixture(peer: RadiclePeer) {
      "Blazingly fast",
    ],
    "feature/better-subtitle",
-
    () =>
-
      Fs.appendFile(Path.join(projectFolder, "README.md"), "\n\n## Better?"),
+
    () => Fs.appendFile(Path.join(repoFolder, "README.md"), "\n\n## Better?"),
    [
      "Taking another stab at the README",
      "This is a big improvement over the last one",
      "Hopefully **this** is the last time",
    ],
-
    { cwd: projectFolder },
+
    { cwd: repoFolder },
  );
  await peer.rad(
    ["patch", "label", patchThree, "--add", "documentation"],
-
    createOptions(projectFolder, 1),
+
    createOptions(repoFolder, 1),
  );
  await peer.rad(
    ["patch", "review", patchThree, "-m", "This looks better"],
-
    createOptions(projectFolder, 2),
+
    createOptions(repoFolder, 2),
  );
  await Fs.appendFile(
-
    Path.join(projectFolder, "README.md"),
+
    Path.join(repoFolder, "README.md"),
    "\n\nHad to push a new revision",
  );
-
  await peer.git(["add", "."], { cwd: projectFolder });
-
  await peer.git(["commit", "-m", "Add more text"], { cwd: projectFolder });
+
  await peer.git(["add", "."], { cwd: repoFolder });
+
  await peer.git(["commit", "-m", "Add more text"], { cwd: repoFolder });
  await peer.git(
    [
      "push",
@@ -510,7 +508,7 @@ export async function createCobsFixture(peer: RadiclePeer) {
      "rad",
      "feature/better-subtitle",
    ],
-
    createOptions(projectFolder, 3),
+
    createOptions(repoFolder, 3),
  );
  await peer.rad(
    [
@@ -521,17 +519,16 @@ export async function createCobsFixture(peer: RadiclePeer) {
      "No this doesn't look better",
      "--reject",
    ],
-
    createOptions(projectFolder, 2),
+
    createOptions(repoFolder, 2),
  );

  const patchFour = await patch.create(
    peer,
    ["This patch is going to be archived"],
    "feature/archived",
-
    () =>
-
      Fs.writeFile(Path.join(projectFolder, "CONTRIBUTING.md"), "# Archived"),
+
    () => Fs.writeFile(Path.join(repoFolder, "CONTRIBUTING.md"), "# Archived"),
    [],
-
    { cwd: projectFolder },
+
    { cwd: repoFolder },
  );
  await peer.rad(
    [
@@ -541,24 +538,21 @@ export async function createCobsFixture(peer: RadiclePeer) {
      "-m",
      "No review due to patch being archived.",
    ],
-
    createOptions(projectFolder, 1),
-
  );
-
  await peer.rad(
-
    ["patch", "archive", patchFour],
-
    createOptions(projectFolder, 2),
+
    createOptions(repoFolder, 1),
  );
+
  await peer.rad(["patch", "archive", patchFour], createOptions(repoFolder, 2));

  const patchFive = await patch.create(
    peer,
    ["This patch is going to be reverted to draft"],
    "feature/draft",
-
    () => Fs.writeFile(Path.join(projectFolder, "LICENSE"), "Draft"),
+
    () => Fs.writeFile(Path.join(repoFolder, "LICENSE"), "Draft"),
    [],
-
    { cwd: projectFolder },
+
    { cwd: repoFolder },
  );
  await peer.rad(
    ["patch", "ready", patchFive, "--undo"],
-
    createOptions(projectFolder, 1),
+
    createOptions(repoFolder, 1),
  );
}

@@ -570,12 +564,12 @@ export async function createMarkdownFixture(peer: RadiclePeer) {
    "-C",
    Path.join(tmpDir, "repos", "markdown"),
  ]);
-
  const { projectFolder } = await createProject(peer, { name: "markdown" });
-
  await Fs.cp(Path.join(tmpDir, "repos", "markdown"), projectFolder, {
+
  const { repoFolder } = await createRepo(peer, { name: "markdown" });
+
  await Fs.cp(Path.join(tmpDir, "repos", "markdown"), repoFolder, {
    recursive: true,
  });

-
  await peer.git(["add", "."], { cwd: projectFolder });
+
  await peer.git(["add", "."], { cwd: repoFolder });
  const commitMessage = `Add Markdown cheat sheet

  Borrowed from [Adam Pritchard][ap].
@@ -583,15 +577,15 @@ export async function createMarkdownFixture(peer: RadiclePeer) {

  [ap]: https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet`;
  await peer.git(["commit", "-m", commitMessage], {
-
    cwd: projectFolder,
+
    cwd: repoFolder,
  });
-
  await peer.git(["push", "rad"], { cwd: projectFolder });
+
  await peer.git(["push", "rad"], { cwd: repoFolder });
  await issue.create(
    peer,
    "This `title` has **markdown**",
    'This is a description\n\nWith some multiline text.\n\n```\n23-11-06 10:19 ➜  radicle-jetbrains-plugin git:(main) rad id update --title "Godify jchrist" --description "where jchrist ascends to a god of this project" --delegate did:key:z6MkpaATbhkGbSMysNomYTFVvKG5bnNKYZ2cCamfoHzX9SnL --threshold 1\n\n✓ Identity revision 029837dde8f5c49704e50a19cd709473ac66a456 created\n```',
    ["bug", "feature-request"],
-
    { cwd: projectFolder },
+
    { cwd: repoFolder },
  );
}

@@ -613,7 +607,6 @@ export const markdownRid = "rad:z2tchH2Ti4LxRKdssPQYs6VHE5rsg";
export const sourceBrowsingUrl = `/nodes/127.0.0.1/${sourceBrowsingRid}`;
export const cobUrl = `/nodes/127.0.0.1/${cobRid}`;
export const markdownUrl = `/nodes/127.0.0.1/${markdownRid}`;
-
export const nodeRemote = "z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S";
export const shortNodeRemote = "z6MktU…1xB22S";
export const defaultHttpdPort = 8081;
export const gitOptions = {
deleted tests/support/project.ts
@@ -1,66 +0,0 @@
-
import type { Page } from "@playwright/test";
-
import type { RadiclePeer } from "@tests/support/peerManager";
-

-
import * as Path from "node:path";
-

-
export async function changeBranch(peer: string, branch: string, page: Page) {
-
  await page.getByTitle("Change branch").click();
-
  const peerLocator = page.getByLabel("peer-item").filter({ hasText: peer });
-
  await peerLocator.getByTitle("Expand peer").click();
-
  await page.getByRole("button", { name: branch }).click();
-
}
-

-
// Create a project using the rad CLI.
-
export async function createProject(
-
  peer: RadiclePeer,
-
  {
-
    name,
-
    description = "",
-
    defaultBranch = "main",
-
    visibility = "public",
-
  }: {
-
    name: string;
-
    description?: string;
-
    defaultBranch?: string;
-
    visibility?: "public" | "private";
-
  },
-
): Promise<{ rid: string; projectFolder: string; defaultBranch: string }> {
-
  const projectFolder = Path.join(peer.checkoutPath, name);
-

-
  await peer.git(["init", name, "--initial-branch", defaultBranch], {
-
    cwd: peer.checkoutPath,
-
  });
-
  await peer.git(["commit", "--allow-empty", "--message", "initial commit"], {
-
    cwd: projectFolder,
-
  });
-
  await peer.rad(
-
    [
-
      "init",
-
      "--name",
-
      name,
-
      "--default-branch",
-
      defaultBranch,
-
      "--description",
-
      description,
-
      `--${visibility}`,
-
    ],
-
    {
-
      cwd: projectFolder,
-
    },
-
  );
-

-
  const { stdout: rid } = await peer.rad(["inspect"], {
-
    cwd: projectFolder,
-
  });
-

-
  return { rid, projectFolder, defaultBranch };
-
}
-

-
export function extractPatchId(cmdOutput: { stderr: string }) {
-
  const match = cmdOutput.stderr.match(/[0-9a-f]{40}/);
-
  if (match) {
-
    return match[0];
-
  } else {
-
    throw new Error("Could not get patch id");
-
  }
-
}
modified tests/support/radicle-httpd-release
@@ -1 +1 @@
-
0.15.0

\ No newline at end of file
+
0.16.0

\ No newline at end of file
added tests/support/repo.ts
@@ -0,0 +1,66 @@
+
import type { Page } from "@playwright/test";
+
import type { RadiclePeer } from "@tests/support/peerManager";
+

+
import * as Path from "node:path";
+

+
export async function changeBranch(peer: string, branch: string, page: Page) {
+
  await page.getByTitle("Change branch").click();
+
  const peerLocator = page.getByLabel("peer-item").filter({ hasText: peer });
+
  await peerLocator.getByTitle("Expand peer").click();
+
  await page.getByRole("button", { name: branch }).click();
+
}
+

+
// Create a repo using the rad CLI.
+
export async function createRepo(
+
  peer: RadiclePeer,
+
  {
+
    name,
+
    description = "",
+
    defaultBranch = "main",
+
    visibility = "public",
+
  }: {
+
    name: string;
+
    description?: string;
+
    defaultBranch?: string;
+
    visibility?: "public" | "private";
+
  },
+
): Promise<{ rid: string; repoFolder: string; defaultBranch: string }> {
+
  const repoFolder = Path.join(peer.checkoutPath, name);
+

+
  await peer.git(["init", name, "--initial-branch", defaultBranch], {
+
    cwd: peer.checkoutPath,
+
  });
+
  await peer.git(["commit", "--allow-empty", "--message", "initial commit"], {
+
    cwd: repoFolder,
+
  });
+
  await peer.rad(
+
    [
+
      "init",
+
      "--name",
+
      name,
+
      "--default-branch",
+
      defaultBranch,
+
      "--description",
+
      description,
+
      `--${visibility}`,
+
    ],
+
    {
+
      cwd: repoFolder,
+
    },
+
  );
+

+
  const { stdout: rid } = await peer.rad(["inspect"], {
+
    cwd: repoFolder,
+
  });
+

+
  return { rid, repoFolder, defaultBranch };
+
}
+

+
export function extractPatchId(cmdOutput: { stderr: string }) {
+
  const match = cmdOutput.stderr.match(/[0-9a-f]{40}/);
+
  if (match) {
+
    return match[0];
+
  } else {
+
    throw new Error("Could not get patch id");
+
  }
+
}
modified tests/support/support.ts
@@ -11,9 +11,9 @@ export function randomTag(): string {
  return Crypto.randomBytes(8).toString("hex");
}

-
export function createOptions(projectFolder: string, days: number): Options {
+
export function createOptions(repoFolder: string, days: number): Options {
  return {
-
    cwd: projectFolder,
+
    cwd: repoFolder,
    // eslint-disable-next-line @typescript-eslint/naming-convention
    env: { RAD_LOCAL_TIME: (1671211684 + days * 86400).toString() },
  };
deleted tests/unit/projectRouter.test.ts
@@ -1,14 +0,0 @@
-
import { describe, expect, test } from "vitest";
-
import { testExports } from "@app/views/projects/router";
-

-
// Defining the window.origin value, since vitest doesn't provide one.
-
window.origin = "http://localhost:3000";
-

-
describe("isOid", () => {
-
  test.each([
-
    { oid: "a64ae9c6d572e0ad906faa9a4a7a8d43f113278c", expected: true },
-
    { oid: "a64ae9c", expected: false },
-
  ])("isOid $oid => $expected", ({ oid, expected }) => {
-
    expect(testExports.isOid(oid)).toEqual(expected);
-
  });
-
});
added tests/unit/repoRouter.test.ts
@@ -0,0 +1,14 @@
+
import { describe, expect, test } from "vitest";
+
import { testExports } from "@app/views/repos/router";
+

+
// Defining the window.origin value, since vitest doesn't provide one.
+
window.origin = "http://localhost:3000";
+

+
describe("isOid", () => {
+
  test.each([
+
    { oid: "a64ae9c6d572e0ad906faa9a4a7a8d43f113278c", expected: true },
+
    { oid: "a64ae9c", expected: false },
+
  ])("isOid $oid => $expected", ({ oid, expected }) => {
+
    expect(testExports.isOid(oid)).toEqual(expected);
+
  });
+
});
modified tests/unit/router.test.ts
@@ -19,35 +19,35 @@ describe("route invariant when parsed", () => {
      params: {
        // TODO: This only works with the value 0. The value is not actually
        // extract.
-
        projectPageIndex: 0,
+
        repoPageIndex: 0,
        baseUrl: node,
      },
    });
  });
-
  test("projects.tree", () => {
+
  test("repos.tree", () => {
    expectParsingInvariant({
-
      resource: "project.source",
+
      resource: "repo.source",
      node,
-
      project: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
+
      repo: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
      route: "",
    });
  });

-
  test("projects.tree with peer", () => {
+
  test("repos.tree with peer", () => {
    expectParsingInvariant({
-
      resource: "project.source",
+
      resource: "repo.source",
      node,
-
      project: "PROJECT",
+
      repo: "REPO",
      peer: "PEER",
      route: "",
    });
  });

-
  test("projects.tree with peer and revision", () => {
+
  test("repos.tree with peer and revision", () => {
    const route: Route = {
-
      resource: "project.source",
+
      resource: "repo.source",
      node,
-
      project: "PROJECT",
+
      repo: "REPO",
      peer: "PEER",
      revision: "REVISION",
      route: "",
@@ -58,11 +58,11 @@ describe("route invariant when parsed", () => {
    expect(testExports.urlToRoute(new URL(path, origin))).toEqual(route);
  });

-
  test("projects.tree with peer and revision and path", () => {
+
  test("repos.tree with peer and revision and path", () => {
    const route: Route = {
-
      resource: "project.source",
+
      resource: "repo.source",
      node,
-
      project: "PROJECT",
+
      repo: "REPO",
      peer: "PEER",
      path: "PATH",
      revision: "REVISION",
@@ -75,121 +75,121 @@ describe("route invariant when parsed", () => {
    expect(testExports.urlToRoute(new URL(path, origin))).toEqual(route);
  });

-
  test("projects.history", () => {
+
  test("repos.history", () => {
    expectParsingInvariant({
-
      resource: "project.history",
+
      resource: "repo.history",
      node,
-
      project: "PROJECT",
+
      repo: "REPO",
      revision: "",
    });
  });

-
  test("projects.history with revision", () => {
+
  test("repos.history with revision", () => {
    expectParsingInvariant({
-
      resource: "project.history",
+
      resource: "repo.history",
      node,
-
      project: "PROJECT",
+
      repo: "REPO",
      revision: "REVISION",
    });
  });

-
  test("projects.commits", () => {
+
  test("repos.commits", () => {
    expectParsingInvariant({
-
      resource: "project.commit",
+
      resource: "repo.commit",
      node,
-
      project: "PROJECT",
+
      repo: "REPO",
      commit: "COMMIT",
    });
  });

-
  test("projects.issues", () => {
+
  test("repos.issues", () => {
    expectParsingInvariant({
-
      resource: "project.issues",
+
      resource: "repo.issues",
      node,
-
      project: "PROJECT",
+
      repo: "REPO",
    });
  });

-
  test("projects.issues with status", () => {
+
  test("repos.issues with status", () => {
    expectParsingInvariant({
-
      resource: "project.issues",
+
      resource: "repo.issues",
      node,
-
      project: "PROJECT",
+
      repo: "REPO",
      status: "closed",
    });
  });

-
  test("projects.issue", () => {
+
  test("repos.issue", () => {
    expectParsingInvariant({
-
      resource: "project.issue",
+
      resource: "repo.issue",
      node,
-
      project: "PROJECT",
+
      repo: "REPO",
      issue: "ISSUE",
    });
  });

-
  test("projects.patches", () => {
+
  test("repos.patches", () => {
    expectParsingInvariant({
-
      resource: "project.patches",
+
      resource: "repo.patches",
      node,
-
      project: "PROJECT",
+
      repo: "REPO",
      search: "SEARCH",
    });
  });

-
  test("projects.patches with search", () => {
+
  test("repos.patches with search", () => {
    expectParsingInvariant({
-
      resource: "project.patches",
+
      resource: "repo.patches",
      node,
-
      project: "PROJECT",
+
      repo: "REPO",
      search: "SEARCH",
    });
  });

-
  test("projects.patch default view", () => {
+
  test("repos.patch default view", () => {
    expectParsingInvariant({
-
      resource: "project.patch",
+
      resource: "repo.patch",
      node,
-
      project: "PROJECT",
+
      repo: "REPO",
      patch: "PATCH",
    });
  });

-
  test("projects.patch activity", () => {
+
  test("repos.patch activity", () => {
    expectParsingInvariant({
-
      resource: "project.patch",
+
      resource: "repo.patch",
      node,
-
      project: "PROJECT",
+
      repo: "REPO",
      patch: "PATCH",
      view: { name: "activity" },
    });
  });

-
  test("projects.patch changes", () => {
+
  test("repos.patch changes", () => {
    expectParsingInvariant({
-
      resource: "project.patch",
+
      resource: "repo.patch",
      node,
-
      project: "PROJECT",
+
      repo: "REPO",
      patch: "PATCH",
      view: { name: "changes" },
    });
  });

-
  test("projects.patch changes with revision", () => {
+
  test("repos.patch changes with revision", () => {
    expectParsingInvariant({
-
      resource: "project.patch",
+
      resource: "repo.patch",
      node,
-
      project: "PROJECT",
+
      repo: "REPO",
      patch: "PATCH",
      view: { name: "changes", revision: "REVISION" },
    });
  });

-
  test("projects.patch diff", () => {
+
  test("repos.patch diff", () => {
    expectParsingInvariant({
-
      resource: "project.patch",
+
      resource: "repo.patch",
      node,
-
      project: "PROJECT",
+
      repo: "REPO",
      patch: "PATCH",
      view: {
        name: "diff",
@@ -219,44 +219,44 @@ describe("pathToRoute", () => {
          scheme: "http",
          port: defaultHttpdPort,
        },
-
        projectPageIndex: 0,
+
        repoPageIndex: 0,
      },
    });
  });

-
  test("project with trailing slash", () => {
+
  test("repo with trailing slash", () => {
    expectPathToRoute(
      "/nodes/example.node.tld/rad:zKtT7DmF9H34KkvcKj9PHW19WzjT/",
      {
-
        resource: "project.source",
+
        resource: "repo.source",
        node: {
          hostname: "example.node.tld",
          scheme: "http",
          port: defaultHttpdPort,
        },
-
        project: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
+
        repo: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
        route: "",
      },
    );
  });

-
  test("project without trailing slash", () => {
+
  test("repo without trailing slash", () => {
    expectPathToRoute(
      "/nodes/example.node.tld/rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
      {
-
        resource: "project.source",
+
        resource: "repo.source",
        node: {
          hostname: "example.node.tld",
          scheme: "http",
          port: defaultHttpdPort,
        },
-
        project: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
+
        repo: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
        route: "",
      },
    );
  });

-
  test("non-existent project route", () => {
+
  test("non-existent repo route", () => {
    expectPathToRoute(
      "/nodes/example.node.tld/rad:zKtT7DmF9H34KkvcKj9PHW19WzjT/nope",
      null,
modified tests/visual/desktop/cob.spec.ts
@@ -90,7 +90,7 @@ test("failed diff loading for a specific revision", async ({ page }) => {
  await page.route(
    ({ pathname }) =>
      pathname ===
-
      "/api/v1/projects/rad:z3fpY7nttPPa6MBnAv2DccHzQJnqe/diff/38c225e2a0b47ba59def211f4e4825c31d9463ec/9898da6155467adad511f63bf0fb5aa4156b92ef",
+
      "/api/v1/repos/rad:z3fpY7nttPPa6MBnAv2DccHzQJnqe/diff/38c225e2a0b47ba59def211f4e4825c31d9463ec/9898da6155467adad511f63bf0fb5aa4156b92ef",
    route => route.fulfill({ status: 500 }),
  );

modified tests/visual/desktop/landingPage.spec.ts
@@ -1,7 +1,7 @@
import { test, expect } from "@tests/support/fixtures.js";
import sinon from "sinon";

-
test("pinned projects", async ({ page }) => {
+
test("pinned repos", async ({ page }) => {
  await page.addInitScript(() => {
    localStorage.setItem(
      "configuredPreferredSeeds",
@@ -18,7 +18,7 @@ test("pinned projects", async ({ page }) => {
  await expect(page).toHaveScreenshot();
});

-
test("load projects error", async ({ page }) => {
+
test("load repos error", async ({ page }) => {
  await page.addInitScript(() => {
    localStorage.setItem(
      "configuredPreferredSeeds",
@@ -32,7 +32,7 @@ test("load projects error", async ({ page }) => {
  });

  await page.route(
-
    ({ pathname }) => pathname === "/api/v1/projects",
+
    ({ pathname }) => pathname === "/api/v1/repos",
    route => route.fulfill({ status: 500 }),
  );

@@ -47,7 +47,7 @@ test("response parse error", async ({ page }) => {
      JSON.stringify([{ hostname: "127.0.0.1", port: 8081, scheme: "http" }]),
    );
  });
-
  await page.route("*/**/v1/projects*", route => {
+
  await page.route("*/**/v1/repos*", route => {
    return route.fulfill({
      json: [{ name: 1337 }],
    });
@@ -63,7 +63,7 @@ test("response error", async ({ page }) => {
      JSON.stringify([{ hostname: "127.0.0.1", port: 8081, scheme: "http" }]),
    );
  });
-
  await page.route("*/**/v1/projects*", route => {
+
  await page.route("*/**/v1/repos*", route => {
    return route.fulfill({
      status: 500,
      body: "There is an error in the response",
modified tests/visual/desktop/node.spec.ts
@@ -14,10 +14,10 @@ test("node page", async ({ page }) => {
  await expect(page).toHaveScreenshot();
});

-
test("empty pinned projects", async ({ page }) => {
+
test("empty pinned repos", async ({ page }) => {
  await page.route(
    ({ hostname, pathname }) =>
-
      pathname === "/api/v1/projects" && hostname === "127.0.0.1",
+
      pathname === "/api/v1/repos" && hostname === "127.0.0.1",
    async route => {
      await route.fulfill({
        status: 200,
@@ -40,7 +40,7 @@ test("node not found", async ({ page }) => {
});

test("response parse error", async ({ page }) => {
-
  await page.route("*/**/v1/projects*", route => {
+
  await page.route("*/**/v1/repos*", route => {
    return route.fulfill({
      json: [{ name: 1337 }],
    });
@@ -53,7 +53,7 @@ test("response parse error", async ({ page }) => {
});

test("response error", async ({ page }) => {
-
  await page.route("*/**/v1/projects*", route => {
+
  await page.route("*/**/v1/repos*", route => {
    return route.fulfill({
      status: 500,
    });
deleted tests/visual/desktop/project.spec.ts
@@ -1,170 +0,0 @@
-
import {
-
  test,
-
  expect,
-
  cobUrl,
-
  sourceBrowsingUrl,
-
  aliceRemote,
-
  markdownUrl,
-
  sourceBrowsingRid,
-
} from "@tests/support/fixtures.js";
-
import sinon from "sinon";
-

-
test("source page", async ({ page }) => {
-
  await page.goto(sourceBrowsingUrl, { waitUntil: "networkidle" });
-
  await expect(page).toHaveScreenshot();
-
});
-

-
test("history page", async ({ page }) => {
-
  await page.addInitScript(() => {
-
    sinon.useFakeTimers({
-
      now: new Date("November 24 2022 12:00:00").valueOf(),
-
      shouldClearNativeTimers: true,
-
      shouldAdvanceTime: false,
-
    });
-
  });
-

-
  await page.goto(
-
    `${sourceBrowsingUrl}/remotes/${aliceRemote.substring(8)}/history`,
-
    {
-
      waitUntil: "networkidle",
-
    },
-
  );
-

-
  await expect(page).toHaveScreenshot({ fullPage: true });
-
});
-

-
test("commit page", async ({ page }) => {
-
  await page.addInitScript(() => {
-
    sinon.useFakeTimers({
-
      now: new Date("November 24 2022 12:00:00").valueOf(),
-
      shouldClearNativeTimers: true,
-
      shouldAdvanceTime: false,
-
    });
-
  });
-

-
  await page.goto(
-
    `${sourceBrowsingUrl}/remotes/${aliceRemote.substring(
-
      8,
-
    )}/commits/1aded56c3ad55299df9f06c326af50b802a05949`,
-
  );
-
  await expect(page.getByText("subconscious.txt added")).toBeVisible();
-
  await expect(page).toHaveScreenshot({ fullPage: true });
-
});
-

-
test("diff selection", async ({ page }) => {
-
  await page.addInitScript(() => {
-
    sinon.useFakeTimers({
-
      now: new Date("November 24 2022 12:00:00").valueOf(),
-
      shouldClearNativeTimers: true,
-
      shouldAdvanceTime: false,
-
    });
-
  });
-

-
  await page.goto(`${cobUrl}/patches`);
-
  await page
-
    .getByRole("link", { name: "Taking another stab at the README" })
-
    .click();
-
  await page.getByRole("link", { name: "Changes" }).click();
-
  await page
-
    .getByRole("row", { name: "- # Cobs Repo" })
-
    .locator("div")
-
    .first()
-
    .click();
-
  await page.keyboard.down("Shift");
-
  await page
-
    .getByRole("row", { name: "+ ## Better?" })
-
    .locator("div")
-
    .first()
-
    .click();
-
  await page.keyboard.up("Shift");
-
  await expect(page).toHaveScreenshot({ fullPage: true });
-
});
-

-
test("project load error", async ({ page }) => {
-
  await page.goto(
-
    `${sourceBrowsingUrl}/remotes/zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz`,
-
    { waitUntil: "networkidle" },
-
  );
-
  await expect(page).toHaveScreenshot();
-
});
-

-
test("project not found", async ({ page }) => {
-
  await page.goto(`/nodes/127.0.0.1/rad:z4Vzzzzzzzzzzzzzzzzzzzzzzzzzz`, {
-
    waitUntil: "networkidle",
-
  });
-
  await expect(page).toHaveScreenshot();
-
});
-

-
test("response parse error", async ({ page }) => {
-
  await page.route(
-
    ({ pathname }) => pathname === `/api/v1/projects/${sourceBrowsingRid}`,
-
    route => {
-
      return route.fulfill({
-
        json: [{ name: 1337 }],
-
      });
-
    },
-
  );
-
  await page.goto(sourceBrowsingUrl, { waitUntil: "networkidle" });
-
  await expect(page).toHaveScreenshot();
-
});
-

-
test("response error", async ({ page }) => {
-
  await page.route(
-
    ({ pathname }) => pathname === `/api/v1/projects/${sourceBrowsingRid}`,
-
    route => {
-
      return route.fulfill({
-
        status: 500,
-
      });
-
    },
-
  );
-
  await page.goto(sourceBrowsingUrl, { waitUntil: "networkidle" });
-
  await expect(page).toHaveScreenshot();
-
});
-

-
test("readme not found", async ({ page }) => {
-
  await page.route(
-
    ({ pathname }) =>
-
      pathname ===
-
      `http://127.0.0.1:8081/api/v1/projects/${sourceBrowsingRid}/readme/f591f9c3d842fdfb9e170e0f467189c6d9e950a2`,
-
    route => {
-
      return route.fulfill({
-
        status: 500,
-
      });
-
    },
-
  );
-
  await page.goto(`${markdownUrl}/tree`, {
-
    waitUntil: "networkidle",
-
  });
-
  await expect(page).toHaveScreenshot();
-
});
-

-
test("file not found", async ({ page }) => {
-
  await page.goto(`${sourceBrowsingUrl}/tree/this.file.does.not.exist`, {
-
    waitUntil: "networkidle",
-
  });
-
  await expect(page).toHaveScreenshot();
-
});
-

-
test("commit not found", async ({ page }) => {
-
  await page.goto(
-
    `${sourceBrowsingUrl}/commits/0000000000000000000000000000000000000000`,
-
    { waitUntil: "networkidle" },
-
  );
-
  await expect(page).toHaveScreenshot();
-
});
-

-
test("issue not found", async ({ page }) => {
-
  await page.goto(
-
    `${sourceBrowsingUrl}/issues/0000000000000000000000000000000000000000`,
-
    { waitUntil: "networkidle" },
-
  );
-
  await expect(page).toHaveScreenshot();
-
});
-

-
test("patch not found", async ({ page }) => {
-
  await page.goto(
-
    `${sourceBrowsingUrl}/patches/0000000000000000000000000000000000000000`,
-
    { waitUntil: "networkidle" },
-
  );
-
  await expect(page).toHaveScreenshot();
-
});
added tests/visual/desktop/repo.spec.ts
@@ -0,0 +1,170 @@
+
import {
+
  test,
+
  expect,
+
  cobUrl,
+
  sourceBrowsingUrl,
+
  aliceRemote,
+
  markdownUrl,
+
  sourceBrowsingRid,
+
} from "@tests/support/fixtures.js";
+
import sinon from "sinon";
+

+
test("source page", async ({ page }) => {
+
  await page.goto(sourceBrowsingUrl, { waitUntil: "networkidle" });
+
  await expect(page).toHaveScreenshot();
+
});
+

+
test("history page", async ({ page }) => {
+
  await page.addInitScript(() => {
+
    sinon.useFakeTimers({
+
      now: new Date("November 24 2022 12:00:00").valueOf(),
+
      shouldClearNativeTimers: true,
+
      shouldAdvanceTime: false,
+
    });
+
  });
+

+
  await page.goto(
+
    `${sourceBrowsingUrl}/remotes/${aliceRemote.substring(8)}/history`,
+
    {
+
      waitUntil: "networkidle",
+
    },
+
  );
+

+
  await expect(page).toHaveScreenshot({ fullPage: true });
+
});
+

+
test("commit page", async ({ page }) => {
+
  await page.addInitScript(() => {
+
    sinon.useFakeTimers({
+
      now: new Date("November 24 2022 12:00:00").valueOf(),
+
      shouldClearNativeTimers: true,
+
      shouldAdvanceTime: false,
+
    });
+
  });
+

+
  await page.goto(
+
    `${sourceBrowsingUrl}/remotes/${aliceRemote.substring(
+
      8,
+
    )}/commits/1aded56c3ad55299df9f06c326af50b802a05949`,
+
  );
+
  await expect(page.getByText("subconscious.txt added")).toBeVisible();
+
  await expect(page).toHaveScreenshot({ fullPage: true });
+
});
+

+
test("diff selection", async ({ page }) => {
+
  await page.addInitScript(() => {
+
    sinon.useFakeTimers({
+
      now: new Date("November 24 2022 12:00:00").valueOf(),
+
      shouldClearNativeTimers: true,
+
      shouldAdvanceTime: false,
+
    });
+
  });
+

+
  await page.goto(`${cobUrl}/patches`);
+
  await page
+
    .getByRole("link", { name: "Taking another stab at the README" })
+
    .click();
+
  await page.getByRole("link", { name: "Changes" }).click();
+
  await page
+
    .getByRole("row", { name: "- # Cobs Repo" })
+
    .locator("div")
+
    .first()
+
    .click();
+
  await page.keyboard.down("Shift");
+
  await page
+
    .getByRole("row", { name: "+ ## Better?" })
+
    .locator("div")
+
    .first()
+
    .click();
+
  await page.keyboard.up("Shift");
+
  await expect(page).toHaveScreenshot({ fullPage: true });
+
});
+

+
test("repo load error", async ({ page }) => {
+
  await page.goto(
+
    `${sourceBrowsingUrl}/remotes/zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz`,
+
    { waitUntil: "networkidle" },
+
  );
+
  await expect(page).toHaveScreenshot();
+
});
+

+
test("repo not found", async ({ page }) => {
+
  await page.goto(`/nodes/127.0.0.1/rad:z4Vzzzzzzzzzzzzzzzzzzzzzzzzzz`, {
+
    waitUntil: "networkidle",
+
  });
+
  await expect(page).toHaveScreenshot();
+
});
+

+
test("response parse error", async ({ page }) => {
+
  await page.route(
+
    ({ pathname }) => pathname === `/api/v1/repos/${sourceBrowsingRid}`,
+
    route => {
+
      return route.fulfill({
+
        json: [{ name: 1337 }],
+
      });
+
    },
+
  );
+
  await page.goto(sourceBrowsingUrl, { waitUntil: "networkidle" });
+
  await expect(page).toHaveScreenshot();
+
});
+

+
test("response error", async ({ page }) => {
+
  await page.route(
+
    ({ pathname }) => pathname === `/api/v1/repos/${sourceBrowsingRid}`,
+
    route => {
+
      return route.fulfill({
+
        status: 500,
+
      });
+
    },
+
  );
+
  await page.goto(sourceBrowsingUrl, { waitUntil: "networkidle" });
+
  await expect(page).toHaveScreenshot();
+
});
+

+
test("readme not found", async ({ page }) => {
+
  await page.route(
+
    ({ pathname }) =>
+
      pathname ===
+
      `http://127.0.0.1:8081/api/v1/repos/${sourceBrowsingRid}/readme/f591f9c3d842fdfb9e170e0f467189c6d9e950a2`,
+
    route => {
+
      return route.fulfill({
+
        status: 500,
+
      });
+
    },
+
  );
+
  await page.goto(`${markdownUrl}/tree`, {
+
    waitUntil: "networkidle",
+
  });
+
  await expect(page).toHaveScreenshot();
+
});
+

+
test("file not found", async ({ page }) => {
+
  await page.goto(`${sourceBrowsingUrl}/tree/this.file.does.not.exist`, {
+
    waitUntil: "networkidle",
+
  });
+
  await expect(page).toHaveScreenshot();
+
});
+

+
test("commit not found", async ({ page }) => {
+
  await page.goto(
+
    `${sourceBrowsingUrl}/commits/0000000000000000000000000000000000000000`,
+
    { waitUntil: "networkidle" },
+
  );
+
  await expect(page).toHaveScreenshot();
+
});
+

+
test("issue not found", async ({ page }) => {
+
  await page.goto(
+
    `${sourceBrowsingUrl}/issues/0000000000000000000000000000000000000000`,
+
    { waitUntil: "networkidle" },
+
  );
+
  await expect(page).toHaveScreenshot();
+
});
+

+
test("patch not found", async ({ page }) => {
+
  await page.goto(
+
    `${sourceBrowsingUrl}/patches/0000000000000000000000000000000000000000`,
+
    { waitUntil: "networkidle" },
+
  );
+
  await expect(page).toHaveScreenshot();
+
});
modified tests/visual/desktop/user.spec.ts
@@ -21,7 +21,7 @@ test("user page", async ({ page }) => {
  await expect(page).toHaveScreenshot();
});

-
test("empty pinned projects", async ({ page }) => {
+
test("empty pinned repos", async ({ page }) => {
  await page.goto(`/nodes/radicle.local/users/${bobRemote}`, {
    waitUntil: "networkidle",
  });
deleted tests/visual/mobile/project.spec.ts
@@ -1,49 +0,0 @@
-
import {
-
  aliceRemote,
-
  expect,
-
  sourceBrowsingUrl,
-
  test,
-
} from "@tests/support/fixtures.js";
-
import sinon from "sinon";
-

-
test("source tree page", async ({ page }) => {
-
  await page.goto(sourceBrowsingUrl, { waitUntil: "networkidle" });
-
  await expect(page).toHaveScreenshot();
-
});
-

-
test("commits page", async ({ page }) => {
-
  await page.addInitScript(() => {
-
    sinon.useFakeTimers({
-
      now: new Date("November 24 2022 12:00:00").valueOf(),
-
      shouldClearNativeTimers: true,
-
      shouldAdvanceTime: false,
-
    });
-
  });
-

-
  await page.goto(
-
    `${sourceBrowsingUrl}/remotes/${aliceRemote.substring(8)}/history`,
-
    {
-
      waitUntil: "networkidle",
-
    },
-
  );
-

-
  await expect(page).toHaveScreenshot({ fullPage: true });
-
});
-

-
test("commit page", async ({ page }) => {
-
  await page.addInitScript(() => {
-
    sinon.useFakeTimers({
-
      now: new Date("November 24 2022 12:00:00").valueOf(),
-
      shouldClearNativeTimers: true,
-
      shouldAdvanceTime: false,
-
    });
-
  });
-

-
  await page.goto(
-
    `${sourceBrowsingUrl}/remotes/${aliceRemote.substring(
-
      8,
-
    )}/commits/1aded56c3ad55299df9f06c326af50b802a05949`,
-
  );
-
  await expect(page.getByText("subconscious.txt added")).toBeVisible();
-
  await expect(page).toHaveScreenshot({ fullPage: true });
-
});
added tests/visual/mobile/repo.spec.ts
@@ -0,0 +1,49 @@
+
import {
+
  aliceRemote,
+
  expect,
+
  sourceBrowsingUrl,
+
  test,
+
} from "@tests/support/fixtures.js";
+
import sinon from "sinon";
+

+
test("source tree page", async ({ page }) => {
+
  await page.goto(sourceBrowsingUrl, { waitUntil: "networkidle" });
+
  await expect(page).toHaveScreenshot();
+
});
+

+
test("commits page", async ({ page }) => {
+
  await page.addInitScript(() => {
+
    sinon.useFakeTimers({
+
      now: new Date("November 24 2022 12:00:00").valueOf(),
+
      shouldClearNativeTimers: true,
+
      shouldAdvanceTime: false,
+
    });
+
  });
+

+
  await page.goto(
+
    `${sourceBrowsingUrl}/remotes/${aliceRemote.substring(8)}/history`,
+
    {
+
      waitUntil: "networkidle",
+
    },
+
  );
+

+
  await expect(page).toHaveScreenshot({ fullPage: true });
+
});
+

+
test("commit page", async ({ page }) => {
+
  await page.addInitScript(() => {
+
    sinon.useFakeTimers({
+
      now: new Date("November 24 2022 12:00:00").valueOf(),
+
      shouldClearNativeTimers: true,
+
      shouldAdvanceTime: false,
+
    });
+
  });
+

+
  await page.goto(
+
    `${sourceBrowsingUrl}/remotes/${aliceRemote.substring(
+
      8,
+
    )}/commits/1aded56c3ad55299df9f06c326af50b802a05949`,
+
  );
+
  await expect(page.getByText("subconscious.txt added")).toBeVisible();
+
  await expect(page).toHaveScreenshot({ fullPage: true });
+
});
modified tests/visual/mobile/user.spec.ts
@@ -21,7 +21,7 @@ test("user page", async ({ page }) => {
  await expect(page).toHaveScreenshot();
});

-
test("empty pinned projects", async ({ page }) => {
+
test("empty pinned repos", async ({ page }) => {
  await page.goto(`/nodes/radicle.local/users/${bobRemote}`, {
    waitUntil: "networkidle",
  });