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