From a481fb05abf29de661913a0d8e228366babf4357 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Thu, 2 Apr 2026 17:24:57 -0700 Subject: [PATCH 1/6] remove orphaned css variables --- packages/diffs/src/style.css | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/packages/diffs/src/style.css b/packages/diffs/src/style.css index 04de76257..344f1c834 100644 --- a/packages/diffs/src/style.css +++ b/packages/diffs/src/style.css @@ -120,10 +120,6 @@ ) ) ); - --diffs-bg-conflict-current: var( - --diffs-bg-conflict-current-override, - light-dark(#e5f8ea, #274432) - ); --diffs-bg-conflict-base: var( --diffs-bg-conflict-base-override, light-dark( @@ -139,10 +135,6 @@ ) ) ); - --diffs-bg-conflict-incoming: var( - --diffs-bg-conflict-incoming-override, - light-dark(#e6f1ff, #253b5a) - ); --diffs-bg-conflict-marker-number: var( --diffs-bg-conflict-marker-number-override, light-dark( @@ -150,10 +142,6 @@ color-mix(in lab, var(--diffs-bg-conflict-marker) 54%, var(--diffs-bg)) ) ); - --diffs-bg-conflict-current-number: var( - --diffs-bg-conflict-current-number-override, - light-dark(#d7f1de, #30533d) - ); --diffs-bg-conflict-base-number: var( --diffs-bg-conflict-base-number-override, light-dark( @@ -161,10 +149,6 @@ color-mix(in lab, var(--diffs-bg-conflict-base) 54%, var(--diffs-bg)) ) ); - --diffs-bg-conflict-incoming-number: var( - --diffs-bg-conflict-incoming-number-override, - light-dark(#d8e8ff, #2f4b73) - ); --conflict-bg-current: var( --conflict-bg-current-override, var(--diffs-bg-addition) @@ -201,21 +185,6 @@ color-mix(in lab, var(--diffs-bg) 68%, var(--diffs-modified-base)) ) ); - --conflict-bg-current-header-number: var( - --conflict-bg-current-header-number-override, - light-dark( - color-mix(in lab, var(--diffs-bg) 72%, var(--diffs-addition-base)), - color-mix(in lab, var(--diffs-bg) 62%, var(--diffs-addition-base)) - ) - ); - --conflict-bg-incoming-header-number: var( - --conflict-bg-incoming-header-number-override, - light-dark( - color-mix(in lab, var(--diffs-bg) 72%, var(--diffs-modified-base)), - color-mix(in lab, var(--diffs-bg) 62%, var(--diffs-modified-base)) - ) - ); - --diffs-bg-separator: var( --diffs-bg-separator-override, light-dark( From eca1cb7e795c0f3a6b873062ca731ec2f5f9a532 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Tue, 31 Mar 2026 14:54:14 -0700 Subject: [PATCH 2/6] 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... --- .../CustomHunkSeparators.tsx | 2 +- apps/docs/app/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 +- 30 files changed, 571 insertions(+), 246 deletions(-) diff --git a/apps/docs/app/diff-examples/CustomHunkSeparators/CustomHunkSeparators.tsx b/apps/docs/app/diff-examples/CustomHunkSeparators/CustomHunkSeparators.tsx index b45643051..4ad894f44 100644 --- a/apps/docs/app/diff-examples/CustomHunkSeparators/CustomHunkSeparators.tsx +++ b/apps/docs/app/diff-examples/CustomHunkSeparators/CustomHunkSeparators.tsx @@ -195,7 +195,7 @@ export function CustomHunkSeparators({ return; } - const options: FileDiffOptions = { + const options: FileDiffOptions = { ...(prerenderedDiff.options ?? {}), expansionLineCount: 5, hunkSeparators: (hunkData, instance) => diff --git a/apps/docs/app/docs/DocsCodeExample.tsx b/apps/docs/app/docs/DocsCodeExample.tsx index d4dfd135d..164b429cd 100644 --- a/apps/docs/app/docs/DocsCodeExample.tsx +++ b/apps/docs/app/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 25b372c23..295be2f7c 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, @@ -49,25 +50,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; /** @@ -96,7 +98,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 { @@ -109,14 +114,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}`; @@ -143,23 +149,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 @@ -185,13 +194,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 }; } @@ -225,6 +238,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); } @@ -266,13 +283,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 we have no pre tag and header tag, then something probably didn't @@ -282,7 +300,7 @@ export class File { } // Otherwise orchestrate our setup. else { - this.hydrationSetup({ file, lineAnnotations }); + this.hydrationSetup({ file, lineAnnotations, decorations }); } if (!preventEmit) { this.emitPostRender(); @@ -342,15 +360,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; } @@ -377,23 +398,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; } @@ -408,7 +437,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 5162d15df..ad2f9fd9f 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, @@ -62,7 +63,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; @@ -71,18 +72,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'> { @@ -93,7 +98,7 @@ export interface FileDiffOptions */ | (( hunk: HunkData, - instance: FileDiff + instance: FileDiff ) => HTMLElement | DocumentFragment | null | undefined); disableFileHeader?: boolean; /** @@ -122,7 +127,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 { @@ -157,16 +165,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; @@ -195,7 +204,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'>; @@ -203,6 +212,7 @@ export class FileDiff { protected annotationCache: Map> = new Map(); protected lineAnnotations: DiffLineAnnotation[] = []; + protected decorations: DiffDecorationItem[] = []; protected deletionFile: FileContents | undefined; protected additionFile: FileContents | undefined; @@ -215,7 +225,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 ) { @@ -243,7 +255,7 @@ export class FileDiff { }; protected getHunksRendererOptions( - options: FileDiffOptions + options: FileDiffOptions ): DiffHunksRendererOptions { return { ...options, @@ -257,8 +269,8 @@ export class FileDiff { } protected createHunksRenderer( - options: FileDiffOptions - ): DiffHunksRenderer { + options: FileDiffOptions + ): DiffHunksRenderer { return new DiffHunksRenderer( this.getHunksRendererOptions(options), this.handleHighlightRender, @@ -352,7 +364,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)); @@ -369,7 +383,9 @@ export class FileDiff { ); } - private mergeOptions(options: Partial>): void { + private mergeOptions( + options: Partial> + ): void { this.options = { ...this.options, ...options }; } @@ -403,6 +419,10 @@ export class FileDiff { this.lineAnnotations = lineAnnotations; } + public setDecorations(decorations: DiffDecorationItem[]): void { + this.decorations = decorations; + } + private canPartiallyRender( forceRender: boolean, annotationsChanged: boolean, @@ -482,12 +502,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, @@ -505,6 +528,7 @@ export class FileDiff { oldFile, newFile, lineAnnotations, + decorations, }); } if (!preventEmit) { @@ -580,11 +604,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 = @@ -597,6 +623,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(); @@ -659,10 +686,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 @@ -672,6 +700,7 @@ export class FileDiff { } const { collapsed = false } = this.options; const nextRenderRange = collapsed ? undefined : renderRange; + const nextDecorations = decorations; const filesDidChange = oldFile != null && newFile != null && @@ -683,12 +712,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) || @@ -718,12 +753,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 852c960c8..89d91d93b 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, @@ -361,7 +371,11 @@ export class UnresolvedFile< } // Otherwise orchestrate our setup else { - this.hydrationSetup({ fileDiff: source.fileDiff, lineAnnotations }); + this.hydrationSetup({ + fileDiff: source.fileDiff, + lineAnnotations, + decorations, + }); } this.renderMergeConflictActionSlots(); @@ -377,13 +391,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; @@ -401,6 +418,7 @@ export class UnresolvedFile< ...rest, fileDiff: source.fileDiff, lineAnnotations, + decorations, preventEmit: true, }); if (didRender) { @@ -825,9 +843,9 @@ function shiftMergeConflictRegion( // 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 1651963ab..b75069cb3 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; @@ -26,7 +27,7 @@ export class VirtualizedFile< private isVisible: boolean = false; constructor( - options: FileOptions | undefined, + options: FileOptions | undefined, private virtualizer: Virtualizer, private metrics: VirtualFileMetrics = DEFAULT_VIRTUAL_FILE_METRICS, workerManager?: WorkerPoolManager, @@ -48,7 +49,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; @@ -243,7 +246,7 @@ export class VirtualizedFile< fileContainer, file, ...props - }: FileRenderProps): boolean { + }: FileRenderProps): boolean { const isFirstRender = this.fileContainer == null; this.file ??= file; diff --git a/packages/diffs/src/components/VirtualizedFileDiff.ts b/packages/diffs/src/components/VirtualizedFileDiff.ts index b1949ec44..802b5ebc0 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; @@ -41,7 +42,7 @@ export class VirtualizedFileDiff< private virtualizer: Virtualizer; constructor( - options: FileDiffOptions | undefined, + options: FileDiffOptions | undefined, virtualizer: Virtualizer, metrics?: Partial, workerManager?: WorkerPoolManager, @@ -68,7 +69,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; @@ -339,7 +342,7 @@ export class VirtualizedFileDiff< newFile, fileDiff, ...props - }: FileDiffRenderProps = {}): boolean { + }: FileDiffRenderProps = {}): boolean { // NOTE(amadeus): Probably not the safest way to determine first render... // but for now... const isFirstRender = this.fileContainer == null; 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 e0dff8b74..7c1b8c9e8 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 4b47fa583..605baaf45 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 f31db98a2..7bbc3c5dd 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 05b9cdebaf0b328bcc2be5ff332d4320cff88fd9 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Tue, 31 Mar 2026 15:56:13 -0700 Subject: [PATCH 3/6] 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 295be2f7c..9fd86f6d4 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -242,6 +242,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); } @@ -363,15 +391,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; } @@ -404,16 +435,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 ( @@ -434,14 +464,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 ad2f9fd9f..8981f8b2a 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -423,6 +423,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, @@ -619,11 +647,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(); @@ -700,7 +734,6 @@ export class FileDiff { } const { collapsed = false } = this.options; const nextRenderRange = collapsed ? undefined : renderRange; - const nextDecorations = decorations; const filesDidChange = oldFile != null && newFile != null && @@ -713,9 +746,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 ( @@ -750,19 +783,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 7c1b8c9e8..c2aadd387 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 605baaf45..fe61eff76 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; @@ -201,6 +210,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 78a28c164746bb655dc02afcf175b314aaac7476 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Mon, 6 Apr 2026 17:35:19 -0700 Subject: [PATCH 4/6] decorations checkpoint... shit's mb about to get weird... --- apps/docs/app/docs/Styling/constants.ts | 8 +- .../diffs/src/components/UnresolvedFile.ts | 2 +- .../diffs/src/renderers/DiffHunksRenderer.ts | 69 +- packages/diffs/src/renderers/FileRenderer.ts | 36 +- packages/diffs/src/style.css | 1197 +++++++++-------- .../src/utils/getLineDecorationProperties.ts | 267 ++++ .../src/utils/normalizeLineDecorations.ts | 186 ++- packages/diffs/test/decorations.test.ts | 568 ++++++++ 8 files changed, 1739 insertions(+), 594 deletions(-) create mode 100644 packages/diffs/src/utils/getLineDecorationProperties.ts create mode 100644 packages/diffs/test/decorations.test.ts diff --git a/apps/docs/app/docs/Styling/constants.ts b/apps/docs/app/docs/Styling/constants.ts index 9e52a215c..32b841816 100644 --- a/apps/docs/app/docs/Styling/constants.ts +++ b/apps/docs/app/docs/Styling/constants.ts @@ -39,14 +39,12 @@ export const STYLING_CODE_GLOBAL: PreloadFileOptions = { --diffs-addition-color-override: yellow; --diffs-modified-color-override: purple; - /* Line selection colors - customize the highlighting when users - * select lines via enableLineSelection. These support light-dark() - * for automatic theme adaptation. */ + /* Line selection colors - customize the staged selection tint that gets + * mixed into selected rows and their gutter/number cells. These support + * light-dark() for automatic theme adaptation. */ --diffs-selection-color-override: rgb(37, 99, 235); --diffs-bg-selection-override: rgba(147, 197, 253, 0.28); --diffs-bg-selection-number-override: rgba(96, 165, 250, 0.55); - --diffs-bg-selection-background-override: rgba(96, 165, 250, 0.2); - --diffs-bg-selection-number-background-override: rgba(59, 130, 246, 0.4); /* Some basic variables for tweaking the layouts of some of the built in * components */ diff --git a/packages/diffs/src/components/UnresolvedFile.ts b/packages/diffs/src/components/UnresolvedFile.ts index 89d91d93b..5d0af1095 100644 --- a/packages/diffs/src/components/UnresolvedFile.ts +++ b/packages/diffs/src/components/UnresolvedFile.ts @@ -394,7 +394,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 c2aadd387..0d5fd28f6 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); } @@ -873,24 +896,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({ @@ -941,6 +975,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 = @@ -987,14 +1033,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; @@ -1003,14 +1049,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; @@ -1597,8 +1643,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 fe61eff76..ed40d2fc0 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, @@ -385,11 +390,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 @@ -533,6 +549,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/style.css b/packages/diffs/src/style.css index 344f1c834..5375bdcd0 100644 --- a/packages/diffs/src/style.css +++ b/packages/diffs/src/style.css @@ -37,27 +37,30 @@ --diffs-bg-deletion-override --diffs-bg-deletion-number-override - --diffs-bg-deletion-hover-override --diffs-bg-deletion-emphasis-override --diffs-bg-addition-override --diffs-bg-addition-number-override - --diffs-bg-addition-hover-override --diffs-bg-addition-emphasis-override + --conflict-bg-current-override + --conflict-bg-current-number-override + --conflict-bg-current-header-override + --conflict-bg-incoming-override + --conflict-bg-incoming-number-override + --conflict-bg-incoming-header-override + // Line Selection Color Overrides (for enableLineSelection) --diffs-selection-color-override --diffs-bg-selection-override --diffs-bg-selection-number-override - --diffs-bg-selection-background-override - --diffs-bg-selection-number-background-override // Available CSS Layout Overrides --diffs-gap-inline --diffs-gap-block --diffs-gap-style --diffs-tab-size - */ + */ color-scheme: light dark; display: block; @@ -83,14 +86,6 @@ color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-mixer)) ) ); - --diffs-bg-hover: var( - --diffs-bg-hover-override, - light-dark( - color-mix(in lab, var(--diffs-bg) 97%, var(--diffs-mixer)), - color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-mixer)) - ) - ); - --diffs-bg-context: var( --diffs-bg-context-override, light-dark( @@ -98,93 +93,6 @@ color-mix(in lab, var(--diffs-bg) 92.5%, var(--diffs-mixer)) ) ); - --diffs-bg-context-number: var( - --diffs-bg-context-number-override, - light-dark( - color-mix(in lab, var(--diffs-bg-context) 80%, var(--diffs-bg)), - color-mix(in lab, var(--diffs-bg-context) 60%, var(--diffs-bg)) - ) - ); - --diffs-bg-conflict-marker: var( - --diffs-bg-conflict-marker-override, - light-dark( - color-mix( - in lab, - var(--diffs-bg-context) 88%, - var(--diffs-modified-base) - ), - color-mix( - in lab, - var(--diffs-bg-context) 80%, - var(--diffs-modified-base) - ) - ) - ); - --diffs-bg-conflict-base: var( - --diffs-bg-conflict-base-override, - light-dark( - color-mix( - in lab, - var(--diffs-bg-context) 90%, - var(--diffs-modified-base) - ), - color-mix( - in lab, - var(--diffs-bg-context) 82%, - var(--diffs-modified-base) - ) - ) - ); - --diffs-bg-conflict-marker-number: var( - --diffs-bg-conflict-marker-number-override, - light-dark( - color-mix(in lab, var(--diffs-bg-conflict-marker) 72%, var(--diffs-bg)), - color-mix(in lab, var(--diffs-bg-conflict-marker) 54%, var(--diffs-bg)) - ) - ); - --diffs-bg-conflict-base-number: var( - --diffs-bg-conflict-base-number-override, - light-dark( - color-mix(in lab, var(--diffs-bg-conflict-base) 72%, var(--diffs-bg)), - color-mix(in lab, var(--diffs-bg-conflict-base) 54%, var(--diffs-bg)) - ) - ); - --conflict-bg-current: var( - --conflict-bg-current-override, - var(--diffs-bg-addition) - ); - --conflict-bg-incoming: var( - --conflict-bg-incoming-override, - light-dark( - color-mix(in lab, var(--diffs-bg) 88%, var(--diffs-modified-base)), - color-mix(in lab, var(--diffs-bg) 80%, var(--diffs-modified-base)) - ) - ); - --conflict-bg-current-number: var( - --conflict-bg-current-number-override, - var(--diffs-bg-addition-number) - ); - --conflict-bg-incoming-number: var( - --conflict-bg-incoming-number-override, - light-dark( - color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-modified-base)), - color-mix(in lab, var(--diffs-bg) 85%, var(--diffs-modified-base)) - ) - ); - --conflict-bg-current-header: var( - --conflict-bg-current-header-override, - light-dark( - color-mix(in lab, var(--diffs-bg) 78%, var(--diffs-addition-base)), - color-mix(in lab, var(--diffs-bg) 68%, var(--diffs-addition-base)) - ) - ); - --conflict-bg-incoming-header: var( - --conflict-bg-incoming-header-override, - light-dark( - color-mix(in lab, var(--diffs-bg) 78%, var(--diffs-modified-base)), - color-mix(in lab, var(--diffs-bg) 68%, var(--diffs-modified-base)) - ) - ); --diffs-bg-separator: var( --diffs-bg-separator-override, light-dark( @@ -256,20 +164,6 @@ color-mix(in lab, var(--diffs-bg) 80%, var(--diffs-deletion-base)) ) ); - --diffs-bg-deletion-number: var( - --diffs-bg-deletion-number-override, - light-dark( - color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-deletion-base)), - color-mix(in lab, var(--diffs-bg) 85%, var(--diffs-deletion-base)) - ) - ); - --diffs-bg-deletion-hover: var( - --diffs-bg-deletion-hover-override, - light-dark( - color-mix(in lab, var(--diffs-bg) 80%, var(--diffs-deletion-base)), - color-mix(in lab, var(--diffs-bg) 75%, var(--diffs-deletion-base)) - ) - ); --diffs-bg-deletion-emphasis: var( --diffs-bg-deletion-emphasis-override, light-dark( @@ -285,20 +179,6 @@ color-mix(in lab, var(--diffs-bg) 80%, var(--diffs-addition-base)) ) ); - --diffs-bg-addition-number: var( - --diffs-bg-addition-number-override, - light-dark( - color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-addition-base)), - color-mix(in lab, var(--diffs-bg) 85%, var(--diffs-addition-base)) - ) - ); - --diffs-bg-addition-hover: var( - --diffs-bg-addition-hover-override, - light-dark( - color-mix(in lab, var(--diffs-bg) 80%, var(--diffs-addition-base)), - color-mix(in lab, var(--diffs-bg) 70%, var(--diffs-addition-base)) - ) - ); --diffs-bg-addition-emphasis: var( --diffs-bg-addition-emphasis-override, light-dark( @@ -312,21 +192,6 @@ color-mix(in lab, var(--diffs-selection-base) 65%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-selection-base) 75%, var(--diffs-mixer)) ); - --diffs-bg-selection: var( - --diffs-bg-selection-override, - light-dark( - color-mix(in lab, var(--diffs-bg) 82%, var(--diffs-selection-base)), - color-mix(in lab, var(--diffs-bg) 75%, var(--diffs-selection-base)) - ) - ); - --diffs-bg-selection-number: var( - --diffs-bg-selection-number-override, - light-dark( - color-mix(in lab, var(--diffs-bg) 75%, var(--diffs-selection-base)), - color-mix(in lab, var(--diffs-bg) 60%, var(--diffs-selection-base)) - ) - ); - background-color: var(--diffs-bg); color: var(--diffs-fg); } @@ -422,177 +287,559 @@ ); } + /* Line Color and BG Calculations */ [data-line], [data-gutter-buffer], + [data-column-number], [data-line-annotation], - [data-no-newline] { + [data-no-newline], + [data-merge-conflict], + [data-merge-conflict-actions] { + /* Pre-fill css variables for appropriate up-mixing */ + --diffs-computed-decoration-bg: var(--diffs-bg); + --diffs-computed-diff-line-bg: var(--diffs-bg); + --diffs-computed-selected-line-bg: var(--diffs-bg); + color: var(--diffs-fg); background-color: var(--diffs-line-bg, var(--diffs-bg)); + + &:where([data-hovered]) { + --diffs-computed-hovered-line-bg: light-dark( + color-mix( + in lab, + var(--diffs-computed-selected-line-bg) 97%, + var(--diffs-bg-hover-override, var(--diffs-mixer)) + ), + color-mix( + in lab, + var(--diffs-computed-selected-line-bg) 91%, + var(--diffs-bg-hover-override, var(--diffs-mixer)) + ) + ); + --diffs-line-bg: var(--diffs-computed-hovered-line-bg, inherit); + } } + [data-line], [data-no-newline] { - user-select: none; + &[data-decoration-bg] { + --mix-deco-light: 92%; + --mix-deco-dark: 85%; - span { - opacity: 0.6; - } - } + &[data-decoration-bg-depth='2'] { + --mix-deco-light: 88%; + --mix-deco-dark: 80%; + } - [data-diff-type='split'][data-overflow='scroll'] { - display: grid; - grid-template-columns: 1fr 1fr; + &[data-decoration-bg-depth='3'] { + --mix-deco-light: 85%; + --mix-deco-dark: 78%; + } - [data-additions] { - border-left: 1px solid var(--diffs-bg); - } + &[data-hovered]:not([data-selected-line]) { + --mix-deco-light: 85%; + --mix-deco-dark: 85%; + } - [data-deletions] { - border-right: 1px solid var(--diffs-bg); - } - } + &[data-hovered]:not([data-selected-line])[data-decoration-bg-depth='2'] { + --mix-deco-light: 83%; + --mix-deco-dark: 83%; + } - [data-code] { - display: grid; - grid-auto-flow: dense; - grid-template-columns: var(--diffs-code-grid); - overflow: scroll clip; - overscroll-behavior-x: none; - tab-size: var(--diffs-tab-size, 2); - align-self: flex-start; - padding-top: var(--diffs-gap-block, var(--diffs-gap-fallback)); - padding-bottom: max( - 0px, - calc(var(--diffs-gap-block, var(--diffs-gap-fallback)) - 6px) - ); - } + &[data-hovered]:not([data-selected-line])[data-decoration-bg-depth='3'] { + --mix-deco-light: 81%; + --mix-deco-dark: 81%; + } - [data-container-size] { - container-type: inline-size; - } + --diffs-computed-decoration-bg: light-dark( + color-mix( + in lab, + var(--diffs-bg) var(--mix-deco-light), + var(--diffs-decoration-bg) + ), + color-mix( + in lab, + var(--diffs-bg) var(--mix-deco-dark), + var(--diffs-decoration-bg) + ) + ); + /* Lets up-select all lines */ + --diffs-computed-diff-line-bg: var(--diffs-computed-decoration-bg); + --diffs-computed-selected-line-bg: var(--diffs-computed-decoration-bg); - [data-code]::-webkit-scrollbar { - width: 0; - height: 6px; + /* Apply the appropriately computed bg line color */ + --diffs-line-bg: var(--diffs-computed-decoration-bg); + } } - [data-code]::-webkit-scrollbar-track { - background: transparent; + [data-line-annotation], + [data-gutter-buffer='annotation'] { + --diffs-computed-decoration-bg: var(--diffs-bg-context); + --diffs-computed-diff-line-bg: var(--diffs-bg-context); + --diffs-computed-selected-line-bg: var(--diffs-bg-context); + --diffs-line-bg: var(--diffs-bg-context); } - [data-code]::-webkit-scrollbar-thumb { - background-color: transparent; - border: 1px solid transparent; - background-clip: content-box; - border-radius: 3px; + [data-merge-conflict-actions], + [data-gutter-buffer='merge-conflict-action'], + [data-gutter-buffer='merge-conflict-marker-base'], + [data-gutter-buffer='merge-conflict-marker-separator'], + [data-merge-conflict='marker-base'], + [data-merge-conflict='marker-separator'] { + --diffs-computed-decoration-bg: var(--diffs-bg-context); + --diffs-computed-diff-line-bg: var(--diffs-bg-context); + --diffs-computed-selected-line-bg: var(--diffs-bg-context); + --diffs-line-bg: var(--diffs-bg-context); } - [data-code]::-webkit-scrollbar-corner { - background-color: transparent; + [data-gutter-buffer='merge-conflict-marker-start'], + [data-merge-conflict='marker-start'] { + --diffs-computed-decoration-bg: light-dark( + color-mix( + in lab, + var(--diffs-bg) 78%, + var(--conflict-bg-current-header-override, var(--diffs-addition-base)) + ), + color-mix( + in lab, + var(--diffs-bg) 68%, + var(--conflict-bg-current-header-override, var(--diffs-addition-base)) + ) + ); + --diffs-computed-diff-line-bg: var(--diffs-computed-decoration-bg); + --diffs-computed-selected-line-bg: var(--diffs-computed-decoration-bg); + --diffs-line-bg: var(--diffs-computed-decoration-bg); } - /* - * If we apply these rules globally it will mean that webkit will opt into the - * standards compliant version of custom css scrollbars, which we do not want - * because the custom stuff will look better - */ - @supports (-moz-appearance: none) { - [data-code] { - scrollbar-width: thin; - scrollbar-color: var(--diffs-bg-context) transparent; - padding-bottom: var(--diffs-gap-block, var(--diffs-gap-fallback)); - } + [data-gutter-buffer='merge-conflict-marker-end'], + [data-merge-conflict='marker-end'] { + --diffs-computed-decoration-bg: light-dark( + color-mix( + in lab, + var(--diffs-bg) 78%, + var(--conflict-bg-incoming-header-override, var(--diffs-modified-base)) + ), + color-mix( + in lab, + var(--diffs-bg) 68%, + var(--conflict-bg-incoming-header-override, var(--diffs-modified-base)) + ) + ); + --diffs-computed-diff-line-bg: var(--diffs-computed-decoration-bg); + --diffs-computed-selected-line-bg: var(--diffs-computed-decoration-bg); + --diffs-line-bg: var(--diffs-computed-decoration-bg); } - [data-diffs-header] ~ [data-diff], - [data-diffs-header] ~ [data-file] { - [data-code], - &[data-overflow='wrap'] { - padding-top: 0; - } + [data-has-merge-conflict] [data-line-annotation], + [data-has-merge-conflict] [data-gutter-buffer='annotation'] { + --diffs-computed-decoration-bg: var(--diffs-bg); + --diffs-computed-diff-line-bg: var(--diffs-bg); + --diffs-computed-selected-line-bg: var(--diffs-bg); + --diffs-line-bg: var(--diffs-bg); } - [data-gutter] { - display: grid; - grid-template-rows: subgrid; - grid-template-columns: subgrid; - grid-column: 1; - z-index: 3; - position: relative; - background-color: var(--diffs-bg); - + /* We are using where here to not affect the nesting impacts later on for + * hover and selection */ + :where([data-background]) { [data-gutter-buffer], [data-column-number] { - border-right: var(--diffs-gap-style, 2px solid var(--diffs-bg)); + --mix-light: 91%; + --mix-dark: 85%; } - } - [data-content] { - display: grid; - grid-template-rows: subgrid; - grid-template-columns: subgrid; - grid-column: 2; - min-width: 0; - } + [data-line], + [data-no-newline] { + --mix-light: 88%; + --mix-dark: 80%; + } - [data-diff-type='split'][data-overflow='wrap'] { - display: grid; - grid-auto-flow: dense; - grid-template-columns: repeat(2, var(--diffs-code-grid)); - padding-block: var(--diffs-gap-block, var(--diffs-gap-fallback)); + [data-gutter-buffer], + [data-column-number], + [data-line], + [data-no-newline] { + --diffs-diff-line-mix-target: var(--diffs-bg); - [data-deletions] { - display: contents; + &[data-line-type='change-deletion'] { + --diffs-diff-line-mix-target: var( + --diffs-bg-deletion-override, + var(--diffs-deletion-base) + ); - [data-gutter] { - grid-column: 1; - } + &[data-hovered] { + --mix-light: 80%; + --mix-dark: 75%; + } - [data-content] { - grid-column: 2; - border-right: 1px solid var(--diffs-bg); - } - } + &:where([data-gutter-buffer], [data-column-number]) { + color: var( + --diffs-fg-number-deletion-override, + var(--diffs-deletion-base) + ); - [data-additions] { - display: contents; + --diffs-diff-line-mix-target: var( + --diffs-bg-deletion-number-override, + var(--diffs-deletion-base) + ); + } - [data-gutter] { - grid-column: 3; - border-left: 1px solid var(--diffs-bg); - } + --diffs-computed-diff-line-bg: light-dark( + color-mix( + in lab, + var(--diffs-computed-decoration-bg) var(--mix-light), + var(--diffs-diff-line-mix-target) + ), + color-mix( + in lab, + var(--diffs-computed-decoration-bg) var(--mix-dark), + var(--diffs-diff-line-mix-target) + ) + ); - [data-content] { - grid-column: 4; + --diffs-computed-selected-line-bg: var(--diffs-computed-diff-line-bg); + --diffs-line-bg: var(--diffs-computed-diff-line-bg, inherit); } - } - } - [data-overflow='scroll'] [data-gutter] { - position: sticky; - left: 0; - } + &[data-line-type='change-addition'] { + --diffs-diff-line-mix-target: var( + --diffs-bg-addition-override, + var(--diffs-addition-base) + ); - [data-line-annotation][data-selected-line] { - background-color: unset; + &[data-hovered] { + --mix-light: 80%; + --mix-dark: 70%; + } - &::before { - content: ''; - /* FIXME(amadeus): This needs to be audited ... */ - position: sticky; - top: 0; - left: 0; - display: block; - border-right: var(--diffs-gap-style, 1px solid var(--diffs-bg)); - background-color: var(--diffs-bg-selection-number); - } + &:where([data-gutter-buffer], [data-column-number]) { + color: var( + --diffs-fg-number-addition-override, + var(--diffs-addition-base) + ); - [data-annotation-content] { - background-color: var(--diffs-bg-selection); - } - } + --diffs-diff-line-mix-target: var( + --diffs-bg-addition-number-override, + var(--diffs-addition-base) + ); + } - [data-interactive-lines] [data-line] { - cursor: pointer; + --diffs-computed-diff-line-bg: light-dark( + color-mix( + in lab, + var(--diffs-computed-decoration-bg) var(--mix-light), + var(--diffs-diff-line-mix-target) + ), + color-mix( + in lab, + var(--diffs-computed-decoration-bg) var(--mix-dark), + var(--diffs-diff-line-mix-target) + ) + ); + + --diffs-computed-selected-line-bg: var(--diffs-computed-diff-line-bg); + --diffs-line-bg: var(--diffs-computed-diff-line-bg, inherit); + } + + &[data-merge-conflict='current'] { + --diffs-diff-line-mix-target: var( + --conflict-bg-current-override, + var(--diffs-addition-base) + ); + + &:where([data-gutter-buffer], [data-column-number]) { + color: var( + --diffs-fg-number-addition-override, + var(--diffs-addition-base) + ); + + --diffs-diff-line-mix-target: var( + --conflict-bg-current-number-override, + var(--diffs-addition-base) + ); + } + + &[data-hovered] { + --mix-light: 80%; + --mix-dark: 70%; + } + + --diffs-computed-diff-line-bg: light-dark( + color-mix( + in lab, + var(--diffs-computed-decoration-bg) var(--mix-light), + var(--diffs-diff-line-mix-target) + ), + color-mix( + in lab, + var(--diffs-computed-decoration-bg) var(--mix-dark), + var(--diffs-diff-line-mix-target) + ) + ); + + --diffs-computed-selected-line-bg: var(--diffs-computed-diff-line-bg); + --diffs-line-bg: var(--diffs-computed-diff-line-bg, inherit); + } + + &[data-merge-conflict='incoming'] { + --diffs-diff-line-mix-target: var( + --conflict-bg-incoming-override, + var(--diffs-modified-base) + ); + + &:where([data-gutter-buffer], [data-column-number]) { + color: var(--diffs-modified-base); + + --diffs-diff-line-mix-target: var( + --conflict-bg-incoming-number-override, + var(--diffs-modified-base) + ); + } + + &[data-hovered] { + --mix-light: 80%; + --mix-dark: 70%; + } + + --diffs-computed-diff-line-bg: light-dark( + color-mix( + in lab, + var(--diffs-computed-decoration-bg) var(--mix-light), + var(--diffs-diff-line-mix-target) + ), + color-mix( + in lab, + var(--diffs-computed-decoration-bg) var(--mix-dark), + var(--diffs-diff-line-mix-target) + ) + ); + + --diffs-computed-selected-line-bg: var(--diffs-computed-diff-line-bg); + --diffs-line-bg: var(--diffs-computed-diff-line-bg, inherit); + } + } + } + + /* Order is important for rationalizing these selectors and for order of + * application, therefore we need to disable this style-lint rule */ + /* stylelint-disable-next-line no-duplicate-selectors */ + [data-gutter-buffer], + [data-column-number], + [data-line], + [data-line-annotation], + [data-merge-conflict], + [data-merge-conflict-actions], + [data-no-newline] { + --diffs-selection-mix-target: var( + --diffs-bg-selection-override, + var(--diffs-selection-base) + ); + + &:where( + [data-line], + [data-line-annotation], + [data-merge-conflict], + [data-merge-conflict-actions], + [data-no-newline] + )[data-selected-line] { + --mix-selection-light: 82%; + --mix-selection-dark: 75%; + + &[data-hovered]:not( + [data-merge-conflict], + [data-line-type='change-addition'], + [data-line-type='change-deletion'] + ) { + --mix-selection-light: 75%; + --mix-selection-dark: 70%; + } + } + + &:where([data-gutter-buffer], [data-column-number])[data-selected-line] { + --mix-selection-light: 75%; + --mix-selection-dark: 60%; + --diffs-selection-mix-target: var( + --diffs-bg-selection-number-override, + var(--diffs-selection-base) + ); + + &[data-hovered]:not( + [data-merge-conflict], + [data-line-type='change-addition'], + [data-line-type='change-deletion'] + ) { + --mix-selection-light: 70%; + --mix-selection-dark: 55%; + } + } + + &[data-selected-line] { + --diffs-computed-selected-line-bg: light-dark( + color-mix( + in lab, + var(--diffs-computed-diff-line-bg) var(--mix-selection-light), + var(--diffs-selection-mix-target) + ), + color-mix( + in lab, + var(--diffs-computed-diff-line-bg) var(--mix-selection-dark), + var(--diffs-selection-mix-target) + ) + ); + --diffs-line-bg: var(--diffs-computed-selected-line-bg, inherit); + } + } + + [data-gutter-buffer], + [data-column-number] { + &[data-selected-line] { + color: var(--diffs-selection-number-fg); + } + } + + [data-no-newline] { + user-select: none; + + span { + opacity: 0.6; + } + } + + [data-diff-type='split'][data-overflow='scroll'] { + display: grid; + grid-template-columns: 1fr 1fr; + + [data-additions] { + border-left: 1px solid var(--diffs-bg); + } + + [data-deletions] { + border-right: 1px solid var(--diffs-bg); + } + } + + [data-code] { + display: grid; + grid-auto-flow: dense; + grid-template-columns: var(--diffs-code-grid); + overflow: scroll clip; + overscroll-behavior-x: none; + tab-size: var(--diffs-tab-size, 2); + align-self: flex-start; + padding-top: var(--diffs-gap-block, var(--diffs-gap-fallback)); + padding-bottom: max( + 0px, + calc(var(--diffs-gap-block, var(--diffs-gap-fallback)) - 6px) + ); + } + + [data-container-size] { + container-type: inline-size; + } + + [data-code]::-webkit-scrollbar { + width: 0; + height: 6px; + } + + [data-code]::-webkit-scrollbar-track { + background: transparent; + } + + [data-code]::-webkit-scrollbar-thumb { + background-color: transparent; + border: 1px solid transparent; + background-clip: content-box; + border-radius: 3px; + } + + [data-code]::-webkit-scrollbar-corner { + background-color: transparent; + } + + /* + * If we apply these rules globally it will mean that webkit will opt into the + * standards compliant version of custom css scrollbars, which we do not want + * because the custom stuff will look better + */ + @supports (-moz-appearance: none) { + [data-code] { + scrollbar-width: thin; + scrollbar-color: var(--diffs-bg-context) transparent; + padding-bottom: var(--diffs-gap-block, var(--diffs-gap-fallback)); + } + } + + [data-diffs-header] ~ [data-diff], + [data-diffs-header] ~ [data-file] { + [data-code], + &[data-overflow='wrap'] { + padding-top: 0; + } + } + + [data-gutter] { + display: grid; + grid-template-rows: subgrid; + grid-template-columns: subgrid; + grid-column: 1; + z-index: 3; + position: relative; + background-color: var(--diffs-bg); + + [data-gutter-buffer], + [data-column-number] { + border-right: var(--diffs-gap-style, 2px solid var(--diffs-bg)); + } + } + + [data-content] { + display: grid; + grid-template-rows: subgrid; + grid-template-columns: subgrid; + grid-column: 2; + min-width: 0; + background-color: var(--diffs-bg); + } + + [data-diff-type='split'][data-overflow='wrap'] { + display: grid; + grid-auto-flow: dense; + grid-template-columns: repeat(2, var(--diffs-code-grid)); + padding-block: var(--diffs-gap-block, var(--diffs-gap-fallback)); + + [data-deletions] { + display: contents; + + [data-gutter] { + grid-column: 1; + } + + [data-content] { + grid-column: 2; + border-right: 1px solid var(--diffs-bg); + } + } + + [data-additions] { + display: contents; + + [data-gutter] { + grid-column: 3; + border-left: 1px solid var(--diffs-bg); + } + + [data-content] { + grid-column: 4; + } + } + } + + [data-overflow='scroll'] [data-gutter] { + position: sticky; + left: 0; + } + + [data-interactive-lines] [data-line] { + cursor: pointer; } [data-content-buffer], @@ -610,8 +857,7 @@ background-size: 8px 8px; background-position: 0 0; background-origin: border-box; - background-color: var(--diffs-bg); - /* This is incredibley expensive... */ + /* This is incredibly expensive... */ background-image: repeating-linear-gradient( -45deg, transparent, @@ -627,7 +873,6 @@ background-size: 8px 8px; background-position: 5px 0; background-origin: border-box; - background-color: var(--diffs-bg); /* This is incredibley expensive... */ background-image: repeating-linear-gradient( -45deg, @@ -638,6 +883,8 @@ ); } + /* Hunk Separators */ + /* --------------- */ [data-separator] { box-sizing: content-box; background-color: var(--diffs-bg); @@ -759,6 +1006,15 @@ } } + [data-unmodified-lines] { + display: block; + overflow: hidden; + min-width: 0; + text-overflow: ellipsis; + white-space: nowrap; + flex: 0 1 auto; + } + @supports (width: 1cqi) { [data-unified] { [data-separator='line-info'] [data-separator-wrapper] { @@ -946,38 +1202,21 @@ } } + /* Hunk separator content is duplicated across gutters and content columns, + * so custom CSS can do some stuff. Lets hide things for defaults */ [data-additions] [data-gutter] [data-separator-wrapper], [data-additions] [data-separator='line-info-basic'] [data-separator-wrapper], [data-content] [data-separator-wrapper] { display: none; } - [data-line-annotation], - [data-gutter-buffer='annotation'] { - --diffs-line-bg: var(--diffs-bg-context); + [data-line-annotation] { + min-height: var(--diffs-annotation-min-height, 0); + z-index: 2; } - [data-merge-conflict-actions], - [data-gutter-buffer='merge-conflict-action'] { - --diffs-line-bg: var(--diffs-bg-context); - } - - [data-has-merge-conflict] [data-line-annotation], - [data-has-merge-conflict] [data-gutter-buffer='annotation'] { - --diffs-line-bg: var(--diffs-bg); - } - - [data-has-merge-conflict] [data-gutter-buffer='merge-conflict-action'] { - --diffs-line-bg: var(--diffs-bg); - } - - [data-line-annotation] { - min-height: var(--diffs-annotation-min-height, 0); - z-index: 2; - } - - [data-merge-conflict-actions] { - z-index: 2; + [data-merge-conflict-actions] { + z-index: 2; } [data-separator='custom'] { @@ -1094,7 +1333,6 @@ box-sizing: content-box; text-align: right; user-select: none; - background-color: var(--diffs-bg); color: var(--diffs-fg-number); padding-left: 2ch; } @@ -1146,46 +1384,12 @@ box-decoration-break: clone; } - [data-line-type='change-addition'] { - &[data-column-number] { - color: var( - --diffs-fg-number-addition-override, - var(--diffs-addition-base) - ); - } - - [data-diff-span] { - background-color: var(--diffs-bg-addition-emphasis); - } + [data-line-type='change-addition'] [data-diff-span] { + background-color: var(--diffs-bg-addition-emphasis); } - [data-line-type='change-deletion'] { - &[data-column-number] { - color: var( - --diffs-fg-number-deletion-override, - var(--diffs-deletion-base) - ); - } - - [data-diff-span] { - background-color: var(--diffs-bg-deletion-emphasis); - } - } - - [data-background] [data-line-type='change-addition'] { - --diffs-line-bg: var(--diffs-bg-addition); - - &[data-column-number] { - background-color: var(--diffs-bg-addition-number); - } - } - - [data-background] [data-line-type='change-deletion'] { - --diffs-line-bg: var(--diffs-bg-deletion); - - &[data-column-number] { - background-color: var(--diffs-bg-deletion-number); - } + [data-line-type='change-deletion'] [data-diff-span] { + background-color: var(--diffs-bg-deletion-emphasis); } [data-merge-conflict='marker-start'], @@ -1222,104 +1426,52 @@ content: '(Incoming Change)'; } - [data-merge-conflict='marker-base'], - [data-merge-conflict='marker-end'] { - &[data-line], - &[data-no-newline] { - background-color: var(--diffs-bg-conflict-marker); - } - - &[data-column-number] { - background-color: var(--diffs-bg-conflict-marker-number); - color: var(--diffs-fg-conflict-marker); - - [data-line-number-content] { - color: var(--diffs-fg-conflict-marker); - } - } - } - - [data-merge-conflict='current'] { - &[data-line], - &[data-no-newline] { - background-color: var(--conflict-bg-current); - } - - &[data-column-number] { - background-color: var(--conflict-bg-current-number); - color: var(--diffs-addition-base); - } - } - - [data-gutter-buffer='merge-conflict-marker-start'], - [data-merge-conflict='marker-start'] { - background-color: var(--conflict-bg-current-header); + [data-merge-conflict-actions-content] { + display: flex; + align-items: center; + gap: 0.25rem; + padding-inline: 0.5rem; + min-height: 1.75rem; + font-family: var( + --diffs-header-font-family, + var(--diffs-header-font-fallback) + ); + font-size: 0.75rem; + line-height: 1.2; + color: var(--diffs-fg); } - [data-gutter-buffer='merge-conflict-marker-end'], - [data-merge-conflict='marker-end'] { - background-color: var(--conflict-bg-incoming-header); + [data-merge-conflict-action] { + appearance: none; + border: 0; + background: transparent; + color: var(--diffs-fg-number); + font: inherit; + font-style: normal; + cursor: pointer; + padding: 0; } - [data-merge-conflict='marker-separator'] { - &[data-line], - &[data-no-newline] { - background-color: var(--diffs-bg); - } - - &[data-column-number] { - background-color: var(--diffs-bg); - } + [data-merge-conflict-action]:hover { + color: var(--diffs-fg); } - [data-merge-conflict='base'] { - &[data-line], - &[data-no-newline] { - background-color: var(--diffs-bg-conflict-base); - } - - &[data-column-number] { - background-color: var(--diffs-bg-conflict-base-number); - color: var(--diffs-modified-base); - } + [data-merge-conflict-action='current']:hover { + color: var(--diffs-addition-base); } - [data-merge-conflict='incoming'] { - &[data-line], - &[data-no-newline] { - background-color: var(--conflict-bg-incoming); - } - - &[data-column-number] { - background-color: var(--conflict-bg-incoming-number); - color: var(--diffs-modified-base); - } + [data-merge-conflict-action='incoming']:hover { + color: var(--diffs-modified-base); } - @media (pointer: fine) { - [data-column-number], - [data-line] { - &[data-hovered] { - background-color: var(--diffs-bg-hover); - } - } - - [data-background] { - [data-column-number], - [data-line] { - &[data-hovered] { - &[data-line-type='change-deletion'] { - background-color: var(--diffs-bg-deletion-hover); - } - - &[data-line-type='change-addition'] { - background-color: var(--diffs-bg-addition-hover); - } - } - } - } + [data-merge-conflict-action-separator] { + color: var(--diffs-fg-number); + opacity: 0.6; + user-select: none; } + /* Default Header Styles */ + /* --------------------- */ [data-diffs-header='default'] { position: relative; background-color: var(--diffs-bg); @@ -1381,57 +1533,36 @@ color: var(--diffs-deletion-base); } - [data-annotation-content] { - position: relative; - display: flow-root; - align-self: flex-start; - z-index: 2; - min-width: 0; - isolation: isolate; - } - - [data-merge-conflict-actions-content] { - display: flex; - align-items: center; - gap: 0.25rem; - padding-inline: 0.5rem; - min-height: 1.75rem; - font-family: var( - --diffs-header-font-family, - var(--diffs-header-font-fallback) - ); - font-size: 0.75rem; - line-height: 1.2; - color: var(--diffs-fg); - } - - [data-merge-conflict-action] { - appearance: none; - border: 0; - background: transparent; - color: var(--diffs-fg-number); - font: inherit; - font-style: normal; - cursor: pointer; - padding: 0; + [data-change-icon] { + fill: currentColor; + flex-shrink: 0; } - [data-merge-conflict-action]:hover { - color: var(--diffs-fg); + [data-change-icon='change'], + [data-change-icon='rename-pure'], + [data-change-icon='rename-changed'] { + color: var(--diffs-modified-base); } - [data-merge-conflict-action='current']:hover { + [data-change-icon='new'] { color: var(--diffs-addition-base); } - [data-merge-conflict-action='incoming']:hover { - color: var(--diffs-modified-base); + [data-change-icon='deleted'] { + color: var(--diffs-deletion-base); } - [data-merge-conflict-action-separator] { - color: var(--diffs-fg-number); + [data-change-icon='file'] { opacity: 0.6; - user-select: none; + } + + [data-annotation-content] { + position: relative; + display: flow-root; + align-self: flex-start; + z-index: 2; + min-width: 0; + isolation: isolate; } /* Sticky positioning has a composite costs, so we should _only_ pay it if we @@ -1455,80 +1586,6 @@ white-space-collapse: collapse; } - [data-change-icon] { - fill: currentColor; - flex-shrink: 0; - } - - [data-change-icon='change'], - [data-change-icon='rename-pure'], - [data-change-icon='rename-changed'] { - color: var(--diffs-modified-base); - } - - [data-change-icon='new'] { - color: var(--diffs-addition-base); - } - - [data-change-icon='deleted'] { - color: var(--diffs-deletion-base); - } - - [data-change-icon='file'] { - opacity: 0.6; - } - - /* Line selection highlighting */ - [data-selected-line] { - &[data-gutter-buffer='annotation'], - &[data-column-number] { - color: var(--diffs-selection-number-fg); - background-color: var(--diffs-bg-selection-number); - } - - &[data-line] { - background-color: var(--diffs-bg-selection); - } - } - - [data-line-type='change-addition'], - [data-line-type='change-deletion'] { - &[data-selected-line] { - &[data-line], - &[data-line][data-hovered] { - background-color: light-dark( - color-mix( - in lab, - var(--diffs-line-bg, var(--diffs-bg)) 82%, - var(--diffs-selection-base) - ), - color-mix( - in lab, - var(--diffs-line-bg, var(--diffs-bg)) 75%, - var(--diffs-selection-base) - ) - ); - } - - &[data-column-number], - &[data-column-number][data-hovered] { - color: var(--diffs-selection-number-fg); - background-color: light-dark( - color-mix( - in lab, - var(--diffs-line-bg, var(--diffs-bg)) 75%, - var(--diffs-selection-base) - ), - color-mix( - in lab, - var(--diffs-line-bg, var(--diffs-bg)) 60%, - var(--diffs-selection-base) - ) - ); - } - } - } - [data-gutter-utility-slot] { position: absolute; top: 0; @@ -1538,37 +1595,6 @@ justify-content: flex-end; } - [data-unmodified-lines] { - display: block; - overflow: hidden; - min-width: 0; - text-overflow: ellipsis; - white-space: nowrap; - flex: 0 1 auto; - } - - [data-error-wrapper] { - overflow: auto; - padding: var(--diffs-gap-block, var(--diffs-gap-fallback)) - var(--diffs-gap-inline, var(--diffs-gap-fallback)); - max-height: 400px; - scrollbar-width: none; - - [data-error-message] { - font-weight: bold; - font-size: 18px; - color: var(--diffs-deletion-base); - } - - [data-error-stack] { - color: var(--diffs-fg-number); - } - } - - [data-placeholder] { - contain: strict; - } - [data-utility-button] { display: flex; align-items: center; @@ -1589,4 +1615,39 @@ position: relative; z-index: 4; } + + /* Decoration Bars */ + /* --------------- */ + [data-decoration-bar]::after { + content: ''; + display: block; + width: 6px; + background-color: var(--diffs-decoration-bar-color); + position: absolute; + top: 0; + bottom: 0; + right: 0; + } + + [data-placeholder] { + contain: strict; + } + + [data-error-wrapper] { + overflow: auto; + padding: var(--diffs-gap-block, var(--diffs-gap-fallback)) + var(--diffs-gap-inline, var(--diffs-gap-fallback)); + max-height: 400px; + scrollbar-width: none; + + [data-error-message] { + font-weight: bold; + font-size: 18px; + color: var(--diffs-deletion-base); + } + + [data-error-stack] { + color: var(--diffs-fg-number); + } + } } 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 593ba3d212c5039147cccd229a408fbedaf51632 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Tue, 7 Apr 2026 17:55:58 -0700 Subject: [PATCH 5/6] 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 6111adaed..286accbc2 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 0d5fd28f6..4cdf8a467 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) @@ -791,11 +797,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 + ) ); }; @@ -914,7 +927,8 @@ export class DiffHunksRenderer< ? additionLine.lineNumber : deletionLine.lineNumber, `${unifiedLineIndex},${splitLineIndex}`, - unifiedLineDecoration.gutterProperties + unifiedLineDecoration.gutterProperties, + unifiedLineDecoration.gutterChildren ); if (additionLineContent != null) { additionLineContent = withContentProperties( @@ -1040,7 +1054,8 @@ export class DiffHunksRenderer< decoratedDeletionLine.gutterLineType, deletionLine.lineNumber, `${deletionLine.unifiedLineIndex},${splitLineIndex}`, - decoratedDeletionLine.gutterProperties + decoratedDeletionLine.gutterProperties, + decoratedDeletionLine.gutterChildren ); if (deletionLineDecorated != null) { deletionLineContent = deletionLineDecorated; @@ -1056,7 +1071,8 @@ export class DiffHunksRenderer< decoratedAdditionLine.gutterLineType, additionLine.lineNumber, `${additionLine.unifiedLineIndex},${splitLineIndex}`, - decoratedAdditionLine.gutterProperties + decoratedAdditionLine.gutterProperties, + decoratedAdditionLine.gutterChildren ); if (additionLineDecorated != null) { additionLineContent = additionLineDecorated; @@ -1403,6 +1419,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 ed40d2fc0..6a8b3ceef 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'; @@ -397,7 +398,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 89981547f6fceddba9e36bc693626cd2937287b4 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Tue, 7 Apr 2026 17:56:08 -0700 Subject: [PATCH 6/6] sorting through ai belligerancy --- apps/demo/src/main.ts | 39 ++- packages/diffs/src/style.css | 98 ++++++- .../src/utils/getLineDecorationProperties.ts | 78 +++-- .../src/utils/normalizeLineDecorations.ts | 77 +++++ packages/diffs/test/decorations.test.ts | 272 +++++++++++++++++- 5 files changed, 501 insertions(+), 63 deletions(-) diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index 286accbc2..4383c9588 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/style.css b/packages/diffs/src/style.css index 5375bdcd0..5d4fe84bc 100644 --- a/packages/diffs/src/style.css +++ b/packages/diffs/src/style.css @@ -1343,6 +1343,8 @@ --diffs-min-number-column-width, var(--diffs-min-number-column-width-default, 3ch) ); + position: relative; + z-index: 1; } [data-disable-line-numbers] { @@ -1618,15 +1620,99 @@ /* Decoration Bars */ /* --------------- */ - [data-decoration-bar]::after { - content: ''; - display: block; - width: 6px; - background-color: var(--diffs-decoration-bar-color); + [data-decoration-bar-stack] { position: absolute; top: 0; bottom: 0; - right: 0; + right: -2px; + width: 6px; + pointer-events: none; + isolation: isolate; + z-index: 1; + background-color: var(--diffs-decoration-bar-color, transparent); + /* overflow: clip visible; */ + box-sizing: content-box; + border-left: 2px solid var(--diffs-bg); + border-right: 2px solid var(--diffs-bg); + + [data-decoration-bar-depth='1'] & { + background-color: color-mix( + in lab, + var(--diffs-bg) 20%, + var(--diffs-decoration-bar-color, transparent) + ); + } + + [data-decoration-bar-depth='2'] & { + background-color: color-mix( + in lab, + var(--diffs-bg) 45%, + var(--diffs-decoration-bar-color, transparent) + ); + } + + [data-decoration-bar-depth='3'] & { + background-color: color-mix( + in lab, + var(--diffs-bg) 65%, + var(--diffs-decoration-bar-color, transparent) + ); + } + + [data-decoration-bar-start] & { + border-top-left-radius: 5px; + border-top-right-radius: 5px; + + /* &::before { */ + /* content: ''; */ + /* position: absolute; */ + /* box-sizing: content-box; */ + /* display: block; */ + /* width: 6px; */ + /* height: 6px; */ + /* z-index: 1; */ + /* border-top-left-radius: 3px; */ + /* border-top-right-radius: 3px; */ + /* border-top: 2px solid var(--diffs-bg); */ + /* border-left: 2px solid var(--diffs-bg); */ + /* border-right: 2px solid var(--diffs-bg); */ + /* left: -2px; */ + /* top: -2px; */ + /* } */ + } + + [data-decoration-bar-end] & { + z-index: 3; + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; + + /* &::after { */ + /* content: ''; */ + /* position: absolute; */ + /* box-sizing: content-box; */ + /* display: block; */ + /* width: 6px; */ + /* height: 6px; */ + /* border-bottom-left-radius: 3px; */ + /* border-bottom-right-radius: 3px; */ + /* border-bottom: 2px solid var(--diffs-bg); */ + /* border-left: 2px solid var(--diffs-bg); */ + /* border-right: 2px solid var(--diffs-bg); */ + /* left: -2px; */ + /* bottom: -2px; */ + /* } */ + } + + /* [data-decoration-bar-end] & { */ + /* border-bottom-left-radius: 5px; */ + /* border-bottom-right-radius: 5px; */ + /* border-bottom: 2px solid var(--diffs-bg); */ + /* border-left: 2px solid var(--diffs-bg); */ + /* border-right: 2px solid var(--diffs-bg); */ + /* right: -2px; */ + /* bottom: -2px; */ + /* z-index: 3; */ + /* } */ } [data-placeholder] { 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