|
1 | 1 | 'use client' |
2 | 2 |
|
3 | | -import type { FileDiffOptions } from '@pierre/diffs' |
| 3 | +import type { FileDiffMetadata, FileDiffOptions } from '@pierre/diffs' |
4 | 4 | import type { ReactNode } from 'react' |
5 | 5 | import type { GitDiff } from '../../../index' |
6 | 6 | import { parsePatchFiles } from '@pierre/diffs' |
7 | 7 | import { FileDiff } from '@pierre/diffs/react' |
8 | | -import { useMemo } from 'react' |
| 8 | +import { useMemo, useState } from 'react' |
9 | 9 | import { cn } from '../../lib/utils' |
10 | 10 | import { useColorScheme } from '../theme' |
11 | 11 | import { Badge } from '../ui/badge' |
@@ -40,27 +40,110 @@ function parsePatch(patch: string) { |
40 | 40 | } |
41 | 41 | } |
42 | 42 |
|
| 43 | +/** Added / deleted line counts for a parsed file diff. */ |
| 44 | +function fileStats(file: FileDiffMetadata): { additions: number, deletions: number } { |
| 45 | + let additions = 0 |
| 46 | + let deletions = 0 |
| 47 | + for (const hunk of file.hunks) { |
| 48 | + additions += hunk.additionLines |
| 49 | + deletions += hunk.deletionLines |
| 50 | + } |
| 51 | + return { additions, deletions } |
| 52 | +} |
| 53 | + |
| 54 | +/** Phosphor icon + tint for a parsed file's change type. */ |
| 55 | +function changeTypeIcon(type: FileDiffMetadata['type']): { name: string, className: string } { |
| 56 | + switch (type) { |
| 57 | + case 'new': |
| 58 | + return { name: 'i-ph-file-plus-duotone', className: 'text-success' } |
| 59 | + case 'deleted': |
| 60 | + return { name: 'i-ph-file-x-duotone', className: 'text-error' } |
| 61 | + case 'rename-pure': |
| 62 | + case 'rename-changed': |
| 63 | + return { name: 'i-ph-file-arrow-up-duotone', className: 'color-muted' } |
| 64 | + default: |
| 65 | + return { name: 'i-ph-file-duotone', className: 'color-muted' } |
| 66 | + } |
| 67 | +} |
| 68 | + |
| 69 | +/** |
| 70 | + * A single file's diff behind a clickable disclosure header (filename, change |
| 71 | + * icon and +/- counts). The `@pierre/diffs` body mounts only while expanded, so |
| 72 | + * a many-file commit stays a scannable list until you open a file. |
| 73 | + */ |
| 74 | +function FileDiffSection({ file, options, defaultOpen }: { file: FileDiffMetadata, options: FileDiffOptions<undefined>, defaultOpen: boolean }) { |
| 75 | + const [open, setOpen] = useState(defaultOpen) |
| 76 | + const { additions, deletions } = fileStats(file) |
| 77 | + const icon = changeTypeIcon(file.type) |
| 78 | + const label = file.prevName ? `${file.prevName} → ${file.name}` : file.name |
| 79 | + |
| 80 | + return ( |
| 81 | + <div> |
| 82 | + <button |
| 83 | + type="button" |
| 84 | + onClick={() => setOpen(value => !value)} |
| 85 | + aria-expanded={open} |
| 86 | + className="hover:bg-active bg-secondary flex w-full items-center gap-2 px-2.5 py-1.5 text-left font-mono text-xs transition-colors" |
| 87 | + > |
| 88 | + <Icon name="i-ph-caret-right" className={cn('color-faint size-3 transition-transform', open && 'rotate-90')} /> |
| 89 | + <Icon name={icon.name} className={cn('size-3.5', icon.className)} /> |
| 90 | + <span className="min-w-0 flex-1 truncate" title={label}>{label}</span> |
| 91 | + {file.hunks.length > 0 |
| 92 | + ? ( |
| 93 | + <span className="shrink-0 tabular-nums"> |
| 94 | + <span className="text-success">{`+${additions}`}</span> |
| 95 | + {' '} |
| 96 | + <span className="text-error">{`−${deletions}`}</span> |
| 97 | + </span> |
| 98 | + ) |
| 99 | + : file.type.startsWith('rename') |
| 100 | + ? <Badge variant="secondary" className="px-1.5 py-0 text-[10px]">renamed</Badge> |
| 101 | + : <Badge variant="secondary" className="px-1.5 py-0 text-[10px]">bin</Badge>} |
| 102 | + </button> |
| 103 | + {open && ( |
| 104 | + file.hunks.length > 0 |
| 105 | + ? <FileDiff fileDiff={file} options={options} disableWorkerPool /> |
| 106 | + : <p className="color-muted px-3 py-2 text-xs">No textual diff (binary or metadata-only change).</p> |
| 107 | + )} |
| 108 | + </div> |
| 109 | + ) |
| 110 | +} |
| 111 | + |
43 | 112 | /** |
44 | 113 | * Renders a unified git patch with `@pierre/diffs` (diffs.com) — Shiki syntax |
45 | 114 | * highlighting, per-file headers, and a theme synced to the app. Set |
46 | 115 | * `scroll={false}` to render inline (no inner scroll area) when the patch |
47 | | - * already sits in a scrolling parent. |
| 116 | + * already sits in a scrolling parent. With `collapsible`, each file sits behind |
| 117 | + * a disclosure header (a scannable, expandable list of the changed files). |
48 | 118 | */ |
49 | | -export function DiffPatchView({ patch, loading, truncated, scroll = true }: { patch: string | null, loading: boolean, truncated: boolean, scroll?: boolean }) { |
| 119 | +export function DiffPatchView({ patch, loading, truncated, scroll = true, collapsible = false }: { patch: string | null, loading: boolean, truncated: boolean, scroll?: boolean, collapsible?: boolean }) { |
50 | 120 | const scheme = useColorScheme() |
51 | 121 | const files = useMemo(() => (patch ? parsePatch(patch) : []), [patch]) |
52 | 122 | const options = useMemo<FileDiffOptions<undefined>>(() => ({ |
53 | 123 | theme: DIFF_THEME, |
54 | 124 | themeType: scheme, |
55 | 125 | diffStyle: 'unified', |
56 | 126 | diffIndicators: 'classic', |
57 | | - }), [scheme]) |
| 127 | + // In collapsible mode the disclosure header replaces the built-in one. |
| 128 | + disableFileHeader: collapsible, |
| 129 | + }), [scheme, collapsible]) |
58 | 130 |
|
59 | 131 | if (loading) |
60 | 132 | return <Skeleton className="h-40 w-full" /> |
61 | 133 | if (!patch || files.length === 0) |
62 | 134 | return <p className="color-muted p-3 text-sm">No textual diff available (binary or unchanged).</p> |
63 | 135 |
|
| 136 | + if (collapsible) { |
| 137 | + return ( |
| 138 | + <div className="flex flex-col [&>*+*]:border-t [&>*+*]:border-base"> |
| 139 | + {files.map((file, i) => ( |
| 140 | + <FileDiffSection key={file.name || i} file={file} options={options} defaultOpen={files.length === 1} /> |
| 141 | + ))} |
| 142 | + {truncated && <p className="text-warning px-3 py-1 text-xs">Patch truncated.</p>} |
| 143 | + </div> |
| 144 | + ) |
| 145 | + } |
| 146 | + |
64 | 147 | const body = ( |
65 | 148 | <> |
66 | 149 | <div className="flex flex-col text-sm [&>*+*]:border-t [&>*+*]:border-base"> |
|
0 commit comments