FileViewer.tsx

  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}