diff --git a/packages/devframe/src/node/services-install.ts b/packages/devframe/src/node/services-install.ts index dd859b72..2a43fee9 100644 --- a/packages/devframe/src/node/services-install.ts +++ b/packages/devframe/src/node/services-install.ts @@ -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 } diff --git a/plugins/git/package.json b/plugins/git/package.json index ff1f03f3..0b9a8915 100644 --- a/plugins/git/package.json +++ b/plugins/git/package.json @@ -56,6 +56,7 @@ }, "dependencies": { "@devframes/service-git": "workspace:*", + "@devframes/service-shiki": "workspace:*", "cac": "catalog:deps", "devframe": "workspace:*", "pathe": "catalog:deps" @@ -63,9 +64,9 @@ "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", @@ -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", diff --git a/plugins/git/src/client/components/diff/build-model.test.ts b/plugins/git/src/client/components/diff/build-model.test.ts new file mode 100644 index 00000000..9e646c5b --- /dev/null +++ b/plugins/git/src/client/components/diff/build-model.test.ts @@ -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([]) + }) +}) diff --git a/plugins/git/src/client/components/diff/build-model.ts b/plugins/git/src/client/components/diff/build-model.ts new file mode 100644 index 00000000..014183b5 --- /dev/null +++ b/plugins/git/src/client/components/diff/build-model.ts @@ -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 { + const ranges = new Map() + 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 } +} diff --git a/plugins/git/src/client/components/diff/diff-file.tsx b/plugins/git/src/client/components/diff/diff-file.tsx new file mode 100644 index 00000000..71f17f70 --- /dev/null +++ b/plugins/git/src/client/components/diff/diff-file.tsx @@ -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) => ( + + {segment.text} + + ))} + + ) +} + +/** 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 ( +
+ {line.oldNumber ?? ''} + {line.newNumber ?? ''} + {marker} + + + +
+ ) +} + +/** 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 ( +
+
{hunk.header}
+ {hunk.lines.map((line, i) => ( + + ))} +
+ ) +} + +/** + * 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

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

+ + if (loading && !unavailable) + return + + const oldT = unavailable ? null : oldTokens + const newT = unavailable ? null : newTokens + return ( +
+ {model.hunks.map((hunk, i) => ( + + ))} +
+ ) +} diff --git a/plugins/git/src/client/components/diff/diff-view.tsx b/plugins/git/src/client/components/diff/diff-view.tsx new file mode 100644 index 00000000..a7d57e49 --- /dev/null +++ b/plugins/git/src/client/components/diff/diff-view.tsx @@ -0,0 +1,150 @@ +'use client' + +import type { FileStatusCode } from '@devframes/service-git' +import type { DiffChangeType, DiffFileChange } from './parse-patch' +import { useMemo, useState } from 'react' +import { cn } from '../../lib/utils' +import { Badge } from '../ui/badge' +import { FileIcon } from '../ui/file-icon' +import { Icon } from '../ui/icon' +import { ScrollArea } from '../ui/scroll-area' +import { Skeleton } from '../ui/skeleton' +import { StatusMark } from '../ui/status-mark' +import { buildFileModel } from './build-model' +import { DiffFile } from './diff-file' +import { parseUnifiedPatch } from './parse-patch' + +/** How the changed files are laid out. */ +type DiffLayout = 'flat' | 'collapsible' + +export interface DiffPatchViewProps { + /** Raw unified/git patch text; `null` while unavailable. */ + patch: string | null + loading: boolean + /** True when the patch was clipped server-side (a size cap). */ + truncated: boolean + /** Set `false` to render inline when a scrolling parent already wraps it. */ + scroll?: boolean + /** + * `flat` lists every file's diff; `collapsible` puts each behind a disclosure + * header (a scannable list that expands on demand). Defaults to `flat`. + */ + layout?: DiffLayout +} + +/** Map a parsed file's change type to a git status code (for the status mark). */ +function changeTypeStatus(type: DiffChangeType): FileStatusCode { + switch (type) { + case 'new': + return 'added' + case 'deleted': + return 'deleted' + case 'rename-pure': + case 'rename-changed': + return 'renamed' + default: + return 'modified' + } +} + +/** + * A single file's diff behind a clickable disclosure header (filename, change + * icon and +/- counts). The diff body mounts only while expanded, so a + * many-file commit stays a scannable list — and does no highlight work — until + * you open a file. + */ +function DiffFileSection({ file, defaultOpen }: { file: DiffFileChange, defaultOpen: boolean }) { + const [open, setOpen] = useState(defaultOpen) + const model = useMemo(() => buildFileModel(file), [file]) + const label = file.prevName ? `${file.prevName} → ${file.name}` : file.name + + return ( +
+ + {open && } +
+ ) +} + +/** A file's diff rendered inline (flat layout), with its filename header. */ +function DiffFileBlock({ file }: { file: DiffFileChange }) { + const model = useMemo(() => buildFileModel(file), [file]) + const label = file.prevName ? `${file.prevName} → ${file.name}` : file.name + return ( +
+
+ + + {label} + + {`+${file.additions}`} + {' '} + {`−${file.deletions}`} + +
+ +
+ ) +} + +/** + * Render a unified git patch with in-house diff rendering: the patch is parsed + * client-side and each file's changed lines are syntax-highlighted through the + * shared `@devframes/service-shiki` service (with intra-line word emphasis), so + * the client ships no highlighter of its own. Set `scroll={false}` to render + * inline; use `layout="collapsible"` for an expandable per-file list. + */ +export function DiffPatchView({ patch, loading, truncated, scroll = true, layout = 'flat' }: DiffPatchViewProps) { + const files = useMemo(() => (patch ? parseUnifiedPatch(patch) : []), [patch]) + + if (loading) + return + if (!patch || files.length === 0) + return

No textual diff available (binary or unchanged).

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

Patch truncated.

} +
+ ) + } + + const body = ( + <> +
+ {files.map((file, i) => ( + + ))} +
+ {truncated &&

Patch truncated.

} + + ) + if (!scroll) + return
{body}
+ return {body} +} diff --git a/plugins/git/src/client/components/diff/parse-patch.test.ts b/plugins/git/src/client/components/diff/parse-patch.test.ts new file mode 100644 index 00000000..3464ab3c --- /dev/null +++ b/plugins/git/src/client/components/diff/parse-patch.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest' +import { parseUnifiedPatch } from './parse-patch' + +const MODIFIED = `diff --git a/src/a.ts b/src/a.ts +index 1111111..2222222 100644 +--- a/src/a.ts ++++ b/src/a.ts +@@ -1,3 +1,3 @@ function foo() { + a +-b ++B + c +` + +const NEW_FILE = `diff --git a/new.md b/new.md +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/new.md +@@ -0,0 +1,2 @@ ++hello ++world +` + +const DELETED = `diff --git a/gone.txt b/gone.txt +deleted file mode 100644 +index 1111111..0000000 +--- a/gone.txt ++++ /dev/null +@@ -1,2 +0,0 @@ +-bye +-now +` + +const RENAME_PURE = `diff --git a/old/name.ts b/new/name.ts +similarity index 100% +rename from old/name.ts +rename to new/name.ts +` + +const RENAME_CHANGED = `diff --git a/old.ts b/new.ts +similarity index 80% +rename from old.ts +rename to new.ts +index 1111111..2222222 100644 +--- a/old.ts ++++ b/new.ts +@@ -1,2 +1,2 @@ + keep +-x ++y +` + +const BINARY = `diff --git a/img.png b/img.png +index 1111111..2222222 100644 +Binary files a/img.png and b/img.png differ +` + +const MULTI_HUNK = `diff --git a/m.ts b/m.ts +index 1111111..2222222 100644 +--- a/m.ts ++++ b/m.ts +@@ -1,2 +1,2 @@ + a +-b ++B +@@ -10,2 +10,3 @@ + x ++y + z +` + +const NO_NEWLINE = `diff --git a/n.txt b/n.txt +index 1111111..2222222 100644 +--- a/n.txt ++++ b/n.txt +@@ -1 +1 @@ +-old +\\ No newline at end of file ++new +\\ No newline at end of file +` + +// Hunk header claims 50 lines but only 3 follow (server truncated the patch). +const TRUNCATED = `diff --git a/t.ts b/t.ts +index 1111111..2222222 100644 +--- a/t.ts ++++ b/t.ts +@@ -1,50 +1,50 @@ + a +-b ++B` + +describe('parseUnifiedPatch', () => { + it('parses a modified file with counts, line numbers, and section heading', () => { + const [file] = parseUnifiedPatch(MODIFIED) + expect(file).toMatchObject({ name: 'src/a.ts', prevName: null, type: 'modified', binary: false, additions: 1, deletions: 1, lang: 'ts' }) + expect(file.hunks).toHaveLength(1) + expect(file.hunks[0].header).toBe('@@ -1,3 +1,3 @@ function foo() {') + expect(file.hunks[0].lines).toEqual([ + { type: 'context', content: 'a', oldNumber: 1, newNumber: 1 }, + { type: 'del', content: 'b', oldNumber: 2, newNumber: null }, + { type: 'add', content: 'B', oldNumber: null, newNumber: 2 }, + { type: 'context', content: 'c', oldNumber: 3, newNumber: 3 }, + ]) + }) + + it('classifies a new file', () => { + const [file] = parseUnifiedPatch(NEW_FILE) + expect(file).toMatchObject({ name: 'new.md', type: 'new', additions: 2, deletions: 0, lang: 'md' }) + }) + + it('classifies a deleted file (named by its old path)', () => { + const [file] = parseUnifiedPatch(DELETED) + expect(file).toMatchObject({ name: 'gone.txt', type: 'deleted', additions: 0, deletions: 2 }) + }) + + it('classifies a pure rename (no hunks) with prevName', () => { + const [file] = parseUnifiedPatch(RENAME_PURE) + expect(file).toMatchObject({ name: 'new/name.ts', prevName: 'old/name.ts', type: 'rename-pure' }) + expect(file.hunks).toHaveLength(0) + }) + + it('classifies a rename with content changes', () => { + const [file] = parseUnifiedPatch(RENAME_CHANGED) + expect(file).toMatchObject({ name: 'new.ts', prevName: 'old.ts', type: 'rename-changed', additions: 1, deletions: 1 }) + }) + + it('flags a binary change with no hunks', () => { + const [file] = parseUnifiedPatch(BINARY) + expect(file).toMatchObject({ name: 'img.png', type: 'modified', binary: true }) + expect(file.hunks).toHaveLength(0) + }) + + it('parses multiple hunks with independent line numbering', () => { + const [file] = parseUnifiedPatch(MULTI_HUNK) + expect(file.hunks).toHaveLength(2) + expect(file.hunks[1].lines).toEqual([ + { type: 'context', content: 'x', oldNumber: 10, newNumber: 10 }, + { type: 'add', content: 'y', oldNumber: null, newNumber: 11 }, + { type: 'context', content: 'z', oldNumber: 11, newNumber: 12 }, + ]) + }) + + it('drops "no newline at end of file" markers', () => { + const [file] = parseUnifiedPatch(NO_NEWLINE) + expect(file.additions).toBe(1) + expect(file.deletions).toBe(1) + expect(file.hunks[0].lines.map(l => l.type)).toEqual(['del', 'add']) + }) + + it('salvages a truncated hunk instead of dropping the file', () => { + const [file] = parseUnifiedPatch(TRUNCATED) + expect(file).toMatchObject({ name: 't.ts', additions: 1, deletions: 1 }) + expect(file.hunks[0].lines).toHaveLength(3) + }) + + it('parses every file in a multi-file patch', () => { + const files = parseUnifiedPatch(MODIFIED + NEW_FILE + BINARY) + expect(files.map(f => f.name)).toEqual(['src/a.ts', 'new.md', 'img.png']) + }) + + it('returns nothing for an empty patch', () => { + expect(parseUnifiedPatch('')).toEqual([]) + }) +}) diff --git a/plugins/git/src/client/components/diff/parse-patch.ts b/plugins/git/src/client/components/diff/parse-patch.ts new file mode 100644 index 00000000..c26ff2cb --- /dev/null +++ b/plugins/git/src/client/components/diff/parse-patch.ts @@ -0,0 +1,225 @@ +import type { StructuredPatchHunk } from 'diff' +import { parsePatch } from 'diff' + +/** A file's change type, mirroring the categories the status mark understands. */ +export type DiffChangeType = 'new' | 'deleted' | 'rename-pure' | 'rename-changed' | 'modified' + +export interface DiffLineChange { + type: 'context' | 'add' | 'del' + /** Line content without the leading +/-/space indicator. */ + content: string + /** 1-based line number on the old side, or `null` for added lines. */ + oldNumber: number | null + /** 1-based line number on the new side, or `null` for removed lines. */ + newNumber: number | null +} + +interface DiffHunkChange { + /** The `@@ -a,b +c,d @@` header, with the section/function context git provides. */ + header: string + lines: DiffLineChange[] +} + +export interface DiffFileChange { + /** Display name — the new path, or the old path for a deletion. */ + name: string + /** The pre-rename path, when this file was renamed; otherwise `null`. */ + prevName: string | null + type: DiffChangeType + binary: boolean + additions: number + deletions: number + hunks: DiffHunkChange[] + /** Language id (from the file extension) for the highlighter; `undefined` when unknown. */ + lang: string | undefined +} + +/** Strip git's `a/` / `b/` path prefixes; map `/dev/null` to `null`. */ +function cleanName(name: string | undefined): string | null { + if (!name || name === '/dev/null') + return null + return name.replace(/^[ab]\//, '') +} + +/** Infer a Shiki language id from a path's extension (unknown ids degrade server-side). */ +function inferLang(name: string | null): string | undefined { + if (!name) + return undefined + const ext = /\.([^./\\]+)$/.exec(name)?.[1]?.toLowerCase() + return ext || undefined +} + +/** + * Split a git patch into per-file blocks on `diff --git` boundaries, so each + * block parses to exactly one file and its `@@` section headings stay aligned + * with that file's hunks. A patch without any `diff --git` header (e.g. plain + * `diff -u` output) is treated as a single block. + */ +function splitFileBlocks(patch: string): string[] { + const starts: number[] = [] + const re = /^diff --git .*$/gm + for (let m = re.exec(patch); m; m = re.exec(patch)) + starts.push(m.index) + if (starts.length === 0) + return patch.trim() ? [patch] : [] + return starts.map((start, i) => patch.slice(start, starts[i + 1] ?? patch.length)) +} + +interface GitFileHeader { + oldName: string | null + newName: string | null + isRename: boolean + isCreate: boolean + isDelete: boolean + isBinary: boolean +} + +/** + * Read the git-specific metadata from a file block's extended headers — the + * thin layer `diff`'s hunk parser doesn't surface on its own. `rename`/`copy` + * and `---`/`+++` lines give clean single paths; the `diff --git` line is the + * fallback for pure renames and binary changes that carry no `---`/`+++`. + */ +function parseGitHeader(block: string): GitFileHeader { + const header: GitFileHeader = { oldName: null, newName: null, isRename: false, isCreate: false, isDelete: false, isBinary: false } + for (const line of block.split('\n')) { + if (line.startsWith('@@')) + break + if (line.startsWith('new file mode')) { + header.isCreate = true + } + else if (line.startsWith('deleted file mode')) { + header.isDelete = true + } + else if (line.startsWith('rename from ') || line.startsWith('copy from ')) { + header.oldName = line.slice(line.indexOf('from ') + 5) + header.isRename ||= line.startsWith('rename') + } + else if (line.startsWith('rename to ') || line.startsWith('copy to ')) { + header.newName = line.slice(line.indexOf('to ') + 3) + header.isRename ||= line.startsWith('rename') + } + else if (line.startsWith('--- ')) { + header.oldName = cleanName(line.slice(4)) + } + else if (line.startsWith('+++ ')) { + header.newName = cleanName(line.slice(4)) + } + else if (line.startsWith('Binary files') || line.startsWith('GIT binary patch')) { + header.isBinary = true + } + else if (line.startsWith('diff --git ')) { + const m = /^diff --git a\/(.*) b\/(.*)$/.exec(line) + if (m) { + header.oldName ??= m[1] + header.newName ??= m[2] + } + } + } + return header +} + +/** The `@@ ... @@
` headings inside a single file block, in order. */ +function sectionHeadings(block: string): string[] { + return [...block.matchAll(/^@@ .* @@(.*)$/gm)].map(m => m[1].trim()) +} + +/** + * A lenient hunk parser used when `diff`'s strict parser rejects a block — the + * common case being a patch truncated mid-hunk (the server caps patch size), so + * the final hunk's line count won't match its `@@` header. Reads each `@@` + * header and consumes the diff lines that follow, ignoring the declared counts. + */ +function parseHunksLenient(block: string): StructuredPatchHunk[] { + const hunks: StructuredPatchHunk[] = [] + let current: StructuredPatchHunk | null = null + for (const line of block.split('\n')) { + const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line) + if (m) { + current = { oldStart: +m[1], oldLines: m[2] ? +m[2] : 1, newStart: +m[3], newLines: m[4] ? +m[4] : 1, lines: [] } + hunks.push(current) + continue + } + if (!current) + continue + const marker = line[0] + if (marker === ' ' || marker === '+' || marker === '-' || marker === '\\') + current.lines.push(line) + else + current = null + } + return hunks +} + +/** Parse a block's hunks with `diff`, falling back to the lenient parser on failure. */ +function parseHunks(block: string): StructuredPatchHunk[] { + try { + return parsePatch(block)[0]?.hunks ?? [] + } + catch { + return parseHunksLenient(block) + } +} + +/** + * Parse a unified/git patch into structured per-file diffs. Uses `diff`'s + * battle-tested hunk parser and layers on the git-specific semantics (rename / + * create / delete / binary, old and new paths). Tolerant of truncation: a + * block that `diff` rejects (e.g. a hunk clipped by the server's size cap) + * falls back to a lenient hunk parse rather than being dropped. + */ +export function parseUnifiedPatch(patch: string): DiffFileChange[] { + const files: DiffFileChange[] = [] + + for (const block of splitFileBlocks(patch)) { + const meta = parseGitHeader(block) + const rawHunks = parseHunks(block) + const headings = sectionHeadings(block) + const hasHunks = rawHunks.length > 0 + + const type: DiffChangeType = meta.isCreate + ? 'new' + : meta.isDelete + ? 'deleted' + : meta.isRename + ? (hasHunks ? 'rename-changed' : 'rename-pure') + : 'modified' + + const name = (type === 'deleted' ? meta.oldName : meta.newName) ?? meta.oldName ?? meta.newName ?? '(unknown)' + const prevName = meta.isRename && meta.oldName && meta.oldName !== name ? meta.oldName : null + + let additions = 0 + let deletions = 0 + const hunks: DiffHunkChange[] = rawHunks.map((hunk, i) => { + let oldNo = hunk.oldStart + let newNo = hunk.newStart + const lines: DiffLineChange[] = [] + for (const raw of hunk.lines) { + const marker = raw[0] + const content = raw.slice(1) + if (marker === '+') { + additions++ + lines.push({ type: 'add', content, oldNumber: null, newNumber: newNo++ }) + } + else if (marker === '-') { + deletions++ + lines.push({ type: 'del', content, oldNumber: oldNo++, newNumber: null }) + } + else if (marker === '\\') { + // "\ No newline at end of file" — a marker, not a content line. + continue + } + else { + lines.push({ type: 'context', content, oldNumber: oldNo++, newNumber: newNo++ }) + } + } + const range = `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@` + const section = headings[i] + return { header: section ? `${range} ${section}` : range, lines } + }) + + files.push({ name, prevName, type, binary: meta.isBinary, additions, deletions, hunks, lang: inferLang(name) }) + } + + return files +} diff --git a/plugins/git/src/client/components/diff/render-segments.test.ts b/plugins/git/src/client/components/diff/render-segments.test.ts new file mode 100644 index 00000000..1093f891 --- /dev/null +++ b/plugins/git/src/client/components/diff/render-segments.test.ts @@ -0,0 +1,36 @@ +import type { Token } from './use-diff-tokens' +import { describe, expect, it } from 'vitest' +import { buildSegments } from './render-segments' + +describe('buildSegments', () => { + it('splits plain content around a changed word range', () => { + const segments = buildSegments(null, 'abc', [[1, 2]]) + expect(segments).toEqual([ + { text: 'a', style: undefined, changed: false }, + { text: 'b', style: undefined, changed: true }, + { text: 'c', style: undefined, changed: false }, + ]) + }) + + it('keeps token styles while marking changed segments', () => { + const tokens = [ + { content: 'foo', offset: 0, htmlStyle: { 'color': '#111', '--shiki-dark': '#eee' } }, + { content: 'bar', offset: 3, htmlStyle: { 'color': '#222', '--shiki-dark': '#ddd' } }, + ] as unknown as Token[] + const segments = buildSegments(tokens, 'foobar', [[3, 6]]) + expect(segments).toEqual([ + { text: 'foo', style: { 'color': '#111', '--shiki-dark': '#eee' }, changed: false }, + { text: 'bar', style: { 'color': '#222', '--shiki-dark': '#ddd' }, changed: true }, + ]) + }) + + it('falls back to one plain span when tokens do not cover the content', () => { + const tokens = [{ content: 'xy', offset: 0, htmlStyle: { color: '#111' } }] as unknown as Token[] + const segments = buildSegments(tokens, 'mismatched', []) + expect(segments).toEqual([{ text: 'mismatched', style: undefined, changed: false }]) + }) + + it('renders an empty line as a single empty segment', () => { + expect(buildSegments(null, '', [])).toEqual([{ text: '', style: undefined, changed: false }]) + }) +}) diff --git a/plugins/git/src/client/components/diff/render-segments.ts b/plugins/git/src/client/components/diff/render-segments.ts new file mode 100644 index 00000000..48521313 --- /dev/null +++ b/plugins/git/src/client/components/diff/render-segments.ts @@ -0,0 +1,61 @@ +import type { WordRange } from './build-model' +import type { Token } from './use-diff-tokens' + +export interface DiffSegment { + text: string + /** Shiki dual-theme inline style (`{ color, '--shiki-dark' }`), if highlighted. */ + style: Record | undefined + /** True when this segment falls inside a changed word range (intra-line emphasis). */ + changed: boolean +} + +interface Span { start: number, end: number, style?: Record } + +/** + * Slice a line's syntax tokens and its changed word ranges into a flat list of + * render segments. Each segment carries the token color (as a Shiki dual-theme + * inline style) and whether it sits inside a changed word, so the renderer can + * paint syntax color and intra-line emphasis in one pass. Falls back to a single + * plain span when tokens are absent or don't line up with the content length. + */ +export function buildSegments(tokens: Token[] | null | undefined, content: string, wordRanges: WordRange[]): DiffSegment[] { + const spans: Span[] = [] + let total = 0 + if (tokens) { + for (const token of tokens) { + spans.push({ start: total, end: total + token.content.length, style: token.htmlStyle }) + total += token.content.length + } + } + // Without tokens, or if they don't cover the content exactly (stale/mismatched + // line), treat the whole line as one unstyled span. + if (!tokens || total !== content.length) { + spans.length = 0 + spans.push({ start: 0, end: content.length }) + } + + const bounds = new Set([0, content.length]) + for (const span of spans) { + bounds.add(span.start) + bounds.add(span.end) + } + for (const [start, end] of wordRanges) { + bounds.add(start) + bounds.add(end) + } + const ordered = [...bounds].filter(n => n >= 0 && n <= content.length).sort((a, b) => a - b) + + const segments: DiffSegment[] = [] + for (let i = 0; i < ordered.length - 1; i++) { + const a = ordered[i] + const b = ordered[i + 1] + if (a === b) + continue + const style = spans.find(span => span.start <= a && span.end > a)?.style + const changed = wordRanges.some(([start, end]) => a >= start && a < end) + segments.push({ text: content.slice(a, b), style, changed }) + } + if (segments.length === 0) + segments.push({ text: '', style: undefined, changed: false }) + return segments +} diff --git a/plugins/git/src/client/components/diff/use-diff-tokens.ts b/plugins/git/src/client/components/diff/use-diff-tokens.ts new file mode 100644 index 00000000..825d18a8 --- /dev/null +++ b/plugins/git/src/client/components/diff/use-diff-tokens.ts @@ -0,0 +1,74 @@ +'use client' + +// Types-only: loads the service's RPC/scope augmentations so the scoped +// `call('code-to-tokens', …)` below is fully typed. +import type { ShikiTokens } from '@devframes/service-shiki' +import { useEffect, useState } from 'react' +import { useRpc } from '../rpc-provider' + +const SHIKI_SERVICE = '@devframes/service-shiki' + +/** The diff's Shiki theme pair, kept explicit so colors stay stable per host config. */ +const DIFF_THEMES = { light: 'vitesse-light', dark: 'vitesse-dark' } + +/** One reconstructed side's tokens: an array of lines, each an array of tokens. */ +export type TokenLines = ShikiTokens['tokens'] +export type Token = TokenLines[number][number] + +export interface DiffTokensState { + oldTokens: TokenLines | null + newTokens: TokenLines | null + /** True while the highlight round-trip is in flight (drives the skeleton). */ + loading: boolean + /** True when the shiki service isn't advertised — render plain, no skeleton. */ + unavailable: boolean +} + +/** + * Highlight a file's reconstructed old/new sides through the + * `@devframes/service-shiki` `codeToTokens` RPC (dual-theme tokens, one call + * per non-empty side). Resolves `unavailable` when the host doesn't advertise + * the service, so the caller can fall back to a plain, un-highlighted diff. Only + * runs when `enabled` (so a collapsed section does no network work). + */ +export function useDiffTokens(oldText: string, newText: string, lang: string | undefined, enabled: boolean): DiffTokensState { + const { rpc } = useRpc() + const [state, setState] = useState({ oldTokens: null, newTokens: null, loading: true, unavailable: false }) + + useEffect(() => { + if (!enabled) + return + if (!rpc) { + setState({ oldTokens: null, newTokens: null, loading: true, unavailable: false }) + return + } + const shiki = rpc.services.get(SHIKI_SERVICE) + if (!shiki) { + setState({ oldTokens: null, newTokens: null, loading: false, unavailable: true }) + return + } + + let cancelled = false + setState({ oldTokens: null, newTokens: null, loading: true, unavailable: false }) + const fetchSide = (code: string): Promise => + code === '' + ? Promise.resolve([]) + : shiki.rpc.call('code-to-tokens', { code, lang, themes: DIFF_THEMES }).then(result => result.tokens) + + Promise.all([fetchSide(oldText), fetchSide(newText)]).then( + ([oldTokens, newTokens]) => { + if (!cancelled) + setState({ oldTokens, newTokens, loading: false, unavailable: false }) + }, + () => { + if (!cancelled) + setState({ oldTokens: null, newTokens: null, loading: false, unavailable: true }) + }, + ) + return () => { + cancelled = true + } + }, [rpc, oldText, newText, lang, enabled]) + + return state +} diff --git a/plugins/git/src/client/components/rpc-provider.tsx b/plugins/git/src/client/components/rpc-provider.tsx index d708ee00..e1ae0409 100644 --- a/plugins/git/src/client/components/rpc-provider.tsx +++ b/plugins/git/src/client/components/rpc-provider.tsx @@ -7,13 +7,15 @@ import { connectDevframe } from 'devframe/client' import { DEVFRAME_WS_ROUTE } from 'devframe/constants' import { createContext, use, useEffect, useState } from 'react' -interface ConnectionState { +export interface ConnectionState { rpc: DevframeRpcClient | null status: DevframeConnectionStatus error: string | null } -const RpcContext = createContext({ rpc: null, status: 'connecting', error: null }) +// Exported so tests and Storybook can supply a mock connection (e.g. a stubbed +// shiki service) through the same context the components read. +export const RpcContext = createContext({ rpc: null, status: 'connecting', error: null }) export function useRpc(): ConnectionState { return use(RpcContext) diff --git a/plugins/git/src/client/components/status-panel.tsx b/plugins/git/src/client/components/status-panel.tsx index a5e4f200..e8d4bc65 100644 --- a/plugins/git/src/client/components/status-panel.tsx +++ b/plugins/git/src/client/components/status-panel.tsx @@ -2,9 +2,9 @@ import type { DevframeRpcClient } from 'devframe/client' import { useCallback, useState } from 'react' +import { DiffPatchView } from './diff/diff-view' import { useRpc } from './rpc-provider' import { useRpcResource } from './use-rpc-resource' -import { DiffPatchView } from './views/diff-panel-view' import { StatusPanelView } from './views/status-panel-view' function PatchViewer({ staged, path }: { staged: boolean, path: string }) { diff --git a/plugins/git/src/client/components/theme.ts b/plugins/git/src/client/components/theme.ts index 62df3632..39f6fa85 100644 --- a/plugins/git/src/client/components/theme.ts +++ b/plugins/git/src/client/components/theme.ts @@ -49,24 +49,3 @@ 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/commit-details-view.tsx b/plugins/git/src/client/components/views/commit-details-view.tsx index a91555ae..9028fbda 100644 --- a/plugins/git/src/client/components/views/commit-details-view.tsx +++ b/plugins/git/src/client/components/views/commit-details-view.tsx @@ -1,6 +1,7 @@ 'use client' import type { CommitDetail } from '@devframes/service-git' +import { DiffPatchView } from '../diff/diff-view' import { Badge } from '../ui/badge' import { IconButton } from '../ui/button' import { FileIcon } from '../ui/file-icon' @@ -8,7 +9,6 @@ import { Icon } from '../ui/icon' import { ScrollArea } from '../ui/scroll-area' import { Skeleton } from '../ui/skeleton' import { StatusMark } from '../ui/status-mark' -import { DiffPatchView } from './diff-panel-view' export interface CommitDetailsViewProps { data: CommitDetail | null @@ -130,7 +130,7 @@ export function CommitDetailsView({ data, loading, error, onClose }: CommitDetai {data.patch !== null ? (
- +
) : ( diff --git a/plugins/git/src/client/components/views/diff-panel-view.stories.tsx b/plugins/git/src/client/components/views/diff-panel-view.stories.tsx index 510367da..a3024444 100644 --- a/plugins/git/src/client/components/views/diff-panel-view.stories.tsx +++ b/plugins/git/src/client/components/views/diff-panel-view.stories.tsx @@ -1,19 +1,54 @@ import type { GitDiff } from '@devframes/service-git' import type { Meta, StoryObj } from '@storybook/react-vite' +import type { ReactNode } from 'react' +import type { BundledLanguage } from 'shiki' +import type { ConnectionState } from '../rpc-provider' import { useState } from 'react' -import { DiffPanelView, DiffPatchView } from './diff-panel-view' +import { codeToTokens } from 'shiki' +import { DiffPatchView } from '../diff/diff-view' +import { RpcContext } from '../rpc-provider' +import { DiffPanelView } from './diff-panel-view' const PATCH = `diff --git a/src/rpc/functions/log.ts b/src/rpc/functions/log.ts index 1234567..89abcde 100644 --- a/src/rpc/functions/log.ts +++ b/src/rpc/functions/log.ts -@@ -72,7 +72,7 @@ export const log = defineRpcFunction({ +@@ -72,4 +72,4 @@ export const log = defineRpcFunction({ name: 'devframes:service:git:log', type: 'query', - snapshot: true, + dump: async (_ctx, handler) => { /* bake head of history */ }, - jsonSerializable: true, - setup: (ctx) => {` + jsonSerializable: true,` + +// Storybook has no host, so stand up a mock `@devframes/service-shiki` handle +// that highlights in-browser with the real Shiki — the diff stories then render +// true syntax colors through the same code path production uses. +async function mockCodeToTokens({ code, lang, themes }: { code: string, lang?: string, themes?: { light: string, dark: string } }) { + const pair = themes ?? { light: 'vitesse-light', dark: 'vitesse-dark' } + try { + return await codeToTokens(code, { lang: (lang ?? 'text') as BundledLanguage, themes: pair }) + } + catch { + return await codeToTokens(code, { lang: 'text' as BundledLanguage, themes: pair }) + } +} + +const mockConnection = { + rpc: { + services: { + has: () => true, + get: (pkg: string) => (pkg === '@devframes/service-shiki' + ? { scope: 'devframes:service:shiki', rpc: { call: (_name: string, input: Parameters[0]) => mockCodeToTokens(input) } } + : undefined), + }, + }, + status: 'connected', + error: null, +} as unknown as ConnectionState + +function WithMockRpc({ children }: { children: ReactNode }) { + return {children} +} const data: GitDiff = { isRepo: true, @@ -37,17 +72,19 @@ function Harness(props: Partial>) { const [staged, setStaged] = useState(false) const [selected, setSelected] = useState('src/rpc/functions/log.ts') return ( - undefined} - patchSlot={} - {...props} - /> + + undefined} + patchSlot={} + {...props} + /> + ) } 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 b4856af0..0610d18b 100644 --- a/plugins/git/src/client/components/views/diff-panel-view.tsx +++ b/plugins/git/src/client/components/views/diff-panel-view.tsx @@ -1,20 +1,13 @@ 'use client' -import type { FileStatusCode, GitDiff } from '@devframes/service-git' -import type { FileDiffMetadata, FileDiffOptions } from '@pierre/diffs' +import type { GitDiff } from '@devframes/service-git' import type { ReactNode } from 'react' -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 { FileIcon } from '../ui/file-icon' import { Icon } from '../ui/icon' import { ScrollArea } from '../ui/scroll-area' import { Skeleton } from '../ui/skeleton' -import { StatusMark } from '../ui/status-mark' export interface DiffPanelViewProps { data: GitDiff | null @@ -28,139 +21,6 @@ export interface DiffPanelViewProps { patchSlot?: ReactNode } -// 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 } -} - -/** Map a parsed file's change type to a git status code (for the status mark). */ -function changeTypeStatus(type: FileDiffMetadata['type']): FileStatusCode { - switch (type) { - case 'new': - return 'added' - case 'deleted': - return 'deleted' - case 'rename-pure': - case 'rename-changed': - return 'renamed' - default: - return 'modified' - } -} - -/** - * 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 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. 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>(() => ({ - 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 - 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 = ( - <> -
- {files.map((file, i) => ( - - ))} -
- {truncated &&

Patch truncated.

} - - ) - if (!scroll) - return
{body}
- return {body} -} - export function DiffPanelView(props: DiffPanelViewProps) { const { data, loading, staged, selected, onSelectScope, onSelectFile, onRefresh, patchSlot } = props return ( diff --git a/plugins/git/src/index.ts b/plugins/git/src/index.ts index 771f9987..7edb1294 100644 --- a/plugins/git/src/index.ts +++ b/plugins/git/src/index.ts @@ -75,8 +75,15 @@ export function createGitDevframe(options: GitDevframeOptions = {}): DevframeDef // current OTP into the `--open` URL so the tab lands already trusted. auth: options.auth ?? true, }, - // The git service backs every panel; the SPA calls it directly. - services: [{ package: GIT_SERVICE, ...(cwd ? { options: { cwd } } : {}) }], + // Declared, not imperatively installed: devframe constructs each (merging + // options across every declarer) before setup. `service-git` backs every + // panel; the SPA calls it directly. `service-shiki` highlights diff patches + // via its `codeToTokens`; when a host doesn't advertise it, the client + // falls back to a plain, un-highlighted diff. + services: [ + { package: GIT_SERVICE, ...(cwd ? { options: { cwd } } : {}) }, + { package: '@devframes/service-shiki' }, + ], // Bake repo state into the static build. The service defines no dump of // its own, so the read ops are opted in here: status/branches/diff bake // their no-arg call; log bakes the 200-commit head; show bakes one diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acfcef98..be89ef3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -192,9 +192,6 @@ catalogs: '@json-render/vue': specifier: ^0.19.0 version: 0.19.0 - '@pierre/diffs': - specifier: ^1.2.12 - version: 1.2.12 '@radix-ui/react-scroll-area': specifier: ^1.2.18 version: 1.2.18 @@ -231,6 +228,9 @@ catalogs: colorjs.io: specifier: ^0.7.1 version: 0.7.1 + diff: + specifier: ^9.0.0 + version: 9.0.0 dompurify: specifier: ^3.4.13 version: 3.4.13 @@ -1970,6 +1970,9 @@ importers: '@devframes/service-git': specifier: workspace:* version: link:../../services/git + '@devframes/service-shiki': + specifier: workspace:* + version: link:../../services/shiki cac: specifier: catalog:deps version: 7.0.0 @@ -1992,9 +1995,6 @@ importers: '@iconify-json/catppuccin': specifier: catalog:frontend version: 1.2.17 - '@pierre/diffs': - specifier: catalog:frontend - version: 1.2.12(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-scroll-area': specifier: catalog:frontend version: 1.2.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -2028,6 +2028,9 @@ importers: colorjs.io: specifier: catalog:frontend version: 0.7.1 + diff: + specifier: catalog:frontend + version: 9.0.0 h3: specifier: catalog:deps version: 2.0.1-rc.26(crossws@0.4.10(srvx@0.12.4)) @@ -2040,6 +2043,9 @@ importers: react-dom: specifier: catalog:frontend version: 19.2.8(react@19.2.8) + shiki: + specifier: catalog:deps + version: 4.4.3 storybook: specifier: catalog:storybook version: 10.5.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -4524,36 +4530,6 @@ 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 - '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -5214,10 +5190,6 @@ packages: '@swc/helpers': optional: true - '@shikijs/core@4.3.1': - resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==} - engines: {node: '>=20'} - '@shikijs/core@4.4.2': resolution: {integrity: sha512-StyzbAyxg2/tBGf78gwbBkGyeQ73lf8UiJArFaQhTQIDqQOCKPCQFanvrs4/Yv3Yfyc+ONInJM6K+FMIf+P+kA==} engines: {node: '>=20'} @@ -5226,10 +5198,6 @@ packages: resolution: {integrity: sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==} engines: {node: '>=20'} - '@shikijs/engine-javascript@4.3.1': - resolution: {integrity: sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==} - engines: {node: '>=20'} - '@shikijs/engine-javascript@4.4.2': resolution: {integrity: sha512-MnIkeqWdVPUWsxlx8gKLVCJFTsqrQJgpTPBPpQwaFeJ56lOnJxj5aN2LUFnfxUEcvOQuNocmbaMnVrCEln6rkw==} engines: {node: '>=20'} @@ -5238,10 +5206,6 @@ packages: resolution: {integrity: sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==} engines: {node: '>=20'} - '@shikijs/engine-oniguruma@4.3.1': - resolution: {integrity: sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg==} - engines: {node: '>=20'} - '@shikijs/engine-oniguruma@4.4.2': resolution: {integrity: sha512-GLhowz1+jixjz+wiZ3wMnOn1jTxiFCGl2PkXufivbnwPHKuyw1AYqu5/hbWhZZ2oAb0NP05WUJhYeigY14drnw==} engines: {node: '>=20'} @@ -5250,10 +5214,6 @@ packages: resolution: {integrity: sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==} engines: {node: '>=20'} - '@shikijs/langs@4.3.1': - resolution: {integrity: sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==} - engines: {node: '>=20'} - '@shikijs/langs@4.4.2': resolution: {integrity: sha512-8DfeusD+Zdv/eYIDdXyJTUnSMHt+aAWjAOCXV20HNGAHRlInXpG8wh421v6B91WOm9TFwRLN+b/LG5F2NAIojg==} engines: {node: '>=20'} @@ -5262,10 +5222,6 @@ packages: resolution: {integrity: sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==} engines: {node: '>=20'} - '@shikijs/primitive@4.3.1': - resolution: {integrity: sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==} - engines: {node: '>=20'} - '@shikijs/primitive@4.4.2': resolution: {integrity: sha512-l6fQQKsOMlz72n38fztmSgZ76MO6KSWuw8o+GJ+FhmqrpC9pIOJNQNXGgbb5yX2AwpzlEHwsaLPnk/8o4Fm+rA==} engines: {node: '>=20'} @@ -5274,10 +5230,6 @@ packages: resolution: {integrity: sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==} engines: {node: '>=20'} - '@shikijs/themes@4.3.1': - resolution: {integrity: sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==} - engines: {node: '>=20'} - '@shikijs/themes@4.4.2': resolution: {integrity: sha512-H0CFoL07ddDC2Dd6EdrPYNkRhUR6YCkJlnuYFceYYUJJA5TIm2b5B33qqiDYryBExgbKMndFJPb2u1gTuqO37g==} engines: {node: '>=20'} @@ -5286,18 +5238,10 @@ packages: resolution: {integrity: sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==} engines: {node: '>=20'} - '@shikijs/transformers@4.3.1': - resolution: {integrity: sha512-z6ir0bGDgWcF2FduktEfPgIsdOtIlDiLAjFBgBzE42Q9xHbkkIXZtORHzlLVB71iZP9elEcqKg6keajvOUwE2A==} - engines: {node: '>=20'} - '@shikijs/transformers@4.4.2': resolution: {integrity: sha512-d81PJ9KkR1tVP95FH/9296HTtDo0mh76wv10u9T1YmsZq/UcXgt0OLdBszfUQ1i+umkRMCjDnFbFZU7/tCODTQ==} engines: {node: '>=20'} - '@shikijs/types@4.3.1': - resolution: {integrity: sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==} - engines: {node: '>=20'} - '@shikijs/types@4.4.2': resolution: {integrity: sha512-PFYitV4vpDr/iPCIhnHp+Q4ftic5N5VeNJ3KQ1O8gn3h2ar8qgwMAXF7tq4m1CWaMS60fV4VqF6vfnWH4F7vqQ==} engines: {node: '>=20'} @@ -8452,9 +8396,6 @@ 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 @@ -9778,10 +9719,6 @@ packages: resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} engines: {node: '>= 0.4'} - shiki@4.3.1: - resolution: {integrity: sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw==} - engines: {node: '>=20'} - shiki@4.4.2: resolution: {integrity: sha512-P8F/dFhRevaw2uSdeIYlq/5SXZNY85DPtmXQ947gD1Zj2JqO5AkNvVVBar0Me9JkFx3uzVud/qOtP5ek9NEQGA==} engines: {node: '>=20'} @@ -13129,30 +13066,6 @@ snapshots: '@parcel/watcher-win32-ia32': 2.5.6 '@parcel/watcher-win32-x64': 2.5.6 - '@pierre/diffs@1.2.12(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': - dependencies: - '@pierre/theme': 1.1.0 - '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(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.8 - react-dom: 19.2.8(react@19.2.8) - 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.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(shiki@4.3.1)': - optionalDependencies: - '@pierre/theme': 1.1.0 - '@shikijs/themes': 4.4.3 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - shiki: 4.3.1 - '@pinojs/redact@0.4.0': {} '@pkgjs/parseargs@0.11.0': @@ -13611,14 +13524,6 @@ snapshots: optionalDependencies: '@swc/helpers': 0.5.23 - '@shikijs/core@4.3.1': - dependencies: - '@shikijs/primitive': 4.3.1 - '@shikijs/types': 4.3.1 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.5 - hast-util-to-html: 9.0.5 - '@shikijs/core@4.4.2': dependencies: '@shikijs/primitive': 4.4.2 @@ -13635,12 +13540,6 @@ snapshots: '@types/hast': 3.0.5 hast-util-to-html: 9.0.5 - '@shikijs/engine-javascript@4.3.1': - dependencies: - '@shikijs/types': 4.3.1 - '@shikijs/vscode-textmate': 10.0.2 - oniguruma-to-es: 4.3.6 - '@shikijs/engine-javascript@4.4.2': dependencies: '@shikijs/types': 4.4.2 @@ -13653,11 +13552,6 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 4.3.6 - '@shikijs/engine-oniguruma@4.3.1': - dependencies: - '@shikijs/types': 4.3.1 - '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/engine-oniguruma@4.4.2': dependencies: '@shikijs/types': 4.4.2 @@ -13668,10 +13562,6 @@ snapshots: '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/langs@4.3.1': - dependencies: - '@shikijs/types': 4.3.1 - '@shikijs/langs@4.4.2': dependencies: '@shikijs/types': 4.4.2 @@ -13680,12 +13570,6 @@ snapshots: dependencies: '@shikijs/types': 4.4.3 - '@shikijs/primitive@4.3.1': - dependencies: - '@shikijs/types': 4.3.1 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.5 - '@shikijs/primitive@4.4.2': dependencies: '@shikijs/types': 4.4.2 @@ -13698,10 +13582,6 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 - '@shikijs/themes@4.3.1': - dependencies: - '@shikijs/types': 4.3.1 - '@shikijs/themes@4.4.2': dependencies: '@shikijs/types': 4.4.2 @@ -13710,21 +13590,11 @@ snapshots: dependencies: '@shikijs/types': 4.4.3 - '@shikijs/transformers@4.3.1': - dependencies: - '@shikijs/core': 4.3.1 - '@shikijs/types': 4.3.1 - '@shikijs/transformers@4.4.2': dependencies: '@shikijs/core': 4.4.2 '@shikijs/types': 4.4.2 - '@shikijs/types@4.3.1': - dependencies: - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.5 - '@shikijs/types@4.4.2': dependencies: '@shikijs/vscode-textmate': 10.0.2 @@ -17139,8 +17009,6 @@ snapshots: lru-cache@8.0.5: {} - lru_map@0.4.1: {} - lz-string@1.5.0: {} magic-regexp@0.10.0: @@ -19150,17 +19018,6 @@ snapshots: shell-quote@1.10.0: {} - shiki@4.3.1: - dependencies: - '@shikijs/core': 4.3.1 - '@shikijs/engine-javascript': 4.3.1 - '@shikijs/engine-oniguruma': 4.3.1 - '@shikijs/langs': 4.3.1 - '@shikijs/themes': 4.3.1 - '@shikijs/types': 4.3.1 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.5 - shiki@4.4.2: dependencies: '@shikijs/core': 4.4.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 45273549..1c4bc77a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -22,7 +22,6 @@ strictPeerDependencies: false trustPolicy: no-downgrade trustPolicyExclude: - tinyexec@1.2.2 - - '@pierre/theme@1.1.0' packages: - packages/* - plugins/* @@ -127,7 +126,6 @@ catalogs: '@iconify-json/ph': ^1.2.2 '@json-render/react': ^0.19.0 '@json-render/vue': ^0.19.0 - '@pierre/diffs': ^1.2.12 '@radix-ui/react-scroll-area': ^1.2.18 '@radix-ui/react-slot': ^1.3.3 '@sveltejs/vite-plugin-svelte': ^7.3.0 @@ -140,6 +138,7 @@ catalogs: axe-core: ^4.13.0 clsx: ^2.1.1 colorjs.io: ^0.7.1 + diff: ^9.0.0 dompurify: ^3.4.13 floating-vue: ^5.2.2 fuse.js: ^7.5.0