FileViewer.tsx

  1// Syntax-highlighted file viewer with line numbers and copy button.
  2// highlight.js is loaded lazily so it doesn't bloat the initial bundle.
  3
  4import { Copy } from "lucide-react";
  5import { useState, useEffect } from "react";
  6
  7import type { GitBlob } from "@/__generated__/graphql";
  8import { Button } from "@/components/ui/button";
  9import { Skeleton } from "@/components/ui/skeleton";
 10
 11interface FileViewerProps {
 12  blob: GitBlob | null;
 13}
 14
 15export function FileViewer({ blob }: FileViewerProps) {
 16  if (!blob) {
 17    return (
 18      <div className="divide-border border-border divide-y rounded-md border">
 19        <div className="flex items-center gap-2 px-4 py-2">
 20          <Skeleton className="h-4 w-48" />
 21        </div>
 22        <div className="p-4">
 23          <Skeleton className="h-64 w-full" />
 24        </div>
 25      </div>
 26    );
 27  }
 28  const [highlighted, setHighlighted] = useState<{ html: string; lineCount: number } | null>(null);
 29
 30  useEffect(() => {
 31    if (blob.isBinary || !blob.text) {
 32      setHighlighted({ html: "", lineCount: 0 });
 33      return;
 34    }
 35    setHighlighted(null);
 36    let cancelled = false;
 37    void import("highlight.js").then(({ default: hljs }) => {
 38      if (cancelled) return;
 39      const ext = blob.path.split(".").pop() ?? "";
 40      const result = hljs.getLanguage(ext)
 41        ? hljs.highlight(blob.text!, { language: ext })
 42        : hljs.highlightAuto(blob.text!);
 43      setHighlighted({
 44        html: result.value,
 45        lineCount: blob.text!.split("\n").length,
 46      });
 47    });
 48    return () => {
 49      cancelled = true;
 50    };
 51  }, [blob]);
 52
 53  if (highlighted === null) return <FileViewerSkeleton />;
 54  const { html, lineCount } = highlighted;
 55
 56  function copyToClipboard() {
 57    if (blob?.text) void navigator.clipboard.writeText(blob.text);
 58  }
 59
 60  return (
 61    <div className="border-border overflow-hidden rounded-md border">
 62      <div className="border-border bg-muted/40 text-muted-foreground flex items-center justify-between border-b px-4 py-2 text-xs">
 63        <span>
 64          {lineCount.toLocaleString()} lines · {formatBytes(blob.size)}
 65          {blob.isTruncated && " · truncated"}
 66        </span>
 67        <Button
 68          variant="ghost"
 69          size="icon"
 70          className="size-7"
 71          onClick={copyToClipboard}
 72          title="Copy"
 73        >
 74          <Copy className="size-3.5" />
 75        </Button>
 76      </div>
 77
 78      {blob.isBinary ? (
 79        <div className="text-muted-foreground px-4 py-8 text-center text-sm">
 80          Binary file  {formatBytes(blob.size)}
 81        </div>
 82      ) : (
 83        <div className="flex overflow-x-auto font-mono text-xs leading-5">
 84          <div
 85            className="border-border bg-muted/20 text-muted-foreground/50 border-r px-4 py-4 text-right select-none"
 86            aria-hidden
 87          >
 88            {Array.from({ length: lineCount }, (_, i) => (
 89              <div key={i}>{i + 1}</div>
 90            ))}
 91          </div>
 92          <pre className="flex-1 overflow-visible px-4 py-4">
 93            <code
 94              className="hljs !bg-transparent !p-0"
 95              dangerouslySetInnerHTML={{ __html: html }}
 96            />
 97          </pre>
 98        </div>
 99      )}
100    </div>
101  );
102}
103
104function formatBytes(bytes: number): string {
105  if (bytes < 1024) return `${bytes} B`;
106  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
107  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
108}
109
110function FileViewerSkeleton() {
111  return (
112    <div className="border-border overflow-hidden rounded-md border">
113      <div className="border-border bg-muted/40 border-b px-4 py-2">
114        <Skeleton className="h-4 w-32" />
115      </div>
116      <div className="flex gap-4 p-4">
117        <div className="space-y-1.5">
118          {Array.from({ length: 20 }).map((_, i) => (
119            <Skeleton key={i} className="h-3.5 w-6" />
120          ))}
121        </div>
122        <div className="flex-1 space-y-1.5">
123          {Array.from({ length: 20 }).map((_, i) => (
124            <Skeleton key={i} className="h-3.5" style={{ width: `${30 + Math.random() * 60}%` }} />
125          ))}
126        </div>
127      </div>
128    </div>
129  );
130}