From 207006622d2bee8e15133cb5ef737c4d883af764 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 8 Jul 2026 23:46:49 +0000 Subject: [PATCH 1/2] feat(git): render diffs with @pierre/diffs (diffs.com) Replace the git plugin's line-prefix `
` patch renderer with the
Shiki-based diffs.com library, giving syntax-highlighted, per-file diffs
in both the working-tree/staged diff panel and the multi-file commit
details view. The diff theme tracks the app's light/dark toggle.
---
 plugins/git/package.json                      |  1 +
 plugins/git/src/client/components/theme.ts    | 21 ++++++
 .../components/views/diff-panel-view.tsx      | 56 ++++++++++-----
 pnpm-lock.yaml                                | 71 +++++++++++++++++++
 pnpm-workspace.yaml                           |  2 +
 5 files changed, 132 insertions(+), 19 deletions(-)

diff --git a/plugins/git/package.json b/plugins/git/package.json
index ed4734d..367b0c8 100644
--- a/plugins/git/package.json
+++ b/plugins/git/package.json
@@ -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",
diff --git a/plugins/git/src/client/components/theme.ts b/plugins/git/src/client/components/theme.ts
index 39f6fa8..62df363 100644
--- a/plugins/git/src/client/components/theme.ts
+++ b/plugins/git/src/client/components/theme.ts
@@ -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 ``. 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('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
+}
diff --git a/plugins/git/src/client/components/views/diff-panel-view.tsx b/plugins/git/src/client/components/views/diff-panel-view.tsx
index 293207a..f0c1939 100644
--- a/plugins/git/src/client/components/views/diff-panel-view.tsx
+++ b/plugins/git/src/client/components/views/diff-panel-view.tsx
@@ -1,8 +1,13 @@
 'use client'
 
+import type { 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 } 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'
@@ -21,39 +26,53 @@ 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 []
+  }
 }
 
 /**
- * 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.
+ * 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.
  */
 export function DiffPatchView({ patch, loading, truncated, scroll = true }: { patch: string | null, loading: boolean, truncated: boolean, scroll?: boolean }) {
+  const scheme = useColorScheme()
+  const files = useMemo(() => (patch ? parsePatch(patch) : []), [patch])
+  const options = useMemo>(() => ({
+    theme: DIFF_THEME,
+    themeType: scheme,
+    diffStyle: 'unified',
+    diffIndicators: 'classic',
+  }), [scheme])
+
   if (loading)
     return 
-  if (!patch)
+  if (!patch || files.length === 0)
     return 

No textual diff available (binary or unchanged).

+ const body = ( <> -
-        {patch.split('\n').map((line, i) => (
-          
{line || ' '}
+
+ {files.map((file, i) => ( + ))} -
+ {truncated &&

Patch truncated.

} ) if (!scroll) - return
{body}
+ return
{body}
return {body} } @@ -154,7 +173,6 @@ export function DiffPanelView(props: DiffPanelViewProps) { {selected && (
-
{selected}
{patchSlot}
)} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55d74ff..aecdf6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,6 +132,9 @@ catalogs: '@iconify-json/ph': specifier: ^1.2.2 version: 1.2.2 + '@pierre/diffs': + specifier: ^1.2.12 + version: 1.2.12 '@radix-ui/react-scroll-area': specifier: ^1.2.14 version: 1.2.14 @@ -912,6 +915,9 @@ importers: '@antfu/design': specifier: catalog:frontend version: 0.2.1(@antfu/utils@9.3.0)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.6.1)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.0)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3)) + '@pierre/diffs': + specifier: catalog:frontend + version: 1.2.12(@shikijs/themes@4.3.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-scroll-area': specifier: catalog:frontend version: 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -3383,6 +3389,36 @@ packages: resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} engines: {node: '>= 10.0.0'} + '@pierre/diffs@1.2.12': + resolution: {integrity: sha512-pY/gmgWL03WnagqCyCnBi3QtRXUv4hCIY6FYqd5b1ZGaoI6a4Bsji8j+yRl2RfzPh/8Hf19rCl1GE80G6a1cLQ==} + peerDependencies: + react: ^18.3.1 || ^19.0.0 + react-dom: ^18.3.1 || ^19.0.0 + + '@pierre/theme@1.1.0': + resolution: {integrity: sha512-GC2OWTAfTIIWWYhPCygwG8t2EtePQkRfON4MI2rwIkJylmiyqIttJID2dCL8sUD8cNdEvYkEyfEHHKMeCiDLoQ==} + engines: {vscode: ^1.0.0} + + '@pierre/theming@0.0.2': + resolution: {integrity: sha512-QM1M4stXfnzfaE8I8YbjXSApV8c+2dBsXJj8eYg9WTpBR/cTmCZIcfGnN4p13iRrYu2Br/R/OJfEL7uR8Qjctw==} + peerDependencies: + '@pierre/theme': ^1.1.0 + '@shikijs/themes': ^3.0.0 || ^4.0.0 + react: ^18.3.1 || ^19.0.0 + react-dom: ^18.3.1 || ^19.0.0 + shiki: ^3.0.0 || ^4.0.0 + peerDependenciesMeta: + '@pierre/theme': + optional: true + '@shikijs/themes': + optional: true + react: + optional: true + react-dom: + optional: true + shiki: + optional: true + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -5690,6 +5726,10 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + doctrine@3.0.0: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} @@ -6830,6 +6870,9 @@ packages: resolution: {integrity: sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==} engines: {node: '>=16.14'} + lru_map@0.4.1: + resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -10905,6 +10948,30 @@ snapshots: '@parcel/watcher-win32-ia32': 2.5.6 '@parcel/watcher-win32-x64': 2.5.6 + '@pierre/diffs@1.2.12(@shikijs/themes@4.3.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@pierre/theme': 1.1.0 + '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.3.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(shiki@4.3.1) + '@shikijs/transformers': 4.3.1 + diff: 9.0.0 + hast-util-to-html: 9.0.5 + lru_map: 0.4.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + shiki: 4.3.1 + transitivePeerDependencies: + - '@shikijs/themes' + + '@pierre/theme@1.1.0': {} + + '@pierre/theming@0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.3.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(shiki@4.3.1)': + optionalDependencies: + '@pierre/theme': 1.1.0 + '@shikijs/themes': 4.3.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + shiki: 4.3.1 + '@pkgjs/parseargs@0.11.0': optional: true @@ -13354,6 +13421,8 @@ snapshots: diff@8.0.4: {} + diff@9.0.0: {} + doctrine@3.0.0: dependencies: esutils: 2.0.3 @@ -14533,6 +14602,8 @@ snapshots: lru-cache@8.0.5: {} + lru_map@0.4.1: {} + lz-string@1.5.0: {} magic-regexp@0.10.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 95bfd74..1dd9614 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -19,6 +19,7 @@ strictPeerDependencies: false trustPolicy: no-downgrade trustPolicyExclude: - tinyexec@1.2.2 + - '@pierre/theme@1.1.0' packages: - packages/* - plugins/* @@ -77,6 +78,7 @@ catalogs: frontend: '@antfu/design': ^0.2.1 '@iconify-json/ph': ^1.2.2 + '@pierre/diffs': ^1.2.12 '@radix-ui/react-scroll-area': ^1.2.14 '@radix-ui/react-separator': ^1.1.11 '@radix-ui/react-slot': ^1.3.0 From 171a2003fae718024b7bbaf719d124535481125c Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 9 Jul 2026 04:35:13 +0000 Subject: [PATCH 2/2] 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. --- .../views/commit-details-view.stories.tsx | 13 ++- .../components/views/commit-details-view.tsx | 47 +++++----- .../components/views/diff-panel-view.tsx | 93 ++++++++++++++++++- 3 files changed, 125 insertions(+), 28 deletions(-) diff --git a/plugins/git/src/client/components/views/commit-details-view.stories.tsx b/plugins/git/src/client/components/views/commit-details-view.stories.tsx index 8e4dc47..916f7d7 100644 --- a/plugins/git/src/client/components/views/commit-details-view.stories.tsx +++ b/plugins/git/src/client/components/views/commit-details-view.stories.tsx @@ -14,7 +14,18 @@ index 1234567..89abcde 100644 - )} + {hasMore && ( +
Loading more…
-+ )}` ++ )} +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(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, diff --git a/plugins/git/src/client/components/views/commit-details-view.tsx b/plugins/git/src/client/components/views/commit-details-view.tsx index ed2473c..e332f0f 100644 --- a/plugins/git/src/client/components/views/commit-details-view.tsx +++ b/plugins/git/src/client/components/views/commit-details-view.tsx @@ -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' @@ -123,29 +122,33 @@ export function CommitDetailsView({ data, loading, error, onClose }: CommitDetai {`−${data.totalDeletions}`} -
    - {data.files.map(file => ( -
  • - {file.path} - {file.binary - ? bin - : ( - - {`+${file.additions}`} - {' '} - {`−${file.deletions}`} - - )} -
  • - ))} -
- -
-
Patch
+ {/* Live: an expandable per-file diff list. Static builds bake no + patch, so fall back to the plain changed-files list. */} {data.patch !== null - ? - :

Patch is not available in static builds.

} + ? ( +
+ +
+ ) + : ( +
    + {data.files.map(file => ( +
  • + {file.path} + {file.binary + ? bin + : ( + + {`+${file.additions}`} + {' '} + {`−${file.deletions}`} + + )} +
  • + ))} +
+ )}
diff --git a/plugins/git/src/client/components/views/diff-panel-view.tsx b/plugins/git/src/client/components/views/diff-panel-view.tsx index f0c1939..fc4df48 100644 --- a/plugins/git/src/client/components/views/diff-panel-view.tsx +++ b/plugins/git/src/client/components/views/diff-panel-view.tsx @@ -1,11 +1,11 @@ 'use client' -import type { FileDiffOptions } from '@pierre/diffs' +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 } from 'react' +import { useMemo, useState } from 'react' import { cn } from '../../lib/utils' import { useColorScheme } from '../theme' import { Badge } from '../ui/badge' @@ -40,13 +40,83 @@ function parsePatch(patch: string) { } } +/** 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' } + } +} + +/** + * 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. + */ +function FileDiffSection({ file, options, defaultOpen }: { file: FileDiffMetadata, options: FileDiffOptions, 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 ( +
+ + {open && ( + file.hunks.length > 0 + ? + :

No textual diff (binary or metadata-only change).

+ )} +
+ ) +} + /** * 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. + * 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 }: { patch: string | null, loading: boolean, truncated: boolean, scroll?: boolean }) { +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>(() => ({ @@ -54,13 +124,26 @@ export function DiffPatchView({ patch, loading, truncated, scroll = true }: { pa themeType: scheme, diffStyle: 'unified', diffIndicators: 'classic', - }), [scheme]) + // In collapsible mode the disclosure header replaces the built-in one. + disableFileHeader: collapsible, + }), [scheme, collapsible]) if (loading) return if (!patch || files.length === 0) return

No textual diff available (binary or unchanged).

+ if (collapsible) { + return ( +
+ {files.map((file, i) => ( + + ))} + {truncated &&

Patch truncated.

} +
+ ) + } + const body = ( <>