diff --git a/apps/demo/index.html b/apps/demo/index.html index ae3128409..165d98a75 100644 --- a/apps/demo/index.html +++ b/apps/demo/index.html @@ -28,7 +28,7 @@ -
+
diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index 6522a78d8..d198e8d16 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -1,28 +1,34 @@ import { CodeRenderer, - DiffRenderer, + DiffFileRenderer, type ParsedPatch, + type SupportedLanguages, + getFiletypeFromFileName, isHighlighterNull, parseDiffFromFiles, parsePatchContent, preloadHighlighter, - renderFileHeader, } from '@pierre/diff-ui'; import type { BundledLanguage, BundledTheme } from 'shiki'; -import { - DIFF_CONTENT as CONTENT, - CodeConfigs, - FILE_NEW, - FILE_OLD, - getFiletypeFromMetadata, - toggleTheme, -} from './mocks/'; +import { CodeConfigs, FILE_NEW, FILE_OLD, toggleTheme } from './mocks/'; import './style.css'; import { createFakeContentStream } from './utils/createFakeContentStream'; +let loadingPatch: Promise | undefined; +async function loadPatchContent() { + loadingPatch = + loadingPatch ?? + new Promise((resolve) => { + import('./mocks/diff.patch?raw').then(({ default: content }) => + resolve(content) + ); + }); + return loadingPatch; +} + function startStreaming() { - const container = document.getElementById('content'); + const container = document.getElementById('wrapper'); if (container == null) return; if (loadDiff != null) { loadDiff.parentElement?.removeChild(loadDiff); @@ -39,14 +45,15 @@ function startStreaming() { } let parsedPatches: ParsedPatch[] | undefined; -function handlePreloadDiff() { +async function handlePreloadDiff() { if (parsedPatches != null || !isHighlighterNull()) return; - parsedPatches = parsePatchContent(CONTENT); + const content = await loadPatchContent(); + parsedPatches = parsePatchContent(content); console.log('Parsed File:', parsedPatches); - const langs = new Set(); + const langs = new Set(); for (const parsedPatch of parsedPatches) { for (const file of parsedPatch.files) { - const lang = getFiletypeFromMetadata(file); + const lang = getFiletypeFromFileName(file.name); if (lang != null) { langs.add(lang); } @@ -58,23 +65,23 @@ function handlePreloadDiff() { }); } -const diffInstances: DiffRenderer[] = []; +const diffInstances: DiffFileRenderer[] = []; function renderDiff(parsedPatches: ParsedPatch[]) { - const container = document.getElementById('content'); - if (container == null) return; + const wrapper = document.getElementById('wrapper'); + if (wrapper == null) return; if (loadDiff != null) { loadDiff.parentElement?.removeChild(loadDiff); } if (streamCode != null) { streamCode.parentElement?.removeChild(streamCode); } - container.innerHTML = ''; + wrapper.innerHTML = ''; window.scrollTo({ top: 0 }); for (const instance of diffInstances) { instance.cleanUp(); } diffInstances.length = 0; - container.dataset.diff = ''; + wrapper.dataset.diff = ''; const checkbox = document.getElementById('unified') as | HTMLInputElement @@ -82,18 +89,15 @@ function renderDiff(parsedPatches: ParsedPatch[]) { const unified = checkbox?.checked ?? false; for (const parsedPatch of parsedPatches) { if (parsedPatch.patchMetadata != null) { - container.appendChild(createFileMetadata(parsedPatch.patchMetadata)); + wrapper.appendChild(createFileMetadata(parsedPatch.patchMetadata)); } - for (const file of parsedPatch.files) { - container.appendChild(renderFileHeader(file)); - const pre = document.createElement('pre'); - container.appendChild(pre); - const instance = new DiffRenderer({ - lang: getFiletypeFromMetadata(file), + for (const fileDiff of parsedPatch.files) { + const instance = new DiffFileRenderer({ themes: { dark: 'tokyo-night', light: 'solarized-light' }, - unified, + diffStyle: unified ? 'unified' : 'split', + detectLanguage: true, }); - instance.render(file, pre); + instance.render({ fileDiff, wrapper }); diffInstances.push(instance); } } @@ -134,9 +138,9 @@ if (streamCode != null) { const loadDiff = document.getElementById('load-diff'); if (loadDiff != null) { - loadDiff.addEventListener('click', () => - renderDiff(parsedPatches ?? parsePatchContent(CONTENT)) - ); + loadDiff.addEventListener('click', async () => { + renderDiff(parsedPatches ?? parsePatchContent(await loadPatchContent())); + }); loadDiff.addEventListener('mouseenter', handlePreloadDiff); } @@ -166,7 +170,10 @@ if (unifiedCheckbox instanceof HTMLInputElement) { unifiedCheckbox.addEventListener('change', () => { const checked = unifiedCheckbox.checked; for (const instance of diffInstances) { - instance.setOptions({ ...instance.options, unified: checked }); + instance.setOptions({ + ...instance.options, + diffStyle: checked ? 'unified' : 'split', + }); } }); } @@ -180,33 +187,33 @@ if (diff2Files != null) { } lastWrapper = document.createElement('div'); - const file1Container = document.createElement('div'); - file1Container.className = 'file'; + const fileOldContainer = document.createElement('div'); + fileOldContainer.className = 'file'; lastWrapper.className = 'files-input'; - const file1Name = document.createElement('input'); - file1Name.type = 'text'; - file1Name.value = 'file_old.ts'; - file1Name.spellcheck = false; - const file1Contents = document.createElement('textarea'); - file1Contents.value = FILE_OLD; - file1Contents.spellcheck = false; - file1Container.appendChild(file1Name); - file1Container.appendChild(file1Contents); - lastWrapper.appendChild(file1Container); - - const file2Container = document.createElement('div'); - file2Container.className = 'file'; + const fileOldName = document.createElement('input'); + fileOldName.type = 'text'; + fileOldName.value = 'file_old.ts'; + fileOldName.spellcheck = false; + const fileOldContents = document.createElement('textarea'); + fileOldContents.value = FILE_OLD; + fileOldContents.spellcheck = false; + fileOldContainer.appendChild(fileOldName); + fileOldContainer.appendChild(fileOldContents); + lastWrapper.appendChild(fileOldContainer); + + const fileNewContainer = document.createElement('div'); + fileNewContainer.className = 'file'; lastWrapper.className = 'files-input'; - const file2Name = document.createElement('input'); - file2Name.type = 'text'; - file2Name.value = 'file_new.ts'; - file2Name.spellcheck = false; - const file2Contents = document.createElement('textarea'); - file2Contents.value = FILE_NEW; - file2Contents.spellcheck = false; - file2Container.appendChild(file2Name); - file2Container.appendChild(file2Contents); - lastWrapper.appendChild(file2Container); + const fileNewName = document.createElement('input'); + fileNewName.type = 'text'; + fileNewName.value = 'file_new.ts'; + fileNewName.spellcheck = false; + const fileNewContents = document.createElement('textarea'); + fileNewContents.value = FILE_NEW; + fileNewContents.spellcheck = false; + fileNewContainer.appendChild(fileNewName); + fileNewContainer.appendChild(fileNewContents); + lastWrapper.appendChild(fileNewContainer); const bottomWrapper = document.createElement('div'); bottomWrapper.className = 'buttons'; @@ -214,12 +221,12 @@ if (diff2Files != null) { render.innerText = 'Render Diff'; render.addEventListener('click', () => { const oldFile = { - name: file1Name.value, - contents: file1Contents.value, + name: fileOldName.value, + contents: fileOldContents.value, }; const newFile = { - name: file2Name.value, - contents: file2Contents.value, + name: fileNewName.value, + contents: fileNewContents.value, }; lastWrapper?.parentNode?.removeChild(lastWrapper); diff --git a/apps/demo/src/mocks/index.ts b/apps/demo/src/mocks/index.ts index ede6d9b58..167731292 100644 --- a/apps/demo/src/mocks/index.ts +++ b/apps/demo/src/mocks/index.ts @@ -1,13 +1,5 @@ -import type { FileMetadata } from '@pierre/diff-ui'; -import type { BundledLanguage } from 'shiki'; - import { createHighlighterCleanup } from '../utils/createHighlighterCleanup'; import { createScrollFixer } from '../utils/createScrollFixer'; -import diffContent2 from './diff2.patch?raw'; -import diffContent3 from './diff3.patch?raw'; -import diffContent4 from './diff4.patch?raw'; -import diffContent5 from './diff5.patch?raw'; -import diffContent from './diff.patch?raw'; import mdContent from './example_md.txt?raw'; import tsContent from './example_ts.txt?raw'; import fileNew from './fileNew.txt?raw'; @@ -53,25 +45,3 @@ export function toggleTheme() { export const FILE_OLD = fileOld; export const FILE_NEW = fileNew; - -export const DIFF_CONTENT = diffContent; -export const DIFF_CONTENT_2 = diffContent2; -export const DIFF_CONTENT_3 = diffContent3; -export const DIFF_CONTENT_4 = diffContent4; -export const DIFF_CONTENT_5 = diffContent5; - -export const DIFF_CONTENT_FORMATS: Record = - { - js: 'javascript', - jsx: 'jsx', - html: 'html', - json: 'json', - ts: 'typescript', - tsx: 'tsx', - css: 'css', - patch: 'diff', - }; - -export function getFiletypeFromMetadata(file: FileMetadata) { - return DIFF_CONTENT_FORMATS[file.name.match(/\.([^.]+)$/)?.[1] || '']; -} diff --git a/apps/demo/src/style.css b/apps/demo/src/style.css index c33b282fb..07d50e049 100644 --- a/apps/demo/src/style.css +++ b/apps/demo/src/style.css @@ -134,7 +134,7 @@ code { align-items: center; } -.content { +.wrapper { display: grid; min-width: 0; min-height: 0; @@ -147,7 +147,7 @@ code { overflow: hidden; } -.content[data-diff] { +.wrapper[data-diff] { grid-template-columns: 1fr; grid-template-rows: min-content; min-height: auto; @@ -225,3 +225,12 @@ code { display: flex; flex-direction: row-reverse; } + +[data-pjs], +[data-pjs-header] { + content-visibility: auto; +} + +[data-pjs-header] { + contain-intrinsic-size: auto 36px; +} diff --git a/packages/diff-ui/src/DiffFileRenderer.ts b/packages/diff-ui/src/DiffFileRenderer.ts new file mode 100644 index 000000000..731a77cb0 --- /dev/null +++ b/packages/diff-ui/src/DiffFileRenderer.ts @@ -0,0 +1,136 @@ +import { DiffHunksRenderer } from './DiffHunksRenderer'; +import type { + BaseRendererOptions, + FileDiffMetadata, + RenderCustomFileMetadata, + ThemeRendererOptions, + ThemesRendererOptions, +} from './types'; +import { getFiletypeFromFileName } from './utils/getFiletypeFromFileName'; +import { renderFileHeader } from './utils/html_render_utils'; + +interface FileDiffRenderProps { + lang?: BaseRendererOptions['lang']; + fileDiff: FileDiffMetadata; + fileContainer?: HTMLElement; + wrapper?: HTMLElement; +} + +interface DiffFileBaseOptions { + disableFileHeader?: boolean; + renderCustomMetadata?: RenderCustomFileMetadata; + detectLanguage?: boolean; +} + +type FileBaseRendererOptions = Omit; + +interface DiffFileThemeRendererOptions + extends FileBaseRendererOptions, + ThemeRendererOptions, + DiffFileBaseOptions {} + +interface DiffFileThemesRendererOptions + extends FileBaseRendererOptions, + ThemesRendererOptions, + DiffFileBaseOptions {} + +export type DiffFileRendererOptions = + | DiffFileThemeRendererOptions + | DiffFileThemesRendererOptions; + +export class DiffFileRenderer { + options: DiffFileRendererOptions; + private fileContainer: HTMLElement | undefined; + private header: HTMLDivElement | undefined; + private pre: HTMLPreElement | undefined; + + hunksRenderer: DiffHunksRenderer | undefined; + + constructor(options: DiffFileRendererOptions) { + this.options = options; + } + + setOptions(options: DiffFileRendererOptions) { + this.options = options; + if (this.fileDiff == null) { + return; + } + this.render({ fileDiff: this.fileDiff }); + } + + cleanUp() { + this.fileContainer?.parentNode?.removeChild(this.fileContainer); + this.fileContainer = undefined; + this.pre = undefined; + this.header = undefined; + this.fileDiff = undefined; + } + + private fileDiff: FileDiffMetadata | undefined; + async render({ + fileDiff, + fileContainer, + wrapper, + lang = (this.options.detectLanguage ?? false) + ? getFiletypeFromFileName(fileDiff.name) + : 'text', + }: FileDiffRenderProps) { + fileContainer = this.getOrCreateFileContainer(fileContainer); + if (wrapper != null && fileContainer.parentNode !== wrapper) { + wrapper.appendChild(fileContainer); + } + const pre = this.getOrCreatePre(fileContainer); + this.renderHeader(fileDiff, fileContainer); + if (this.hunksRenderer == null) { + this.hunksRenderer = new DiffHunksRenderer({ ...this.options, lang }); + } else { + this.hunksRenderer.setOptions({ ...this.options, lang }, true); + } + this.fileDiff = fileDiff; + await this.hunksRenderer.render(this.fileDiff, pre); + } + + getOrCreateFileContainer(fileContainer?: HTMLElement) { + if ( + (fileContainer != null && fileContainer === this.fileContainer) || + (fileContainer == null && this.fileContainer != null) + ) { + return this.fileContainer; + } + this.fileContainer = fileContainer ?? document.createElement('div'); + this.fileContainer.dataset.pjsContainer = ''; + return this.fileContainer; + } + + getOrCreatePre(container: HTMLElement) { + // If we haven't created a pre element yet, lets go ahead and do that + if (this.pre == null) { + this.pre = document.createElement('pre'); + container.appendChild(this.pre); + } + // If we have a new parent container for the pre element, lets go ahead and + // move it into the new container + else if (this.pre.parentNode !== container) { + container.appendChild(this.pre); + } + return this.pre; + } + + // NOTE(amadeus): We just always do a full re-render with the header... + renderHeader(file: FileDiffMetadata, container: HTMLElement) { + const { renderCustomMetadata, disableFileHeader = false } = this.options; + if (disableFileHeader) { + if (this.header != null) { + this.header.parentNode?.removeChild(this.header); + } + return; + } + const newHeader = renderFileHeader(file, renderCustomMetadata); + if (this.header != null) { + container.replaceChild(newHeader, this.header); + } else { + container.prepend(newHeader); + } + this.header = newHeader; + } +} diff --git a/packages/diff-ui/src/DiffRenderer.ts b/packages/diff-ui/src/DiffHunksRenderer.ts similarity index 79% rename from packages/diff-ui/src/DiffRenderer.ts rename to packages/diff-ui/src/DiffHunksRenderer.ts index 1d3704548..976f4df76 100644 --- a/packages/diff-ui/src/DiffRenderer.ts +++ b/packages/diff-ui/src/DiffHunksRenderer.ts @@ -1,17 +1,24 @@ import type { - CodeOptionsMultipleThemes, CodeToHastOptions, DecorationItem, HighlighterGeneric, ShikiTransformer, } from '@shikijs/core'; -import { type ChangeObject, diffWordsWithSpace } from 'diff'; +import { type ChangeObject, diffChars, diffWordsWithSpace } from 'diff'; import type { Element, ElementContent, Root, RootContent } from 'hast'; import { toHtml } from 'hast-util-to-html'; -import type { BundledLanguage, BundledTheme } from 'shiki'; +import type { BundledTheme } from 'shiki'; import { getSharedHighlighter } from './SharedHighlighter'; -import type { FileMetadata, HUNK_LINE_TYPE, Hunk } from './types'; +import type { + BaseRendererOptions, + FileDiffMetadata, + HUNK_LINE_TYPE, + Hunk, + SupportedLanguages, + ThemeRendererOptions, + ThemesRendererOptions, +} from './types'; import { createCodeNode, createHunkSeparator, @@ -27,26 +34,9 @@ interface ChangeHunk { additionLines: string[]; } -export interface DiffDecorationItem extends DecorationItem { - type: 'additions' | 'deletions'; - // Kinda hate this API for now... need to think about it more... - hunkIndex: number; -} - -interface CodeTokenOptionsBase { - lang?: BundledLanguage; - defaultColor?: CodeOptionsMultipleThemes['defaultColor']; - preferWasmHighlighter?: boolean; - unified?: boolean; - - // FIXME(amadeus): Figure out how to incorporate these mb? - onPreRender?(instance: DiffRenderer): unknown; - onPostRender?(instance: DiffRenderer): unknown; -} - interface RenderHunkProps { hunk: Hunk; - highlighter: HighlighterGeneric; + highlighter: HighlighterGeneric; state: SharedRenderState; transformer: ShikiTransformer; @@ -65,33 +55,28 @@ interface SharedRenderState { lineInfo: Record; spans: Record; decorations: DecorationItem[]; + disableLineNumbers: boolean; } -interface CodeTokenOptionsSingleTheme extends CodeTokenOptionsBase { - theme: BundledTheme; - themes?: never; -} +interface DiffHunkThemeRendererOptions + extends BaseRendererOptions, + ThemeRendererOptions {} -interface CodeTokenOptionsMultiThemes extends CodeTokenOptionsBase { - theme?: never; - themes: { dark: BundledTheme; light: BundledTheme }; -} +interface DiffHunkThemesRendererOptions + extends BaseRendererOptions, + ThemesRendererOptions {} + +export type DiffHunksRendererOptions = + | DiffHunkThemeRendererOptions + | DiffHunkThemesRendererOptions; -export type DiffRendererOptions = - | CodeTokenOptionsSingleTheme - | CodeTokenOptionsMultiThemes; - -// Something to think about here -- might be worth not forcing a renderer to -// take a stream right off the bat, and instead allow it to get the highlighter -// and everything setup ASAP, and allow setup the ability to pass a -// ReadableStream to it... -export class DiffRenderer { - highlighter: HighlighterGeneric | undefined; - options: DiffRendererOptions; +export class DiffHunksRenderer { + highlighter: HighlighterGeneric | undefined; + options: DiffHunksRendererOptions; pre: HTMLPreElement | undefined; - diff: FileMetadata | undefined; + diff: FileDiffMetadata | undefined; - constructor(options: DiffRendererOptions) { + constructor(options: DiffHunksRendererOptions) { this.options = options; } @@ -101,12 +86,47 @@ export class DiffRenderer { this.diff = undefined; } - setOptions(options: DiffRendererOptions) { + setOptions( + options: DiffHunksRendererOptions, + disableRerender: boolean = false + ) { this.options = options; if (this.pre == null || this.diff == null) { return; } - this.render(this.diff, this.pre); + if (!disableRerender) { + this.render(this.diff, this.pre); + } + } + + getOptionsWithDefaults() { + const { + theme, + themes, + diffStyle = 'split', + lineDiffType = 'word-alt', + maxLineDiffLength = 1000, + maxLineLengthForHighlighting = 1000, + disableLineNumbers = false, + } = this.options; + if (themes != null) { + return { + themes, + diffStyle, + lineDiffType, + maxLineDiffLength, + maxLineLengthForHighlighting, + disableLineNumbers, + }; + } + return { + theme, + diffStyle, + lineDiffType, + maxLineDiffLength, + maxLineLengthForHighlighting, + disableLineNumbers, + }; } private async initializeHighlighter() { @@ -114,9 +134,9 @@ export class DiffRenderer { return this.highlighter; } - private queuedRenderArgs: [FileMetadata, HTMLPreElement] | undefined; + private queuedRenderArgs: [FileDiffMetadata, HTMLPreElement] | undefined; - async render(_diff: FileMetadata, _wrapper: HTMLPreElement) { + async render(_diff: FileDiffMetadata, _wrapper: HTMLPreElement) { const isSettingUp = this.queuedRenderArgs != null; this.queuedRenderArgs = [_diff, _wrapper]; if (isSettingUp) { @@ -135,14 +155,15 @@ export class DiffRenderer { private renderDiff( wrapper: HTMLPreElement, - diff: FileMetadata, - highlighter: HighlighterGeneric + diff: FileDiffMetadata, + highlighter: HighlighterGeneric ) { - const { themes, theme, unified = false } = this.options; - const split = - unified === true - ? false - : diff.type === 'change' || diff.type === 'rename-changed'; + const { themes, theme, diffStyle, disableLineNumbers } = + this.getOptionsWithDefaults(); + const unified = diffStyle === 'unified'; + const split = unified + ? false + : diff.type === 'change' || diff.type === 'rename-changed'; const pre = setupPreNode( themes != null ? { pre: wrapper, themes, highlighter, split } @@ -154,7 +175,8 @@ export class DiffRenderer { const codeAdditions = createCodeNode({ columnType: 'additions' }); const codeDeletions = createCodeNode({ columnType: 'deletions' }); const codeUnified = createCodeNode({ columnType: 'unified' }); - const { state, transformer } = createTransformerWithState(); + const { state, transformer } = + createTransformerWithState(disableLineNumbers); let hunkIndex = 0; for (const hunk of diff.hunks) { if (hunkIndex > 0) { @@ -191,13 +213,14 @@ export class DiffRenderer { private createHastOptions( transformer: ShikiTransformer, - decorations?: DecorationItem[] + decorations?: DecorationItem[], + forceTextLang: boolean = false ): CodeToHastOptions { if ('theme' in this.options && this.options.theme != null) { return { theme: this.options.theme, cssVariablePrefix: formatCSSVariablePrefix(), - lang: this.options.lang ?? ('text' as BundledLanguage), + lang: forceTextLang ? 'text' : (this.options.lang ?? 'text'), defaultColor: this.options.defaultColor ?? false, transformers: [transformer], decorations, @@ -208,7 +231,7 @@ export class DiffRenderer { return { themes: this.options.themes, cssVariablePrefix: formatCSSVariablePrefix(), - lang: this.options.lang ?? ('text' as BundledLanguage), + lang: forceTextLang ? 'text' : (this.options.lang ?? 'text'), defaultColor: this.options.defaultColor ?? false, transformers: [transformer], decorations, @@ -227,7 +250,8 @@ export class DiffRenderer { codeUnified, }: RenderHunkProps) { if (hunk.hunkContent == null) return; - const { additions, deletions, unified } = this.processLines(hunk); + const { additions, deletions, unified, hasLongLines } = + this.processLines(hunk); if (unified.content.length > 0) { // Remove trailing blank line @@ -236,7 +260,7 @@ export class DiffRenderer { state.lineInfo = unified.lineInfo; const nodes = highlighter.codeToHast( content, - this.createHastOptions(transformer, unified.decorations) + this.createHastOptions(transformer, unified.decorations, hasLongLines) ); codeUnified.insertAdjacentHTML( 'beforeend', @@ -253,7 +277,8 @@ export class DiffRenderer { content, this.createHastOptions( transformer, - deletions.decorations.length > 0 ? deletions.decorations : undefined + deletions.decorations.length > 0 ? deletions.decorations : undefined, + hasLongLines ) ); codeDeletions.insertAdjacentHTML( @@ -271,7 +296,8 @@ export class DiffRenderer { content, this.createHastOptions( transformer, - additions.decorations.length > 0 ? additions.decorations : undefined + additions.decorations.length > 0 ? additions.decorations : undefined, + hasLongLines ) ); codeAdditions.insertAdjacentHTML( @@ -282,7 +308,10 @@ export class DiffRenderer { } private processLines(hunk: Hunk) { - const { unified = false } = this.options; + const { maxLineLengthForHighlighting, diffStyle } = + this.getOptionsWithDefaults(); + const unified = diffStyle === 'unified'; + let hasLongLines = false; const additionContent: string[] = []; const additionLineInfo: Record = {}; @@ -358,7 +387,11 @@ export class DiffRenderer { let lastType: HUNK_LINE_TYPE | undefined; for (const rawLine of hunk.hunkContent ?? []) { - const { line, type } = parseLineType(rawLine); + const { line, type, longLine } = parseLineType( + rawLine, + maxLineLengthForHighlighting + ); + hasLongLines = hasLongLines || longLine; if (type === 'context') { createSpanIfNecessary(); } @@ -465,6 +498,7 @@ export class DiffRenderer { const { unifiedDecorations, deletionDecorations, additionDecorations } = this.parseDecorations(diffGroups); return { + hasLongLines, additions: { content: additionContent, lineInfo: additionLineInfo, @@ -485,11 +519,19 @@ export class DiffRenderer { }; } - private parseDecorations(diffGroups: ChangeHunk[]) { - const { unified = false } = this.options; + private parseDecorations( + diffGroups: ChangeHunk[], + disableDecorations = false + ) { + const { lineDiffType, maxLineDiffLength, diffStyle } = + this.getOptionsWithDefaults(); + const unified = diffStyle === 'unified'; const unifiedDecorations: DecorationItem[] = []; const additionDecorations: DecorationItem[] = []; const deletionDecorations: DecorationItem[] = []; + if (disableDecorations || lineDiffType === 'none') { + return { unifiedDecorations, deletionDecorations, additionDecorations }; + } for (const group of diffGroups) { const len = Math.min( group.additionLines.length, @@ -503,20 +545,37 @@ export class DiffRenderer { } // Lets skep running diffs on super long lines because it's probably // expensive and hard to follow - if (deletionLine.length > 1000 || additionLine.length > 1000) { + if ( + deletionLine.length > maxLineDiffLength || + additionLine.length > maxLineDiffLength + ) { continue; } - const lineDiff = diffWordsWithSpace(deletionLine, additionLine); + const lineDiff = + lineDiffType === 'char' + ? diffChars(deletionLine, additionLine) + : diffWordsWithSpace(deletionLine, additionLine); const deletionSpans: [0 | 1, string][] = []; const additionSpans: [0 | 1, string][] = []; + const enableJoin = lineDiffType === 'word-alt'; for (const item of lineDiff) { if (!item.added && !item.removed) { - pushOrJoinSpan(item, deletionSpans, true); - pushOrJoinSpan(item, additionSpans, true); + pushOrJoinSpan({ + item, + arr: deletionSpans, + enableJoin, + isNeutral: true, + }); + pushOrJoinSpan({ + item, + arr: additionSpans, + enableJoin, + isNeutral: true, + }); } else if (item.removed) { - pushOrJoinSpan(item, deletionSpans); + pushOrJoinSpan({ item, arr: deletionSpans, enableJoin }); } else { - pushOrJoinSpan(item, additionSpans); + pushOrJoinSpan({ item, arr: additionSpans, enableJoin }); } } let spanIndex = 0; @@ -572,7 +631,7 @@ export class DiffRenderer { theme, preferWasmHighlighter, } = this.options; - const langs: BundledLanguage[] = []; + const langs: SupportedLanguages[] = []; if (lang != null) { langs.push(lang); } @@ -633,16 +692,17 @@ function convertLine( node.children.push({ type: 'text', value: '\n' }); } const children = [node]; - // NOTE(amadeus): This should probably be based on a setting - children.unshift({ - tagName: 'div', - type: 'element', - properties: { 'data-column-number': '' }, - children: - lineInfo.metadataContent == null - ? [{ type: 'text', value: `${lineInfo.number}` }] - : [], - }); + if (!state.disableLineNumbers) { + children.unshift({ + tagName: 'div', + type: 'element', + properties: { 'data-column-number': '' }, + children: + lineInfo.metadataContent == null + ? [{ type: 'text', value: `${lineInfo.number}` }] + : [], + }); + } return { tagName: 'div', type: 'element', @@ -682,7 +742,7 @@ function createEmptyRowBuffer(size: number): Element { }; } -function createTransformerWithState(): { +function createTransformerWithState(disableLineNumbers: boolean): { state: SharedRenderState; transformer: ShikiTransformer; } { @@ -690,6 +750,7 @@ function createTransformerWithState(): { spans: {}, lineInfo: {}, decorations: [], + disableLineNumbers, }; return { state, @@ -730,18 +791,26 @@ function createTransformerWithState(): { }; } +interface PushOrJoinSpanProps { + item: ChangeObject; + arr: [0 | 1, string][]; + enableJoin: boolean; + isNeutral?: boolean; +} + // 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][], - isNeutral: boolean = false -) { +function pushOrJoinSpan({ + item, + arr, + enableJoin, + isNeutral = false, +}: PushOrJoinSpanProps) { const lastItem = arr[arr.length - 1]; - if (lastItem == null || item.value === '\n') { + if (lastItem == null || item.value === '\n' || !enableJoin) { arr.push([isNeutral ? 0 : 1, item.value]); return; } diff --git a/packages/diff-ui/src/SharedHighlighter.ts b/packages/diff-ui/src/SharedHighlighter.ts index f31391d1c..3c5a95c67 100644 --- a/packages/diff-ui/src/SharedHighlighter.ts +++ b/packages/diff-ui/src/SharedHighlighter.ts @@ -1,5 +1,4 @@ import { - type BundledLanguage, type BundledTheme, type HighlighterGeneric, createHighlighter, @@ -8,7 +7,9 @@ import { loadWasm, } from 'shiki'; -type PierreHighlighter = HighlighterGeneric; +import type { SupportedLanguages } from './types'; + +type PierreHighlighter = HighlighterGeneric; type CachedOrLoadingHighlighterType = | Promise @@ -18,11 +19,11 @@ type CachedOrLoadingHighlighterType = let highlighter: CachedOrLoadingHighlighterType; const loadedThemes = new Map>(); -const loadedLanguages = new Map>(); +const loadedLanguages = new Map>(); interface HighlighterOptions { themes: BundledTheme[]; - langs: BundledLanguage[]; + langs: SupportedLanguages[]; preferWasmHighlighter?: boolean; } @@ -50,13 +51,14 @@ export async function getSharedHighlighter({ for (const language of langs) { loadedLanguages.set(language, true); } + loadedLanguages.set('text', true); return createHighlighter({ themes, - langs, + langs: [...langs, 'text'], engine: preferWasmHighlighter ? createOnigurumaEngine() : createJavaScriptRegexEngine(), - }); + }) as Promise; }) .then((instance) => { highlighter = instance; diff --git a/packages/diff-ui/src/constants.ts b/packages/diff-ui/src/constants.ts index ebf7349f9..818f97169 100644 --- a/packages/diff-ui/src/constants.ts +++ b/packages/diff-ui/src/constants.ts @@ -2,7 +2,8 @@ export const COMMIT_METADATA_SPLIT = /(?=^From [a-f0-9]+ .+$)/m; export const GIT_DIFF_FILE_BREAK_REGEX = /(?=^diff --git)/gm; export const UNIFIED_DIFF_FILE_BREAK_REGEX = /(?=^---\s+\S)/gm; export const FILE_CONTEXT_BLOB = /(?=^@@ )/gm; -export const HUNK_HEADER = /^@@ -(\d+),(\d+) \+(\d+),(\d+) @@(?: (.*))?/m; +export const HUNK_HEADER = + /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: (.*))?/m; export const SPLIT_WITH_NEWLINES = /(?<=\n)/; export const FILENAME_HEADER_REGEX = /^(---|\+\+\+)\s+([^\t\n]+)/; export const FILENAME_HEADER_REGEX_GIT = /^(---|\+\+\+)\s+[ab]\/([^\t\n]+)/; diff --git a/packages/diff-ui/src/index.ts b/packages/diff-ui/src/index.ts index 232327d78..8caf3f1a6 100644 --- a/packages/diff-ui/src/index.ts +++ b/packages/diff-ui/src/index.ts @@ -2,10 +2,12 @@ import './style.css'; export * from '@pierre/shiki-stream'; export * from './CodeRenderer'; -export * from './DiffRenderer'; +export * from './DiffFileRenderer'; +export * from './DiffHunksRenderer'; export * from './SharedHighlighter'; export * from './UnversialRenderer'; export * from './createStreamingHighlighter'; +export * from './utils/getFiletypeFromFileName'; export * from './utils/html_render_utils'; export * from './utils/parseDiffFromFiles'; export * from './utils/parseLineType'; diff --git a/packages/diff-ui/src/types.ts b/packages/diff-ui/src/types.ts index f1c73374f..77420000d 100644 --- a/packages/diff-ui/src/types.ts +++ b/packages/diff-ui/src/types.ts @@ -1,4 +1,8 @@ -import type { BundledTheme } from 'shiki'; +import type { + BundledLanguage, + BundledTheme, + CodeOptionsMultipleThemes, +} from 'shiki'; export interface ThemesType { dark: BundledTheme; @@ -14,7 +18,7 @@ export type FileTypes = export interface ParsedPatch { patchMetadata: string | undefined; - files: FileMetadata[]; + files: FileDiffMetadata[]; } export interface Hunk { @@ -26,11 +30,45 @@ export interface Hunk { hunkContext: string | undefined; } -export interface FileMetadata { +export interface FileDiffMetadata { name: string; prevName: string | undefined; type: FileTypes; hunks: Hunk[]; + lines: number; } +export type SupportedLanguages = BundledLanguage | 'text'; + export type HUNK_LINE_TYPE = 'context' | 'addition' | 'deletion' | 'metadata'; + +export interface BaseRendererOptions { + diffStyle: 'unified' | 'split'; // split is default + // NOTE(amadeus): 'word-alt' attempts to join word regions that are separated + // by a single character + lineDiffType?: 'word-alt' | 'word' | 'char' | 'none'; // 'word-alt' is default + maxLineDiffLength?: number; // 1000 is default + maxLineLengthForHighlighting?: number; // 1000 is default + disableLineNumbers?: boolean; + + // Shiki config options + lang?: SupportedLanguages; + defaultColor?: CodeOptionsMultipleThemes['defaultColor']; + preferWasmHighlighter?: boolean; +} + +export interface ThemeRendererOptions { + theme: BundledTheme; + themes?: never; +} + +export interface ThemesRendererOptions { + theme?: never; + themes: { dark: BundledTheme; light: BundledTheme }; +} + +export type RenderCustomFileMetadata = ( + file: FileDiffMetadata +) => Element | null | undefined | string | number; + +export type ExtensionFormatMap = Record; diff --git a/packages/diff-ui/src/utils/getFiletypeFromFileName.ts b/packages/diff-ui/src/utils/getFiletypeFromFileName.ts new file mode 100644 index 000000000..099291393 --- /dev/null +++ b/packages/diff-ui/src/utils/getFiletypeFromFileName.ts @@ -0,0 +1,25 @@ +import type { ExtensionFormatMap } from '../types'; + +export const EXTENSION_TO_FILE_FORMAT: ExtensionFormatMap = { + css: 'css', + go: 'go', + html: 'html', + js: 'javascript', + json: 'json', + jsx: 'jsx', + patch: 'diff', + ts: 'typescript', + tsx: 'tsx', + txt: 'text', + // TODO: Flesh this out... +}; + +export function getFiletypeFromFileName(fileName: string) { + return EXTENSION_TO_FILE_FORMAT[fileName.match(/\.([^.]+)$/)?.[1] || '']; +} + +export function extendFileFormatMap(map: ExtensionFormatMap) { + for (const key in map) { + EXTENSION_TO_FILE_FORMAT[key] = map[key]; + } +} diff --git a/packages/diff-ui/src/utils/html_render_utils.ts b/packages/diff-ui/src/utils/html_render_utils.ts index 6c01aa0a5..7dc7bb058 100644 --- a/packages/diff-ui/src/utils/html_render_utils.ts +++ b/packages/diff-ui/src/utils/html_render_utils.ts @@ -7,7 +7,11 @@ import { stringifyTokenStyle, } from 'shiki'; -import type { FileMetadata, ThemesType } from '../types'; +import type { + FileDiffMetadata, + RenderCustomFileMetadata, + ThemesType, +} from '../types'; interface ThemeVariant { themes?: never; @@ -124,14 +128,10 @@ export function formatCSSVariablePrefix(prefix: string = 'pjs') { return `--${prefix}-`; } -type RenderCustomFileMetadata = ( - file: FileMetadata -) => Element | null | undefined | string | number; - export function renderFileHeader( - file: FileMetadata, + file: FileDiffMetadata, renderCustomMetadata?: RenderCustomFileMetadata -) { +): HTMLDivElement { const container = document.createElement('div'); container.dataset.pjsHeader = ''; container.dataset.changeType = file.type; diff --git a/packages/diff-ui/src/utils/parseLineType.ts b/packages/diff-ui/src/utils/parseLineType.ts index 5b7598393..325215d0b 100644 --- a/packages/diff-ui/src/utils/parseLineType.ts +++ b/packages/diff-ui/src/utils/parseLineType.ts @@ -3,9 +3,13 @@ import type { HUNK_LINE_TYPE } from '../types'; export interface ParseLineTypeReturn { line: string; type: HUNK_LINE_TYPE; + longLine: boolean; } -export function parseLineType(line: string): ParseLineTypeReturn { +export function parseLineType( + line: string, + maxLineLength: number +): ParseLineTypeReturn { const firstChar = line.substring(0, 1); if ( firstChar !== '+' && @@ -27,5 +31,6 @@ export function parseLineType(line: string): ParseLineTypeReturn { : firstChar === '+' ? 'addition' : 'deletion', + longLine: line.length - 1 >= maxLineLength, }; } diff --git a/packages/diff-ui/src/utils/parsePatchContent.ts b/packages/diff-ui/src/utils/parsePatchContent.ts index 4cee3d2af..914405b0a 100644 --- a/packages/diff-ui/src/utils/parsePatchContent.ts +++ b/packages/diff-ui/src/utils/parsePatchContent.ts @@ -8,7 +8,7 @@ import { SPLIT_WITH_NEWLINES, UNIFIED_DIFF_FILE_BREAK_REGEX, } from '../constants'; -import type { FileMetadata, Hunk, ParsedPatch } from '../types'; +import type { FileDiffMetadata, Hunk, ParsedPatch } from '../types'; function processPatch(data: string): ParsedPatch { const isGitDiff = GIT_DIFF_FILE_BREAK_REGEX.test(data); @@ -16,8 +16,8 @@ function processPatch(data: string): ParsedPatch { isGitDiff ? GIT_DIFF_FILE_BREAK_REGEX : UNIFIED_DIFF_FILE_BREAK_REGEX ); let patchMetadata: string | undefined; - const files: FileMetadata[] = []; - let currentFile: FileMetadata | undefined; + const files: FileDiffMetadata[] = []; + let currentFile: FileDiffMetadata | undefined; for (const file of rawFiles) { if (isGitDiff && !GIT_DIFF_FILE_BREAK_REGEX.test(file)) { if (patchMetadata == null) { @@ -56,6 +56,7 @@ function processPatch(data: string): ParsedPatch { prevName: undefined, type: 'change', hunks: [], + lines: 0, }; // Push that first line back into the group of lines so we can properly // parse it out @@ -72,7 +73,9 @@ function processPatch(data: string): ParsedPatch { } else if (type === '+++' && fileName !== '/dev/null') { currentFile.name = fileName; } - } else if (isGitDiff) { + } + // Git diffs have a bunch of additional metadata we can pull from + else if (isGitDiff) { if (line.startsWith('new file mode')) { currentFile.type = 'new'; } @@ -86,21 +89,29 @@ function processPatch(data: string): ParsedPatch { currentFile.type = 'rename-changed'; } } + // We have to handle these for pure renames because there won't be + // --- and +++ lines + if (line.startsWith('rename from ')) { + currentFile.prevName = line.replace('rename from ', ''); + } + if (line.startsWith('rename to ')) { + currentFile.name = line.replace('rename to ', ''); + } } } continue; } const hunkData: Hunk = { - additionCount: parseInt(match[4]), + additionCount: parseInt(match[4] ?? '1'), additionStart: parseInt(match[3]), - deletedCount: parseInt(match[2]), + deletedCount: parseInt(match[2] ?? '1'), deletedStart: parseInt(match[1]), hunkContent: lines.length > 0 ? lines : undefined, hunkContext: match[5], }; if ( isNaN(hunkData.additionCount) || - isNaN(hunkData.additionCount) || + isNaN(hunkData.deletedCount) || isNaN(hunkData.additionStart) || isNaN(hunkData.deletedStart) ) { @@ -114,6 +125,7 @@ function processPatch(data: string): ParsedPatch { lines.pop(); } currentFile.hunks.push(hunkData); + currentFile.lines += hunkData.hunkContent?.length ?? 0; } if (currentFile != null) { if (