From ebb1179eeaa42314368962f22cad4a3a58b3a0b0 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Tue, 31 Mar 2026 14:54:14 -0700 Subject: [PATCH 1/5] phase 1: types and scaffolding. mostly a non-functional changes, a lot of types changes, that I had to refactor from the AI slop because he really did some ugly shit... --- apps/docs/components/docs/DocsCodeExample.tsx | 13 +- .../components/AdvancedVirtualizedFileDiff.ts | 11 +- .../src/components/AdvancedVirtualizer.ts | 38 +++-- packages/diffs/src/components/File.ts | 67 ++++++--- packages/diffs/src/components/FileDiff.ts | 75 +++++++--- .../diffs/src/components/UnresolvedFile.ts | 78 ++++++---- .../diffs/src/components/VirtualizedFile.ts | 11 +- .../src/components/VirtualizedFileDiff.ts | 11 +- .../src/components/VirtulizerDevelopment.d.ts | 2 +- packages/diffs/src/react/File.tsx | 6 +- packages/diffs/src/react/FileDiff.tsx | 9 +- packages/diffs/src/react/MultiFileDiff.tsx | 12 +- packages/diffs/src/react/PatchDiff.tsx | 9 +- packages/diffs/src/react/UnresolvedFile.tsx | 28 ++-- packages/diffs/src/react/types.ts | 12 +- .../src/react/utils/renderDiffChildren.tsx | 41 +++-- .../src/react/utils/renderFileChildren.tsx | 26 ++-- .../src/react/utils/useFileDiffInstance.ts | 30 ++-- .../diffs/src/react/utils/useFileInstance.ts | 33 +++-- .../react/utils/useUnresolvedFileInstance.ts | 43 ++++-- .../diffs/src/renderers/DiffHunksRenderer.ts | 10 +- packages/diffs/src/renderers/FileRenderer.ts | 7 +- .../renderers/UnresolvedFileHunksRenderer.ts | 3 +- packages/diffs/src/ssr/preloadDiffs.ts | 140 +++++++++++++----- packages/diffs/src/ssr/preloadFile.ts | 38 ++++- packages/diffs/src/ssr/preloadPatchFile.ts | 21 ++- packages/diffs/src/types.ts | 24 ++- packages/diffs/src/utils/areOptionsEqual.ts | 15 +- packages/diffs/test/annotations.test.ts | 2 +- 29 files changed, 570 insertions(+), 245 deletions(-) diff --git a/apps/docs/components/docs/DocsCodeExample.tsx b/apps/docs/components/docs/DocsCodeExample.tsx index d4dfd135d..164b429cd 100644 --- a/apps/docs/components/docs/DocsCodeExample.tsx +++ b/apps/docs/components/docs/DocsCodeExample.tsx @@ -12,20 +12,21 @@ import { IconBrandGithub } from '@pierre/icons'; import { CopyCodeButton } from './CopyCodeButton'; import { cn } from '@/lib/utils'; -interface DocsCodeExampleProps { +interface DocsCodeExampleProps { file: FileContents; - options?: FileOptions; + options?: FileOptions; annotations?: LineAnnotation[]; prerenderedHTML?: string; - style?: FileProps['style']; + style?: FileProps['style']; className?: string | undefined; /** Optional link to the source file on GitHub */ href?: string; } -export function DocsCodeExample( - props: DocsCodeExampleProps -) { +export function DocsCodeExample< + LAnnotation = undefined, + LDecoration = undefined, +>(props: DocsCodeExampleProps) { const { href, ...rest } = props; return ( extends FileDiff { + LDecoration = undefined, +> extends FileDiff { override readonly __id: string = `virtualized-file-diff:${++instanceId}`; public unifiedTop: number; @@ -41,7 +42,9 @@ export class AdvancedVirtualizedFileDiff< constructor( { unifiedTop, splitTop, fileDiff }: PositionProps, - options: FileDiffOptions = { theme: DEFAULT_THEMES }, + options: FileDiffOptions = { + theme: DEFAULT_THEMES, + }, metrics?: Partial, workerManager?: WorkerPoolManager | undefined ) { @@ -217,8 +220,8 @@ export class AdvancedVirtualizedFileDiff< } } -function getSpecs( - instance: AdvancedVirtualizedFileDiff, +function getSpecs( + instance: AdvancedVirtualizedFileDiff, type: 'split' | 'unified' = 'split' ) { if (type === 'split') { diff --git a/packages/diffs/src/components/AdvancedVirtualizer.ts b/packages/diffs/src/components/AdvancedVirtualizer.ts index 173ea3af3..6c8722b2e 100644 --- a/packages/diffs/src/components/AdvancedVirtualizer.ts +++ b/packages/diffs/src/components/AdvancedVirtualizer.ts @@ -14,22 +14,25 @@ import type { FileDiffOptions } from './FileDiff'; const ENABLE_RENDERING = true; const OVERSCROLL_SIZE = 500; -interface RenderedItems { - instance: AdvancedVirtualizedFileDiff; +interface RenderedItems { + instance: AdvancedVirtualizedFileDiff; element: HTMLElement; } -export class AdvancedVirtualizer { +export class AdvancedVirtualizer< + LAnnotations = undefined, + LDecoration = undefined, +> { static __STOP = false; static __lastScrollPosition = 0; public type = 'advanced'; - private files: AdvancedVirtualizedFileDiff[] = []; + private files: AdvancedVirtualizedFileDiff[] = []; private totalHeightUnified = 0; private totalHeightSplit = 0; private rendered: Map< - AdvancedVirtualizedFileDiff, - RenderedItems + AdvancedVirtualizedFileDiff, + RenderedItems > = new Map(); private containerOffset = 0; @@ -44,7 +47,7 @@ export class AdvancedVirtualizer { constructor( private container: HTMLElement, - private fileOptions: FileDiffOptions = { + private fileOptions: FileDiffOptions = { theme: DEFAULT_THEMES, // FIXME(amadeus): Fix selected lines crashing when scroll out of the window enableLineSelection: true, @@ -103,7 +106,10 @@ export class AdvancedVirtualizer { addFiles(parsedPatches: ParsedPatch[]): void { for (const patch of parsedPatches) { for (const fileDiff of patch.files) { - const vFileDiff = new AdvancedVirtualizedFileDiff( + const vFileDiff = new AdvancedVirtualizedFileDiff< + LAnnotations, + LDecoration + >( { unifiedTop: this.totalHeightUnified, splitTop: this.totalHeightSplit, @@ -164,8 +170,12 @@ export class AdvancedVirtualizer { } } let prevElement: HTMLElement | undefined; - let firstInstance: AdvancedVirtualizedFileDiff | undefined; - let lastInstance: AdvancedVirtualizedFileDiff | undefined; + let firstInstance: + | AdvancedVirtualizedFileDiff + | undefined; + let lastInstance: + | AdvancedVirtualizedFileDiff + | undefined; for (const instance of this.files) { // We can stop iterating when we get to elements after the window if (getInstanceSpecs(instance, diffStyle).top > bottom) { @@ -272,7 +282,9 @@ export class AdvancedVirtualizer { }; } -function cleanupRenderedItem(item: RenderedItems) { +function cleanupRenderedItem( + item: RenderedItems +) { item.instance.cleanUp(true); item.element.remove(); item.element.innerHTML = ''; @@ -281,8 +293,8 @@ function cleanupRenderedItem(item: RenderedItems) { } } -function getInstanceSpecs( - instance: AdvancedVirtualizedFileDiff, +function getInstanceSpecs( + instance: AdvancedVirtualizedFileDiff, diffStyle: 'split' | 'unified' = 'split' ) { if (diffStyle === 'split') { diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index db6bff62b..819587d71 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -25,6 +25,7 @@ import type { AppliedThemeStyleCache, BaseCodeOptions, FileContents, + FileDecorationItem, LineAnnotation, PrePropertiesConfig, RenderFileMetadata, @@ -54,25 +55,26 @@ import { DiffsContainerLoaded } from './web-components'; const EMPTY_STRINGS: string[] = []; -export interface FileRenderProps { +export interface FileRenderProps { file: FileContents; fileContainer?: HTMLElement; containerWrapper?: HTMLElement; forceRender?: boolean; preventEmit?: boolean; lineAnnotations?: LineAnnotation[]; + decorations?: FileDecorationItem[]; renderRange?: RenderRange; } -export interface FileHydrateProps extends Omit< - FileRenderProps, +export interface FileHydrateProps extends Omit< + FileRenderProps, 'fileContainer' > { fileContainer: HTMLElement; prerenderedHTML?: string; } -export interface FileOptions +export interface FileOptions extends BaseCodeOptions, InteractionManagerBaseOptions<'file'> { disableFileHeader?: boolean; /** @@ -101,7 +103,10 @@ export interface FileOptions getHoveredRow: () => GetHoveredLineResult<'file'> | undefined ): HTMLElement | null | undefined; - onPostRender?(node: HTMLElement, instance: File): unknown; + onPostRender?( + node: HTMLElement, + instance: File + ): unknown; } interface AnnotationElementCache { @@ -114,14 +119,15 @@ interface ColumnElements { content: HTMLElement; } -interface HydrationSetup { +interface HydrationSetup { file: FileContents; lineAnnotations: LineAnnotation[] | undefined; + decorations: FileDecorationItem[] | undefined; } let instanceId = -1; -export class File { +export class File { static LoadedCustomComponent: boolean = DiffsContainerLoaded; readonly __id: string = `file:${++instanceId}`; @@ -148,23 +154,26 @@ export class File { protected headerPrefix: HTMLElement | undefined; protected headerMetadata: HTMLElement | undefined; - protected fileRenderer: FileRenderer; + protected fileRenderer: FileRenderer; protected resizeManager: ResizeManager; protected interactionManager: InteractionManager<'file'>; protected annotationCache: Map> = new Map(); protected lineAnnotations: LineAnnotation[] = []; + protected decorations: FileDecorationItem[] = []; protected file: FileContents | undefined; protected renderRange: RenderRange | undefined; constructor( - public options: FileOptions = { theme: DEFAULT_THEMES }, + public options: FileOptions = { + theme: DEFAULT_THEMES, + }, private workerManager?: WorkerPoolManager | undefined, private isContainerManaged = false ) { - this.fileRenderer = new FileRenderer( + this.fileRenderer = new FileRenderer( options, this.handleHighlightRender, this.workerManager @@ -190,13 +199,17 @@ export class File { }); } - public setOptions(options: FileOptions | undefined): void { + public setOptions( + options: FileOptions | undefined + ): void { if (options == null) return; this.options = options; this.interactionManager.setOptions(pluckInteractionOptions(options)); } - private mergeOptions(options: Partial>): void { + private mergeOptions( + options: Partial> + ): void { this.options = { ...this.options, ...options }; } @@ -230,6 +243,10 @@ export class File { this.lineAnnotations = lineAnnotations; } + public setDecorations(decorations: FileDecorationItem[]): void { + this.decorations = decorations; + } + public setSelectedLines(range: SelectedLineRange | null): void { this.interactionManager.setSelection(range); } @@ -271,13 +288,14 @@ export class File { this.placeHolder = undefined; } - public hydrate(props: FileHydrateProps): void { + public hydrate(props: FileHydrateProps): void { const { fileContainer, prerenderedHTML, preventEmit = false, file, lineAnnotations, + decorations, } = props; this.hydrateElements(fileContainer, prerenderedHTML); if ( @@ -292,7 +310,7 @@ export class File { } // Otherwise orchestrate our setup. else { - this.hydrationSetup({ file, lineAnnotations }); + this.hydrationSetup({ file, lineAnnotations, decorations }); } if (!preventEmit) { this.emitPostRender(); @@ -351,15 +369,18 @@ export class File { protected hydrationSetup({ file, lineAnnotations, - }: HydrationSetup): void { + decorations, + }: HydrationSetup): void { const { overflow = 'scroll' } = this.options; this.lineAnnotations = lineAnnotations ?? this.lineAnnotations; + this.decorations = decorations ?? this.decorations; this.file = file; this.fileRenderer.setOptions({ ...this.options, headerRenderMode: this.options.renderCustomHeader != null ? 'custom' : 'default', }); + this.fileRenderer.setDecorations(this.decorations); if (this.pre == null) { return; } @@ -386,23 +407,31 @@ export class File { preventEmit = false, containerWrapper, lineAnnotations, + decorations, renderRange, - }: FileRenderProps): boolean { + }: FileRenderProps): boolean { const { collapsed = false, themeType = 'system' } = this.options; const nextRenderRange = collapsed ? undefined : renderRange; const previousRenderRange = this.renderRange; + const nextDecorations = decorations; const annotationsChanged = lineAnnotations != null && (lineAnnotations.length > 0 || this.lineAnnotations.length > 0) ? lineAnnotations !== this.lineAnnotations : false; + const decorationsChanged = + nextDecorations != null && + (nextDecorations.length > 0 || this.decorations.length > 0) + ? nextDecorations !== this.decorations + : false; const didFileChange = !areFilesEqual(this.file, file); if ( !collapsed && !forceRender && areRenderRangesEqual(nextRenderRange, this.renderRange) && !didFileChange && - !annotationsChanged + !annotationsChanged && + !decorationsChanged ) { return false; } @@ -417,7 +446,11 @@ export class File { if (lineAnnotations != null) { this.setLineAnnotations(lineAnnotations); } + if (nextDecorations != null) { + this.decorations = nextDecorations; + } this.fileRenderer.setLineAnnotations(this.lineAnnotations); + this.fileRenderer.setDecorations(this.decorations); const { disableErrorHandling = false, diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index f54a3db29..e53068d4c 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -31,6 +31,7 @@ import type { AppliedThemeStyleCache, BaseDiffOptions, CustomPreProperties, + DiffDecorationItem, DiffLineAnnotation, ExpansionDirections, FileContents, @@ -67,7 +68,7 @@ import { setPreNodeProperties } from '../utils/setWrapperNodeProps'; import type { WorkerPoolManager } from '../worker'; import { DiffsContainerLoaded } from './web-components'; -export interface FileDiffRenderProps { +export interface FileDiffRenderProps { fileDiff?: FileDiffMetadata; oldFile?: FileContents; newFile?: FileContents; @@ -76,18 +77,22 @@ export interface FileDiffRenderProps { fileContainer?: HTMLElement; containerWrapper?: HTMLElement; lineAnnotations?: DiffLineAnnotation[]; + decorations?: DiffDecorationItem[]; renderRange?: RenderRange; } -export interface FileDiffHydrationProps extends Omit< - FileDiffRenderProps, +export interface FileDiffHydrationProps extends Omit< + FileDiffRenderProps, 'fileContainer' > { fileContainer: HTMLElement; prerenderedHTML?: string; } -export interface FileDiffOptions +export interface FileDiffOptions< + LAnnotation = undefined, + LDecoration = undefined, +> extends Omit, InteractionManagerBaseOptions<'diff'> { @@ -98,7 +103,7 @@ export interface FileDiffOptions */ | (( hunk: HunkData, - instance: FileDiff + instance: FileDiff ) => HTMLElement | DocumentFragment | null | undefined); disableFileHeader?: boolean; /** @@ -127,7 +132,10 @@ export interface FileDiffOptions getHoveredRow: () => GetHoveredLineResult<'diff'> | undefined ): HTMLElement | null | undefined; - onPostRender?(node: HTMLElement, instance: FileDiff): unknown; + onPostRender?( + node: HTMLElement, + instance: FileDiff + ): unknown; } interface AnnotationElementCache { @@ -162,16 +170,17 @@ interface ApplyPartialRenderProps { renderRange: RenderRange | undefined; } -interface HydrationSetup { +interface HydrationSetup { fileDiff: FileDiffMetadata | undefined; lineAnnotations: DiffLineAnnotation[] | undefined; + decorations: DiffDecorationItem[] | undefined; oldFile?: FileContents; newFile?: FileContents; } let instanceId = -1; -export class FileDiff { +export class FileDiff { // NOTE(amadeus): We sorta need this to ensure the web-component file is // properly loaded static LoadedCustomComponent: boolean = DiffsContainerLoaded; @@ -200,7 +209,7 @@ export class FileDiff { protected errorWrapper: HTMLElement | undefined; protected placeHolder: HTMLElement | undefined; - protected hunksRenderer: DiffHunksRenderer; + protected hunksRenderer: DiffHunksRenderer; protected resizeManager: ResizeManager; protected scrollSyncManager: ScrollSyncManager; protected interactionManager: InteractionManager<'diff'>; @@ -208,6 +217,7 @@ export class FileDiff { protected annotationCache: Map> = new Map(); protected lineAnnotations: DiffLineAnnotation[] = []; + protected decorations: DiffDecorationItem[] = []; protected deletionFile: FileContents | undefined; protected additionFile: FileContents | undefined; @@ -220,7 +230,9 @@ export class FileDiff { protected enabled = true; constructor( - public options: FileDiffOptions = { theme: DEFAULT_THEMES }, + public options: FileDiffOptions = { + theme: DEFAULT_THEMES, + }, protected workerManager?: WorkerPoolManager | undefined, protected isContainerManaged = false ) { @@ -248,7 +260,7 @@ export class FileDiff { }; protected getHunksRendererOptions( - options: FileDiffOptions + options: FileDiffOptions ): DiffHunksRendererOptions { return { ...options, @@ -262,8 +274,8 @@ export class FileDiff { } protected createHunksRenderer( - options: FileDiffOptions - ): DiffHunksRenderer { + options: FileDiffOptions + ): DiffHunksRenderer { return new DiffHunksRenderer( this.getHunksRendererOptions(options), this.handleHighlightRender, @@ -357,7 +369,9 @@ export class FileDiff { // * There's also an issue of options that live here on the File class and // those that live on the Hunk class, and it's a bit of an issue with passing // settings down and mirroring them (not great...) - public setOptions(options: FileDiffOptions | undefined): void { + public setOptions( + options: FileDiffOptions | undefined + ): void { if (options == null) return; this.options = options; this.hunksRenderer.setOptions(this.getHunksRendererOptions(options)); @@ -374,7 +388,9 @@ export class FileDiff { ); } - private mergeOptions(options: Partial>): void { + private mergeOptions( + options: Partial> + ): void { this.options = { ...this.options, ...options }; } @@ -408,6 +424,10 @@ export class FileDiff { this.lineAnnotations = lineAnnotations; } + public setDecorations(decorations: DiffDecorationItem[]): void { + this.decorations = decorations; + } + private canPartiallyRender( forceRender: boolean, annotationsChanged: boolean, @@ -487,12 +507,15 @@ export class FileDiff { this.workerManager?.subscribeToThemeChanges(this); } - public hydrate(props: FileDiffHydrationProps): void { + public hydrate( + props: FileDiffHydrationProps + ): void { const { fileContainer, prerenderedHTML, preventEmit = false, lineAnnotations, + decorations, oldFile, newFile, fileDiff, @@ -519,6 +542,7 @@ export class FileDiff { oldFile, newFile, lineAnnotations, + decorations, }); } if (!preventEmit) { @@ -593,11 +617,13 @@ export class FileDiff { oldFile, newFile, lineAnnotations, - }: HydrationSetup): void { + decorations, + }: HydrationSetup): void { // It's possible we are hydrating a pure-rename and therefore there will be // no pre element const { diffStyle = 'split', overflow = 'scroll' } = this.options; this.lineAnnotations = lineAnnotations ?? this.lineAnnotations; + this.decorations = decorations ?? this.decorations; this.additionFile = newFile; this.deletionFile = oldFile; this.fileDiff = @@ -610,6 +636,7 @@ export class FileDiff { return; } + this.hunksRenderer.setDecorations(this.decorations); this.hunksRenderer.hydrate(this.fileDiff); // FIXME(amadeus): not sure how to handle this yet... // this.renderSeparators(); @@ -672,10 +699,11 @@ export class FileDiff { forceRender = false, preventEmit = false, lineAnnotations, + decorations, fileContainer, containerWrapper, renderRange, - }: FileDiffRenderProps): boolean { + }: FileDiffRenderProps): boolean { if (!this.enabled) { // NOTE(amadeus): May need to be a silent failure? Making it loud for now // to better understand it @@ -685,6 +713,7 @@ export class FileDiff { } const { collapsed = false } = this.options; const nextRenderRange = collapsed ? undefined : renderRange; + const nextDecorations = decorations; const filesDidChange = oldFile != null && newFile != null && @@ -696,12 +725,18 @@ export class FileDiff { (lineAnnotations.length > 0 || this.lineAnnotations.length > 0) ? lineAnnotations !== this.lineAnnotations : false; + const decorationsChanged = + nextDecorations != null && + (nextDecorations.length > 0 || this.decorations.length > 0) + ? nextDecorations !== this.decorations + : false; if ( !collapsed && areRenderRangesEqual(nextRenderRange, this.renderRange) && !forceRender && !annotationsChanged && + !decorationsChanged && // If using the fileDiff API, lets check to see if they are equal to // avoid doing work ((fileDiff != null && fileDiff === this.fileDiff) || @@ -731,12 +766,16 @@ export class FileDiff { if (lineAnnotations != null) { this.setLineAnnotations(lineAnnotations); } + if (nextDecorations != null) { + this.decorations = nextDecorations; + } if (this.fileDiff == null) { return false; } this.hunksRenderer.setOptions(this.getHunksRendererOptions(this.options)); this.hunksRenderer.setLineAnnotations(this.lineAnnotations); + this.hunksRenderer.setDecorations(this.decorations); const { diffStyle = 'split', diff --git a/packages/diffs/src/components/UnresolvedFile.ts b/packages/diffs/src/components/UnresolvedFile.ts index 42acdfa01..ec7faea04 100644 --- a/packages/diffs/src/components/UnresolvedFile.ts +++ b/packages/diffs/src/components/UnresolvedFile.ts @@ -33,28 +33,31 @@ import { type FileDiffRenderProps, } from './FileDiff'; -export type RenderMergeConflictActions = ( +export type RenderMergeConflictActions = ( action: MergeConflictDiffAction, - instance: UnresolvedFile + instance: UnresolvedFile ) => HTMLElement | DocumentFragment | null | undefined; -export type MergeConflictActionsTypeOption = +export type MergeConflictActionsTypeOption = | 'none' | 'default' - | RenderMergeConflictActions; + | RenderMergeConflictActions; -export interface UnresolvedFileOptions extends Omit< - FileDiffOptions, - 'diffStyle' -> { +export interface UnresolvedFileOptions< + LAnnotation = undefined, + LDecoration = undefined, +> extends Omit, 'diffStyle'> { onPostRender?( node: HTMLElement, - instance: UnresolvedFile + instance: UnresolvedFile ): unknown; - mergeConflictActionsType?: MergeConflictActionsTypeOption; + mergeConflictActionsType?: MergeConflictActionsTypeOption< + LAnnotation, + LDecoration + >; onMergeConflictAction?( payload: MergeConflictActionPayload, - instance: UnresolvedFile + instance: UnresolvedFile ): void; onMergeConflictResolve?( file: FileContents, @@ -63,8 +66,11 @@ export interface UnresolvedFileOptions extends Omit< maxContextLines?: number; } -export interface UnresolvedFileRenderProps extends Omit< - FileDiffRenderProps, +export interface UnresolvedFileRenderProps< + LAnnotation, + LDecoration, +> extends Omit< + FileDiffRenderProps, 'oldFile' | 'newFile' > { file?: FileContents; @@ -72,10 +78,10 @@ export interface UnresolvedFileRenderProps extends Omit< markerRows?: MergeConflictMarkerRow[]; } -export interface UnresolvedFileHydrationProps extends Omit< - UnresolvedFileRenderProps, - 'file' -> { +export interface UnresolvedFileHydrationProps< + LAnnotation, + LDecoration, +> extends Omit, 'file'> { file?: FileContents; fileContainer: HTMLElement; prerenderedHTML?: string; @@ -112,7 +118,8 @@ let instanceId = -1; export class UnresolvedFile< LAnnotation = undefined, -> extends FileDiff { + LDecoration = undefined, +> extends FileDiff { override readonly __id: string = `unresolved-file:${++instanceId}`; protected computedCache: UnresolvedFileDataCache = { file: undefined, @@ -126,7 +133,7 @@ export class UnresolvedFile< new Map(); constructor( - public override options: UnresolvedFileOptions = { + public override options: UnresolvedFileOptions = { theme: DEFAULT_THEMES, }, workerManager?: WorkerPoolManager | undefined, @@ -137,7 +144,7 @@ export class UnresolvedFile< } override setOptions( - options: UnresolvedFileOptions | undefined + options: UnresolvedFileOptions | undefined ): void { if (options == null) { return; @@ -171,9 +178,9 @@ export class UnresolvedFile< } protected override createHunksRenderer( - options: UnresolvedFileOptions - ): UnresolvedFileHunksRenderer { - const renderer = new UnresolvedFileHunksRenderer( + options: UnresolvedFileOptions + ): UnresolvedFileHunksRenderer { + const renderer = new UnresolvedFileHunksRenderer( this.getHunksRendererOptions(options), this.handleHighlightRender, this.workerManager @@ -182,7 +189,7 @@ export class UnresolvedFile< } protected override getHunksRendererOptions( - options: UnresolvedFileOptions + options: UnresolvedFileOptions ): UnresolvedFileHunksRendererOptions { return getUnresolvedDiffHunksRendererOptions(options, this.options); } @@ -333,13 +340,16 @@ export class UnresolvedFile< return { fileDiff, actions, markerRows }; } - override hydrate(props: UnresolvedFileHydrationProps): void { + override hydrate( + props: UnresolvedFileHydrationProps + ): void { const { file, fileDiff, actions, markerRows, lineAnnotations, + decorations, fileContainer, prerenderedHTML, preventEmit = false, @@ -369,7 +379,11 @@ export class UnresolvedFile< } // Otherwise orchestrate our setup else { - this.hydrationSetup({ fileDiff: source.fileDiff, lineAnnotations }); + this.hydrationSetup({ + fileDiff: source.fileDiff, + lineAnnotations, + decorations, + }); if (this.pre != null) { this.renderMergeConflictActionSlots(); } @@ -386,13 +400,16 @@ export class UnresolvedFile< this.render({ forceRender: true, renderRange: this.renderRange }); } - override render(props: UnresolvedFileRenderProps = {}): boolean { + override render( + props: UnresolvedFileRenderProps = {} + ): boolean { let { file, fileDiff, actions, markerRows, lineAnnotations, + decorations, preventEmit = false, ...rest } = props; @@ -410,6 +427,7 @@ export class UnresolvedFile< ...rest, fileDiff: source.fileDiff, lineAnnotations, + decorations, preventEmit: true, }); if (didRender) { @@ -850,9 +868,9 @@ function shouldRenderHeader( // NOTE(amadeus): Should probably pull this out into a util, and make variants // for all component types -export function getUnresolvedDiffHunksRendererOptions( - options?: UnresolvedFileOptions, - baseOptions?: UnresolvedFileOptions +export function getUnresolvedDiffHunksRendererOptions( + options?: UnresolvedFileOptions, + baseOptions?: UnresolvedFileOptions ): UnresolvedFileHunksRendererOptions { return { ...baseOptions, diff --git a/packages/diffs/src/components/VirtualizedFile.ts b/packages/diffs/src/components/VirtualizedFile.ts index f4b1ec494..a7a00889c 100644 --- a/packages/diffs/src/components/VirtualizedFile.ts +++ b/packages/diffs/src/components/VirtualizedFile.ts @@ -14,7 +14,8 @@ let instanceId = -1; export class VirtualizedFile< LAnnotation = undefined, -> extends File { + LDecoration = undefined, +> extends File { override readonly __id: string = `virtualized-file:${++instanceId}`; public top: number | undefined; @@ -27,7 +28,7 @@ export class VirtualizedFile< private isSetup: boolean = false; constructor( - options: FileOptions | undefined, + options: FileOptions | undefined, private virtualizer: Virtualizer, private metrics: VirtualFileMetrics = DEFAULT_VIRTUAL_FILE_METRICS, workerManager?: WorkerPoolManager, @@ -49,7 +50,9 @@ export class VirtualizedFile< } // Override setOptions to clear height cache when overflow changes - override setOptions(options: FileOptions | undefined): void { + override setOptions( + options: FileOptions | undefined + ): void { if (options == null) return; const previousOverflow = this.options.overflow; const previousCollapsed = this.options.collapsed; @@ -245,7 +248,7 @@ export class VirtualizedFile< fileContainer, file, ...props - }: FileRenderProps): boolean { + }: FileRenderProps): boolean { const { isSetup } = this; this.file ??= file; diff --git a/packages/diffs/src/components/VirtualizedFileDiff.ts b/packages/diffs/src/components/VirtualizedFileDiff.ts index c05e89aef..89276216d 100644 --- a/packages/diffs/src/components/VirtualizedFileDiff.ts +++ b/packages/diffs/src/components/VirtualizedFileDiff.ts @@ -28,7 +28,8 @@ let instanceId = -1; export class VirtualizedFileDiff< LAnnotation = undefined, -> extends FileDiff { + LDecoration = undefined, +> extends FileDiff { override readonly __id: string = `little-virtualized-file-diff:${++instanceId}`; public top: number | undefined; @@ -42,7 +43,7 @@ export class VirtualizedFileDiff< private virtualizer: Virtualizer; constructor( - options: FileDiffOptions | undefined, + options: FileDiffOptions | undefined, virtualizer: Virtualizer, metrics?: Partial, workerManager?: WorkerPoolManager, @@ -69,7 +70,9 @@ export class VirtualizedFileDiff< } // Override setOptions to clear height cache when diffStyle changes - override setOptions(options: FileDiffOptions | undefined): void { + override setOptions( + options: FileDiffOptions | undefined + ): void { if (options == null) return; const previousDiffStyle = this.options.diffStyle; const previousOverflow = this.options.overflow; @@ -341,7 +344,7 @@ export class VirtualizedFileDiff< newFile, fileDiff, ...props - }: FileDiffRenderProps = {}): boolean { + }: FileDiffRenderProps = {}): boolean { const { isSetup } = this; this.fileDiff ??= diff --git a/packages/diffs/src/components/VirtulizerDevelopment.d.ts b/packages/diffs/src/components/VirtulizerDevelopment.d.ts index 74f56849c..7fe0ab72e 100644 --- a/packages/diffs/src/components/VirtulizerDevelopment.d.ts +++ b/packages/diffs/src/components/VirtulizerDevelopment.d.ts @@ -5,7 +5,7 @@ import type { Virtualizer } from './Virtualizer'; declare global { interface Window { // oxlint-disable-next-line typescript/no-explicit-any - __INSTANCE?: AdvancedVirtualizer | Virtualizer; + __INSTANCE?: AdvancedVirtualizer | Virtualizer; __TOGGLE?: () => void; __LOG?: boolean; } diff --git a/packages/diffs/src/react/File.tsx b/packages/diffs/src/react/File.tsx index 11727a8d3..116669a8c 100644 --- a/packages/diffs/src/react/File.tsx +++ b/packages/diffs/src/react/File.tsx @@ -9,9 +9,10 @@ import { useFileInstance } from './utils/useFileInstance'; export type { FileOptions }; -export function File({ +export function File({ file, lineAnnotations, + decorations, selectedLines, options, metrics, @@ -25,12 +26,13 @@ export function File({ renderGutterUtility, renderHoverUtility, disableWorkerPool = false, -}: FileProps): React.JSX.Element { +}: FileProps): React.JSX.Element { const { ref, getHoveredLine } = useFileInstance({ file, options, metrics, lineAnnotations, + decorations, selectedLines, prerenderedHTML, hasGutterRenderUtility: diff --git a/packages/diffs/src/react/FileDiff.tsx b/packages/diffs/src/react/FileDiff.tsx index 85096fe09..92e463b6a 100644 --- a/packages/diffs/src/react/FileDiff.tsx +++ b/packages/diffs/src/react/FileDiff.tsx @@ -11,16 +11,18 @@ export type { FileDiffMetadata }; export interface FileDiffProps< LAnnotation, -> extends DiffBasePropsReact { + LDecoration, +> extends DiffBasePropsReact { fileDiff: FileDiffMetadata; disableWorkerPool?: boolean; } -export function FileDiff({ +export function FileDiff({ fileDiff, options, metrics, lineAnnotations, + decorations, selectedLines, className, style, @@ -32,12 +34,13 @@ export function FileDiff({ renderGutterUtility, renderHoverUtility, disableWorkerPool = false, -}: FileDiffProps): React.JSX.Element { +}: FileDiffProps): React.JSX.Element { const { ref, getHoveredLine } = useFileDiffInstance({ fileDiff, options, metrics, lineAnnotations, + decorations, selectedLines, prerenderedHTML, hasGutterRenderUtility: diff --git a/packages/diffs/src/react/MultiFileDiff.tsx b/packages/diffs/src/react/MultiFileDiff.tsx index d08aafec3..b61d7017f 100644 --- a/packages/diffs/src/react/MultiFileDiff.tsx +++ b/packages/diffs/src/react/MultiFileDiff.tsx @@ -14,18 +14,23 @@ export type { FileContents }; export interface MultiFileDiffProps< LAnnotation, -> extends DiffBasePropsReact { + LDecoration, +> extends DiffBasePropsReact { oldFile: FileContents; newFile: FileContents; disableWorkerPool?: boolean; } -export function MultiFileDiff({ +export function MultiFileDiff< + LAnnotation = undefined, + LDecoration = undefined, +>({ oldFile, newFile, options, metrics, lineAnnotations, + decorations, selectedLines, className, style, @@ -37,7 +42,7 @@ export function MultiFileDiff({ renderGutterUtility, renderHoverUtility, disableWorkerPool = false, -}: MultiFileDiffProps): React.JSX.Element { +}: MultiFileDiffProps): React.JSX.Element { const fileDiff = useMemo(() => { return parseDiffFromFile(oldFile, newFile, options?.parseDiffOptions); }, [oldFile, newFile, options?.parseDiffOptions]); @@ -46,6 +51,7 @@ export function MultiFileDiff({ options, metrics, lineAnnotations, + decorations, selectedLines, prerenderedHTML, hasGutterRenderUtility: diff --git a/packages/diffs/src/react/PatchDiff.tsx b/packages/diffs/src/react/PatchDiff.tsx index 515141045..4b6c18c20 100644 --- a/packages/diffs/src/react/PatchDiff.tsx +++ b/packages/diffs/src/react/PatchDiff.tsx @@ -12,16 +12,18 @@ import { useFileDiffInstance } from './utils/useFileDiffInstance'; export interface PatchDiffProps< LAnnotation, -> extends DiffBasePropsReact { + LDecoration, +> extends DiffBasePropsReact { patch: string; disableWorkerPool?: boolean; } -export function PatchDiff({ +export function PatchDiff({ patch, options, metrics, lineAnnotations, + decorations, selectedLines, className, style, @@ -33,13 +35,14 @@ export function PatchDiff({ renderGutterUtility, renderHoverUtility, disableWorkerPool = false, -}: PatchDiffProps): React.JSX.Element { +}: PatchDiffProps): React.JSX.Element { const fileDiff = usePatch(patch); const { ref, getHoveredLine } = useFileDiffInstance({ fileDiff, options, metrics, lineAnnotations, + decorations, selectedLines, prerenderedHTML, hasGutterRenderUtility: diff --git a/packages/diffs/src/react/UnresolvedFile.tsx b/packages/diffs/src/react/UnresolvedFile.tsx index b6f601511..fb4b9e130 100644 --- a/packages/diffs/src/react/UnresolvedFile.tsx +++ b/packages/diffs/src/react/UnresolvedFile.tsx @@ -31,38 +31,45 @@ export type MergeConflictActionsTypeOption = | 'default' | RenderMergeConflictActions; -export interface UnresolvedFileReactOptions +export interface UnresolvedFileReactOptions< + LAnnotation = undefined, + LDecoration = undefined, +> extends Omit< - FileDiffOptions, + FileDiffOptions, 'hunkSeparators' | 'diffStyle' | 'onMergeConflictAction' | 'onPostRender' >, UnresolvedFileHunksRendererOptions { hunkSeparators?: HunkSeparators; onPostRender?( node: HTMLElement, - instance: UnresolvedFileClass + instance: UnresolvedFileClass ): unknown; maxContextLines?: number; } -export interface UnresolvedFileProps extends Omit< - FileDiffProps, +export interface UnresolvedFileProps extends Omit< + FileDiffProps, 'fileDiff' | 'options' > { file: FileContents; - options?: UnresolvedFileReactOptions; + options?: UnresolvedFileReactOptions; renderMergeConflictUtility?( action: MergeConflictDiffAction, - getInstance: () => UnresolvedFileClass | undefined + getInstance: () => UnresolvedFileClass | undefined ): ReactNode; disableWorkerPool?: boolean; } -export function UnresolvedFile({ +export function UnresolvedFile< + LAnnotation = undefined, + LDecoration = undefined, +>({ file, options, lineAnnotations, + decorations, selectedLines, className, style, @@ -75,12 +82,13 @@ export function UnresolvedFile({ renderHoverUtility, renderMergeConflictUtility, disableWorkerPool = false, -}: UnresolvedFileProps): React.JSX.Element { +}: UnresolvedFileProps): React.JSX.Element { const { ref, getHoveredLine, fileDiff, actions, getInstance } = - useUnresolvedFileInstance({ + useUnresolvedFileInstance({ file, options, lineAnnotations, + decorations, selectedLines, prerenderedHTML, hasConflictUtility: renderMergeConflictUtility != null, diff --git a/packages/diffs/src/react/types.ts b/packages/diffs/src/react/types.ts index 47ee19078..837d6af69 100644 --- a/packages/diffs/src/react/types.ts +++ b/packages/diffs/src/react/types.ts @@ -7,17 +7,20 @@ import type { SelectedLineRange, } from '../managers/InteractionManager'; import type { + DiffDecorationItem, DiffLineAnnotation, FileContents, + FileDecorationItem, FileDiffMetadata, LineAnnotation, VirtualFileMetrics, } from '../types'; -export interface DiffBasePropsReact { - options?: FileDiffOptions; +export interface DiffBasePropsReact { + options?: FileDiffOptions; metrics?: VirtualFileMetrics; lineAnnotations?: DiffLineAnnotation[]; + decorations?: DiffDecorationItem[]; selectedLines?: SelectedLineRange | null; renderAnnotation?(annotations: DiffLineAnnotation): ReactNode; renderCustomHeader?(fileDiff: FileDiffMetadata): ReactNode; @@ -37,11 +40,12 @@ export interface DiffBasePropsReact { prerenderedHTML?: string; } -export interface FileProps { +export interface FileProps { file: FileContents; - options?: FileOptions; + options?: FileOptions; metrics?: VirtualFileMetrics; lineAnnotations?: LineAnnotation[]; + decorations?: FileDecorationItem[]; selectedLines?: SelectedLineRange | null; renderAnnotation?(annotations: LineAnnotation): ReactNode; renderCustomHeader?(file: FileContents): ReactNode; diff --git a/packages/diffs/src/react/utils/renderDiffChildren.tsx b/packages/diffs/src/react/utils/renderDiffChildren.tsx index edab811c0..5ca813a25 100644 --- a/packages/diffs/src/react/utils/renderDiffChildren.tsx +++ b/packages/diffs/src/react/utils/renderDiffChildren.tsx @@ -16,25 +16,46 @@ import { import { GutterUtilitySlotStyles, MergeConflictSlotStyles } from '../constants'; import type { DiffBasePropsReact } from '../types'; -interface RenderDiffChildrenProps { +interface RenderDiffChildrenProps { fileDiff: FileDiffMetadata; actions?: (MergeConflictDiffAction | undefined)[]; - renderCustomHeader: DiffBasePropsReact['renderCustomHeader']; - renderHeaderPrefix: DiffBasePropsReact['renderHeaderPrefix']; - renderHeaderMetadata: DiffBasePropsReact['renderHeaderMetadata']; - renderAnnotation: DiffBasePropsReact['renderAnnotation']; - renderGutterUtility: DiffBasePropsReact['renderGutterUtility']; - renderHoverUtility: DiffBasePropsReact['renderHoverUtility']; + renderCustomHeader: DiffBasePropsReact< + LAnnotation, + LDecoration + >['renderCustomHeader']; + renderHeaderPrefix: DiffBasePropsReact< + LAnnotation, + LDecoration + >['renderHeaderPrefix']; + renderHeaderMetadata: DiffBasePropsReact< + LAnnotation, + LDecoration + >['renderHeaderMetadata']; + renderAnnotation: DiffBasePropsReact< + LAnnotation, + LDecoration + >['renderAnnotation']; + renderGutterUtility: DiffBasePropsReact< + LAnnotation, + LDecoration + >['renderGutterUtility']; + renderHoverUtility: DiffBasePropsReact< + LAnnotation, + LDecoration + >['renderHoverUtility']; renderMergeConflictUtility?( action: MergeConflictDiffAction, getInstance: () => T | undefined ): ReactNode; - lineAnnotations: DiffBasePropsReact['lineAnnotations']; + lineAnnotations: DiffBasePropsReact< + LAnnotation, + LDecoration + >['lineAnnotations']; getHoveredLine(): GetHoveredLineResult<'diff'> | undefined; getInstance?(): T | undefined; } -export function renderDiffChildren({ +export function renderDiffChildren({ fileDiff, actions, renderCustomHeader, @@ -47,7 +68,7 @@ export function renderDiffChildren({ lineAnnotations, getHoveredLine, getInstance, -}: RenderDiffChildrenProps): ReactNode { +}: RenderDiffChildrenProps): ReactNode { const gutterUtility = renderGutterUtility ?? renderHoverUtility; const customHeader = renderCustomHeader?.(fileDiff); const prefix = renderHeaderPrefix?.(fileDiff); diff --git a/packages/diffs/src/react/utils/renderFileChildren.tsx b/packages/diffs/src/react/utils/renderFileChildren.tsx index 388d19f3d..9841db995 100644 --- a/packages/diffs/src/react/utils/renderFileChildren.tsx +++ b/packages/diffs/src/react/utils/renderFileChildren.tsx @@ -11,19 +11,25 @@ import { getLineAnnotationName } from '../../utils/getLineAnnotationName'; import { GutterUtilitySlotStyles } from '../constants'; import type { FileProps } from '../types'; -interface RenderFileChildrenProps { +interface RenderFileChildrenProps { file: FileContents; - renderCustomHeader: FileProps['renderCustomHeader']; - renderHeaderPrefix: FileProps['renderHeaderPrefix']; - renderHeaderMetadata: FileProps['renderHeaderMetadata']; - renderAnnotation: FileProps['renderAnnotation']; - lineAnnotations: FileProps['lineAnnotations']; - renderGutterUtility: FileProps['renderGutterUtility']; - renderHoverUtility: FileProps['renderHoverUtility']; + renderCustomHeader: FileProps['renderCustomHeader']; + renderHeaderPrefix: FileProps['renderHeaderPrefix']; + renderHeaderMetadata: FileProps< + LAnnotation, + LDecoration + >['renderHeaderMetadata']; + renderAnnotation: FileProps['renderAnnotation']; + lineAnnotations: FileProps['lineAnnotations']; + renderGutterUtility: FileProps< + LAnnotation, + LDecoration + >['renderGutterUtility']; + renderHoverUtility: FileProps['renderHoverUtility']; getHoveredLine(): GetHoveredLineResult<'file'> | undefined; } -export function renderFileChildren({ +export function renderFileChildren({ file, renderCustomHeader, renderHeaderPrefix, @@ -33,7 +39,7 @@ export function renderFileChildren({ renderGutterUtility, renderHoverUtility, getHoveredLine, -}: RenderFileChildrenProps): ReactNode { +}: RenderFileChildrenProps): ReactNode { const gutterUtility = renderGutterUtility ?? renderHoverUtility; const customHeader = renderCustomHeader?.(file); const prefix = renderHeaderPrefix?.(file); diff --git a/packages/diffs/src/react/utils/useFileDiffInstance.ts b/packages/diffs/src/react/utils/useFileDiffInstance.ts index 4b417717f..b86ea2646 100644 --- a/packages/diffs/src/react/utils/useFileDiffInstance.ts +++ b/packages/diffs/src/react/utils/useFileDiffInstance.ts @@ -13,6 +13,7 @@ import type { SelectedLineRange, } from '../../managers/InteractionManager'; import type { + DiffDecorationItem, DiffLineAnnotation, FileDiffMetadata, VirtualFileMetrics, @@ -26,10 +27,11 @@ import { useStableCallback } from './useStableCallback'; const useIsometricEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; -interface UseFileDiffInstanceProps { +interface UseFileDiffInstanceProps { fileDiff: FileDiffMetadata; - options: FileDiffOptions | undefined; + options: FileDiffOptions | undefined; lineAnnotations: DiffLineAnnotation[] | undefined; + decorations: DiffDecorationItem[] | undefined; selectedLines: SelectedLineRange | null | undefined; prerenderedHTML: string | undefined; metrics?: VirtualFileMetrics; @@ -43,21 +45,27 @@ interface UseFileDiffInstanceReturn { getHoveredLine(): GetHoveredLineResult<'diff'> | undefined; } -export function useFileDiffInstance({ +export function useFileDiffInstance({ fileDiff, options, lineAnnotations, + decorations, selectedLines, prerenderedHTML, metrics, hasGutterRenderUtility, hasCustomHeader, disableWorkerPool, -}: UseFileDiffInstanceProps): UseFileDiffInstanceReturn { +}: UseFileDiffInstanceProps< + LAnnotation, + LDecoration +>): UseFileDiffInstanceReturn { const simpleVirtualizer = useVirtualizer(); const poolManager = useContext(WorkerPoolContext); const instanceRef = useRef< - FileDiff | VirtualizedFileDiff | null + | FileDiff + | VirtualizedFileDiff + | null >(null); const ref = useStableCallback((fileContainer: HTMLElement | null) => { if (fileContainer != null) { @@ -93,6 +101,7 @@ export function useFileDiffInstance({ fileDiff, fileContainer, lineAnnotations, + decorations, prerenderedHTML, }); } else { @@ -120,6 +129,7 @@ export function useFileDiffInstance({ forceRender, fileDiff, lineAnnotations, + decorations, }); if (selectedLines !== undefined) { instance.setSelectedLines(selectedLines); @@ -135,18 +145,18 @@ export function useFileDiffInstance({ return { ref, getHoveredLine }; } -interface MergeFileDiffOptionsProps { +interface MergeFileDiffOptionsProps { hasCustomHeader: boolean; hasGutterRenderUtility: boolean; - options: FileDiffOptions | undefined; + options: FileDiffOptions | undefined; } -function mergeFileDiffOptions({ +function mergeFileDiffOptions({ options, hasCustomHeader, hasGutterRenderUtility, -}: MergeFileDiffOptionsProps): - | FileDiffOptions +}: MergeFileDiffOptionsProps): + | FileDiffOptions | undefined { if (hasGutterRenderUtility || hasCustomHeader) { return { diff --git a/packages/diffs/src/react/utils/useFileInstance.ts b/packages/diffs/src/react/utils/useFileInstance.ts index 9b0971594..64702f070 100644 --- a/packages/diffs/src/react/utils/useFileInstance.ts +++ b/packages/diffs/src/react/utils/useFileInstance.ts @@ -14,6 +14,7 @@ import type { } from '../../managers/InteractionManager'; import type { FileContents, + FileDecorationItem, LineAnnotation, VirtualFileMetrics, } from '../../types'; @@ -26,10 +27,11 @@ import { useStableCallback } from './useStableCallback'; const useIsometricEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; -interface UseFileInstanceProps { +interface UseFileInstanceProps { file: FileContents; - options: FileOptions | undefined; + options: FileOptions | undefined; lineAnnotations: LineAnnotation[] | undefined; + decorations: FileDecorationItem[] | undefined; selectedLines: SelectedLineRange | null | undefined; prerenderedHTML: string | undefined; metrics?: VirtualFileMetrics; @@ -43,21 +45,24 @@ interface UseFileInstanceReturn { getHoveredLine(): GetHoveredLineResult<'file'> | undefined; } -export function useFileInstance({ +export function useFileInstance({ file, options, lineAnnotations, + decorations, selectedLines, prerenderedHTML, metrics, hasGutterRenderUtility, hasCustomHeader, disableWorkerPool, -}: UseFileInstanceProps): UseFileInstanceReturn { +}: UseFileInstanceProps): UseFileInstanceReturn { const simpleVirtualizer = useVirtualizer(); const poolManager = useContext(WorkerPoolContext); const instanceRef = useRef< - File | VirtualizedFile | null + | File + | VirtualizedFile + | null >(null); const ref = useStableCallback((node: HTMLElement | null) => { if (node != null) { @@ -93,6 +98,7 @@ export function useFileInstance({ file, fileContainer: node, lineAnnotations, + decorations, prerenderedHTML, }); } else { @@ -116,7 +122,12 @@ export function useFileInstance({ newOptions ); instanceRef.current.setOptions(newOptions); - void instanceRef.current.render({ file, lineAnnotations, forceRender }); + void instanceRef.current.render({ + file, + lineAnnotations, + decorations, + forceRender, + }); if (selectedLines !== undefined) { instanceRef.current.setSelectedLines(selectedLines); } @@ -130,17 +141,19 @@ export function useFileInstance({ return { ref, getHoveredLine }; } -interface MergeFileOptionsProps { - options: FileOptions | undefined; +interface MergeFileOptionsProps { + options: FileOptions | undefined; hasGutterRenderUtility: boolean; hasCustomHeader: boolean; } -function mergeFileOptions({ +function mergeFileOptions({ options, hasCustomHeader, hasGutterRenderUtility, -}: MergeFileOptionsProps): FileOptions | undefined { +}: MergeFileOptionsProps): + | FileOptions + | undefined { if (hasGutterRenderUtility || hasCustomHeader) { return { ...options, diff --git a/packages/diffs/src/react/utils/useUnresolvedFileInstance.ts b/packages/diffs/src/react/utils/useUnresolvedFileInstance.ts index 3de0e786c..8945e6188 100644 --- a/packages/diffs/src/react/utils/useUnresolvedFileInstance.ts +++ b/packages/diffs/src/react/utils/useUnresolvedFileInstance.ts @@ -17,6 +17,7 @@ import type { SelectedLineRange, } from '../../managers/InteractionManager'; import type { + DiffDecorationItem, DiffLineAnnotation, FileContents, FileDiffMetadata, @@ -36,10 +37,11 @@ import { useStableCallback } from './useStableCallback'; const useIsometricEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; -interface UseUnresolvedFileInstanceProps { +interface UseUnresolvedFileInstanceProps { file: FileContents; - options?: UnresolvedFileReactOptions; + options?: UnresolvedFileReactOptions; lineAnnotations: DiffLineAnnotation[] | undefined; + decorations: DiffDecorationItem[] | undefined; selectedLines: SelectedLineRange | null | undefined; prerenderedHTML: string | undefined; hasConflictUtility: boolean; @@ -48,26 +50,30 @@ interface UseUnresolvedFileInstanceProps { disableWorkerPool: boolean; } -interface UseUnresolvedFileInstanceReturn { +interface UseUnresolvedFileInstanceReturn { fileDiff: FileDiffMetadata; actions: (MergeConflictDiffAction | undefined)[]; markerRows: MergeConflictMarkerRow[]; ref(node: HTMLElement | null): void; getHoveredLine(): GetHoveredLineResult<'diff'> | undefined; - getInstance(): UnresolvedFile | undefined; + getInstance(): UnresolvedFile | undefined; } -export function useUnresolvedFileInstance({ +export function useUnresolvedFileInstance({ file, options, lineAnnotations, + decorations, selectedLines, prerenderedHTML, hasConflictUtility, hasGutterRenderUtility, hasCustomHeader, disableWorkerPool, -}: UseUnresolvedFileInstanceProps): UseUnresolvedFileInstanceReturn { +}: UseUnresolvedFileInstanceProps< + LAnnotation, + LDecoration +>): UseUnresolvedFileInstanceReturn { const [{ fileDiff, actions, markerRows }, setState] = useState(() => { const { fileDiff, actions, markerRows } = parseMergeConflictDiffFromFile( file, @@ -81,7 +87,7 @@ export function useUnresolvedFileInstance({ const onMergeConflictAction = useStableCallback( ( payload: MergeConflictActionPayload, - instance: UnresolvedFile + instance: UnresolvedFile ) => { setState((prevState) => { const { fileDiff, actions, markerRows } = @@ -99,7 +105,10 @@ export function useUnresolvedFileInstance({ } ); const poolManager = useContext(WorkerPoolContext); - const instanceRef = useRef | null>(null); + const instanceRef = useRef | null>(null); const ref = useStableCallback((fileContainer: HTMLElement | null) => { if (fileContainer != null) { if (instanceRef.current != null) { @@ -124,6 +133,7 @@ export function useUnresolvedFileInstance({ markerRows, fileContainer, lineAnnotations, + decorations, prerenderedHTML, }); } else { @@ -154,6 +164,7 @@ export function useUnresolvedFileInstance({ actions, markerRows, lineAnnotations, + decorations, forceRender, }); if (selectedLines !== undefined) { @@ -174,21 +185,27 @@ export function useUnresolvedFileInstance({ return { ref, getHoveredLine, fileDiff, actions, markerRows, getInstance }; } -interface MergeUnresolvedOptionsProps { - options: UnresolvedFileReactOptions | undefined; - onMergeConflictAction: UnresolvedFileOptions['onMergeConflictAction']; +interface MergeUnresolvedOptionsProps { + options: UnresolvedFileReactOptions | undefined; + onMergeConflictAction: UnresolvedFileOptions< + LAnnotation, + LDecoration + >['onMergeConflictAction']; hasConflictUtility: boolean; hasGutterRenderUtility: boolean; hasCustomHeader: boolean; } -function mergeUnresolvedOptions({ +function mergeUnresolvedOptions({ options, onMergeConflictAction, hasConflictUtility, hasCustomHeader, hasGutterRenderUtility, -}: MergeUnresolvedOptionsProps): UnresolvedFileOptions { +}: MergeUnresolvedOptionsProps< + LAnnotation, + LDecoration +>): UnresolvedFileOptions { return { ...options, onMergeConflictAction, diff --git a/packages/diffs/src/renderers/DiffHunksRenderer.ts b/packages/diffs/src/renderers/DiffHunksRenderer.ts index 6cc71784a..092008bd6 100644 --- a/packages/diffs/src/renderers/DiffHunksRenderer.ts +++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts @@ -20,6 +20,7 @@ import type { BaseDiffOptionsWithDefaults, CodeColumnType, CustomPreProperties, + DiffDecorationItem, DiffLineAnnotation, DiffsHighlighter, ExpansionDirections, @@ -197,7 +198,10 @@ export interface HunksRenderResult { let instanceId = -1; -export class DiffHunksRenderer { +export class DiffHunksRenderer< + LAnnotation = undefined, + LDecoration = undefined, +> { readonly __id: string = `diff-hunks-renderer:${++instanceId}`; private highlighter: DiffsHighlighter | undefined; @@ -301,6 +305,10 @@ export class DiffHunksRenderer { } } + public setDecorations( + _decorations: readonly DiffDecorationItem[] + ): void {} + protected getUnifiedLineDecoration({ lineType, }: UnifiedLineDecorationProps): LineDecoration { diff --git a/packages/diffs/src/renderers/FileRenderer.ts b/packages/diffs/src/renderers/FileRenderer.ts index 98e6dff3b..a00064366 100644 --- a/packages/diffs/src/renderers/FileRenderer.ts +++ b/packages/diffs/src/renderers/FileRenderer.ts @@ -13,6 +13,7 @@ import type { BaseCodeOptions, DiffsHighlighter, FileContents, + FileDecorationItem, FileHeaderRenderMode, LineAnnotation, RenderedFileASTCache, @@ -80,7 +81,7 @@ export interface FileRendererOptions extends BaseCodeOptions { let instanceId = -1; -export class FileRenderer { +export class FileRenderer { readonly __id: string = `file-renderer:${++instanceId}`; private highlighter: DiffsHighlighter | undefined; @@ -120,6 +121,10 @@ export class FileRenderer { } } + public setDecorations( + _decorations: readonly FileDecorationItem[] + ): void {} + public cleanUp(): void { this.renderCache = undefined; this.highlighter = undefined; diff --git a/packages/diffs/src/renderers/UnresolvedFileHunksRenderer.ts b/packages/diffs/src/renderers/UnresolvedFileHunksRenderer.ts index beb45fe42..191f3f8f7 100644 --- a/packages/diffs/src/renderers/UnresolvedFileHunksRenderer.ts +++ b/packages/diffs/src/renderers/UnresolvedFileHunksRenderer.ts @@ -72,7 +72,8 @@ export interface UnresolvedFileHunksRendererOptions extends DiffHunksRendererOpt export class UnresolvedFileHunksRenderer< LAnnotation = undefined, -> extends DiffHunksRenderer { + LDecoration = undefined, +> extends DiffHunksRenderer { private pendingConflictActions: (MergeConflictDiffAction | undefined)[] = []; private pendingMarkerRows: MergeConflictMarkerRow[] = []; private injectedRows = new Map(); diff --git a/packages/diffs/src/ssr/preloadDiffs.ts b/packages/diffs/src/ssr/preloadDiffs.ts index 4ee40b75b..247133d16 100644 --- a/packages/diffs/src/ssr/preloadDiffs.ts +++ b/packages/diffs/src/ssr/preloadDiffs.ts @@ -10,6 +10,7 @@ import { } from '../renderers/DiffHunksRenderer'; import { UnresolvedFileHunksRenderer } from '../renderers/UnresolvedFileHunksRenderer'; import type { + DiffDecorationItem, DiffLineAnnotation, FileContents, FileDiffMetadata, @@ -24,21 +25,29 @@ import { parseDiffFromFile } from '../utils/parseDiffFromFile'; import { parseMergeConflictDiffFromFile } from '../utils/parseMergeConflictDiffFromFile'; import { renderHTML } from './renderHTML'; -export interface PreloadDiffOptions { +export interface PreloadDiffOptions< + LAnnotation = undefined, + LDecoration = undefined, +> { fileDiff?: FileDiffMetadata; oldFile?: FileContents; newFile?: FileContents; - options?: FileDiffOptions; + options?: FileDiffOptions; annotations?: DiffLineAnnotation[]; + decorations?: DiffDecorationItem[]; } -export async function preloadDiffHTML({ +export async function preloadDiffHTML< + LAnnotation = undefined, + LDecoration = undefined, +>({ fileDiff, oldFile, newFile, options, annotations, -}: PreloadDiffOptions): Promise { + decorations, +}: PreloadDiffOptions): Promise { if (fileDiff == null && oldFile != null && newFile != null) { fileDiff = parseDiffFromFile(oldFile, newFile, options?.parseDiffOptions); } @@ -47,12 +56,15 @@ export async function preloadDiffHTML({ 'preloadFileDiff: You must pass at least a fileDiff prop or oldFile/newFile props' ); } - const renderer = new DiffHunksRenderer( + const renderer = new DiffHunksRenderer( getHunksRendererOptions(options) ); if (annotations != null && annotations.length > 0) { renderer.setLineAnnotations(annotations); } + if (decorations != null && decorations.length > 0) { + renderer.setDecorations(decorations); + } return renderHTML( processHunkResult( await renderer.asyncRender(fileDiff), @@ -63,21 +75,28 @@ export async function preloadDiffHTML({ ); } -export async function preloadUnresolvedFileHTML({ +export async function preloadUnresolvedFileHTML< + LAnnotation = undefined, + LDecoration = undefined, +>({ file, options, annotations, -}: PreloadUnresolvedFileOptions): Promise { + decorations, +}: PreloadUnresolvedFileOptions): Promise { const { fileDiff, actions, markerRows } = parseMergeConflictDiffFromFile( file, options?.maxContextLines ); - const renderer = new UnresolvedFileHunksRenderer( + const renderer = new UnresolvedFileHunksRenderer( getUnresolvedDiffHunksRendererOptions(options) ); if (annotations != null && annotations.length > 0) { renderer.setLineAnnotations(annotations); } + if (decorations != null && decorations.length > 0) { + renderer.setDecorations(decorations); + } renderer.setConflictState(actions, markerRows, fileDiff); return renderHTML( processHunkResult( @@ -89,143 +108,184 @@ export async function preloadUnresolvedFileHTML({ ); } -export interface PreloadMultiFileDiffOptions { +export interface PreloadMultiFileDiffOptions< + LAnnotation = undefined, + LDecoration = undefined, +> { oldFile: FileContents; newFile: FileContents; - options?: FileDiffOptions; + options?: FileDiffOptions; annotations?: DiffLineAnnotation[]; + decorations?: DiffDecorationItem[]; } export interface PreloadMultiFileDiffResult< - LAnnotation, -> extends PreloadMultiFileDiffOptions { + LAnnotation = undefined, + LDecoration = undefined, +> extends PreloadMultiFileDiffOptions { prerenderedHTML: string; } -export async function preloadMultiFileDiff({ +export async function preloadMultiFileDiff< + LAnnotation = undefined, + LDecoration = undefined, +>({ oldFile, newFile, options, annotations, -}: PreloadMultiFileDiffOptions): Promise< - PreloadMultiFileDiffResult + decorations, +}: PreloadMultiFileDiffOptions): Promise< + PreloadMultiFileDiffResult > { return { newFile, oldFile, options, annotations, + decorations, prerenderedHTML: await preloadDiffHTML({ oldFile, newFile, options, annotations, + decorations, }), }; } -export interface PreloadFileDiffOptions { +export interface PreloadFileDiffOptions< + LAnnotation = undefined, + LDecoration = undefined, +> { fileDiff: FileDiffMetadata; - options?: FileDiffOptions; + options?: FileDiffOptions; annotations?: DiffLineAnnotation[]; + decorations?: DiffDecorationItem[]; } export interface PreloadFileDiffResult< - LAnnotation, -> extends PreloadFileDiffOptions { + LAnnotation = undefined, + LDecoration = undefined, +> extends PreloadFileDiffOptions { prerenderedHTML: string; } -export async function preloadFileDiff({ +export async function preloadFileDiff< + LAnnotation = undefined, + LDecoration = undefined, +>({ fileDiff, options, annotations, -}: PreloadFileDiffOptions): Promise< - PreloadFileDiffResult + decorations, +}: PreloadFileDiffOptions): Promise< + PreloadFileDiffResult > { return { fileDiff, options, annotations, + decorations, prerenderedHTML: await preloadDiffHTML({ fileDiff, options, annotations, + decorations, }), }; } -export interface PreloadUnresolvedFileOptions { +export interface PreloadUnresolvedFileOptions< + LAnnotation = undefined, + LDecoration = undefined, +> { file: FileContents; options?: Omit< - UnresolvedFileOptions, + UnresolvedFileOptions, 'onMergeConflictAction' | 'onMergeConflictResolve' | 'onPostRender' >; annotations?: DiffLineAnnotation[]; + decorations?: DiffDecorationItem[]; } export interface PreloadUnresolvedFileResult< - LAnnotation, -> extends PreloadUnresolvedFileOptions { + LAnnotation = undefined, + LDecoration = undefined, +> extends PreloadUnresolvedFileOptions { prerenderedHTML: string; } -export async function preloadUnresolvedFile({ +export async function preloadUnresolvedFile< + LAnnotation = undefined, + LDecoration = undefined, +>({ file, options, annotations, -}: PreloadUnresolvedFileOptions): Promise< - PreloadUnresolvedFileResult + decorations, +}: PreloadUnresolvedFileOptions): Promise< + PreloadUnresolvedFileResult > { return { file, options, annotations, + decorations, prerenderedHTML: await preloadUnresolvedFileHTML({ file, options, annotations, + decorations, }), }; } -export interface PreloadPatchDiffOptions { +export interface PreloadPatchDiffOptions { patch: string; - options?: FileDiffOptions; + options?: FileDiffOptions; annotations?: DiffLineAnnotation[]; + decorations?: DiffDecorationItem[]; } export interface PreloadPatchDiffResult< LAnnotation, -> extends PreloadPatchDiffOptions { + LDecoration, +> extends PreloadPatchDiffOptions { prerenderedHTML: string; } -export async function preloadPatchDiff({ +export async function preloadPatchDiff< + LAnnotation = undefined, + LDecoration = undefined, +>({ patch, options, annotations, -}: PreloadPatchDiffOptions): Promise< - PreloadPatchDiffResult + decorations, +}: PreloadPatchDiffOptions): Promise< + PreloadPatchDiffResult > { const fileDiff = getSingularPatch(patch); return { patch, options, annotations, + decorations, prerenderedHTML: await preloadDiffHTML({ fileDiff, options, annotations, + decorations, }), }; } -function processHunkResult( +function processHunkResult( hunkResult: HunksRenderResult, renderer: - | DiffHunksRenderer - | UnresolvedFileHunksRenderer, + | DiffHunksRenderer + | UnresolvedFileHunksRenderer, unsafeCSS: string | undefined, themeType: 'system' | 'light' | 'dark' ) { @@ -250,8 +310,8 @@ function processHunkResult( return children; } -function getHunksRendererOptions( - options: FileDiffOptions | undefined +function getHunksRendererOptions( + options: FileDiffOptions | undefined ): DiffHunksRendererOptions { return { ...options, diff --git a/packages/diffs/src/ssr/preloadFile.ts b/packages/diffs/src/ssr/preloadFile.ts index 199b205bf..3135905d6 100644 --- a/packages/diffs/src/ssr/preloadFile.ts +++ b/packages/diffs/src/ssr/preloadFile.ts @@ -1,6 +1,10 @@ import type { FileOptions } from '../components/File'; import { FileRenderer } from '../renderers/FileRenderer'; -import type { FileContents, LineAnnotation } from '../types'; +import type { + FileContents, + FileDecorationItem, + LineAnnotation, +} from '../types'; import { createStyleElement, createThemeStyleElement, @@ -8,25 +12,39 @@ import { import { wrapThemeCSS } from '../utils/cssWrappers'; import { renderHTML } from './renderHTML'; -export type PreloadFileOptions = { +export type PreloadFileOptions< + LAnnotation = undefined, + LDecoration = undefined, +> = { file: FileContents; - options?: FileOptions; + options?: FileOptions; annotations?: LineAnnotation[]; + decorations?: FileDecorationItem[]; }; -export interface PreloadedFileResult { +export interface PreloadedFileResult< + LAnnotation = undefined, + LDecoration = undefined, +> { file: FileContents; - options?: FileOptions; + options?: FileOptions; annotations?: LineAnnotation[]; + decorations?: FileDecorationItem[]; prerenderedHTML: string; } -export async function preloadFile({ +export async function preloadFile< + LAnnotation = undefined, + LDecoration = undefined, +>({ file, options, annotations, -}: PreloadFileOptions): Promise> { - const fileRenderer = new FileRenderer({ + decorations, +}: PreloadFileOptions): Promise< + PreloadedFileResult +> { + const fileRenderer = new FileRenderer({ ...options, headerRenderMode: options?.renderCustomHeader != null ? 'custom' : 'default', @@ -36,6 +54,9 @@ export async function preloadFile({ if (annotations !== undefined && annotations.length > 0) { fileRenderer.setLineAnnotations(annotations); } + if (decorations !== undefined && decorations.length > 0) { + fileRenderer.setDecorations(decorations); + } const fileResult = await fileRenderer.asyncRender(file); const children = [createStyleElement(fileResult.css, true)]; @@ -64,6 +85,7 @@ export async function preloadFile({ file, options, annotations, + decorations, prerenderedHTML: renderHTML(children), }; } diff --git a/packages/diffs/src/ssr/preloadPatchFile.ts b/packages/diffs/src/ssr/preloadPatchFile.ts index e8e941bb3..5b26df3e9 100644 --- a/packages/diffs/src/ssr/preloadPatchFile.ts +++ b/packages/diffs/src/ssr/preloadPatchFile.ts @@ -2,25 +2,30 @@ import type { FileDiffOptions } from '../components/FileDiff'; import { parsePatchFiles } from '../utils/parsePatchFiles'; import { preloadFileDiff, type PreloadFileDiffResult } from './preloadDiffs'; -export type PreloadPatchFileOptions = { +export interface PreloadPatchFileOptions { patch: string; - options?: FileDiffOptions; + options?: FileDiffOptions; // We need to support annotations, but it's unclear the best way to do this // right now... (i.e. what API people would want, so intentionally leaving // this blank for now) -}; +} -export async function preloadPatchFile({ +export async function preloadPatchFile< + LAnnotation = undefined, + LDecoration = undefined, +>({ patch, options, -}: PreloadPatchFileOptions): Promise< - PreloadFileDiffResult[] +}: PreloadPatchFileOptions): Promise< + PreloadFileDiffResult[] > { - const diffs: Promise>[] = []; + const diffs: Promise>[] = []; const patches = parsePatchFiles(patch); for (const patch of patches) { for (const fileDiff of patch.files) { - diffs.push(preloadFileDiff({ fileDiff, options })); + diffs.push( + preloadFileDiff({ fileDiff, options }) + ); } } return await Promise.all(diffs); diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index eeaad5e65..a63424c46 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -452,14 +452,30 @@ type OptionalMetadata = T extends undefined ? { metadata?: undefined } : { metadata: T }; -export type LineAnnotation = { +export type LineAnnotation = { lineNumber: number; -} & OptionalMetadata; +} & OptionalMetadata; -export type DiffLineAnnotation = { +export type DiffLineAnnotation = { side: AnnotationSide; lineNumber: number; -} & OptionalMetadata; +} & OptionalMetadata; + +export type DecorationRange = { + lineNumber: number; + endLineNumber?: number; + bar?: boolean; + color?: string; + background?: boolean | string; +} & OptionalMetadata; + +export type FileDecorationItem = + DecorationRange; + +export type DiffDecorationItem = + DecorationRange & { + side: AnnotationSide; + }; export type MergeConflictResolution = 'current' | 'incoming' | 'both'; diff --git a/packages/diffs/src/utils/areOptionsEqual.ts b/packages/diffs/src/utils/areOptionsEqual.ts index edb0bf81c..92bc19b16 100644 --- a/packages/diffs/src/utils/areOptionsEqual.ts +++ b/packages/diffs/src/utils/areOptionsEqual.ts @@ -6,11 +6,14 @@ import type { FileOptions } from '../react'; import { areObjectsEqual } from './areObjectsEqual'; import { areThemesEqual } from './areThemesEqual'; -type AnyOptions = FileOptions | FileDiffOptions | undefined; +type AnyOptions = + | FileOptions + | FileDiffOptions + | undefined; -export function areOptionsEqual( - optionsA: AnyOptions, - optionsB: AnyOptions +export function areOptionsEqual( + optionsA: AnyOptions, + optionsB: AnyOptions ): boolean { const themeA = optionsA?.theme ?? DEFAULT_THEMES; const themeB = optionsB?.theme ?? DEFAULT_THEMES; @@ -26,8 +29,8 @@ export function areOptionsEqual( ); } -function getParseDiffOptions( - options: AnyOptions +function getParseDiffOptions( + options: AnyOptions ): CreatePatchOptionsNonabortable | undefined { if (options != null && 'parseDiffOptions' in options) { return options.parseDiffOptions; diff --git a/packages/diffs/test/annotations.test.ts b/packages/diffs/test/annotations.test.ts index 0d639b0b4..59f35c79b 100644 --- a/packages/diffs/test/annotations.test.ts +++ b/packages/diffs/test/annotations.test.ts @@ -31,7 +31,7 @@ describe('Annotation Rendering', () => { { side: 'deletions', lineNumber: 25, metadata: 'old-line' }, ]; - const renderer = new DiffHunksRenderer({ + const renderer = new DiffHunksRenderer({ diffStyle: 'unified', expandUnchanged: true, }); From 2d632825fe0f2cb37fd7d4cf8114d8f825499ee7 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Tue, 31 Mar 2026 15:56:13 -0700 Subject: [PATCH 2/5] phase 2: normalization and piping data through Some AI slop here for sure, but i think we wrangled it into a decent spot. --- packages/diffs/src/components/File.ts | 58 ++++++-- packages/diffs/src/components/FileDiff.ts | 60 ++++++-- .../diffs/src/renderers/DiffHunksRenderer.ts | 31 ++++- packages/diffs/src/renderers/FileRenderer.ts | 22 ++- .../src/utils/normalizeLineDecorations.ts | 130 ++++++++++++++++++ 5 files changed, 267 insertions(+), 34 deletions(-) create mode 100644 packages/diffs/src/utils/normalizeLineDecorations.ts diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 819587d71..c36a9ef64 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -247,6 +247,34 @@ export class File { this.decorations = decorations; } + private syncRenderState({ + nextLineAnnotations, + nextDecorations, + syncAnnotations, + syncDecorations, + }: { + nextLineAnnotations?: LineAnnotation[]; + nextDecorations?: FileDecorationItem[]; + syncAnnotations: boolean; + syncDecorations: boolean; + }): void { + if (syncAnnotations && nextLineAnnotations != null) { + this.setLineAnnotations(nextLineAnnotations); + } + + if (syncDecorations && nextDecorations != null) { + this.setDecorations(nextDecorations); + } + + if (syncAnnotations) { + this.fileRenderer.setLineAnnotations(this.lineAnnotations); + } + + if (syncDecorations) { + this.fileRenderer.setDecorations(this.decorations); + } + } + public setSelectedLines(range: SelectedLineRange | null): void { this.interactionManager.setSelection(range); } @@ -372,15 +400,18 @@ export class File { decorations, }: HydrationSetup): void { const { overflow = 'scroll' } = this.options; - this.lineAnnotations = lineAnnotations ?? this.lineAnnotations; - this.decorations = decorations ?? this.decorations; this.file = file; this.fileRenderer.setOptions({ ...this.options, headerRenderMode: this.options.renderCustomHeader != null ? 'custom' : 'default', }); - this.fileRenderer.setDecorations(this.decorations); + this.syncRenderState({ + nextLineAnnotations: lineAnnotations, + nextDecorations: decorations, + syncAnnotations: true, + syncDecorations: true, + }); if (this.pre == null) { return; } @@ -413,16 +444,15 @@ export class File { const { collapsed = false, themeType = 'system' } = this.options; const nextRenderRange = collapsed ? undefined : renderRange; const previousRenderRange = this.renderRange; - const nextDecorations = decorations; const annotationsChanged = lineAnnotations != null && (lineAnnotations.length > 0 || this.lineAnnotations.length > 0) ? lineAnnotations !== this.lineAnnotations : false; const decorationsChanged = - nextDecorations != null && - (nextDecorations.length > 0 || this.decorations.length > 0) - ? nextDecorations !== this.decorations + decorations != null && + (decorations.length > 0 || this.decorations.length > 0) + ? decorations !== this.decorations : false; const didFileChange = !areFilesEqual(this.file, file); if ( @@ -443,14 +473,12 @@ export class File { headerRenderMode: this.options.renderCustomHeader != null ? 'custom' : 'default', }); - if (lineAnnotations != null) { - this.setLineAnnotations(lineAnnotations); - } - if (nextDecorations != null) { - this.decorations = nextDecorations; - } - this.fileRenderer.setLineAnnotations(this.lineAnnotations); - this.fileRenderer.setDecorations(this.decorations); + this.syncRenderState({ + nextLineAnnotations: lineAnnotations, + nextDecorations: decorations, + syncAnnotations: annotationsChanged, + syncDecorations: decorationsChanged, + }); const { disableErrorHandling = false, diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index e53068d4c..b876a5711 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -428,6 +428,34 @@ export class FileDiff { this.decorations = decorations; } + private syncRenderState({ + nextLineAnnotations, + nextDecorations, + syncAnnotations, + syncDecorations, + }: { + nextLineAnnotations?: DiffLineAnnotation[]; + nextDecorations?: DiffDecorationItem[]; + syncAnnotations: boolean; + syncDecorations: boolean; + }): void { + if (syncAnnotations && nextLineAnnotations != null) { + this.setLineAnnotations(nextLineAnnotations); + } + + if (syncDecorations && nextDecorations != null) { + this.setDecorations(nextDecorations); + } + + if (syncAnnotations) { + this.hunksRenderer.setLineAnnotations(this.lineAnnotations); + } + + if (syncDecorations) { + this.hunksRenderer.setDecorations(this.decorations); + } + } + private canPartiallyRender( forceRender: boolean, annotationsChanged: boolean, @@ -632,11 +660,17 @@ export class FileDiff { ? parseDiffFromFile(oldFile, newFile, this.options.parseDiffOptions) : undefined); + this.syncRenderState({ + nextLineAnnotations: lineAnnotations, + nextDecorations: decorations, + syncAnnotations: true, + syncDecorations: true, + }); + if (this.pre == null) { return; } - this.hunksRenderer.setDecorations(this.decorations); this.hunksRenderer.hydrate(this.fileDiff); // FIXME(amadeus): not sure how to handle this yet... // this.renderSeparators(); @@ -713,7 +747,6 @@ export class FileDiff { } const { collapsed = false } = this.options; const nextRenderRange = collapsed ? undefined : renderRange; - const nextDecorations = decorations; const filesDidChange = oldFile != null && newFile != null && @@ -726,9 +759,9 @@ export class FileDiff { ? lineAnnotations !== this.lineAnnotations : false; const decorationsChanged = - nextDecorations != null && - (nextDecorations.length > 0 || this.decorations.length > 0) - ? nextDecorations !== this.decorations + decorations != null && + (decorations.length > 0 || this.decorations.length > 0) + ? decorations !== this.decorations : false; if ( @@ -763,19 +796,16 @@ export class FileDiff { ); } - if (lineAnnotations != null) { - this.setLineAnnotations(lineAnnotations); - } - if (nextDecorations != null) { - this.decorations = nextDecorations; - } + this.hunksRenderer.setOptions(this.getHunksRendererOptions(this.options)); + this.syncRenderState({ + nextLineAnnotations: lineAnnotations, + nextDecorations: decorations, + syncAnnotations: annotationsChanged, + syncDecorations: decorationsChanged, + }); if (this.fileDiff == null) { return false; } - this.hunksRenderer.setOptions(this.getHunksRendererOptions(this.options)); - - this.hunksRenderer.setLineAnnotations(this.lineAnnotations); - this.hunksRenderer.setDecorations(this.decorations); const { diffStyle = 'split', diff --git a/packages/diffs/src/renderers/DiffHunksRenderer.ts b/packages/diffs/src/renderers/DiffHunksRenderer.ts index 092008bd6..34bffc2e1 100644 --- a/packages/diffs/src/renderers/DiffHunksRenderer.ts +++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts @@ -61,6 +61,11 @@ import { isDefaultRenderRange } from '../utils/isDefaultRenderRange'; import { isDiffPlainText } from '../utils/isDiffPlainText'; import type { DiffLineMetadata } from '../utils/iterateOverDiff'; import { iterateOverDiff } from '../utils/iterateOverDiff'; +import { + normalizeDiffDecorations, + type NormalizedLineDecorationMap, + type NormalizedLineDecorations, +} from '../utils/normalizeLineDecorations'; import { renderDiffWithHighlighter } from '../utils/renderDiffWithHighlighter'; import { shouldUseTokenTransformer } from '../utils/shouldUseTokenTransformer'; import type { WorkerPoolManager } from '../worker'; @@ -211,6 +216,8 @@ export class DiffHunksRenderer< private deletionAnnotations: AnnotationLineMap = {}; private additionAnnotations: AnnotationLineMap = {}; + private deletionDecorationsByLine: NormalizedLineDecorationMap = {}; + private additionDecorationsByLine: NormalizedLineDecorationMap = {}; private computedLang: SupportedLanguages = 'text'; private renderCache: RenderedDiffASTCache | undefined; @@ -228,6 +235,8 @@ export class DiffHunksRenderer< } public cleanUp(): void { + this.deletionDecorationsByLine = {}; + this.additionDecorationsByLine = {}; this.highlighter = undefined; this.diff = undefined; this.renderCache = undefined; @@ -237,6 +246,8 @@ export class DiffHunksRenderer< } public recycle(): void { + this.deletionDecorationsByLine = {}; + this.additionDecorationsByLine = {}; this.highlighter = undefined; this.diff = undefined; this.renderCache = undefined; @@ -306,8 +317,12 @@ export class DiffHunksRenderer< } public setDecorations( - _decorations: readonly DiffDecorationItem[] - ): void {} + decorations: readonly DiffDecorationItem[] + ): void { + const maps = normalizeDiffDecorations(decorations); + this.additionDecorationsByLine = maps.additions; + this.deletionDecorationsByLine = maps.deletions; + } protected getUnifiedLineDecoration({ lineType, @@ -328,6 +343,18 @@ export class DiffHunksRenderer< }; } + protected getLineDecorations( + side: 'deletions' | 'additions', + lineNumber: number | undefined + ): NormalizedLineDecorations | undefined { + if (lineNumber == null) { + return undefined; + } + return side === 'deletions' + ? this.deletionDecorationsByLine[lineNumber] + : this.additionDecorationsByLine[lineNumber]; + } + protected createAnnotationElement(span: AnnotationSpan): HASTElement { return createDefaultAnnotationElement(span); } diff --git a/packages/diffs/src/renderers/FileRenderer.ts b/packages/diffs/src/renderers/FileRenderer.ts index a00064366..f74c60e97 100644 --- a/packages/diffs/src/renderers/FileRenderer.ts +++ b/packages/diffs/src/renderers/FileRenderer.ts @@ -41,6 +41,11 @@ import { } from '../utils/hast_utils'; import { isFilePlainText } from '../utils/isFilePlainText'; import { iterateOverFile } from '../utils/iterateOverFile'; +import { + type NormalizedLineDecorationMap, + type NormalizedLineDecorations, + normalizeFileDecorations, +} from '../utils/normalizeLineDecorations'; import { renderFileWithHighlighter } from '../utils/renderFileWithHighlighter'; import { shouldUseTokenTransformer } from '../utils/shouldUseTokenTransformer'; import { splitFileContents } from '../utils/splitFileContents'; @@ -88,6 +93,7 @@ export class FileRenderer { private renderCache: RenderedFileASTCache | undefined; private computedLang: SupportedLanguages = 'text'; private lineAnnotations: AnnotationLineMap = {}; + private decorationsByLine: NormalizedLineDecorationMap = {}; private lineCache: LineCache | undefined; constructor( @@ -122,10 +128,13 @@ export class FileRenderer { } public setDecorations( - _decorations: readonly FileDecorationItem[] - ): void {} + decorations: readonly FileDecorationItem[] + ): void { + this.decorationsByLine = normalizeFileDecorations(decorations); + } public cleanUp(): void { + this.decorationsByLine = {}; this.renderCache = undefined; this.highlighter = undefined; this.workerManager = undefined; @@ -205,6 +214,15 @@ export class FileRenderer { return lineCache.lines; } + protected getLineDecorations( + lineNumber: number | undefined + ): NormalizedLineDecorations | undefined { + if (lineNumber == null) { + return undefined; + } + return this.decorationsByLine[lineNumber]; + } + public renderFile( file: FileContents | undefined = this.renderCache?.file, renderRange: RenderRange = DEFAULT_RENDER_RANGE diff --git a/packages/diffs/src/utils/normalizeLineDecorations.ts b/packages/diffs/src/utils/normalizeLineDecorations.ts new file mode 100644 index 000000000..f34922b4a --- /dev/null +++ b/packages/diffs/src/utils/normalizeLineDecorations.ts @@ -0,0 +1,130 @@ +import type { DiffDecorationItem, FileDecorationItem } from '../types'; + +const DEFAULT_DECORATION_COLOR = 'var(--diffs-modified-base)'; + +export interface NormalizedLineDecorations { + barIndices?: number[]; + backgroundIndices?: number[]; + barColor?: string; + backgroundColor?: string; +} + +export type NormalizedLineDecorationMap = Record< + number, + NormalizedLineDecorations | undefined +>; + +export interface NormalizedDiffDecorationMaps { + additions: NormalizedLineDecorationMap; + deletions: NormalizedLineDecorationMap; +} + +interface NormalizedRange { + startLineNumber: number; + endLineNumber: number; +} + +// This expands decoration ranges once so renderers can do O(1) lookups while +// they walk already-rendered lines. +function applyDecorationRange( + map: NormalizedLineDecorationMap, + decoration: FileDecorationItem, + sourceIndex: number +): void { + const range = getNormalizedRange( + decoration.lineNumber, + decoration.endLineNumber + ); + if (range == null) { + return; + } + + const barColor = + decoration.bar === true + ? (decoration.color ?? DEFAULT_DECORATION_COLOR) + : undefined; + const backgroundColor = getBackgroundColor(decoration); + if (barColor == null && backgroundColor == null) { + return; + } + + for ( + let lineNumber = range.startLineNumber; + lineNumber <= range.endLineNumber; + lineNumber++ + ) { + const lineDecorations = map[lineNumber] ?? (map[lineNumber] = {}); + if (barColor != null) { + const barIndices = lineDecorations.barIndices ?? []; + lineDecorations.barIndices = barIndices; + barIndices.push(sourceIndex); + lineDecorations.barColor = barColor; + } + if (backgroundColor != null) { + const backgroundIndices = lineDecorations.backgroundIndices ?? []; + lineDecorations.backgroundIndices = backgroundIndices; + backgroundIndices.push(sourceIndex); + lineDecorations.backgroundColor = backgroundColor; + } + } +} + +export function normalizeFileDecorations( + decorations: readonly FileDecorationItem[] +): NormalizedLineDecorationMap { + const normalized: NormalizedLineDecorationMap = {}; + for (const [sourceIndex, decoration] of decorations.entries()) { + applyDecorationRange(normalized, decoration, sourceIndex); + } + return normalized; +} + +export function normalizeDiffDecorations( + decorations: readonly DiffDecorationItem[] +): NormalizedDiffDecorationMaps { + const normalized: NormalizedDiffDecorationMaps = { + additions: {}, + deletions: {}, + }; + for (const [sourceIndex, decoration] of decorations.entries()) { + applyDecorationRange(normalized[decoration.side], decoration, sourceIndex); + } + return normalized; +} + +function getNormalizedRange( + lineNumber: number, + endLineNumber: number | undefined +): NormalizedRange | undefined { + const normalizedEndLineNumber = endLineNumber ?? lineNumber; + if ( + !Number.isSafeInteger(lineNumber) || + !Number.isSafeInteger(normalizedEndLineNumber) || + lineNumber < 1 || + normalizedEndLineNumber < 1 + ) { + return undefined; + } + + if (normalizedEndLineNumber < lineNumber) { + return undefined; + } + + return { + startLineNumber: lineNumber, + endLineNumber: normalizedEndLineNumber, + }; +} + +function getBackgroundColor( + decoration: FileDecorationItem +): string | undefined { + if (typeof decoration.background === 'string') { + return decoration.background; + } + if (decoration.background !== true) { + return undefined; + } + + return `color-mix(in lab, ${decoration.color ?? DEFAULT_DECORATION_COLOR}, transparent)`; +} From 7d9fb4a7ff0192df1ca5383ae6d187770ad35e17 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Mon, 6 Apr 2026 17:35:19 -0700 Subject: [PATCH 3/5] decorations checkpoint... shit's mb about to get weird... --- .../diffs/src/components/UnresolvedFile.ts | 2 +- .../diffs/src/renderers/DiffHunksRenderer.ts | 69 ++- packages/diffs/src/renderers/FileRenderer.ts | 36 +- .../src/utils/getLineDecorationProperties.ts | 267 ++++++++ .../src/utils/normalizeLineDecorations.ts | 186 +++++- packages/diffs/test/decorations.test.ts | 568 ++++++++++++++++++ 6 files changed, 1107 insertions(+), 21 deletions(-) create mode 100644 packages/diffs/src/utils/getLineDecorationProperties.ts create mode 100644 packages/diffs/test/decorations.test.ts diff --git a/packages/diffs/src/components/UnresolvedFile.ts b/packages/diffs/src/components/UnresolvedFile.ts index ec7faea04..f4f863d3f 100644 --- a/packages/diffs/src/components/UnresolvedFile.ts +++ b/packages/diffs/src/components/UnresolvedFile.ts @@ -403,7 +403,7 @@ export class UnresolvedFile< override render( props: UnresolvedFileRenderProps = {} ): boolean { - let { + const { file, fileDiff, actions, diff --git a/packages/diffs/src/renderers/DiffHunksRenderer.ts b/packages/diffs/src/renderers/DiffHunksRenderer.ts index 34bffc2e1..6a5ba9f71 100644 --- a/packages/diffs/src/renderers/DiffHunksRenderer.ts +++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts @@ -50,6 +50,12 @@ import { getFiletypeFromFileName } from '../utils/getFiletypeFromFileName'; import { getHighlighterOptions } from '../utils/getHighlighterOptions'; import { getHunkSeparatorSlotName } from '../utils/getHunkSeparatorSlotName'; import { getLineAnnotationName } from '../utils/getLineAnnotationName'; +import { + getLineDecorationContentProperties, + getLineDecorationGutterProperties, + mergeHastProperties, + mergeNormalizedLineDecorations, +} from '../utils/getLineDecorationProperties'; import { getTotalLineCountFromHunks } from '../utils/getTotalLineCountFromHunks'; import { createGutterGap, @@ -355,6 +361,23 @@ export class DiffHunksRenderer< : this.additionDecorationsByLine[lineNumber]; } + protected mergeLineDecoration( + decoration: LineDecoration, + lineDecorations: NormalizedLineDecorations | undefined + ): LineDecoration { + return { + ...decoration, + gutterProperties: mergeHastProperties( + decoration.gutterProperties, + getLineDecorationGutterProperties(lineDecorations) + ), + contentProperties: mergeHastProperties( + decoration.contentProperties, + getLineDecorationContentProperties(lineDecorations) + ), + }; + } + protected createAnnotationElement(span: AnnotationSpan): HASTElement { return createDefaultAnnotationElement(span); } @@ -882,24 +905,35 @@ export class DiffHunksRenderer< additionLineIndex: additionLine?.lineIndex, deletionLineIndex: deletionLine?.lineIndex, }); + const unifiedLineDecoration = this.mergeLineDecoration( + lineDecoration, + mergeNormalizedLineDecorations( + deletionLine != null + ? this.getLineDecorations('deletions', deletionLine.lineNumber) + : undefined, + additionLine != null + ? this.getLineDecorations('additions', additionLine.lineNumber) + : undefined + ) + ); pushGutterLineNumber( 'unified', - lineDecoration.gutterLineType, + unifiedLineDecoration.gutterLineType, additionLine != null ? additionLine.lineNumber : deletionLine.lineNumber, `${unifiedLineIndex},${splitLineIndex}`, - lineDecoration.gutterProperties + unifiedLineDecoration.gutterProperties ); if (additionLineContent != null) { additionLineContent = withContentProperties( additionLineContent, - lineDecoration.contentProperties + unifiedLineDecoration.contentProperties ); } else if (deletionLineContent != null) { deletionLineContent = withContentProperties( deletionLineContent, - lineDecoration.contentProperties + unifiedLineDecoration.contentProperties ); } pushLineWithAnnotation({ @@ -950,6 +984,18 @@ export class DiffHunksRenderer< type, lineIndex: additionLine?.lineIndex, }); + const decoratedDeletionLine = this.mergeLineDecoration( + deletionLineDecoration, + deletionLine != null + ? this.getLineDecorations('deletions', deletionLine.lineNumber) + : undefined + ); + const decoratedAdditionLine = this.mergeLineDecoration( + additionLineDecoration, + additionLine != null + ? this.getLineDecorations('additions', additionLine.lineNumber) + : undefined + ); if (deletionLineContent == null && additionLineContent == null) { const errorMessage = @@ -996,14 +1042,14 @@ export class DiffHunksRenderer< if (deletionLine != null) { const deletionLineDecorated = withContentProperties( deletionLineContent, - deletionLineDecoration.contentProperties + decoratedDeletionLine.contentProperties ); pushGutterLineNumber( 'deletions', - deletionLineDecoration.gutterLineType, + decoratedDeletionLine.gutterLineType, deletionLine.lineNumber, `${deletionLine.unifiedLineIndex},${splitLineIndex}`, - deletionLineDecoration.gutterProperties + decoratedDeletionLine.gutterProperties ); if (deletionLineDecorated != null) { deletionLineContent = deletionLineDecorated; @@ -1012,14 +1058,14 @@ export class DiffHunksRenderer< if (additionLine != null) { const additionLineDecorated = withContentProperties( additionLineContent, - additionLineDecoration.contentProperties + decoratedAdditionLine.contentProperties ); pushGutterLineNumber( 'additions', - additionLineDecoration.gutterLineType, + decoratedAdditionLine.gutterLineType, additionLine.lineNumber, `${additionLine.unifiedLineIndex},${splitLineIndex}`, - additionLineDecoration.gutterProperties + decoratedAdditionLine.gutterProperties ); if (additionLineDecorated != null) { additionLineContent = additionLineDecorated; @@ -1606,8 +1652,7 @@ function withContentProperties( return { ...lineNode, properties: { - ...lineNode.properties, - ...contentProperties, + ...(mergeHastProperties(lineNode.properties, contentProperties) ?? {}), }, }; } diff --git a/packages/diffs/src/renderers/FileRenderer.ts b/packages/diffs/src/renderers/FileRenderer.ts index f74c60e97..c92c35468 100644 --- a/packages/diffs/src/renderers/FileRenderer.ts +++ b/packages/diffs/src/renderers/FileRenderer.ts @@ -1,4 +1,4 @@ -import type { ElementContent, Element as HASTElement } from 'hast'; +import type { ElementContent, Element as HASTElement, Properties } from 'hast'; import { toHtml } from 'hast-util-to-html'; import { DEFAULT_RENDER_RANGE, DEFAULT_THEMES } from '../constants'; @@ -32,6 +32,11 @@ import { createPreElement } from '../utils/createPreElement'; import { getFiletypeFromFileName } from '../utils/getFiletypeFromFileName'; import { getHighlighterOptions } from '../utils/getHighlighterOptions'; import { getLineAnnotationName } from '../utils/getLineAnnotationName'; +import { + getLineDecorationContentProperties, + getLineDecorationGutterProperties, + mergeHastProperties, +} from '../utils/getLineDecorationProperties'; import { getThemes } from '../utils/getThemes'; import { createGutterGap, @@ -394,11 +399,22 @@ export class FileRenderer { } if (line != null) { + const lineDecorations = this.getLineDecorations(lineNumber); // Add gutter line number gutter.children.push( - createGutterItem('context', lineNumber, `${lineIndex}`) + createGutterItem( + 'context', + lineNumber, + `${lineIndex}`, + getLineDecorationGutterProperties(lineDecorations) + ) + ); + contentArray.push( + withContentProperties( + line, + getLineDecorationContentProperties(lineDecorations) + ) ); - contentArray.push(line); rowCount++; // Check annotations using ACTUAL line number from file @@ -542,6 +558,20 @@ export class FileRenderer { } } +function withContentProperties( + lineNode: ElementContent, + contentProperties: Properties | undefined +): ElementContent { + if (lineNode.type !== 'element' || contentProperties == null) { + return lineNode; + } + return { + ...lineNode, + properties: + mergeHastProperties(lineNode.properties, contentProperties) ?? {}, + }; +} + function areRenderOptionsEqual( optionsA: RenderFileOptions, optionsB: RenderFileOptions diff --git a/packages/diffs/src/utils/getLineDecorationProperties.ts b/packages/diffs/src/utils/getLineDecorationProperties.ts new file mode 100644 index 000000000..7872143ca --- /dev/null +++ b/packages/diffs/src/utils/getLineDecorationProperties.ts @@ -0,0 +1,267 @@ +import type { Properties } from 'hast'; + +import { + getHigherPriorityDecoration, + mergeDecorationDepth, +} from './normalizeLineDecorations'; +import type { NormalizedLineDecorations } from './normalizeLineDecorations'; + +export function getLineDecorationGutterProperties( + decorations: NormalizedLineDecorations | undefined +): Properties | undefined { + return getLineDecorationBarProperties(decorations); +} + +export function getLineDecorationContentProperties( + decorations: NormalizedLineDecorations | undefined +): Properties | undefined { + return mergeHastProperties( + getLineDecorationLifecycleProperties( + 'data-decoration-bg-start', + decorations?.startIndices, + 'data-decoration-bg-end', + decorations?.endIndices + ), + mergeHastProperties( + getLineDecorationProperties( + 'data-decoration-bg', + decorations?.backgroundIndices, + '--diffs-decoration-bg', + decorations?.backgroundColor + ), + getLineDecorationDepthProperties( + 'data-decoration-bg-depth', + decorations?.backgroundIndices, + decorations?.backgroundDepth + ) + ) + ); +} + +export function mergeHastProperties( + base: Properties | undefined, + next: Properties | undefined +): Properties | undefined { + if (base == null) { + return next; + } + if (next == null) { + return base; + } + + const style = mergeStyleStrings(base.style, next.style); + return { + ...base, + ...next, + style, + }; +} + +export function mergeNormalizedLineDecorations( + first: NormalizedLineDecorations | undefined, + second: NormalizedLineDecorations | undefined +): NormalizedLineDecorations | undefined { + if (first == null) { + return second; + } + if (second == null) { + return first; + } + + const barIndices = mergeSortedIndices(first.barIndices, second.barIndices); + const backgroundIndices = mergeSortedIndices( + first.backgroundIndices, + second.backgroundIndices + ); + if (barIndices == null && backgroundIndices == null) { + return undefined; + } + + const bar = getHigherPriorityDecoration( + { + color: first.barColor, + lineNumber: first.barLineNumber, + sourceIndex: first.barSourceIndex, + }, + { + color: second.barColor, + lineNumber: second.barLineNumber, + sourceIndex: second.barSourceIndex, + } + ); + const background = getHigherPriorityDecoration( + { + color: first.backgroundColor, + lineNumber: first.backgroundLineNumber, + sourceIndex: first.backgroundSourceIndex, + }, + { + color: second.backgroundColor, + lineNumber: second.backgroundLineNumber, + sourceIndex: second.backgroundSourceIndex, + } + ); + + return { + barIndices, + startIndices: mergeSortedIndices(first.startIndices, second.startIndices), + endIndices: mergeSortedIndices(first.endIndices, second.endIndices), + backgroundIndices, + barColor: bar?.color, + barLineNumber: bar?.lineNumber, + barSourceIndex: bar?.sourceIndex, + backgroundColor: background?.color, + backgroundLineNumber: background?.lineNumber, + backgroundSourceIndex: background?.sourceIndex, + barDepth: mergeDecorationDepth(first.barDepth, second.barDepth), + backgroundDepth: mergeDecorationDepth( + first.backgroundDepth, + second.backgroundDepth + ), + }; +} + +function getLineDecorationBarProperties( + decorations: NormalizedLineDecorations | undefined +): Properties | undefined { + return mergeHastProperties( + mergeHastProperties( + getLineDecorationProperties( + 'data-decoration-bar', + decorations?.barIndices, + '--diffs-decoration-bar-color', + decorations?.barColor + ), + getLineDecorationDepthProperties( + 'data-decoration-bar-depth', + decorations?.barIndices, + decorations?.barDepth + ) + ), + getLineDecorationLifecycleProperties( + 'data-decoration-bar-start', + decorations?.startIndices, + 'data-decoration-bar-end', + decorations?.endIndices + ) + ); +} + +function getLineDecorationProperties( + dataAttribute: 'data-decoration-bar' | 'data-decoration-bg', + indices: number[] | undefined, + cssVariable: '--diffs-decoration-bar-color' | '--diffs-decoration-bg', + color: string | undefined +): Properties | undefined { + if (indices == null || indices.length === 0) { + return undefined; + } + + return { + [dataAttribute]: indices.join(','), + style: color != null ? `${cssVariable}:${color};` : undefined, + }; +} + +function getLineDecorationDepthProperties( + dataAttribute: 'data-decoration-bar-depth' | 'data-decoration-bg-depth', + indices: number[] | undefined, + depth: 1 | 2 | 3 | undefined +): Properties | undefined { + if (indices == null || indices.length === 0 || depth == null) { + return undefined; + } + + return { + [dataAttribute]: String(depth), + }; +} + +function getLineDecorationLifecycleProperties( + startAttribute: 'data-decoration-bar-start' | 'data-decoration-bg-start', + startIndices: number[] | undefined, + endAttribute: 'data-decoration-bar-end' | 'data-decoration-bg-end', + endIndices: number[] | undefined +): Properties | undefined { + return mergeHastProperties( + getLineDecorationIndexProperties(startAttribute, startIndices), + getLineDecorationIndexProperties(endAttribute, endIndices) + ); +} + +function getLineDecorationIndexProperties( + dataAttribute: + | 'data-decoration-bar-start' + | 'data-decoration-bar-end' + | 'data-decoration-bg-start' + | 'data-decoration-bg-end', + indices: number[] | undefined +): Properties | undefined { + if (indices == null || indices.length === 0) { + return undefined; + } + + return { + [dataAttribute]: indices.join(','), + }; +} + +function mergeSortedIndices( + first: number[] | undefined, + second: number[] | undefined +): number[] | undefined { + if (first == null || first.length === 0) { + return second; + } + if (second == null || second.length === 0) { + return first; + } + + const merged: number[] = []; + let firstIndex = 0; + let secondIndex = 0; + while (firstIndex < first.length && secondIndex < second.length) { + if (first[firstIndex] < second[secondIndex]) { + merged.push(first[firstIndex]); + firstIndex += 1; + } else { + merged.push(second[secondIndex]); + secondIndex += 1; + } + } + while (firstIndex < first.length) { + merged.push(first[firstIndex]); + firstIndex += 1; + } + while (secondIndex < second.length) { + merged.push(second[secondIndex]); + secondIndex += 1; + } + return merged; +} + +function mergeStyleStrings( + first: Properties['style'], + second: Properties['style'] +): Properties['style'] { + const firstStyle = normalizeStyleValue(first); + const secondStyle = normalizeStyleValue(second); + if (firstStyle == null) { + return secondStyle; + } + if (secondStyle == null) { + return firstStyle; + } + return `${ensureTrailingSemicolon(firstStyle)}${secondStyle}`; +} + +function normalizeStyleValue(style: Properties['style']): string | undefined { + if (typeof style !== 'string' || style === '') { + return undefined; + } + return style; +} + +function ensureTrailingSemicolon(style: string): string { + return style.trimEnd().endsWith(';') ? style : `${style};`; +} diff --git a/packages/diffs/src/utils/normalizeLineDecorations.ts b/packages/diffs/src/utils/normalizeLineDecorations.ts index f34922b4a..f55484d8d 100644 --- a/packages/diffs/src/utils/normalizeLineDecorations.ts +++ b/packages/diffs/src/utils/normalizeLineDecorations.ts @@ -1,12 +1,23 @@ import type { DiffDecorationItem, FileDecorationItem } from '../types'; const DEFAULT_DECORATION_COLOR = 'var(--diffs-modified-base)'; +const MAX_VISIBLE_DECORATION_DEPTH = 3; + +export type DecorationOverlapDepth = 1 | 2 | 3; export interface NormalizedLineDecorations { barIndices?: number[]; + startIndices?: number[]; + endIndices?: number[]; backgroundIndices?: number[]; barColor?: string; + barLineNumber?: number; + barSourceIndex?: number; backgroundColor?: string; + backgroundLineNumber?: number; + backgroundSourceIndex?: number; + barDepth?: DecorationOverlapDepth; + backgroundDepth?: DecorationOverlapDepth; } export type NormalizedLineDecorationMap = Record< @@ -48,23 +59,74 @@ function applyDecorationRange( return; } + const startLineDecorations = + map[range.startLineNumber] ?? (map[range.startLineNumber] = {}); + const startIndices = startLineDecorations.startIndices ?? []; + startLineDecorations.startIndices = startIndices; + startIndices.push(sourceIndex); + + const endLineDecorations = + map[range.endLineNumber] ?? (map[range.endLineNumber] = {}); + const endIndices = endLineDecorations.endIndices ?? []; + endLineDecorations.endIndices = endIndices; + endIndices.push(sourceIndex); + + const barState = + barColor == null + ? undefined + : createDecorationWinner(decoration.lineNumber, sourceIndex, barColor); + const backgroundState = + backgroundColor == null + ? undefined + : createDecorationWinner( + decoration.lineNumber, + sourceIndex, + backgroundColor + ); + for ( let lineNumber = range.startLineNumber; lineNumber <= range.endLineNumber; lineNumber++ ) { const lineDecorations = map[lineNumber] ?? (map[lineNumber] = {}); - if (barColor != null) { + if (barState != null) { const barIndices = lineDecorations.barIndices ?? []; lineDecorations.barIndices = barIndices; barIndices.push(sourceIndex); - lineDecorations.barColor = barColor; + lineDecorations.barDepth = incrementDecorationDepth( + lineDecorations.barDepth + ); + const nextBar = getHigherPriorityDecoration( + { + color: lineDecorations.barColor, + lineNumber: lineDecorations.barLineNumber, + sourceIndex: lineDecorations.barSourceIndex, + }, + barState + ); + lineDecorations.barColor = nextBar?.color; + lineDecorations.barLineNumber = nextBar?.lineNumber; + lineDecorations.barSourceIndex = nextBar?.sourceIndex; } - if (backgroundColor != null) { + if (backgroundState != null) { const backgroundIndices = lineDecorations.backgroundIndices ?? []; lineDecorations.backgroundIndices = backgroundIndices; backgroundIndices.push(sourceIndex); - lineDecorations.backgroundColor = backgroundColor; + lineDecorations.backgroundDepth = incrementDecorationDepth( + lineDecorations.backgroundDepth + ); + const nextBackground = getHigherPriorityDecoration( + { + color: lineDecorations.backgroundColor, + lineNumber: lineDecorations.backgroundLineNumber, + sourceIndex: lineDecorations.backgroundSourceIndex, + }, + backgroundState + ); + lineDecorations.backgroundColor = nextBackground?.color; + lineDecorations.backgroundLineNumber = nextBackground?.lineNumber; + lineDecorations.backgroundSourceIndex = nextBackground?.sourceIndex; } } } @@ -92,6 +154,78 @@ export function normalizeDiffDecorations( return normalized; } +export function getHigherPriorityDecoration( + first: + | { + color: string | undefined; + lineNumber: number | undefined; + sourceIndex: number | undefined; + } + | undefined, + second: + | { + color: string | undefined; + lineNumber: number | undefined; + sourceIndex: number | undefined; + } + | undefined +): + | { + color: string; + lineNumber: number; + sourceIndex: number; + } + | undefined { + const firstDecoration = + first?.color != null && + first.lineNumber != null && + first.sourceIndex != null + ? { + color: first.color, + lineNumber: first.lineNumber, + sourceIndex: first.sourceIndex, + } + : undefined; + const secondDecoration = + second?.color != null && + second.lineNumber != null && + second.sourceIndex != null + ? { + color: second.color, + lineNumber: second.lineNumber, + sourceIndex: second.sourceIndex, + } + : undefined; + + if (firstDecoration == null) { + if (secondDecoration == null) { + return undefined; + } + return secondDecoration; + } + if (secondDecoration == null) { + return firstDecoration; + } + + return compareDecorationPriority(firstDecoration, secondDecoration) > 0 + ? firstDecoration + : secondDecoration; +} + +export function mergeDecorationDepth( + first: DecorationOverlapDepth | undefined, + second: DecorationOverlapDepth | undefined +): DecorationOverlapDepth | undefined { + if (first == null) { + return second; + } + if (second == null) { + return first; + } + + return getDecorationDepth(first + second); +} + function getNormalizedRange( lineNumber: number, endLineNumber: number | undefined @@ -126,5 +260,47 @@ function getBackgroundColor( return undefined; } - return `color-mix(in lab, ${decoration.color ?? DEFAULT_DECORATION_COLOR}, transparent)`; + return DEFAULT_DECORATION_COLOR; +} + +function createDecorationWinner( + lineNumber: number, + sourceIndex: number, + color: string +): { color: string; lineNumber: number; sourceIndex: number } { + return { + sourceIndex, + lineNumber, + color, + }; +} + +// This keeps overlap resolution incremental so renderers can read one finished +// winner per line instead of re-sorting active decorations. +function compareDecorationPriority( + first: { lineNumber: number; sourceIndex: number }, + second: { lineNumber: number; sourceIndex: number } +): number { + const lineNumberDelta = first.lineNumber - second.lineNumber; + if (lineNumberDelta !== 0) { + return lineNumberDelta; + } + + return first.sourceIndex - second.sourceIndex; +} + +function incrementDecorationDepth( + current: DecorationOverlapDepth | undefined +): DecorationOverlapDepth { + return getDecorationDepth((current ?? 0) + 1); +} + +function getDecorationDepth(depth: number): DecorationOverlapDepth { + if (depth <= 1) { + return 1; + } + if (depth === 2) { + return 2; + } + return MAX_VISIBLE_DECORATION_DEPTH; } diff --git a/packages/diffs/test/decorations.test.ts b/packages/diffs/test/decorations.test.ts new file mode 100644 index 000000000..e7d0e389e --- /dev/null +++ b/packages/diffs/test/decorations.test.ts @@ -0,0 +1,568 @@ +import { describe, expect, test } from 'bun:test'; +import type { ElementContent, Element as HASTElement } from 'hast'; + +import { DiffHunksRenderer, FileRenderer, parseDiffFromFile } from '../src'; +import { UnresolvedFileHunksRenderer } from '../src/renderers/UnresolvedFileHunksRenderer'; +import type { DiffDecorationItem, FileDecorationItem } from '../src/types'; +import { mergeNormalizedLineDecorations } from '../src/utils/getLineDecorationProperties'; +import { parseMergeConflictDiffFromFile } from '../src/utils/parseMergeConflictDiffFromFile'; +import { assertDefined, collectAllElements } from './testUtils'; + +describe('Decoration Rendering', () => { + test('file renderer writes gutter and content decoration attrs', async () => { + const file = { + name: 'example.ts', + contents: ['one', 'two', 'three'].join('\n'), + }; + const decorations: FileDecorationItem[] = [ + { lineNumber: 1, bar: true, color: 'red' }, + { lineNumber: 1, endLineNumber: 3, bar: true, color: 'green' }, + { lineNumber: 2, endLineNumber: 4, background: true, color: 'blue' }, + { lineNumber: 2, background: '#123456', bar: true, color: 'orange' }, + ]; + + const renderer = new FileRenderer(); + renderer.setDecorations(decorations); + const result = await renderer.asyncRender(file); + const codeAST = renderer.renderCodeAST(result) as HASTElement[]; + const [gutter, content] = codeAST; + assertDefined(gutter, 'expected gutter column'); + assertDefined(content, 'expected content column'); + + const gutterLine1 = findElementByProperty( + gutter.children, + 'data-column-number', + 1 + ); + const gutterLine2 = findElementByProperty( + gutter.children, + 'data-column-number', + 2 + ); + const contentLine2 = findElementByProperty( + content.children, + 'data-line', + 2 + ); + const contentLine1 = findElementByProperty( + content.children, + 'data-line', + 1 + ); + const contentLine3 = findElementByProperty( + content.children, + 'data-line', + 3 + ); + const gutterLine3 = findElementByProperty( + gutter.children, + 'data-column-number', + 3 + ); + + assertDefined(gutterLine1, 'expected first gutter line'); + assertDefined(gutterLine2, 'expected second gutter line'); + assertDefined(gutterLine3, 'expected third gutter line'); + assertDefined(contentLine1, 'expected first content line'); + assertDefined(contentLine2, 'expected second content line'); + assertDefined(contentLine3, 'expected third content line'); + + expect(gutterLine1.properties['data-decoration-bar']).toBe('0,1'); + expect(gutterLine1.properties['data-decoration-bar-depth']).toBe('2'); + expect(gutterLine1.properties['data-decoration-bar-start']).toBe('0,1'); + expect(gutterLine1.properties['data-decoration-bar-end']).toBe('0'); + expect(gutterLine2.properties['data-decoration-bar']).toBe('1,3'); + expect(gutterLine2.properties['data-decoration-bar-depth']).toBe('2'); + expect(gutterLine2.properties['data-decoration-bar-start']).toBe('2,3'); + expect(gutterLine2.properties['data-decoration-bar-end']).toBe('3'); + expect(gutterLine2.properties['data-decoration-bg']).toBeUndefined(); + expect(gutterLine2.properties['data-decoration-bg-depth']).toBeUndefined(); + expect(gutterLine2.properties.style).toBe( + '--diffs-decoration-bar-color:orange;' + ); + expect(gutterLine3.properties['data-decoration-bar']).toBe('1'); + expect(gutterLine3.properties['data-decoration-bar-depth']).toBe('1'); + expect(gutterLine3.properties['data-decoration-bar-start']).toBeUndefined(); + expect(gutterLine3.properties['data-decoration-bar-end']).toBe('1'); + + expect(contentLine1.properties['data-decoration-bar']).toBeUndefined(); + expect(contentLine1.properties['data-decoration-bg-depth']).toBeUndefined(); + expect(contentLine1.properties['data-decoration-bg-start']).toBe('0,1'); + expect(contentLine1.properties['data-decoration-bg-end']).toBe('0'); + expect(contentLine2.properties['data-decoration-bar']).toBeUndefined(); + expect(contentLine2.properties['data-decoration-bg-start']).toBe('2,3'); + expect(contentLine2.properties['data-decoration-bg-end']).toBe('3'); + expect(contentLine2.properties['data-decoration-bg']).toBe('2,3'); + expect(contentLine2.properties['data-decoration-bg-depth']).toBe('2'); + expect(contentLine2.properties.style).toBe( + '--diffs-decoration-bg:#123456;' + ); + expect(contentLine3.properties['data-decoration-bar']).toBeUndefined(); + expect(contentLine3.properties['data-decoration-bg-start']).toBeUndefined(); + expect(contentLine3.properties['data-decoration-bg-end']).toBe('1'); + expect(contentLine3.properties['data-decoration-bg']).toBe('2'); + expect(contentLine3.properties['data-decoration-bg-depth']).toBe('1'); + expect(contentLine3.properties.style).toBe( + '--diffs-decoration-bg:var(--diffs-modified-base);' + ); + }); + + test('file renderer keeps source-order identity but resolves the winner by line number', async () => { + const file = { + name: 'example.ts', + contents: ['one', 'two', 'three', 'four'].join('\n'), + }; + const decorations: FileDecorationItem[] = [ + { lineNumber: 2, endLineNumber: 4, background: '#111111' }, + { lineNumber: 1, endLineNumber: 3, background: '#222222' }, + { lineNumber: 2, background: '#333333' }, + ]; + + const renderer = new FileRenderer(); + renderer.setDecorations(decorations); + const result = await renderer.asyncRender(file); + const codeAST = renderer.renderCodeAST(result) as HASTElement[]; + const [, content] = codeAST; + assertDefined(content, 'expected content column'); + + const contentLine2 = findElementByProperty( + content.children, + 'data-line', + 2 + ); + const contentLine3 = findElementByProperty( + content.children, + 'data-line', + 3 + ); + + assertDefined(contentLine2, 'expected second content line'); + assertDefined(contentLine3, 'expected third content line'); + + expect(contentLine2.properties['data-decoration-bg']).toBe('0,1,2'); + expect(contentLine2.properties['data-decoration-bg-depth']).toBe('3'); + expect(contentLine2.properties.style).toBe( + '--diffs-decoration-bg:#333333;' + ); + expect(contentLine3.properties['data-decoration-bg']).toBe('0,1'); + expect(contentLine3.properties['data-decoration-bg-depth']).toBe('2'); + expect(contentLine3.properties.style).toBe( + '--diffs-decoration-bg:#111111;' + ); + }); + + test('merged normalized decorations keep source-order identity and line-number winners', () => { + const merged = mergeNormalizedLineDecorations( + { + backgroundIndices: [0], + backgroundDepth: 1, + backgroundColor: '#111111', + backgroundLineNumber: 5, + backgroundSourceIndex: 0, + }, + { + backgroundIndices: [1], + backgroundDepth: 1, + backgroundColor: '#222222', + backgroundLineNumber: 3, + backgroundSourceIndex: 1, + } + ); + + assertDefined(merged, 'expected merged line decorations'); + expect(merged.backgroundIndices).toEqual([0, 1]); + expect(merged.backgroundDepth).toBe(2); + expect(merged.backgroundColor).toBe('#111111'); + }); + + test('diff renderer keeps split decorations side-owned and combines unified overlaps', async () => { + const oldFile = { + name: 'example.ts', + contents: ['keep', 'old only', 'shared'].join('\n'), + }; + const newFile = { + name: 'example.ts', + contents: ['keep', 'new only', 'shared'].join('\n'), + }; + const diff = parseDiffFromFile(oldFile, newFile); + const decorations: DiffDecorationItem[] = [ + { side: 'deletions', lineNumber: 1, bar: true, color: 'red' }, + { side: 'additions', lineNumber: 1, bar: true, color: 'blue' }, + { side: 'deletions', lineNumber: 2, background: '#111111' }, + { side: 'additions', lineNumber: 2, background: '#222222' }, + ]; + + const splitRenderer = new DiffHunksRenderer({ + diffStyle: 'split', + expandUnchanged: true, + }); + splitRenderer.setDecorations(decorations); + const splitResult = await splitRenderer.asyncRender(diff); + assertDefined( + splitResult.deletionsGutterAST, + 'expected deletions gutter AST' + ); + assertDefined( + splitResult.additionsGutterAST, + 'expected additions gutter AST' + ); + assertDefined( + splitResult.deletionsContentAST, + 'expected deletions content AST' + ); + assertDefined( + splitResult.additionsContentAST, + 'expected additions content AST' + ); + + const splitDeletionLine1 = findElementByProperty( + splitResult.deletionsGutterAST, + 'data-column-number', + 1 + ); + const splitAdditionLine1 = findElementByProperty( + splitResult.additionsGutterAST, + 'data-column-number', + 1 + ); + const splitDeletionLine2Gutter = findElementByProperty( + splitResult.deletionsGutterAST, + 'data-column-number', + 2 + ); + const splitAdditionLine2Gutter = findElementByProperty( + splitResult.additionsGutterAST, + 'data-column-number', + 2 + ); + const splitDeletionLine2 = findElementByProperty( + splitResult.deletionsContentAST, + 'data-line', + 2 + ); + const splitDeletionLine1Content = findElementByProperty( + splitResult.deletionsContentAST, + 'data-line', + 1 + ); + const splitAdditionLine2 = findElementByProperty( + splitResult.additionsContentAST, + 'data-line', + 2 + ); + const splitAdditionLine1Content = findElementByProperty( + splitResult.additionsContentAST, + 'data-line', + 1 + ); + + assertDefined(splitDeletionLine1, 'expected split deletions gutter line 1'); + assertDefined(splitAdditionLine1, 'expected split additions gutter line 1'); + assertDefined( + splitDeletionLine2Gutter, + 'expected split deletions gutter line 2' + ); + assertDefined( + splitAdditionLine2Gutter, + 'expected split additions gutter line 2' + ); + assertDefined( + splitDeletionLine2, + 'expected split deletions content line 2' + ); + assertDefined( + splitDeletionLine1Content, + 'expected split deletions content line 1' + ); + assertDefined( + splitAdditionLine2, + 'expected split additions content line 2' + ); + assertDefined( + splitAdditionLine1Content, + 'expected split additions content line 1' + ); + + expect(splitDeletionLine1.properties['data-decoration-bar']).toBe('0'); + expect(splitDeletionLine1.properties['data-decoration-bar-depth']).toBe( + '1' + ); + expect(splitDeletionLine1.properties['data-decoration-bar-start']).toBe( + '0' + ); + expect(splitDeletionLine1.properties['data-decoration-bar-end']).toBe('0'); + expect(splitAdditionLine1.properties['data-decoration-bar']).toBe('1'); + expect(splitAdditionLine1.properties['data-decoration-bar-depth']).toBe( + '1' + ); + expect(splitAdditionLine1.properties['data-decoration-bar-start']).toBe( + '1' + ); + expect(splitAdditionLine1.properties['data-decoration-bar-end']).toBe('1'); + expect( + splitDeletionLine1Content.properties['data-decoration-bg-start'] + ).toBe('0'); + expect(splitDeletionLine1Content.properties['data-decoration-bg-end']).toBe( + '0' + ); + expect( + splitAdditionLine1Content.properties['data-decoration-bg-start'] + ).toBe('1'); + expect(splitAdditionLine1Content.properties['data-decoration-bg-end']).toBe( + '1' + ); + expect( + splitDeletionLine2Gutter.properties['data-decoration-bar-start'] + ).toBe('2'); + expect(splitDeletionLine2Gutter.properties['data-decoration-bar-end']).toBe( + '2' + ); + expect( + splitDeletionLine2Gutter.properties['data-decoration-bg'] + ).toBeUndefined(); + expect( + splitDeletionLine2Gutter.properties['data-decoration-bg-depth'] + ).toBeUndefined(); + expect(splitDeletionLine2Gutter.properties.style).toBeUndefined(); + expect( + splitAdditionLine2Gutter.properties['data-decoration-bar-start'] + ).toBe('3'); + expect(splitAdditionLine2Gutter.properties['data-decoration-bar-end']).toBe( + '3' + ); + expect( + splitAdditionLine2Gutter.properties['data-decoration-bg'] + ).toBeUndefined(); + expect( + splitAdditionLine2Gutter.properties['data-decoration-bg-depth'] + ).toBeUndefined(); + expect(splitAdditionLine2Gutter.properties.style).toBeUndefined(); + expect(splitDeletionLine2.properties['data-decoration-bg']).toBe('2'); + expect(splitDeletionLine2.properties['data-decoration-bg-depth']).toBe('1'); + expect(splitDeletionLine2.properties['data-decoration-bg-start']).toBe('2'); + expect(splitDeletionLine2.properties['data-decoration-bg-end']).toBe('2'); + expect(splitDeletionLine2.properties.style).toBe( + '--diffs-decoration-bg:#111111;' + ); + expect(splitAdditionLine2.properties['data-decoration-bg']).toBe('3'); + expect(splitAdditionLine2.properties['data-decoration-bg-depth']).toBe('1'); + expect(splitAdditionLine2.properties['data-decoration-bg-start']).toBe('3'); + expect(splitAdditionLine2.properties['data-decoration-bg-end']).toBe('3'); + expect(splitAdditionLine2.properties.style).toBe( + '--diffs-decoration-bg:#222222;' + ); + + const unifiedRenderer = new DiffHunksRenderer({ + diffStyle: 'unified', + expandUnchanged: true, + }); + unifiedRenderer.setDecorations(decorations); + const unifiedResult = await unifiedRenderer.asyncRender(diff); + assertDefined( + unifiedResult.unifiedGutterAST, + 'expected unified gutter AST' + ); + assertDefined( + unifiedResult.unifiedContentAST, + 'expected unified content AST' + ); + + const unifiedLine1Gutter = findElementByProperty( + unifiedResult.unifiedGutterAST, + 'data-column-number', + 1 + ); + const unifiedLine1Content = findElementByProperty( + unifiedResult.unifiedContentAST, + 'data-line', + 1 + ); + const unifiedLine2Deletion = findElementByProperties( + unifiedResult.unifiedContentAST, + { + 'data-line': 2, + 'data-line-type': 'change-deletion', + } + ); + const unifiedLine2Addition = findElementByProperties( + unifiedResult.unifiedContentAST, + { + 'data-line': 2, + 'data-line-type': 'change-addition', + } + ); + + assertDefined(unifiedLine1Gutter, 'expected unified gutter line 1'); + assertDefined(unifiedLine1Content, 'expected unified content line 1'); + assertDefined(unifiedLine2Deletion, 'expected unified deletion line 2'); + assertDefined(unifiedLine2Addition, 'expected unified addition line 2'); + + expect(unifiedLine1Gutter.properties['data-decoration-bar']).toBe('0,1'); + expect(unifiedLine1Gutter.properties['data-decoration-bar-depth']).toBe( + '2' + ); + expect(unifiedLine1Gutter.properties['data-decoration-bar-start']).toBe( + '0,1' + ); + expect(unifiedLine1Gutter.properties['data-decoration-bar-end']).toBe( + '0,1' + ); + expect(unifiedLine1Gutter.properties.style).toBe( + '--diffs-decoration-bar-color:blue;' + ); + expect(unifiedLine1Content.properties['data-decoration-bg-start']).toBe( + '0,1' + ); + expect(unifiedLine1Content.properties['data-decoration-bg-end']).toBe( + '0,1' + ); + expect(unifiedLine2Deletion.properties['data-decoration-bg']).toBe('2'); + expect(unifiedLine2Deletion.properties['data-decoration-bg-depth']).toBe( + '1' + ); + expect(unifiedLine2Deletion.properties['data-decoration-bg-start']).toBe( + '2' + ); + expect(unifiedLine2Deletion.properties['data-decoration-bg-end']).toBe('2'); + expect(unifiedLine2Deletion.properties.style).toBe( + '--diffs-decoration-bg:#111111;' + ); + expect(unifiedLine2Addition.properties['data-decoration-bg']).toBe('3'); + expect(unifiedLine2Addition.properties['data-decoration-bg-depth']).toBe( + '1' + ); + expect(unifiedLine2Addition.properties['data-decoration-bg-start']).toBe( + '3' + ); + expect(unifiedLine2Addition.properties['data-decoration-bg-end']).toBe('3'); + expect(unifiedLine2Addition.properties.style).toBe( + '--diffs-decoration-bg:#222222;' + ); + }); + + test('unresolved renderer merges decoration attrs with merge conflict attrs', async () => { + const file = { + name: 'conflict.ts', + contents: [ + 'const before = true;', + '<<<<<<< HEAD', + 'const ours = true;', + '=======', + 'const theirs = true;', + '>>>>>>> topic', + 'const after = true;', + ].join('\n'), + }; + const { fileDiff, actions, markerRows } = + parseMergeConflictDiffFromFile(file); + const decorations: DiffDecorationItem[] = [ + { + side: 'deletions', + lineNumber: 2, + bar: true, + background: '#111111', + color: 'red', + }, + { + side: 'additions', + lineNumber: 2, + bar: true, + background: '#222222', + color: 'blue', + }, + ]; + + const renderer = new UnresolvedFileHunksRenderer({ expandUnchanged: true }); + renderer.setDecorations(decorations); + renderer.setConflictState(actions, markerRows, fileDiff); + + const result = await renderer.asyncRender(fileDiff); + assertDefined(result.unifiedGutterAST, 'expected unified gutter AST'); + assertDefined(result.unifiedContentAST, 'expected unified content AST'); + + const currentGutter = findElementByProperties(result.unifiedGutterAST, { + 'data-column-number': 2, + 'data-merge-conflict': 'current', + }); + const incomingGutter = findElementByProperties(result.unifiedGutterAST, { + 'data-column-number': 2, + 'data-merge-conflict': 'incoming', + }); + const currentLine = findElementByProperties(result.unifiedContentAST, { + 'data-line': 2, + 'data-merge-conflict': 'current', + }); + const incomingLine = findElementByProperties(result.unifiedContentAST, { + 'data-line': 2, + 'data-merge-conflict': 'incoming', + }); + + assertDefined(currentGutter, 'expected current conflict gutter line'); + assertDefined(incomingGutter, 'expected incoming conflict gutter line'); + assertDefined(currentLine, 'expected current conflict content line'); + assertDefined(incomingLine, 'expected incoming conflict content line'); + + expect(currentGutter.properties['data-decoration-bar']).toBe('0'); + expect(currentGutter.properties['data-decoration-bar-depth']).toBe('1'); + expect(currentGutter.properties['data-decoration-bar-start']).toBe('0'); + expect(currentGutter.properties['data-decoration-bar-end']).toBe('0'); + expect(currentGutter.properties['data-decoration-bg']).toBeUndefined(); + expect(currentGutter.properties['data-merge-conflict']).toBe('current'); + expect(currentGutter.properties.style).toBe( + '--diffs-decoration-bar-color:red;' + ); + expect(incomingGutter.properties['data-decoration-bar']).toBe('1'); + expect(incomingGutter.properties['data-decoration-bar-depth']).toBe('1'); + expect(incomingGutter.properties['data-decoration-bar-start']).toBe('1'); + expect(incomingGutter.properties['data-decoration-bar-end']).toBe('1'); + expect(incomingGutter.properties['data-decoration-bg']).toBeUndefined(); + expect(incomingGutter.properties['data-merge-conflict']).toBe('incoming'); + expect(incomingGutter.properties.style).toBe( + '--diffs-decoration-bar-color:blue;' + ); + expect(currentLine.properties['data-decoration-bg']).toBe('0'); + expect(currentLine.properties['data-decoration-bg-depth']).toBe('1'); + expect(currentLine.properties['data-decoration-bg-start']).toBe('0'); + expect(currentLine.properties['data-decoration-bg-end']).toBe('0'); + expect(currentLine.properties['data-merge-conflict']).toBe('current'); + expect(currentLine.properties.style).toBe('--diffs-decoration-bg:#111111;'); + expect(incomingLine.properties['data-decoration-bg']).toBe('1'); + expect(incomingLine.properties['data-decoration-bg-depth']).toBe('1'); + expect(incomingLine.properties['data-decoration-bg-start']).toBe('1'); + expect(incomingLine.properties['data-decoration-bg-end']).toBe('1'); + expect(incomingLine.properties['data-merge-conflict']).toBe('incoming'); + expect(incomingLine.properties.style).toBe( + '--diffs-decoration-bg:#222222;' + ); + }); +}); + +function findElementByProperty( + nodes: ElementContent[], + property: string, + value: string | number +): HASTElement | undefined { + return findElementByProperties(nodes, { [property]: value }); +} + +function findElementByProperties( + nodes: ElementContent[], + properties: Record +): HASTElement | undefined { + for (const node of collectAllElements(nodes)) { + if (!matchesProperties(node, properties)) { + continue; + } + return node; + } + return undefined; +} + +function matchesProperties( + node: HASTElement, + properties: Record +): boolean { + return Object.entries(properties).every(([key, value]) => { + return node.properties?.[key] === value; + }); +} From f7781fd6966ed89c31e8efc2f348a94280354f8e Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Tue, 7 Apr 2026 17:55:58 -0700 Subject: [PATCH 4/5] before ai belligerance --- apps/demo/src/main.ts | 79 +++- .../diffs/src/renderers/DiffHunksRenderer.ts | 39 +- packages/diffs/src/renderers/FileRenderer.ts | 4 +- .../src/utils/getLineDecorationProperties.ts | 74 ++- packages/diffs/src/utils/hast_utils.ts | 26 +- .../src/utils/normalizeLineDecorations.ts | 138 +++++- packages/diffs/test/decorations.test.ts | 438 ++++++++++++++++++ 7 files changed, 749 insertions(+), 49 deletions(-) diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index 853a8417c..4c0d7a17f 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -1,9 +1,11 @@ import { DEFAULT_THEMES, + type DiffDecorationItem, DIFFS_TAG_NAME, type DiffsThemeNames, File, type FileContents, + type FileDecorationItem, FileDiff, type FileDiffOptions, type FileOptions, @@ -42,8 +44,8 @@ import { renderDiffAnnotation, } from './utils/renderAnnotation'; -// FAKE_DIFF_LINE_ANNOTATIONS.length = 0; -// FAKE_LINE_ANNOTATIONS.length = 0; +FAKE_DIFF_LINE_ANNOTATIONS.length = 0; +FAKE_LINE_ANNOTATIONS.length = 0; const DEMO_THEME: DiffsThemeNames | ThemesType = DEFAULT_THEMES; const WORKER_POOL = true; const VIRTUALIZE = true; @@ -392,6 +394,7 @@ function renderDiff(parsedPatches: ParsedPatch[], manager?: WorkerPoolManager) { fileDiff, lineAnnotations: fileAnnotations, fileContainer, + decorations: DECORATIONS_DIFF, }); diffInstances.push(instance); hunkIndex++; @@ -657,6 +660,53 @@ const fileExample: FileContents | Promise = (() => { }; })(); +const DECORATIONS: FileDecorationItem[] = [ + { + lineNumber: 1, + bar: true, + /* color: 'red' */ + }, + { + lineNumber: 2, + endLineNumber: 4, + background: true, + /* color: 'blue' */ + }, + { + lineNumber: 5, + endLineNumber: 11, + bar: true, + // background: '#123456', + // color: 'orange', + }, +]; + +const DECORATIONS_DIFF: DiffDecorationItem[] = [ + { + lineNumber: 1, + side: 'deletions', + bar: true, + /* color: 'red' */ + }, + { + lineNumber: 2, + endLineNumber: 6, + side: 'additions', + bar: true, + background: 'red', + // color: 'blue', + }, + { + lineNumber: 5, + endLineNumber: 11, + side: 'additions', + bar: true, + background: true, + // background: '#123456', + // color: 'orange', + }, +]; + const fileConflict: FileContents = { name: 'file.ts', contents: FILE_CONFLICT, @@ -792,6 +842,7 @@ if (renderFileButton != null) { file, lineAnnotations: FAKE_LINE_ANNOTATIONS, fileContainer, + decorations: DECORATIONS, }); fileInstances.push(instance); }); @@ -902,15 +953,15 @@ function createCollapsedToggle( // For quick testing diffs // FAKE_DIFF_LINE_ANNOTATIONS.length = 0; -// (() => { -// const oldFile = { -// name: 'file_old.ts', -// contents: FILE_OLD, -// }; -// const newFile = { -// name: 'file_new.ts', -// contents: FILE_NEW, -// }; -// const parsed = parseDiffFromFile(oldFile, newFile); -// renderDiff([{ files: [parsed] }], poolManager); -// })(); +(() => { + const oldFile = { + name: 'file_old.ts', + contents: FILE_OLD, + }; + const newFile = { + name: 'file_new.ts', + contents: FILE_NEW, + }; + const parsed = parseDiffFromFile(oldFile, newFile); + renderDiff([{ files: [parsed] }], poolManager); +})(); diff --git a/packages/diffs/src/renderers/DiffHunksRenderer.ts b/packages/diffs/src/renderers/DiffHunksRenderer.ts index 6a5ba9f71..63feabc1a 100644 --- a/packages/diffs/src/renderers/DiffHunksRenderer.ts +++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts @@ -52,6 +52,7 @@ import { getHunkSeparatorSlotName } from '../utils/getHunkSeparatorSlotName'; import { getLineAnnotationName } from '../utils/getLineAnnotationName'; import { getLineDecorationContentProperties, + getLineDecorationGutterChildren, getLineDecorationGutterProperties, mergeHastProperties, mergeNormalizedLineDecorations, @@ -148,6 +149,7 @@ export interface SplitLineDecorationProps { export interface LineDecoration { gutterLineType: LineTypes; gutterProperties?: Properties; + gutterChildren?: ElementContent[]; contentProperties?: Properties; } @@ -367,6 +369,10 @@ export class DiffHunksRenderer< ): LineDecoration { return { ...decoration, + gutterChildren: mergeElementContents( + decoration.gutterChildren, + getLineDecorationGutterChildren(lineDecorations) + ), gutterProperties: mergeHastProperties( decoration.gutterProperties, getLineDecorationGutterProperties(lineDecorations) @@ -800,11 +806,18 @@ export class DiffHunksRenderer< lineType: LineTypes | 'buffer' | 'separator' | 'annotation', lineNumber: number, lineIndex: string, - gutterProperties: Properties | undefined + gutterProperties: Properties | undefined, + gutterChildren: ElementContent[] | undefined ) => { context.pushToGutter( type, - createGutterItem(lineType, lineNumber, lineIndex, gutterProperties) + createGutterItem( + lineType, + lineNumber, + lineIndex, + gutterProperties, + gutterChildren + ) ); }; @@ -923,7 +936,8 @@ export class DiffHunksRenderer< ? additionLine.lineNumber : deletionLine.lineNumber, `${unifiedLineIndex},${splitLineIndex}`, - unifiedLineDecoration.gutterProperties + unifiedLineDecoration.gutterProperties, + unifiedLineDecoration.gutterChildren ); if (additionLineContent != null) { additionLineContent = withContentProperties( @@ -1049,7 +1063,8 @@ export class DiffHunksRenderer< decoratedDeletionLine.gutterLineType, deletionLine.lineNumber, `${deletionLine.unifiedLineIndex},${splitLineIndex}`, - decoratedDeletionLine.gutterProperties + decoratedDeletionLine.gutterProperties, + decoratedDeletionLine.gutterChildren ); if (deletionLineDecorated != null) { deletionLineContent = deletionLineDecorated; @@ -1065,7 +1080,8 @@ export class DiffHunksRenderer< decoratedAdditionLine.gutterLineType, additionLine.lineNumber, `${additionLine.unifiedLineIndex},${splitLineIndex}`, - decoratedAdditionLine.gutterProperties + decoratedAdditionLine.gutterProperties, + decoratedAdditionLine.gutterChildren ); if (additionLineDecorated != null) { additionLineContent = additionLineDecorated; @@ -1412,6 +1428,19 @@ function getModifiedLinesString(lines: number) { return `${lines} unmodified line${lines > 1 ? 's' : ''}`; } +function mergeElementContents( + first: ElementContent[] | undefined, + second: ElementContent[] | undefined +): ElementContent[] | undefined { + if (first == null) { + return second; + } + if (second == null) { + return first; + } + return [...first, ...second]; +} + function pushUnifiedInjectedRows( rows: InjectedRow[], context: ProcessContext diff --git a/packages/diffs/src/renderers/FileRenderer.ts b/packages/diffs/src/renderers/FileRenderer.ts index c92c35468..387fd166b 100644 --- a/packages/diffs/src/renderers/FileRenderer.ts +++ b/packages/diffs/src/renderers/FileRenderer.ts @@ -34,6 +34,7 @@ import { getHighlighterOptions } from '../utils/getHighlighterOptions'; import { getLineAnnotationName } from '../utils/getLineAnnotationName'; import { getLineDecorationContentProperties, + getLineDecorationGutterChildren, getLineDecorationGutterProperties, mergeHastProperties, } from '../utils/getLineDecorationProperties'; @@ -406,7 +407,8 @@ export class FileRenderer { 'context', lineNumber, `${lineIndex}`, - getLineDecorationGutterProperties(lineDecorations) + getLineDecorationGutterProperties(lineDecorations), + getLineDecorationGutterChildren(lineDecorations) ) ); contentArray.push( diff --git a/packages/diffs/src/utils/getLineDecorationProperties.ts b/packages/diffs/src/utils/getLineDecorationProperties.ts index 7872143ca..926c360b7 100644 --- a/packages/diffs/src/utils/getLineDecorationProperties.ts +++ b/packages/diffs/src/utils/getLineDecorationProperties.ts @@ -1,10 +1,15 @@ -import type { Properties } from 'hast'; +import type { ElementContent, Properties } from 'hast'; +import { createHastElement } from './hast_utils'; import { getHigherPriorityDecoration, mergeDecorationDepth, + mergeVisibleBarLayerStacks, +} from './normalizeLineDecorations'; +import type { + NormalizedLineDecorations, + VisibleBarLayer, } from './normalizeLineDecorations'; -import type { NormalizedLineDecorations } from './normalizeLineDecorations'; export function getLineDecorationGutterProperties( decorations: NormalizedLineDecorations | undefined @@ -38,6 +43,26 @@ export function getLineDecorationContentProperties( ); } +export function getLineDecorationGutterChildren( + decorations: NormalizedLineDecorations | undefined +): ElementContent[] | undefined { + const barLayers = decorations?.barLayers; + if (barLayers == null || barLayers.length === 0) { + return undefined; + } + + return [ + createHastElement({ + tagName: 'span', + properties: { + 'data-decoration-bar-stack': '', + 'data-decoration-bar-layer-count': String(barLayers.length), + style: getLineDecorationBarStackStyle(barLayers), + }, + }), + ]; +} + export function mergeHastProperties( base: Properties | undefined, next: Properties | undefined @@ -101,19 +126,25 @@ export function mergeNormalizedLineDecorations( sourceIndex: second.backgroundSourceIndex, } ); + const barLayers = mergeVisibleBarLayerStacks( + first.barLayers, + second.barLayers + ); + const topBar = barLayers?.at(-1); return { barIndices, startIndices: mergeSortedIndices(first.startIndices, second.startIndices), endIndices: mergeSortedIndices(first.endIndices, second.endIndices), backgroundIndices, - barColor: bar?.color, - barLineNumber: bar?.lineNumber, - barSourceIndex: bar?.sourceIndex, + barColor: topBar?.color ?? bar?.color, + barLineNumber: topBar?.lineNumber ?? bar?.lineNumber, + barSourceIndex: topBar?.sourceIndex ?? bar?.sourceIndex, backgroundColor: background?.color, backgroundLineNumber: background?.lineNumber, backgroundSourceIndex: background?.sourceIndex, barDepth: mergeDecorationDepth(first.barDepth, second.barDepth), + barLayers, backgroundDepth: mergeDecorationDepth( first.backgroundDepth, second.backgroundDepth @@ -147,6 +178,39 @@ function getLineDecorationBarProperties( ); } +function getLineDecorationBarStackStyle(barLayers: VisibleBarLayer[]): string { + const serializedLayers = [...barLayers].reverse(); + const styles = [ + `--diffs-decoration-bar-layer-count:${serializedLayers.length};`, + ]; + + for (const [index, layer] of serializedLayers.entries()) { + const layerNumber = index + 1; + styles.push(`--diffs-decoration-bar-color-${layerNumber}:${layer.color};`); + styles.push( + `--diffs-decoration-bar-tier-${layerNumber}:${getBarVisualTier(layerNumber)};` + ); + styles.push( + `--diffs-decoration-bar-start-cap-${layerNumber}:${layer.showStartCap ? 1 : 0};` + ); + styles.push( + `--diffs-decoration-bar-end-cap-${layerNumber}:${layer.showEndCap ? 1 : 0};` + ); + } + + return styles.join(''); +} + +function getBarVisualTier(layerNumber: number): 1 | 2 | 3 { + if (layerNumber <= 1) { + return 1; + } + if (layerNumber === 2) { + return 2; + } + return 3; +} + function getLineDecorationProperties( dataAttribute: 'data-decoration-bar' | 'data-decoration-bg', indices: number[] | undefined, diff --git a/packages/diffs/src/utils/hast_utils.ts b/packages/diffs/src/utils/hast_utils.ts index a1990d742..b2d6c17dd 100644 --- a/packages/diffs/src/utils/hast_utils.ts +++ b/packages/diffs/src/utils/hast_utils.ts @@ -98,8 +98,21 @@ export function createGutterItem( lineType: LineTypes | 'buffer' | 'separator' | 'annotation', lineNumber: number, lineIndex: string, - properties: Properties = {} + properties: Properties = {}, + additionalChildren: ElementContent[] = [] ): HASTElement { + const children: ElementContent[] = []; + if (lineNumber != null) { + children.push( + createHastElement({ + tagName: 'span', + properties: { 'data-line-number-content': '' }, + children: [createTextNodeElement(`${lineNumber}`)], + }) + ); + } + children.push(...additionalChildren); + return createHastElement({ tagName: 'div', properties: { @@ -108,16 +121,7 @@ export function createGutterItem( 'data-line-index': lineIndex, ...properties, }, - children: - lineNumber != null - ? [ - createHastElement({ - tagName: 'span', - properties: { 'data-line-number-content': '' }, - children: [createTextNodeElement(`${lineNumber}`)], - }), - ] - : undefined, + children: children.length > 0 ? children : undefined, }); } diff --git a/packages/diffs/src/utils/normalizeLineDecorations.ts b/packages/diffs/src/utils/normalizeLineDecorations.ts index f55484d8d..12688edae 100644 --- a/packages/diffs/src/utils/normalizeLineDecorations.ts +++ b/packages/diffs/src/utils/normalizeLineDecorations.ts @@ -1,10 +1,19 @@ import type { DiffDecorationItem, FileDecorationItem } from '../types'; const DEFAULT_DECORATION_COLOR = 'var(--diffs-modified-base)'; -const MAX_VISIBLE_DECORATION_DEPTH = 3; +const MAX_DECORATION_VISUAL_DEPTH = 3; export type DecorationOverlapDepth = 1 | 2 | 3; +export interface VisibleBarLayer { + color: string; + lineNumber: number; + endLineNumber: number; + sourceIndex: number; + showStartCap: boolean; + showEndCap: boolean; +} + export interface NormalizedLineDecorations { barIndices?: number[]; startIndices?: number[]; @@ -17,6 +26,7 @@ export interface NormalizedLineDecorations { backgroundLineNumber?: number; backgroundSourceIndex?: number; barDepth?: DecorationOverlapDepth; + barLayers?: VisibleBarLayer[]; backgroundDepth?: DecorationOverlapDepth; } @@ -74,7 +84,12 @@ function applyDecorationRange( const barState = barColor == null ? undefined - : createDecorationWinner(decoration.lineNumber, sourceIndex, barColor); + : createVisibleBarLayer( + decoration.lineNumber, + range.endLineNumber, + sourceIndex, + barColor + ); const backgroundState = backgroundColor == null ? undefined @@ -97,17 +112,15 @@ function applyDecorationRange( lineDecorations.barDepth = incrementDecorationDepth( lineDecorations.barDepth ); - const nextBar = getHigherPriorityDecoration( - { - color: lineDecorations.barColor, - lineNumber: lineDecorations.barLineNumber, - sourceIndex: lineDecorations.barSourceIndex, - }, - barState + lineDecorations.barLayers = mergeVisibleBarLayersForLine( + lineDecorations.barLayers, + barState, + lineNumber ); - lineDecorations.barColor = nextBar?.color; - lineDecorations.barLineNumber = nextBar?.lineNumber; - lineDecorations.barSourceIndex = nextBar?.sourceIndex; + const topBar = lineDecorations.barLayers.at(-1); + lineDecorations.barColor = topBar?.color; + lineDecorations.barLineNumber = topBar?.lineNumber; + lineDecorations.barSourceIndex = topBar?.sourceIndex; } if (backgroundState != null) { const backgroundIndices = lineDecorations.backgroundIndices ?? []; @@ -226,6 +239,24 @@ export function mergeDecorationDepth( return getDecorationDepth(first + second); } +export function mergeVisibleBarLayerStacks( + first: VisibleBarLayer[] | undefined, + second: VisibleBarLayer[] | undefined +): VisibleBarLayer[] | undefined { + if (first == null || first.length === 0) { + return second; + } + if (second == null || second.length === 0) { + return first; + } + + const merged = sortVisibleBarLayers([ + ...first.map(cloneVisibleBarLayer), + ...second.map(cloneVisibleBarLayer), + ]); + return resolveMergedBarLayerCaps(merged); +} + function getNormalizedRange( lineNumber: number, endLineNumber: number | undefined @@ -275,6 +306,22 @@ function createDecorationWinner( }; } +function createVisibleBarLayer( + lineNumber: number, + endLineNumber: number, + sourceIndex: number, + color: string +): VisibleBarLayer { + return { + color, + lineNumber, + endLineNumber, + sourceIndex, + showStartCap: false, + showEndCap: false, + }; +} + // This keeps overlap resolution incremental so renderers can read one finished // winner per line instead of re-sorting active decorations. function compareDecorationPriority( @@ -289,6 +336,71 @@ function compareDecorationPriority( return first.sourceIndex - second.sourceIndex; } +function mergeVisibleBarLayersForLine( + current: VisibleBarLayer[] | undefined, + next: VisibleBarLayer, + lineNumber: number +): VisibleBarLayer[] { + const merged = sortVisibleBarLayers( + current == null + ? [cloneVisibleBarLayer(next)] + : [...current.map(cloneVisibleBarLayer), cloneVisibleBarLayer(next)] + ); + return resolveBarLayerCapsForLine(merged, lineNumber); +} + +function compareVisibleBarLayerPriority( + first: VisibleBarLayer, + second: VisibleBarLayer +): number { + return compareDecorationPriority(first, second); +} + +function sortVisibleBarLayers(layers: VisibleBarLayer[]): VisibleBarLayer[] { + layers.sort(compareVisibleBarLayerPriority); + return layers; +} + +function resolveBarLayerCapsForLine( + layers: VisibleBarLayer[], + lineNumber: number +): VisibleBarLayer[] { + const resolved = layers.map((layer) => ({ + ...layer, + showStartCap: layer.lineNumber === lineNumber, + showEndCap: false, + })); + let hasHigherContinuingBelow = false; + for (let index = resolved.length - 1; index >= 0; index--) { + const layer = resolved[index]; + layer.showEndCap = + layer.endLineNumber === lineNumber && !hasHigherContinuingBelow; + if (layer.endLineNumber > lineNumber) { + hasHigherContinuingBelow = true; + } + } + return resolved; +} + +function resolveMergedBarLayerCaps( + layers: VisibleBarLayer[] +): VisibleBarLayer[] { + const resolved = layers.map(cloneVisibleBarLayer); + let hasHigherContinuingBelow = false; + for (let index = resolved.length - 1; index >= 0; index--) { + const layer = resolved[index]; + layer.showEndCap = layer.showEndCap && !hasHigherContinuingBelow; + if (!layer.showEndCap) { + hasHigherContinuingBelow = true; + } + } + return resolved; +} + +function cloneVisibleBarLayer(layer: VisibleBarLayer): VisibleBarLayer { + return { ...layer }; +} + function incrementDecorationDepth( current: DecorationOverlapDepth | undefined ): DecorationOverlapDepth { @@ -302,5 +414,5 @@ function getDecorationDepth(depth: number): DecorationOverlapDepth { if (depth === 2) { return 2; } - return MAX_VISIBLE_DECORATION_DEPTH; + return MAX_DECORATION_VISUAL_DEPTH; } diff --git a/packages/diffs/test/decorations.test.ts b/packages/diffs/test/decorations.test.ts index e7d0e389e..e9793cc1f 100644 --- a/packages/diffs/test/decorations.test.ts +++ b/packages/diffs/test/decorations.test.ts @@ -5,6 +5,10 @@ import { DiffHunksRenderer, FileRenderer, parseDiffFromFile } from '../src'; import { UnresolvedFileHunksRenderer } from '../src/renderers/UnresolvedFileHunksRenderer'; import type { DiffDecorationItem, FileDecorationItem } from '../src/types'; import { mergeNormalizedLineDecorations } from '../src/utils/getLineDecorationProperties'; +import { + normalizeDiffDecorations, + normalizeFileDecorations, +} from '../src/utils/normalizeLineDecorations'; import { parseMergeConflictDiffFromFile } from '../src/utils/parseMergeConflictDiffFromFile'; import { assertDefined, collectAllElements } from './testUtils'; @@ -67,11 +71,45 @@ describe('Decoration Rendering', () => { assertDefined(contentLine2, 'expected second content line'); assertDefined(contentLine3, 'expected third content line'); + const gutterLine1BarStack = findElementByProperty( + gutterLine1.children, + 'data-decoration-bar-stack', + '' + ); + const gutterLine2BarStack = findElementByProperty( + gutterLine2.children, + 'data-decoration-bar-stack', + '' + ); + const gutterLine3BarStack = findElementByProperty( + gutterLine3.children, + 'data-decoration-bar-stack', + '' + ); + + assertDefined(gutterLine1BarStack, 'expected first gutter bar stack'); + assertDefined(gutterLine2BarStack, 'expected second gutter bar stack'); + assertDefined(gutterLine3BarStack, 'expected third gutter bar stack'); + expect(gutterLine1.properties['data-decoration-bar']).toBe('0,1'); + expect( + gutterLine1BarStack.properties['data-decoration-bar-layer-count'] + ).toBe('2'); + expect(gutterLine1BarStack.children).toHaveLength(0); + expect(gutterLine1BarStack.properties.style).toBe( + '--diffs-decoration-bar-layer-count:2;--diffs-decoration-bar-color-1:green;--diffs-decoration-bar-tier-1:1;--diffs-decoration-bar-start-cap-1:1;--diffs-decoration-bar-end-cap-1:0;--diffs-decoration-bar-color-2:red;--diffs-decoration-bar-tier-2:2;--diffs-decoration-bar-start-cap-2:1;--diffs-decoration-bar-end-cap-2:0;' + ); expect(gutterLine1.properties['data-decoration-bar-depth']).toBe('2'); expect(gutterLine1.properties['data-decoration-bar-start']).toBe('0,1'); expect(gutterLine1.properties['data-decoration-bar-end']).toBe('0'); expect(gutterLine2.properties['data-decoration-bar']).toBe('1,3'); + expect( + gutterLine2BarStack.properties['data-decoration-bar-layer-count'] + ).toBe('2'); + expect(gutterLine2BarStack.children).toHaveLength(0); + expect(gutterLine2BarStack.properties.style).toBe( + '--diffs-decoration-bar-layer-count:2;--diffs-decoration-bar-color-1:orange;--diffs-decoration-bar-tier-1:1;--diffs-decoration-bar-start-cap-1:1;--diffs-decoration-bar-end-cap-1:1;--diffs-decoration-bar-color-2:green;--diffs-decoration-bar-tier-2:2;--diffs-decoration-bar-start-cap-2:0;--diffs-decoration-bar-end-cap-2:0;' + ); expect(gutterLine2.properties['data-decoration-bar-depth']).toBe('2'); expect(gutterLine2.properties['data-decoration-bar-start']).toBe('2,3'); expect(gutterLine2.properties['data-decoration-bar-end']).toBe('3'); @@ -81,6 +119,13 @@ describe('Decoration Rendering', () => { '--diffs-decoration-bar-color:orange;' ); expect(gutterLine3.properties['data-decoration-bar']).toBe('1'); + expect( + gutterLine3BarStack.properties['data-decoration-bar-layer-count'] + ).toBe('1'); + expect(gutterLine3BarStack.children).toHaveLength(0); + expect(gutterLine3BarStack.properties.style).toBe( + '--diffs-decoration-bar-layer-count:1;--diffs-decoration-bar-color-1:green;--diffs-decoration-bar-tier-1:1;--diffs-decoration-bar-start-cap-1:0;--diffs-decoration-bar-end-cap-1:1;' + ); expect(gutterLine3.properties['data-decoration-bar-depth']).toBe('1'); expect(gutterLine3.properties['data-decoration-bar-start']).toBeUndefined(); expect(gutterLine3.properties['data-decoration-bar-end']).toBe('1'); @@ -151,6 +196,51 @@ describe('Decoration Rendering', () => { ); }); + test('file renderer keeps one bar stack element while bar depth clamps at 3', async () => { + const file = { + name: 'example.ts', + contents: ['one', 'two', 'three', 'four'].join('\n'), + }; + const decorations: FileDecorationItem[] = [ + { lineNumber: 1, endLineNumber: 4, bar: true, color: 'red' }, + { lineNumber: 2, endLineNumber: 4, bar: true, color: 'blue' }, + { lineNumber: 2, endLineNumber: 4, bar: true, color: 'green' }, + { lineNumber: 3, endLineNumber: 4, bar: true, color: 'yellow' }, + ]; + + const renderer = new FileRenderer(); + renderer.setDecorations(decorations); + const result = await renderer.asyncRender(file); + const codeAST = renderer.renderCodeAST(result) as HASTElement[]; + const [gutter] = codeAST; + assertDefined(gutter, 'expected gutter column'); + + const gutterLine4 = findElementByProperty( + gutter.children, + 'data-column-number', + 4 + ); + + assertDefined(gutterLine4, 'expected fourth gutter line'); + + const gutterLine4BarStack = findElementByProperty( + gutterLine4.children, + 'data-decoration-bar-stack', + '' + ); + + assertDefined(gutterLine4BarStack, 'expected fourth gutter bar stack'); + expect(gutterLine4.properties['data-decoration-bar']).toBe('0,1,2,3'); + expect(gutterLine4.properties['data-decoration-bar-depth']).toBe('3'); + expect( + gutterLine4BarStack.properties['data-decoration-bar-layer-count'] + ).toBe('4'); + expect(gutterLine4BarStack.children).toHaveLength(0); + expect(gutterLine4BarStack.properties.style).toBe( + '--diffs-decoration-bar-layer-count:4;--diffs-decoration-bar-color-1:yellow;--diffs-decoration-bar-tier-1:1;--diffs-decoration-bar-start-cap-1:0;--diffs-decoration-bar-end-cap-1:1;--diffs-decoration-bar-color-2:green;--diffs-decoration-bar-tier-2:2;--diffs-decoration-bar-start-cap-2:0;--diffs-decoration-bar-end-cap-2:1;--diffs-decoration-bar-color-3:blue;--diffs-decoration-bar-tier-3:3;--diffs-decoration-bar-start-cap-3:0;--diffs-decoration-bar-end-cap-3:1;--diffs-decoration-bar-color-4:red;--diffs-decoration-bar-tier-4:3;--diffs-decoration-bar-start-cap-4:0;--diffs-decoration-bar-end-cap-4:1;' + ); + }); + test('merged normalized decorations keep source-order identity and line-number winners', () => { const merged = mergeNormalizedLineDecorations( { @@ -175,6 +265,339 @@ describe('Decoration Rendering', () => { expect(merged.backgroundColor).toBe('#111111'); }); + test('merged normalized decorations recompute bar end caps against merged visible order', () => { + const merged = mergeNormalizedLineDecorations( + { + barIndices: [0], + barDepth: 1, + barColor: 'red', + barLineNumber: 1, + barSourceIndex: 0, + barLayers: [ + { + color: 'red', + lineNumber: 1, + endLineNumber: 1, + sourceIndex: 0, + showStartCap: true, + showEndCap: true, + }, + ], + }, + { + barIndices: [1], + barDepth: 1, + barColor: 'blue', + barLineNumber: 2, + barSourceIndex: 1, + barLayers: [ + { + color: 'blue', + lineNumber: 2, + endLineNumber: 3, + sourceIndex: 1, + showStartCap: false, + showEndCap: false, + }, + ], + } + ); + + assertDefined(merged, 'expected merged line decorations'); + expect(merged.barDepth).toBe(2); + expect(merged.barColor).toBe('blue'); + expect(merged.barLayers).toEqual([ + { + color: 'red', + lineNumber: 1, + endLineNumber: 1, + sourceIndex: 0, + showStartCap: true, + showEndCap: false, + }, + { + color: 'blue', + lineNumber: 2, + endLineNumber: 3, + sourceIndex: 1, + showStartCap: false, + showEndCap: false, + }, + ]); + }); + + test('merged normalized decorations keep all bar layers while clamping depth', () => { + const merged = mergeNormalizedLineDecorations( + { + barIndices: [0, 1], + barDepth: 2, + barColor: 'blue', + barLineNumber: 2, + barSourceIndex: 1, + barLayers: [ + { + color: 'red', + lineNumber: 1, + endLineNumber: 4, + sourceIndex: 0, + showStartCap: false, + showEndCap: true, + }, + { + color: 'blue', + lineNumber: 2, + endLineNumber: 4, + sourceIndex: 1, + showStartCap: false, + showEndCap: true, + }, + ], + }, + { + barIndices: [2, 3], + barDepth: 2, + barColor: 'yellow', + barLineNumber: 3, + barSourceIndex: 3, + barLayers: [ + { + color: 'green', + lineNumber: 2, + endLineNumber: 4, + sourceIndex: 2, + showStartCap: false, + showEndCap: true, + }, + { + color: 'yellow', + lineNumber: 3, + endLineNumber: 4, + sourceIndex: 3, + showStartCap: false, + showEndCap: true, + }, + ], + } + ); + + assertDefined(merged, 'expected merged line decorations'); + expect(merged.barDepth).toBe(3); + expect(merged.barColor).toBe('yellow'); + expect(merged.barLayers).toEqual([ + { + color: 'red', + lineNumber: 1, + endLineNumber: 4, + sourceIndex: 0, + showStartCap: false, + showEndCap: true, + }, + { + color: 'blue', + lineNumber: 2, + endLineNumber: 4, + sourceIndex: 1, + showStartCap: false, + showEndCap: true, + }, + { + color: 'green', + lineNumber: 2, + endLineNumber: 4, + sourceIndex: 2, + showStartCap: false, + showEndCap: true, + }, + { + color: 'yellow', + lineNumber: 3, + endLineNumber: 4, + sourceIndex: 3, + showStartCap: false, + showEndCap: true, + }, + ]); + }); + + test('file normalization keeps all visible bar layers while clamping bar depth', () => { + const normalized = normalizeFileDecorations([ + { lineNumber: 1, endLineNumber: 4, bar: true, color: 'red' }, + { lineNumber: 2, endLineNumber: 4, bar: true, color: 'blue' }, + { lineNumber: 2, endLineNumber: 4, bar: true, color: 'green' }, + { lineNumber: 3, endLineNumber: 4, bar: true, color: 'yellow' }, + ]); + + const line2 = normalized[2]; + const line4 = normalized[4]; + + assertDefined(line2, 'expected normalized line 2 decorations'); + assertDefined(line4, 'expected normalized line 4 decorations'); + + expect(line2.barDepth).toBe(3); + expect(line2.barColor).toBe('green'); + expect(line2.barLineNumber).toBe(2); + expect(line2.barSourceIndex).toBe(2); + expect(line2.barLayers).toEqual([ + { + color: 'red', + lineNumber: 1, + endLineNumber: 4, + sourceIndex: 0, + showStartCap: false, + showEndCap: false, + }, + { + color: 'blue', + lineNumber: 2, + endLineNumber: 4, + sourceIndex: 1, + showStartCap: true, + showEndCap: false, + }, + { + color: 'green', + lineNumber: 2, + endLineNumber: 4, + sourceIndex: 2, + showStartCap: true, + showEndCap: false, + }, + ]); + + expect(line4.barIndices).toEqual([0, 1, 2, 3]); + expect(line4.barDepth).toBe(3); + expect(line4.barColor).toBe('yellow'); + expect(line4.barLineNumber).toBe(3); + expect(line4.barSourceIndex).toBe(3); + expect(line4.barLayers).toEqual([ + { + color: 'red', + lineNumber: 1, + endLineNumber: 4, + sourceIndex: 0, + showStartCap: false, + showEndCap: true, + }, + { + color: 'blue', + lineNumber: 2, + endLineNumber: 4, + sourceIndex: 1, + showStartCap: false, + showEndCap: true, + }, + { + color: 'green', + lineNumber: 2, + endLineNumber: 4, + sourceIndex: 2, + showStartCap: false, + showEndCap: true, + }, + { + color: 'yellow', + lineNumber: 3, + endLineNumber: 4, + sourceIndex: 3, + showStartCap: false, + showEndCap: true, + }, + ]); + }); + + test('file normalization hides lower bar end caps when a higher layer continues below', () => { + const normalized = normalizeFileDecorations([ + { lineNumber: 1, endLineNumber: 1, bar: true, color: 'red' }, + { lineNumber: 1, endLineNumber: 2, bar: true, color: 'blue' }, + ]); + + expect(normalized[1]?.barLayers).toEqual([ + { + color: 'red', + lineNumber: 1, + endLineNumber: 1, + sourceIndex: 0, + showStartCap: true, + showEndCap: false, + }, + { + color: 'blue', + lineNumber: 1, + endLineNumber: 2, + sourceIndex: 1, + showStartCap: true, + showEndCap: false, + }, + ]); + expect(normalized[2]?.barLayers).toEqual([ + { + color: 'blue', + lineNumber: 1, + endLineNumber: 2, + sourceIndex: 1, + showStartCap: false, + showEndCap: true, + }, + ]); + }); + + test('diff normalization keeps per-side visible bar layers', () => { + const normalized = normalizeDiffDecorations([ + { + side: 'deletions', + lineNumber: 1, + endLineNumber: 2, + bar: true, + color: 'red', + }, + { + side: 'additions', + lineNumber: 1, + endLineNumber: 2, + bar: true, + color: 'blue', + }, + { + side: 'deletions', + lineNumber: 2, + endLineNumber: 2, + bar: true, + color: 'green', + }, + ]); + + expect(normalized.deletions[2]?.barLayers).toEqual([ + { + color: 'red', + lineNumber: 1, + endLineNumber: 2, + sourceIndex: 0, + showStartCap: false, + showEndCap: true, + }, + { + color: 'green', + lineNumber: 2, + endLineNumber: 2, + sourceIndex: 2, + showStartCap: true, + showEndCap: true, + }, + ]); + expect(normalized.deletions[2]?.barColor).toBe('green'); + expect(normalized.additions[2]?.barLayers).toEqual([ + { + color: 'blue', + lineNumber: 1, + endLineNumber: 2, + sourceIndex: 1, + showStartCap: false, + showEndCap: true, + }, + ]); + expect(normalized.additions[2]?.barColor).toBe('blue'); + }); + test('diff renderer keeps split decorations side-owned and combines unified overlaps', async () => { const oldFile = { name: 'example.ts', @@ -397,7 +820,22 @@ describe('Decoration Rendering', () => { assertDefined(unifiedLine2Deletion, 'expected unified deletion line 2'); assertDefined(unifiedLine2Addition, 'expected unified addition line 2'); + const unifiedLine1BarStack = findElementByProperty( + unifiedLine1Gutter.children, + 'data-decoration-bar-stack', + '' + ); + + assertDefined(unifiedLine1BarStack, 'expected unified gutter bar stack'); + expect(unifiedLine1Gutter.properties['data-decoration-bar']).toBe('0,1'); + expect( + unifiedLine1BarStack.properties['data-decoration-bar-layer-count'] + ).toBe('2'); + expect(unifiedLine1BarStack.children).toHaveLength(0); + expect(unifiedLine1BarStack.properties.style).toBe( + '--diffs-decoration-bar-layer-count:2;--diffs-decoration-bar-color-1:blue;--diffs-decoration-bar-tier-1:1;--diffs-decoration-bar-start-cap-1:1;--diffs-decoration-bar-end-cap-1:1;--diffs-decoration-bar-color-2:red;--diffs-decoration-bar-tier-2:2;--diffs-decoration-bar-start-cap-2:1;--diffs-decoration-bar-end-cap-2:1;' + ); expect(unifiedLine1Gutter.properties['data-decoration-bar-depth']).toBe( '2' ); From 041859ae59d944b81b48bd82528ee39669ea0234 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Tue, 7 Apr 2026 17:56:08 -0700 Subject: [PATCH 5/5] sorting through ai belligerancy --- apps/demo/src/main.ts | 39 ++- .../src/utils/getLineDecorationProperties.ts | 78 +++-- .../src/utils/normalizeLineDecorations.ts | 77 +++++ packages/diffs/test/decorations.test.ts | 272 +++++++++++++++++- 4 files changed, 409 insertions(+), 57 deletions(-) diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index 4c0d7a17f..665802589 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -202,6 +202,7 @@ function renderDiff(parsedPatches: ParsedPatch[], manager?: WorkerPoolManager) { | FileDiff | VirtualizedFileDiff; const options: FileDiffOptions = { + expandUnchanged: true, theme: DEMO_THEME, themeType, diffStyle: unified ? 'unified' : 'split', @@ -226,7 +227,7 @@ function renderDiff(parsedPatches: ParsedPatch[], manager?: WorkerPoolManager) { // expandUnchanged: true, // Hover Decoration Snippets - enableGutterUtility: true, + // enableGutterUtility: true, // onGutterUtilityClick(event) { // console.log('onGutterUtilityClick', event); // }, @@ -682,28 +683,40 @@ const DECORATIONS: FileDecorationItem[] = [ ]; const DECORATIONS_DIFF: DiffDecorationItem[] = [ - { - lineNumber: 1, - side: 'deletions', - bar: true, - /* color: 'red' */ - }, { lineNumber: 2, endLineNumber: 6, side: 'additions', bar: true, + // color: 'red', background: 'red', - // color: 'blue', }, { lineNumber: 5, - endLineNumber: 11, + endLineNumber: 6, + side: 'additions', + bar: true, + background: true, + }, + { + lineNumber: 7, + side: 'additions', + bar: true, + background: true, + }, + { + lineNumber: 9, + endLineNumber: 15, + side: 'additions', + bar: true, + background: true, + }, + { + lineNumber: 12, + endLineNumber: 15, side: 'additions', bar: true, background: true, - // background: '#123456', - // color: 'orange', }, ]; @@ -777,7 +790,7 @@ if (renderFileButton != null) { // }, // Hover Decoration Snippets - enableGutterUtility: true, + // enableGutterUtility: true, // onGutterUtilityClick(event) { // console.log('onGutterUtilityClick', event); // }, @@ -867,7 +880,7 @@ if (renderFileConflictButton != null) { overflow: wrap ? 'wrap' : 'scroll', renderAnnotation, enableLineSelection: true, - enableGutterUtility: true, + // enableGutterUtility: true, maxContextLines: 4, // Token Testing Helpers diff --git a/packages/diffs/src/utils/getLineDecorationProperties.ts b/packages/diffs/src/utils/getLineDecorationProperties.ts index 926c360b7..04cf94fe5 100644 --- a/packages/diffs/src/utils/getLineDecorationProperties.ts +++ b/packages/diffs/src/utils/getLineDecorationProperties.ts @@ -51,13 +51,20 @@ export function getLineDecorationGutterChildren( return undefined; } + const visualBarLayers = collapseBarLayersForRendering(barLayers); + return [ createHastElement({ tagName: 'span', properties: { 'data-decoration-bar-stack': '', - 'data-decoration-bar-layer-count': String(barLayers.length), - style: getLineDecorationBarStackStyle(barLayers), + 'data-decoration-bar-layer-count': String(visualBarLayers.length), + 'data-decoration-bar-overlap': + visualBarLayers.length > 1 ? '' : undefined, + 'data-decoration-bar-second': + visualBarLayers.length > 1 ? '' : undefined, + 'data-decoration-bar-third': + visualBarLayers.length > 2 ? '' : undefined, }, }), ]; @@ -131,6 +138,12 @@ export function mergeNormalizedLineDecorations( second.barLayers ); const topBar = barLayers?.at(-1); + const topBarDepth = + topBar?.sourceIndex === first.barSourceIndex + ? first.barDepth + : topBar?.sourceIndex === second.barSourceIndex + ? second.barDepth + : undefined; return { barIndices, @@ -143,7 +156,7 @@ export function mergeNormalizedLineDecorations( backgroundColor: background?.color, backgroundLineNumber: background?.lineNumber, backgroundSourceIndex: background?.sourceIndex, - barDepth: mergeDecorationDepth(first.barDepth, second.barDepth), + barDepth: topBarDepth, barLayers, backgroundDepth: mergeDecorationDepth( first.backgroundDepth, @@ -155,6 +168,8 @@ export function mergeNormalizedLineDecorations( function getLineDecorationBarProperties( decorations: NormalizedLineDecorations | undefined ): Properties | undefined { + const topmostBarEndIndices = getTopmostBarEndIndices(decorations); + return mergeHastProperties( mergeHastProperties( getLineDecorationProperties( @@ -173,42 +188,45 @@ function getLineDecorationBarProperties( 'data-decoration-bar-start', decorations?.startIndices, 'data-decoration-bar-end', - decorations?.endIndices + topmostBarEndIndices ) ); } -function getLineDecorationBarStackStyle(barLayers: VisibleBarLayer[]): string { - const serializedLayers = [...barLayers].reverse(); - const styles = [ - `--diffs-decoration-bar-layer-count:${serializedLayers.length};`, - ]; - - for (const [index, layer] of serializedLayers.entries()) { - const layerNumber = index + 1; - styles.push(`--diffs-decoration-bar-color-${layerNumber}:${layer.color};`); - styles.push( - `--diffs-decoration-bar-tier-${layerNumber}:${getBarVisualTier(layerNumber)};` - ); - styles.push( - `--diffs-decoration-bar-start-cap-${layerNumber}:${layer.showStartCap ? 1 : 0};` - ); - styles.push( - `--diffs-decoration-bar-end-cap-${layerNumber}:${layer.showEndCap ? 1 : 0};` - ); +function getTopmostBarEndIndices( + decorations: NormalizedLineDecorations | undefined +): number[] | undefined { + const topmostBarSourceIndex = decorations?.barSourceIndex; + if (topmostBarSourceIndex == null) { + return undefined; } - return styles.join(''); + return (decorations?.endIndices?.includes(topmostBarSourceIndex) ?? false) + ? [topmostBarSourceIndex] + : undefined; } -function getBarVisualTier(layerNumber: number): 1 | 2 | 3 { - if (layerNumber <= 1) { - return 1; - } - if (layerNumber === 2) { - return 2; +// When adjacent visible layers share the same bar color, render them as one +// continuous visual bar so overlap identity does not create artificial gaps. +function collapseBarLayersForRendering( + barLayers: VisibleBarLayer[] +): VisibleBarLayer[] { + const serializedLayers = [...barLayers].reverse(); + const collapsed: VisibleBarLayer[] = []; + + for (const layer of serializedLayers) { + const previousLayer = collapsed.at(-1); + if (previousLayer?.color !== layer.color) { + collapsed.push({ ...layer }); + continue; + } + + previousLayer.showStartCap = + previousLayer.showStartCap && layer.showStartCap; + previousLayer.showEndCap = previousLayer.showEndCap && layer.showEndCap; } - return 3; + + return collapsed.reverse(); } function getLineDecorationProperties( diff --git a/packages/diffs/src/utils/normalizeLineDecorations.ts b/packages/diffs/src/utils/normalizeLineDecorations.ts index 12688edae..faba2db27 100644 --- a/packages/diffs/src/utils/normalizeLineDecorations.ts +++ b/packages/diffs/src/utils/normalizeLineDecorations.ts @@ -151,6 +151,7 @@ export function normalizeFileDecorations( for (const [sourceIndex, decoration] of decorations.entries()) { applyDecorationRange(normalized, decoration, sourceIndex); } + finalizeBarDepths(normalized); return normalized; } @@ -164,6 +165,8 @@ export function normalizeDiffDecorations( for (const [sourceIndex, decoration] of decorations.entries()) { applyDecorationRange(normalized[decoration.side], decoration, sourceIndex); } + finalizeBarDepths(normalized.additions); + finalizeBarDepths(normalized.deletions); return normalized; } @@ -401,6 +404,80 @@ function cloneVisibleBarLayer(layer: VisibleBarLayer): VisibleBarLayer { return { ...layer }; } +function finalizeBarDepths(map: NormalizedLineDecorationMap): void { + const priorityBySourceIndex = new Map< + number, + { lineNumber: number; sourceIndex: number } + >(); + const higherNeighborsBySourceIndex = new Map>(); + + for (const lineDecorations of Object.values(map)) { + const barLayers = lineDecorations?.barLayers; + if (barLayers == null || barLayers.length === 0) { + continue; + } + + for (const layer of barLayers) { + if (!priorityBySourceIndex.has(layer.sourceIndex)) { + priorityBySourceIndex.set(layer.sourceIndex, { + lineNumber: layer.lineNumber, + sourceIndex: layer.sourceIndex, + }); + } + } + + for (let index = 0; index < barLayers.length - 1; index++) { + const lowerLayer = barLayers[index]; + const higherLayer = barLayers[index + 1]; + if (lowerLayer == null || higherLayer == null) { + continue; + } + + const higherNeighbors = + higherNeighborsBySourceIndex.get(lowerLayer.sourceIndex) ?? new Set(); + higherNeighborsBySourceIndex.set( + lowerLayer.sourceIndex, + higherNeighbors + ); + higherNeighbors.add(higherLayer.sourceIndex); + } + } + + const coveredDepthBySourceIndex = new Map(); + const sortedSourceIndices = [...priorityBySourceIndex.values()] + .sort((first, second) => compareDecorationPriority(second, first)) + .map(({ sourceIndex }) => sourceIndex); + + for (const sourceIndex of sortedSourceIndices) { + const higherNeighbors = higherNeighborsBySourceIndex.get(sourceIndex); + if (higherNeighbors == null || higherNeighbors.size === 0) { + coveredDepthBySourceIndex.set(sourceIndex, 0); + continue; + } + + let maxCoveredDepth = 0; + for (const higherSourceIndex of higherNeighbors) { + const higherCoveredDepth = coveredDepthBySourceIndex.get(higherSourceIndex) ?? 0; + if (higherCoveredDepth + 1 > maxCoveredDepth) { + maxCoveredDepth = higherCoveredDepth + 1; + } + } + + coveredDepthBySourceIndex.set(sourceIndex, maxCoveredDepth); + } + + for (const lineDecorations of Object.values(map)) { + const topBarSourceIndex = lineDecorations?.barSourceIndex; + if (topBarSourceIndex == null || lineDecorations == null) { + continue; + } + + const coveredCount = coveredDepthBySourceIndex.get(topBarSourceIndex) ?? 0; + lineDecorations.barDepth = + coveredCount > 0 ? getDecorationDepth(coveredCount) : undefined; + } +} + function incrementDecorationDepth( current: DecorationOverlapDepth | undefined ): DecorationOverlapDepth { diff --git a/packages/diffs/test/decorations.test.ts b/packages/diffs/test/decorations.test.ts index e9793cc1f..a0ceea7fc 100644 --- a/packages/diffs/test/decorations.test.ts +++ b/packages/diffs/test/decorations.test.ts @@ -95,10 +95,24 @@ describe('Decoration Rendering', () => { expect( gutterLine1BarStack.properties['data-decoration-bar-layer-count'] ).toBe('2'); - expect(gutterLine1BarStack.children).toHaveLength(0); - expect(gutterLine1BarStack.properties.style).toBe( - '--diffs-decoration-bar-layer-count:2;--diffs-decoration-bar-color-1:green;--diffs-decoration-bar-tier-1:1;--diffs-decoration-bar-start-cap-1:1;--diffs-decoration-bar-end-cap-1:0;--diffs-decoration-bar-color-2:red;--diffs-decoration-bar-tier-2:2;--diffs-decoration-bar-start-cap-2:1;--diffs-decoration-bar-end-cap-2:0;' + expect(gutterLine1BarStack.properties['data-decoration-bar-overlap']).toBe( + '' ); + expect(gutterLine1BarStack.properties['data-decoration-bar-second']).toBe( + '' + ); + expect( + gutterLine1BarStack.properties['data-decoration-bar-third'] + ).toBeUndefined(); + expect(gutterLine1BarStack.children).toHaveLength(0); + expectStyleContains(gutterLine1BarStack.properties.style, [ + '--diffs-decoration-bar-width-1:6px;', + '--diffs-decoration-bar-color-1:green;', + '--diffs-decoration-bar-start-radius-1:4px;', + '--diffs-decoration-bar-end-radius-1:0px;', + '--diffs-decoration-bar-width-2:10px;', + 'color-mix(in lab, red 74%, var(--diffs-bg))', + ]); expect(gutterLine1.properties['data-decoration-bar-depth']).toBe('2'); expect(gutterLine1.properties['data-decoration-bar-start']).toBe('0,1'); expect(gutterLine1.properties['data-decoration-bar-end']).toBe('0'); @@ -106,10 +120,24 @@ describe('Decoration Rendering', () => { expect( gutterLine2BarStack.properties['data-decoration-bar-layer-count'] ).toBe('2'); - expect(gutterLine2BarStack.children).toHaveLength(0); - expect(gutterLine2BarStack.properties.style).toBe( - '--diffs-decoration-bar-layer-count:2;--diffs-decoration-bar-color-1:orange;--diffs-decoration-bar-tier-1:1;--diffs-decoration-bar-start-cap-1:1;--diffs-decoration-bar-end-cap-1:1;--diffs-decoration-bar-color-2:green;--diffs-decoration-bar-tier-2:2;--diffs-decoration-bar-start-cap-2:0;--diffs-decoration-bar-end-cap-2:0;' + expect(gutterLine2BarStack.properties['data-decoration-bar-overlap']).toBe( + '' ); + expect(gutterLine2BarStack.properties['data-decoration-bar-second']).toBe( + '' + ); + expect( + gutterLine2BarStack.properties['data-decoration-bar-third'] + ).toBeUndefined(); + expect(gutterLine2BarStack.children).toHaveLength(0); + expectStyleContains(gutterLine2BarStack.properties.style, [ + '--diffs-decoration-bar-width-1:6px;', + '--diffs-decoration-bar-color-1:orange;', + '--diffs-decoration-bar-start-radius-1:4px;', + '--diffs-decoration-bar-end-radius-1:4px;', + '--diffs-decoration-bar-width-2:10px;', + 'color-mix(in lab, green 74%, var(--diffs-bg))', + ]); expect(gutterLine2.properties['data-decoration-bar-depth']).toBe('2'); expect(gutterLine2.properties['data-decoration-bar-start']).toBe('2,3'); expect(gutterLine2.properties['data-decoration-bar-end']).toBe('3'); @@ -122,10 +150,22 @@ describe('Decoration Rendering', () => { expect( gutterLine3BarStack.properties['data-decoration-bar-layer-count'] ).toBe('1'); + expect( + gutterLine3BarStack.properties['data-decoration-bar-overlap'] + ).toBeUndefined(); + expect( + gutterLine3BarStack.properties['data-decoration-bar-second'] + ).toBeUndefined(); + expect( + gutterLine3BarStack.properties['data-decoration-bar-third'] + ).toBeUndefined(); expect(gutterLine3BarStack.children).toHaveLength(0); - expect(gutterLine3BarStack.properties.style).toBe( - '--diffs-decoration-bar-layer-count:1;--diffs-decoration-bar-color-1:green;--diffs-decoration-bar-tier-1:1;--diffs-decoration-bar-start-cap-1:0;--diffs-decoration-bar-end-cap-1:1;' - ); + expectStyleContains(gutterLine3BarStack.properties.style, [ + '--diffs-decoration-bar-width-1:6px;', + '--diffs-decoration-bar-color-1:green;', + '--diffs-decoration-bar-end-radius-1:4px;', + '--diffs-decoration-bar-shadow-3:none;', + ]); expect(gutterLine3.properties['data-decoration-bar-depth']).toBe('1'); expect(gutterLine3.properties['data-decoration-bar-start']).toBeUndefined(); expect(gutterLine3.properties['data-decoration-bar-end']).toBe('1'); @@ -196,6 +236,64 @@ describe('Decoration Rendering', () => { ); }); + test('file renderer keeps the visible bar when a higher overlapping decoration has no bar', async () => { + const file = { + name: 'example.ts', + contents: ['one', 'two', 'three'].join('\n'), + }; + const decorations: FileDecorationItem[] = [ + { lineNumber: 1, endLineNumber: 3, bar: true, color: 'red' }, + { lineNumber: 2, endLineNumber: 3, background: '#111111' }, + ]; + + const renderer = new FileRenderer(); + renderer.setDecorations(decorations); + const result = await renderer.asyncRender(file); + const codeAST = renderer.renderCodeAST(result) as HASTElement[]; + const [gutter, content] = codeAST; + assertDefined(gutter, 'expected gutter column'); + assertDefined(content, 'expected content column'); + + const gutterLine2 = findElementByProperty( + gutter.children, + 'data-column-number', + 2 + ); + const contentLine2 = findElementByProperty( + content.children, + 'data-line', + 2 + ); + + assertDefined(gutterLine2, 'expected second gutter line'); + assertDefined(contentLine2, 'expected second content line'); + + const gutterLine2BarStack = findElementByProperty( + gutterLine2.children, + 'data-decoration-bar-stack', + '' + ); + + assertDefined(gutterLine2BarStack, 'expected second gutter bar stack'); + expect(gutterLine2.properties['data-decoration-bar']).toBe('0'); + expect(gutterLine2.properties['data-decoration-bar-depth']).toBe('1'); + expect(gutterLine2.properties.style).toBe( + '--diffs-decoration-bar-color:red;' + ); + expect( + gutterLine2BarStack.properties['data-decoration-bar-layer-count'] + ).toBe('1'); + expectStyleContains(gutterLine2BarStack.properties.style, [ + '--diffs-decoration-bar-color-1:red;', + '--diffs-decoration-bar-width-1:6px;', + '--diffs-decoration-bar-shadow-3:none;', + ]); + expect(contentLine2.properties['data-decoration-bg']).toBe('1'); + expect(contentLine2.properties.style).toBe( + '--diffs-decoration-bg:#111111;' + ); + }); + test('file renderer keeps one bar stack element while bar depth clamps at 3', async () => { const file = { name: 'example.ts', @@ -235,10 +333,126 @@ describe('Decoration Rendering', () => { expect( gutterLine4BarStack.properties['data-decoration-bar-layer-count'] ).toBe('4'); + expect(gutterLine4BarStack.properties['data-decoration-bar-overlap']).toBe( + '' + ); + expect(gutterLine4BarStack.properties['data-decoration-bar-second']).toBe( + '' + ); + expect(gutterLine4BarStack.properties['data-decoration-bar-third']).toBe( + '' + ); expect(gutterLine4BarStack.children).toHaveLength(0); - expect(gutterLine4BarStack.properties.style).toBe( - '--diffs-decoration-bar-layer-count:4;--diffs-decoration-bar-color-1:yellow;--diffs-decoration-bar-tier-1:1;--diffs-decoration-bar-start-cap-1:0;--diffs-decoration-bar-end-cap-1:1;--diffs-decoration-bar-color-2:green;--diffs-decoration-bar-tier-2:2;--diffs-decoration-bar-start-cap-2:0;--diffs-decoration-bar-end-cap-2:1;--diffs-decoration-bar-color-3:blue;--diffs-decoration-bar-tier-3:3;--diffs-decoration-bar-start-cap-3:0;--diffs-decoration-bar-end-cap-3:1;--diffs-decoration-bar-color-4:red;--diffs-decoration-bar-tier-4:3;--diffs-decoration-bar-start-cap-4:0;--diffs-decoration-bar-end-cap-4:1;' + expectStyleContains(gutterLine4BarStack.properties.style, [ + '--diffs-decoration-bar-width-1:6px;', + '--diffs-decoration-bar-width-2:10px;', + '--diffs-decoration-bar-width-3:14px;', + '--diffs-decoration-bar-color-3:color-mix(in lab, blue 58%, var(--diffs-bg));', + '--diffs-decoration-bar-shadow-3:0 0 0 2px var(--diffs-bg),-4px 0 0 2px var(--diffs-bg),-4px 0 0 0 color-mix(in lab, red 58%, var(--diffs-bg));', + ]); + expectStyleNotContains(gutterLine4BarStack.properties.style, [ + '--diffs-decoration-bar-color-4:', + ]); + }); + + test('diff renderer collapses overlapping same-color bars into one continuous visual bar', async () => { + const oldFile = { + name: 'example.ts', + contents: '', + }; + const newFile = { + name: 'example.ts', + contents: Array.from( + { length: 12 }, + (_, index) => `line ${index + 1}` + ).join('\n'), + }; + const diff = parseDiffFromFile(oldFile, newFile); + const decorations: DiffDecorationItem[] = [ + { + side: 'additions', + lineNumber: 2, + endLineNumber: 6, + bar: true, + background: 'red', + }, + { + side: 'additions', + lineNumber: 5, + endLineNumber: 11, + bar: true, + background: true, + }, + ]; + + const renderer = new DiffHunksRenderer({ + diffStyle: 'split', + expandUnchanged: true, + }); + renderer.setDecorations(decorations); + const result = await renderer.asyncRender(diff); + assertDefined(result.additionsGutterAST, 'expected additions gutter AST'); + + const additionsLine5 = findElementByProperty( + result.additionsGutterAST, + 'data-column-number', + 5 + ); + const additionsLine6 = findElementByProperty( + result.additionsGutterAST, + 'data-column-number', + 6 ); + + assertDefined(additionsLine5, 'expected additions gutter line 5'); + assertDefined(additionsLine6, 'expected additions gutter line 6'); + + const additionsLine5BarStack = findElementByProperty( + additionsLine5.children, + 'data-decoration-bar-stack', + '' + ); + const additionsLine6BarStack = findElementByProperty( + additionsLine6.children, + 'data-decoration-bar-stack', + '' + ); + + assertDefined( + additionsLine5BarStack, + 'expected additions line 5 bar stack' + ); + assertDefined( + additionsLine6BarStack, + 'expected additions line 6 bar stack' + ); + + expect(additionsLine5.properties['data-decoration-bar']).toBe('0,1'); + expect(additionsLine6.properties['data-decoration-bar']).toBe('0,1'); + expect(additionsLine5.properties['data-decoration-bar-depth']).toBe('2'); + expect(additionsLine6.properties['data-decoration-bar-depth']).toBe('2'); + expect( + additionsLine5BarStack.properties['data-decoration-bar-layer-count'] + ).toBe('1'); + expect( + additionsLine6BarStack.properties['data-decoration-bar-layer-count'] + ).toBe('1'); + expectStyleContains(additionsLine5BarStack.properties.style, [ + '--diffs-decoration-bar-color-1:var(--diffs-modified-base);', + '--diffs-decoration-bar-width-1:6px;', + ]); + expectStyleContains(additionsLine6BarStack.properties.style, [ + '--diffs-decoration-bar-color-1:var(--diffs-modified-base);', + '--diffs-decoration-bar-width-1:6px;', + ]); + expectStyleNotContains(additionsLine5BarStack.properties.style, [ + 'color-mix(', + '--diffs-decoration-bar-width-2:', + ]); + expectStyleNotContains(additionsLine6BarStack.properties.style, [ + 'color-mix(', + '--diffs-decoration-bar-width-2:', + ]); }); test('merged normalized decorations keep source-order identity and line-number winners', () => { @@ -832,10 +1046,23 @@ describe('Decoration Rendering', () => { expect( unifiedLine1BarStack.properties['data-decoration-bar-layer-count'] ).toBe('2'); - expect(unifiedLine1BarStack.children).toHaveLength(0); - expect(unifiedLine1BarStack.properties.style).toBe( - '--diffs-decoration-bar-layer-count:2;--diffs-decoration-bar-color-1:blue;--diffs-decoration-bar-tier-1:1;--diffs-decoration-bar-start-cap-1:1;--diffs-decoration-bar-end-cap-1:1;--diffs-decoration-bar-color-2:red;--diffs-decoration-bar-tier-2:2;--diffs-decoration-bar-start-cap-2:1;--diffs-decoration-bar-end-cap-2:1;' + expect(unifiedLine1BarStack.properties['data-decoration-bar-overlap']).toBe( + '' + ); + expect(unifiedLine1BarStack.properties['data-decoration-bar-second']).toBe( + '' ); + expect( + unifiedLine1BarStack.properties['data-decoration-bar-third'] + ).toBeUndefined(); + expect(unifiedLine1BarStack.children).toHaveLength(0); + expectStyleContains(unifiedLine1BarStack.properties.style, [ + '--diffs-decoration-bar-width-1:6px;', + '--diffs-decoration-bar-color-1:blue;', + '--diffs-decoration-bar-start-radius-2:4px;', + '--diffs-decoration-bar-end-radius-2:4px;', + 'color-mix(in lab, red 74%, var(--diffs-bg))', + ]); expect(unifiedLine1Gutter.properties['data-decoration-bar-depth']).toBe( '2' ); @@ -983,6 +1210,23 @@ function findElementByProperty( return findElementByProperties(nodes, { [property]: value }); } +function expectStyleContains(style: unknown, expectedParts: string[]): void { + expect(typeof style).toBe('string'); + for (const expectedPart of expectedParts) { + expect(style).toContain(expectedPart); + } +} + +function expectStyleNotContains( + style: unknown, + unexpectedParts: string[] +): void { + expect(typeof style).toBe('string'); + for (const unexpectedPart of unexpectedParts) { + expect(style).not.toContain(unexpectedPart); + } +} + function findElementByProperties( nodes: ElementContent[], properties: Record