diff --git a/src/components/DiffCard.tsx b/src/components/DiffCard.tsx index 65376e00..c80b8b54 100644 --- a/src/components/DiffCard.tsx +++ b/src/components/DiffCard.tsx @@ -18,9 +18,15 @@ interface FileInfo { imageNew: string | null; } +interface Hunk { + header: string; // The @@ line + lines: string[]; +} + interface FileDiff { file: FileInfo; - hunks: string[]; + hunks: Hunk[]; + rawContent: string; // Full diff content for copy } interface Props { @@ -36,6 +42,24 @@ interface Props { onMouseLeave?: () => void; } +function parseHunks(content: string): Hunk[] { + const hunks: Hunk[] = []; + const lines = content.split("\n"); + let currentHunk: Hunk | null = null; + + for (const line of lines) { + if (line.startsWith("@@")) { + if (currentHunk) hunks.push(currentHunk); + currentHunk = { header: line, lines: [] }; + } else if (currentHunk) { + currentHunk.lines.push(line); + } + // Lines before first @@ (index, ---, +++) are skipped from hunks + } + if (currentHunk) hunks.push(currentHunk); + return hunks; +} + function parseDiff(raw: string, files: FileInfo[]): FileDiff[] { const fileMap = new Map(files.map((f) => [f.name, f])); const result: FileDiff[] = []; @@ -59,13 +83,14 @@ function parseDiff(raw: string, files: FileInfo[]): FileDiff[] { }; // Everything after the header is the diff content const content = lines.slice(1).join("\n"); - result.push({ file, hunks: [content] }); + const hunks = parseHunks(content); + result.push({ file, hunks, rawContent: content }); } // Add files from numstat that have no diff section (binary only) for (const f of files) { if (f.binary && !result.find((r) => r.file.name === f.name)) { - result.push({ file: f, hunks: [] }); + result.push({ file: f, hunks: [], rawContent: "" }); } } @@ -120,9 +145,12 @@ export function DiffCard({ const [fileDiffs, setFileDiffs] = useState([]); const [loading, setLoading] = useState(true); const [expandedFiles, setExpandedFiles] = useState>(() => new Set()); + const [collapsedHunks, setCollapsedHunks] = useState>(() => new Set()); + const [copyFeedback, setCopyFeedback] = useState(false); const [pos, setPos] = useState({ x: anchorX + 16, y: anchorY }); const [size, setSize] = useState({ w: 400, h: 340 }); const [justPinned, setJustPinned] = useState(false); + const scrollContainerRef = useRef(null); const dragRef = useRef<{ startX: number; startY: number; @@ -281,6 +309,101 @@ export function DiffCard({ const totalAdd = fileDiffs.reduce((s, f) => s + f.file.additions, 0); const totalDel = fileDiffs.reduce((s, f) => s + f.file.deletions, 0); + // Build flat list of all hunk anchors for navigation + const allHunkIds = useMemo(() => { + const ids: string[] = []; + for (const fd of fileDiffs) { + for (let i = 0; i < fd.hunks.length; i++) { + ids.push(`${fd.file.name}:${i}`); + } + } + return ids; + }, [fileDiffs]); + + const toggleHunkCollapse = useCallback((hunkId: string) => { + setCollapsedHunks((prev) => { + const next = new Set(prev); + if (next.has(hunkId)) next.delete(hunkId); + else next.add(hunkId); + return next; + }); + }, []); + + const scrollToHunk = useCallback((hunkId: string) => { + const container = scrollContainerRef.current; + if (!container) return; + const el = container.querySelector(`[data-hunk-id="${hunkId}"]`); + if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); + }, []); + + const navigateHunk = useCallback( + (direction: "prev" | "next") => { + if (allHunkIds.length === 0) return; + const container = scrollContainerRef.current; + if (!container) return; + + // Find which hunk is currently most visible + let currentIdx = -1; + const containerRect = container.getBoundingClientRect(); + for (let i = 0; i < allHunkIds.length; i++) { + const el = container.querySelector( + `[data-hunk-id="${allHunkIds[i]}"]`, + ); + if (el) { + const rect = el.getBoundingClientRect(); + if (rect.top <= containerRect.top + containerRect.height / 3) { + currentIdx = i; + } + } + } + + const targetIdx = + direction === "next" + ? Math.min(currentIdx + 1, allHunkIds.length - 1) + : Math.max(currentIdx - 1, 0); + + // Ensure the file containing this hunk is expanded + const targetHunkId = allHunkIds[targetIdx]; + if (!targetHunkId) return; + const fileName = targetHunkId.substring( + 0, + targetHunkId.lastIndexOf(":"), + ); + setExpandedFiles((prev) => { + if (prev.has(fileName)) return prev; + const next = new Set(prev); + next.add(fileName); + return next; + }); + // Also uncollapse the target hunk if it's collapsed + setCollapsedHunks((prev) => { + if (!prev.has(targetHunkId)) return prev; + const next = new Set(prev); + next.delete(targetHunkId); + return next; + }); + + // Scroll after a tick so the DOM can update + requestAnimationFrame(() => scrollToHunk(targetHunkId)); + }, + [allHunkIds, scrollToHunk], + ); + + const handleCopyDiff = useCallback(() => { + const fullText = fileDiffs + .map((fd) => { + const hunkText = fd.hunks + .map((h) => h.header + "\n" + h.lines.join("\n")) + .join("\n"); + return `diff --git a/${fd.file.name} b/${fd.file.name}\n${hunkText}`; + }) + .join("\n"); + navigator.clipboard.writeText(fullText).then(() => { + setCopyFeedback(true); + setTimeout(() => setCopyFeedback(false), 1500); + }); + }, [fileDiffs]); + // Connection line endpoints: worktree right edge → DiffCard left edge const lineX1 = anchorX; const lineY1 = anchorY + 20; @@ -355,6 +478,84 @@ export function DiffCard({ )}
+ {/* Hunk navigation */} + {!loading && allHunkIds.length > 0 && ( +
+ + +
+ )} + {/* Copy button */} + {!loading && fileDiffs.length > 0 && ( + + )} {pinned && (
) : ( fileDiffs.map((fd) => ( -
+
{/* File header */}
- ); - })} - + + + + + {hunk.header} + + + {/* Hunk body */} + {!isCollapsed && ( +
+                                  {hunk.lines.map((line, i) => {
+                                    let color = "var(--text-secondary)";
+                                    let bg = "transparent";
+                                    let lineNumOld = "";
+                                    let lineNumNew = "";
+                                    if (
+                                      line.startsWith("+") &&
+                                      !line.startsWith("+++")
+                                    ) {
+                                      color = "var(--cyan)";
+                                      bg = "rgba(80, 227, 194, 0.06)";
+                                      lineNumNew = String(newLine);
+                                      newLine++;
+                                    } else if (
+                                      line.startsWith("-") &&
+                                      !line.startsWith("---")
+                                    ) {
+                                      color = "var(--red)";
+                                      bg = "rgba(238, 0, 0, 0.06)";
+                                      lineNumOld = String(oldLine);
+                                      oldLine++;
+                                    } else if (
+                                      line.startsWith("index ") ||
+                                      line.startsWith("---") ||
+                                      line.startsWith("+++")
+                                    ) {
+                                      color = "var(--text-muted)";
+                                    } else {
+                                      // Context line
+                                      lineNumOld = String(oldLine);
+                                      lineNumNew = String(newLine);
+                                      oldLine++;
+                                      newLine++;
+                                    }
+                                    return (
+                                      
+ + {lineNumOld} + + + {lineNumNew} + + + {line || " "} + +
+ ); + })} +
+ )} +
+ ); + })} + )} )} diff --git a/src/components/FileTreeCard.tsx b/src/components/FileTreeCard.tsx index 7160eb6d..4a8ba4d0 100644 --- a/src/components/FileTreeCard.tsx +++ b/src/components/FileTreeCard.tsx @@ -335,6 +335,16 @@ export function FileTreeCard({ > {t.files} + {!loading && entries.has(worktreePath) && ( + + {(() => { + const rootItems = entries.get(worktreePath) ?? []; + const dirs = rootItems.filter((e) => e.isDirectory).length; + const files = rootItems.length - dirs; + return t.filetree_summary(files, dirs); + })()} + + )}
{pinned && (