Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions plugins/git/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
},
"devDependencies": {
"@antfu/design": "catalog:frontend",
"@pierre/diffs": "catalog:frontend",
"@radix-ui/react-scroll-area": "catalog:frontend",
"@radix-ui/react-separator": "catalog:frontend",
"@radix-ui/react-slot": "catalog:frontend",
Expand Down
21 changes: 21 additions & 0 deletions plugins/git/src/client/components/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,24 @@ export function useTheme() {

return { theme, toggle }
}

/**
* The color scheme currently applied to the document, tracked by observing the
* `.dark` class on `<html>`. Unlike {@link useTheme}, this reacts to toggles
* made anywhere in the app (or by Storybook), so Shiki-themed surfaces like the
* diff viewer stay in step with the rest of the UI regardless of who flipped it.
*/
export function useColorScheme(): Theme {
const [scheme, setScheme] = useState<Theme>('dark')

useEffect(() => {
const root = document.documentElement
const read = () => setScheme(root.classList.contains('dark') ? 'dark' : 'light')
read()
const observer = new MutationObserver(read)
observer.observe(root, { attributes: true, attributeFilter: ['class'] })
return () => observer.disconnect()
}, [])

return scheme
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,18 @@ index 1234567..89abcde 100644
- )}
+ {hasMore && (
+ <div ref={sentinelRef}>Loading more…</div>
+ )}`
+ )}
diff --git a/src/client/components/log-panel.tsx b/src/client/components/log-panel.tsx
index 2345678..9abcdef 100644
--- a/src/client/components/log-panel.tsx
+++ b/src/client/components/log-panel.tsx
@@ -12,7 +12,6 @@ export function LogPanel({ branch }: LogPanelProps) {
const sentinelRef = useRef<HTMLDivElement>(null)
- const [loadingMore, setLoadingMore] = useState(false)
useIntersectionObserver(sentinelRef, onLoadMore)
diff --git a/public/preview.png b/public/preview.png
index 3456789..0abcdef 100644
Binary files a/public/preview.png and b/public/preview.png differ`

const detail: CommitDetail = {
isRepo: true,
Expand Down
47 changes: 25 additions & 22 deletions plugins/git/src/client/components/views/commit-details-view.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
'use client'

import type { CommitDetail } from '../../../index'
import { cn } from '../../lib/utils'
import { Badge } from '../ui/badge'
import { IconButton } from '../ui/button'
import { Icon } from '../ui/icon'
Expand Down Expand Up @@ -123,29 +122,33 @@ export function CommitDetailsView({ data, loading, error, onClose }: CommitDetai
<span className="text-error">{`−${data.totalDeletions}`}</span>
</span>
</div>
<ul className="space-y-0.5">
{data.files.map(file => (
<li key={file.path} className="flex items-center gap-2 font-mono text-xs">
<span className="min-w-0 flex-1 truncate" title={file.path}>{file.path}</span>
{file.binary
? <Badge variant="secondary" className="px-1.5 py-0 text-[10px]">bin</Badge>
: (
<span className="shrink-0 tabular-nums">
<span className="text-success">{`+${file.additions}`}</span>
{' '}
<span className="text-error">{`−${file.deletions}`}</span>
</span>
)}
</li>
))}
</ul>
</div>

<div className={cn('overflow-hidden rounded-md border')}>
<div className="bg-secondary border-b px-3 py-1 text-xs font-medium">Patch</div>
{/* Live: an expandable per-file diff list. Static builds bake no
patch, so fall back to the plain changed-files list. */}
{data.patch !== null
? <DiffPatchView patch={data.patch} loading={false} truncated={data.truncated} scroll={false} />
: <p className="color-muted p-3 text-xs">Patch is not available in static builds.</p>}
? (
<div className="overflow-hidden rounded-md border">
<DiffPatchView patch={data.patch} loading={false} truncated={data.truncated} scroll={false} collapsible />
</div>
)
: (
<ul className="space-y-0.5">
{data.files.map(file => (
<li key={file.path} className="flex items-center gap-2 font-mono text-xs">
<span className="min-w-0 flex-1 truncate" title={file.path}>{file.path}</span>
{file.binary
? <Badge variant="secondary" className="px-1.5 py-0 text-[10px]">bin</Badge>
: (
<span className="shrink-0 tabular-nums">
<span className="text-success">{`+${file.additions}`}</span>
{' '}
<span className="text-error">{`−${file.deletions}`}</span>
</span>
)}
</li>
))}
</ul>
)}
</div>
</div>
</ScrollArea>
Expand Down
141 changes: 121 additions & 20 deletions plugins/git/src/client/components/views/diff-panel-view.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
'use client'

import type { FileDiffMetadata, FileDiffOptions } from '@pierre/diffs'
import type { ReactNode } from 'react'
import type { GitDiff } from '../../../index'
import { parsePatchFiles } from '@pierre/diffs'
import { FileDiff } from '@pierre/diffs/react'
import { useMemo, useState } from 'react'
import { cn } from '../../lib/utils'
import { useColorScheme } from '../theme'
import { Badge } from '../ui/badge'
import { IconButton } from '../ui/button'
import { Icon } from '../ui/icon'
Expand All @@ -21,39 +26,136 @@ export interface DiffPanelViewProps {
patchSlot?: ReactNode
}

function patchLineClass(line: string): string {
if (line.startsWith('@@'))
return 'color-active'
if (line.startsWith('+') && !line.startsWith('+++'))
return 'text-success'
if (line.startsWith('-') && !line.startsWith('---'))
return 'text-error'
if (line.startsWith('diff ') || line.startsWith('index ') || line.startsWith('+++') || line.startsWith('---'))
return 'color-muted font-semibold'
return 'color-base'
// Shiki themes for the diff renderer, chosen to sit alongside the @antfu/design
// surfaces; @pierre/diffs picks light vs. dark from `themeType`.
const DIFF_THEME = { light: 'vitesse-light', dark: 'vitesse-dark' } as const

/** Split a unified/git patch into its per-file diffs, tolerant of truncation. */
function parsePatch(patch: string) {
try {
return parsePatchFiles(patch).flatMap(p => p.files)
}
catch {
return []
}
}

/** Added / deleted line counts for a parsed file diff. */
function fileStats(file: FileDiffMetadata): { additions: number, deletions: number } {
let additions = 0
let deletions = 0
for (const hunk of file.hunks) {
additions += hunk.additionLines
deletions += hunk.deletionLines
}
return { additions, deletions }
}

/** Phosphor icon + tint for a parsed file's change type. */
function changeTypeIcon(type: FileDiffMetadata['type']): { name: string, className: string } {
switch (type) {
case 'new':
return { name: 'i-ph-file-plus-duotone', className: 'text-success' }
case 'deleted':
return { name: 'i-ph-file-x-duotone', className: 'text-error' }
case 'rename-pure':
case 'rename-changed':
return { name: 'i-ph-file-arrow-up-duotone', className: 'color-muted' }
default:
return { name: 'i-ph-file-duotone', className: 'color-muted' }
}
}

/**
* Pure renderer for a unified patch. Set `scroll={false}` to render inline
* (no inner scroll area) when the patch already sits in a scrolling parent.
* A single file's diff behind a clickable disclosure header (filename, change
* icon and +/- counts). The `@pierre/diffs` body mounts only while expanded, so
* a many-file commit stays a scannable list until you open a file.
*/
export function DiffPatchView({ patch, loading, truncated, scroll = true }: { patch: string | null, loading: boolean, truncated: boolean, scroll?: boolean }) {
function FileDiffSection({ file, options, defaultOpen }: { file: FileDiffMetadata, options: FileDiffOptions<undefined>, defaultOpen: boolean }) {
const [open, setOpen] = useState(defaultOpen)
const { additions, deletions } = fileStats(file)
const icon = changeTypeIcon(file.type)
const label = file.prevName ? `${file.prevName} → ${file.name}` : file.name

return (
<div>
<button
type="button"
onClick={() => setOpen(value => !value)}
aria-expanded={open}
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"
>
<Icon name="i-ph-caret-right" className={cn('color-faint size-3 transition-transform', open && 'rotate-90')} />
<Icon name={icon.name} className={cn('size-3.5', icon.className)} />
<span className="min-w-0 flex-1 truncate" title={label}>{label}</span>
{file.hunks.length > 0
? (
<span className="shrink-0 tabular-nums">
<span className="text-success">{`+${additions}`}</span>
{' '}
<span className="text-error">{`−${deletions}`}</span>
</span>
)
: file.type.startsWith('rename')
? <Badge variant="secondary" className="px-1.5 py-0 text-[10px]">renamed</Badge>
: <Badge variant="secondary" className="px-1.5 py-0 text-[10px]">bin</Badge>}
</button>
{open && (
file.hunks.length > 0
? <FileDiff fileDiff={file} options={options} disableWorkerPool />
: <p className="color-muted px-3 py-2 text-xs">No textual diff (binary or metadata-only change).</p>
)}
</div>
)
}

/**
* Renders a unified git patch with `@pierre/diffs` (diffs.com) — Shiki syntax
* highlighting, per-file headers, and a theme synced to the app. Set
* `scroll={false}` to render inline (no inner scroll area) when the patch
* already sits in a scrolling parent. With `collapsible`, each file sits behind
* a disclosure header (a scannable, expandable list of the changed files).
*/
export function DiffPatchView({ patch, loading, truncated, scroll = true, collapsible = false }: { patch: string | null, loading: boolean, truncated: boolean, scroll?: boolean, collapsible?: boolean }) {
const scheme = useColorScheme()
const files = useMemo(() => (patch ? parsePatch(patch) : []), [patch])
const options = useMemo<FileDiffOptions<undefined>>(() => ({
theme: DIFF_THEME,
themeType: scheme,
diffStyle: 'unified',
diffIndicators: 'classic',
// In collapsible mode the disclosure header replaces the built-in one.
disableFileHeader: collapsible,
}), [scheme, collapsible])

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

if (collapsible) {
return (
<div className="flex flex-col [&>*+*]:border-t [&>*+*]:border-base">
{files.map((file, i) => (
<FileDiffSection key={file.name || i} file={file} options={options} defaultOpen={files.length === 1} />
))}
{truncated && <p className="text-warning px-3 py-1 text-xs">Patch truncated.</p>}
</div>
)
}

const body = (
<>
<pre className="font-mono text-xs leading-relaxed">
{patch.split('\n').map((line, i) => (
<div key={i} className={cn('px-3 whitespace-pre', patchLineClass(line))}>{line || ' '}</div>
<div className="flex flex-col text-sm [&>*+*]:border-t [&>*+*]:border-base">
{files.map((file, i) => (
<FileDiff key={file.name || i} fileDiff={file} options={options} disableWorkerPool />
))}
</pre>
</div>
{truncated && <p className="text-warning px-3 py-1 text-xs">Patch truncated.</p>}
</>
)
if (!scroll)
return <div className="overflow-x-auto py-1">{body}</div>
return <div>{body}</div>
return <ScrollArea className="h-72 w-full">{body}</ScrollArea>
}

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

{selected && (
<div className="overflow-hidden rounded-md border">
<div className="bg-secondary border-b px-3 py-1 font-mono text-xs">{selected}</div>
{patchSlot}
</div>
)}
Expand Down
Loading
Loading