1// Syntax-highlighted file viewer with line numbers and copy button.
2// highlight.js is loaded lazily so it doesn't bloat the initial bundle.
3
4import { Copy } from "lucide-react";
5import { useState, useEffect } from "react";
6
7import type { GitBlob } from "@/__generated__/graphql";
8import { Button } from "@/components/ui/button";
9import { Skeleton } from "@/components/ui/skeleton";
10
11interface FileViewerProps {
12 blob: GitBlob | null;
13}
14
15export function FileViewer({ blob }: FileViewerProps) {
16 if (!blob) {
17 return (
18 <div className="divide-border border-border divide-y rounded-md border">
19 <div className="flex items-center gap-2 px-4 py-2">
20 <Skeleton className="h-4 w-48" />
21 </div>
22 <div className="p-4">
23 <Skeleton className="h-64 w-full" />
24 </div>
25 </div>
26 );
27 }
28 const [highlighted, setHighlighted] = useState<{ html: string; lineCount: number } | null>(null);
29
30 useEffect(() => {
31 if (blob.isBinary || !blob.text) {
32 setHighlighted({ html: "", lineCount: 0 });
33 return;
34 }
35 setHighlighted(null);
36 let cancelled = false;
37 void import("highlight.js").then(({ default: hljs }) => {
38 if (cancelled) return;
39 const ext = blob.path.split(".").pop() ?? "";
40 const result = hljs.getLanguage(ext)
41 ? hljs.highlight(blob.text!, { language: ext })
42 : hljs.highlightAuto(blob.text!);
43 setHighlighted({
44 html: result.value,
45 lineCount: blob.text!.split("\n").length,
46 });
47 });
48 return () => {
49 cancelled = true;
50 };
51 }, [blob]);
52
53 if (highlighted === null) return <FileViewerSkeleton />;
54 const { html, lineCount } = highlighted;
55
56 function copyToClipboard() {
57 if (blob?.text) void navigator.clipboard.writeText(blob.text);
58 }
59
60 return (
61 <div className="border-border overflow-hidden rounded-md border">
62 <div className="border-border bg-muted/40 text-muted-foreground flex items-center justify-between border-b px-4 py-2 text-xs">
63 <span>
64 {lineCount.toLocaleString()} lines · {formatBytes(blob.size)}
65 {blob.isTruncated && " · truncated"}
66 </span>
67 <Button
68 variant="ghost"
69 size="icon"
70 className="size-7"
71 onClick={copyToClipboard}
72 title="Copy"
73 >
74 <Copy className="size-3.5" />
75 </Button>
76 </div>
77
78 {blob.isBinary ? (
79 <div className="text-muted-foreground px-4 py-8 text-center text-sm">
80 Binary file — {formatBytes(blob.size)}
81 </div>
82 ) : (
83 <div className="flex overflow-x-auto font-mono text-xs leading-5">
84 <div
85 className="border-border bg-muted/20 text-muted-foreground/50 border-r px-4 py-4 text-right select-none"
86 aria-hidden
87 >
88 {Array.from({ length: lineCount }, (_, i) => (
89 <div key={i}>{i + 1}</div>
90 ))}
91 </div>
92 <pre className="flex-1 overflow-visible px-4 py-4">
93 <code
94 className="hljs !bg-transparent !p-0"
95 dangerouslySetInnerHTML={{ __html: html }}
96 />
97 </pre>
98 </div>
99 )}
100 </div>
101 );
102}
103
104function formatBytes(bytes: number): string {
105 if (bytes < 1024) return `${bytes} B`;
106 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
107 return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
108}
109
110function FileViewerSkeleton() {
111 return (
112 <div className="border-border overflow-hidden rounded-md border">
113 <div className="border-border bg-muted/40 border-b px-4 py-2">
114 <Skeleton className="h-4 w-32" />
115 </div>
116 <div className="flex gap-4 p-4">
117 <div className="space-y-1.5">
118 {Array.from({ length: 20 }).map((_, i) => (
119 <Skeleton key={i} className="h-3.5 w-6" />
120 ))}
121 </div>
122 <div className="flex-1 space-y-1.5">
123 {Array.from({ length: 20 }).map((_, i) => (
124 <Skeleton key={i} className="h-3.5" style={{ width: `${30 + Math.random() * 60}%` }} />
125 ))}
126 </div>
127 </div>
128 </div>
129 );
130}