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