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
+ );
+ });
+ });
+});