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}