index.tsx

  1// Code browser page. Switches between tree view, file viewer, and commit
  2// history via ?type= search param. Ref is selected via ?ref=.
  3
  4import { gql } from "@apollo/client";
  5import { useQuery, useReadQuery } from "@apollo/client/react";
  6import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router";
  7import { AlertCircle, GitCommit } from "lucide-react";
  8import { useEffect } from "react";
  9
 10import type { GitRef, GitTreeEntry, GitBlob, GitLastCommit } from "@/__generated__/graphql";
 11import { CodeBreadcrumb } from "@/components/code/CodeBreadcrumb";
 12import { CommitList } from "@/components/code/CommitList";
 13import { FileTree } from "@/components/code/FileTree";
 14import type { TreeEntryWithCommit } from "@/components/code/FileTree";
 15import { FileViewer } from "@/components/code/FileViewer";
 16import { RefSelector } from "@/components/code/RefSelector";
 17import { Markdown } from "@/components/content/Markdown";
 18import { ButtonLink } from "@/components/ui/button-link";
 19import { Skeleton } from "@/components/ui/skeleton";
 20import { preloadQuery } from "@/lib/apollo";
 21import { useRepo } from "@/lib/repo";
 22
 23const REFS_QUERY = gql`
 24  query CodePageRefs($repo: String) {
 25    repository(ref: $repo) {
 26      name
 27      refs {
 28        nodes {
 29          name
 30          shortName
 31          type
 32          hash
 33          isDefault
 34        }
 35      }
 36    }
 37  }
 38`;
 39
 40const TREE_QUERY = gql`
 41  query CodePageTree($repo: String, $ref: String!, $path: String) {
 42    repository(ref: $repo) {
 43      tree(ref: $ref, path: $path) {
 44        name
 45        type
 46        hash
 47      }
 48    }
 49  }
 50`;
 51
 52const LAST_COMMITS_QUERY = gql`
 53  query CodePageLastCommits($repo: String, $ref: String!, $path: String, $names: [String!]!) {
 54    repository(ref: $repo) {
 55      lastCommits(ref: $ref, path: $path, names: $names) {
 56        name
 57        commit {
 58          hash
 59          shortHash
 60          message
 61          date
 62        }
 63      }
 64    }
 65  }
 66`;
 67
 68const BLOB_QUERY = gql`
 69  query CodePageBlob($repo: String, $ref: String!, $path: String!) {
 70    repository(ref: $repo) {
 71      blob(ref: $ref, path: $path) {
 72        path
 73        hash
 74        text
 75        size
 76        isBinary
 77        isTruncated
 78      }
 79    }
 80  }
 81`;
 82
 83interface RefsQueryData {
 84  repository: {
 85    name: string;
 86    refs: { nodes: GitRef[] } | null;
 87  } | null;
 88}
 89
 90interface TreeQueryData {
 91  repository: {
 92    tree: GitTreeEntry[] | null;
 93  } | null;
 94}
 95
 96interface LastCommitsQueryData {
 97  repository: {
 98    lastCommits: GitLastCommit[] | null;
 99  } | null;
100}
101
102interface BlobQueryData {
103  repository: {
104    blob: GitBlob | null;
105  } | null;
106}
107
108export type CodePageSearch = {
109  ref: string;
110  path: string;
111  type: "tree" | "blob" | "commits";
112};
113
114type ViewMode = CodePageSearch["type"];
115
116export const Route = createFileRoute("/$repo/")({
117  component: RouteComponent,
118  pendingComponent: CodePageSkeleton,
119  validateSearch: (search: Record<string, unknown>): CodePageSearch => ({
120    ref: (search.ref as string) ?? "",
121    path: (search.path as string) ?? "",
122    type: ["tree", "blob", "commits"].includes(search.type as string)
123      ? (search.type as CodePageSearch["type"])
124      : "tree",
125  }),
126  loader: ({ params: { repo } }) => ({
127    refsRef: preloadQuery<RefsQueryData>(REFS_QUERY, {
128      variables: { repo: repo === "_" ? null : repo },
129    }),
130  }),
131});
132
133function RouteComponent() {
134  const repo = useRepo();
135  const navigate = useNavigate({ from: "/$repo/" });
136  const { ref: currentRef, path: currentPath, type: viewMode } = useSearch({ from: "/$repo/" });
137
138  const { refsRef } = Route.useLoaderData();
139  const { data: refsData, error: refsError } = useReadQuery(refsRef);
140  const refs: GitRef[] = refsData?.repository?.refs?.nodes ?? [];
141
142  // Set default ref once loaded
143  useEffect(() => {
144    if (refs.length === 0 || currentRef) return;
145    const defaultRef = refs.find((r: GitRef) => r.isDefault) ?? refs[0];
146    if (defaultRef) {
147      void navigate({
148        search: (prev) => ({ ...prev, ref: defaultRef.shortName }),
149        replace: true,
150      });
151    }
152  }, [refs.length]); // eslint-disable-line react-hooks/exhaustive-deps
153
154  const inTreeMode = viewMode === "tree" && !!currentRef;
155  const inBlobMode = viewMode === "blob" && !!currentRef && !!currentPath;
156
157  const { data: treeData, loading: treeLoading } = useQuery<TreeQueryData>(TREE_QUERY, {
158    variables: { repo, ref: currentRef, path: currentPath || null },
159    skip: !inTreeMode,
160  });
161  const entries: GitTreeEntry[] = treeData?.repository?.tree ?? [];
162
163  const entryNames = entries.map((e: GitTreeEntry) => e.name);
164  const { data: lastCommitsData } = useQuery<LastCommitsQueryData>(LAST_COMMITS_QUERY, {
165    variables: { repo, ref: currentRef, path: currentPath || null, names: entryNames },
166    skip: !inTreeMode || entryNames.length === 0,
167  });
168  const lastCommitsByName = new Map<string, GitLastCommit>(
169    (lastCommitsData?.repository?.lastCommits ?? []).map((lc: GitLastCommit) => [lc.name, lc]),
170  );
171  const entriesWithCommits: TreeEntryWithCommit[] = entries.map((e: GitTreeEntry) => ({
172    ...e,
173    lastCommit: lastCommitsByName.get(e.name)?.commit ?? undefined,
174  }));
175
176  const { data: blobData, loading: blobLoading } = useQuery<BlobQueryData>(BLOB_QUERY, {
177    variables: { repo, ref: currentRef, path: currentPath },
178    skip: !inBlobMode,
179  });
180  const blob: GitBlob | null = blobData?.repository?.blob ?? null;
181
182  const readmeEntry = entries.find(
183    (e: GitTreeEntry) => e.type === "BLOB" && /^readme(\.md|\.txt|\.rst)?$/i.test(e.name),
184  );
185  const readmePath = readmeEntry
186    ? currentPath
187      ? `${currentPath}/${readmeEntry.name}`
188      : readmeEntry.name
189    : null;
190  const { data: readmeBlobData } = useQuery<BlobQueryData>(BLOB_QUERY, {
191    variables: { repo, ref: currentRef, path: readmePath },
192    skip: !inTreeMode || !readmePath,
193  });
194  const readme: string | null = readmeBlobData?.repository?.blob?.text ?? null;
195
196  const repoName = refsData?.repository?.name ?? repo ?? "default-repo";
197
198  function navigateTo(path: string, type: ViewMode = "tree") {
199    void navigate({ search: (prev) => ({ ...prev, path, type }) });
200  }
201
202  function handleEntryClick(entry: TreeEntryWithCommit) {
203    const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
204    navigateTo(newPath, entry.type === "BLOB" ? "blob" : "tree");
205  }
206
207  function handleNavigateUp() {
208    const parts = currentPath.split("/").filter(Boolean);
209    parts.pop();
210    navigateTo(parts.join("/"), "tree");
211  }
212
213  function handleRefSelect(ref: GitRef) {
214    void navigate({ search: { ref: ref.shortName, path: "", type: "tree" } });
215  }
216
217  if (refsError) {
218    return (
219      <div className="flex flex-col items-center gap-3 py-16 text-center">
220        <AlertCircle className="text-muted-foreground size-8" />
221        <p className="text-sm font-medium">Code browser unavailable</p>
222        <p className="text-muted-foreground max-w-sm text-xs">{refsError.message}</p>
223      </div>
224    );
225  }
226
227  return (
228    <div className="space-y-4">
229      <div className="flex flex-wrap items-center justify-between gap-3">
230        <CodeBreadcrumb
231          repoName={repoName}
232          ref={currentRef}
233          path={currentPath}
234          onNavigate={(p) => navigateTo(p, "tree")}
235        />
236        <div className="flex items-center gap-2">
237          <ButtonLink
238            to="/$repo"
239            params={{ repo: repo! }}
240            search={{
241              ref: currentRef,
242              path: currentPath,
243              type: viewMode === "commits" ? "tree" : "commits",
244            }}
245            variant={viewMode === "commits" ? "secondary" : "outline"}
246            size="sm"
247          >
248            <GitCommit className="size-3.5" />
249            History
250          </ButtonLink>
251          <RefSelector refs={refs} currentRef={currentRef} onSelect={handleRefSelect} />
252        </div>
253      </div>
254
255      {viewMode === "commits" ? (
256        <CommitList ref_={currentRef} path={currentPath || undefined} />
257      ) : viewMode === "tree" || !blob ? (
258        <>
259          <FileTree
260            entries={entriesWithCommits}
261            path={currentPath}
262            loading={treeLoading}
263            onNavigate={handleEntryClick}
264            onNavigateUp={handleNavigateUp}
265          />
266          {readme && (
267            <div className="rounded-md border">
268              <div className="text-muted-foreground border-b px-4 py-2 text-xs font-medium">
269                README
270              </div>
271              <div className="px-6 py-4">
272                <Markdown content={readme} />
273              </div>
274            </div>
275          )}
276        </>
277      ) : (
278        <FileViewer blob={blob} loading={blobLoading} />
279      )}
280    </div>
281  );
282}
283
284function CodePageSkeleton() {
285  return (
286    <div className="space-y-4">
287      <div className="flex items-center justify-between">
288        <Skeleton className="h-5 w-48" />
289        <Skeleton className="h-8 w-28" />
290      </div>
291      <div className="divide-border border-border divide-y rounded-md border">
292        {Array.from({ length: 8 }).map((_, i) => (
293          <div key={i} className="flex items-center gap-3 px-4 py-2">
294            <Skeleton className="size-4 rounded-sm" />
295            <Skeleton className="h-4 w-32" />
296          </div>
297        ))}
298      </div>
299    </div>
300  );
301}