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