Skip to content

Commit 171a200

Browse files
committed
feat(git): make commit files expandable, drop the plain file list
In commit details, each changed file now sits behind a disclosure header (filename, change icon, +/- counts) that expands to its @pierre/diffs patch, replacing the separate plain-text "N files changed" list. The list is kept only as the static-build fallback, where no patch is baked.
1 parent 2070066 commit 171a200

3 files changed

Lines changed: 125 additions & 28 deletions

File tree

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: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
'use client'
22

3-
import type { FileDiffOptions } from '@pierre/diffs'
3+
import type { FileDiffMetadata, FileDiffOptions } from '@pierre/diffs'
44
import type { ReactNode } from 'react'
55
import type { GitDiff } from '../../../index'
66
import { parsePatchFiles } from '@pierre/diffs'
77
import { FileDiff } from '@pierre/diffs/react'
8-
import { useMemo } from 'react'
8+
import { useMemo, useState } from 'react'
99
import { cn } from '../../lib/utils'
1010
import { useColorScheme } from '../theme'
1111
import { Badge } from '../ui/badge'
@@ -40,27 +40,110 @@ function parsePatch(patch: string) {
4040
}
4141
}
4242

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+
43112
/**
44113
* Renders a unified git patch with `@pierre/diffs` (diffs.com) — Shiki syntax
45114
* highlighting, per-file headers, and a theme synced to the app. Set
46115
* `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).
48118
*/
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 }) {
50120
const scheme = useColorScheme()
51121
const files = useMemo(() => (patch ? parsePatch(patch) : []), [patch])
52122
const options = useMemo<FileDiffOptions<undefined>>(() => ({
53123
theme: DIFF_THEME,
54124
themeType: scheme,
55125
diffStyle: 'unified',
56126
diffIndicators: 'classic',
57-
}), [scheme])
127+
// In collapsible mode the disclosure header replaces the built-in one.
128+
disableFileHeader: collapsible,
129+
}), [scheme, collapsible])
58130

59131
if (loading)
60132
return <Skeleton className="h-40 w-full" />
61133
if (!patch || files.length === 0)
62134
return <p className="color-muted p-3 text-sm">No textual diff available (binary or unchanged).</p>
63135

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+
64147
const body = (
65148
<>
66149
<div className="flex flex-col text-sm [&>*+*]:border-t [&>*+*]:border-base">

0 commit comments

Comments
 (0)