Skip to content

Commit 2a155a4

Browse files
antfubotantfu
andauthored
refactor(plugin-git): render diffs in-house via service-shiki, drop @pierre/diffs (#264)
Co-authored-by: Anthony Fu <github@antfu.me>
1 parent 01d8d08 commit 2a155a4

20 files changed

Lines changed: 1054 additions & 343 deletions

packages/devframe/src/node/services-install.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,11 @@ export async function importServicePackage(
6262
lastError = error
6363
continue
6464
}
65-
return await import(pathToFileURL(resolved).href)
65+
// `resolved` is a runtime-resolved absolute path, so this is a fully
66+
// dynamic import. Mark it bundler-ignored (webpack / turbopack) so hosts
67+
// that bundle devframe's node code — e.g. a Next.js hub — leave it as a
68+
// real runtime import instead of failing with "expression too dynamic".
69+
return await import(/* webpackIgnore: true */ /* @vite-ignore */ /* turbopackIgnore: true */ pathToFileURL(resolved).href)
6670
}
6771
throw lastError
6872
}

plugins/git/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,16 +56,17 @@
5656
},
5757
"dependencies": {
5858
"@devframes/service-git": "workspace:*",
59+
"@devframes/service-shiki": "workspace:*",
5960
"cac": "catalog:deps",
6061
"devframe": "workspace:*",
6162
"pathe": "catalog:deps"
6263
},
6364
"devDependencies": {
6465
"@antfu/design": "catalog:frontend",
6566
"@devframes/plugin-git--assets": "workspace:*",
67+
"@devframes/service-shiki": "workspace:*",
6668
"@floating-ui/react": "catalog:frontend",
6769
"@iconify-json/catppuccin": "catalog:frontend",
68-
"@pierre/diffs": "catalog:frontend",
6970
"@radix-ui/react-scroll-area": "catalog:frontend",
7071
"@radix-ui/react-slot": "catalog:frontend",
7172
"@storybook/addon-a11y": "catalog:storybook",
@@ -77,10 +78,12 @@
7778
"@vitejs/plugin-react-oxc": "catalog:storybook",
7879
"clsx": "catalog:frontend",
7980
"colorjs.io": "catalog:frontend",
81+
"diff": "catalog:frontend",
8082
"h3": "catalog:deps",
8183
"next": "catalog:frontend",
8284
"react": "catalog:frontend",
8385
"react-dom": "catalog:frontend",
86+
"shiki": "catalog:deps",
8487
"storybook": "catalog:storybook",
8588
"tailwind-merge": "catalog:frontend",
8689
"tsdown": "catalog:build",
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { buildFileModel } from './build-model'
3+
import { parseUnifiedPatch } from './parse-patch'
4+
5+
const MODIFIED = `diff --git a/a.ts b/a.ts
6+
index 1111111..2222222 100644
7+
--- a/a.ts
8+
+++ b/a.ts
9+
@@ -1,3 +1,3 @@
10+
a
11+
-hello world
12+
+hello there
13+
c
14+
`
15+
16+
describe('buildFileModel', () => {
17+
it('reconstructs coherent old and new side sources', () => {
18+
const [file] = parseUnifiedPatch(MODIFIED)
19+
const model = buildFileModel(file)
20+
expect(model.oldText).toBe('a\nhello world\nc')
21+
expect(model.newText).toBe('a\nhello there\nc')
22+
})
23+
24+
it('maps context lines to the new side and changed lines to their own side', () => {
25+
const [file] = parseUnifiedPatch(MODIFIED)
26+
const { lines } = buildFileModel(file).hunks[0]
27+
expect(lines[0]).toMatchObject({ type: 'context', tokenSide: 'new', tokenLine: 0 })
28+
expect(lines[1]).toMatchObject({ type: 'del', tokenSide: 'old', tokenLine: 1 })
29+
expect(lines[2]).toMatchObject({ type: 'add', tokenSide: 'new', tokenLine: 1 })
30+
expect(lines[3]).toMatchObject({ type: 'context', tokenSide: 'new', tokenLine: 2 })
31+
})
32+
33+
it('computes intra-line word ranges for a paired del/add', () => {
34+
const [file] = parseUnifiedPatch(MODIFIED)
35+
const { lines } = buildFileModel(file).hunks[0]
36+
// "hello world" -> "hello there": only the second word changed.
37+
expect(lines[1].wordRanges).toEqual([[6, 11]])
38+
expect(lines[2].wordRanges).toEqual([[6, 11]])
39+
// Context lines carry no intra-line emphasis.
40+
expect(lines[0].wordRanges).toEqual([])
41+
})
42+
})
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import type { DiffFileChange, DiffLineChange } from './parse-patch'
2+
import { diffWords } from 'diff'
3+
4+
/** A contiguous, changed character range `[start, end)` within a line's content. */
5+
export type WordRange = [number, number]
6+
7+
export interface RenderLine extends DiffLineChange {
8+
/** Which reconstructed side holds this line's syntax tokens. */
9+
tokenSide: 'old' | 'new'
10+
/** Index into that side's tokenized lines. */
11+
tokenLine: number
12+
/** Changed word ranges within `content`, for intra-line emphasis. */
13+
wordRanges: WordRange[]
14+
}
15+
16+
export interface RenderHunk {
17+
header: string
18+
lines: RenderLine[]
19+
}
20+
21+
export interface DiffFileModel {
22+
file: DiffFileChange
23+
/** Reconstructed old-side source (context + removed lines), for tokenizing. */
24+
oldText: string
25+
/** Reconstructed new-side source (context + added lines), for tokenizing. */
26+
newText: string
27+
hunks: RenderHunk[]
28+
}
29+
30+
/** Changed char ranges on each side of a modified line pair, via word-level diff. */
31+
function wordDiffRanges(oldStr: string, newStr: string): { old: WordRange[], new: WordRange[] } {
32+
const oldRanges: WordRange[] = []
33+
const newRanges: WordRange[] = []
34+
let oldOffset = 0
35+
let newOffset = 0
36+
for (const change of diffWords(oldStr, newStr)) {
37+
const len = change.value.length
38+
if (change.added) {
39+
newRanges.push([newOffset, newOffset + len])
40+
newOffset += len
41+
}
42+
else if (change.removed) {
43+
oldRanges.push([oldOffset, oldOffset + len])
44+
oldOffset += len
45+
}
46+
else {
47+
oldOffset += len
48+
newOffset += len
49+
}
50+
}
51+
return { old: oldRanges, new: newRanges }
52+
}
53+
54+
/**
55+
* Pair the removed and added lines of each contiguous change block within a
56+
* hunk (first removed with first added, and so on) and compute their word-level
57+
* ranges. Returns a map from the line's index in `lines` to its changed ranges.
58+
*/
59+
function computeWordRanges(lines: DiffLineChange[]): Map<number, WordRange[]> {
60+
const ranges = new Map<number, WordRange[]>()
61+
let i = 0
62+
while (i < lines.length) {
63+
if (lines[i].type !== 'del') {
64+
i++
65+
continue
66+
}
67+
const delStart = i
68+
while (i < lines.length && lines[i].type === 'del') i++
69+
const addStart = i
70+
while (i < lines.length && lines[i].type === 'add') i++
71+
const pairs = Math.min(addStart - delStart, i - addStart)
72+
for (let k = 0; k < pairs; k++) {
73+
const delLine = lines[delStart + k]
74+
const addLine = lines[addStart + k]
75+
const { old, new: next } = wordDiffRanges(delLine.content, addLine.content)
76+
if (old.length > 0)
77+
ranges.set(delStart + k, old)
78+
if (next.length > 0)
79+
ranges.set(addStart + k, next)
80+
}
81+
}
82+
return ranges
83+
}
84+
85+
/**
86+
* Turn a parsed file diff into a render model: the reconstructed old/new side
87+
* source strings to feed the highlighter, plus per-line token coordinates and
88+
* intra-line word ranges. Context lines join both sides (so each side tokenizes
89+
* as coherent source), and are highlighted from the new side.
90+
*/
91+
export function buildFileModel(file: DiffFileChange): DiffFileModel {
92+
const oldLines: string[] = []
93+
const newLines: string[] = []
94+
95+
const hunks: RenderHunk[] = file.hunks.map((hunk) => {
96+
const wordRanges = computeWordRanges(hunk.lines)
97+
const lines: RenderLine[] = hunk.lines.map((line, idx) => {
98+
let tokenSide: 'old' | 'new'
99+
let tokenLine: number
100+
if (line.type === 'del') {
101+
tokenSide = 'old'
102+
tokenLine = oldLines.length
103+
oldLines.push(line.content)
104+
}
105+
else if (line.type === 'add') {
106+
tokenSide = 'new'
107+
tokenLine = newLines.length
108+
newLines.push(line.content)
109+
}
110+
else {
111+
// Context lines belong to both reconstructed sides; highlight from the new one.
112+
oldLines.push(line.content)
113+
tokenSide = 'new'
114+
tokenLine = newLines.length
115+
newLines.push(line.content)
116+
}
117+
return { ...line, tokenSide, tokenLine, wordRanges: wordRanges.get(idx) ?? [] }
118+
})
119+
return { header: hunk.header, lines }
120+
})
121+
122+
return { file, oldText: oldLines.join('\n'), newText: newLines.join('\n'), hunks }
123+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
'use client'
2+
3+
import type { CSSProperties } from 'react'
4+
import type { DiffFileModel, RenderHunk, RenderLine } from './build-model'
5+
import type { TokenLines } from './use-diff-tokens'
6+
import { cn } from '../../lib/utils'
7+
import { Skeleton } from '../ui/skeleton'
8+
import { buildSegments } from './render-segments'
9+
import { useDiffTokens } from './use-diff-tokens'
10+
11+
const NUMBER_CELL = 'w-10 shrink-0 select-none px-1.5 text-right tabular-nums color-faint'
12+
13+
/** A line's content, split into syntax-colored segments with intra-line emphasis. */
14+
function LineContent({ line, tokens }: { line: RenderLine, tokens: TokenLines | null }) {
15+
const segments = buildSegments(tokens?.[line.tokenLine], line.content, line.wordRanges)
16+
const changedBg = line.type === 'add' ? 'bg-success/25' : 'bg-error/25'
17+
return (
18+
<>
19+
{segments.map((segment, i) => (
20+
<span
21+
key={i}
22+
className={cn('dark:[color:var(--shiki-dark)]', segment.changed && changedBg)}
23+
style={segment.style as CSSProperties | undefined}
24+
>
25+
{segment.text}
26+
</span>
27+
))}
28+
</>
29+
)
30+
}
31+
32+
/** One diff row: old/new line-number gutters, the +/- marker, and the code. */
33+
function DiffLine({ line, tokens }: { line: RenderLine, tokens: TokenLines | null }) {
34+
const bg = line.type === 'add' ? 'bg-success/10' : line.type === 'del' ? 'bg-error/10' : ''
35+
const marker = line.type === 'add' ? '+' : line.type === 'del' ? '−' : ' '
36+
const markerColor = line.type === 'add' ? 'text-success' : line.type === 'del' ? 'text-error' : 'color-faint'
37+
return (
38+
<div className={cn('flex', bg)}>
39+
<span className={NUMBER_CELL}>{line.oldNumber ?? ''}</span>
40+
<span className={NUMBER_CELL}>{line.newNumber ?? ''}</span>
41+
<span className={cn('w-4 shrink-0 select-none text-center', markerColor)}>{marker}</span>
42+
<code className="min-w-0 flex-1 break-all whitespace-pre-wrap pr-2">
43+
<LineContent line={line} tokens={tokens} />
44+
</code>
45+
</div>
46+
)
47+
}
48+
49+
/** A hunk: its `@@` header row followed by the hunk's lines. */
50+
function DiffHunk({ hunk, oldTokens, newTokens }: { hunk: RenderHunk, oldTokens: TokenLines | null, newTokens: TokenLines | null }) {
51+
return (
52+
<div>
53+
<div className="bg-secondary color-faint px-2 py-0.5">{hunk.header}</div>
54+
{hunk.lines.map((line, i) => (
55+
<DiffLine key={i} line={line} tokens={line.tokenSide === 'old' ? oldTokens : newTokens} />
56+
))}
57+
</div>
58+
)
59+
}
60+
61+
/**
62+
* Render a single file's diff: highlight its reconstructed sides through the
63+
* shiki service (skeleton until the tokens land) and lay out the hunks. Files
64+
* with no textual hunks (binary or metadata-only) show a short note; when the
65+
* highlight service is unavailable the diff renders plain (un-highlighted).
66+
*/
67+
export function DiffFile({ model }: { model: DiffFileModel }) {
68+
const hasHunks = model.file.hunks.length > 0
69+
const { oldTokens, newTokens, loading, unavailable } = useDiffTokens(model.oldText, model.newText, model.file.lang, hasHunks)
70+
71+
if (!hasHunks)
72+
return <p className="color-muted px-3 py-2 text-xs">No textual diff (binary or metadata-only change).</p>
73+
74+
if (loading && !unavailable)
75+
return <Skeleton className="m-2 h-20" />
76+
77+
const oldT = unavailable ? null : oldTokens
78+
const newT = unavailable ? null : newTokens
79+
return (
80+
<div className="font-mono text-xs leading-5">
81+
{model.hunks.map((hunk, i) => (
82+
<DiffHunk key={i} hunk={hunk} oldTokens={oldT} newTokens={newT} />
83+
))}
84+
</div>
85+
)
86+
}

0 commit comments

Comments
 (0)