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