From 70b3a8bf829cb53d70d6a3818786f7618de14fb7 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Thu, 25 Sep 2025 17:16:23 -0700 Subject: [PATCH 1/5] Add fast-diff to project... --- bun.lock | 4 ++++ package.json | 1 + packages/diff-ui/package.json | 1 + 3 files changed, 6 insertions(+) diff --git a/bun.lock b/bun.lock index d719b76c2..461923986 100644 --- a/bun.lock +++ b/bun.lock @@ -39,6 +39,7 @@ "dependencies": { "@pierre/shiki-stream": "workspace:*", "@shikijs/core": "catalog:", + "fast-diff": "catalog:", "hast-util-to-html": "catalog:", "shiki": "catalog:", }, @@ -98,6 +99,7 @@ "@vitejs/plugin-react": "5.0.3", "eslint": "9.36.0", "eslint-config-next": "15.5.4", + "fast-diff": "1.3.0", "fumadocs-core": "15.8.0", "fumadocs-mdx": "12.0.1", "fumadocs-ui": "15.8.0", @@ -832,6 +834,8 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="], + "fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], diff --git a/package.json b/package.json index ee319cde0..71d87a5c2 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@vitejs/plugin-react": "5.0.3", "eslint": "9.36.0", "eslint-config-next": "15.5.4", + "fast-diff": "1.3.0", "fumadocs-core": "15.8.0", "fumadocs-mdx": "12.0.1", "fumadocs-ui": "15.8.0", diff --git a/packages/diff-ui/package.json b/packages/diff-ui/package.json index 62c1953da..c6c2983cf 100644 --- a/packages/diff-ui/package.json +++ b/packages/diff-ui/package.json @@ -22,6 +22,7 @@ "dependencies": { "@pierre/shiki-stream": "workspace:*", "@shikijs/core": "catalog:", + "fast-diff": "catalog:", "hast-util-to-html": "catalog:", "shiki": "catalog:" } From c3428acad6aa6c8164ec0027a521a8a41b7f4492 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Fri, 26 Sep 2025 14:26:36 -0700 Subject: [PATCH 2/5] Add first pass usage of per-character diff highlighting --- packages/demo-app/src/main.ts | 4 +- packages/demo-app/src/mocks/index.ts | 45 +--- packages/diff-ui/src/DiffRenderer.ts | 300 +++++++++++++++++++-------- packages/diff-ui/src/style.css | 42 ++++ 4 files changed, 258 insertions(+), 133 deletions(-) diff --git a/packages/demo-app/src/main.ts b/packages/demo-app/src/main.ts index 6827e008d..9bba62f63 100644 --- a/packages/demo-app/src/main.ts +++ b/packages/demo-app/src/main.ts @@ -12,7 +12,6 @@ import type { BundledLanguage, BundledTheme } from 'shiki'; import { CodeConfigs, DIFF_CONTENT, - DIFF_DECORATIONS, getFiletypeFromMetadata, toggleTheme, } from './mocks/'; @@ -77,7 +76,6 @@ function renderDiff() { container.appendChild(createFileMetadata(parsedPatch.patchMetadata)); } for (const file of parsedPatch.files) { - const decorations = DIFF_DECORATIONS[file.name]; container.appendChild(renderFileHeader(file)); const pre = document.createElement('pre'); container.appendChild(pre); @@ -86,7 +84,7 @@ function renderDiff() { themes: { dark: 'tokyo-night', light: 'solarized-light' }, unified, }); - instance.render(file, pre, decorations); + instance.render(file, pre); diffInstances.push(instance); } } diff --git a/packages/demo-app/src/mocks/index.ts b/packages/demo-app/src/mocks/index.ts index 5d44427e0..91712c5f0 100644 --- a/packages/demo-app/src/mocks/index.ts +++ b/packages/demo-app/src/mocks/index.ts @@ -1,4 +1,4 @@ -import type { DiffDecorationItem, FileMetadata } from '@pierre/diff-ui'; +import type { FileMetadata } from '@pierre/diff-ui'; import type { BundledLanguage } from 'shiki'; import { createHighlighterCleanup } from '../utils/createHighlighterCleanup'; @@ -65,49 +65,6 @@ export const DIFF_CONTENT_FORMATS: Record = patch: 'diff', }; -export const DIFF_DECORATIONS: Record = { - 'eslint.config.js': [ - { - type: 'additions', - hunkIndex: 0, - - start: 7, - end: 15, - properties: { - 'data-annotation': '', - }, - }, - ], - 'index.html': [ - { - type: 'additions', - hunkIndex: 0, - start: { - line: 5, - character: 0, - }, - end: { - line: 5, - character: -1, - }, - properties: { - 'data-annotation': '', - }, - }, - ], - 'src/SharedHighlighter.ts': [ - { - type: 'deletions', - hunkIndex: 0, - start: 40, - end: 300, - properties: { - 'data-annotation': '', - }, - }, - ], -}; - export function getFiletypeFromMetadata(file: FileMetadata) { return DIFF_CONTENT_FORMATS[file.name.match(/\.([^.]+)$/)?.[1] || '']; } diff --git a/packages/diff-ui/src/DiffRenderer.ts b/packages/diff-ui/src/DiffRenderer.ts index 82b5acb4c..3c3d93972 100644 --- a/packages/diff-ui/src/DiffRenderer.ts +++ b/packages/diff-ui/src/DiffRenderer.ts @@ -5,6 +5,7 @@ import type { HighlighterGeneric, ShikiTransformer, } from '@shikijs/core'; +import diff, { type Diff } from 'fast-diff'; import type { Element, ElementContent, Root, RootContent } from 'hast'; import { toHtml } from 'hast-util-to-html'; import type { BundledLanguage, BundledTheme } from 'shiki'; @@ -19,6 +20,13 @@ import { } from './utils/html_render_utils'; import { parseLineType } from './utils/parseLineType'; +interface ChangeHunk { + deletionStartIndex: number; + additionStartIndex: number; + deletionLines: string[]; + additionLines: string[]; +} + export interface DiffDecorationItem extends DecorationItem { type: 'additions' | 'deletions'; // Kinda hate this API for now... need to think about it more... @@ -41,8 +49,6 @@ interface RenderHunkProps { highlighter: HighlighterGeneric; state: SharedRenderState; transformer: ShikiTransformer; - deletionDecorations: DiffDecorationItem[] | undefined; - additionDecorations: DiffDecorationItem[] | undefined; codeAdditions: HTMLElement; codeDeletions: HTMLElement; @@ -101,17 +107,11 @@ export class DiffRenderer { return this.highlighter; } - private queuedRenderArgs: - | [FileMetadata, HTMLPreElement, DiffDecorationItem[] | undefined] - | undefined; + private queuedRenderArgs: [FileMetadata, HTMLPreElement] | undefined; - async render( - _diff: FileMetadata, - _wrapper: HTMLPreElement, - _decorations?: DiffDecorationItem[] - ) { + async render(_diff: FileMetadata, _wrapper: HTMLPreElement) { const isSettingUp = this.queuedRenderArgs != null; - this.queuedRenderArgs = [_diff, _wrapper, _decorations]; + this.queuedRenderArgs = [_diff, _wrapper]; if (isSettingUp) { // TODO(amadeus): Make it so that this function can be properly // awaitable, maybe? @@ -121,16 +121,15 @@ export class DiffRenderer { this.highlighter = await this.initializeHighlighter(); } - const [source, wrapper, decorations] = this.queuedRenderArgs; + const [source, wrapper] = this.queuedRenderArgs; this.queuedRenderArgs = undefined; - this.renderDiff(wrapper, source, this.highlighter, decorations); + this.renderDiff(wrapper, source, this.highlighter); } private renderDiff( wrapper: HTMLPreElement, diff: FileMetadata, - highlighter: HighlighterGeneric, - decorations?: DiffDecorationItem[] + highlighter: HighlighterGeneric ) { const { themes, theme, unified = false } = this.options; const split = @@ -150,7 +149,6 @@ export class DiffRenderer { const codeUnified = createCodeNode({ columnType: 'unified' }); const { state, transformer } = createTransformerWithState(); let hunkIndex = 0; - const decorationSet = new Set(decorations); for (const hunk of diff.hunks) { if (hunkIndex > 0) { if (unified) { @@ -160,26 +158,11 @@ export class DiffRenderer { codeDeletions.appendChild(createHunkSeparator()); } } - const additionDecorations: DiffDecorationItem[] = []; - const deletionDecorations: DiffDecorationItem[] = []; - for (const decoration of decorationSet) { - if (decoration.hunkIndex !== hunkIndex) continue; - if (decoration.type === 'additions') { - additionDecorations.push(decoration); - } else { - deletionDecorations.push(decoration); - } - decorationSet.delete(decoration); - } this.renderHunks({ hunk, highlighter, state, transformer, - additionDecorations: - additionDecorations.length > 0 ? additionDecorations : undefined, - deletionDecorations: - deletionDecorations.length > 0 ? deletionDecorations : undefined, codeAdditions, codeDeletions, codeUnified, @@ -232,33 +215,120 @@ export class DiffRenderer { highlighter, state, transformer, - additionDecorations, - deletionDecorations, codeAdditions, codeDeletions, codeUnified, }: RenderHunkProps) { if (hunk.hunkContent == null) return; + const { additions, deletions, unified } = this.processLines(hunk); + + if (unified.content.length > 0) { + // Remove trailing blank line + const content = unified.content.join('').replace(/\n$/, ''); + state.spans = {}; + state.lineInfo = unified.lineInfo; + const nodes = highlighter.codeToHast( + content, + this.createHastOptions(transformer, unified.decorations) + ); + codeUnified.insertAdjacentHTML( + 'beforeend', + toHtml(this.getNodesToRender(nodes)) + ); + } + + if (deletions.content.length > 0) { + // Remove trailing blank line + const content = deletions.content.join('').replace(/\n$/, ''); + state.spans = deletions.spans; + state.lineInfo = deletions.lineInfo; + const nodes = highlighter.codeToHast( + content, + this.createHastOptions( + transformer, + deletions.decorations.length > 0 ? deletions.decorations : undefined + ) + ); + codeDeletions.insertAdjacentHTML( + 'beforeend', + toHtml(this.getNodesToRender(nodes)) + ); + } + + if (additions.content.length > 0) { + // Remove trailing blank line + const content = additions.content.join('').replace(/\n$/, ''); + state.spans = additions.spans; + state.lineInfo = additions.lineInfo; + const nodes = highlighter.codeToHast( + content, + this.createHastOptions( + transformer, + additions.decorations.length > 0 ? additions.decorations : undefined + ) + ); + codeAdditions.insertAdjacentHTML( + 'beforeend', + toHtml(this.getNodesToRender(nodes)) + ); + } + } + + private processLines(hunk: Hunk) { const { unified = false } = this.options; + const additionContent: string[] = []; const additionLineInfo: Record = {}; const additionSpans: Record = {}; let additionLineIndex = 1; let additionLineNumber = hunk.additionStart; - let additionContent: string | undefined; let additionGroupSize = 0; + const deletionContent: string[] = []; const deletionLineInfo: Record = {}; const deletionSpans: Record = {}; let deletionLineIndex = 1; let deletionLineNumber = hunk.deletedStart; let deletionGroupSize = 0; - let deletionContent: string | undefined; + const unifiedContent: string[] = []; const unifiedInfo: Record = {}; - let unifiedContent: string | undefined; let unifiedLineIndex = 1; + const diffGroups: ChangeHunk[] = []; + let currentChangeGroup: ChangeHunk | undefined; + + function addToChangeGroup(type: 'addition' | 'deletion', line: string) { + if (currentChangeGroup == null) { + currentChangeGroup = { + deletionStartIndex: unified ? -1 : deletionLineIndex - 1, + additionStartIndex: unified ? -1 : additionLineIndex - 1, + deletionLines: [], + additionLines: [], + }; + diffGroups.push(currentChangeGroup); + } + if (unified) { + if ( + type === 'deletion' && + currentChangeGroup.deletionStartIndex === -1 + ) { + currentChangeGroup.deletionStartIndex = unifiedLineIndex - 1; + } + if ( + type === 'addition' && + currentChangeGroup.additionStartIndex === -1 + ) { + currentChangeGroup.additionStartIndex = unifiedLineIndex - 1; + } + } + if (type === 'addition') { + currentChangeGroup.additionLines.push(line); + } else { + currentChangeGroup.deletionLines.push(line); + } + } + function createSpanIfNecessary() { if ( !unified && @@ -277,24 +347,25 @@ export class DiffRenderer { } let lastType: HUNK_LINE_TYPE | undefined; - for (const rawLine of hunk.hunkContent) { + for (const rawLine of hunk.hunkContent ?? []) { const { line, type } = parseLineType(rawLine); if (type === 'context') { createSpanIfNecessary(); } if (type === 'context') { + currentChangeGroup = undefined; additionGroupSize = 0; deletionGroupSize = 0; if (unified) { - unifiedContent = (unifiedContent ?? '') + line; + unifiedContent.push(line); unifiedInfo[unifiedLineIndex] = { type: 'context', number: additionLineNumber, }; unifiedLineIndex++; } else { - additionContent = (additionContent ?? '') + line; - deletionContent = (deletionContent ?? '') + line; + additionContent.push(line); + deletionContent.push(line); additionLineInfo[additionLineIndex] = { type: 'context', number: additionLineNumber, @@ -310,15 +381,16 @@ export class DiffRenderer { additionLineNumber++; deletionLineNumber++; } else if (type === 'deletion') { + addToChangeGroup('deletion', line); if (unified) { - unifiedContent = (unifiedContent ?? '') + line; + unifiedContent.push(line); unifiedInfo[unifiedLineIndex] = { type: 'change-deletion', number: deletionLineNumber, }; unifiedLineIndex++; } else { - deletionContent = (deletionContent ?? '') + line; + deletionContent.push(line); deletionLineInfo[deletionLineIndex] = { type: 'change-deletion', number: deletionLineNumber, @@ -328,15 +400,16 @@ export class DiffRenderer { } deletionLineNumber++; } else if (type === 'addition') { + addToChangeGroup('addition', line); if (unified) { - unifiedContent = (unifiedContent ?? '') + line; + unifiedContent.push(line); unifiedInfo[unifiedLineIndex] = { type: 'change-addition', number: additionLineNumber, }; unifiedLineIndex++; } else { - additionContent = (additionContent ?? '') + line; + additionContent.push(line); additionLineInfo[additionLineIndex] = { type: 'change-addition', number: additionLineNumber, @@ -350,51 +423,87 @@ export class DiffRenderer { lastType = type; } createSpanIfNecessary(); + const { unifiedDecorations, deletionDecorations, additionDecorations } = + this.parseDecorations(diffGroups); + return { + additions: { + content: additionContent, + lineInfo: additionLineInfo, + spans: additionSpans, + decorations: additionDecorations, + }, + deletions: { + content: deletionContent, + lineInfo: deletionLineInfo, + spans: deletionSpans, + decorations: deletionDecorations, + }, + unified: { + content: unifiedContent, + lineInfo: unifiedInfo, + decorations: unifiedDecorations, + }, + }; + } - if (unifiedContent != null) { - // Remove trailing blank line - unifiedContent = unifiedContent.replace(/\n$/, ''); - state.spans = {}; - state.lineInfo = unifiedInfo; - const nodes = highlighter.codeToHast( - unifiedContent, - this.createHastOptions(transformer, deletionDecorations) - ); - codeUnified.insertAdjacentHTML( - 'beforeend', - toHtml(this.getNodesToRender(nodes)) - ); - } - - if (deletionContent != null) { - // Remove trailing blank line - deletionContent = deletionContent.replace(/\n$/, ''); - state.spans = deletionSpans; - state.lineInfo = deletionLineInfo; - const nodes = highlighter.codeToHast( - deletionContent, - this.createHastOptions(transformer, deletionDecorations) - ); - codeDeletions.insertAdjacentHTML( - 'beforeend', - toHtml(this.getNodesToRender(nodes)) - ); - } - - if (additionContent != null) { - // Remove trailing blank line - additionContent = additionContent.replace(/\n$/, ''); - state.spans = additionSpans; - state.lineInfo = additionLineInfo; - const nodes = highlighter.codeToHast( - additionContent, - this.createHastOptions(transformer, additionDecorations) - ); - codeAdditions.insertAdjacentHTML( - 'beforeend', - toHtml(this.getNodesToRender(nodes)) + private parseDecorations(diffGroups: ChangeHunk[]) { + const { unified = false } = this.options; + const unifiedDecorations: DecorationItem[] = []; + const additionDecorations: DecorationItem[] = []; + const deletionDecorations: DecorationItem[] = []; + for (const group of diffGroups) { + const len = Math.min( + group.additionLines.length, + group.deletionLines.length ); + for (let i = 0; i < len; i++) { + const deletionLine = group.deletionLines[i]; + const additionLine = group.additionLines[i]; + if (deletionLine == null || additionLine == null) { + break; + } + const lineDiff = diff(deletionLine, additionLine, undefined, true); + const deletionSpans: Diff[] = []; + const additionSpans: Diff[] = []; + for (const item of lineDiff) { + if (item[0] === 0) { + deletionSpans.push(item); + additionSpans.push(item); + } else if (item[0] === -1) { + deletionSpans.push(item); + } else { + additionSpans.push(item); + } + } + let spanIndex = 0; + for (const span of additionSpans) { + if (span[0] !== 0) { + (unified ? unifiedDecorations : additionDecorations).push( + createDiffSpanDecoration({ + line: group.additionStartIndex + i, + spanStart: spanIndex, + spanLength: span[1].length, + }) + ); + } + spanIndex += span[1].length; + } + spanIndex = 0; + for (const span of deletionSpans) { + if (span[0] !== 0) { + (unified ? unifiedDecorations : deletionDecorations).push( + createDiffSpanDecoration({ + line: group.deletionStartIndex + i, + spanStart: spanIndex, + spanLength: span[1].length, + }) + ); + } + spanIndex += span[1].length; + } + } } + return { unifiedDecorations, deletionDecorations, additionDecorations }; } private getNodesToRender(nodes: Root) { @@ -434,6 +543,25 @@ export class DiffRenderer { } } +interface CreateDiffSpanDecorationProps { + line: number; + spanStart: number; + spanLength: number; +} + +function createDiffSpanDecoration({ + line, + spanStart, + spanLength, +}: CreateDiffSpanDecorationProps): DecorationItem { + return { + start: { line, character: spanStart }, + end: { line, character: spanStart + spanLength }, + properties: { 'data-diff-span': '' }, + alwaysWrap: true, + }; +} + function convertLine( node: Element, line: number, diff --git a/packages/diff-ui/src/style.css b/packages/diff-ui/src/style.css index eca68d711..4a1af2250 100644 --- a/packages/diff-ui/src/style.css +++ b/packages/diff-ui/src/style.css @@ -23,6 +23,16 @@ --pjs-bg-deletions: color-mix(in srgb, var(--pjs-bg) 90%, rgb(255, 0, 0)); --pjs-bg-additions: color-mix(in srgb, var(--pjs-bg) 90%, rgb(0, 255, 0)); + --pjs-bg-deletions-emphasis: color-mix( + in srgb, + var(--pjs-bg) 70%, + rgb(255, 0, 0) + ); + --pjs-bg-additions-emphasis: color-mix( + in srgb, + var(--pjs-bg) 70%, + rgb(0, 255, 0) + ); --pjs-bg-deletions-number: color-mix( in srgb, var(--pjs-bg) 85%, @@ -160,6 +170,30 @@ user-select: none; background-color: var(--pjs-bg); color: var(--pjs-fg-number); + z-index: 1; +} + +[data-pjs] [data-diff-span] { + padding-top: 1.5px; + padding-bottom: 1.5px; + position: relative; +} + +[data-pjs] [data-diff-span]:before, +[data-pjs] [data-diff-span]:after { + content: ''; + position: absolute; + top: 0; + height: 100%; + width: 1px; + background-color: var(--pjs-bg); +} + +[data-pjs] [data-diff-span]:before { + left: -1px; +} +[data-pjs] [data-diff-span]:after { + right: -1px; } [data-pjs] [data-line-type='change-addition'] { @@ -168,6 +202,10 @@ [data-column-number] { background-color: var(--pjs-bg-additions-number); } + + [data-diff-span] { + background-color: var(--pjs-bg-additions-emphasis); + } } [data-pjs] [data-line-type='change-deletion'] { @@ -176,6 +214,10 @@ [data-column-number] { background-color: var(--pjs-bg-deletions-number); } + + [data-diff-span] { + background-color: var(--pjs-bg-deletions-emphasis); + } } [data-pjs] [data-line]:hover { From 93c205a4c0740ff718cd5857a3072e3df57b9940 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Fri, 26 Sep 2025 15:43:35 -0700 Subject: [PATCH 3/5] Make it easier to swap in diff diff content --- packages/demo-app/src/main.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/demo-app/src/main.ts b/packages/demo-app/src/main.ts index 9bba62f63..4a2a86c34 100644 --- a/packages/demo-app/src/main.ts +++ b/packages/demo-app/src/main.ts @@ -10,8 +10,8 @@ import { import type { BundledLanguage, BundledTheme } from 'shiki'; import { + DIFF_CONTENT as CONTENT, CodeConfigs, - DIFF_CONTENT, getFiletypeFromMetadata, toggleTheme, } from './mocks/'; @@ -38,7 +38,7 @@ function startStreaming() { let parsedPatches: ParsedPatch[] | undefined; function handlePreloadDiff() { if (parsedPatches != null || !isHighlighterNull()) return; - parsedPatches = parsePatchContent(DIFF_CONTENT); + parsedPatches = parsePatchContent(CONTENT); console.log('Parsed File:', parsedPatches); const langs = new Set(); for (const parsedPatch of parsedPatches) { @@ -69,7 +69,7 @@ function renderDiff() { | HTMLInputElement | undefined; container.dataset.diff = ''; - parsedPatches = parsedPatches ?? parsePatchContent(DIFF_CONTENT); + parsedPatches = parsedPatches ?? parsePatchContent(CONTENT); const unified = checkbox?.checked ?? false; for (const parsedPatch of parsedPatches) { if (parsedPatch.patchMetadata != null) { From 02b5e2bc3a6fc0ec979f524d61daf596c969495f Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Fri, 26 Sep 2025 15:56:38 -0700 Subject: [PATCH 4/5] Use `diff` vs `fast-diff` Not sure if we should allow this to be configurable, but the word-based diffing will probably be a better user experience in general and we'll probably want to utilize `jsdiff` for other things in the future... Also swapped over to use more of a word based diffing algo --- bun.lock | 8 ++--- package.json | 2 +- packages/diff-ui/package.json | 2 +- packages/diff-ui/src/DiffRenderer.ts | 52 +++++++++++++++++++++------- 4 files changed, 46 insertions(+), 18 deletions(-) diff --git a/bun.lock b/bun.lock index 461923986..2f5263c6a 100644 --- a/bun.lock +++ b/bun.lock @@ -39,7 +39,7 @@ "dependencies": { "@pierre/shiki-stream": "workspace:*", "@shikijs/core": "catalog:", - "fast-diff": "catalog:", + "diff": "catalog:", "hast-util-to-html": "catalog:", "shiki": "catalog:", }, @@ -97,9 +97,9 @@ "@types/react": "19.1.13", "@types/react-dom": "19.1.9", "@vitejs/plugin-react": "5.0.3", + "diff": "8.0.2", "eslint": "9.36.0", "eslint-config-next": "15.5.4", - "fast-diff": "1.3.0", "fumadocs-core": "15.8.0", "fumadocs-mdx": "12.0.1", "fumadocs-ui": "15.8.0", @@ -746,6 +746,8 @@ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -834,8 +836,6 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="], - "fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], diff --git a/package.json b/package.json index 71d87a5c2..ab9b1dc41 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "@vitejs/plugin-react": "5.0.3", "eslint": "9.36.0", "eslint-config-next": "15.5.4", - "fast-diff": "1.3.0", + "diff": "8.0.2", "fumadocs-core": "15.8.0", "fumadocs-mdx": "12.0.1", "fumadocs-ui": "15.8.0", diff --git a/packages/diff-ui/package.json b/packages/diff-ui/package.json index c6c2983cf..ceded76ae 100644 --- a/packages/diff-ui/package.json +++ b/packages/diff-ui/package.json @@ -22,7 +22,7 @@ "dependencies": { "@pierre/shiki-stream": "workspace:*", "@shikijs/core": "catalog:", - "fast-diff": "catalog:", + "diff": "catalog:", "hast-util-to-html": "catalog:", "shiki": "catalog:" } diff --git a/packages/diff-ui/src/DiffRenderer.ts b/packages/diff-ui/src/DiffRenderer.ts index 3c3d93972..24548ad5a 100644 --- a/packages/diff-ui/src/DiffRenderer.ts +++ b/packages/diff-ui/src/DiffRenderer.ts @@ -5,7 +5,7 @@ import type { HighlighterGeneric, ShikiTransformer, } from '@shikijs/core'; -import diff, { type Diff } from 'fast-diff'; +import { type ChangeObject, diffWordsWithSpace } from 'diff'; import type { Element, ElementContent, Root, RootContent } from 'hast'; import { toHtml } from 'hast-util-to-html'; import type { BundledLanguage, BundledTheme } from 'shiki'; @@ -462,22 +462,27 @@ export class DiffRenderer { if (deletionLine == null || additionLine == null) { break; } - const lineDiff = diff(deletionLine, additionLine, undefined, true); - const deletionSpans: Diff[] = []; - const additionSpans: Diff[] = []; + // Lets skep running diffs on super long lines because it's probably + // expensive and hard to follow + if (deletionLine.length > 1000 || additionLine.length > 1000) { + continue; + } + const lineDiff = diffWordsWithSpace(deletionLine, additionLine); + const deletionSpans: [0 | 1, string][] = []; + const additionSpans: [0 | 1, string][] = []; for (const item of lineDiff) { - if (item[0] === 0) { - deletionSpans.push(item); - additionSpans.push(item); - } else if (item[0] === -1) { - deletionSpans.push(item); + if (!item.added && !item.removed) { + pushOrJoinSpan(item, deletionSpans, true); + pushOrJoinSpan(item, additionSpans, true); + } else if (item.removed) { + pushOrJoinSpan(item, deletionSpans); } else { - additionSpans.push(item); + pushOrJoinSpan(item, additionSpans); } } let spanIndex = 0; for (const span of additionSpans) { - if (span[0] !== 0) { + if (span[0] === 1) { (unified ? unifiedDecorations : additionDecorations).push( createDiffSpanDecoration({ line: group.additionStartIndex + i, @@ -490,7 +495,7 @@ export class DiffRenderer { } spanIndex = 0; for (const span of deletionSpans) { - if (span[0] !== 0) { + if (span[0] === 1) { (unified ? unifiedDecorations : deletionDecorations).push( createDiffSpanDecoration({ line: group.deletionStartIndex + i, @@ -673,3 +678,26 @@ function createTransformerWithState(): { }, }; } + +function pushOrJoinSpan( + item: ChangeObject, + arr: [0 | 1, string][], + isNeutral: boolean = false +) { + const lastItem = arr[arr.length - 1]; + if (lastItem == null || item.value === '\n') { + arr.push([isNeutral ? 0 : 1, item.value]); + return; + } + const isLastItemNeutral = lastItem[0] === 0; + if ( + isNeutral === isLastItemNeutral || + // If we have a single space neutral item, lets join it to a previously + // space non-neutral item to avoid single space gaps + (isNeutral && item.value.length === 1 && !isLastItemNeutral) + ) { + lastItem[1] += item.value; + return; + } + arr.push([isNeutral ? 0 : 1, item.value]); +} From 96fc18ee0d75bd3e750183b341bd710013358d59 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Fri, 26 Sep 2025 16:18:39 -0700 Subject: [PATCH 5/5] Adding some descriptive comments --- packages/diff-ui/src/DiffRenderer.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/diff-ui/src/DiffRenderer.ts b/packages/diff-ui/src/DiffRenderer.ts index 24548ad5a..97ce21787 100644 --- a/packages/diff-ui/src/DiffRenderer.ts +++ b/packages/diff-ui/src/DiffRenderer.ts @@ -301,6 +301,9 @@ export class DiffRenderer { function addToChangeGroup(type: 'addition' | 'deletion', line: string) { if (currentChangeGroup == null) { currentChangeGroup = { + // In unified layout, deletionLineIndex and additionLineIndex won't + // be usable, and we will have to compute start indexes as we are + // iterating deletionStartIndex: unified ? -1 : deletionLineIndex - 1, additionStartIndex: unified ? -1 : additionLineIndex - 1, deletionLines: [], @@ -679,6 +682,11 @@ function createTransformerWithState(): { }; } +// For diff decoration spans, we want to be sure that if there is a single +// white-space gap between diffs that we join them together into a longer diff span. +// Spans are basically just a tuple - 1 means the content should be +// highlighted, 0 means it should not, we still need to the span data to figure +// out span positions function pushOrJoinSpan( item: ChangeObject, arr: [0 | 1, string][],