11'use client'
22
3+ import type { FileDiffMetadata , FileDiffOptions } from '@pierre/diffs'
34import type { ReactNode } from 'react'
45import type { GitDiff } from '../../../index'
6+ import { parsePatchFiles } from '@pierre/diffs'
7+ import { FileDiff } from '@pierre/diffs/react'
8+ import { useMemo , useState } from 'react'
59import { cn } from '../../lib/utils'
10+ import { useColorScheme } from '../theme'
611import { Badge } from '../ui/badge'
712import { IconButton } from '../ui/button'
813import { 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