web/app/%5Bowner%5D/%5Brepo%5D/commit/%5Bsha%5D/diff-viewer.tsxblame
View source
62019fc1"use client";
62019fc2
818dc903import { useState, useEffect } from "react";
62019fc4import type { ParsedFile, DiffHunk, DiffLine } from "./page";
62019fc5
818dc906const DIFF_VIEW_KEY = "grove:diff-view";
818dc907
818dc908function getStoredView(): "unified" | "split" {
818dc909 if (typeof window === "undefined") return "unified";
818dc9010 const stored = localStorage.getItem(DIFF_VIEW_KEY);
818dc9011 return stored === "split" ? "split" : "unified";
818dc9012}
818dc9013
62019fc14interface Props {
62019fc15 files: ParsedFile[];
62019fc16 owner: string;
62019fc17 repo: string;
62019fc18 gitSha: string;
62019fc19}
62019fc20
62019fc21interface SplitSide {
62019fc22 num: number | null;
62019fc23 content: string;
62019fc24 html?: string;
62019fc25 type: "del" | "add" | "context" | "empty";
62019fc26}
62019fc27
62019fc28interface SplitRow {
62019fc29 type: "context" | "add" | "del" | "change";
62019fc30 left: SplitSide;
62019fc31 right: SplitSide;
62019fc32}
62019fc33
62019fc34function buildSplitRows(hunk: DiffHunk): SplitRow[] {
62019fc35 const rows: SplitRow[] = [];
62019fc36 const lines = hunk.lines;
62019fc37 let i = 0;
62019fc38
62019fc39 while (i < lines.length) {
62019fc40 const line = lines[i];
62019fc41
62019fc42 if (line.type === "context") {
62019fc43 rows.push({
62019fc44 type: "context",
62019fc45 left: { num: line.oldNum, content: line.content, html: line.html, type: "context" },
62019fc46 right: { num: line.newNum, content: line.content, html: line.html, type: "context" },
62019fc47 });
62019fc48 i++;
62019fc49 } else {
62019fc50 // Collect consecutive del lines then add lines
62019fc51 const dels: typeof lines = [];
62019fc52 const adds: typeof lines = [];
62019fc53 while (i < lines.length && lines[i].type === "del") {
62019fc54 dels.push(lines[i]);
62019fc55 i++;
62019fc56 }
62019fc57 while (i < lines.length && lines[i].type === "add") {
62019fc58 adds.push(lines[i]);
62019fc59 i++;
62019fc60 }
62019fc61
62019fc62 const maxLen = Math.max(dels.length, adds.length);
62019fc63 for (let j = 0; j < maxLen; j++) {
62019fc64 const del = dels[j];
62019fc65 const add = adds[j];
62019fc66 rows.push({
62019fc67 type: del && add ? "change" : del ? "del" : "add",
62019fc68 left: del
62019fc69 ? { num: del.oldNum, content: del.content, html: del.html, type: "del" }
62019fc70 : { num: null, content: "", type: "empty" },
62019fc71 right: add
62019fc72 ? { num: add.newNum, content: add.content, html: add.html, type: "add" }
62019fc73 : { num: null, content: "", type: "empty" },
62019fc74 });
62019fc75 }
62019fc76 }
62019fc77 }
62019fc78
62019fc79 return rows;
62019fc80}
62019fc81
62019fc82function cellBg(type: "del" | "add" | "context" | "empty"): string {
62019fc83 if (type === "add") return "var(--diff-add-bg)";
62019fc84 if (type === "del") return "var(--diff-del-bg)";
62019fc85 return "transparent";
62019fc86}
62019fc87
62019fc88function cellColor(type: "del" | "add" | "context" | "empty"): string {
62019fc89 if (type === "add") return "var(--diff-add-text)";
62019fc90 if (type === "del") return "var(--diff-del-text)";
62019fc91 return "var(--text-secondary)";
62019fc92}
62019fc93
62019fc94export function DiffViewer({ files, owner, repo, gitSha }: Props) {
818dc9095 const [view, setView] = useState<"unified" | "split">(getStoredView);
818dc9096
818dc9097 useEffect(() => {
818dc9098 localStorage.setItem(DIFF_VIEW_KEY, view);
818dc9099 }, [view]);
62019fc100
62019fc101 let additions = 0;
62019fc102 let deletions = 0;
62019fc103 for (const file of files) {
62019fc104 for (const hunk of file.hunks) {
62019fc105 for (const line of hunk.lines) {
62019fc106 if (line.type === "add") additions++;
62019fc107 if (line.type === "del") deletions++;
62019fc108 }
62019fc109 }
62019fc110 }
62019fc111
62019fc112 return (
62019fc113 <>
62019fc114 <div className="flex items-center gap-3 text-xs mb-4" style={{ color: "var(--text-muted)" }}>
62019fc115 <span>{files.length} file{files.length !== 1 ? "s" : ""} changed</span>
62019fc116 {additions > 0 && (
62019fc117 <span style={{ color: "var(--diff-add-text)" }}>+{additions}</span>
62019fc118 )}
62019fc119 {deletions > 0 && (
62019fc120 <span style={{ color: "var(--diff-del-text)" }}>-{deletions}</span>
62019fc121 )}
62019fc122 <div className="ml-auto flex gap-1">
62019fc123 <button
62019fc124 onClick={() => setView("unified")}
62019fc125 className="px-2 py-0.5"
62019fc126 style={{
62019fc127 color: view === "unified" ? "var(--accent)" : "var(--text-faint)",
62019fc128 backgroundColor: view === "unified" ? "var(--bg-inset)" : "transparent",
62019fc129 border: "1px solid",
62019fc130 borderColor: view === "unified" ? "var(--border-subtle)" : "transparent",
62019fc131 cursor: "pointer",
62019fc132 fontSize: "0.75rem",
62019fc133 }}
62019fc134 >
62019fc135 Unified
62019fc136 </button>
62019fc137 <button
62019fc138 onClick={() => setView("split")}
62019fc139 className="px-2 py-0.5"
62019fc140 style={{
62019fc141 color: view === "split" ? "var(--accent)" : "var(--text-faint)",
62019fc142 backgroundColor: view === "split" ? "var(--bg-inset)" : "transparent",
62019fc143 border: "1px solid",
62019fc144 borderColor: view === "split" ? "var(--border-subtle)" : "transparent",
62019fc145 cursor: "pointer",
62019fc146 fontSize: "0.75rem",
62019fc147 }}
62019fc148 >
62019fc149 Split
62019fc150 </button>
62019fc151 </div>
62019fc152 </div>
62019fc153
62019fc154 {files.map((file) => (
62019fc155 <div key={file.path} className="mb-4">
62019fc156 <div
62019fc157 className="text-sm font-mono py-2 px-3"
62019fc158 style={{
62019fc159 backgroundColor: "var(--bg-inset)",
62019fc160 border: "1px solid var(--border-subtle)",
62019fc161 borderBottom: "none",
62019fc162 color: "var(--text-secondary)",
62019fc163 }}
62019fc164 >
818dc90165 <a
62019fc166 href={`/${owner}/${repo}/blob/${gitSha}/${file.path}`}
62019fc167 className="hover:underline"
62019fc168 style={{ color: "var(--accent)" }}
62019fc169 >
62019fc170 {file.path}
818dc90171 </a>
62019fc172 </div>
62019fc173 {file.hunks.length === 0 ? (
62019fc174 <div
62019fc175 className="text-sm py-3 px-3"
62019fc176 style={{
62019fc177 border: "1px solid var(--border-subtle)",
62019fc178 color: "var(--text-faint)",
62019fc179 }}
62019fc180 >
62019fc181 Binary file
62019fc182 </div>
62019fc183 ) : view === "unified" ? (
62019fc184 <UnifiedView hunks={file.hunks} />
62019fc185 ) : (
62019fc186 <SplitView hunks={file.hunks} />
62019fc187 )}
62019fc188 </div>
62019fc189 ))}
62019fc190 </>
62019fc191 );
62019fc192}
62019fc193
62019fc194function UnifiedView({ hunks }: { hunks: DiffHunk[] }) {
62019fc195 return (
62019fc196 <div className="overflow-x-auto shiki" style={{ border: "1px solid var(--border-subtle)" }}>
62019fc197 <table className="w-full text-sm font-mono" style={{ borderCollapse: "collapse" }}>
62019fc198 <tbody>
62019fc199 {hunks.flatMap((hunk, hi) => [
62019fc200 <tr key={`hdr-${hi}`}>
62019fc201 <td
62019fc202 colSpan={3}
62019fc203 className="text-xs py-1 px-3"
62019fc204 style={{
62019fc205 backgroundColor: "var(--bg-inset)",
62019fc206 color: "var(--text-faint)",
62019fc207 }}
62019fc208 >
62019fc209 {hunk.header}
62019fc210 </td>
62019fc211 </tr>,
62019fc212 ...hunk.lines.map((line, li) => (
62019fc213 <tr
62019fc214 key={`${hi}-${li}`}
62019fc215 style={{
62019fc216 backgroundColor:
62019fc217 line.type === "add"
62019fc218 ? "var(--diff-add-bg)"
62019fc219 : line.type === "del"
62019fc220 ? "var(--diff-del-bg)"
62019fc221 : "transparent",
62019fc222 }}
62019fc223 >
62019fc224 <td
818dc90225 className="text-right select-none px-1 py-0 w-8"
62019fc226 style={{
62019fc227 color: "var(--text-faint)",
62019fc228 borderRight: "1px solid var(--divide)",
62019fc229 fontSize: "0.75rem",
62019fc230 }}
62019fc231 >
62019fc232 {line.oldNum ?? ""}
62019fc233 </td>
62019fc234 <td
818dc90235 className="text-right select-none px-1 py-0 w-8"
62019fc236 style={{
62019fc237 color: "var(--text-faint)",
62019fc238 borderRight: "1px solid var(--divide)",
62019fc239 fontSize: "0.75rem",
62019fc240 }}
62019fc241 >
62019fc242 {line.newNum ?? ""}
62019fc243 </td>
62019fc244 {line.html ? (
62019fc245 <td
62019fc246 className="pl-3 py-0 whitespace-pre"
62019fc247 dangerouslySetInnerHTML={{ __html: line.html }}
62019fc248 />
62019fc249 ) : (
62019fc250 <td
62019fc251 className="pl-3 py-0 whitespace-pre"
62019fc252 style={{
62019fc253 color:
62019fc254 line.type === "add"
62019fc255 ? "var(--diff-add-text)"
62019fc256 : line.type === "del"
62019fc257 ? "var(--diff-del-text)"
62019fc258 : "var(--text-secondary)",
62019fc259 }}
62019fc260 >
62019fc261 {line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
62019fc262 {line.content}
62019fc263 </td>
62019fc264 )}
62019fc265 </tr>
62019fc266 )),
62019fc267 ])}
62019fc268 </tbody>
62019fc269 </table>
62019fc270 </div>
62019fc271 );
62019fc272}
62019fc273
62019fc274function SplitView({ hunks }: { hunks: DiffHunk[] }) {
62019fc275 return (
62019fc276 <div className="overflow-x-auto shiki" style={{ border: "1px solid var(--border-subtle)" }}>
62019fc277 <table className="w-full text-sm font-mono" style={{ borderCollapse: "collapse", tableLayout: "fixed" }}>
818dc90278 <colgroup>
818dc90279 <col style={{ width: "2.5rem" }} />
818dc90280 <col />
818dc90281 <col style={{ width: "2.5rem" }} />
818dc90282 <col />
818dc90283 </colgroup>
62019fc284 <tbody>
62019fc285 {hunks.flatMap((hunk, hi) => {
62019fc286 const rows = buildSplitRows(hunk);
62019fc287 return [
62019fc288 <tr key={`hdr-${hi}`}>
62019fc289 <td
62019fc290 colSpan={4}
62019fc291 className="text-xs py-1 px-3"
62019fc292 style={{
62019fc293 backgroundColor: "var(--bg-inset)",
62019fc294 color: "var(--text-faint)",
62019fc295 }}
62019fc296 >
62019fc297 {hunk.header}
62019fc298 </td>
62019fc299 </tr>,
62019fc300 ...rows.map((row, ri) => (
62019fc301 <tr key={`${hi}-${ri}`}>
62019fc302 {/* Left side (old) */}
62019fc303 <td
818dc90304 className="text-right select-none px-1 py-0"
62019fc305 style={{
62019fc306 color: "var(--text-faint)",
62019fc307 backgroundColor: cellBg(row.left.type),
62019fc308 borderRight: "1px solid var(--divide)",
62019fc309 fontSize: "0.75rem",
62019fc310 }}
62019fc311 >
62019fc312 {row.left.num ?? ""}
62019fc313 </td>
62019fc314 {row.left.html ? (
62019fc315 <td
62019fc316 className="pl-3 py-0 whitespace-pre overflow-hidden"
62019fc317 style={{
62019fc318 backgroundColor: cellBg(row.left.type),
62019fc319 borderRight: "1px solid var(--border-subtle)",
62019fc320 }}
62019fc321 dangerouslySetInnerHTML={{ __html: row.left.html }}
62019fc322 />
62019fc323 ) : (
62019fc324 <td
62019fc325 className="pl-3 py-0 whitespace-pre overflow-hidden"
62019fc326 style={{
62019fc327 color: cellColor(row.left.type),
62019fc328 backgroundColor: cellBg(row.left.type),
62019fc329 borderRight: "1px solid var(--border-subtle)",
62019fc330 }}
62019fc331 >
62019fc332 {row.left.type === "del" ? "-" : row.left.type === "context" ? " " : ""}
62019fc333 {row.left.content}
62019fc334 </td>
62019fc335 )}
62019fc336 {/* Right side (new) */}
62019fc337 <td
818dc90338 className="text-right select-none px-1 py-0"
62019fc339 style={{
62019fc340 color: "var(--text-faint)",
62019fc341 backgroundColor: cellBg(row.right.type),
62019fc342 borderRight: "1px solid var(--divide)",
62019fc343 fontSize: "0.75rem",
62019fc344 }}
62019fc345 >
62019fc346 {row.right.num ?? ""}
62019fc347 </td>
62019fc348 {row.right.html ? (
62019fc349 <td
62019fc350 className="pl-3 py-0 whitespace-pre overflow-hidden"
62019fc351 style={{
62019fc352 backgroundColor: cellBg(row.right.type),
62019fc353 }}
62019fc354 dangerouslySetInnerHTML={{ __html: row.right.html }}
62019fc355 />
62019fc356 ) : (
62019fc357 <td
62019fc358 className="pl-3 py-0 whitespace-pre overflow-hidden"
62019fc359 style={{
62019fc360 color: cellColor(row.right.type),
62019fc361 backgroundColor: cellBg(row.right.type),
62019fc362 }}
62019fc363 >
62019fc364 {row.right.type === "add" ? "+" : row.right.type === "context" ? " " : ""}
62019fc365 {row.right.content}
62019fc366 </td>
62019fc367 )}
62019fc368 </tr>
62019fc369 )),
62019fc370 ];
62019fc371 })}
62019fc372 </tbody>
62019fc373 </table>
62019fc374 </div>
62019fc375 );
62019fc376}