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}