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 diff --git a/packages/diffs/src/components/CodeView.ts b/packages/diffs/src/components/CodeView.ts index d8e1b297a..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 = [ @@ -3450,6 +3453,7 @@ function renderItem( fileDiff: item.item.fileDiff, forceRender, lineAnnotations: item.item.annotations, + spanDecorations: item.item.spanDecorations, }); } else { return item.instance.render({ @@ -3458,6 +3462,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..7226ad65d 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; @@ -211,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 }; } @@ -267,6 +283,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 +323,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 +370,7 @@ export class File { preventEmit = false, file, lineAnnotations, + spanDecorations, } = props; this.hydrateElements(fileContainer, prerenderedHTML); if ( @@ -361,7 +385,7 @@ export class File { } // Otherwise orchestrate our setup. else { - this.hydrationSetup({ file, lineAnnotations }); + this.hydrationSetup({ file, lineAnnotations, spanDecorations }); } if (!preventEmit) { this.emitPostRender(); @@ -423,8 +447,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 +482,7 @@ export class File { containerWrapper, deferManagers = false, lineAnnotations, + spanDecorations, renderRange, }: FileRenderProps): boolean { const { collapsed = false, themeType = 'system' } = this.options; @@ -471,6 +499,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 +508,7 @@ export class File { areRenderRangesEqual(nextRenderRange, this.renderRange) && !didFileChange && !annotationsChanged && + !spanDecorationsChanged && !themeChanged ) { return this.applyCachedThemeState(themeType); @@ -494,6 +525,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..27e591a1a 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; @@ -375,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 }; } @@ -433,6 +445,12 @@ export class FileDiff { this.lineAnnotations = lineAnnotations; } + public setSpanDecorations( + spanDecorations: DiffSpanDecoration[] | undefined + ): void { + this.spanDecorations = spanDecorations; + } + private canPartiallyRender( forceRender: boolean, annotationsChanged: boolean, @@ -493,6 +511,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 +564,7 @@ export class FileDiff { prerenderedHTML, preventEmit = false, lineAnnotations, + spanDecorations, oldFile, newFile, fileDiff, @@ -571,6 +591,7 @@ export class FileDiff { oldFile, newFile, lineAnnotations, + spanDecorations, }); } if (!preventEmit) { @@ -648,10 +669,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 +750,7 @@ export class FileDiff { forceRender = false, preventEmit = false, lineAnnotations, + spanDecorations, fileContainer, containerWrapper, renderRange, @@ -751,12 +776,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 +825,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..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 ) ); } @@ -345,6 +346,7 @@ export class UnresolvedFile< actions, markerRows, lineAnnotations, + spanDecorations, fileContainer, prerenderedHTML, preventEmit = false, @@ -374,7 +376,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/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/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..6e9645f71 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-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. + */ +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; }; @@ -626,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: { @@ -712,6 +746,7 @@ export interface RenderFileOptions { theme: DiffsThemeNames | Record<'dark' | 'light', DiffsThemeNames>; useTokenTransformer: boolean; tokenizeMaxLineLength: number; + spanDecorations?: SpanDecoration[]; } export interface RenderDiffOptions { @@ -720,6 +755,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..dd59b49f7 100644 --- a/packages/diffs/src/utils/parseDiffDecorations.ts +++ b/packages/diffs/src/utils/parseDiffDecorations.ts @@ -21,6 +21,35 @@ 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 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({ + line, + spanStart, + spanLength, + className, + index, +}: CreateSpanDecorationProps): DecorationItem { + return { + start: { line, character: spanStart }, + end: { line, character: spanStart + spanLength }, + properties: { + class: className, + 'data-span-decoration': index != null ? String(index) : '', + }, + 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..c4feda842 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,86 @@ export function renderDiffWithHighlighter( return { code, themeStyles, baseThemeType }; } +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. +function groupSpanDecorations( + spanDecorations: DiffSpanDecoration[] | undefined, + side: DiffSpanDecoration['side'] +): SpanDecorationLineMap | undefined { + if (spanDecorations == null || spanDecorations.length === 0) { + return undefined; + } + const map: SpanDecorationLineMap = {}; + 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, index }); + } + 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 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, + 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, 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, + lineLength + ); + if (spanEnd <= spanStart) { + continue; + } + target.push( + createSpanDecoration({ + line: bucketLineIndex, + spanStart, + spanLength: spanEnd - spanStart, + className: decoration.className, + index, + }) + ); + } +} + 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..44c8062c8 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,46 @@ 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 (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 || + // 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; + 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, + index, + }) + ); + } + } const highlightedLines = getLineNodes( highlighter.codeToHast( isWindowedHighlight 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(); + } + }); +}); diff --git a/packages/diffs/test/spanDecorations.test.ts b/packages/diffs/test/spanDecorations.test.ts new file mode 100644 index 000000000..893572e47 --- /dev/null +++ b/packages/diffs/test/spanDecorations.test.ts @@ -0,0 +1,195 @@ +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, 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); + 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 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( + (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', + }, + // 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); + 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 + ); + }); + }); +});