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