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
6 changes: 5 additions & 1 deletion packages/devframe/src/node/services-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,11 @@ export async function importServicePackage(
lastError = error
continue
}
return await import(pathToFileURL(resolved).href)
// `resolved` is a runtime-resolved absolute path, so this is a fully
// dynamic import. Mark it bundler-ignored (webpack / turbopack) so hosts
// that bundle devframe's node code — e.g. a Next.js hub — leave it as a
// real runtime import instead of failing with "expression too dynamic".
return await import(/* webpackIgnore: true */ /* @vite-ignore */ /* turbopackIgnore: true */ pathToFileURL(resolved).href)
}
throw lastError
}
Expand Down
5 changes: 4 additions & 1 deletion plugins/git/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,17 @@
},
"dependencies": {
"@devframes/service-git": "workspace:*",
"@devframes/service-shiki": "workspace:*",
"cac": "catalog:deps",
"devframe": "workspace:*",
"pathe": "catalog:deps"
},
"devDependencies": {
"@antfu/design": "catalog:frontend",
"@devframes/plugin-git--assets": "workspace:*",
"@devframes/service-shiki": "workspace:*",
"@floating-ui/react": "catalog:frontend",
"@iconify-json/catppuccin": "catalog:frontend",
"@pierre/diffs": "catalog:frontend",
"@radix-ui/react-scroll-area": "catalog:frontend",
"@radix-ui/react-slot": "catalog:frontend",
"@storybook/addon-a11y": "catalog:storybook",
Expand All @@ -77,10 +78,12 @@
"@vitejs/plugin-react-oxc": "catalog:storybook",
"clsx": "catalog:frontend",
"colorjs.io": "catalog:frontend",
"diff": "catalog:frontend",
"h3": "catalog:deps",
"next": "catalog:frontend",
"react": "catalog:frontend",
"react-dom": "catalog:frontend",
"shiki": "catalog:deps",
"storybook": "catalog:storybook",
"tailwind-merge": "catalog:frontend",
"tsdown": "catalog:build",
Expand Down
42 changes: 42 additions & 0 deletions plugins/git/src/client/components/diff/build-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { buildFileModel } from './build-model'
import { parseUnifiedPatch } from './parse-patch'

const MODIFIED = `diff --git a/a.ts b/a.ts
index 1111111..2222222 100644
--- a/a.ts
+++ b/a.ts
@@ -1,3 +1,3 @@
a
-hello world
+hello there
c
`

describe('buildFileModel', () => {
it('reconstructs coherent old and new side sources', () => {
const [file] = parseUnifiedPatch(MODIFIED)
const model = buildFileModel(file)
expect(model.oldText).toBe('a\nhello world\nc')
expect(model.newText).toBe('a\nhello there\nc')
})

it('maps context lines to the new side and changed lines to their own side', () => {
const [file] = parseUnifiedPatch(MODIFIED)
const { lines } = buildFileModel(file).hunks[0]
expect(lines[0]).toMatchObject({ type: 'context', tokenSide: 'new', tokenLine: 0 })
expect(lines[1]).toMatchObject({ type: 'del', tokenSide: 'old', tokenLine: 1 })
expect(lines[2]).toMatchObject({ type: 'add', tokenSide: 'new', tokenLine: 1 })
expect(lines[3]).toMatchObject({ type: 'context', tokenSide: 'new', tokenLine: 2 })
})

it('computes intra-line word ranges for a paired del/add', () => {
const [file] = parseUnifiedPatch(MODIFIED)
const { lines } = buildFileModel(file).hunks[0]
// "hello world" -> "hello there": only the second word changed.
expect(lines[1].wordRanges).toEqual([[6, 11]])
expect(lines[2].wordRanges).toEqual([[6, 11]])
// Context lines carry no intra-line emphasis.
expect(lines[0].wordRanges).toEqual([])
})
})
123 changes: 123 additions & 0 deletions plugins/git/src/client/components/diff/build-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type { DiffFileChange, DiffLineChange } from './parse-patch'
import { diffWords } from 'diff'

/** A contiguous, changed character range `[start, end)` within a line's content. */
export type WordRange = [number, number]

export interface RenderLine extends DiffLineChange {
/** Which reconstructed side holds this line's syntax tokens. */
tokenSide: 'old' | 'new'
/** Index into that side's tokenized lines. */
tokenLine: number
/** Changed word ranges within `content`, for intra-line emphasis. */
wordRanges: WordRange[]
}

export interface RenderHunk {
header: string
lines: RenderLine[]
}

export interface DiffFileModel {
file: DiffFileChange
/** Reconstructed old-side source (context + removed lines), for tokenizing. */
oldText: string
/** Reconstructed new-side source (context + added lines), for tokenizing. */
newText: string
hunks: RenderHunk[]
}

/** Changed char ranges on each side of a modified line pair, via word-level diff. */
function wordDiffRanges(oldStr: string, newStr: string): { old: WordRange[], new: WordRange[] } {
const oldRanges: WordRange[] = []
const newRanges: WordRange[] = []
let oldOffset = 0
let newOffset = 0
for (const change of diffWords(oldStr, newStr)) {
const len = change.value.length
if (change.added) {
newRanges.push([newOffset, newOffset + len])
newOffset += len
}
else if (change.removed) {
oldRanges.push([oldOffset, oldOffset + len])
oldOffset += len
}
else {
oldOffset += len
newOffset += len
}
}
return { old: oldRanges, new: newRanges }
}

/**
* Pair the removed and added lines of each contiguous change block within a
* hunk (first removed with first added, and so on) and compute their word-level
* ranges. Returns a map from the line's index in `lines` to its changed ranges.
*/
function computeWordRanges(lines: DiffLineChange[]): Map<number, WordRange[]> {
const ranges = new Map<number, WordRange[]>()
let i = 0
while (i < lines.length) {
if (lines[i].type !== 'del') {
i++
continue
}
const delStart = i
while (i < lines.length && lines[i].type === 'del') i++
const addStart = i
while (i < lines.length && lines[i].type === 'add') i++
const pairs = Math.min(addStart - delStart, i - addStart)
for (let k = 0; k < pairs; k++) {
const delLine = lines[delStart + k]
const addLine = lines[addStart + k]
const { old, new: next } = wordDiffRanges(delLine.content, addLine.content)
if (old.length > 0)
ranges.set(delStart + k, old)
if (next.length > 0)
ranges.set(addStart + k, next)
}
}
return ranges
}

/**
* Turn a parsed file diff into a render model: the reconstructed old/new side
* source strings to feed the highlighter, plus per-line token coordinates and
* intra-line word ranges. Context lines join both sides (so each side tokenizes
* as coherent source), and are highlighted from the new side.
*/
export function buildFileModel(file: DiffFileChange): DiffFileModel {
const oldLines: string[] = []
const newLines: string[] = []

const hunks: RenderHunk[] = file.hunks.map((hunk) => {
const wordRanges = computeWordRanges(hunk.lines)
const lines: RenderLine[] = hunk.lines.map((line, idx) => {
let tokenSide: 'old' | 'new'
let tokenLine: number
if (line.type === 'del') {
tokenSide = 'old'
tokenLine = oldLines.length
oldLines.push(line.content)
}
else if (line.type === 'add') {
tokenSide = 'new'
tokenLine = newLines.length
newLines.push(line.content)
}
else {
// Context lines belong to both reconstructed sides; highlight from the new one.
oldLines.push(line.content)
tokenSide = 'new'
tokenLine = newLines.length
newLines.push(line.content)
}
return { ...line, tokenSide, tokenLine, wordRanges: wordRanges.get(idx) ?? [] }
})
return { header: hunk.header, lines }
})

return { file, oldText: oldLines.join('\n'), newText: newLines.join('\n'), hunks }
}
86 changes: 86 additions & 0 deletions plugins/git/src/client/components/diff/diff-file.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
'use client'

import type { CSSProperties } from 'react'
import type { DiffFileModel, RenderHunk, RenderLine } from './build-model'
import type { TokenLines } from './use-diff-tokens'
import { cn } from '../../lib/utils'
import { Skeleton } from '../ui/skeleton'
import { buildSegments } from './render-segments'
import { useDiffTokens } from './use-diff-tokens'

const NUMBER_CELL = 'w-10 shrink-0 select-none px-1.5 text-right tabular-nums color-faint'

/** A line's content, split into syntax-colored segments with intra-line emphasis. */
function LineContent({ line, tokens }: { line: RenderLine, tokens: TokenLines | null }) {
const segments = buildSegments(tokens?.[line.tokenLine], line.content, line.wordRanges)
const changedBg = line.type === 'add' ? 'bg-success/25' : 'bg-error/25'
return (
<>
{segments.map((segment, i) => (
<span
key={i}
className={cn('dark:[color:var(--shiki-dark)]', segment.changed && changedBg)}
style={segment.style as CSSProperties | undefined}
>
{segment.text}
</span>
))}
</>
)
}

/** One diff row: old/new line-number gutters, the +/- marker, and the code. */
function DiffLine({ line, tokens }: { line: RenderLine, tokens: TokenLines | null }) {
const bg = line.type === 'add' ? 'bg-success/10' : line.type === 'del' ? 'bg-error/10' : ''
const marker = line.type === 'add' ? '+' : line.type === 'del' ? '−' : ' '
const markerColor = line.type === 'add' ? 'text-success' : line.type === 'del' ? 'text-error' : 'color-faint'
return (
<div className={cn('flex', bg)}>
<span className={NUMBER_CELL}>{line.oldNumber ?? ''}</span>
<span className={NUMBER_CELL}>{line.newNumber ?? ''}</span>
<span className={cn('w-4 shrink-0 select-none text-center', markerColor)}>{marker}</span>
<code className="min-w-0 flex-1 break-all whitespace-pre-wrap pr-2">
<LineContent line={line} tokens={tokens} />
</code>
</div>
)
}

/** A hunk: its `@@` header row followed by the hunk's lines. */
function DiffHunk({ hunk, oldTokens, newTokens }: { hunk: RenderHunk, oldTokens: TokenLines | null, newTokens: TokenLines | null }) {
return (
<div>
<div className="bg-secondary color-faint px-2 py-0.5">{hunk.header}</div>
{hunk.lines.map((line, i) => (
<DiffLine key={i} line={line} tokens={line.tokenSide === 'old' ? oldTokens : newTokens} />
))}
</div>
)
}

/**
* Render a single file's diff: highlight its reconstructed sides through the
* shiki service (skeleton until the tokens land) and lay out the hunks. Files
* with no textual hunks (binary or metadata-only) show a short note; when the
* highlight service is unavailable the diff renders plain (un-highlighted).
*/
export function DiffFile({ model }: { model: DiffFileModel }) {
const hasHunks = model.file.hunks.length > 0
const { oldTokens, newTokens, loading, unavailable } = useDiffTokens(model.oldText, model.newText, model.file.lang, hasHunks)

if (!hasHunks)
return <p className="color-muted px-3 py-2 text-xs">No textual diff (binary or metadata-only change).</p>

if (loading && !unavailable)
return <Skeleton className="m-2 h-20" />

const oldT = unavailable ? null : oldTokens
const newT = unavailable ? null : newTokens
return (
<div className="font-mono text-xs leading-5">
{model.hunks.map((hunk, i) => (
<DiffHunk key={i} hunk={hunk} oldTokens={oldT} newTokens={newT} />
))}
</div>
)
}
Loading
Loading