FileViewer.tsx

  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}