FileDiffView.tsx

  1// Collapsible diff view for a single file in a commit.
  2// Diff is fetched lazily on first expand via GraphQL.
  3
  4import { gql } from "@apollo/client";
  5import { useLazyQuery } from "@apollo/client/react";
  6import { ChevronRight, FilePlus, FileMinus, FileEdit } from "lucide-react";
  7import { useState } from "react";
  8
  9import { useRepo } from "@/lib/repo";
 10import { cn } from "@/lib/utils";
 11
 12const DIFF_QUERY = gql`
 13  query FileDiff($repo: String, $hash: String!, $path: String!) {
 14    repository(ref: $repo) {
 15      commit(hash: $hash) {
 16        diff(path: $path) {
 17          path
 18          oldPath
 19          isBinary
 20          isNew
 21          isDelete
 22          hunks {
 23            oldStart
 24            oldLines
 25            newStart
 26            newLines
 27            lines {
 28              type
 29              content
 30              oldLine
 31              newLine
 32            }
 33          }
 34        }
 35      }
 36    }
 37  }
 38`;
 39
 40interface DiffQueryData {
 41  repository: {
 42    commit: {
 43      diff: {
 44        path: string;
 45        oldPath: string | null;
 46        isBinary: boolean;
 47        isNew: boolean;
 48        isDelete: boolean;
 49        hunks: HunkType[];
 50      } | null;
 51    } | null;
 52  } | null;
 53}
 54
 55interface FileDiffViewProps {
 56  hash: string;
 57  path: string;
 58  oldPath?: string;
 59  status: string;
 60}
 61
 62const statusIcon: Record<string, React.ReactNode> = {
 63  ADDED: <FilePlus className="size-3.5 text-green-600 dark:text-green-400" />,
 64  DELETED: <FileMinus className="size-3.5 text-red-500 dark:text-red-400" />,
 65  MODIFIED: <FileEdit className="size-3.5 text-yellow-500 dark:text-yellow-400" />,
 66  RENAMED: <FileEdit className="size-3.5 text-blue-500 dark:text-blue-400" />,
 67};
 68const statusBadge: Record<string, string> = {
 69  ADDED: "A",
 70  DELETED: "D",
 71  MODIFIED: "M",
 72  RENAMED: "R",
 73};
 74
 75export function FileDiffView({ hash, path, oldPath, status }: FileDiffViewProps) {
 76  const repo = useRepo();
 77  const [open, setOpen] = useState(false);
 78  const [fetchDiff, { data, loading, error }] = useLazyQuery<DiffQueryData>(DIFF_QUERY);
 79
 80  function toggle() {
 81    if (!open && !data && !loading) {
 82      void fetchDiff({ variables: { repo, hash, path } });
 83    }
 84    setOpen((v) => !v);
 85  }
 86
 87  const diff = data?.repository?.commit?.diff;
 88
 89  return (
 90    <div className="divide-border divide-y">
 91      <button
 92        onClick={toggle}
 93        className="hover:bg-muted/50 flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors"
 94      >
 95        <ChevronRight
 96          className={cn(
 97            "size-3.5 shrink-0 text-muted-foreground transition-transform duration-150",
 98            open && "rotate-90",
 99          )}
100        />
101        {statusIcon[status] ?? <FileEdit className="text-muted-foreground size-3.5" />}
102        <span className="min-w-0 flex-1 font-mono text-sm">
103          {status === "RENAMED" ? (
104            <>
105              <span className="text-muted-foreground line-through">{oldPath}</span>
106              {" → "}
107              <span>{path}</span>
108            </>
109          ) : (
110            path
111          )}
112        </span>
113        <span className="border-border text-muted-foreground shrink-0 rounded-sm border px-1.5 py-0.5 font-mono text-xs">
114          {statusBadge[status] ?? "?"}
115        </span>
116      </button>
117
118      {open && (
119        <div className="overflow-x-auto">
120          {loading && <div className="text-muted-foreground px-4 py-3 text-xs">Loading diff</div>}
121          {error && (
122            <div className="text-destructive px-4 py-3 text-xs">
123              Failed to load diff: {error.message}
124            </div>
125          )}
126          {diff &&
127            (diff.isBinary ? (
128              <div className="text-muted-foreground px-4 py-3 text-xs">Binary file</div>
129            ) : diff.hunks.length === 0 ? (
130              <div className="text-muted-foreground px-4 py-3 text-xs">No changes</div>
131            ) : (
132              diff.hunks.map((hunk: HunkType, i: number) => <Hunk key={i} hunk={hunk} />)
133            ))}
134        </div>
135      )}
136    </div>
137  );
138}
139
140type LineType = { type: string; content: string; oldLine: number; newLine: number };
141type HunkType = {
142  oldStart: number;
143  oldLines: number;
144  newStart: number;
145  newLines: number;
146  lines: LineType[];
147};
148
149function Hunk({ hunk }: { hunk: HunkType }) {
150  return (
151    <div className="font-mono text-xs leading-5">
152      <div className="bg-blue-50 px-4 py-0.5 text-blue-600 select-none dark:bg-blue-950/40 dark:text-blue-400">
153        @@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@
154      </div>
155      {hunk.lines.map((line, i) => (
156        <div
157          key={i}
158          className={cn(
159            "flex",
160            line.type === "ADDED" && "bg-green-50  dark:bg-green-950/30",
161            line.type === "DELETED" && "bg-red-50    dark:bg-red-950/30",
162          )}
163        >
164          <span className="border-border/50 text-muted-foreground/50 w-10 shrink-0 border-r px-2 text-right select-none">
165            {line.oldLine || ""}
166          </span>
167          <span className="border-border/50 text-muted-foreground/50 w-10 shrink-0 border-r px-2 text-right select-none">
168            {line.newLine || ""}
169          </span>
170          <span
171            className={cn(
172              "w-5 shrink-0 select-none text-center",
173              line.type === "ADDED" && "text-green-600 dark:text-green-400",
174              line.type === "DELETED" && "text-red-500   dark:text-red-400",
175              line.type === "CONTEXT" && "text-muted-foreground/40",
176            )}
177          >
178            {line.type === "ADDED" ? "+" : line.type === "DELETED" ? "-" : " "}
179          </span>
180          <pre
181            className={cn(
182              "flex-1 overflow-visible whitespace-pre px-2",
183              line.type === "ADDED" && "text-green-900 dark:text-green-200",
184              line.type === "DELETED" && "text-red-900   dark:text-red-200",
185            )}
186          >
187            {line.content}
188          </pre>
189        </div>
190      ))}
191    </div>
192  );
193}