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 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}