Files
radicle-explorer/src/project.ts
T
Rūdolfs Ošiņš d0edc52831 Refactor search logic
- type events that are handed between components
- simplify the `resolve` function interface
  - before we had return values and exceptions, now just a single union
    type as return value
  - move navigation to the Search component to reduce its
    responsibilities
  - flatten and simplify multi-level branching
- shuffle around code
  - move the `resolve` function to the `Search` component module,
    because it's only used there

Signed-off-by: Rūdolfs Ošiņš <rudolfs@osins.org>
2022-09-29 14:50:13 +02:00

505 lines
11 KiB
TypeScript

import { navigate } from "svelte-routing";
import { get, writable } from "svelte/store";
import { type Host, Request } from "@app/api";
import type { Commit, CommitHeader, CommitsHistory } from "@app/commit";
import { isFulfilled, isOid, isRadicleId } from "@app/utils";
import { Profile, ProfileType } from "@app/profile";
import { Seed } from "@app/base/seeds/Seed";
import type { Config } from "@app/config";
export type Urn = string;
export type PeerId = string;
export type Branches = { [key: string]: string };
export type Delegate =
| {
type: "indirect";
urn: Urn;
ids: PeerId[];
}
| {
type: "direct";
id: PeerId;
};
// Enumerates the space below the Header component in the projects View component
export enum ProjectContent {
Tree,
History,
Commit,
Issues,
Issue,
Patches,
Patch,
}
export interface ProjectInfo {
head: string | null;
urn: string;
name: string;
description: string;
defaultBranch: string;
delegates: Delegate[];
remotes: PeerId[];
patches?: number;
issues?: number;
}
export interface Tree {
path: string;
info: EntryInfo;
entries: Array<Entry>;
stats: Stats;
}
export interface Stats {
commits: number;
contributors: number;
}
export enum ObjectType {
Blob = "BLOB",
Tree = "TREE",
}
export interface EntryInfo {
name: string;
objectType: ObjectType;
lastCommit: CommitHeader;
}
export interface Entry {
path: string;
info: EntryInfo;
}
export interface Blob {
binary?: boolean;
html?: boolean;
content: string;
path: string;
info: EntryInfo;
}
export interface Remote {
heads: Branches;
}
export interface Person {
name: string;
}
export interface Peer {
id: PeerId;
person?: Person;
delegate: boolean;
}
export interface Browser {
content: ProjectContent;
revision: string | null;
issue: string | null;
patch: string | null;
peer: string | null;
path: string | null;
line: number | null;
search: URLSearchParams | null;
}
export const browserStore = writable({
content: ProjectContent.Tree,
branches: {},
revision: null,
issue: null,
patch: null,
peer: null,
path: null,
line: null,
search: null,
} as Browser);
export interface BrowseTo {
content?: ProjectContent;
revision?: string | null;
issue?: string | null;
patch?: string | null;
path?: string | null;
peer?: string | null;
line?: number | null;
search?: URLSearchParams | null;
}
export interface PathOptions extends BrowseTo {
urn: string;
profile?: string | null;
seed?: string | null;
}
export function browse(browse: BrowseTo): void {
const browser = get(browserStore);
browserStore.set({ ...browser, ...browse });
}
export function path(opts: PathOptions): string {
const { urn, profile, seed, peer, content, revision, path, issue, patch } =
opts;
const result = [];
if (profile) {
result.push(profile);
} else if (seed) {
result.push("seeds", seed);
}
result.push(urn);
if (peer) {
result.push("remotes", peer);
}
switch (content) {
case ProjectContent.History:
result.push("history");
break;
case ProjectContent.Commit:
result.push("commits");
break;
case ProjectContent.Issues:
result.push("issues");
break;
case ProjectContent.Issue:
result.push("issues");
break;
case ProjectContent.Patches:
result.push("patches");
break;
case ProjectContent.Patch:
result.push("patches");
break;
default:
result.push("tree");
break;
}
if (issue) {
result.push(issue);
}
if (patch) {
result.push(patch);
}
if (revision) {
result.push(revision);
}
// Avoids appending a slash when the path is the root directory.
if (path && path !== "/") {
result.push(path);
}
return "/" + result.join("/");
}
// We need a SHA1 commit in some places, so we return early if the revision is a SHA and else we look into branches.
export function getOid(revision: string, branches?: Branches): string | null {
if (isOid(revision)) return revision;
if (branches) {
const oid = branches[revision];
if (oid) {
return oid;
}
}
return null;
}
// Parses the path consisting of a revision (eg. branch or commit) and file path into a tuple [revision, file-path]
export function parseRoute(
input: string,
branches: Branches,
): { path?: string; revision?: string } {
const branch = Object.entries(branches).find(([branchName]) =>
input.startsWith(branchName),
);
const commitPath = [input.slice(0, 40), input.slice(41)];
const parsed: { path?: string; revision?: string } = {};
if (branch) {
const [rev, path] = [
input.slice(0, branch[0].length),
input.slice(branch[0].length + 1),
];
parsed.revision = rev;
parsed.path = path ? path : "/";
} else if (isOid(commitPath[0])) {
parsed.revision = commitPath[0];
parsed.path = commitPath[1] ? commitPath[1] : "/";
} else {
parsed.path = input;
}
return parsed;
}
export class Project implements ProjectInfo {
urn: string;
head: string | null;
name: string;
description: string;
defaultBranch: string;
delegates: Delegate[];
remotes: PeerId[];
seed: Seed;
peers: Peer[];
branches: Branches;
profile: Profile | null;
// At the moment we still have seed nodes which won't return neither patches or issues
patches?: number;
issues?: number;
constructor(
urn: string,
info: ProjectInfo,
seed: Seed,
peers: Peer[],
branches: Branches,
profile: Profile | null,
) {
this.urn = urn;
this.head = info.head;
this.name = info.name;
this.description = info.description;
this.defaultBranch = info.defaultBranch;
this.delegates = info.delegates;
this.remotes = info.remotes;
this.seed = seed;
this.peers = peers;
this.branches = branches;
this.patches = info.patches;
this.issues = info.issues;
this.profile = profile;
}
async getRoot(
revision: string | null,
): Promise<{ tree: Tree; commit: string }> {
const head = this.branches[this.defaultBranch];
const commit = revision ? getOid(revision, this.branches) : head;
if (!commit) {
throw new Error(`Revision ${revision} not found`);
}
const tree = await this.getTree(commit, "/");
return { tree, commit };
}
static async getInfo(nameOrUrn: string, host: Host): Promise<ProjectInfo> {
const info = await new Request(`projects/${nameOrUrn}`, host).get();
return {
...info,
...info.meta, // Nb. This is only needed while we are upgrading to the new http-api.
};
}
static async getProjects(
host: Host,
opts?: {
perPage?: number;
page?: number;
},
): Promise<ProjectInfo[]> {
const params: Record<string, any> = {
"per-page": opts?.perPage,
page: opts?.page,
};
return new Request("projects", host).get(params);
}
static async getDelegateProjects(
delegate: string,
host: Host,
opts?: {
perPage?: number;
page?: number;
},
): Promise<ProjectInfo[]> {
const params: Record<string, any> = {
"per-page": opts?.perPage,
page: opts?.page,
};
return new Request(`delegates/${delegate}/projects`, host).get(params);
}
static async getRemote(
urn: string,
peer: string,
host: Host,
): Promise<Remote> {
return new Request(`projects/${urn}/remotes/${peer}`, host).get();
}
static async getRemotes(urn: string, host: Host): Promise<Peer[]> {
return new Request(`projects/${urn}/remotes`, host).get();
}
static async getCommits(
urn: string,
host: Host,
opts?: {
parent?: string | null;
since?: string;
until?: string;
perPage?: number;
page?: number;
verified?: boolean;
},
): Promise<CommitsHistory> {
const params: Record<string, any> = {
parent: opts?.parent,
since: opts?.since,
until: opts?.until,
"per-page": opts?.perPage,
page: opts?.page,
verified: opts?.verified,
};
return new Request(`projects/${urn}/commits`, host).get(params);
}
static async getActivity(
urn: string,
host: Host,
): Promise<{ activity: number[] }> {
return new Request(`projects/${urn}/activity`, host).get();
}
async getCommit(commit: string): Promise<Commit> {
return new Request(
`projects/${this.urn}/commits/${commit}`,
this.seed.api,
).get();
}
async getTree(commit: string, path: string): Promise<Tree> {
if (path === "/") path = "";
return new Request(
`projects/${this.urn}/tree/${commit}/${path}`,
this.seed.api,
).get();
}
async getBlob(
commit: string,
path: string,
options:
| { highlight: false }
| { highlight: true; theme: "base16-ocean.dark" | "base16-ocean.light" },
): Promise<Blob> {
return new Request(
`projects/${this.urn}/blob/${commit}/${path}`,
this.seed.api,
).get(options);
}
async getReadme(commit: string): Promise<Blob> {
return new Request(
`projects/${this.urn}/readme/${commit}`,
this.seed.api,
).get();
}
navigateTo(browse: BrowseTo): void {
navigate(this.pathTo(browse));
}
pathTo(browse: BrowseTo): string {
const browser = get(browserStore);
const options: PathOptions = {
urn: this.urn,
...browser,
...browse,
};
if (this.profile) {
options.profile = this.profile?.nameOrAddress;
} else {
options.seed = this.seed.host;
}
return path(options);
}
static async get(
id: string,
peer: string | null,
profileName: string | null,
seedHost: string | null,
config: Config,
): Promise<Project> {
const profile = profileName
? await Profile.get(profileName, ProfileType.Project, config)
: null;
const seed = profile
? profile.seed
: seedHost
? await Seed.lookup(seedHost, config)
: null;
if (!profile && !seed) {
throw new Error("Couldn't load project");
}
if (!seed?.valid) {
throw new Error("Couldn't load project: invalid seed");
}
const info = await Project.getInfo(id, seed.api);
const urn = isRadicleId(id) ? id : info.urn;
// Older versions of http-api don't include the URN.
if (!info.urn) info.urn = urn;
const peers: Peer[] = info.delegates
? await Project.getRemotes(urn, seed.api)
: [];
let remote: Remote = {
heads: info.head ? { [info.defaultBranch]: info.head } : {},
};
if (peer) {
try {
remote = await Project.getRemote(urn, peer, seed.api);
} catch {
remote.heads = {};
}
}
return new Project(urn, info, seed, peers, remote.heads, profile);
}
static async getMulti(
projs: { nameOrUrn: Urn; seed: string }[],
): Promise<{ info: ProjectInfo; seed: Host }[]> {
const promises = [];
for (const proj of projs) {
const seed = { host: proj.seed, port: null };
promises.push(
Project.getInfo(proj.nameOrUrn, seed).then(info => {
return { info, seed };
}),
);
}
const results = await Promise.allSettled(promises);
return results.filter(isFulfilled).map(r => r.value);
}
}