1/**
2 * The repository browser: GitHub-style Code / Commits / Settings tabs with
3 * branch picker, tree navigation, README rendering, and blob view.
4 *
5 * Tree resolution walks the object graph exactly the way a git client
6 * would: resolve ref → commit → root tree, then descend one `trees/:oid`
7 * read per path segment.
8 */
9import { lazy, Suspense, useEffect, useMemo, useState } from "react";
10import {
11 getCommit,
12 getFile,
13 getRepo,
14 getTree,
15 listRefs,
16 type CommitInfo,
17 type Connection,
18 type Ref,
19 type Repo,
20 type TreeEntry,
21 type User,
22} from "../client.ts";
23import {
24 Badge,
25 BranchIcon,
26 makeAssetResolver,
27 CommitIcon,
28 CopyButton,
29 ErrorBox,
30 FileIcon,
31 FolderIcon,
32 ForkIcon,
33 GearIcon,
34 Markdown,
35 PullRequestIcon,
36 RepoIcon,
37 Spinner,
38 TagIcon,
39} from "../components.tsx";
40import { decodeText, formatBytes, imageMime, isMarkdown } from "../format.ts";
41import { href, Link, useRouter } from "../router.tsx";
42import { CommitsTab } from "./Commits.tsx";
43import { PullsTab } from "./Pulls.tsx";
44import { SettingsTab } from "./Settings.tsx";
45
46// Lazy chunks: everything importing @pierre/diffs (diffs + shiki runtime)
47// loads on demand, keeping the initial bundle small.
48const CommitPage = lazy(() => import("./Commit.tsx"));
49const FileView = lazy(() => import("./FileView.tsx"));
50const PullPage = lazy(() => import("./Pull.tsx"));
51
52// ── shared context of every tab ─────────────────────────────────────────────
53
54export interface RepoContext {
55 readonly connection: Connection;
56 /** The signed-in user, or `null` when browsing anonymously. */
57 readonly user: User | null;
58 readonly repo: Repo;
59 readonly refs: { head: string | null; refs: Ref[] };
60 /** Short name of the ref being browsed (branch or tag). */
61 readonly refName: string;
62}
63
64const shortRefName = (ref: string): string =>
65 ref.replace(/^refs\/(heads|tags)\//, "");
66
67/** Raw-file endpoint URL for `<img>`-style fetches (anonymous on public repos). */
68const rawFileUrl = (context: RepoContext, path: string): string => {
69 const params = new URLSearchParams({ path, ref: context.refName });
70 const { owner, name } = context.repo;
71 return `${context.connection.url}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/file?${params}`;
72};
73
74/**
75 * Resolves the browsed ref name to a commit oid using the already-loaded
76 * refs advertisement — `commits/:oid` takes a full 40-hex oid only, so ref
77 * resolution is the client's job (exactly as in the wire protocol).
78 * Annotated tags resolve through their peeled oid.
79 */
80const resolveOid = (context: RepoContext): string | undefined => {
81 const name = context.refName;
82 if (/^[0-9a-f]{40}$/.test(name)) return name;
83 const match =
84 context.refs.refs.find((ref) => ref.name === `refs/heads/${name}`) ??
85 context.refs.refs.find((ref) => ref.name === `refs/tags/${name}`);
86 return match === undefined ? undefined : (match.peeled ?? match.oid);
87};
88
89// ── branch / tag picker ─────────────────────────────────────────────────────
90
91const RefPicker = ({
92 context,
93 path,
94 view,
95}: {
96 context: RepoContext;
97 path: string[];
98 view: "tree" | "commits";
99}) => {
100 const { navigate } = useRouter();
101 const branches = context.refs.refs.filter((ref) =>
102 ref.name.startsWith("refs/heads/"),
103 );
104 const tags = context.refs.refs.filter((ref) =>
105 ref.name.startsWith("refs/tags/"),
106 );
107 return (
108 <div className="relative inline-flex items-center gap-1.5 rounded-md border border-border-muted bg-canvas-subtle px-3 py-1.5 text-sm font-medium">
109 <BranchIcon className="text-fg-muted" />
110 <select
111 value={context.refName}
112 onChange={(event) => {
113 const target = event.target.value;
114 navigate(
115 view === "commits"
116 ? href(context.repo.owner, context.repo.name, "commits", target)
117 : href(
118 context.repo.owner,
119 context.repo.name,
120 "tree",
121 target,
122 ...path,
123 ),
124 );
125 }}
126 className="cursor-pointer appearance-none bg-transparent pr-4 outline-none"
127 >
128 <optgroup label="Branches">
129 {branches.map((ref) => (
130 <option key={ref.name} value={shortRefName(ref.name)}>
131 {shortRefName(ref.name)}
132 </option>
133 ))}
134 </optgroup>
135 {tags.length > 0 && (
136 <optgroup label="Tags">
137 {tags.map((ref) => (
138 <option key={ref.name} value={shortRefName(ref.name)}>
139 {shortRefName(ref.name)}
140 </option>
141 ))}
142 </optgroup>
143 )}
144 </select>
145 </div>
146 );
147};
148
149// ── path breadcrumbs ────────────────────────────────────────────────────────
150
151const Breadcrumbs = ({
152 context,
153 path,
154 leafIsBlob,
155}: {
156 context: RepoContext;
157 path: string[];
158 leafIsBlob: boolean;
159}) => (
160 <div className="flex min-w-0 flex-wrap items-center gap-1 text-sm">
161 <Link
162 to={href(context.repo.owner, context.repo.name)}
163 className="font-semibold text-accent hover:underline"
164 >
165 {context.repo.name}
166 </Link>
167 {path.map((part, index) => {
168 const isLeaf = index === path.length - 1;
169 return (
170 <span key={index} className="flex items-center gap-1">
171 <span className="text-fg-muted">/</span>
172 {isLeaf ? (
173 <span className={leafIsBlob ? "font-semibold" : "font-semibold"}>
174 {part}
175 </span>
176 ) : (
177 <Link
178 to={href(
179 context.repo.owner,
180 context.repo.name,
181 "tree",
182 context.refName,
183 ...path.slice(0, index + 1),
184 )}
185 className="text-accent hover:underline"
186 >
187 {part}
188 </Link>
189 )}
190 </span>
191 );
192 })}
193 </div>
194);
195
196// ── tree resolution ─────────────────────────────────────────────────────────
197
198/** Resolves ref → commit, then walks `path` one tree read at a time. */
199const useTreeAt = (context: RepoContext, path: string[]) => {
200 const [state, setState] = useState<{
201 commit: CommitInfo;
202 entries: TreeEntry[];
203 } | null>(null);
204 const [error, setError] = useState<unknown>(null);
205 const key = `${context.refName}${path.join("/")}`;
206
207 useEffect(() => {
208 let cancelled = false;
209 setState(null);
210 setError(null);
211 void (async () => {
212 try {
213 const { connection, repo } = context;
214 const oid = resolveOid(context);
215 if (oid === undefined) {
216 throw new Error(`unknown ref: ${context.refName}`);
217 }
218 const commit = await getCommit(connection, repo.owner, repo.name, oid);
219 let treeOid = commit.tree;
220 for (const segment of path) {
221 const tree = await getTree(
222 connection,
223 repo.owner,
224 repo.name,
225 treeOid,
226 );
227 const entry = tree.entries.find(
228 (candidate) =>
229 candidate.name === segment && candidate.type === "tree",
230 );
231 if (entry === undefined) {
232 throw new Error(`path not found: ${path.join("/")}`);
233 }
234 treeOid = entry.oid;
235 }
236 const tree = await getTree(connection, repo.owner, repo.name, treeOid);
237 if (!cancelled) setState({ commit, entries: [...tree.entries] });
238 } catch (cause) {
239 if (!cancelled) setError(cause);
240 }
241 })();
242 return () => {
243 cancelled = true;
244 };
245 // eslint-disable-next-line react-hooks/exhaustive-deps
246 }, [key, context.repo.repoId]);
247
248 return { state, error };
249};
250
251const sortEntries = (entries: TreeEntry[]): TreeEntry[] =>
252 [...entries].sort((a, b) =>
253 a.type === b.type
254 ? a.name.localeCompare(b.name)
255 : a.type === "tree"
256 ? -1
257 : b.type === "tree"
258 ? 1
259 : a.name.localeCompare(b.name),
260 );
261
262// ── README ──────────────────────────────────────────────────────────────────
263
264const Readme = ({
265 context,
266 path,
267 entries,
268}: {
269 context: RepoContext;
270 path: string[];
271 entries: TreeEntry[];
272}) => {
273 const readme = entries.find(
274 (entry) =>
275 entry.type === "blob" && /^readme\.(md|markdown)$/i.test(entry.name),
276 );
277 const [content, setContent] = useState<string | null>(null);
278 useEffect(() => {
279 setContent(null);
280 if (readme === undefined) return;
281 let cancelled = false;
282 void getFile(context.connection, context.repo.owner, context.repo.name, {
283 ref: context.refName,
284 path: [...path, readme.name].join("/"),
285 })
286 .then((bytes) => {
287 if (!cancelled) setContent(decodeText(bytes));
288 })
289 .catch(() => {});
290 return () => {
291 cancelled = true;
292 };
293 // eslint-disable-next-line react-hooks/exhaustive-deps
294 }, [readme?.oid]);
295
296 if (readme === undefined || content === null) return null;
297 return (
298 <div className="mt-4 rounded-md border border-border-muted">
299 <div className="border-b border-border-muted px-4 py-2 text-sm font-semibold">
300 {readme.name}
301 </div>
302 <div className="p-6">
303 <Markdown
304 source={content}
305 resolveAsset={makeAssetResolver({
306 fileUrl: (asset) => rawFileUrl(context, asset),
307 dir: path,
308 })}
309 />
310 </div>
311 </div>
312 );
313};
314
315// ── code tab (tree view) ────────────────────────────────────────────────────
316
317const CloneBox = ({ context }: { context: RepoContext }) => {
318 const remote = `${context.connection.url}/${context.repo.owner}/${context.repo.name}.git`;
319 return (
320 <div className="flex items-center gap-1 rounded-md border border-border-muted bg-canvas-subtle px-2 py-1">
321 <code className="max-w-64 overflow-x-auto whitespace-nowrap px-1 font-mono text-xs">
322 {remote}
323 </code>
324 <CopyButton text={remote} />
325 </div>
326 );
327};
328
329const TreeTab = ({
330 context,
331 path,
332}: {
333 context: RepoContext;
334 path: string[];
335}) => {
336 const { state, error } = useTreeAt(context, path);
337 if (error != null) return <ErrorBox error={error} />;
338 if (state === null) return <Spinner />;
339 const entries = sortEntries(state.entries);
340 const { owner, name } = context.repo;
341
342 return (
343 <div>
344 <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
345 <div className="flex items-center gap-3">
346 <RefPicker context={context} path={path} view="tree" />
347 <Breadcrumbs context={context} path={path} leafIsBlob={false} />
348 </div>
349 <div className="flex items-center gap-2">
350 <Link
351 to={href(owner, name, "commits", context.refName)}
352 className="inline-flex items-center gap-1.5 text-sm text-fg-muted hover:text-accent"
353 >
354 <CommitIcon /> Commits
355 </Link>
356 <CloneBox context={context} />
357 </div>
358 </div>
359
360 <div className="overflow-hidden rounded-md border border-border-muted">
361 {path.length > 0 && (
362 <Link
363 to={
364 path.length === 1
365 ? href(owner, name)
366 : href(
367 owner,
368 name,
369 "tree",
370 context.refName,
371 ...path.slice(0, -1),
372 )
373 }
374 className="block border-b border-border-muted px-4 py-2 text-sm text-fg-muted hover:bg-canvas-subtle"
375 >
376 ..
377 </Link>
378 )}
379 {entries.map((entry) => (
380 <div
381 key={entry.oid + entry.name}
382 className="flex items-center gap-2.5 border-b border-border-muted px-4 py-2 text-sm last:border-b-0 hover:bg-canvas-subtle"
383 >
384 {entry.type === "tree" ? (
385 <FolderIcon className="shrink-0" />
386 ) : entry.type === "commit" ? (
387 <ForkIcon className="shrink-0 text-fg-muted" />
388 ) : (
389 <FileIcon className="shrink-0 text-fg-muted" />
390 )}
391 {entry.type === "commit" ? (
392 <span className="text-fg-muted">
393 {entry.name} @ {entry.oid.slice(0, 7)} (submodule)
394 </span>
395 ) : (
396 <Link
397 to={href(
398 owner,
399 name,
400 entry.type === "tree" ? "tree" : "blob",
401 context.refName,
402 ...path,
403 entry.name,
404 )}
405 className="truncate hover:text-accent hover:underline"
406 >
407 {entry.name}
408 </Link>
409 )}
410 {entry.mode === "120000" && (
411 <span className="text-xs text-fg-muted">symlink</span>
412 )}
413 </div>
414 ))}
415 {entries.length === 0 && (
416 <div className="px-4 py-8 text-center text-sm text-fg-muted">
417 Empty tree
418 </div>
419 )}
420 </div>
421
422 <Readme context={context} path={path} entries={entries} />
423 </div>
424 );
425};
426
427// ── blob tab ────────────────────────────────────────────────────────────────
428
429const BlobTab = ({
430 context,
431 path,
432}: {
433 context: RepoContext;
434 path: string[];
435}) => {
436 const [bytes, setBytes] = useState<Uint8Array | null>(null);
437 const [error, setError] = useState<unknown>(null);
438 const filePath = path.join("/");
439
440 useEffect(() => {
441 let cancelled = false;
442 setBytes(null);
443 setError(null);
444 void getFile(context.connection, context.repo.owner, context.repo.name, {
445 ref: context.refName,
446 path: filePath,
447 })
448 .then((data) => {
449 if (!cancelled) setBytes(data);
450 })
451 .catch((cause) => {
452 if (!cancelled) setError(cause);
453 });
454 return () => {
455 cancelled = true;
456 };
457 // eslint-disable-next-line react-hooks/exhaustive-deps
458 }, [context.refName, filePath, context.repo.repoId]);
459
460 const text = useMemo(
461 () => (bytes === null ? null : decodeText(bytes)),
462 [bytes],
463 );
464 const imageUrl = useMemo(() => {
465 const mime = imageMime(filePath);
466 if (bytes === null || mime === undefined) return null;
467 return URL.createObjectURL(
468 new Blob([bytes.slice() as BlobPart], { type: mime }),
469 );
470 }, [bytes, filePath]);
471 useEffect(
472 () => () => {
473 if (imageUrl !== null) URL.revokeObjectURL(imageUrl);
474 },
475 [imageUrl],
476 );
477
478 if (error != null) return <ErrorBox error={error} />;
479 if (bytes === null) return <Spinner />;
480
481 const lines = text?.split("\n") ?? [];
482
483 return (
484 <div>
485 <div className="mb-4 flex flex-wrap items-center gap-3">
486 <RefPicker context={context} path={path.slice(0, -1)} view="tree" />
487 <Breadcrumbs context={context} path={path} leafIsBlob />
488 </div>
489 <div className="overflow-hidden rounded-md border border-border-muted">
490 <div className="flex items-center justify-between border-b border-border-muted bg-canvas-subtle px-4 py-2 text-xs text-fg-muted">
491 <span>
492 {text !== null
493 ? `${lines.length} lines · ${formatBytes(bytes.length)}`
494 : formatBytes(bytes.length)}
495 </span>
496 </div>
497 {imageUrl !== null ? (
498 <div className="flex justify-center bg-canvas-subtle p-8">
499 <img src={imageUrl} alt={filePath} className="max-w-full" />
500 </div>
501 ) : text === null ? (
502 <div className="px-4 py-12 text-center text-sm text-fg-muted">
503 Binary file not shown ({formatBytes(bytes.length)})
504 </div>
505 ) : isMarkdown(filePath) ? (
506 <div className="p-6">
507 <Markdown
508 source={text}
509 resolveAsset={makeAssetResolver({
510 fileUrl: (asset) => rawFileUrl(context, asset),
511 dir: path.slice(0, -1),
512 })}
513 />
514 </div>
515 ) : (
516 <Suspense fallback={<Spinner />}>
517 <FileView name={filePath} text={text} />
518 </Suspense>
519 )}
520 </div>
521 </div>
522 );
523};
524
525// ── page shell ──────────────────────────────────────────────────────────────
526
527export const RepoPage = ({
528 connection,
529 user,
530 owner,
531 name,
532 rest,
533}: {
534 connection: Connection;
535 user: User | null;
536 owner: string;
537 name: string;
538 /** Path segments after `/:owner/:repo` (e.g. `["tree", "main", "src"]`). */
539 rest: string[];
540}) => {
541 const [repo, setRepo] = useState<Repo | null>(null);
542 const [refs, setRefs] = useState<{ head: string | null; refs: Ref[] } | null>(
543 null,
544 );
545 const [error, setError] = useState<unknown>(null);
546
547 useEffect(() => {
548 let cancelled = false;
549 setRepo(null);
550 setRefs(null);
551 setError(null);
552 void Promise.all([
553 getRepo(connection, owner, name),
554 listRefs(connection, owner, name),
555 ])
556 .then(([repoData, refsData]) => {
557 if (cancelled) return;
558 setRepo(repoData);
559 setRefs(refsData);
560 })
561 .catch((cause) => {
562 if (!cancelled) setError(cause);
563 });
564 return () => {
565 cancelled = true;
566 };
567 // eslint-disable-next-line react-hooks/exhaustive-deps
568 }, [connection.url, owner, name]);
569
570 if (error != null) return <ErrorBox error={error} />;
571 if (repo === null || refs === null) return <Spinner />;
572
573 const [view, refName, ...path] = rest;
574 const context: RepoContext = {
575 connection,
576 user,
577 repo,
578 refs,
579 // On pulls routes the second segment is a PR number, not a ref.
580 refName: (view === "pulls" ? undefined : refName) ?? repo.defaultBranch,
581 };
582 const isEmpty = refs.refs.length === 0;
583 const activeTab =
584 view === "commits" || view === "commit"
585 ? "commits"
586 : view === "pulls"
587 ? "pulls"
588 : view === "settings"
589 ? "settings"
590 : "code";
591
592 return (
593 <div>
594 <div className="mb-2 flex flex-wrap items-center gap-2">
595 <RepoIcon className="text-fg-muted" />
596 <h1 className="text-xl">
597 <Link to="/" className="text-accent hover:underline">
598 {repo.owner}
599 </Link>
600 <span className="mx-1 text-fg-muted">/</span>
601 <Link
602 to={href(repo.owner, repo.name)}
603 className="font-semibold text-accent hover:underline"
604 >
605 {repo.name}
606 </Link>
607 </h1>
608 {repo.forkOf !== null && (
609 <Badge>
610 <span className="inline-flex items-center gap-1">
611 <ForkIcon className="size-3" /> fork of {repo.forkOf}
612 </span>
613 </Badge>
614 )}
615 <Badge>{repo.public ? "Public" : "Private"}</Badge>
616 {repo.readOnly && <Badge tone="attention">read-only</Badge>}
617 {repo.status !== "ready" && <Badge tone="danger">{repo.status}</Badge>}
618 </div>
619 {repo.description !== null && repo.description.length > 0 && (
620 <p className="mb-3 text-sm text-fg-muted">{repo.description}</p>
621 )}
622
623 <nav className="mb-4 flex gap-1 border-b border-border-muted text-sm">
624 {(
625 [
626 ["code", "Code", <RepoIcon key="i" className="size-4" />],
627 ["commits", "Commits", <CommitIcon key="i" className="size-4" />],
628 [
629 "pulls",
630 "Pull requests",
631 <PullRequestIcon key="i" className="size-4" />,
632 ],
633 ["settings", "Settings", <GearIcon key="i" className="size-4" />],
634 ] as const
635 ).map(([tab, label, icon]) => (
636 <Link
637 key={tab}
638 to={
639 tab === "code"
640 ? href(owner, name)
641 : tab === "commits"
642 ? href(owner, name, "commits", context.refName)
643 : tab === "pulls"
644 ? href(owner, name, "pulls")
645 : href(owner, name, "settings")
646 }
647 className={`inline-flex items-center gap-1.5 border-b-2 px-3 py-2 ${
648 activeTab === tab
649 ? "border-tab-active font-semibold"
650 : "border-transparent text-fg-muted hover:border-border-muted"
651 }`}
652 >
653 {icon}
654 {label}
655 </Link>
656 ))}
657 </nav>
658
659 {activeTab === "settings" ? (
660 <SettingsTab context={context} />
661 ) : isEmpty ? (
662 <EmptyRepo context={context} />
663 ) : view === "commit" && refName !== undefined ? (
664 // /:owner/:repo/commit/:oid — refName carries the full commit oid.
665 <Suspense fallback={<Spinner />}>
666 <CommitPage context={context} oid={refName} />
667 </Suspense>
668 ) : view === "pulls" && refName !== undefined && /^\d+$/.test(refName) ? (
669 // /:owner/:repo/pulls/:number — refName carries the PR number.
670 <Suspense fallback={<Spinner />}>
671 <PullPage context={context} number={Number(refName)} />
672 </Suspense>
673 ) : view === "pulls" ? (
674 <PullsTab context={context} />
675 ) : activeTab === "commits" ? (
676 <CommitsTab context={context} />
677 ) : view === "blob" ? (
678 <BlobTab context={context} path={path} />
679 ) : (
680 <TreeTab context={context} path={path} />
681 )}
682 </div>
683 );
684};
685
686/** Quick-start instructions for a freshly created (unborn) repo. */
687const EmptyRepo = ({ context }: { context: RepoContext }) => {
688 const remote = `${context.connection.url}/${context.repo.owner}/${context.repo.name}.git`;
689 const script = [
690 `git init -b ${context.repo.defaultBranch}`,
691 `git add -A && git commit -m "initial commit"`,
692 `git remote add origin ${remote}`,
693 `git push -u origin ${context.repo.defaultBranch}`,
694 ].join("\n");
695 return (
696 <div className="rounded-md border border-border-muted p-6">
697 <h2 className="mb-2 font-semibold">
698 This repository is empty — push something:
699 </h2>
700 <div className="flex items-start gap-2">
701 <pre className="grow overflow-x-auto rounded-md bg-canvas-subtle p-4 font-mono text-xs leading-6">
702 {script}
703 </pre>
704 <CopyButton text={script} />
705 </div>
706 <p className="mt-3 text-xs text-fg-muted">
707 Authenticate with a repo token:{" "}
708 <code>
709 {context.connection.url.replace("://", "://x:<token>@")}/
710 {context.repo.owner}/{context.repo.name}.git
711 </code>{" "}
712 — mint one under Settings.
713 </p>
714 </div>
715 );
716};