GitFOSS
feat(repository): make it possible to browse folders and see file contents
+ 283
- 21
@@ -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": {

new file
app/controllers/repository/getRepositoryBrowserView.ts
@@ -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;

app/controllers/repository/getRepositoryCreateView.ts
@@ -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";

app/controllers/repository/getRepositoryDetailsView.ts
@@ -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,

app/controllers/repository/getRepositoryExploreView.ts
@@ -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

app/controllers/repository/index.ts
@@ -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,

app/islands/RepositoryTreeView.tsx
@@ -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>
   );

new file
app/services/repository/getRepositoryFileContent.ts
@@ -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;

app/services/repository/getRepositoryFiles.ts
@@ -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,
       });
 

app/services/repository/index.ts
@@ -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,

app/services/repository/types.ts
@@ -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;
+}

app/utils/server/makeRequestHandler.ts
@@ -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"

new file
app/views/repository/RepositoryBrowserView.tsx
@@ -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;

app/views/repository/RepositoryDetailsView.tsx
@@ -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}

types/global/index.d.ts
@@ -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>;