From 051df499c08dac308e6af7de09741d612d53c69b Mon Sep 17 00:00:00 2001 From: Chase McCoy Date: Thu, 11 Jun 2026 21:35:05 -0500 Subject: [PATCH 1/5] feat(diffs): Add consumer span decorations for sub-line styling Add a public spanDecorations input that lets consumers style arbitrary character ranges within a line, using the same shiki decorations pipeline that powers the internal intra-line word/char diff highlight. A SpanDecoration addresses a range with a 1-based lineNumber plus spanStart/spanLength character offsets and resolves to a className on the wrapping span (plus a data-span-decoration attribute). The diff variant, DiffSpanDecoration, adds the same side field used by DiffLineAnnotation to target the deletions or additions side. Only structured input is accepted; classes are the styling contract, in line with the package's existing unsafeCSS posture. Decorations flow like lineAnnotations: render/hydrate props on File, FileDiff and UnresolvedFile, a spanDecorations field on CodeView items, props on the React wrappers, and options on the SSR preload helpers. Renderers translate file line numbers into bucket-local shiki DecorationItems, clamp ranges to the rendered line length, and drop out-of-range spans. Consumer spans are pushed after the internal diff spans so shiki nests them when ranges overlap and both stay applied. Because shiki bakes decorations into the highlighted AST, they are included in Render*Options and the equality helpers via the new areSpanDecorationsEqual, so cached or worker-produced ASTs that were highlighted without them are invalidated and re-highlighted locally. --- packages/diffs/src/components/CodeView.ts | 2 + packages/diffs/src/components/File.ts | 25 ++- packages/diffs/src/components/FileDiff.ts | 24 +++ .../diffs/src/components/UnresolvedFile.ts | 7 +- packages/diffs/src/index.ts | 1 + packages/diffs/src/react/File.tsx | 2 + packages/diffs/src/react/FileDiff.tsx | 2 + packages/diffs/src/react/MultiFileDiff.tsx | 2 + packages/diffs/src/react/PatchDiff.tsx | 2 + packages/diffs/src/react/UnresolvedFile.tsx | 2 + packages/diffs/src/react/types.ts | 4 + .../src/react/utils/useFileDiffInstance.ts | 5 + .../diffs/src/react/utils/useFileInstance.ts | 11 +- .../react/utils/useUnresolvedFileInstance.ts | 5 + .../diffs/src/renderers/DiffHunksRenderer.ts | 16 ++ packages/diffs/src/renderers/FileRenderer.ts | 17 ++ packages/diffs/src/ssr/preloadDiffs.ts | 22 +++ packages/diffs/src/ssr/preloadFile.ts | 7 +- packages/diffs/src/types.ts | 21 ++ .../src/utils/areDiffRenderOptionsEqual.ts | 4 +- .../src/utils/areFileRenderOptionsEqual.ts | 4 +- .../src/utils/areSpanDecorationsEqual.ts | 35 ++++ .../diffs/src/utils/parseDiffDecorations.ts | 22 +++ .../src/utils/renderDiffWithHighlighter.ts | 89 ++++++++- .../src/utils/renderFileWithHighlighter.ts | 33 ++++ packages/diffs/test/spanDecorations.test.ts | 185 ++++++++++++++++++ 26 files changed, 542 insertions(+), 7 deletions(-) create mode 100644 packages/diffs/src/utils/areSpanDecorationsEqual.ts create mode 100644 packages/diffs/test/spanDecorations.test.ts diff --git a/packages/diffs/src/components/CodeView.ts b/packages/diffs/src/components/CodeView.ts index d8e1b297a..9ca5c9c4e 100644 --- a/packages/diffs/src/components/CodeView.ts +++ b/packages/diffs/src/components/CodeView.ts @@ -3450,6 +3450,7 @@ function renderItem( fileDiff: item.item.fileDiff, forceRender, lineAnnotations: item.item.annotations, + spanDecorations: item.item.spanDecorations, }); } else { return item.instance.render({ @@ -3458,6 +3459,7 @@ function renderItem( file: item.item.file, forceRender, lineAnnotations: item.item.annotations, + spanDecorations: item.item.spanDecorations, }); } } diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 5bcbe78cd..3c9b613df 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -32,6 +32,7 @@ import type { RenderFileMetadata, RenderRange, SelectedLineRange, + SpanDecoration, ThemeTypes, } from '../types'; import { areFilesEqual } from '../utils/areFilesEqual'; @@ -69,6 +70,7 @@ export interface FileRenderProps { forceRender?: boolean; preventEmit?: boolean; lineAnnotations?: LineAnnotation[]; + spanDecorations?: SpanDecoration[]; renderRange?: RenderRange; } @@ -119,6 +121,7 @@ interface ColumnElements { interface HydrationSetup { file: FileContents; lineAnnotations: LineAnnotation[] | undefined; + spanDecorations?: SpanDecoration[]; } let instanceId = -1; @@ -161,6 +164,7 @@ export class File { protected annotationCache: Map> = new Map(); protected lineAnnotations: LineAnnotation[] = []; + protected spanDecorations: SpanDecoration[] | undefined; protected managersDirty = false; public file: FileContents | undefined; @@ -267,6 +271,12 @@ export class File { this.lineAnnotations = lineAnnotations; } + public setSpanDecorations( + spanDecorations: SpanDecoration[] | undefined + ): void { + this.spanDecorations = spanDecorations; + } + public setSelectedLines( range: SelectedLineRange | null, options?: SelectionWriteOptions @@ -301,6 +311,7 @@ export class File { this.fileContainer = undefined; this.mounted = false; this.lineAnnotations = []; + this.spanDecorations = undefined; this.annotationCache.clear(); this.pre = undefined; this.bufferBefore = undefined; @@ -347,6 +358,7 @@ export class File { preventEmit = false, file, lineAnnotations, + spanDecorations, } = props; this.hydrateElements(fileContainer, prerenderedHTML); if ( @@ -361,7 +373,7 @@ export class File { } // Otherwise orchestrate our setup. else { - this.hydrationSetup({ file, lineAnnotations }); + this.hydrationSetup({ file, lineAnnotations, spanDecorations }); } if (!preventEmit) { this.emitPostRender(); @@ -423,8 +435,11 @@ export class File { protected hydrationSetup({ file, lineAnnotations, + spanDecorations, }: HydrationSetup): void { this.lineAnnotations = lineAnnotations ?? this.lineAnnotations; + this.spanDecorations = spanDecorations ?? this.spanDecorations; + this.fileRenderer.setSpanDecorations(this.spanDecorations); this.file = file; this.fileRenderer.setOptions(getFileRendererOptions(this.options)); this.syncInteractionOptions(); @@ -455,6 +470,7 @@ export class File { containerWrapper, deferManagers = false, lineAnnotations, + spanDecorations, renderRange, }: FileRenderProps): boolean { const { collapsed = false, themeType = 'system' } = this.options; @@ -471,6 +487,8 @@ export class File { (lineAnnotations.length > 0 || this.lineAnnotations.length > 0) ? lineAnnotations !== this.lineAnnotations : false; + const spanDecorationsChanged = + spanDecorations !== undefined && spanDecorations !== this.spanDecorations; const didFileChange = !areFilesEqual(this.file, file); if ( !collapsed && @@ -478,6 +496,7 @@ export class File { areRenderRangesEqual(nextRenderRange, this.renderRange) && !didFileChange && !annotationsChanged && + !spanDecorationsChanged && !themeChanged ) { return this.applyCachedThemeState(themeType); @@ -494,6 +513,10 @@ export class File { this.setLineAnnotations(lineAnnotations); } this.fileRenderer.setLineAnnotations(this.lineAnnotations); + if (spanDecorations !== undefined) { + this.setSpanDecorations(spanDecorations); + } + this.fileRenderer.setSpanDecorations(this.spanDecorations); const { disableErrorHandling = false, disableFileHeader = false } = this.options; diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index cf03eefec..e5708d812 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -33,6 +33,7 @@ import type { BaseDiffOptions, CustomPreProperties, DiffLineAnnotation, + DiffSpanDecoration, ExpansionDirections, FileContents, FileDiffMetadata, @@ -84,6 +85,7 @@ export interface FileDiffRenderProps { fileContainer?: HTMLElement; containerWrapper?: HTMLElement; lineAnnotations?: DiffLineAnnotation[]; + spanDecorations?: DiffSpanDecoration[]; renderRange?: RenderRange; } @@ -169,6 +171,7 @@ interface ApplyPartialRenderProps { interface HydrationSetup { fileDiff: FileDiffMetadata | undefined; lineAnnotations: DiffLineAnnotation[] | undefined; + spanDecorations?: DiffSpanDecoration[]; oldFile?: FileContents; newFile?: FileContents; } @@ -214,6 +217,7 @@ export class FileDiff { protected annotationCache: Map> = new Map(); protected lineAnnotations: DiffLineAnnotation[] = []; + protected spanDecorations: DiffSpanDecoration[] | undefined; protected managersDirty = false; protected deletionFile: FileContents | undefined; @@ -433,6 +437,12 @@ export class FileDiff { this.lineAnnotations = lineAnnotations; } + public setSpanDecorations( + spanDecorations: DiffSpanDecoration[] | undefined + ): void { + this.spanDecorations = spanDecorations; + } + private canPartiallyRender( forceRender: boolean, annotationsChanged: boolean, @@ -493,6 +503,7 @@ export class FileDiff { this.fileContainer = undefined; this.mounted = false; this.lineAnnotations = []; + this.spanDecorations = undefined; this.clearAuxiliaryNodes(); this.annotationCache.clear(); this.pre = undefined; @@ -545,6 +556,7 @@ export class FileDiff { prerenderedHTML, preventEmit = false, lineAnnotations, + spanDecorations, oldFile, newFile, fileDiff, @@ -571,6 +583,7 @@ export class FileDiff { oldFile, newFile, lineAnnotations, + spanDecorations, }); } if (!preventEmit) { @@ -648,10 +661,13 @@ export class FileDiff { oldFile, newFile, lineAnnotations, + spanDecorations, }: HydrationSetup): void { // It's possible we are hydrating a pure-rename and therefore there will be // no pre element this.lineAnnotations = lineAnnotations ?? this.lineAnnotations; + this.spanDecorations = spanDecorations ?? this.spanDecorations; + this.hunksRenderer.setSpanDecorations(this.spanDecorations); this.additionFile = newFile; this.deletionFile = oldFile; this.fileDiff = @@ -726,6 +742,7 @@ export class FileDiff { forceRender = false, preventEmit = false, lineAnnotations, + spanDecorations, fileContainer, containerWrapper, renderRange, @@ -751,12 +768,15 @@ export class FileDiff { (lineAnnotations.length > 0 || this.lineAnnotations.length > 0) ? lineAnnotations !== this.lineAnnotations : false; + const spanDecorationsChanged = + spanDecorations !== undefined && spanDecorations !== this.spanDecorations; if ( !collapsed && areRenderRangesEqual(nextRenderRange, this.renderRange) && !forceRender && !annotationsChanged && + !spanDecorationsChanged && !themeChanged && // If using the fileDiff API, lets check to see if they are equal to // avoid doing work @@ -797,6 +817,10 @@ export class FileDiff { this.syncInteractionOptions(); this.hunksRenderer.setLineAnnotations(this.lineAnnotations); + if (spanDecorations !== undefined) { + this.setSpanDecorations(spanDecorations); + } + this.hunksRenderer.setSpanDecorations(this.spanDecorations); const { disableErrorHandling = false, disableFileHeader = false } = this.options; diff --git a/packages/diffs/src/components/UnresolvedFile.ts b/packages/diffs/src/components/UnresolvedFile.ts index 2213472fa..c9d97e406 100644 --- a/packages/diffs/src/components/UnresolvedFile.ts +++ b/packages/diffs/src/components/UnresolvedFile.ts @@ -345,6 +345,7 @@ export class UnresolvedFile< actions, markerRows, lineAnnotations, + spanDecorations, fileContainer, prerenderedHTML, preventEmit = false, @@ -374,7 +375,11 @@ export class UnresolvedFile< } // Otherwise orchestrate our setup else { - this.hydrationSetup({ fileDiff: source.fileDiff, lineAnnotations }); + this.hydrationSetup({ + fileDiff: source.fileDiff, + lineAnnotations, + spanDecorations, + }); if (this.pre != null) { this.renderMergeConflictActionSlots(); } diff --git a/packages/diffs/src/index.ts b/packages/diffs/src/index.ts index 49f6f0929..1abf86666 100644 --- a/packages/diffs/src/index.ts +++ b/packages/diffs/src/index.ts @@ -54,6 +54,7 @@ export * from './utils/areOptionsEqual'; export * from './utils/arePrePropertiesEqual'; export * from './utils/areRenderRangesEqual'; export * from './utils/areSelectionsEqual'; +export * from './utils/areSpanDecorationsEqual'; export * from './utils/areThemesEqual'; export * from './utils/areVirtualWindowSpecsEqual'; export * from './utils/areWorkerStatsEqual'; diff --git a/packages/diffs/src/react/File.tsx b/packages/diffs/src/react/File.tsx index ae3aeda58..b0f3d8eb1 100644 --- a/packages/diffs/src/react/File.tsx +++ b/packages/diffs/src/react/File.tsx @@ -12,6 +12,7 @@ export type { FileOptions }; export function File({ file, lineAnnotations, + spanDecorations, selectedLines, options, metrics, @@ -30,6 +31,7 @@ export function File({ options, metrics, lineAnnotations, + spanDecorations, selectedLines, prerenderedHTML, hasGutterRenderUtility: renderGutterUtility != null, diff --git a/packages/diffs/src/react/FileDiff.tsx b/packages/diffs/src/react/FileDiff.tsx index 052f5b0dd..47906c8bc 100644 --- a/packages/diffs/src/react/FileDiff.tsx +++ b/packages/diffs/src/react/FileDiff.tsx @@ -21,6 +21,7 @@ export function FileDiff({ options, metrics, lineAnnotations, + spanDecorations, selectedLines, className, style, @@ -37,6 +38,7 @@ export function FileDiff({ options, metrics, lineAnnotations, + spanDecorations, selectedLines, prerenderedHTML, hasGutterRenderUtility: renderGutterUtility != null, diff --git a/packages/diffs/src/react/MultiFileDiff.tsx b/packages/diffs/src/react/MultiFileDiff.tsx index 076288610..c25234a29 100644 --- a/packages/diffs/src/react/MultiFileDiff.tsx +++ b/packages/diffs/src/react/MultiFileDiff.tsx @@ -26,6 +26,7 @@ export function MultiFileDiff({ options, metrics, lineAnnotations, + spanDecorations, selectedLines, className, style, @@ -45,6 +46,7 @@ export function MultiFileDiff({ options, metrics, lineAnnotations, + spanDecorations, selectedLines, prerenderedHTML, hasGutterRenderUtility: renderGutterUtility != null, diff --git a/packages/diffs/src/react/PatchDiff.tsx b/packages/diffs/src/react/PatchDiff.tsx index a57b304b1..d51b66a2a 100644 --- a/packages/diffs/src/react/PatchDiff.tsx +++ b/packages/diffs/src/react/PatchDiff.tsx @@ -22,6 +22,7 @@ export function PatchDiff({ options, metrics, lineAnnotations, + spanDecorations, selectedLines, className, style, @@ -39,6 +40,7 @@ export function PatchDiff({ options, metrics, lineAnnotations, + spanDecorations, selectedLines, prerenderedHTML, hasGutterRenderUtility: renderGutterUtility != null, diff --git a/packages/diffs/src/react/UnresolvedFile.tsx b/packages/diffs/src/react/UnresolvedFile.tsx index 922338308..6261a434d 100644 --- a/packages/diffs/src/react/UnresolvedFile.tsx +++ b/packages/diffs/src/react/UnresolvedFile.tsx @@ -65,6 +65,7 @@ export function UnresolvedFile({ file, options, lineAnnotations, + spanDecorations, selectedLines, className, style, @@ -82,6 +83,7 @@ export function UnresolvedFile({ file, options, lineAnnotations, + spanDecorations, selectedLines, prerenderedHTML, hasConflictUtility: renderMergeConflictUtility != null, diff --git a/packages/diffs/src/react/types.ts b/packages/diffs/src/react/types.ts index fb45c43bd..178c457fa 100644 --- a/packages/diffs/src/react/types.ts +++ b/packages/diffs/src/react/types.ts @@ -5,10 +5,12 @@ import type { FileDiffOptions } from '../components/FileDiff'; import type { GetHoveredLineResult } from '../managers/InteractionManager'; import type { DiffLineAnnotation, + DiffSpanDecoration, FileContents, FileDiffMetadata, LineAnnotation, SelectedLineRange, + SpanDecoration, VirtualFileMetrics, } from '../types'; @@ -16,6 +18,7 @@ export interface DiffBasePropsReact { options?: FileDiffOptions; metrics?: VirtualFileMetrics; lineAnnotations?: DiffLineAnnotation[]; + spanDecorations?: DiffSpanDecoration[]; selectedLines?: SelectedLineRange | null; renderAnnotation?(annotations: DiffLineAnnotation): ReactNode; renderCustomHeader?(fileDiff: FileDiffMetadata): ReactNode; @@ -34,6 +37,7 @@ export interface FileProps { options?: FileOptions; metrics?: VirtualFileMetrics; lineAnnotations?: LineAnnotation[]; + spanDecorations?: SpanDecoration[]; selectedLines?: SelectedLineRange | null; renderAnnotation?(annotations: LineAnnotation): ReactNode; renderCustomHeader?(file: FileContents): ReactNode; diff --git a/packages/diffs/src/react/utils/useFileDiffInstance.ts b/packages/diffs/src/react/utils/useFileDiffInstance.ts index 65d988cf4..812ab22a1 100644 --- a/packages/diffs/src/react/utils/useFileDiffInstance.ts +++ b/packages/diffs/src/react/utils/useFileDiffInstance.ts @@ -11,6 +11,7 @@ import { VirtualizedFileDiff } from '../../components/VirtualizedFileDiff'; import type { GetHoveredLineResult } from '../../managers/InteractionManager'; import type { DiffLineAnnotation, + DiffSpanDecoration, FileDiffMetadata, SelectedLineRange, VirtualFileMetrics, @@ -28,6 +29,7 @@ interface UseFileDiffInstanceProps { fileDiff: FileDiffMetadata; options: FileDiffOptions | undefined; lineAnnotations: DiffLineAnnotation[] | undefined; + spanDecorations: DiffSpanDecoration[] | undefined; selectedLines: SelectedLineRange | null | undefined; prerenderedHTML: string | undefined; metrics?: VirtualFileMetrics; @@ -45,6 +47,7 @@ export function useFileDiffInstance({ fileDiff, options, lineAnnotations, + spanDecorations, selectedLines, prerenderedHTML, metrics, @@ -94,6 +97,7 @@ export function useFileDiffInstance({ fileDiff, fileContainer, lineAnnotations, + spanDecorations, prerenderedHTML, }); } else { @@ -122,6 +126,7 @@ export function useFileDiffInstance({ forceRender, fileDiff, lineAnnotations, + spanDecorations, }); if (selectedLines !== undefined) { instance.setSelectedLines(selectedLines); diff --git a/packages/diffs/src/react/utils/useFileInstance.ts b/packages/diffs/src/react/utils/useFileInstance.ts index 16e251386..99de5195a 100644 --- a/packages/diffs/src/react/utils/useFileInstance.ts +++ b/packages/diffs/src/react/utils/useFileInstance.ts @@ -13,6 +13,7 @@ import type { FileContents, LineAnnotation, SelectedLineRange, + SpanDecoration, VirtualFileMetrics, } from '../../types'; import { areOptionsEqual } from '../../utils/areOptionsEqual'; @@ -28,6 +29,7 @@ interface UseFileInstanceProps { file: FileContents; options: FileOptions | undefined; lineAnnotations: LineAnnotation[] | undefined; + spanDecorations: SpanDecoration[] | undefined; selectedLines: SelectedLineRange | null | undefined; prerenderedHTML: string | undefined; metrics?: VirtualFileMetrics; @@ -45,6 +47,7 @@ export function useFileInstance({ file, options, lineAnnotations, + spanDecorations, selectedLines, prerenderedHTML, metrics, @@ -94,6 +97,7 @@ export function useFileInstance({ file, fileContainer: node, lineAnnotations, + spanDecorations, prerenderedHTML, }); } else { @@ -118,7 +122,12 @@ export function useFileInstance({ newOptions ); instanceRef.current.setOptions(newOptions); - void instanceRef.current.render({ file, lineAnnotations, forceRender }); + void instanceRef.current.render({ + file, + lineAnnotations, + spanDecorations, + forceRender, + }); if (selectedLines !== undefined) { instanceRef.current.setSelectedLines(selectedLines); } diff --git a/packages/diffs/src/react/utils/useUnresolvedFileInstance.ts b/packages/diffs/src/react/utils/useUnresolvedFileInstance.ts index fb558faf2..5f5691aac 100644 --- a/packages/diffs/src/react/utils/useUnresolvedFileInstance.ts +++ b/packages/diffs/src/react/utils/useUnresolvedFileInstance.ts @@ -15,6 +15,7 @@ import { import type { GetHoveredLineResult } from '../../managers/InteractionManager'; import type { DiffLineAnnotation, + DiffSpanDecoration, FileContents, FileDiffMetadata, MergeConflictActionPayload, @@ -38,6 +39,7 @@ interface UseUnresolvedFileInstanceProps { file: FileContents; options?: UnresolvedFileReactOptions; lineAnnotations: DiffLineAnnotation[] | undefined; + spanDecorations: DiffSpanDecoration[] | undefined; selectedLines: SelectedLineRange | null | undefined; prerenderedHTML: string | undefined; hasConflictUtility: boolean; @@ -59,6 +61,7 @@ export function useUnresolvedFileInstance({ file, options, lineAnnotations, + spanDecorations, selectedLines, prerenderedHTML, hasConflictUtility, @@ -124,6 +127,7 @@ export function useUnresolvedFileInstance({ markerRows, fileContainer, lineAnnotations, + spanDecorations, prerenderedHTML, }); } else { @@ -155,6 +159,7 @@ export function useUnresolvedFileInstance({ actions, markerRows, lineAnnotations, + spanDecorations, forceRender, }); if (selectedLines !== undefined) { diff --git a/packages/diffs/src/renderers/DiffHunksRenderer.ts b/packages/diffs/src/renderers/DiffHunksRenderer.ts index 719c6708e..52ab2513b 100644 --- a/packages/diffs/src/renderers/DiffHunksRenderer.ts +++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts @@ -23,6 +23,7 @@ import type { CustomPreProperties, DiffLineAnnotation, DiffsHighlighter, + DiffSpanDecoration, ExpansionDirections, FileDiffMetadata, FileHeaderRenderMode, @@ -210,6 +211,7 @@ export class DiffHunksRenderer { private deletionAnnotations: AnnotationLineMap = {}; private additionAnnotations: AnnotationLineMap = {}; + private spanDecorations: DiffSpanDecoration[] | undefined; private computedLang: SupportedLanguages = 'text'; private renderCache: RenderedDiffASTCache | undefined; @@ -239,6 +241,7 @@ export class DiffHunksRenderer { this.clearRenderCache(); this.additionAnnotations = {}; this.deletionAnnotations = {}; + this.spanDecorations = undefined; this.workerManager?.cleanUpTasks(this); } @@ -308,6 +311,15 @@ export class DiffHunksRenderer { } } + public setSpanDecorations( + spanDecorations: DiffSpanDecoration[] | undefined + ): void { + this.spanDecorations = + spanDecorations != null && spanDecorations.length > 0 + ? spanDecorations + : undefined; + } + protected getUnifiedLineDecoration({ lineType, }: UnifiedLineDecorationProps): LineDecoration { @@ -443,6 +455,10 @@ export class DiffHunksRenderer { maxLineDiffLength, }; })(); + // Span decorations are per-diff content, layered onto whichever options + // source (worker pool or local) produced the rest so the cache comparator + // sees them and re-highlights when they change. + options.spanDecorations = this.spanDecorations; this.getOptionsWithDefaults(); const { renderCache } = this; if (renderCache?.result == null) { diff --git a/packages/diffs/src/renderers/FileRenderer.ts b/packages/diffs/src/renderers/FileRenderer.ts index 2a569052e..6fe1aae9a 100644 --- a/packages/diffs/src/renderers/FileRenderer.ts +++ b/packages/diffs/src/renderers/FileRenderer.ts @@ -23,6 +23,7 @@ import type { RenderFileOptions, RenderFileResult, RenderRange, + SpanDecoration, SupportedLanguages, ThemedFileResult, } from '../types'; @@ -92,6 +93,7 @@ export class FileRenderer { private renderCache: RenderedFileASTCache | undefined; private computedLang: SupportedLanguages = 'text'; private lineAnnotations: AnnotationLineMap = {}; + private spanDecorations: SpanDecoration[] | undefined; private lineCache: LineCache | undefined; constructor( @@ -125,6 +127,15 @@ export class FileRenderer { } } + public setSpanDecorations( + spanDecorations: SpanDecoration[] | undefined + ): void { + this.spanDecorations = + spanDecorations != null && spanDecorations.length > 0 + ? spanDecorations + : undefined; + } + public cleanUp(): void { this.recycle(); this.workerManager = undefined; @@ -136,6 +147,7 @@ export class FileRenderer { this.highlighter = undefined; this.workerManager?.cleanUpTasks(this); this.lineCache = undefined; + this.spanDecorations = undefined; } public clearRenderCache(): void { @@ -187,6 +199,11 @@ export class FileRenderer { tokenizeMaxLineLength, }; })(); + // Span decorations are per-file content (not pool-level config), so they + // are layered onto the options bag here regardless of whether a worker + // supplied the rest. The areFileRenderOptionsEqual check below then + // invalidates worker-produced ASTs that were highlighted without them. + options.spanDecorations = this.spanDecorations; const { renderCache } = this; if (renderCache?.result == null) { return { options, forceHighlight: true }; diff --git a/packages/diffs/src/ssr/preloadDiffs.ts b/packages/diffs/src/ssr/preloadDiffs.ts index 4ee40b75b..eb4569151 100644 --- a/packages/diffs/src/ssr/preloadDiffs.ts +++ b/packages/diffs/src/ssr/preloadDiffs.ts @@ -11,6 +11,7 @@ import { import { UnresolvedFileHunksRenderer } from '../renderers/UnresolvedFileHunksRenderer'; import type { DiffLineAnnotation, + DiffSpanDecoration, FileContents, FileDiffMetadata, } from '../types'; @@ -30,6 +31,7 @@ export interface PreloadDiffOptions { newFile?: FileContents; options?: FileDiffOptions; annotations?: DiffLineAnnotation[]; + spanDecorations?: DiffSpanDecoration[]; } export async function preloadDiffHTML({ @@ -38,6 +40,7 @@ export async function preloadDiffHTML({ newFile, options, annotations, + spanDecorations, }: PreloadDiffOptions): Promise { if (fileDiff == null && oldFile != null && newFile != null) { fileDiff = parseDiffFromFile(oldFile, newFile, options?.parseDiffOptions); @@ -53,6 +56,7 @@ export async function preloadDiffHTML({ if (annotations != null && annotations.length > 0) { renderer.setLineAnnotations(annotations); } + renderer.setSpanDecorations(spanDecorations); return renderHTML( processHunkResult( await renderer.asyncRender(fileDiff), @@ -67,6 +71,7 @@ export async function preloadUnresolvedFileHTML({ file, options, annotations, + spanDecorations, }: PreloadUnresolvedFileOptions): Promise { const { fileDiff, actions, markerRows } = parseMergeConflictDiffFromFile( file, @@ -78,6 +83,7 @@ export async function preloadUnresolvedFileHTML({ if (annotations != null && annotations.length > 0) { renderer.setLineAnnotations(annotations); } + renderer.setSpanDecorations(spanDecorations); renderer.setConflictState(actions, markerRows, fileDiff); return renderHTML( processHunkResult( @@ -94,6 +100,7 @@ export interface PreloadMultiFileDiffOptions { newFile: FileContents; options?: FileDiffOptions; annotations?: DiffLineAnnotation[]; + spanDecorations?: DiffSpanDecoration[]; } export interface PreloadMultiFileDiffResult< @@ -107,6 +114,7 @@ export async function preloadMultiFileDiff({ newFile, options, annotations, + spanDecorations, }: PreloadMultiFileDiffOptions): Promise< PreloadMultiFileDiffResult > { @@ -115,11 +123,13 @@ export async function preloadMultiFileDiff({ oldFile, options, annotations, + spanDecorations, prerenderedHTML: await preloadDiffHTML({ oldFile, newFile, options, annotations, + spanDecorations, }), }; } @@ -128,6 +138,7 @@ export interface PreloadFileDiffOptions { fileDiff: FileDiffMetadata; options?: FileDiffOptions; annotations?: DiffLineAnnotation[]; + spanDecorations?: DiffSpanDecoration[]; } export interface PreloadFileDiffResult< @@ -140,6 +151,7 @@ export async function preloadFileDiff({ fileDiff, options, annotations, + spanDecorations, }: PreloadFileDiffOptions): Promise< PreloadFileDiffResult > { @@ -147,10 +159,12 @@ export async function preloadFileDiff({ fileDiff, options, annotations, + spanDecorations, prerenderedHTML: await preloadDiffHTML({ fileDiff, options, annotations, + spanDecorations, }), }; } @@ -162,6 +176,7 @@ export interface PreloadUnresolvedFileOptions { 'onMergeConflictAction' | 'onMergeConflictResolve' | 'onPostRender' >; annotations?: DiffLineAnnotation[]; + spanDecorations?: DiffSpanDecoration[]; } export interface PreloadUnresolvedFileResult< @@ -174,6 +189,7 @@ export async function preloadUnresolvedFile({ file, options, annotations, + spanDecorations, }: PreloadUnresolvedFileOptions): Promise< PreloadUnresolvedFileResult > { @@ -181,10 +197,12 @@ export async function preloadUnresolvedFile({ file, options, annotations, + spanDecorations, prerenderedHTML: await preloadUnresolvedFileHTML({ file, options, annotations, + spanDecorations, }), }; } @@ -193,6 +211,7 @@ export interface PreloadPatchDiffOptions { patch: string; options?: FileDiffOptions; annotations?: DiffLineAnnotation[]; + spanDecorations?: DiffSpanDecoration[]; } export interface PreloadPatchDiffResult< @@ -205,6 +224,7 @@ export async function preloadPatchDiff({ patch, options, annotations, + spanDecorations, }: PreloadPatchDiffOptions): Promise< PreloadPatchDiffResult > { @@ -213,10 +233,12 @@ export async function preloadPatchDiff({ patch, options, annotations, + spanDecorations, prerenderedHTML: await preloadDiffHTML({ fileDiff, options, annotations, + spanDecorations, }), }; } diff --git a/packages/diffs/src/ssr/preloadFile.ts b/packages/diffs/src/ssr/preloadFile.ts index 199b205bf..16d06963e 100644 --- a/packages/diffs/src/ssr/preloadFile.ts +++ b/packages/diffs/src/ssr/preloadFile.ts @@ -1,6 +1,6 @@ import type { FileOptions } from '../components/File'; import { FileRenderer } from '../renderers/FileRenderer'; -import type { FileContents, LineAnnotation } from '../types'; +import type { FileContents, LineAnnotation, SpanDecoration } from '../types'; import { createStyleElement, createThemeStyleElement, @@ -12,12 +12,14 @@ export type PreloadFileOptions = { file: FileContents; options?: FileOptions; annotations?: LineAnnotation[]; + spanDecorations?: SpanDecoration[]; }; export interface PreloadedFileResult { file: FileContents; options?: FileOptions; annotations?: LineAnnotation[]; + spanDecorations?: SpanDecoration[]; prerenderedHTML: string; } @@ -25,6 +27,7 @@ export async function preloadFile({ file, options, annotations, + spanDecorations, }: PreloadFileOptions): Promise> { const fileRenderer = new FileRenderer({ ...options, @@ -36,6 +39,7 @@ export async function preloadFile({ if (annotations !== undefined && annotations.length > 0) { fileRenderer.setLineAnnotations(annotations); } + fileRenderer.setSpanDecorations(spanDecorations); const fileResult = await fileRenderer.asyncRender(file); const children = [createStyleElement(fileResult.css, true)]; @@ -64,6 +68,7 @@ export async function preloadFile({ file, options, annotations, + spanDecorations, prerenderedHTML: renderHTML(children), }; } diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index c7d8f3f71..898093bae 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -476,11 +476,29 @@ export type DiffLineAnnotation = { lineNumber: number; } & OptionalMetadata; +/** + * Consumer-facing sub-line decoration. Wraps the [spanStart, spanStart + + * spanLength) character range on the given 1-indexed line in a span carrying + * `className`. Ranges are addressed against the rendered line text (no diff + * indicator prefix, no trailing newline). Out-of-range spans are dropped. + */ +export interface SpanDecoration { + lineNumber: number; + spanStart: number; + spanLength: number; + className: string; +} + +export interface DiffSpanDecoration extends SpanDecoration { + side: AnnotationSide; +} + export type CodeViewFileItem = { id: string; type: 'file'; file: FileContents; annotations?: LineAnnotation[]; + spanDecorations?: SpanDecoration[]; version?: number; collapsed?: boolean; }; @@ -490,6 +508,7 @@ export type CodeViewDiffItem = { type: 'diff'; fileDiff: FileDiffMetadata; annotations?: DiffLineAnnotation[]; + spanDecorations?: DiffSpanDecoration[]; version?: number; collapsed?: boolean; }; @@ -712,6 +731,7 @@ export interface RenderFileOptions { theme: DiffsThemeNames | Record<'dark' | 'light', DiffsThemeNames>; useTokenTransformer: boolean; tokenizeMaxLineLength: number; + spanDecorations?: SpanDecoration[]; } export interface RenderDiffOptions { @@ -720,6 +740,7 @@ export interface RenderDiffOptions { tokenizeMaxLineLength: number; lineDiffType: LineDiffTypes; maxLineDiffLength: number; + spanDecorations?: DiffSpanDecoration[]; } export interface RenderFileResult { diff --git a/packages/diffs/src/utils/areDiffRenderOptionsEqual.ts b/packages/diffs/src/utils/areDiffRenderOptionsEqual.ts index 3da266ee7..e2722a556 100644 --- a/packages/diffs/src/utils/areDiffRenderOptionsEqual.ts +++ b/packages/diffs/src/utils/areDiffRenderOptionsEqual.ts @@ -1,4 +1,5 @@ import type { RenderDiffOptions } from '../types'; +import { areSpanDecorationsEqual } from './areSpanDecorationsEqual'; import { areThemesEqual } from './areThemesEqual'; export function areDiffRenderOptionsEqual( @@ -10,6 +11,7 @@ export function areDiffRenderOptionsEqual( optionsA.useTokenTransformer === optionsB.useTokenTransformer && optionsA.tokenizeMaxLineLength === optionsB.tokenizeMaxLineLength && optionsA.lineDiffType === optionsB.lineDiffType && - optionsA.maxLineDiffLength === optionsB.maxLineDiffLength + optionsA.maxLineDiffLength === optionsB.maxLineDiffLength && + areSpanDecorationsEqual(optionsA.spanDecorations, optionsB.spanDecorations) ); } diff --git a/packages/diffs/src/utils/areFileRenderOptionsEqual.ts b/packages/diffs/src/utils/areFileRenderOptionsEqual.ts index 2cd4813a9..57e5a065b 100644 --- a/packages/diffs/src/utils/areFileRenderOptionsEqual.ts +++ b/packages/diffs/src/utils/areFileRenderOptionsEqual.ts @@ -1,4 +1,5 @@ import type { RenderFileOptions } from '../types'; +import { areSpanDecorationsEqual } from './areSpanDecorationsEqual'; import { areThemesEqual } from './areThemesEqual'; export function areFileRenderOptionsEqual( @@ -8,6 +9,7 @@ export function areFileRenderOptionsEqual( return ( areThemesEqual(optionsA.theme, optionsB.theme) && optionsA.useTokenTransformer === optionsB.useTokenTransformer && - optionsA.tokenizeMaxLineLength === optionsB.tokenizeMaxLineLength + optionsA.tokenizeMaxLineLength === optionsB.tokenizeMaxLineLength && + areSpanDecorationsEqual(optionsA.spanDecorations, optionsB.spanDecorations) ); } diff --git a/packages/diffs/src/utils/areSpanDecorationsEqual.ts b/packages/diffs/src/utils/areSpanDecorationsEqual.ts new file mode 100644 index 000000000..e221681c9 --- /dev/null +++ b/packages/diffs/src/utils/areSpanDecorationsEqual.ts @@ -0,0 +1,35 @@ +import type { DiffSpanDecoration, SpanDecoration } from '../types'; + +// Structural comparison so renderers can keep a cached highlighted AST when a +// consumer passes a fresh-but-identical spanDecorations array on re-render. +// Handles both file and diff variants (side is undefined for SpanDecoration). +export function areSpanDecorationsEqual( + a: SpanDecoration[] | DiffSpanDecoration[] | undefined, + b: SpanDecoration[] | DiffSpanDecoration[] | undefined +): boolean { + if (a === b) { + return true; + } + const lenA = a?.length ?? 0; + const lenB = b?.length ?? 0; + if (lenA !== lenB) { + return false; + } + if (lenA === 0) { + return true; + } + for (let i = 0; i < lenA; i++) { + const da = (a as DiffSpanDecoration[])[i]; + const db = (b as DiffSpanDecoration[])[i]; + if ( + da.lineNumber !== db.lineNumber || + da.spanStart !== db.spanStart || + da.spanLength !== db.spanLength || + da.className !== db.className || + da.side !== db.side + ) { + return false; + } + } + return true; +} diff --git a/packages/diffs/src/utils/parseDiffDecorations.ts b/packages/diffs/src/utils/parseDiffDecorations.ts index 8d11c0b46..2b57741a9 100644 --- a/packages/diffs/src/utils/parseDiffDecorations.ts +++ b/packages/diffs/src/utils/parseDiffDecorations.ts @@ -21,6 +21,28 @@ export function createDiffSpanDecoration({ }; } +interface CreateSpanDecorationProps extends CreateDiffSpanDecorationProps { + className: string; +} + +// Consumer-facing variant of createDiffSpanDecoration: same shiki shape, but +// applies a caller-supplied class instead of the internal data-diff-span +// attribute so consumer styles compose with (rather than replace) the engine's +// own intra-line diff highlighting. +export function createSpanDecoration({ + line, + spanStart, + spanLength, + className, +}: CreateSpanDecorationProps): DecorationItem { + return { + start: { line, character: spanStart }, + end: { line, character: spanStart + spanLength }, + properties: { class: className, 'data-span-decoration': '' }, + alwaysWrap: true, + }; +} + interface PushOrJoinSpanProps { item: ChangeObject; arr: [0 | 1, string][]; diff --git a/packages/diffs/src/utils/renderDiffWithHighlighter.ts b/packages/diffs/src/utils/renderDiffWithHighlighter.ts index 5aceeef24..2b4cc9cd8 100644 --- a/packages/diffs/src/utils/renderDiffWithHighlighter.ts +++ b/packages/diffs/src/utils/renderDiffWithHighlighter.ts @@ -8,6 +8,7 @@ import type { CodeToHastOptions, DecorationItem, DiffsHighlighter, + DiffSpanDecoration, DiffsThemeNames, FileContents, FileDiffMetadata, @@ -28,6 +29,7 @@ import { getLineNodes } from './getLineNodes'; import { iterateOverDiff } from './iterateOverDiff'; import { createDiffSpanDecoration, + createSpanDecoration, pushOrJoinSpan, } from './parseDiffDecorations'; @@ -84,7 +86,15 @@ export function renderDiffWithHighlighter( additionLines: [], }; - const { maxLineDiffLength } = options; + const { maxLineDiffLength, spanDecorations } = options; + const deletionSpanDecorations = groupSpanDecorations( + spanDecorations, + 'deletions' + ); + const additionSpanDecorations = groupSpanDecorations( + spanDecorations, + 'additions' + ); const shouldGroupAll = !forcePlainText && !diff.isPartial; const expandedHunksForIteration = forcePlainText ? expandedHunks : undefined; const buckets = new Map(); @@ -147,6 +157,13 @@ export function renderDiffWithHighlighter( } if (deletionLine != null) { + pushSpanDecorations( + deletionSpanDecorations, + deletionLine.lineNumber, + bucket.deletionContent.length, + diff.deletionLines[deletionLine.lineIndex], + bucket.deletionDecorations + ); appendContent( diff.deletionLines[deletionLine.lineIndex], deletionLine.lineIndex, @@ -165,6 +182,13 @@ export function renderDiffWithHighlighter( } if (additionLine != null) { + pushSpanDecorations( + additionSpanDecorations, + additionLine.lineNumber, + bucket.additionContent.length, + diff.additionLines[additionLine.lineIndex], + bucket.additionDecorations + ); appendContent( diff.additionLines[additionLine.lineIndex], additionLine.lineIndex, @@ -245,6 +269,69 @@ export function renderDiffWithHighlighter( return { code, themeStyles, baseThemeType }; } +type SpanDecorationLineMap = Record; + +// Index consumer span decorations by 1-based file line number for one side so +// the iterateOverDiff callback can resolve them in O(1) per rendered line. +function groupSpanDecorations( + spanDecorations: DiffSpanDecoration[] | undefined, + side: DiffSpanDecoration['side'] +): SpanDecorationLineMap | undefined { + if (spanDecorations == null || spanDecorations.length === 0) { + return undefined; + } + const map: SpanDecorationLineMap = {}; + for (const decoration of spanDecorations) { + if (decoration.side !== side) { + continue; + } + const arr = map[decoration.lineNumber] ?? []; + map[decoration.lineNumber] = arr; + arr.push(decoration); + } + return map; +} + +// Translate consumer span decorations addressed by file line number into shiki +// DecorationItems addressed by bucket-local 0-based line index, clamped to the +// rendered line length. Pushed after the engine's own intra-line diff spans so +// shiki nests consumer wrappers inside the data-diff-span wrapper when ranges +// overlap, leaving both classes applied. +function pushSpanDecorations( + map: SpanDecorationLineMap | undefined, + lineNumber: number | undefined, + bucketLineIndex: number, + lineContent: string, + target: DecorationItem[] +): void { + if (map == null || lineNumber == null) { + return; + } + const decorations = map[lineNumber]; + if (decorations == null) { + return; + } + const lineLength = cleanLastNewline(lineContent).length; + for (const decoration of decorations) { + const spanStart = Math.min(decoration.spanStart, lineLength); + const spanEnd = Math.min( + decoration.spanStart + decoration.spanLength, + lineLength + ); + if (spanEnd <= spanStart) { + continue; + } + target.push( + createSpanDecoration({ + line: bucketLineIndex, + spanStart, + spanLength: spanEnd - spanStart, + className: decoration.className, + }) + ); + } +} + interface ProcessLineDiffProps { deletionLine: string | undefined; additionLine: string | undefined; diff --git a/packages/diffs/src/utils/renderFileWithHighlighter.ts b/packages/diffs/src/utils/renderFileWithHighlighter.ts index 5b4f83656..b24648cc0 100644 --- a/packages/diffs/src/utils/renderFileWithHighlighter.ts +++ b/packages/diffs/src/utils/renderFileWithHighlighter.ts @@ -15,6 +15,7 @@ import { getFiletypeFromFileName } from './getFiletypeFromFileName'; import { getHighlighterThemeStyles } from './getHighlighterThemeStyles'; import { getLineNodes } from './getLineNodes'; import { iterateOverFile } from './iterateOverFile'; +import { createSpanDecoration } from './parseDiffDecorations'; import { splitFileContents } from './splitFileContents'; const DEFAULT_PLAIN_TEXT_OPTIONS: ForceFilePlainTextOptions = { @@ -28,6 +29,7 @@ export function renderFileWithHighlighter( theme = DEFAULT_THEMES, tokenizeMaxLineLength, useTokenTransformer, + spanDecorations, }: RenderFileOptions, { forcePlainText, @@ -84,6 +86,37 @@ export function renderFileWithHighlighter( tokenizeMaxLineLength, }; })(); + if (spanDecorations != null && spanDecorations.length > 0) { + const fileLines = lines ?? splitFileContents(file.contents); + const renderedLineCount = isWindowedHighlight + ? Math.min(totalLines, fileLines.length - startingLine) + : fileLines.length; + hastConfig.decorations = []; + for (const decoration of spanDecorations) { + const line = decoration.lineNumber - 1 - startingLine; + const lineContent = fileLines[decoration.lineNumber - 1]; + if (line < 0 || line >= renderedLineCount || lineContent == null) { + continue; + } + const lineLength = cleanLastNewline(lineContent).length; + const spanStart = Math.min(decoration.spanStart, lineLength); + const spanEnd = Math.min( + decoration.spanStart + decoration.spanLength, + lineLength + ); + if (spanEnd <= spanStart) { + continue; + } + hastConfig.decorations.push( + createSpanDecoration({ + line, + spanStart, + spanLength: spanEnd - spanStart, + className: decoration.className, + }) + ); + } + } const highlightedLines = getLineNodes( highlighter.codeToHast( isWindowedHighlight diff --git a/packages/diffs/test/spanDecorations.test.ts b/packages/diffs/test/spanDecorations.test.ts new file mode 100644 index 000000000..690631642 --- /dev/null +++ b/packages/diffs/test/spanDecorations.test.ts @@ -0,0 +1,185 @@ +import { afterAll, describe, expect, test } from 'bun:test'; +import type { Element as HASTElement } from 'hast'; + +import { + areSpanDecorationsEqual, + DiffHunksRenderer, + disposeHighlighter, + FileRenderer, + parseDiffFromFile, +} from '../src'; +import type { DiffSpanDecoration, SpanDecoration } from '../src/types'; +import { assertDefined, collectAllElements, isHastElement } from './testUtils'; + +afterAll(async () => { + await disposeHighlighter(); +}); + +function getElementClasses(el: HASTElement): string[] { + const value = el.properties?.['className'] ?? el.properties?.['class']; + if (Array.isArray(value)) { + return value.map(String); + } + return typeof value === 'string' ? value.split(/\s+/) : []; +} + +function isSpanDecorationElement(el: HASTElement): boolean { + return el.properties?.['data-span-decoration'] != null; +} + +function flattenText(el: HASTElement): string { + let text = ''; + for (const child of el.children) { + if (child.type === 'text') { + text += child.value; + } else if (isHastElement(child)) { + text += flattenText(child); + } + } + return text; +} + +describe('Span Decorations', () => { + describe('FileRenderer', () => { + const file = { + name: 'example.ts', + contents: 'const alpha = 1;\nconst beta = 2;\nconst gamma = 3;\n', + }; + + test('wraps the addressed character range with the consumer class', async () => { + const decorations: SpanDecoration[] = [ + { lineNumber: 2, spanStart: 6, spanLength: 4, className: 'hl-risk' }, + ]; + const renderer = new FileRenderer(); + renderer.setSpanDecorations(decorations); + const result = await renderer.asyncRender(file); + const ast = renderer.renderCodeAST(result); + const decorated = collectAllElements(ast).filter(isSpanDecorationElement); + expect(decorated.length).toBe(1); + expect(getElementClasses(decorated[0])).toContain('hl-risk'); + expect(flattenText(decorated[0])).toBe('beta'); + }); + + test('drops zero/negative-length spans and out-of-range lines', async () => { + const decorations: SpanDecoration[] = [ + { lineNumber: 1, spanStart: 0, spanLength: 0, className: 'noop' }, + { lineNumber: 99, spanStart: 0, spanLength: 1, className: 'noop' }, + ]; + const renderer = new FileRenderer(); + renderer.setSpanDecorations(decorations); + const result = await renderer.asyncRender(file); + const ast = renderer.renderCodeAST(result); + const decorated = collectAllElements(ast).filter(isSpanDecorationElement); + expect(decorated.length).toBe(0); + }); + }); + + describe('DiffHunksRenderer', () => { + const oldFile = { + name: 'example.ts', + contents: 'const a = one;\nconst b = two;\nconst c = three;\n', + }; + const newFile = { + name: 'example.ts', + contents: 'const a = one;\nconst b = TWO;\nconst c = three;\n', + }; + const diff = parseDiffFromFile(oldFile, newFile); + + test('wraps the addressed range on the addressed side and coexists with intra-line diff spans', async () => { + const decorations: DiffSpanDecoration[] = [ + { + side: 'additions', + lineNumber: 2, + spanStart: 6, + spanLength: 1, + className: 'hl-add', + }, + { + side: 'deletions', + lineNumber: 2, + spanStart: 6, + spanLength: 1, + className: 'hl-del', + }, + ]; + const renderer = new DiffHunksRenderer({ + diffStyle: 'unified', + expandUnchanged: true, + }); + renderer.setSpanDecorations(decorations); + const { unifiedContentAST } = await renderer.asyncRender(diff); + assertDefined(unifiedContentAST, 'unifiedContentAST should be defined'); + const all = collectAllElements(unifiedContentAST); + + const decorated = all.filter(isSpanDecorationElement); + expect(decorated.length).toBe(2); + const byClass = new Map( + decorated.map((el) => [getElementClasses(el).join(' '), el]) + ); + const add = byClass.get('hl-add'); + const del = byClass.get('hl-del'); + assertDefined(add, 'addition decoration should render'); + assertDefined(del, 'deletion decoration should render'); + expect(flattenText(add)).toBe('b'); + expect(flattenText(del)).toBe('b'); + + // The engine's own intra-line diff highlight (data-diff-span) must still + // be present on the same change line — consumer spans compose, not + // replace. + const diffSpans = all.filter( + (el) => el.properties?.['data-diff-span'] != null + ); + expect(diffSpans.length).toBeGreaterThan(0); + const diffSpanTexts = diffSpans.map(flattenText); + expect(diffSpanTexts).toContain('TWO'); + }); + + test('decorations on lines outside the rendered diff are dropped', async () => { + const decorations: DiffSpanDecoration[] = [ + { + side: 'additions', + lineNumber: 999, + spanStart: 0, + spanLength: 1, + className: 'noop', + }, + ]; + const renderer = new DiffHunksRenderer({ diffStyle: 'unified' }); + renderer.setSpanDecorations(decorations); + const { unifiedContentAST } = await renderer.asyncRender(diff); + assertDefined(unifiedContentAST, 'unifiedContentAST should be defined'); + const decorated = collectAllElements(unifiedContentAST).filter( + isSpanDecorationElement + ); + expect(decorated.length).toBe(0); + }); + + test('decorations participate in render-options equality', () => { + const a: DiffSpanDecoration[] = [ + { + side: 'additions', + lineNumber: 1, + spanStart: 0, + spanLength: 1, + className: 'x', + }, + ]; + const b: DiffSpanDecoration[] = [ + { + side: 'additions', + lineNumber: 1, + spanStart: 0, + spanLength: 1, + className: 'x', + }, + ]; + expect(areSpanDecorationsEqual(a, b)).toBe(true); + expect(areSpanDecorationsEqual(a, undefined)).toBe(false); + expect(areSpanDecorationsEqual(undefined, undefined)).toBe(true); + expect(areSpanDecorationsEqual([], undefined)).toBe(true); + expect(areSpanDecorationsEqual(a, [{ ...a[0], className: 'y' }])).toBe( + false + ); + }); + }); +}); From fa5e5b3db59b0cf85e4eb70c919b83d159956a02 Mon Sep 17 00:00:00 2001 From: Chase McCoy Date: Thu, 11 Jun 2026 22:07:49 -0500 Subject: [PATCH 2/5] feat(diffs): Add decoration interaction callbacks Add onDecorationClick, onDecorationEnter and onDecorationLeave options so consumers can attach behavior to span decorations the same way they do with onTokenClick/onTokenEnter/onTokenLeave. Callbacks receive the consumer's original decoration object, the rendered span element, the line number and (for diffs) the side, plus the raw pointer event. To resolve events back to decoration objects, the rendered span's data-span-decoration attribute now carries the decoration's index in the consumer's spanDecorations array, and the InteractionManager looks it up through a resolver wired by File/FileDiff, which own that array. Events flow through the existing InteractionManager pointer pipeline: the target resolver picks up the decoration span from the composed event path alongside line and token info, hover transitions are tracked like token hover, and the new callbacks participate in the listener-attachment checks. The callbacks are also registered as CodeView shared callback keys so CodeView consumers get the item context argument appended, consistent with the token callbacks. --- packages/diffs/src/components/CodeView.ts | 3 + packages/diffs/src/components/File.ts | 14 +- packages/diffs/src/components/FileDiff.ts | 10 +- .../diffs/src/components/UnresolvedFile.ts | 3 +- .../diffs/src/managers/InteractionManager.ts | 154 +++++++++++++++++- packages/diffs/src/types.ts | 15 ++ .../diffs/src/utils/parseDiffDecorations.ts | 11 +- .../src/utils/renderDiffWithHighlighter.ts | 20 ++- .../src/utils/renderFileWithHighlighter.ts | 4 +- .../test/spanDecorations.interactions.test.ts | 143 ++++++++++++++++ 10 files changed, 363 insertions(+), 14 deletions(-) create mode 100644 packages/diffs/test/spanDecorations.interactions.test.ts diff --git a/packages/diffs/src/components/CodeView.ts b/packages/diffs/src/components/CodeView.ts index 9ca5c9c4e..f28077fac 100644 --- a/packages/diffs/src/components/CodeView.ts +++ b/packages/diffs/src/components/CodeView.ts @@ -341,6 +341,9 @@ const CODE_VIEW_SHARED_CALLBACK_KEYS = [ 'onTokenClick', 'onTokenEnter', 'onTokenLeave', + 'onDecorationClick', + 'onDecorationEnter', + 'onDecorationLeave', ] as const; const CODE_VIEW_SELECTION_CALLBACK_KEYS = [ diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 3c9b613df..7226ad65d 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -215,9 +215,21 @@ export class File { } protected syncInteractionOptions(): void { - this.interactionManager.setOptions(pluckInteractionOptions(this.options)); + this.interactionManager.setOptions( + pluckInteractionOptions( + this.options, + undefined, + undefined, + undefined, + this.getSpanDecoration + ) + ); } + protected getSpanDecoration = (index: number): SpanDecoration | undefined => { + return this.spanDecorations?.[index]; + }; + private mergeOptions(options: Partial>): void { this.options = { ...this.options, ...options }; } diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index e5708d812..27e591a1a 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -379,11 +379,19 @@ export class FileDiff { this.options.hunkSeparators === 'line-info-basic' ? this.handleExpandHunk : undefined, - this.getLineIndex + this.getLineIndex, + undefined, + this.getSpanDecoration ) ); } + protected getSpanDecoration = ( + index: number + ): DiffSpanDecoration | undefined => { + return this.spanDecorations?.[index]; + }; + private mergeOptions(options: Partial>): void { this.options = { ...this.options, ...options }; } diff --git a/packages/diffs/src/components/UnresolvedFile.ts b/packages/diffs/src/components/UnresolvedFile.ts index c9d97e406..d3e027767 100644 --- a/packages/diffs/src/components/UnresolvedFile.ts +++ b/packages/diffs/src/components/UnresolvedFile.ts @@ -169,7 +169,8 @@ export class UnresolvedFile< ? this.expandHunk : undefined, this.getLineIndex, - this.handleMergeConflictActionClick + this.handleMergeConflictActionClick, + this.getSpanDecoration ) ); } diff --git a/packages/diffs/src/managers/InteractionManager.ts b/packages/diffs/src/managers/InteractionManager.ts index 78b6859af..50089cb6c 100644 --- a/packages/diffs/src/managers/InteractionManager.ts +++ b/packages/diffs/src/managers/InteractionManager.ts @@ -2,7 +2,10 @@ import { toHtml } from 'hast-util-to-html'; import type { AnnotationSide, + DecorationEventBaseProps, + DiffDecorationEventBaseProps, DiffLineEventBaseProps, + DiffSpanDecoration, DiffTokenEventBaseProps, ExpansionDirections, LineEventBaseProps, @@ -11,6 +14,7 @@ import type { SelectedLineRange, SelectionPoint, SelectionSide, + SpanDecoration, TokenEventBase, } from '../types'; import { areSelectionPointsEqual } from '../utils/areSelectionPointsEqual'; @@ -24,6 +28,11 @@ interface TokenCache { tokenText: string; } +interface DecorationCache { + decorationElement: HTMLElement; + decorationIndex: number; +} + interface ExpandCache { hunkIndex: number | undefined; direction: ExpansionDirections; @@ -72,6 +81,15 @@ type EventBaseProps = TMode extends 'file' export type OnTokenEventProps = TMode extends 'file' ? TokenEventBase : DiffTokenEventBaseProps; +export type OnDecorationEventProps = + TMode extends 'file' + ? DecorationEventBaseProps + : DiffDecorationEventBaseProps; + +export type GetSpanDecorationUtility = ( + index: number +) => SpanDecoration | DiffSpanDecoration | undefined; + interface ExpandoEventProps { type: 'line-info'; hunkIndex: number; @@ -115,6 +133,7 @@ interface ResolvedLineTarget { numberElement: HTMLElement; side: TMode extends 'diff' ? AnnotationSide : undefined; splitLineIndex: number | undefined; + decoration?: DecorationCache; } interface ResolvedTokenTarget { @@ -130,6 +149,7 @@ interface ResolvedTokenTarget { tokenText: string; lineCharStart: number; lineCharEnd: number; + decoration?: DecorationCache; } export interface MergeConflictActionTarget { @@ -197,6 +217,18 @@ export interface InteractionManagerBaseOptions< onTokenClick?(props: OnTokenEventProps, event: MouseEvent): unknown; onTokenEnter?(props: OnTokenEventProps, event: PointerEvent): unknown; onTokenLeave?(props: OnTokenEventProps, event: PointerEvent): unknown; + onDecorationClick?( + props: OnDecorationEventProps, + event: MouseEvent + ): unknown; + onDecorationEnter?( + props: OnDecorationEventProps, + event: PointerEvent + ): unknown; + onDecorationLeave?( + props: OnDecorationEventProps, + event: PointerEvent + ): unknown; __debugPointerEvents?: LogTypes; enableLineSelection?: boolean; controlledSelection?: boolean; @@ -217,6 +249,9 @@ export interface InteractionManagerOptions< expansionLineCountOverride?: number ): unknown; onMergeConflictActionClick?(target: MergeConflictActionTarget): void; + // Resolves a data-span-decoration index back to the consumer's decoration + // object. Wired by File/FileDiff, which own the spanDecorations array. + getSpanDecoration?: GetSpanDecorationUtility; } interface HandlePointerEventProps { @@ -227,6 +262,7 @@ interface HandlePointerEventProps { export class InteractionManager { private hoveredLine: EventBaseProps | undefined; private hoveredToken: OnTokenEventProps | undefined; + private hoveredDecoration: OnDecorationEventProps | undefined; private pre: HTMLPreElement | undefined; private gutterUtilityLine: EventBaseProps | undefined; @@ -271,6 +307,7 @@ export class InteractionManager { this.gutterUtilitySlot = undefined; this.clearHoveredLine(); this.clearHoveredToken(); + this.hoveredDecoration = undefined; this.detachDocumentPointerListeners(); this.clearPointerSession(); if (this.queuedSelectionRender != null) { @@ -369,6 +406,7 @@ export class InteractionManager { onLineClick, onLineNumberClick, onTokenClick, + onDecorationClick, onMergeConflictActionClick, } = this.options; if ( @@ -376,7 +414,8 @@ export class InteractionManager { onLineClick == null && onLineNumberClick == null && onMergeConflictActionClick == null && - onTokenClick == null + onTokenClick == null && + onDecorationClick == null ) { return; } @@ -408,6 +447,8 @@ export class InteractionManager { onLineLeave, onTokenEnter, onTokenLeave, + onDecorationEnter, + onDecorationLeave, enableGutterUtility = false, } = this.options; if ( @@ -416,7 +457,9 @@ export class InteractionManager { onLineEnter == null && onLineLeave == null && onTokenEnter == null && - onTokenLeave == null + onTokenLeave == null && + onDecorationEnter == null && + onDecorationLeave == null ) { return; } @@ -438,7 +481,11 @@ export class InteractionManager { 'move', 'FileDiff.DEBUG.handlePointerLeave: no event' ); - if (this.hoveredLine == null && this.hoveredToken == null) { + if ( + this.hoveredLine == null && + this.hoveredToken == null && + this.hoveredDecoration == null + ) { debugLogIfEnabled( __debugPointerEvents, 'move', @@ -450,6 +497,10 @@ export class InteractionManager { this.options.onTokenLeave?.(this.hoveredToken, event); this.clearHoveredToken(); } + if (this.hoveredDecoration != null) { + this.options.onDecorationLeave?.(this.hoveredDecoration, event); + this.hoveredDecoration = undefined; + } if (this.hoveredLine != null) { this.options.onLineLeave?.({ @@ -486,6 +537,9 @@ export class InteractionManager { onTokenClick, onTokenEnter, onTokenLeave, + onDecorationClick, + onDecorationEnter, + onDecorationLeave, onHunkExpand, onMergeConflictActionClick, } = this.options; @@ -498,6 +552,22 @@ export class InteractionManager { const sameToken = isTokenPointerTarget(target) && this.hoveredToken?.tokenElement === target.tokenElement; + const nextDecoration = this.toDecorationEventProps(target); + const sameDecoration = + this.hoveredDecoration?.decorationElement === + nextDecoration?.decorationElement; + + // Handle decoration transitions + if (!sameDecoration) { + if (this.hoveredDecoration != null) { + onDecorationLeave?.(this.hoveredDecoration, event as PointerEvent); + this.hoveredDecoration = undefined; + } + if (nextDecoration != null) { + this.hoveredDecoration = nextDecoration; + onDecorationEnter?.(nextDecoration, event as PointerEvent); + } + } // Handle token transitions if (!sameToken) { @@ -560,6 +630,13 @@ export class InteractionManager { break; } + if (onDecorationClick != null) { + const decorationProps = this.toDecorationEventProps(target); + if (decorationProps != null) { + onDecorationClick(decorationProps, event as MouseEvent); + } + } + if (isTokenPointerTarget(target) && onTokenClick != null) { onTokenClick(this.toTokenEventBaseProps(target), event as MouseEvent); } @@ -592,6 +669,9 @@ export class InteractionManager { onTokenClick, onTokenEnter, onTokenLeave, + onDecorationClick, + onDecorationEnter, + onDecorationLeave, onHunkExpand, onMergeConflictActionClick, enableGutterUtility = false, @@ -608,6 +688,9 @@ export class InteractionManager { onTokenClick != null || onTokenEnter != null || onTokenLeave != null || + onDecorationClick != null || + onDecorationEnter != null || + onDecorationLeave != null || onHunkExpand != null || onMergeConflictActionClick != null || enableGutterUtility || @@ -1639,6 +1722,42 @@ export class InteractionManager { } as EventBaseProps; } + // Builds the consumer-facing decoration event props for a resolved pointer + // target, or undefined when the target carries no decoration or the index + // no longer resolves (e.g. decorations changed between render and event). + private toDecorationEventProps( + target: ResolvedPointerTarget | undefined + ): OnDecorationEventProps | undefined { + if ( + target == null || + !isHoverableLinePointerTarget(target) || + target.decoration == null + ) { + return undefined; + } + const decoration = this.options.getSpanDecoration?.( + target.decoration.decorationIndex + ); + if (decoration == null) { + return undefined; + } + if (this.mode === 'file') { + return { + type: 'decoration', + lineNumber: target.lineNumber, + decoration, + decorationElement: target.decoration.decorationElement, + } as OnDecorationEventProps; + } + return { + type: 'decoration', + lineNumber: target.lineNumber, + side: target.side, + decoration, + decorationElement: target.decoration.decorationElement, + } as OnDecorationEventProps; + } + private toTokenEventBaseProps({ lineCharEnd, lineCharStart, @@ -1706,6 +1825,7 @@ export class InteractionManager { let numberElement: HTMLElement | undefined; let tokenElement: HTMLElement | undefined; let tokenInfo: TokenCache | undefined; + let decorationInfo: DecorationCache | undefined; let expandInfo: ExpandCache | undefined; let lineNumber: number | undefined; let mergeConflictActionTarget: MergeConflictActionTarget | undefined; @@ -1740,6 +1860,20 @@ export class InteractionManager { } } + if ( + decorationInfo == null && + element.hasAttribute('data-span-decoration') + ) { + const indexValue = element.getAttribute('data-span-decoration'); + const decorationIndex = + indexValue != null && indexValue !== '' + ? Number.parseInt(indexValue, 10) + : Number.NaN; + if (Number.isFinite(decorationIndex)) { + decorationInfo = { decorationElement: element, decorationIndex }; + } + } + if (tokenElement == null && element.hasAttribute('data-char')) { tokenElement = element; const startAttr = element.getAttribute('data-char'); @@ -1881,6 +2015,7 @@ export class InteractionManager { numberElement, side: undefined, splitLineIndex, + decoration: decorationInfo, ...tokenInfo, } as ResolvedPointerTarget; } @@ -1894,6 +2029,7 @@ export class InteractionManager { numberElement, side: getAnnotationSide(lineType, codeElement), splitLineIndex, + decoration: decorationInfo, ...tokenInfo, } as ResolvedPointerTarget; } @@ -1909,6 +2045,7 @@ export class InteractionManager { numberElement, side: undefined, splitLineIndex, + decoration: decorationInfo, } as ResolvedPointerTarget; } @@ -1921,6 +2058,7 @@ export class InteractionManager { numberElement, side: getAnnotationSide(lineType, codeElement), splitLineIndex, + decoration: decorationInfo, } as ResolvedPointerTarget; } @@ -1967,6 +2105,9 @@ export function pluckInteractionOptions( onTokenClick, onTokenEnter, onTokenLeave, + onDecorationClick, + onDecorationEnter, + onDecorationLeave, renderGutterUtility, __debugPointerEvents, enableLineSelection, @@ -1982,7 +2123,8 @@ export function pluckInteractionOptions( expansionLineCountOverride?: number ) => unknown, getLineIndex?: GetLineIndexUtility, - onMergeConflictActionClick?: (target: MergeConflictActionTarget) => void + onMergeConflictActionClick?: (target: MergeConflictActionTarget) => void, + getSpanDecoration?: GetSpanDecorationUtility ): InteractionManagerOptions { return { enableTokenInteractionsOnWhitespace, @@ -2004,6 +2146,9 @@ export function pluckInteractionOptions( onTokenClick, onTokenEnter, onTokenLeave, + onDecorationClick, + onDecorationEnter, + onDecorationLeave, __debugPointerEvents, enableLineSelection, @@ -2014,6 +2159,7 @@ export function pluckInteractionOptions( onLineSelectionEnd, getLineIndex, + getSpanDecoration, }; } diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index 898093bae..e6452c75b 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -645,6 +645,21 @@ export interface DiffTokenEventBaseProps extends TokenEventBase { side: AnnotationSide; } +export interface DecorationEventBaseProps { + type: 'decoration'; + lineNumber: number; + decoration: SpanDecoration; + decorationElement: HTMLElement; +} + +export interface DiffDecorationEventBaseProps extends Omit< + DecorationEventBaseProps, + 'decoration' +> { + side: AnnotationSide; + decoration: DiffSpanDecoration; +} + export interface ObservedAnnotationNodes { type: 'annotations'; column1: { diff --git a/packages/diffs/src/utils/parseDiffDecorations.ts b/packages/diffs/src/utils/parseDiffDecorations.ts index 2b57741a9..af90b8946 100644 --- a/packages/diffs/src/utils/parseDiffDecorations.ts +++ b/packages/diffs/src/utils/parseDiffDecorations.ts @@ -23,22 +23,29 @@ export function createDiffSpanDecoration({ interface CreateSpanDecorationProps extends CreateDiffSpanDecorationProps { className: string; + index?: number; } // Consumer-facing variant of createDiffSpanDecoration: same shiki shape, but // applies a caller-supplied class instead of the internal data-diff-span // attribute so consumer styles compose with (rather than replace) the engine's -// own intra-line diff highlighting. +// own intra-line diff highlighting. The data-span-decoration attribute carries +// the decoration's index in the consumer's spanDecorations array so the +// InteractionManager can resolve pointer events back to the original object. export function createSpanDecoration({ line, spanStart, spanLength, className, + index, }: CreateSpanDecorationProps): DecorationItem { return { start: { line, character: spanStart }, end: { line, character: spanStart + spanLength }, - properties: { class: className, 'data-span-decoration': '' }, + properties: { + class: className, + 'data-span-decoration': index != null ? String(index) : '', + }, alwaysWrap: true, }; } diff --git a/packages/diffs/src/utils/renderDiffWithHighlighter.ts b/packages/diffs/src/utils/renderDiffWithHighlighter.ts index 2b4cc9cd8..36ec36a48 100644 --- a/packages/diffs/src/utils/renderDiffWithHighlighter.ts +++ b/packages/diffs/src/utils/renderDiffWithHighlighter.ts @@ -269,7 +269,17 @@ export function renderDiffWithHighlighter( return { code, themeStyles, baseThemeType }; } -type SpanDecorationLineMap = Record; +interface IndexedSpanDecoration { + decoration: DiffSpanDecoration; + // Position in the consumer's spanDecorations array, stamped into the DOM so + // pointer events can be resolved back to the original decoration object. + index: number; +} + +type SpanDecorationLineMap = Record< + number, + IndexedSpanDecoration[] | undefined +>; // Index consumer span decorations by 1-based file line number for one side so // the iterateOverDiff callback can resolve them in O(1) per rendered line. @@ -281,13 +291,14 @@ function groupSpanDecorations( return undefined; } const map: SpanDecorationLineMap = {}; - for (const decoration of spanDecorations) { + for (let index = 0; index < spanDecorations.length; index++) { + const decoration = spanDecorations[index]; if (decoration.side !== side) { continue; } const arr = map[decoration.lineNumber] ?? []; map[decoration.lineNumber] = arr; - arr.push(decoration); + arr.push({ decoration, index }); } return map; } @@ -312,7 +323,7 @@ function pushSpanDecorations( return; } const lineLength = cleanLastNewline(lineContent).length; - for (const decoration of decorations) { + for (const { decoration, index } of decorations) { const spanStart = Math.min(decoration.spanStart, lineLength); const spanEnd = Math.min( decoration.spanStart + decoration.spanLength, @@ -327,6 +338,7 @@ function pushSpanDecorations( spanStart, spanLength: spanEnd - spanStart, className: decoration.className, + index, }) ); } diff --git a/packages/diffs/src/utils/renderFileWithHighlighter.ts b/packages/diffs/src/utils/renderFileWithHighlighter.ts index b24648cc0..e05e9a349 100644 --- a/packages/diffs/src/utils/renderFileWithHighlighter.ts +++ b/packages/diffs/src/utils/renderFileWithHighlighter.ts @@ -92,7 +92,8 @@ export function renderFileWithHighlighter( ? Math.min(totalLines, fileLines.length - startingLine) : fileLines.length; hastConfig.decorations = []; - for (const decoration of spanDecorations) { + for (let index = 0; index < spanDecorations.length; index++) { + const decoration = spanDecorations[index]; const line = decoration.lineNumber - 1 - startingLine; const lineContent = fileLines[decoration.lineNumber - 1]; if (line < 0 || line >= renderedLineCount || lineContent == null) { @@ -113,6 +114,7 @@ export function renderFileWithHighlighter( spanStart, spanLength: spanEnd - spanStart, className: decoration.className, + index, }) ); } diff --git a/packages/diffs/test/spanDecorations.interactions.test.ts b/packages/diffs/test/spanDecorations.interactions.test.ts new file mode 100644 index 000000000..0bb13e0ef --- /dev/null +++ b/packages/diffs/test/spanDecorations.interactions.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test } from 'bun:test'; + +import { File } from '../src/components/File'; +import { FileDiff } from '../src/components/FileDiff'; +import { disposeHighlighter } from '../src/highlighter/shared_highlighter'; +import type { + DecorationEventBaseProps, + DiffDecorationEventBaseProps, + DiffSpanDecoration, + SpanDecoration, +} from '../src/types'; +import { installDom, wait } from './domHarness'; + +const FILE_CONTENTS = 'const alpha = 1;\nconst beta = 2;\nconst gamma = 3;\n'; + +async function waitForDecoration( + fileContainer: HTMLElement +): Promise { + for (let i = 0; i < 50; i++) { + const el = fileContainer.shadowRoot?.querySelector( + '[data-span-decoration]' + ); + if (el instanceof HTMLElement) { + return el; + } + await wait(10); + } + throw new Error('decoration span never rendered'); +} + +describe('Span decoration interactions', () => { + test('onDecorationClick receives the original decoration on file views', async () => { + const { cleanup } = installDom(); + const decorations: SpanDecoration[] = [ + { lineNumber: 2, spanStart: 6, spanLength: 4, className: 'hl' }, + ]; + const clicks: DecorationEventBaseProps[] = []; + const instance = new File({ + disableErrorHandling: true, + onDecorationClick: (props) => { + clicks.push(props); + }, + }); + try { + const fileContainer = document.createElement('div'); + document.body.appendChild(fileContainer); + instance.render({ + file: { name: 'example.ts', contents: FILE_CONTENTS }, + fileContainer, + spanDecorations: decorations, + }); + const span = await waitForDecoration(fileContainer); + span.dispatchEvent( + new window.MouseEvent('click', { + bubbles: true, + cancelable: true, + composed: true, + }) + ); + expect(clicks.length).toBe(1); + expect(clicks[0].type).toBe('decoration'); + expect(clicks[0].decoration).toBe(decorations[0]); + expect(clicks[0].lineNumber).toBe(2); + expect(clicks[0].decorationElement).toBe(span); + } finally { + instance.cleanUp(); + cleanup(); + await disposeHighlighter(); + } + }); + + test('onDecorationEnter/Leave fire on hover transitions in diff views', async () => { + const { cleanup } = installDom(); + const decorations: DiffSpanDecoration[] = [ + { + side: 'additions', + lineNumber: 2, + spanStart: 6, + spanLength: 1, + className: 'hl', + }, + ]; + const entered: DiffDecorationEventBaseProps[] = []; + const left: DiffDecorationEventBaseProps[] = []; + const instance = new FileDiff({ + disableErrorHandling: true, + diffStyle: 'unified', + onDecorationEnter: (props) => { + entered.push(props); + }, + onDecorationLeave: (props) => { + left.push(props); + }, + }); + try { + const fileContainer = document.createElement('div'); + document.body.appendChild(fileContainer); + instance.render({ + oldFile: { + name: 'example.ts', + contents: 'const a = one;\nconst b = two;\n', + }, + newFile: { + name: 'example.ts', + contents: 'const a = one;\nconst b = TWO;\n', + }, + fileContainer, + spanDecorations: decorations, + }); + const span = await waitForDecoration(fileContainer); + span.dispatchEvent( + new window.PointerEvent('pointermove', { + bubbles: true, + composed: true, + pointerType: 'mouse', + }) + ); + expect(entered.length).toBe(1); + expect(entered[0].decoration).toBe(decorations[0]); + expect(entered[0].side).toBe('additions'); + expect(entered[0].lineNumber).toBe(2); + expect(left.length).toBe(0); + + // Moving onto a different line leaves the decoration + const otherLine = fileContainer.shadowRoot?.querySelector( + '[data-line="1"]' + ) as HTMLElement; + otherLine.dispatchEvent( + new window.PointerEvent('pointermove', { + bubbles: true, + composed: true, + pointerType: 'mouse', + }) + ); + expect(left.length).toBe(1); + expect(left[0].decoration).toBe(decorations[0]); + } finally { + instance.cleanUp(); + cleanup(); + await disposeHighlighter(); + } + }); +}); From ef9f4a8d432712ea0d477b507cac9317c8c8a4ae Mon Sep 17 00:00:00 2001 From: Chase McCoy Date: Thu, 11 Jun 2026 22:18:12 -0500 Subject: [PATCH 3/5] docs(diffs): Document span decorations Add a Span Decorations docs section modeled on Token Hooks: a content.mdx covering addressing semantics, styling through unsafeCSS, overlap behavior with the built-in intra-line diff highlight, and the interaction callbacks, plus React and Vanilla JS code examples behind the usual tab switcher. Wire the section into DocsPage after Token Hooks, register the tabs component for MDX, add cross-reference sentences in the React API and Vanilla JS API sections, add spanDecorations entries to the shared render-prop example files, and add a feature bullet to the package README. --- apps/docs/app/(diffs)/_docs/DocsPage.tsx | 20 ++ .../app/(diffs)/docs/ReactAPI/constants.ts | 36 ++++ .../app/(diffs)/docs/ReactAPI/content.mdx | 4 + .../docs/SpanDecorations/ComponentTabs.tsx | 38 ++++ .../(diffs)/docs/SpanDecorations/constants.ts | 171 ++++++++++++++++++ .../(diffs)/docs/SpanDecorations/content.mdx | 46 +++++ .../app/(diffs)/docs/VanillaAPI/constants.ts | 8 + .../app/(diffs)/docs/VanillaAPI/content.mdx | 4 + apps/docs/lib/mdx.tsx | 2 + packages/diffs/README.md | 1 + 10 files changed, 330 insertions(+) create mode 100644 apps/docs/app/(diffs)/docs/SpanDecorations/ComponentTabs.tsx create mode 100644 apps/docs/app/(diffs)/docs/SpanDecorations/constants.ts create mode 100644 apps/docs/app/(diffs)/docs/SpanDecorations/content.mdx diff --git a/apps/docs/app/(diffs)/_docs/DocsPage.tsx b/apps/docs/app/(diffs)/_docs/DocsPage.tsx index 6c4019913..abb68588a 100644 --- a/apps/docs/app/(diffs)/_docs/DocsPage.tsx +++ b/apps/docs/app/(diffs)/_docs/DocsPage.tsx @@ -50,6 +50,10 @@ import { REACT_API_SHARED_FILE_RENDER_PROPS, REACT_API_UNRESOLVED_FILE, } from '../docs/ReactAPI/constants'; +import { + SPAN_DECORATIONS_REACT, + SPAN_DECORATIONS_VANILLA, +} from '../docs/SpanDecorations/constants'; import { SSR_PRELOAD_FILE, SSR_PRELOAD_FILE_DIFF, @@ -169,6 +173,7 @@ export default function DocsPage() { + @@ -503,6 +508,21 @@ async function TokenHooksSection() { return {content}; } +async function SpanDecorationsSection() { + const [reactSpanDecorations, vanillaSpanDecorations] = await Promise.all([ + preloadFile(SPAN_DECORATIONS_REACT), + preloadFile(SPAN_DECORATIONS_VANILLA), + ]); + const content = await renderMDX({ + filePath: '(diffs)/docs/SpanDecorations/content.mdx', + scope: { + reactSpanDecorations, + vanillaSpanDecorations, + }, + }); + return {content}; +} + async function SSRSection() { const [ usageServer, diff --git a/apps/docs/app/(diffs)/docs/ReactAPI/constants.ts b/apps/docs/app/(diffs)/docs/ReactAPI/constants.ts index d9288c4d0..f2c76df27 100644 --- a/apps/docs/app/(diffs)/docs/ReactAPI/constants.ts +++ b/apps/docs/app/(diffs)/docs/ReactAPI/constants.ts @@ -364,6 +364,25 @@ interface ThreadMetadata { )} + // ───────────────────────────────────────────────────────────── + // SPAN DECORATIONS + // ───────────────────────────────────────────────────────────── + + // Style arbitrary character ranges within a line. lineNumber is + // 1-based, spanStart is a 0-based character offset. className is + // applied to the rendered span; style it with options.unsafeCSS. + // Keep arrays stable (useState/useMemo) - changes re-highlight. + // See the Span Decorations section for interaction callbacks. + spanDecorations={[ + { + side: 'additions', // or 'deletions' + lineNumber: 16, + spanStart: 4, + spanLength: 9, + className: 'hl-risk', + }, + ]} + // ───────────────────────────────────────────────────────────── // HEADER CALLBACKS // ───────────────────────────────────────────────────────────── @@ -988,6 +1007,23 @@ interface CommentMetadata { )} + // ───────────────────────────────────────────────────────────── + // SPAN DECORATIONS + // ───────────────────────────────────────────────────────────── + + // Style arbitrary character ranges within a line. Like + // lineAnnotations, the File variant has no 'side' property. + // className is applied to the rendered span; style it with + // options.unsafeCSS. See the Span Decorations section. + spanDecorations={[ + { + lineNumber: 5, + spanStart: 4, + spanLength: 9, + className: 'hl-match', + }, + ]} + // ───────────────────────────────────────────────────────────── // HEADER CALLBACKS // ───────────────────────────────────────────────────────────── diff --git a/apps/docs/app/(diffs)/docs/ReactAPI/content.mdx b/apps/docs/app/(diffs)/docs/ReactAPI/content.mdx index 5638949a8..108ccafad 100644 --- a/apps/docs/app/(diffs)/docs/ReactAPI/content.mdx +++ b/apps/docs/app/(diffs)/docs/ReactAPI/content.mdx @@ -89,3 +89,7 @@ state captured by `node`. Token callbacks (`onTokenClick`, `onTokenEnter`, `onTokenLeave`) and `useTokenTransformer` are documented in [Token Hooks](#token-hooks), including examples, payload details, performance notes, and Worker Pool caveats. + +Sub-line range styling (`spanDecorations`) and its interaction callbacks +(`onDecorationClick`, `onDecorationEnter`, `onDecorationLeave`) are documented +in [Span Decorations](#span-decorations). diff --git a/apps/docs/app/(diffs)/docs/SpanDecorations/ComponentTabs.tsx b/apps/docs/app/(diffs)/docs/SpanDecorations/ComponentTabs.tsx new file mode 100644 index 000000000..f5590518f --- /dev/null +++ b/apps/docs/app/(diffs)/docs/SpanDecorations/ComponentTabs.tsx @@ -0,0 +1,38 @@ +'use client'; + +import type { PreloadedFileResult } from '@pierre/diffs/ssr'; +import { useState } from 'react'; + +import { DocsCodeExample } from '@/components/docs/DocsCodeExample'; +import { ButtonGroup, ButtonGroupItem } from '@/components/ui/button-group'; + +type SpanDecorationMode = 'react' | 'vanilla'; + +interface SpanDecorationTabsProps { + reactExample: PreloadedFileResult; + vanillaExample: PreloadedFileResult; +} + +export function SpanDecorationTabs({ + reactExample, + vanillaExample, +}: SpanDecorationTabsProps) { + const [mode, setMode] = useState('react'); + + return ( + <> + setMode(value as SpanDecorationMode)} + > + React + Vanilla JS + + {mode === 'react' ? ( + + ) : ( + + )} + + ); +} diff --git a/apps/docs/app/(diffs)/docs/SpanDecorations/constants.ts b/apps/docs/app/(diffs)/docs/SpanDecorations/constants.ts new file mode 100644 index 000000000..3a26aae3b --- /dev/null +++ b/apps/docs/app/(diffs)/docs/SpanDecorations/constants.ts @@ -0,0 +1,171 @@ +import type { PreloadFileOptions } from '@pierre/diffs/ssr'; + +import { CustomScrollbarCSS } from '@/components/CustomScrollbarCSS'; + +const options = { + theme: { dark: 'pierre-dark', light: 'pierre-light' }, + disableFileHeader: true, + unsafeCSS: CustomScrollbarCSS, +} as const; + +export const SPAN_DECORATIONS_REACT: PreloadFileOptions = { + file: { + name: 'span_decorations.tsx', + contents: `import type { DiffSpanDecoration } from '@pierre/diffs'; +import { MultiFileDiff } from '@pierre/diffs/react'; + +const oldFile = { + name: 'query.ts', + contents: "const user = db.query('SELECT * FROM users WHERE id = ?', [id]);", +}; + +const newFile = { + name: 'query.ts', + contents: 'const user = db.query("SELECT * FROM users WHERE id = " + id);', +}; + +// Decorations address character ranges on rendered lines: +// 1-based lineNumber, 0-based spanStart, end-exclusive length. +// Diff decorations also take a side, like DiffLineAnnotation. +// Keep decoration arrays stable (useState/useMemo) to avoid re-highlights. +const spanDecorations: DiffSpanDecoration[] = [ + { + side: 'additions', + lineNumber: 1, + spanStart: 22, + spanLength: 39, + className: 'hl-risk', + }, +]; + +export function SpanDecorationsExample() { + return ( + + ); +}`, + }, + options, +}; + +export const SPAN_DECORATIONS_VANILLA: PreloadFileOptions = { + file: { + name: 'span_decorations.ts', + contents: `import { + FileDiff, + type DiffSpanDecoration, +} from '@pierre/diffs'; + +const instance = new FileDiff({ + theme: { dark: 'pierre-dark', light: 'pierre-light' }, + + // Decoration spans render inside the shadow DOM, so classes are + // styled through unsafeCSS. + unsafeCSS: \` + .hl-risk { + background: light-dark( + rgba(220, 38, 38, 0.14), + rgba(248, 113, 113, 0.18) + ); + box-shadow: inset 0 -2px 0 light-dark(#dc2626, #f87171); + border-radius: 2px; + } + \`, + + // Optional interaction callbacks, mirroring onToken*. + onDecorationClick({ decoration, decorationElement, lineNumber, side }) { + console.log('clicked decoration', { + className: decoration.className, + lineNumber, + side, + rect: decorationElement.getBoundingClientRect(), + }); + }, + onDecorationEnter({ decorationElement }) { + decorationElement.style.outline = '1px solid currentColor'; + }, + onDecorationLeave({ decorationElement }) { + decorationElement.style.outline = ''; + }, +}); + +// Decorations address character ranges on rendered lines: +// 1-based lineNumber, 0-based spanStart, end-exclusive length. +// Diff decorations also take a side, like DiffLineAnnotation. +const spanDecorations: DiffSpanDecoration[] = [ + { + side: 'additions', + lineNumber: 1, + spanStart: 22, + spanLength: 39, + className: 'hl-risk', + }, +]; + +instance.render({ + oldFile: { + name: 'query.ts', + contents: "const user = db.query('SELECT * FROM users WHERE id = ?', [id]);", + }, + newFile: { + name: 'query.ts', + contents: 'const user = db.query("SELECT * FROM users WHERE id = " + id);', + }, + spanDecorations, + containerWrapper: document.getElementById('diff-container'), +}); + +// Update decorations after the initial render +instance.render({ + spanDecorations: [ + { + side: 'additions', + lineNumber: 1, + spanStart: 22, + spanLength: 39, + className: 'hl-risk', + }, + ], +});`, + }, + options, +}; diff --git a/apps/docs/app/(diffs)/docs/SpanDecorations/content.mdx b/apps/docs/app/(diffs)/docs/SpanDecorations/content.mdx new file mode 100644 index 000000000..3cc668939 --- /dev/null +++ b/apps/docs/app/(diffs)/docs/SpanDecorations/content.mdx @@ -0,0 +1,46 @@ +## Span Decorations + +Span decorations style arbitrary character ranges within a line — without +touching tokenization. Use them for review-assist overlays like flagging +high-risk spans, search match highlighting, diagnostics squiggles, muting +low-relevance code, or anchoring assist UI to sub-line selections. + +Where [annotations](#react-api) attach content to whole lines, span decorations +address `{ lineNumber, spanStart, spanLength }` character ranges and wrap them +in a span carrying your `className`. They ride the same Shiki decorations +pipeline as the built-in intra-line diff highlighting. + +Available on: + +- React: `MultiFileDiff`, `PatchDiff`, `FileDiff`, `File`, `UnresolvedFile`, and + `CodeView` (per item) +- Vanilla JS: `FileDiff`, `File`, `UnresolvedFile`, and `CodeView` (per item) +- SSR: all `preload*` helpers accept a `spanDecorations` option + +Shared behavior: + +- `lineNumber` is 1-based; `spanStart` is a 0-based character offset; + `spanLength` is end-exclusive. Diff variants (`DiffSpanDecoration`) also take + `side: 'deletions' | 'additions'`, matching `DiffLineAnnotation`. +- Decorations are content-coupled, so they are render props / item fields (like + `lineAnnotations`), not options. Keep arrays stable to avoid re-highlights — + changing them re-runs highlighting for that file. +- `className` resolves to classes on the rendered span (plus a + `data-span-decoration` attribute). Spans render inside the shadow DOM, so + style the classes with `unsafeCSS`. Structured input only — no raw HTML. +- Ranges are clamped to the rendered line length; zero-length and out-of-range + decorations are dropped rather than throwing. +- When a decoration overlaps the built-in intra-line diff highlight, the + consumer span nests inside the `data-diff-span` wrapper and both classes apply + — built-in styling wins by default, and you can layer on top with CSS + specificity. +- `onDecorationClick`, `onDecorationEnter`, and `onDecorationLeave` options + mirror the [Token Hooks](#token-hooks) callbacks: they receive your original + decoration object, the rendered `decorationElement`, `lineNumber`, and (for + diffs) `side`, plus the raw pointer event. Unlike token hooks, they work + without `useTokenTransformer` and add no DOM overhead when unused. + + diff --git a/apps/docs/app/(diffs)/docs/VanillaAPI/constants.ts b/apps/docs/app/(diffs)/docs/VanillaAPI/constants.ts index 9763cf965..e6836cd5a 100644 --- a/apps/docs/app/(diffs)/docs/VanillaAPI/constants.ts +++ b/apps/docs/app/(diffs)/docs/VanillaAPI/constants.ts @@ -553,6 +553,10 @@ instance.render({ oldFile: { name: 'file.ts', contents: '...' }, newFile: { name: 'file.ts', contents: '...' }, lineAnnotations: [{ side: 'additions', lineNumber: 5, metadata: {} }], + // Sub-line range styling - see the Span Decorations section + spanDecorations: [ + { side: 'additions', lineNumber: 5, spanStart: 4, spanLength: 9, className: 'hl' }, + ], containerWrapper: document.body, }); @@ -796,6 +800,10 @@ const instance = new File({ instance.render({ file: { name: 'example.ts', contents: '...' }, lineAnnotations: [{ lineNumber: 5, metadata: {} }], + // Sub-line range styling - see the Span Decorations section + spanDecorations: [ + { lineNumber: 5, spanStart: 4, spanLength: 9, className: 'hl' }, + ], containerWrapper: document.body, }); diff --git a/apps/docs/app/(diffs)/docs/VanillaAPI/content.mdx b/apps/docs/app/(diffs)/docs/VanillaAPI/content.mdx index 1ecc32f27..fcf0f3f45 100644 --- a/apps/docs/app/(diffs)/docs/VanillaAPI/content.mdx +++ b/apps/docs/app/(diffs)/docs/VanillaAPI/content.mdx @@ -79,6 +79,10 @@ Token callbacks (`onTokenClick`, `onTokenEnter`, `onTokenLeave`) and `useTokenTransformer` are documented in [Token Hooks](#token-hooks), including examples, payload details, performance notes, and Worker Pool caveats. +Sub-line range styling (`spanDecorations`) and its interaction callbacks +(`onDecorationClick`, `onDecorationEnter`, `onDecorationLeave`) are documented +in [Span Decorations](#span-decorations). + #### Custom Hunk Separators Start with the [Hunk Separators](#hunk-separators) section first. In most cases, diff --git a/apps/docs/lib/mdx.tsx b/apps/docs/lib/mdx.tsx index 36312f6a1..c6f779e0a 100644 --- a/apps/docs/lib/mdx.tsx +++ b/apps/docs/lib/mdx.tsx @@ -22,6 +22,7 @@ import { ComponentTabs, SharedPropTabs, } from '../app/(diffs)/docs/ReactAPI/ComponentTabs'; +import { SpanDecorationTabs } from '../app/(diffs)/docs/SpanDecorations/ComponentTabs'; import { TokenHookTabs } from '../app/(diffs)/docs/TokenHooks/ComponentTabs'; import { AcceptRejectTabs } from '../app/(diffs)/docs/Utilities/AcceptRejectTabs'; import { @@ -72,6 +73,7 @@ const defaultComponents = { ComponentTabs, SharedPropTabs, TokenHookTabs, + SpanDecorationTabs, AcceptRejectTabs, DiffHunksTabs, VanillaComponentTabs, diff --git a/packages/diffs/README.md b/packages/diffs/README.md index 8dd99ee35..71a6b0033 100644 --- a/packages/diffs/README.md +++ b/packages/diffs/README.md @@ -20,6 +20,7 @@ JavaScript and React components. - Flexible annotation framework for injecting comments, annotations, and more - Add your own accept/reject changes UI - Select and highlight lines +- Span decorations for styling and interacting with sub-line character ranges ## Install From 96f28de2d41ab6b0050019dd54eb26bfa37454a8 Mon Sep 17 00:00:00 2001 From: Chase McCoy Date: Thu, 11 Jun 2026 22:24:20 -0500 Subject: [PATCH 4/5] fix(diffs): Drop span decorations with negative offsets Math.min only capped the upper bound, so a negative spanStart (for example from an indexOf miss) was forwarded to Shiki, which treats negative character positions as offsets from the end of the line and would wrap the wrong text. Reject these spans in both the file and diff translation paths, matching the documented behavior of dropping invalid ranges. --- .../diffs/src/utils/renderDiffWithHighlighter.ts | 5 +++++ .../diffs/src/utils/renderFileWithHighlighter.ts | 9 ++++++++- packages/diffs/test/spanDecorations.test.ts | 12 +++++++++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/diffs/src/utils/renderDiffWithHighlighter.ts b/packages/diffs/src/utils/renderDiffWithHighlighter.ts index 36ec36a48..56b1ea744 100644 --- a/packages/diffs/src/utils/renderDiffWithHighlighter.ts +++ b/packages/diffs/src/utils/renderDiffWithHighlighter.ts @@ -324,6 +324,11 @@ function pushSpanDecorations( } const lineLength = cleanLastNewline(lineContent).length; for (const { decoration, index } of decorations) { + // Negative offsets (e.g. indexOf misses) are invalid addressing, not + // clampable ranges — Shiki would treat them as from-end-of-line. + if (decoration.spanStart < 0) { + continue; + } const spanStart = Math.min(decoration.spanStart, lineLength); const spanEnd = Math.min( decoration.spanStart + decoration.spanLength, diff --git a/packages/diffs/src/utils/renderFileWithHighlighter.ts b/packages/diffs/src/utils/renderFileWithHighlighter.ts index e05e9a349..44c8062c8 100644 --- a/packages/diffs/src/utils/renderFileWithHighlighter.ts +++ b/packages/diffs/src/utils/renderFileWithHighlighter.ts @@ -96,7 +96,14 @@ export function renderFileWithHighlighter( const decoration = spanDecorations[index]; const line = decoration.lineNumber - 1 - startingLine; const lineContent = fileLines[decoration.lineNumber - 1]; - if (line < 0 || line >= renderedLineCount || lineContent == null) { + if ( + line < 0 || + line >= renderedLineCount || + lineContent == null || + // Negative offsets (e.g. indexOf misses) are invalid addressing, not + // clampable ranges — Shiki would treat them as from-end-of-line. + decoration.spanStart < 0 + ) { continue; } const lineLength = cleanLastNewline(lineContent).length; diff --git a/packages/diffs/test/spanDecorations.test.ts b/packages/diffs/test/spanDecorations.test.ts index 690631642..083b93031 100644 --- a/packages/diffs/test/spanDecorations.test.ts +++ b/packages/diffs/test/spanDecorations.test.ts @@ -60,10 +60,12 @@ describe('Span Decorations', () => { expect(flattenText(decorated[0])).toBe('beta'); }); - test('drops zero/negative-length spans and out-of-range lines', async () => { + test('drops zero/negative-length spans, negative offsets and out-of-range lines', async () => { const decorations: SpanDecoration[] = [ { lineNumber: 1, spanStart: 0, spanLength: 0, className: 'noop' }, { lineNumber: 99, spanStart: 0, spanLength: 1, className: 'noop' }, + // e.g. indexOf returning -1 — must not wrap from end-of-line + { lineNumber: 1, spanStart: -1, spanLength: 4, className: 'noop' }, ]; const renderer = new FileRenderer(); renderer.setSpanDecorations(decorations); @@ -143,6 +145,14 @@ describe('Span Decorations', () => { spanLength: 1, className: 'noop', }, + // e.g. indexOf returning -1 — must not wrap from end-of-line + { + side: 'additions', + lineNumber: 2, + spanStart: -1, + spanLength: 4, + className: 'noop', + }, ]; const renderer = new DiffHunksRenderer({ diffStyle: 'unified' }); renderer.setSpanDecorations(decorations); From ad2379bbcb3914fc33af411c841437286b400a7e Mon Sep 17 00:00:00 2001 From: Chase McCoy Date: Thu, 11 Jun 2026 22:27:28 -0500 Subject: [PATCH 5/5] chore(diffs): Align comment terminology with codebase conventions --- packages/diffs/src/types.ts | 2 +- packages/diffs/src/utils/parseDiffDecorations.ts | 6 +++--- packages/diffs/src/utils/renderDiffWithHighlighter.ts | 6 +++--- packages/diffs/test/spanDecorations.test.ts | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index e6452c75b..6e9645f71 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -478,7 +478,7 @@ export type DiffLineAnnotation = { /** * Consumer-facing sub-line decoration. Wraps the [spanStart, spanStart + - * spanLength) character range on the given 1-indexed line in a span carrying + * spanLength) character range on the given 1-based line in a span carrying * `className`. Ranges are addressed against the rendered line text (no diff * indicator prefix, no trailing newline). Out-of-range spans are dropped. */ diff --git a/packages/diffs/src/utils/parseDiffDecorations.ts b/packages/diffs/src/utils/parseDiffDecorations.ts index af90b8946..dd59b49f7 100644 --- a/packages/diffs/src/utils/parseDiffDecorations.ts +++ b/packages/diffs/src/utils/parseDiffDecorations.ts @@ -26,10 +26,10 @@ interface CreateSpanDecorationProps extends CreateDiffSpanDecorationProps { index?: number; } -// Consumer-facing variant of createDiffSpanDecoration: same shiki shape, but +// Consumer-facing variant of createDiffSpanDecoration: same Shiki shape, but // applies a caller-supplied class instead of the internal data-diff-span -// attribute so consumer styles compose with (rather than replace) the engine's -// own intra-line diff highlighting. The data-span-decoration attribute carries +// attribute so consumer styles compose with (rather than replace) the built-in +// intra-line diff highlighting. The data-span-decoration attribute carries // the decoration's index in the consumer's spanDecorations array so the // InteractionManager can resolve pointer events back to the original object. export function createSpanDecoration({ diff --git a/packages/diffs/src/utils/renderDiffWithHighlighter.ts b/packages/diffs/src/utils/renderDiffWithHighlighter.ts index 56b1ea744..c4feda842 100644 --- a/packages/diffs/src/utils/renderDiffWithHighlighter.ts +++ b/packages/diffs/src/utils/renderDiffWithHighlighter.ts @@ -303,10 +303,10 @@ function groupSpanDecorations( return map; } -// Translate consumer span decorations addressed by file line number into shiki +// Translate consumer span decorations addressed by file line number into Shiki // DecorationItems addressed by bucket-local 0-based line index, clamped to the -// rendered line length. Pushed after the engine's own intra-line diff spans so -// shiki nests consumer wrappers inside the data-diff-span wrapper when ranges +// rendered line length. Pushed after the built-in intra-line diff spans so +// Shiki nests consumer wrappers inside the data-diff-span wrapper when ranges // overlap, leaving both classes applied. function pushSpanDecorations( map: SpanDecorationLineMap | undefined, diff --git a/packages/diffs/test/spanDecorations.test.ts b/packages/diffs/test/spanDecorations.test.ts index 083b93031..893572e47 100644 --- a/packages/diffs/test/spanDecorations.test.ts +++ b/packages/diffs/test/spanDecorations.test.ts @@ -125,7 +125,7 @@ describe('Span Decorations', () => { expect(flattenText(add)).toBe('b'); expect(flattenText(del)).toBe('b'); - // The engine's own intra-line diff highlight (data-diff-span) must still + // The built-in intra-line diff highlight (data-diff-span) must still // be present on the same change line — consumer spans compose, not // replace. const diffSpans = all.filter(