Skip to content

Commit 990a736

Browse files
authored
feat(git): render diffs with @pierre/diffs (diffs.com) (#77)
1 parent d090001 commit 990a736

7 files changed

Lines changed: 253 additions & 43 deletions

File tree

plugins/git/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
},
5353
"devDependencies": {
5454
"@antfu/design": "catalog:frontend",
55+
"@pierre/diffs": "catalog:frontend",
5556
"@radix-ui/react-scroll-area": "catalog:frontend",
5657
"@radix-ui/react-separator": "catalog:frontend",
5758
"@radix-ui/react-slot": "catalog:frontend",

plugins/git/src/client/components/theme.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,24 @@ export function useTheme() {
4949

5050
return { theme, toggle }
5151
}
52+
53+
/**
54+
* The color scheme currently applied to the document, tracked by observing the
55+
* `.dark` class on `<html>`. Unlike {@link useTheme}, this reacts to toggles
56+
* made anywhere in the app (or by Storybook), so Shiki-themed surfaces like the
57+
* diff viewer stay in step with the rest of the UI regardless of who flipped it.
58+
*/
59+
export function useColorScheme(): Theme {
60+
const [scheme, setScheme] = useState<Theme>('dark')
61+
62+
useEffect(() => {
63+
const root = document.documentElement
64+
const read = () => setScheme(root.classList.contains('dark') ? 'dark' : 'light')
65+
read()
66+
const observer = new MutationObserver(read)
67+
observer.observe(root, { attributes: true, attributeFilter: ['class'] })
68+
return () => observer.disconnect()
69+
}, [])
70+
71+
return scheme
72+
}

plugins/git/src/client/components/views/commit-details-view.stories.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,18 @@ index 1234567..89abcde 100644
1414
- )}
1515
+ {hasMore && (
1616
+ <div ref={sentinelRef}>Loading more…</div>
17-
+ )}`
17+
+ )}
18+
diff --git a/src/client/components/log-panel.tsx b/src/client/components/log-panel.tsx
19+
index 2345678..9abcdef 100644
20+
--- a/src/client/components/log-panel.tsx
21+
+++ b/src/client/components/log-panel.tsx
22+
@@ -12,7 +12,6 @@ export function LogPanel({ branch }: LogPanelProps) {
23+
const sentinelRef = useRef<HTMLDivElement>(null)
24+
- const [loadingMore, setLoadingMore] = useState(false)
25+
useIntersectionObserver(sentinelRef, onLoadMore)
26+
diff --git a/public/preview.png b/public/preview.png
27+
index 3456789..0abcdef 100644
28+
Binary files a/public/preview.png and b/public/preview.png differ`
1829

1930
const detail: CommitDetail = {
2031
isRepo: true,

plugins/git/src/client/components/views/commit-details-view.tsx

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
'use client'
22

33
import type { CommitDetail } from '../../../index'
4-
import { cn } from '../../lib/utils'
54
import { Badge } from '../ui/badge'
65
import { IconButton } from '../ui/button'
76
import { Icon } from '../ui/icon'
@@ -123,29 +122,33 @@ export function CommitDetailsView({ data, loading, error, onClose }: CommitDetai
123122
<span className="text-error">{`−${data.totalDeletions}`}</span>
124123
</span>
125124
</div>
126-
<ul className="space-y-0.5">
127-
{data.files.map(file => (
128-
<li key={file.path} className="flex items-center gap-2 font-mono text-xs">
129-
<span className="min-w-0 flex-1 truncate" title={file.path}>{file.path}</span>
130-
{file.binary
131-
? <Badge variant="secondary" className="px-1.5 py-0 text-[10px]">bin</Badge>
132-
: (
133-
<span className="shrink-0 tabular-nums">
134-
<span className="text-success">{`+${file.additions}`}</span>
135-
{' '}
136-
<span className="text-error">{`−${file.deletions}`}</span>
137-
</span>
138-
)}
139-
</li>
140-
))}
141-
</ul>
142-
</div>
143125

144-
<div className={cn('overflow-hidden rounded-md border')}>
145-
<div className="bg-secondary border-b px-3 py-1 text-xs font-medium">Patch</div>
126+
{/* Live: an expandable per-file diff list. Static builds bake no
127+
patch, so fall back to the plain changed-files list. */}
146128
{data.patch !== null
147-
? <DiffPatchView patch={data.patch} loading={false} truncated={data.truncated} scroll={false} />
148-
: <p className="color-muted p-3 text-xs">Patch is not available in static builds.</p>}
129+
? (
130+
<div className="overflow-hidden rounded-md border">
131+
<DiffPatchView patch={data.patch} loading={false} truncated={data.truncated} scroll={false} collapsible />
132+
</div>
133+
)
134+
: (
135+
<ul className="space-y-0.5">
136+
{data.files.map(file => (
137+
<li key={file.path} className="flex items-center gap-2 font-mono text-xs">
138+
<span className="min-w-0 flex-1 truncate" title={file.path}>{file.path}</span>
139+
{file.binary
140+
? <Badge variant="secondary" className="px-1.5 py-0 text-[10px]">bin</Badge>
141+
: (
142+
<span className="shrink-0 tabular-nums">
143+
<span className="text-success">{`+${file.additions}`}</span>
144+
{' '}
145+
<span className="text-error">{`−${file.deletions}`}</span>
146+
</span>
147+
)}
148+
</li>
149+
))}
150+
</ul>
151+
)}
149152
</div>
150153
</div>
151154
</ScrollArea>

plugins/git/src/client/components/views/diff-panel-view.tsx

Lines changed: 121 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
'use client'
22

3+
import type { FileDiffMetadata, FileDiffOptions } from '@pierre/diffs'
34
import type { ReactNode } from 'react'
45
import type { GitDiff } from '../../../index'
6+
import { parsePatchFiles } from '@pierre/diffs'
7+
import { FileDiff } from '@pierre/diffs/react'
8+
import { useMemo, useState } from 'react'
59
import { cn } from '../../lib/utils'
10+
import { useColorScheme } from '../theme'
611
import { Badge } from '../ui/badge'
712
import { IconButton } from '../ui/button'
813
import { Icon } from '../ui/icon'
@@ -21,39 +26,136 @@ export interface DiffPanelViewProps {
2126
patchSlot?: ReactNode
2227
}
2328

24-
function patchLineClass(line: string): string {
25-
if (line.startsWith('@@'))
26-
return 'color-active'
27-
if (line.startsWith('+') && !line.startsWith('+++'))
28-
return 'text-success'
29-
if (line.startsWith('-') && !line.startsWith('---'))
30-
return 'text-error'
31-
if (line.startsWith('diff ') || line.startsWith('index ') || line.startsWith('+++') || line.startsWith('---'))
32-
return 'color-muted font-semibold'
33-
return 'color-base'
29+
// Shiki themes for the diff renderer, chosen to sit alongside the @antfu/design
30+
// surfaces; @pierre/diffs picks light vs. dark from `themeType`.
31+
const DIFF_THEME = { light: 'vitesse-light', dark: 'vitesse-dark' } as const
32+
33+
/** Split a unified/git patch into its per-file diffs, tolerant of truncation. */
34+
function parsePatch(patch: string) {
35+
try {
36+
return parsePatchFiles(patch).flatMap(p => p.files)
37+
}
38+
catch {
39+
return []
40+
}
41+
}
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+
}
3467
}
3568

3669
/**
37-
* Pure renderer for a unified patch. Set `scroll={false}` to render inline
38-
* (no inner scroll area) when the patch already sits in a scrolling parent.
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.
3973
*/
40-
export function DiffPatchView({ patch, loading, truncated, scroll = true }: { patch: string | null, loading: boolean, truncated: boolean, scroll?: boolean }) {
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+
112+
/**
113+
* Renders a unified git patch with `@pierre/diffs` (diffs.com) — Shiki syntax
114+
* highlighting, per-file headers, and a theme synced to the app. Set
115+
* `scroll={false}` to render inline (no inner scroll area) when the patch
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).
118+
*/
119+
export function DiffPatchView({ patch, loading, truncated, scroll = true, collapsible = false }: { patch: string | null, loading: boolean, truncated: boolean, scroll?: boolean, collapsible?: boolean }) {
120+
const scheme = useColorScheme()
121+
const files = useMemo(() => (patch ? parsePatch(patch) : []), [patch])
122+
const options = useMemo<FileDiffOptions<undefined>>(() => ({
123+
theme: DIFF_THEME,
124+
themeType: scheme,
125+
diffStyle: 'unified',
126+
diffIndicators: 'classic',
127+
// In collapsible mode the disclosure header replaces the built-in one.
128+
disableFileHeader: collapsible,
129+
}), [scheme, collapsible])
130+
41131
if (loading)
42132
return <Skeleton className="h-40 w-full" />
43-
if (!patch)
133+
if (!patch || files.length === 0)
44134
return <p className="color-muted p-3 text-sm">No textual diff available (binary or unchanged).</p>
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+
45147
const body = (
46148
<>
47-
<pre className="font-mono text-xs leading-relaxed">
48-
{patch.split('\n').map((line, i) => (
49-
<div key={i} className={cn('px-3 whitespace-pre', patchLineClass(line))}>{line || ' '}</div>
149+
<div className="flex flex-col text-sm [&>*+*]:border-t [&>*+*]:border-base">
150+
{files.map((file, i) => (
151+
<FileDiff key={file.name || i} fileDiff={file} options={options} disableWorkerPool />
50152
))}
51-
</pre>
153+
</div>
52154
{truncated && <p className="text-warning px-3 py-1 text-xs">Patch truncated.</p>}
53155
</>
54156
)
55157
if (!scroll)
56-
return <div className="overflow-x-auto py-1">{body}</div>
158+
return <div>{body}</div>
57159
return <ScrollArea className="h-72 w-full">{body}</ScrollArea>
58160
}
59161

@@ -154,7 +256,6 @@ export function DiffPanelView(props: DiffPanelViewProps) {
154256

155257
{selected && (
156258
<div className="overflow-hidden rounded-md border">
157-
<div className="bg-secondary border-b px-3 py-1 font-mono text-xs">{selected}</div>
158259
{patchSlot}
159260
</div>
160261
)}

0 commit comments

Comments
 (0)