feat(repository): make it possible to browse folders and see file contents@@ -1,5 +1,5 @@
{
- "_generatedAtUnix": 1663685370478,
+ "_generatedAtUnix": 1663688911068,
"_hashAlgorithm": "sha1",
"_version": 2,
"islands": {
@@ -22,7 +22,7 @@
"pathSourceMap": "./public/.islands/RepositoryInitialSetup.bundle.js.map"
},
"RepositoryTreeView": {
- "hash": "e5a3555080f0a865e31011f45a376c4a0e78a2c7",
+ "hash": "d95f43002e5bcb46f965c529b3306eca87a533b2",
"pathSource": "./app/islands/RepositoryTreeView.tsx",
"pathBundle": "./public/.islands/RepositoryTreeView.bundle.js",
"pathSourceMap": "./public/.islands/RepositoryTreeView.bundle.js.map"
@@ -55,12 +55,16 @@
"hash": "3e2c7053b529624ed6cfa9ea8631b4480bd9775b",
"pathSource": "./app/views/auth/RegisterView.tsx"
},
+ "RepositoryBrowserView": {
+ "hash": "49cc11b5e655c6b7d7bf4f36a36226160613939b",
+ "pathSource": "./app/views/repository/RepositoryBrowserView.tsx"
+ },
"RepositoryCreateView": {
"hash": "cb01e6394094a287f6084a43d556277abbbc5b05",
"pathSource": "./app/views/repository/RepositoryCreateView.tsx"
},
"RepositoryDetailsView": {
- "hash": "43ee50554a5b3adaf6ab010a62816f4bb9d32d68",
+ "hash": "c8490681d1af914b28aeaee4d454260cb3f1bfbb",
"pathSource": "./app/views/repository/RepositoryDetailsView.tsx"
},
"RepositoryExploreView": {
@@ -0,0 +1,78 @@
+// 1st-party
+import type { ReqHandler } from "@ethicdevs/react-monolith";
+// app
+import { AppRoute, AppRoutesParams } from "../../routes";
+// app services
+import { makeOrganizationService } from "../../services/organization";
+import { makeRepositoryService } from "../../services/repository";
+import { makeUsersService } from "../../services/user";
+// app views
+import RepositoryBrowserView, {
+ RepositoryBrowserViewProps,
+} from "../../views/repository/RepositoryBrowserView";
+import RepositoryDetailsView, {
+ RepositoryDetailsViewProps,
+} from "../../views/repository/RepositoryDetailsView";
+
+const getRepositoryBrowserView: ReqHandler = async (request, reply) => {
+ const params =
+ request.params as AppRoutesParams[AppRoute.REPOSITORY_BROWSER]["params"];
+ const { orgSlug, repoSlug, ref } = params;
+ const path = params["*"];
+
+ const orgService = makeOrganizationService({ request });
+ const repoService = makeRepositoryService({ request });
+ const usersService = makeUsersService({ request });
+
+ const currentUser =
+ request.session.data.authenticated &&
+ request.session.data.curr_user_uid != null
+ ? await usersService.getUserById(request.session.data.curr_user_uid)
+ : null;
+
+ const parentOrg = await orgService.getOrganizationBySlug(orgSlug);
+ const repo = await repoService.getRepository(orgSlug, repoSlug);
+
+ if (repo == null) {
+ return reply.status(404).callNotFound();
+ }
+
+ const reqHandler = reply.makeRequestHandler(request, reply);
+
+ if (path.endsWith("/")) {
+ return reqHandler<RepositoryDetailsViewProps>(RepositoryDetailsView.name, {
+ currentUser,
+ cloneUrl: {
+ http: await repoService.getRepositoryHTTPCloneUrl(repo),
+ ssh: await repoService.getRepositorySSHCloneUrl(repo),
+ },
+ parentOrg,
+ path,
+ ref,
+ repo,
+ repoHead: await repoService.getRepositoryHead(repo, ref),
+ repoFiles: await repoService.getRepositoryFiles(repo, path, ref),
+ });
+ }
+
+ const fileContent = await repoService.getRepositoryFileContent(
+ repo,
+ path,
+ ref
+ );
+
+ if (fileContent == null) {
+ return reply.status(404).callNotFound();
+ }
+
+ return reqHandler<RepositoryBrowserViewProps>(RepositoryBrowserView.name, {
+ currentUser,
+ fileContent,
+ parentOrg,
+ path,
+ ref,
+ repo,
+ });
+};
+
+export default getRepositoryBrowserView;
@@ -1,5 +1,5 @@
// 1st-party
-import { ReqHandler } from "@ethicdevs/react-monolith";
+import type { ReqHandler } from "@ethicdevs/react-monolith";
// app
import { AppRoute } from "../../routes";
import { makeUsersService } from "../../services/user";
@@ -1,5 +1,5 @@
// 1st-party
-import { ReqHandler } from "@ethicdevs/react-monolith";
+import type { ReqHandler } from "@ethicdevs/react-monolith";
// app
import { AppRoute, AppRoutesParams } from "../../routes";
// app services
@@ -26,6 +26,7 @@ const getRepositoryDetailsView: ReqHandler = async (request, reply) => {
: null;
const parentOrg = await orgService.getOrganizationBySlug(orgSlug);
+ const path = "/";
const ref = "HEAD";
const repo = await repoService.getRepository(orgSlug, repoSlug);
if (repo == null) {
@@ -42,10 +43,11 @@ const getRepositoryDetailsView: ReqHandler = async (request, reply) => {
ssh: await repoService.getRepositorySSHCloneUrl(repo),
},
parentOrg,
+ path,
ref,
repo,
repoHead: await repoService.getRepositoryHead(repo, ref),
- repoFiles: await repoService.getRepositoryFiles(repo, ref),
+ repoFiles: await repoService.getRepositoryFiles(repo, "", ref),
});
} catch (err) {
const error = err as Error;
@@ -59,6 +61,7 @@ const getRepositoryDetailsView: ReqHandler = async (request, reply) => {
ssh: await repoService.getRepositorySSHCloneUrl(repo),
},
parentOrg,
+ path,
ref,
repo,
repoHead: null,
@@ -1,5 +1,5 @@
// 1st-party
-import { ReqHandler } from "@ethicdevs/react-monolith";
+import type { ReqHandler } from "@ethicdevs/react-monolith";
// app
import { makeRepositoryService } from "../../services/repository";
// app views
@@ -1,9 +1,11 @@
+import { default as getRepositoryBrowserView } from "./getRepositoryBrowserView";
import { default as getRepositoryCreateView } from "./getRepositoryCreateView";
import { default as getRepositoryDetailsView } from "./getRepositoryDetailsView";
import { default as getRepositoryExploreView } from "./getRepositoryExploreView";
import { default as postRepositoryCreateAction } from "./postRepositoryCreateAction";
export const RepositoryController = {
+ getRepositoryBrowserView,
getRepositoryCreateView,
getRepositoryDetailsView,
getRepositoryExploreView,
@@ -29,7 +29,9 @@ const RepositoryTreeView: ReactIsland<RepositoryTreeViewProps> = ({
href:
currPath === "/"
? `/${orgSlug}/${repoSlug}/main/tree/${fileName}`
- : `/${orgSlug}/${repoSlug}/main/tree/${currPath}/${fileName}`,
+ : `/${orgSlug}/${repoSlug}/main/tree/${
+ currPath.endsWith("/") ? currPath : `${currPath}/`
+ }${fileName}`,
};
},
[orgSlug, repoSlug, currPath]
@@ -27,6 +27,7 @@ export enum AppRoute {
REPOSITORY_CREATE = "repository.create",
REPOSITORY_CREATE_ACTION = "repository.create.action",
REPOSITORY_DETAILS = "repository.details",
+ REPOSITORY_BROWSER = "repository.browser",
}
export interface AppRoutesParams extends IRouteParams {
@@ -74,6 +75,14 @@ export interface AppRoutesParams extends IRouteParams {
repoSlug: string;
};
};
+ [AppRoute.REPOSITORY_BROWSER]: {
+ params: {
+ orgSlug: string;
+ repoSlug: string;
+ ref: string;
+ "*": string;
+ };
+ };
}
export const AppRoutesSchemas: Record<AppRoute, undefined | FastifySchema> = {
@@ -199,6 +208,27 @@ export const AppRoutesSchemas: Record<AppRoute, undefined | FastifySchema> = {
},
},
},
+ [AppRoute.REPOSITORY_BROWSER]: {
+ params: {
+ type: "object",
+ required: ["orgSlug", "repoSlug", "ref", "*"],
+ additionalProperties: false,
+ properties: {
+ orgSlug: {
+ type: "string",
+ },
+ repoSlug: {
+ type: "string",
+ },
+ ref: {
+ type: "string",
+ },
+ "*": {
+ type: "string",
+ },
+ },
+ },
+ },
};
const RootAppRouter: AppRouter = () => {
@@ -300,6 +330,13 @@ const RootAppRouter: AppRouter = () => {
schema={AppRoutesSchemas[AppRoute.REPOSITORY_DETAILS]}
handler={RepositoryController.getRepositoryDetailsView}
/>
+ <Router.Route
+ name={AppRoute.REPOSITORY_BROWSER}
+ method={"GET"}
+ path={"/:orgSlug/:repoSlug/:ref/tree/*"}
+ schema={AppRoutesSchemas[AppRoute.REPOSITORY_BROWSER]}
+ handler={RepositoryController.getRepositoryBrowserView}
+ />
</Router.Group>
</Router.Root>
);
@@ -0,0 +1,73 @@
+// std
+import { existsSync } from "node:fs";
+import { spawn } from "node:child_process";
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+// generated via script[generate:prisma]
+import type { Repository } from "@prisma/client";
+// app
+import { Env } from "../../env";
+// service
+import type { RepositoryFileContent } from "../../types";
+import type { RepositoryServiceDeps } from "./types";
+
+const makeGetRepositoryFileContent: ServiceMethodFactory<
+ RepositoryServiceDeps,
+ [Repository, string, string | undefined],
+ Promise<null | RepositoryFileContent>
+> = ({ request }) => {
+ return async (repo, path, ref = "HEAD") => {
+ if (path.endsWith("/")) {
+ throw new Error("Could not retrieve file content for a folder.");
+ }
+
+ const parentOrg = await request.prisma.organization.findUnique({
+ where: {
+ id: repo.organizationId,
+ },
+ });
+
+ if (parentOrg == null) {
+ throw new Error(
+ `Could not find the parent organization for project "${repo.slug}".`
+ );
+ }
+
+ try {
+ const repoPath = `${Env.GIT_REPOSITORIES_ROOT}/${parentOrg.slug}/${repo.slug}.git`;
+ if (existsSync(repoPath) === false) {
+ throw new Error(
+ `Could not find a valid git repository at: ${repoPath}`
+ );
+ }
+
+ const gitCatFileProcess = spawn(
+ "git",
+ ["cat-file", "-p", `${ref}:${path}`],
+ {
+ cwd: repoPath,
+ }
+ );
+
+ const gitCatFileResult = await new Promise<string>((resolve, reject) => {
+ let buffer = [] as string[];
+ gitCatFileProcess.stdout.on("data", (data) => buffer.push(data));
+ gitCatFileProcess.stderr.on("data", (data) => {
+ reject(new Error(Buffer.from(data).toString("utf-8")));
+ });
+ gitCatFileProcess.stdout.on("close", () => {
+ resolve(buffer.join(""));
+ });
+ });
+
+ return {
+ content: gitCatFileResult,
+ mimeType: "mime/not-yet-supported",
+ };
+ } catch (_) {
+ return null;
+ }
+ };
+};
+
+export default makeGetRepositoryFileContent;
@@ -16,10 +16,10 @@ const GIT_LS_TREE_REGEXP =
const makeGetRepositoryFiles: ServiceMethodFactory<
RepositoryServiceDeps,
- [Repository, string | undefined],
+ [Repository, string | undefined, string | undefined],
Promise<RepositoryFile[]>
> = ({ request }) => {
- return async (repo, ref = "HEAD") => {
+ return async (repo, path = "", ref = "HEAD") => {
const parentOrg = await request.prisma.organization.findUnique({
where: {
id: repo.organizationId,
@@ -40,7 +40,7 @@ const makeGetRepositoryFiles: ServiceMethodFactory<
);
}
- const gitLsTreeProcess = spawn("git", ["ls-tree", ref], {
+ const gitLsTreeProcess = spawn("git", ["ls-tree", `${ref}:${path}`], {
cwd: repoPath,
});
@@ -6,6 +6,7 @@ import type { RepositoryServiceAPI, RepositoryServiceDeps } from "./types";
import { default as makeCreateRepository } from "./createRepository";
import { default as makeGetRepository } from "./getRepository";
import { default as makeGetRepositoryExploreCollection } from "./getRepositoryExploreCollection";
+import { default as makeGetRepositoryFileContent } from "./getRepositoryFileContent";
import { default as makeGetRepositoryFiles } from "./getRepositoryFiles";
import { default as makeGetRepositoryHead } from "./getRepositoryHead";
import { default as makeGetRepositoryHTTPCloneUrl } from "./getRepositoryHTTPCloneUrl";
@@ -18,6 +19,7 @@ export const makeRepositoryService = makeService<
createRepository: makeCreateRepository,
getRepository: makeGetRepository,
getRepositoryExploreCollection: makeGetRepositoryExploreCollection,
+ getRepositoryFileContent: makeGetRepositoryFileContent,
getRepositoryFiles: makeGetRepositoryFiles,
getRepositoryHead: makeGetRepositoryHead,
getRepositoryHTTPCloneUrl: makeGetRepositoryHTTPCloneUrl,
@@ -7,7 +7,11 @@ import type { FastifyRequest } from "fastify";
// generated via script[generate:prisma]
import type { Organization, Repository } from "@prisma/client";
// app
-import type { RepositoryFile, RepositoryHead } from "../../types";
+import type {
+ RepositoryFile,
+ RepositoryFileContent,
+ RepositoryHead,
+} from "../../types";
export interface CreateRepositoryDTO {
parentOrgSlug: string;
@@ -30,8 +34,14 @@ export interface RepositoryServiceAPI extends ServiceApiContract {
getRepositoryExploreCollection(): Promise<
(Repository & { parentOrg: Organization })[]
>;
+ getRepositoryFileContent(
+ repository: Repository,
+ path: string,
+ ref?: string
+ ): Promise<null | RepositoryFileContent>;
getRepositoryFiles(
repository: Repository,
+ path?: string,
ref?: string
): Promise<RepositoryFile[]>;
getRepositoryHead(
@@ -104,3 +104,8 @@ export interface RepositoryFile {
permissions: string;
type: "blob" | "tree";
}
+
+export interface RepositoryFileContent {
+ content: string;
+ mimeType: string;
+}
@@ -25,7 +25,6 @@ export const makeRequestHandler = {
title: props?.title,
currentSectionSlug: sectionSlug,
currentPageSlug: pageSlug,
- menuDefinition: request.sectionsWithPages,
themeScheme:
(request.cookies?.["theme_scheme"]?.split(".")?.[0] ||
Const.DEFAULT_THEME_SCHEME) === "light"
@@ -0,0 +1,51 @@
+// 1st-party
+import type { ReactView } from "@ethicdevs/react-monolith";
+// 3rd-party
+import React from "react";
+// generated via script[prisma:generate]
+import type { Organization, Repository, User } from "@prisma/client";
+// app
+import type { CommonProps, RepositoryFileContent } from "../../types";
+import { Layout, PageWrapper } from "../../components";
+
+export interface RepositoryBrowserViewProps extends CommonProps {
+ currentUser: null | User;
+ fileContent: RepositoryFileContent;
+ parentOrg: Organization;
+ path: string;
+ ref: string;
+ repo: Repository;
+}
+
+const RepositoryBrowserView: ReactView<RepositoryBrowserViewProps> = ({
+ commonProps,
+ fileContent,
+ parentOrg,
+ path,
+ ref,
+ repo,
+}) => {
+ return (
+ <Layout {...commonProps} showSideMenu={false}>
+ <PageWrapper>
+ <h1>
+ {parentOrg.displayName || parentOrg.slug}
+ {" / "}
+ {repo.displayName || repo.slug}
+ {" / "}
+ {ref}
+ {" / "}
+ {path}
+ </h1>
+ <div>
+ <code>
+ <pre>{fileContent.content}</pre>
+ </code>
+ </div>
+ </PageWrapper>
+ </Layout>
+ );
+};
+
+RepositoryBrowserView.displayName = "RepositoryBrowserView";
+export default RepositoryBrowserView;
@@ -18,6 +18,7 @@ export interface RepositoryDetailsViewProps extends CommonProps {
ssh: string;
};
parentOrg: Organization;
+ path: string;
ref: string;
repo: Repository;
repoHead: null | RepositoryHead;
@@ -29,6 +30,7 @@ const RepositoryDetailsView: ReactView<RepositoryDetailsViewProps> = ({
commonProps,
cloneUrl,
parentOrg,
+ path,
ref,
repo,
repoHead,
@@ -88,7 +90,7 @@ const RepositoryDetailsView: ReactView<RepositoryDetailsViewProps> = ({
) : (
<div data-islandid={`${RepositoryTreeView.name}$$0`}>
<RepositoryTreeView
- currPath={"/"}
+ currPath={path}
orgSlug={parentOrg.slug}
repoHead={repoHead}
repoFiles={repoFiles}
@@ -5,11 +5,7 @@ import fastify from "fastify";
// generated via script[generate:prisma]
import { PrismaClient } from "@prisma/client";
// app
-import type {
- AppSessionData,
- AppThemeScheme,
- SectionsWithPages,
-} from "../../app/types";
+import type { AppSessionData, AppThemeScheme } from "../../app/types";
import type { CryptoServiceAPI } from "../../app/services/crypto/types";
declare module "@ethicdevs/fastify-custom-session" {
@@ -34,8 +30,6 @@ declare module "fastify" {
cookies: {
theme_scheme: AppThemeScheme;
};
- // from app
- sectionsWithPages: SectionsWithPages;
// from react-monolith
// A request utility that maps a viewName to its routerPath
namedViewsPathMap: Record<string, string>;