From 9e9c59888b6cb3e7809606be64e51287287914da Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Tue, 31 Mar 2026 14:54:14 -0700 Subject: [PATCH 1/5] phase 1: types and scaffolding. mostly a non-functional changes, a lot of types changes, that I had to refactor from the AI slop because he really did some ugly shit... --- apps/demo/src/codeViewDemo.ts | 12 +- .../(view)/_components/CodeViewWrapper.tsx | 10 +- .../(view)/_components/ReviewUI.tsx | 4 +- .../(view)/_components/usePatchLoader.ts | 6 +- apps/docs/components/docs/DocsCodeExample.tsx | 13 +- packages/diffs/src/components/CodeView.ts | 432 +++++++++++------- packages/diffs/src/components/File.ts | 64 ++- packages/diffs/src/components/FileDiff.ts | 72 ++- .../diffs/src/components/UnresolvedFile.ts | 77 ++-- .../diffs/src/components/VirtualizedFile.ts | 19 +- .../src/components/VirtualizedFileDiff.ts | 34 +- .../src/components/VirtulizerDevelopment.d.ts | 2 +- packages/diffs/src/react/CodeView.tsx | 250 +++++----- 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 | 36 +- .../src/react/utils/renderFileChildren.tsx | 24 +- .../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 | 46 +- .../src/utils/areManagedSnapshotsEqual.ts | 6 +- packages/diffs/src/utils/areOptionsEqual.ts | 18 +- .../src/utils/getDiffHunksRendererOptions.ts | 4 +- .../diffs/src/utils/getFileRendererOptions.ts | 4 +- .../test/CodeView.elementPooling.test.ts | 2 +- packages/diffs/test/annotations.test.ts | 2 +- packages/diffs/test/hydration.test.ts | 34 +- packages/diffs/test/themeTypeUpdates.test.ts | 2 +- 39 files changed, 1011 insertions(+), 563 deletions(-) diff --git a/apps/demo/src/codeViewDemo.ts b/apps/demo/src/codeViewDemo.ts index b3044b784..4001f2414 100644 --- a/apps/demo/src/codeViewDemo.ts +++ b/apps/demo/src/codeViewDemo.ts @@ -36,7 +36,7 @@ interface CodeViewDraftCommentMetadata { interface CodeViewDemoInstance { instance: CodeView; - options: CodeViewOptions; + options: CodeViewOptions; } type CodeViewDemoAnnotation = @@ -44,13 +44,13 @@ type CodeViewDemoAnnotation = | LineAnnotation; type CodeViewDiffStyle = NonNullable< - CodeViewOptions['diffStyle'] + CodeViewOptions['diffStyle'] >; type CodeViewOverflow = NonNullable< - CodeViewOptions['overflow'] + CodeViewOptions['overflow'] >; type CodeViewThemeType = NonNullable< - CodeViewOptions['themeType'] + CodeViewOptions['themeType'] >; interface RenderDemoCodeViewOptions { @@ -87,8 +87,8 @@ export function renderDemoCodeView( setupCodeViewWrapper(wrapper); const items = createCodeViewItems(parsedPatches); - let viewer: CodeView; - const options: CodeViewOptions = { + let viewer: CodeView; + const options: CodeViewOptions = { theme, themeType, diffStyle, diff --git a/apps/docs/app/(diffshub)/(view)/_components/CodeViewWrapper.tsx b/apps/docs/app/(diffshub)/(view)/_components/CodeViewWrapper.tsx index f10cfd33d..1af4783cc 100644 --- a/apps/docs/app/(diffshub)/(view)/_components/CodeViewWrapper.tsx +++ b/apps/docs/app/(diffshub)/(view)/_components/CodeViewWrapper.tsx @@ -49,7 +49,7 @@ function getNextItemVersion(item: CodeViewItem): number { } function updateViewerDiffItem( - viewer: CodeViewHandle, + viewer: CodeViewHandle, itemId: string, updateItem: (item: CodeViewDiffItem) => boolean ): CodeViewDiffItem | undefined { @@ -84,7 +84,7 @@ interface CodeViewWrapperProps { lineNumbers: boolean; scrollRef: RefObject; themeType: ThemeTypes; - viewerRef: RefObject | null>; + viewerRef: RefObject | null>; initialItems: CodeViewItem[]; onLineLinkChange(selection: CodeViewLineSelection | null): void; onViewerReady(): void; @@ -146,7 +146,7 @@ export const CodeViewWrapper = memo(function CodeViewWrapper({ ); const handleViewerRef = useStableCallback( - (viewer: CodeViewHandle | null) => { + (viewer: CodeViewHandle | null) => { viewerRef.current = viewer; if (viewer != null) { onViewerReady(); @@ -431,7 +431,7 @@ export const CodeViewWrapper = memo(function CodeViewWrapper({ // NOTE(amadeus): For some insane reason, the react compiler did not know how // to properly memoize this, so we pulled it into a `useMemo` for safety... - const options: CodeViewOptions = useMemo( + const options: CodeViewOptions = useMemo( () => ({ // Use this to validate itemMetrics when changing layout with unsafeCSS. @@ -459,7 +459,7 @@ export const CodeViewWrapper = memo(function CodeViewWrapper({ onLineSelectionEnd(range, context) { handleLineSelectionEnd(range, context.item); }, - }) satisfies CodeViewOptions, + }) satisfies CodeViewOptions, [ diffIndicators, diffStyle, diff --git a/apps/docs/app/(diffshub)/(view)/_components/ReviewUI.tsx b/apps/docs/app/(diffshub)/(view)/_components/ReviewUI.tsx index de80f109b..85a41bcb2 100644 --- a/apps/docs/app/(diffshub)/(view)/_components/ReviewUI.tsx +++ b/apps/docs/app/(diffshub)/(view)/_components/ReviewUI.tsx @@ -136,7 +136,9 @@ export function ReviewUI({ domain, initialUrl, path }: ReviewUIProps) { }); }, [workerPool, darkTheme, lightTheme, themesHydrated]); const scrollRef = useRef(null); - const viewerRef = useRef | null>(null); + const viewerRef = useRef | null>( + null + ); const handlePatchLoadStart = useCallback(() => { setFileTreeOverlayOpen(false); }, []); diff --git a/apps/docs/app/(diffshub)/(view)/_components/usePatchLoader.ts b/apps/docs/app/(diffshub)/(view)/_components/usePatchLoader.ts index a07cee686..6fe432dd0 100644 --- a/apps/docs/app/(diffshub)/(view)/_components/usePatchLoader.ts +++ b/apps/docs/app/(diffshub)/(view)/_components/usePatchLoader.ts @@ -58,7 +58,7 @@ interface UsePatchLoaderOptions { domain?: string; onLoadStart(): void; path: string; - viewerRef: RefObject | null>; + viewerRef: RefObject | null>; } interface UsePatchLoaderResult { @@ -529,7 +529,7 @@ function getLineHashApplyKey(viewerKey: number, hash: string): string { } function applyCodeViewLineHashTarget( - viewer: CodeViewHandle, + viewer: CodeViewHandle, target: CodeViewLineHashTarget ): boolean { const item = viewer.getItem(target.itemId); @@ -566,7 +566,7 @@ function applyCodeViewLineHashTarget( } function applyCodeViewItemIdRename( - viewer: CodeViewHandle | null, + viewer: CodeViewHandle | null, rename: CodeViewItemIdRename ): void { viewer?.updateItemId(rename.oldId, rename.newId); diff --git a/apps/docs/components/docs/DocsCodeExample.tsx b/apps/docs/components/docs/DocsCodeExample.tsx index d4dfd135d..164b429cd 100644 --- a/apps/docs/components/docs/DocsCodeExample.tsx +++ b/apps/docs/components/docs/DocsCodeExample.tsx @@ -12,20 +12,21 @@ import { IconBrandGithub } from '@pierre/icons'; import { CopyCodeButton } from './CopyCodeButton'; import { cn } from '@/lib/utils'; -interface DocsCodeExampleProps { +interface DocsCodeExampleProps { file: FileContents; - options?: FileOptions; + options?: FileOptions; annotations?: LineAnnotation[]; prerenderedHTML?: string; - style?: FileProps['style']; + style?: FileProps['style']; className?: string | undefined; /** Optional link to the source file on GitHub */ href?: string; } -export function DocsCodeExample( - props: DocsCodeExampleProps -) { +export function DocsCodeExample< + LAnnotation = undefined, + LDecoration = undefined, +>(props: DocsCodeExampleProps) { const { href, ...rest } = props; return ( extends AdvancedVirtualizedBaseItem { type: 'diff'; /** Latest item snapshot for this record. Controlled updates can replace it. */ - item: CodeViewDiffItem; + item: CodeViewDiffItem; /** Virtualized diff instance responsible for rendering this item. */ - instance: VirtualizedFileDiff; + instance: VirtualizedFileDiff; } interface CodeViewFileItemContext< LAnnotation, + LDecoration, > extends AdvancedVirtualizedBaseItem { type: 'file'; /** Latest item snapshot for this record. Controlled updates can replace it. */ - item: CodeViewFileItem; + item: CodeViewFileItem; /** Virtualized file instance responsible for rendering this item. */ - instance: VirtualizedFile; + instance: VirtualizedFile; } -type CodeViewContextItem = - | CodeViewDiffItemContext - | CodeViewFileItemContext; +type CodeViewContextItem = + | CodeViewDiffItemContext + | CodeViewFileItemContext; -export interface CodeViewRenderedDiffItem { +export interface CodeViewRenderedDiffItem { id: string; type: 'diff'; - item: CodeViewDiffItem; + item: CodeViewDiffItem; version: number | undefined; element: HTMLElement; - instance: VirtualizedFileDiff; + instance: VirtualizedFileDiff; } -export interface CodeViewRenderedFileItem { +export interface CodeViewRenderedFileItem { id: string; type: 'file'; - item: CodeViewFileItem; + item: CodeViewFileItem; version: number | undefined; element: HTMLElement; - instance: VirtualizedFile; + instance: VirtualizedFile; } -export type CodeViewRenderedItem = - | CodeViewRenderedDiffItem - | CodeViewRenderedFileItem; +export type CodeViewRenderedItem = + | CodeViewRenderedDiffItem + | CodeViewRenderedFileItem; export interface CodeViewLineSelection { id: string; range: SelectedLineRange; } -export interface CodeViewCoordinator { +export interface CodeViewCoordinator { hasHeaderRenderers: boolean; hasAnnotationRenderer: boolean; hasGutterRenderer: boolean; onSnapshotChange( - snapshot: CodeViewRenderedItem[] | undefined + snapshot: CodeViewRenderedItem[] | undefined ): void; } -export type CodeViewScrollListener = ( +export type CodeViewScrollListener = ( scrollTop: number, - viewer: CodeView + viewer: CodeView ) => void; type OverloadCallbackArgs = TCallback extends ( @@ -181,51 +183,64 @@ type CallbackReturn = TCallback extends ( type OverloadFileCallbackArgs< LAnnotation, - TKey extends keyof FileOptions, -> = OverloadCallbackArgs[TKey]>>; + LDecoration, + TKey extends keyof FileOptions, +> = OverloadCallbackArgs< + NonNullable[TKey]> +>; type OverloadDiffCallbackArgs< LAnnotation, - TKey extends keyof FileDiffOptions, -> = OverloadCallbackArgs[TKey]>>; + LDecoration, + TKey extends keyof FileDiffOptions, +> = OverloadCallbackArgs< + NonNullable[TKey]> +>; type CodeViewFileOptionCallback< LAnnotation, + LDecoration, TKey extends keyof FileOptions, > = ( ...args: [ - ...OverloadFileCallbackArgs, - context: CodeViewFileItemContext, + ...OverloadFileCallbackArgs, + context: CodeViewFileItemContext, ] -) => CallbackReturn[TKey]>>; +) => CallbackReturn[TKey]>>; type CodeViewDiffOptionCallback< LAnnotation, - TKey extends keyof FileDiffOptions, + LDecoration, + TKey extends keyof FileDiffOptions, > = ( ...args: [ - ...OverloadDiffCallbackArgs, - context: CodeViewDiffItemContext, + ...OverloadDiffCallbackArgs, + context: CodeViewDiffItemContext, ] -) => CallbackReturn[TKey]>>; +) => CallbackReturn< + NonNullable[TKey]> +>; type CodeViewOptionCallback< LAnnotation, - TKey extends keyof FileOptions & - keyof FileDiffOptions, + LDecoration, + TKey extends keyof FileOptions & + keyof FileDiffOptions, > = { ( ...args: [ - ...OverloadFileCallbackArgs, - context: CodeViewFileItemContext, + ...OverloadFileCallbackArgs, + context: CodeViewFileItemContext, ] - ): CallbackReturn[TKey]>>; + ): CallbackReturn[TKey]>>; ( ...args: [ - ...OverloadDiffCallbackArgs, - context: CodeViewDiffItemContext, + ...OverloadDiffCallbackArgs, + context: CodeViewDiffItemContext, ] - ): CallbackReturn[TKey]>>; + ): CallbackReturn< + NonNullable[TKey]> + >; }; const CODE_VIEW_DIFF_OPTION_KEYS = [ @@ -284,8 +299,8 @@ const CODE_VIEW_FILE_OPTION_KEYS = [ type CodeViewFileOptionKeys = (typeof CODE_VIEW_FILE_OPTION_KEYS)[number]; -type CodeViewPassThroughOptions = Pick< - FileDiffOptions, +type CodeViewPassThroughOptions = Pick< + FileDiffOptions, CodeViewDiffOptionKeys >; @@ -293,38 +308,44 @@ type CodeViewMode = 'file' | 'diff'; type CodeViewModeItemContext< LAnnotation, + LDecoration, TMode extends CodeViewMode, > = TMode extends 'file' - ? CodeViewFileItemContext - : CodeViewDiffItemContext; + ? CodeViewFileItemContext + : CodeViewDiffItemContext; type CodeViewModeOptionCallback< LAnnotation, + LDecoration, TMode extends CodeViewMode, TKey extends CodeViewSharedCallbackKeys | CodeViewSelectionCallbackKeys, > = TMode extends 'file' - ? CodeViewFileOptionCallback - : CodeViewDiffOptionCallback; + ? CodeViewFileOptionCallback + : CodeViewDiffOptionCallback; type CodeViewModeInternalOptionCallback< LAnnotation, + LDecoration, TMode extends CodeViewMode, TKey extends CodeViewSharedCallbackKeys | CodeViewSelectionCallbackKeys, > = ( ...args: [ ...OverloadCallbackArgs< - NonNullable[TKey]> + NonNullable[TKey]> >, - CodeViewModeItemContext, + CodeViewModeItemContext, ] -) => CallbackReturn[TKey]>>; +) => CallbackReturn< + NonNullable[TKey]> +>; type CodeViewModeOptions< LAnnotation, + LDecoration, TMode extends CodeViewMode, > = TMode extends 'file' - ? FileOptions - : FileDiffOptions; + ? FileOptions + : FileDiffOptions; const CODE_VIEW_SHARED_CALLBACK_KEYS = [ 'renderCustomHeader', @@ -378,13 +399,18 @@ interface CodeViewItemOptionsState { type CodeViewItemOptions< LAnnotation, + LDecoration, TMode extends CodeViewMode, -> = CodeViewModeOptions & { +> = CodeViewModeOptions & { [CODE_VIEW_ITEM_OPTIONS_STATE]: CodeViewItemOptionsState; }; -function defineOptionsState( - options: CodeViewModeOptions, +function defineOptionsState< + LAnnotation, + LDecoration, + TMode extends CodeViewMode, +>( + options: CodeViewModeOptions, state: CodeViewItemOptionsState ): void { // Keep the state hidden from option enumeration. Renderer option builders @@ -396,24 +422,30 @@ function defineOptionsState( }); } -function getItemOptionsState( - options: CodeViewModeOptions +function getItemOptionsState< + LAnnotation, + LDecoration, + TMode extends CodeViewMode, +>( + options: CodeViewModeOptions ): CodeViewItemOptionsState { - return (options as CodeViewItemOptions)[ + return (options as CodeViewItemOptions)[ CODE_VIEW_ITEM_OPTIONS_STATE ]; } -type CodeViewSharedCallbackOptions = { +type CodeViewSharedCallbackOptions = { [TKey in CodeViewSharedCallbackKeys]?: CodeViewOptionCallback< LAnnotation, + LDecoration, TKey >; }; -type CodeViewSelectionCallbackOptions = { +type CodeViewSelectionCallbackOptions = { [TKey in CodeViewSelectionCallbackKeys]?: CodeViewOptionCallback< LAnnotation, + LDecoration, TKey >; }; @@ -435,11 +467,11 @@ function defineItemOption( }); } -export interface CodeViewOptions +export interface CodeViewOptions extends - CodeViewPassThroughOptions, - CodeViewSharedCallbackOptions, - CodeViewSelectionCallbackOptions { + CodeViewPassThroughOptions, + CodeViewSharedCallbackOptions, + CodeViewSelectionCallbackOptions { hunkSeparators?: Exclude; itemMetrics?: Partial; pointerEventsOnScroll?: boolean; @@ -512,7 +544,7 @@ type PendingScrollTarget = | PendingRangeTarget | PendingItemTarget; -export class CodeView { +export class CodeView { static __STOP = false; static __lastScrollPosition = 0; @@ -522,21 +554,29 @@ export class CodeView { intersectionObserverMargin: 0, resizeDebugging: false, }; - private items: CodeViewContextItem[] = []; - private idToItem: Map> = new Map(); + private items: CodeViewContextItem[] = []; + private idToItem: Map> = + new Map(); private selectedLines: CodeViewLineSelection | null = null; // NOTE(amadeus): We should probably attach an id to instances and use that // for lookups, instead of maintaining this map... private instanceToItem: Map< - VirtualizedFileDiff | VirtualizedFile, - CodeViewContextItem + | VirtualizedFileDiff + | VirtualizedFile, + CodeViewContextItem > = new Map(); private layoutDirtyIndex: number | undefined; private pendingLayoutReset: PendingCodeViewLayoutReset | undefined; private renderOptionsRevision = 0; - private slotCoordinator: CodeViewCoordinator | undefined; - private slotSnapshot: CodeViewRenderedItem[] | undefined; - private scrollListeners: Set> = new Set(); + private slotCoordinator: + | CodeViewCoordinator + | undefined; + private slotSnapshot: + | CodeViewRenderedItem[] + | undefined; + private scrollListeners: Set< + CodeViewScrollListener + > = new Set(); private scrollHeight = 0; private containerHeight = -1; private scrollTop: number = 0; @@ -557,8 +597,11 @@ export class CodeView { stickyBottom: -1, }; private itemMetricsCache: VirtualFileMetrics = DEFAULT_CODE_VIEW_FILE_METRICS; - private readonly fileOptionsPrototype: FileOptions; - private readonly diffOptionsPrototype: FileDiffOptions; + private readonly fileOptionsPrototype: FileOptions; + private readonly diffOptionsPrototype: FileDiffOptions< + LAnnotation, + LDecoration + >; // Pending scroll target, either instant or smooth. The next render cycle // will attempt to resolve it's position instantly or as part of a dynamic // animation. @@ -596,12 +639,14 @@ export class CodeView { // i.e. the react CodeView component will require a separate react cleanup // phase that we don't want to interrupt private pendingElementPool: HTMLElement[] = []; - private options: CodeViewOptions; + private options: CodeViewOptions; private workerManager: WorkerPoolManager | undefined; private isContainerManaged: boolean; constructor( - options: CodeViewOptions = { theme: DEFAULT_THEMES }, + options: CodeViewOptions = { + theme: DEFAULT_THEMES, + }, workerManager?: WorkerPoolManager | undefined, isContainerManaged = false ) { @@ -661,7 +706,7 @@ export class CodeView { } private validateRenderedItemHeight( - item: CodeViewContextItem + item: CodeViewContextItem ): void { if (!this.shouldValidateItemHeights() || item.element == null) { return; @@ -980,7 +1025,9 @@ export class CodeView { return element; } - private releaseRenderedItem(item: CodeViewContextItem): void { + private releaseRenderedItem( + item: CodeViewContextItem + ): void { const { element } = item; item.instance.cleanUp(true); item.element = undefined; @@ -1152,11 +1199,13 @@ export class CodeView { this.applySelectedLines(null, options); } - public getItem(itemId: string): CodeViewItem | undefined { + public getItem( + itemId: string + ): CodeViewItem | undefined { return this.idToItem.get(itemId)?.item; } - public updateItem(input: CodeViewItem): boolean { + public updateItem(input: CodeViewItem): boolean { const item = this.idToItem.get(input.id); if (item == null) { console.error(`CodeView.updateItem: unknown item id "${input.id}"`); @@ -1205,17 +1254,21 @@ export class CodeView { return true; } - public addItem(input: CodeViewItem): void { + public addItem(input: CodeViewItem): void { this.addItems([input]); this.syncSelection(); } - public addItems(inputs: readonly CodeViewItem[]): void { + public addItems( + inputs: readonly CodeViewItem[] + ): void { this.appendItemsInternal(inputs); this.syncSelection(); } - public setItems(items: readonly CodeViewItem[]): void { + public setItems( + items: readonly CodeViewItem[] + ): void { if (items.length === 0) { this.reset(); } else if (this.items.length === 0) { @@ -1233,7 +1286,7 @@ export class CodeView { * once at the end. */ private appendItemsInternal( - inputs: readonly CodeViewItem[], + inputs: readonly CodeViewItem[], render = true ): void { if (inputs.length === 0) { @@ -1286,7 +1339,9 @@ export class CodeView { this.invalidateElementPool(); } - public setOptions(options: CodeViewOptions | undefined): void { + public setOptions( + options: CodeViewOptions | undefined + ): void { if (options == null) { return; } @@ -1365,7 +1420,9 @@ export class CodeView { } public instanceChanged( - instance: VirtualizedFile | VirtualizedFileDiff, + instance: + | VirtualizedFile + | VirtualizedFileDiff, layoutDirty: boolean ): void { // NOTE(amadeus): This is technically broken at the moment. What we @@ -1392,13 +1449,13 @@ export class CodeView { return this.root; } - public getRenderedItems(): CodeViewRenderedItem[] { + public getRenderedItems(): CodeViewRenderedItem[] { const { firstIndex, lastIndex } = this.renderState; if (firstIndex === -1 || lastIndex === -1 || lastIndex < firstIndex) { return []; } - const renderedItems: CodeViewRenderedItem[] = []; + const renderedItems: CodeViewRenderedItem[] = []; for (let index = firstIndex; index <= lastIndex; index++) { const item = this.items[index]; @@ -1431,7 +1488,7 @@ export class CodeView { } public setSlotCoordinator( - coordinator?: CodeViewCoordinator + coordinator?: CodeViewCoordinator ): boolean { if (coordinator === this.slotCoordinator) { return false; @@ -1442,13 +1499,13 @@ export class CodeView { } public getSlotSnapshot( - coordinator: CodeViewCoordinator - ): CodeViewRenderedItem[] | undefined { + coordinator: CodeViewCoordinator + ): CodeViewRenderedItem[] | undefined { return getSlotSnapshot(this.getRenderedItems(), coordinator); } public subscribeToScroll( - listener: CodeViewScrollListener + listener: CodeViewScrollListener ): () => void { this.scrollListeners.add(listener); return () => { @@ -1457,7 +1514,9 @@ export class CodeView { } public getLocalTopForInstance( - instance: VirtualizedFile | VirtualizedFileDiff + instance: + | VirtualizedFile + | VirtualizedFileDiff ): number { const item = this.instanceToItem.get(instance); if (item == null) { @@ -1477,13 +1536,13 @@ export class CodeView { } private createItem( - input: CodeViewItem, + input: CodeViewItem, index: number, top: number - ): CodeViewContextItem { + ): CodeViewContextItem { const { itemMetricsCache: itemMetrics } = this; if (input.type === 'diff') { - const instance = new VirtualizedFileDiff( + const instance = new VirtualizedFileDiff( this.createDiffOptions(input.id), this, itemMetrics, @@ -1500,10 +1559,10 @@ export class CodeView { element: undefined, renderedOptionsRevision: this.renderOptionsRevision, instance, - } satisfies CodeViewDiffItemContext; + } satisfies CodeViewDiffItemContext; } - const instance = new VirtualizedFile( + const instance = new VirtualizedFile( this.createFileOptions(input.id), this, itemMetrics, @@ -1520,7 +1579,7 @@ export class CodeView { element: undefined, renderedOptionsRevision: this.renderOptionsRevision, instance, - } satisfies CodeViewFileItemContext; + } satisfies CodeViewFileItemContext; } private applySelectedLines( @@ -1589,15 +1648,14 @@ export class CodeView { // answer current option reads for the item instance that keeps them for its // lifetime. The accessors live on per-CodeView prototypes so large viewers do // not allocate the full option surface for every file or diff item. - private createFileOptionsPrototype(): FileOptions { - const prototype = {} as FileOptions; + private createFileOptionsPrototype(): FileOptions { + const prototype = {} as FileOptions; for (const key of CODE_VIEW_FILE_OPTION_KEYS) { - defineItemOption, CodeViewFileOptionKeys>( - prototype, - key, - () => this.options[key] - ); + defineItemOption< + FileOptions, + CodeViewFileOptionKeys + >(prototype, key, () => this.options[key]); } defineItemOption( @@ -1623,15 +1681,17 @@ export class CodeView { return prototype; } - private createDiffOptionsPrototype(): FileDiffOptions { - const prototype = {} as FileDiffOptions; + private createDiffOptionsPrototype(): FileDiffOptions< + LAnnotation, + LDecoration + > { + const prototype = {} as FileDiffOptions; for (const key of CODE_VIEW_DIFF_OPTION_KEYS) { - defineItemOption, CodeViewDiffOptionKeys>( - prototype, - key, - () => this.options[key] - ); + defineItemOption< + FileDiffOptions, + CodeViewDiffOptionKeys + >(prototype, key, () => this.options[key]); } defineItemOption( @@ -1662,12 +1722,13 @@ export class CodeView { return prototype; } - private createFileOptions(id: string): FileOptions { + private createFileOptions(id: string): FileOptions { // The per-item options object intentionally owns only hidden state. All // public option reads fall through to the shared prototype above. - const options = Object.create( - this.fileOptionsPrototype - ) as FileOptions; + const options = Object.create(this.fileOptionsPrototype) as FileOptions< + LAnnotation, + LDecoration + >; const state: CodeViewItemOptionsState = { id, }; @@ -1675,21 +1736,26 @@ export class CodeView { return options; } - private createDiffOptions(id: string): FileDiffOptions { + private createDiffOptions( + id: string + ): FileDiffOptions { // The per-item options object intentionally owns only hidden state. All // public option reads fall through to the shared prototype above. - const options = Object.create( - this.diffOptionsPrototype - ) as FileDiffOptions; + const options = Object.create(this.diffOptionsPrototype) as FileDiffOptions< + LAnnotation, + LDecoration + >; const state: CodeViewItemOptionsState = { id, }; - defineOptionsState(options, state); + defineOptionsState(options, state); return options; } private updateItemOptionsId( - options: FileOptions | FileDiffOptions, + options: + | FileOptions + | FileDiffOptions, id: string ): void { getItemOptionsState(options).id = id; @@ -1698,44 +1764,44 @@ export class CodeView { private getItemOptions( state: CodeViewItemOptionsState, mode: TMode - ): CodeViewModeItemContext | undefined { + ): CodeViewModeItemContext | undefined { const item = this.idToItem.get(state.id); if (item == null || item.type !== mode) { return undefined; } - return item as CodeViewModeItemContext; + return item as CodeViewModeItemContext; } private defineItemSharedCallback< TMode extends CodeViewMode, TKey extends CodeViewSharedCallbackKeys, >( - options: CodeViewModeOptions, + options: CodeViewModeOptions, mode: TMode, key: TKey ): void { defineItemOption( options as Record< TKey, - CodeViewModeOptions[TKey] | undefined + CodeViewModeOptions[TKey] | undefined >, key, (receiver) => { const current = this.options[key] as - | CodeViewModeOptionCallback + | CodeViewModeOptionCallback | undefined; if (current == null) { return undefined; } const state = getItemOptionsState( - receiver as CodeViewModeOptions + receiver as CodeViewModeOptions ); // Allocate wrapper storage only once a callback option is actually // observed. Most large CodeViews never read these callback properties. const callbackCache = (state.callbackCache ??= {}); let wrapped = callbackCache[key] as - | CodeViewModeOptions[TKey] + | CodeViewModeOptions[TKey] | undefined; if (wrapped == null) { wrapped = ((...args: unknown[]) => { @@ -1744,12 +1810,17 @@ export class CodeView { return undefined; } const callback = this.options[key] as - | CodeViewModeInternalOptionCallback + | CodeViewModeInternalOptionCallback< + LAnnotation, + LDecoration, + TMode, + TKey + > | undefined; return ( callback as ((...callbackArgs: unknown[]) => unknown) | undefined )?.(...args, latest); - }) as CodeViewModeOptions[TKey]; + }) as CodeViewModeOptions[TKey]; callbackCache[key] = wrapped; } @@ -1763,14 +1834,14 @@ export class CodeView { TMode extends CodeViewMode, TKey extends CodeViewSelectionCallbackKeys, >( - options: CodeViewModeOptions, + options: CodeViewModeOptions, mode: TMode, key: TKey ): void { defineItemOption( options as Record< TKey, - CodeViewModeOptions[TKey] | undefined + CodeViewModeOptions[TKey] | undefined >, key, (receiver) => { @@ -1779,14 +1850,14 @@ export class CodeView { } const state = getItemOptionsState( - receiver as CodeViewModeOptions + receiver as CodeViewModeOptions ); // Selection callbacks also use the per-item lazy cache. The wrapper // owns CodeView selection synchronization and then delegates to the // latest user callback, if one exists. const callbackCache = (state.callbackCache ??= {}); let wrapped = callbackCache[key] as - | CodeViewModeOptions[TKey] + | CodeViewModeOptions[TKey] | undefined; if (wrapped == null) { wrapped = ((range: SelectedLineRange | null) => { @@ -1808,11 +1879,15 @@ export class CodeView { const callback = this.options[key] as | (( nextRange: SelectedLineRange | null, - context: CodeViewModeItemContext + context: CodeViewModeItemContext< + LAnnotation, + LDecoration, + TMode + > ) => unknown) | undefined; return callback?.(range, latest); - }) as CodeViewModeOptions[TKey]; + }) as CodeViewModeOptions[TKey]; callbackCache[key] = wrapped; } @@ -1836,7 +1911,9 @@ export class CodeView { * Each record carries its current array index so this stays O(1) even when * the viewer holds a very large number of items. */ - private markItemLayoutDirty(item: CodeViewContextItem): void { + private markItemLayoutDirty( + item: CodeViewContextItem + ): void { if (this.items[item.index] !== item) { throw new Error( `CodeView.markItemLayoutDirty: unknown item id "${item.item.id}"` @@ -1852,7 +1929,9 @@ export class CodeView { * record in place, sync any versioned payload changes, and append only the new * tail instead of rebuilding the whole list. */ - private tryAppendItems(items: readonly CodeViewItem[]): boolean { + private tryAppendItems( + items: readonly CodeViewItem[] + ): boolean { if (items.length <= this.items.length) { return false; } @@ -1900,17 +1979,20 @@ export class CodeView { * records, rebuilds the lookup maps, and marks layout dirty whenever order, * membership, or versioned item data changes. */ - private reconcileItems(items: readonly CodeViewItem[]): void { + private reconcileItems( + items: readonly CodeViewItem[] + ): void { const { items: previousItems, idToItem: previousById } = this; const removedItems = new Set(previousItems); - const nextItems: CodeViewContextItem[] = []; + const nextItems: CodeViewContextItem[] = []; const nextIdToItem: Map< string, - CodeViewContextItem + CodeViewContextItem > = new Map(); const nextInstanceToItem: Map< - VirtualizedFileDiff | VirtualizedFile, - CodeViewContextItem + | VirtualizedFileDiff + | VirtualizedFile, + CodeViewContextItem > = new Map(); let firstDirtyIndex: number | undefined; @@ -1985,8 +2067,8 @@ export class CodeView { * intentionally publishes a newer version. */ private syncItemRecord( - item: CodeViewContextItem, - nextItem: CodeViewItem + item: CodeViewContextItem, + nextItem: CodeViewItem ): boolean { if (item.type !== nextItem.type) { throw new Error( @@ -2336,7 +2418,7 @@ export class CodeView { } private getLineScrollPosition( - item: CodeViewContextItem, + item: CodeViewContextItem, target: CodeViewLineScrollTarget ): LineScrollPosition | undefined { if (item.type === 'diff') { @@ -2347,7 +2429,7 @@ export class CodeView { } private getRangeScrollPosition( - item: CodeViewContextItem, + item: CodeViewContextItem, target: CodeViewRangeScrollTarget ): LineScrollPosition | undefined { const { range } = target; @@ -2604,7 +2686,9 @@ export class CodeView { } let prevElement: HTMLElement | undefined; - const updatedItems = new Set>(); + const updatedItems = new Set< + CodeViewContextItem + >(); const startingIndex = this.findFirstVisibleIndex(top); const lastRenderedIndex = this.findLastVisibleIndex(bottom); @@ -2730,7 +2814,7 @@ export class CodeView { }; private flushManagers( - updatedItems: Set> + updatedItems: Set> ): void { for (const item of updatedItems) { item.instance.flushManagers(); @@ -2813,7 +2897,7 @@ export class CodeView { } private reconcileRenderedItems( - updatedItems?: Set> + updatedItems?: Set> ): void { const { firstIndex, lastIndex } = this.renderState; if (firstIndex === -1) { @@ -3312,8 +3396,8 @@ export class CodeView { } } -function prepareItemInstance( - item: CodeViewContextItem +function prepareItemInstance( + item: CodeViewContextItem ): number { item.instance.cleanUp(true); if (item.type === 'diff') { @@ -3323,9 +3407,9 @@ function prepareItemInstance( } } -function shouldClearPool( - previousOptions: CodeViewOptions, - nextOptions: CodeViewOptions +function shouldClearPool( + previousOptions: CodeViewOptions, + nextOptions: CodeViewOptions ): boolean { return ( !areThemesEqual( @@ -3338,9 +3422,9 @@ function shouldClearPool( ); } -function hasItemLayoutOptionChanged( - previousOptions: CodeViewOptions, - nextOptions: CodeViewOptions +function hasItemLayoutOptionChanged( + previousOptions: CodeViewOptions, + nextOptions: CodeViewOptions ): boolean { return ( (previousOptions.overflow ?? 'scroll') !== @@ -3365,9 +3449,9 @@ function hasItemLayoutOptionChanged( ); } -function hasCodeViewDiffEstimateOptionChanged( - previousOptions: CodeViewOptions, - nextOptions: CodeViewOptions +function hasCodeViewDiffEstimateOptionChanged( + previousOptions: CodeViewOptions, + nextOptions: CodeViewOptions ): boolean { return ( (previousOptions.disableFileHeader ?? false) !== @@ -3412,8 +3496,8 @@ function formatSelectedLinePoint( return `${side === 'deletions' ? 'D' : 'A'}${lineNumber}`; } -function renderItem( - item: CodeViewContextItem, +function renderItem( + item: CodeViewContextItem, fileContainer?: HTMLElement, forceRender = false ): boolean { @@ -3424,6 +3508,7 @@ function renderItem( fileDiff: item.item.fileDiff, forceRender, lineAnnotations: item.item.annotations, + decorations: item.item.decorations, }); } else { return item.instance.render({ @@ -3432,6 +3517,7 @@ function renderItem( file: item.item.file, forceRender, lineAnnotations: item.item.annotations, + decorations: item.item.decorations, }); } } @@ -3459,18 +3545,20 @@ function syncRenderedItemOrder( } } -function hasAnnotations(item: CodeViewItem): boolean { +function hasAnnotations( + item: CodeViewItem +): boolean { return (item.annotations?.length ?? 0) > 0; } -function getSlotSnapshot( - renderedItems: CodeViewRenderedItem[], +function getSlotSnapshot( + renderedItems: CodeViewRenderedItem[], { hasHeaderRenderers, hasAnnotationRenderer, hasGutterRenderer, - }: CodeViewCoordinator -): CodeViewRenderedItem[] | undefined { + }: CodeViewCoordinator +): CodeViewRenderedItem[] | undefined { if (renderedItems.length === 0) { return undefined; } @@ -3483,7 +3571,7 @@ function getSlotSnapshot( return undefined; } - const slotSnapshot: CodeViewRenderedItem[] = []; + const slotSnapshot: CodeViewRenderedItem[] = []; for (const renderedItem of renderedItems) { if (hasAnnotations(renderedItem.item)) { @@ -3494,9 +3582,9 @@ function getSlotSnapshot( return slotSnapshot.length > 0 ? slotSnapshot : undefined; } -function areSlotSnapshotsEqual( - previous: CodeViewRenderedItem[] | undefined, - next: CodeViewRenderedItem[] | undefined +function areSlotSnapshotsEqual( + previous: CodeViewRenderedItem[] | undefined, + next: CodeViewRenderedItem[] | undefined ): boolean { if (previous == null || next == null) { return previous === next; diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 5bcbe78cd..ba7d12a11 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -26,6 +26,7 @@ import type { AppliedThemeStyleCache, BaseCodeOptions, FileContents, + FileDecorationItem, LineAnnotation, PostRenderPhase, PrePropertiesConfig, @@ -61,7 +62,7 @@ import { DiffsContainerLoaded } from './web-components'; const EMPTY_STRINGS: string[] = []; -export interface FileRenderProps { +export interface FileRenderProps { file: FileContents; fileContainer?: HTMLElement; containerWrapper?: HTMLElement; @@ -69,18 +70,19 @@ export interface FileRenderProps { 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; renderHeaderPrefix?: RenderFileMetadata; @@ -101,7 +103,7 @@ export interface FileOptions onPostRender?( node: HTMLElement, - instance: File, + instance: File, phase: PostRenderPhase ): unknown; } @@ -116,14 +118,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}`; @@ -154,13 +157,14 @@ 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 managersDirty = false; public file: FileContents | undefined; @@ -168,11 +172,13 @@ export class File { protected enabled = true; 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 @@ -203,7 +209,9 @@ export class File { this.rerender(); } - public setOptions(options: FileOptions | undefined): void { + public setOptions( + options: FileOptions | undefined + ): void { if (options == null) return; this.options = options; this.cachedHeaderHTML = undefined; @@ -214,7 +222,9 @@ export class File { this.interactionManager.setOptions(pluckInteractionOptions(this.options)); } - private mergeOptions(options: Partial>): void { + private mergeOptions( + options: Partial> + ): void { this.options = { ...this.options, ...options }; } @@ -274,6 +284,10 @@ export class File { this.interactionManager.setSelection(range, options); } + public setDecorations(decorations: FileDecorationItem[]): void { + this.decorations = decorations; + } + public flushManagers(): void { if (!this.managersDirty || this.pre == null) { this.managersDirty = false; @@ -340,13 +354,14 @@ export class File { this.workerManager?.subscribeToThemeChanges(this); } - public hydrate(props: FileHydrateProps): void { + public hydrate(props: FileHydrateProps): void { const { fileContainer, prerenderedHTML, preventEmit = false, file, lineAnnotations, + decorations, } = props; this.hydrateElements(fileContainer, prerenderedHTML); if ( @@ -361,7 +376,7 @@ export class File { } // Otherwise orchestrate our setup. else { - this.hydrationSetup({ file, lineAnnotations }); + this.hydrationSetup({ file, lineAnnotations, decorations }); } if (!preventEmit) { this.emitPostRender(); @@ -423,11 +438,14 @@ export class File { protected hydrationSetup({ file, lineAnnotations, - }: HydrationSetup): void { + decorations, + }: HydrationSetup): void { this.lineAnnotations = lineAnnotations ?? this.lineAnnotations; + this.decorations = decorations ?? this.decorations; this.file = file; this.fileRenderer.setOptions(getFileRendererOptions(this.options)); this.syncInteractionOptions(); + this.fileRenderer.setDecorations(this.decorations); if (this.pre == null) { return; } @@ -455,8 +473,9 @@ export class File { containerWrapper, deferManagers = false, lineAnnotations, + decorations, renderRange, - }: FileRenderProps): boolean { + }: FileRenderProps): boolean { const { collapsed = false, themeType = 'system' } = this.options; if (!this.enabled) { throw new Error( @@ -466,11 +485,17 @@ export class File { const nextRenderRange = collapsed ? undefined : renderRange; const previousRenderRange = this.renderRange; const themeChanged = this.hasThemeChanged(); + 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 && @@ -478,7 +503,8 @@ export class File { areRenderRangesEqual(nextRenderRange, this.renderRange) && !didFileChange && !annotationsChanged && - !themeChanged + !themeChanged && + !decorationsChanged ) { return this.applyCachedThemeState(themeType); } @@ -493,7 +519,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, disableFileHeader = false } = this.options; diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index cf03eefec..95091e051 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -32,6 +32,7 @@ import type { AppliedThemeStyleCache, BaseDiffOptions, CustomPreProperties, + DiffDecorationItem, DiffLineAnnotation, ExpansionDirections, FileContents, @@ -74,7 +75,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; @@ -84,11 +85,12 @@ 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; @@ -97,7 +99,10 @@ export interface FileDiffHydrationProps extends Omit< export type FileDiffType = 'file-diff' | 'unresolved-file'; -export interface FileDiffOptions +export interface FileDiffOptions< + LAnnotation = undefined, + LDecoration = undefined, +> extends Omit, InteractionManagerBaseOptions<'diff'> { @@ -108,7 +113,7 @@ export interface FileDiffOptions */ | (( hunk: HunkData, - instance: FileDiff + instance: FileDiff ) => HTMLElement | DocumentFragment | null | undefined); disableFileHeader?: boolean; renderHeaderPrefix?: RenderHeaderPrefixCallback; @@ -129,7 +134,7 @@ export interface FileDiffOptions onPostRender?( node: HTMLElement, - instance: FileDiff, + instance: FileDiff, phase: PostRenderPhase ): unknown; } @@ -166,16 +171,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; @@ -206,7 +212,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'>; @@ -214,6 +220,7 @@ export class FileDiff { protected annotationCache: Map> = new Map(); protected lineAnnotations: DiffLineAnnotation[] = []; + protected decorations: DiffDecorationItem[] = []; protected managersDirty = false; protected deletionFile: FileContents | undefined; @@ -229,7 +236,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 ) { @@ -257,14 +266,14 @@ export class FileDiff { }; protected getHunksRendererOptions( - options: FileDiffOptions + options: FileDiffOptions ): DiffHunksRendererOptions { return getDiffHunksRendererOptions(options); } protected createHunksRenderer( - options: FileDiffOptions - ): DiffHunksRenderer { + options: FileDiffOptions + ): DiffHunksRenderer { return new DiffHunksRenderer( this.getHunksRendererOptions(options), this.handleHighlightRender, @@ -358,7 +367,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.cachedHeaderHTML = undefined; @@ -380,7 +391,9 @@ export class FileDiff { ); } - private mergeOptions(options: Partial>): void { + private mergeOptions( + options: Partial> + ): void { this.options = { ...this.options, ...options }; } @@ -433,6 +446,10 @@ export class FileDiff { this.lineAnnotations = lineAnnotations; } + public setDecorations(decorations: DiffDecorationItem[]): void { + this.decorations = decorations; + } + private canPartiallyRender( forceRender: boolean, annotationsChanged: boolean, @@ -539,12 +556,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, @@ -571,6 +591,7 @@ export class FileDiff { oldFile, newFile, lineAnnotations, + decorations, }); } if (!preventEmit) { @@ -648,10 +669,12 @@ 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 this.lineAnnotations = lineAnnotations ?? this.lineAnnotations; + this.decorations = decorations ?? this.decorations; this.additionFile = newFile; this.deletionFile = oldFile; this.fileDiff = @@ -665,6 +688,7 @@ export class FileDiff { } this.syncInteractionOptions(); + this.hunksRenderer.setDecorations(this.decorations); this.hunksRenderer.hydrate(this.fileDiff); // FIXME(amadeus): not sure how to handle this yet... // this.renderSeparators(); @@ -726,10 +750,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 @@ -740,6 +765,7 @@ export class FileDiff { const { collapsed = false, themeType = 'system' } = this.options; const nextRenderRange = collapsed ? undefined : renderRange; const themeChanged = this.hasThemeChanged(); + const nextDecorations = decorations; const filesDidChange = oldFile != null && newFile != null && @@ -751,6 +777,11 @@ 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 && @@ -758,6 +789,7 @@ export class FileDiff { !forceRender && !annotationsChanged && !themeChanged && + !decorationsChanged && // If using the fileDiff API, lets check to see if they are equal to // avoid doing work ((fileDiff != null && fileDiff === this.fileDiff) || @@ -790,6 +822,9 @@ export class FileDiff { if (lineAnnotations != null) { this.setLineAnnotations(lineAnnotations); } + if (nextDecorations != null) { + this.decorations = nextDecorations; + } if (this.fileDiff == null) { return false; } @@ -797,6 +832,7 @@ export class FileDiff { this.syncInteractionOptions(); this.hunksRenderer.setLineAnnotations(this.lineAnnotations); + this.hunksRenderer.setDecorations(this.decorations); const { disableErrorHandling = false, disableFileHeader = false } = this.options; diff --git a/packages/diffs/src/components/UnresolvedFile.ts b/packages/diffs/src/components/UnresolvedFile.ts index 2213472fa..2d27f6f99 100644 --- a/packages/diffs/src/components/UnresolvedFile.ts +++ b/packages/diffs/src/components/UnresolvedFile.ts @@ -34,29 +34,35 @@ 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, +export interface UnresolvedFileOptions< + LAnnotation = undefined, + LDecoration = undefined, +> extends Omit< + FileDiffOptions, 'diffStyle' | 'onPostRender' > { onPostRender?( node: HTMLElement, - instance: UnresolvedFile, + instance: UnresolvedFile, phase: PostRenderPhase ): unknown; - mergeConflictActionsType?: MergeConflictActionsTypeOption; + mergeConflictActionsType?: MergeConflictActionsTypeOption< + LAnnotation, + LDecoration + >; onMergeConflictAction?( payload: MergeConflictActionPayload, - instance: UnresolvedFile + instance: UnresolvedFile ): void; onMergeConflictResolve?( file: FileContents, @@ -65,8 +71,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; @@ -74,10 +83,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; @@ -114,7 +123,8 @@ let instanceId = -1; export class UnresolvedFile< LAnnotation = undefined, -> extends FileDiff { + LDecoration = undefined, +> extends FileDiff { override readonly __id: string = `unresolved-file:${++instanceId}`; override readonly type = 'unresolved-file'; @@ -130,7 +140,7 @@ export class UnresolvedFile< new Map(); constructor( - public override options: UnresolvedFileOptions = { + public override options: UnresolvedFileOptions = { theme: DEFAULT_THEMES, }, workerManager?: WorkerPoolManager | undefined, @@ -141,7 +151,7 @@ export class UnresolvedFile< } override setOptions( - options: UnresolvedFileOptions | undefined + options: UnresolvedFileOptions | undefined ): void { if (options == null) { return; @@ -175,9 +185,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 @@ -186,7 +196,7 @@ export class UnresolvedFile< } protected override getHunksRendererOptions( - options: UnresolvedFileOptions + options: UnresolvedFileOptions ): UnresolvedFileHunksRendererOptions { return getUnresolvedDiffHunksRendererOptions(options, this.options); } @@ -338,13 +348,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, @@ -374,7 +387,11 @@ export class UnresolvedFile< } // Otherwise orchestrate our setup else { - this.hydrationSetup({ fileDiff: source.fileDiff, lineAnnotations }); + this.hydrationSetup({ + fileDiff: source.fileDiff, + lineAnnotations, + decorations, + }); if (this.pre != null) { this.renderMergeConflictActionSlots(); } @@ -391,13 +408,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; @@ -415,6 +435,7 @@ export class UnresolvedFile< ...rest, fileDiff: source.fileDiff, lineAnnotations, + decorations, preventEmit: true, }); if (didRender) { @@ -855,9 +876,9 @@ function shouldRenderHeader( // NOTE(amadeus): Should probably pull this out into a util, and make variants // for all component types -export function getUnresolvedDiffHunksRendererOptions( - options?: UnresolvedFileOptions, - baseOptions?: UnresolvedFileOptions +export function getUnresolvedDiffHunksRendererOptions( + options?: UnresolvedFileOptions, + baseOptions?: UnresolvedFileOptions ): UnresolvedFileHunksRendererOptions { return { ...baseOptions, diff --git a/packages/diffs/src/components/VirtualizedFile.ts b/packages/diffs/src/components/VirtualizedFile.ts index 49f1a53d5..4606b1d95 100644 --- a/packages/diffs/src/components/VirtualizedFile.ts +++ b/packages/diffs/src/components/VirtualizedFile.ts @@ -39,9 +39,9 @@ const LAYOUT_CHECKPOINT_INTERVAL = 5_000; let instanceId = -1; -function hasFileLayoutOptionChanged( - previousOptions: FileOptions, - nextOptions: FileOptions +function hasFileLayoutOptionChanged( + previousOptions: FileOptions, + nextOptions: FileOptions ): boolean { return ( (previousOptions.overflow ?? 'scroll') !== @@ -57,7 +57,8 @@ function hasFileLayoutOptionChanged( export class VirtualizedFile< LAnnotation = undefined, -> extends File { + LDecoration = undefined, +> extends File { override readonly __id: string = `virtualized-file:${++instanceId}`; public top: number | undefined; @@ -70,8 +71,8 @@ export class VirtualizedFile< private currentCollapsed: boolean | undefined; constructor( - options: FileOptions | undefined, - private virtualizer: Virtualizer | CodeView, + options: FileOptions | undefined, + private virtualizer: Virtualizer | CodeView, private metrics: VirtualFileMetrics = DEFAULT_VIRTUAL_FILE_METRICS, workerManager?: WorkerPoolManager, isContainerManaged = false @@ -100,7 +101,9 @@ export class VirtualizedFile< return this.metrics.lineHeight * multiplier; } - override setOptions(options: FileOptions | undefined): void { + override setOptions( + options: FileOptions | undefined + ): void { if (this.isAdvancedMode()) { throw new Error( 'VirtualizedFile.setOptions cannot be used inside CodeView. Update CodeView options instead.' @@ -557,7 +560,7 @@ export class VirtualizedFile< file, forceRender = false, ...props - }: FileRenderProps): boolean { + }: FileRenderProps): boolean { const { forceRenderOverride, isSetup } = this; this.forceRenderOverride = undefined; diff --git a/packages/diffs/src/components/VirtualizedFileDiff.ts b/packages/diffs/src/components/VirtualizedFileDiff.ts index d916083d8..c3304fe66 100644 --- a/packages/diffs/src/components/VirtualizedFileDiff.ts +++ b/packages/diffs/src/components/VirtualizedFileDiff.ts @@ -73,7 +73,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; @@ -89,14 +90,14 @@ export class VirtualizedFileDiff< }; private isVisible: boolean = false; private isSetup: boolean = false; - private virtualizer: Virtualizer | CodeView; + private virtualizer: Virtualizer | CodeView; private layoutDirty = true; private forceRenderOverride: true | undefined; private currentCollapsed: boolean | undefined; constructor( - options: FileDiffOptions | undefined, - virtualizer: Virtualizer | CodeView, + options: FileDiffOptions | undefined, + virtualizer: Virtualizer | CodeView, metrics?: Partial, workerManager?: WorkerPoolManager, isContainerManaged = false @@ -133,13 +134,14 @@ export class VirtualizedFileDiff< return this.metrics.lineHeight * multiplier; } - override setOptions(options: FileDiffOptions | undefined): void { + override setOptions( + options: FileDiffOptions | undefined + ): void { if (this.isAdvancedMode()) { throw new Error( 'VirtualizedFileDiff.setOptions cannot be used inside CodeView. Update CodeView options instead.' ); } - if (options == null) return; const { options: previousOptions } = this; const optionsChanged = !areOptionsEqual(previousOptions, options); @@ -794,7 +796,7 @@ export class VirtualizedFileDiff< fileDiff, forceRender = false, ...props - }: FileDiffRenderProps = {}): boolean { + }: FileDiffRenderProps = {}): boolean { const { forceRenderOverride, isSetup } = this; this.forceRenderOverride = undefined; @@ -1554,9 +1556,9 @@ function getHunkMetadataOffsets({ return offsets; } -function hasDiffLayoutOptionChanged( - previousOptions: FileDiffOptions, - nextOptions: FileDiffOptions +function hasDiffLayoutOptionChanged( + previousOptions: FileDiffOptions, + nextOptions: FileDiffOptions ): boolean { return ( (previousOptions.diffStyle ?? 'split') !== @@ -1582,9 +1584,9 @@ function hasDiffLayoutOptionChanged( ); } -function hasDiffEstimateOptionChanged( - previousOptions: FileDiffOptions, - nextOptions: FileDiffOptions +function hasDiffEstimateOptionChanged( + previousOptions: FileDiffOptions, + nextOptions: FileDiffOptions ): boolean { return ( (previousOptions.disableFileHeader ?? false) !== @@ -1600,8 +1602,10 @@ function hasDiffEstimateOptionChanged( ); } -function getOptionHunkSeparatorType( - hunkSeparators: FileDiffOptions['hunkSeparators'] | undefined +function getOptionHunkSeparatorType( + hunkSeparators: + | FileDiffOptions['hunkSeparators'] + | undefined ): HunkSeparators { return typeof hunkSeparators === 'function' ? 'custom' diff --git a/packages/diffs/src/components/VirtulizerDevelopment.d.ts b/packages/diffs/src/components/VirtulizerDevelopment.d.ts index d06362284..4cf475e7b 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?: CodeView | Virtualizer; + __INSTANCE?: CodeView | Virtualizer; __TOGGLE?: () => void; __LOG?: boolean; } diff --git a/packages/diffs/src/react/CodeView.tsx b/packages/diffs/src/react/CodeView.tsx index 9c46ec7ec..be5d649bb 100644 --- a/packages/diffs/src/react/CodeView.tsx +++ b/packages/diffs/src/react/CodeView.tsx @@ -43,88 +43,97 @@ type CodeViewGutterUtilityGetter = | (() => GetHoveredLineResult<'file'> | undefined) | (() => GetHoveredLineResult<'diff'> | undefined); -interface CodeViewBaseProps { - options?: CodeViewOptions; +interface CodeViewBaseProps { + options?: CodeViewOptions; className?: string; style?: CSSProperties; containerRef?: Ref; disableWorkerPool?: boolean; selectedLines?: CodeViewLineSelection | null; onSelectedLinesChange?(selection: CodeViewLineSelection | null): void; - onScroll?(scrollTop: number, viewer: CodeViewClass): void; - renderCustomHeader?(item: CodeViewItem): ReactNode; - renderHeaderPrefix?(item: CodeViewItem): ReactNode; - renderHeaderMetadata?(item: CodeViewItem): ReactNode; + onScroll?( + scrollTop: number, + viewer: CodeViewClass + ): void; + renderCustomHeader?(item: CodeViewItem): ReactNode; + renderHeaderPrefix?(item: CodeViewItem): ReactNode; + renderHeaderMetadata?( + item: CodeViewItem + ): ReactNode; renderAnnotation?( annotation: LineAnnotation | DiffLineAnnotation, - item: CodeViewItem + item: CodeViewItem ): ReactNode; renderGutterUtility?( getHoveredLine: CodeViewGutterUtilityGetter, - item: CodeViewItem + item: CodeViewItem ): ReactNode; } export interface ControlledCodeViewProps< LAnnotation, -> extends CodeViewBaseProps { - items: readonly CodeViewItem[]; + LDecoration, +> extends CodeViewBaseProps { + items: readonly CodeViewItem[]; initialItems?: never; } export interface UncontrolledCodeViewProps< LAnnotation, -> extends CodeViewBaseProps { + LDecoration, +> extends CodeViewBaseProps { // Seeds the imperative CodeView instance once. Later item changes should go // through the ref API instead of being reconciled from React props. - initialItems?: readonly CodeViewItem[]; + initialItems?: readonly CodeViewItem[]; items?: never; } -export type CodeViewProps = - | ControlledCodeViewProps - | UncontrolledCodeViewProps; +export type CodeViewProps = + | ControlledCodeViewProps + | UncontrolledCodeViewProps; -export interface CodeViewHandle { - addItems(items: readonly CodeViewItem[]): void; - getItem(id: string): CodeViewItem | undefined; - updateItem(item: CodeViewItem): boolean; +export interface CodeViewHandle { + addItems(items: readonly CodeViewItem[]): void; + getItem(id: string): CodeViewItem | undefined; + updateItem(item: CodeViewItem): boolean; updateItemId(oldId: string, newId: string): boolean; scrollTo(target: CodeViewScrollTarget): void; setSelectedLines(selection: CodeViewLineSelection | null): void; getSelectedLines(): CodeViewLineSelection | null; clearSelectedLines(): void; - getInstance(): CodeViewClass | undefined; + getInstance(): CodeViewClass | undefined; } -type CodeViewComponent = ( - props: CodeViewProps & { - ref?: React.Ref>; +type CodeViewComponent = ( + props: CodeViewProps & { + ref?: React.Ref>; } ) => React.JSX.Element; -type SlotPortalsComponent = ( - props: SlotPortalsProps +type SlotPortalsComponent = ( + props: SlotPortalsProps ) => React.JSX.Element; -interface ManagedContentStore { - getSnapshot(): CodeViewRenderedItem[] | undefined; - publish(snapshot: CodeViewRenderedItem[] | undefined): void; +interface ManagedContentStore { + getSnapshot(): CodeViewRenderedItem[] | undefined; + publish( + snapshot: CodeViewRenderedItem[] | undefined + ): void; subscribe(listener: () => void): () => void; } -interface CachedDataRef { - instance: CodeViewClass | undefined; - items: readonly CodeViewItem[] | undefined; +interface CachedDataRef { + instance: CodeViewClass | undefined; + items: readonly CodeViewItem[] | undefined; controlled: boolean; - managedOptions: CodeViewOptions | undefined; + managedOptions: CodeViewOptions | undefined; disableFlushSync: boolean; - slotCoordinator: CodeViewCoordinator | undefined; + slotCoordinator: CodeViewCoordinator | undefined; } -function createDefaultCache( +function createDefaultCache( controlled: boolean -): CachedDataRef { +): CachedDataRef { return { instance: undefined, items: undefined, @@ -135,9 +144,9 @@ function createDefaultCache( }; } -function CodeViewInner( - props: CodeViewProps, - ref: React.ForwardedRef> +function CodeViewInner( + props: CodeViewProps, + ref: React.ForwardedRef> ): React.JSX.Element { const { className, @@ -158,8 +167,8 @@ function CodeViewInner( } = props; const controlled = controlledItems !== undefined; const poolManager = useContext(WorkerPoolContext); - const cachedDataRef = useRef>( - createDefaultCache(controlled) + const cachedDataRef = useRef>( + createDefaultCache(controlled) ); const hasCustomHeader = renderCustomHeader != null; const hasAnnotationRenderer = renderAnnotation != null; @@ -197,9 +206,9 @@ function CodeViewInner( ] ); - const [slotContentStore] = useState>(() => - createSlotContentStore() - ); + const [slotContentStore] = useState< + ManagedContentStore + >(() => createSlotContentStore()); const [, forceUpdate] = useState({}); const nodeRef = useStableCallback((node: HTMLDivElement | null) => { @@ -213,7 +222,9 @@ function CodeViewInner( ) { cachedDataRef.current.instance.cleanUp(); slotContentStore.publish(undefined); - cachedDataRef.current = createDefaultCache(controlled); + cachedDataRef.current = createDefaultCache( + controlled + ); } // If our node matches the existing node then we should not attempt to @@ -224,11 +235,10 @@ function CodeViewInner( node != null && node !== cachedDataRef.current.instance?.getContainerElement() ) { - cachedDataRef.current.instance = new CodeViewClass( - managedOptions, - !disableWorkerPool ? poolManager : undefined, - true - ); + cachedDataRef.current.instance = new CodeViewClass< + LAnnotation, + LDecoration + >(managedOptions, !disableWorkerPool ? poolManager : undefined, true); cachedDataRef.current.instance.setup(node); } @@ -240,7 +250,9 @@ function CodeViewInner( }); const onSnapshotChange = useStableCallback( - (snapshot: CodeViewRenderedItem[] | undefined) => { + ( + snapshot: CodeViewRenderedItem[] | undefined + ) => { if (cachedDataRef.current.disableFlushSync) { slotContentStore.publish(snapshot); } else { @@ -251,24 +263,25 @@ function CodeViewInner( } ); - const slotCoordinator: CodeViewCoordinator | undefined = - useMemo(() => { - if (!hasHeaderRenderers && !hasAnnotationRenderer && !hasGutterRenderer) { - return undefined; - } else { - return { - hasHeaderRenderers, - hasAnnotationRenderer, - hasGutterRenderer, - onSnapshotChange, - }; - } - }, [ - onSnapshotChange, - hasAnnotationRenderer, - hasGutterRenderer, - hasHeaderRenderers, - ]); + const slotCoordinator: + | CodeViewCoordinator + | undefined = useMemo(() => { + if (!hasHeaderRenderers && !hasAnnotationRenderer && !hasGutterRenderer) { + return undefined; + } else { + return { + hasHeaderRenderers, + hasAnnotationRenderer, + hasGutterRenderer, + onSnapshotChange, + }; + } + }, [ + onSnapshotChange, + hasAnnotationRenderer, + hasGutterRenderer, + hasHeaderRenderers, + ]); useIsometricEffect(() => { return onScroll != null @@ -366,7 +379,7 @@ function CodeViewInner( // Setup the ref handler useImperativeHandle( ref, - (): CodeViewHandle => ({ + (): CodeViewHandle => ({ addItems(items) { const { controlled, instance } = cachedDataRef.current; assertUncontrolledCodeViewAction(controlled, 'addItems'); @@ -469,7 +482,7 @@ function CodeViewInner( <>
{hasRenderers && ( - + managedContentStore={slotContentStore} renderCustomHeader={renderCustomHeader} renderHeaderPrefix={renderHeaderPrefix} @@ -485,10 +498,10 @@ function CodeViewInner( // React was a mistake export const CodeView = forwardRef(CodeViewInner) as CodeViewComponent; -function isAppendOnlyItemUpdate( - previousItems: readonly CodeViewItem[] | undefined, - nextItems: readonly CodeViewItem[] -): previousItems is readonly CodeViewItem[] { +function isAppendOnlyItemUpdate( + previousItems: readonly CodeViewItem[] | undefined, + nextItems: readonly CodeViewItem[] +): previousItems is readonly CodeViewItem[] { if (previousItems == null || nextItems.length <= previousItems.length) { return false; } @@ -506,9 +519,9 @@ function isAppendOnlyItemUpdate( return true; } -function areItemListsEqual( - previousItems: readonly CodeViewItem[] | undefined, - nextItems: readonly CodeViewItem[] +function areItemListsEqual( + previousItems: readonly CodeViewItem[] | undefined, + nextItems: readonly CodeViewItem[] ): boolean { if (previousItems == null || previousItems.length !== nextItems.length) { return false; @@ -538,8 +551,9 @@ function assertUncontrolledCodeViewAction( function createSlotContentStore< LAnnotation, ->(): ManagedContentStore { - let snapshot: CodeViewRenderedItem[] | undefined; + LDecoration, +>(): ManagedContentStore { + let snapshot: CodeViewRenderedItem[] | undefined; const listeners = new Set<() => void>(); return { @@ -565,22 +579,22 @@ function createSlotContentStore< }; } -interface CreateManagedCodeViewOptionsProps { - options: CodeViewOptions | undefined; +interface CreateManagedCodeViewOptionsProps { + options: CodeViewOptions | undefined; hasCustomHeader: boolean; hasGutterRenderer: boolean; onSelectedLinesChange?(selection: CodeViewLineSelection | null): void; controlledSelection: boolean; } -function createManagedCodeViewOptions({ +function createManagedCodeViewOptions({ options, hasCustomHeader, hasGutterRenderer, onSelectedLinesChange, controlledSelection, -}: CreateManagedCodeViewOptionsProps): - | CodeViewOptions +}: CreateManagedCodeViewOptionsProps): + | CodeViewOptions | undefined { if ( !hasCustomHeader && @@ -610,32 +624,62 @@ function createManagedCodeViewOptions({ return options; } -interface RenderCodeViewItemChildrenProps { - renderedItem: CodeViewRenderedItem; - renderCustomHeader: CodeViewBaseProps['renderCustomHeader']; - renderHeaderPrefix: CodeViewBaseProps['renderHeaderPrefix']; - renderHeaderMetadata: CodeViewBaseProps['renderHeaderMetadata']; - renderAnnotation: CodeViewBaseProps['renderAnnotation']; - renderGutterUtility: CodeViewBaseProps['renderGutterUtility']; +interface RenderCodeViewItemChildrenProps { + renderedItem: CodeViewRenderedItem; + renderCustomHeader: CodeViewBaseProps< + LAnnotation, + LDecoration + >['renderCustomHeader']; + renderHeaderPrefix: CodeViewBaseProps< + LAnnotation, + LDecoration + >['renderHeaderPrefix']; + renderHeaderMetadata: CodeViewBaseProps< + LAnnotation, + LDecoration + >['renderHeaderMetadata']; + renderAnnotation: CodeViewBaseProps< + LAnnotation, + LDecoration + >['renderAnnotation']; + renderGutterUtility: CodeViewBaseProps< + LAnnotation, + LDecoration + >['renderGutterUtility']; } -interface SlotPortalsProps { - managedContentStore: ManagedContentStore; - renderCustomHeader: CodeViewBaseProps['renderCustomHeader']; - renderHeaderPrefix: CodeViewBaseProps['renderHeaderPrefix']; - renderHeaderMetadata: CodeViewBaseProps['renderHeaderMetadata']; - renderAnnotation: CodeViewBaseProps['renderAnnotation']; - renderGutterUtility: CodeViewBaseProps['renderGutterUtility']; +interface SlotPortalsProps { + managedContentStore: ManagedContentStore; + renderCustomHeader: CodeViewBaseProps< + LAnnotation, + LDecoration + >['renderCustomHeader']; + renderHeaderPrefix: CodeViewBaseProps< + LAnnotation, + LDecoration + >['renderHeaderPrefix']; + renderHeaderMetadata: CodeViewBaseProps< + LAnnotation, + LDecoration + >['renderHeaderMetadata']; + renderAnnotation: CodeViewBaseProps< + LAnnotation, + LDecoration + >['renderAnnotation']; + renderGutterUtility: CodeViewBaseProps< + LAnnotation, + LDecoration + >['renderGutterUtility']; } -const SlotPortals = memo(function SlotPortals({ +const SlotPortals = memo(function SlotPortals({ managedContentStore, renderCustomHeader, renderHeaderPrefix, renderHeaderMetadata, renderAnnotation, renderGutterUtility, -}: SlotPortalsProps) { +}: SlotPortalsProps) { const subscribe = useStableCallback((listener: () => void) => managedContentStore.subscribe(listener) ); @@ -643,7 +687,7 @@ const SlotPortals = memo(function SlotPortals({ managedContentStore.getSnapshot() ); const renderedItems = useSyncExternalStore< - CodeViewRenderedItem[] | undefined + CodeViewRenderedItem[] | undefined >(subscribe, getSnapshot, getSnapshot); return renderedItems?.map((renderedItem) => { return createPortal( @@ -661,14 +705,14 @@ const SlotPortals = memo(function SlotPortals({ }); }) as SlotPortalsComponent; -function renderCodeViewItemChildren({ +function renderCodeViewItemChildren({ renderedItem, renderCustomHeader, renderHeaderPrefix, renderHeaderMetadata, renderAnnotation, renderGutterUtility, -}: RenderCodeViewItemChildrenProps): ReactNode { +}: RenderCodeViewItemChildrenProps): ReactNode { if (renderedItem.type === 'diff') { const { item, instance } = renderedItem; return renderDiffChildren({ diff --git a/packages/diffs/src/react/File.tsx b/packages/diffs/src/react/File.tsx index ae3aeda58..0c9787b30 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, @@ -24,12 +25,13 @@ export function File({ prerenderedHTML, renderGutterUtility, disableWorkerPool = false, -}: FileProps): React.JSX.Element { +}: FileProps): React.JSX.Element { const { ref, getHoveredLine } = useFileInstance({ file, options, metrics, lineAnnotations, + decorations, selectedLines, prerenderedHTML, hasGutterRenderUtility: renderGutterUtility != null, diff --git a/packages/diffs/src/react/FileDiff.tsx b/packages/diffs/src/react/FileDiff.tsx index 052f5b0dd..bb442fef2 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, @@ -31,12 +33,13 @@ export function FileDiff({ renderHeaderMetadata, renderGutterUtility, disableWorkerPool = false, -}: FileDiffProps): React.JSX.Element { +}: FileDiffProps): React.JSX.Element { const { ref, getHoveredLine } = useFileDiffInstance({ fileDiff, options, metrics, lineAnnotations, + decorations, selectedLines, prerenderedHTML, hasGutterRenderUtility: renderGutterUtility != null, diff --git a/packages/diffs/src/react/MultiFileDiff.tsx b/packages/diffs/src/react/MultiFileDiff.tsx index 076288610..cea35511a 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, @@ -36,7 +41,7 @@ export function MultiFileDiff({ renderHeaderMetadata, renderGutterUtility, disableWorkerPool = false, -}: MultiFileDiffProps): React.JSX.Element { +}: MultiFileDiffProps): React.JSX.Element { const fileDiff = useMemo(() => { return parseDiffFromFile(oldFile, newFile, options?.parseDiffOptions); }, [oldFile, newFile, options?.parseDiffOptions]); @@ -45,6 +50,7 @@ export function MultiFileDiff({ options, metrics, lineAnnotations, + decorations, selectedLines, prerenderedHTML, hasGutterRenderUtility: renderGutterUtility != null, diff --git a/packages/diffs/src/react/PatchDiff.tsx b/packages/diffs/src/react/PatchDiff.tsx index a57b304b1..e91fb982c 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, @@ -32,13 +34,14 @@ export function PatchDiff({ renderHeaderMetadata, renderGutterUtility, 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: renderGutterUtility != null, diff --git a/packages/diffs/src/react/UnresolvedFile.tsx b/packages/diffs/src/react/UnresolvedFile.tsx index 922338308..b7703b6f8 100644 --- a/packages/diffs/src/react/UnresolvedFile.tsx +++ b/packages/diffs/src/react/UnresolvedFile.tsx @@ -32,39 +32,46 @@ 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, phase: PostRenderPhase ): 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, @@ -76,12 +83,13 @@ export function UnresolvedFile({ renderGutterUtility, 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 fb45c43bd..d33665802 100644 --- a/packages/diffs/src/react/types.ts +++ b/packages/diffs/src/react/types.ts @@ -4,18 +4,21 @@ import type { FileOptions } from '../components/File'; import type { FileDiffOptions } from '../components/FileDiff'; import type { GetHoveredLineResult } from '../managers/InteractionManager'; import type { + DiffDecorationItem, DiffLineAnnotation, FileContents, + FileDecorationItem, FileDiffMetadata, LineAnnotation, SelectedLineRange, 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; @@ -29,11 +32,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 cd0218fcf..e8cf763ab 100644 --- a/packages/diffs/src/react/utils/renderDiffChildren.tsx +++ b/packages/diffs/src/react/utils/renderDiffChildren.tsx @@ -16,24 +16,42 @@ 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']; + 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']; 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, @@ -45,7 +63,7 @@ export function renderDiffChildren({ lineAnnotations, getHoveredLine, getInstance, -}: RenderDiffChildrenProps): ReactNode { +}: RenderDiffChildrenProps): ReactNode { const customHeader = renderCustomHeader?.(fileDiff); const prefix = renderHeaderPrefix?.(fileDiff); const metadata = renderHeaderMetadata?.(fileDiff); diff --git a/packages/diffs/src/react/utils/renderFileChildren.tsx b/packages/diffs/src/react/utils/renderFileChildren.tsx index 6f9703d54..779348a7a 100644 --- a/packages/diffs/src/react/utils/renderFileChildren.tsx +++ b/packages/diffs/src/react/utils/renderFileChildren.tsx @@ -11,18 +11,24 @@ 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']; + renderCustomHeader: FileProps['renderCustomHeader']; + renderHeaderPrefix: FileProps['renderHeaderPrefix']; + renderHeaderMetadata: FileProps< + LAnnotation, + LDecoration + >['renderHeaderMetadata']; + renderAnnotation: FileProps['renderAnnotation']; + lineAnnotations: FileProps['lineAnnotations']; + renderGutterUtility: FileProps< + LAnnotation, + LDecoration + >['renderGutterUtility']; getHoveredLine(): GetHoveredLineResult<'file'> | undefined; } -export function renderFileChildren({ +export function renderFileChildren({ file, renderCustomHeader, renderHeaderPrefix, @@ -31,7 +37,7 @@ export function renderFileChildren({ lineAnnotations, renderGutterUtility, getHoveredLine, -}: RenderFileChildrenProps): ReactNode { +}: RenderFileChildrenProps): ReactNode { const customHeader = renderCustomHeader?.(file); const prefix = renderHeaderPrefix?.(file); const metadata = renderHeaderMetadata?.(file); diff --git a/packages/diffs/src/react/utils/useFileDiffInstance.ts b/packages/diffs/src/react/utils/useFileDiffInstance.ts index 65d988cf4..ec8964daf 100644 --- a/packages/diffs/src/react/utils/useFileDiffInstance.ts +++ b/packages/diffs/src/react/utils/useFileDiffInstance.ts @@ -10,6 +10,7 @@ import { FileDiff, type FileDiffOptions } from '../../components/FileDiff'; import { VirtualizedFileDiff } from '../../components/VirtualizedFileDiff'; import type { GetHoveredLineResult } from '../../managers/InteractionManager'; import type { + DiffDecorationItem, DiffLineAnnotation, FileDiffMetadata, SelectedLineRange, @@ -24,10 +25,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; @@ -41,22 +43,28 @@ 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 controlledSelection = selectedLines !== undefined; const poolManager = useContext(WorkerPoolContext); const instanceRef = useRef< - FileDiff | VirtualizedFileDiff | null + | FileDiff + | VirtualizedFileDiff + | null >(null); const ref = useStableCallback((fileContainer: HTMLElement | null) => { if (fileContainer != null) { @@ -94,6 +102,7 @@ export function useFileDiffInstance({ fileDiff, fileContainer, lineAnnotations, + decorations, prerenderedHTML, }); } else { @@ -122,6 +131,7 @@ export function useFileDiffInstance({ forceRender, fileDiff, lineAnnotations, + decorations, }); if (selectedLines !== undefined) { instance.setSelectedLines(selectedLines); @@ -137,20 +147,20 @@ export function useFileDiffInstance({ return { ref, getHoveredLine }; } -interface MergeFileDiffOptionsProps { +interface MergeFileDiffOptionsProps { controlledSelection: boolean; hasCustomHeader: boolean; hasGutterRenderUtility: boolean; - options: FileDiffOptions | undefined; + options: FileDiffOptions | undefined; } -function mergeFileDiffOptions({ +function mergeFileDiffOptions({ options, controlledSelection, hasCustomHeader, hasGutterRenderUtility, -}: MergeFileDiffOptionsProps): - | FileDiffOptions +}: MergeFileDiffOptionsProps): + | FileDiffOptions | undefined { if (!controlledSelection && !hasGutterRenderUtility && !hasCustomHeader) { return options; diff --git a/packages/diffs/src/react/utils/useFileInstance.ts b/packages/diffs/src/react/utils/useFileInstance.ts index 16e251386..4ba47df18 100644 --- a/packages/diffs/src/react/utils/useFileInstance.ts +++ b/packages/diffs/src/react/utils/useFileInstance.ts @@ -11,6 +11,7 @@ import { VirtualizedFile } from '../../components/VirtualizedFile'; import type { GetHoveredLineResult } from '../../managers/InteractionManager'; import type { FileContents, + FileDecorationItem, LineAnnotation, SelectedLineRange, VirtualFileMetrics, @@ -24,10 +25,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; @@ -41,22 +43,25 @@ 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 controlledSelection = selectedLines !== undefined; const poolManager = useContext(WorkerPoolContext); const instanceRef = useRef< - File | VirtualizedFile | null + | File + | VirtualizedFile + | null >(null); const ref = useStableCallback((node: HTMLElement | null) => { if (node != null) { @@ -94,6 +99,7 @@ export function useFileInstance({ file, fileContainer: node, lineAnnotations, + decorations, prerenderedHTML, }); } else { @@ -118,7 +124,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); } @@ -132,19 +143,21 @@ export function useFileInstance({ return { ref, getHoveredLine }; } -interface MergeFileOptionsProps { - options: FileOptions | undefined; +interface MergeFileOptionsProps { + options: FileOptions | undefined; controlledSelection: boolean; hasGutterRenderUtility: boolean; hasCustomHeader: boolean; } -function mergeFileOptions({ +function mergeFileOptions({ options, controlledSelection, hasCustomHeader, hasGutterRenderUtility, -}: MergeFileOptionsProps): FileOptions | undefined { +}: MergeFileOptionsProps): + | FileOptions + | undefined { if (!controlledSelection && !hasGutterRenderUtility && !hasCustomHeader) { return options; } diff --git a/packages/diffs/src/react/utils/useUnresolvedFileInstance.ts b/packages/diffs/src/react/utils/useUnresolvedFileInstance.ts index fb558faf2..9e2698e4e 100644 --- a/packages/diffs/src/react/utils/useUnresolvedFileInstance.ts +++ b/packages/diffs/src/react/utils/useUnresolvedFileInstance.ts @@ -14,6 +14,7 @@ import { } from '../../components/UnresolvedFile'; import type { GetHoveredLineResult } from '../../managers/InteractionManager'; import type { + DiffDecorationItem, DiffLineAnnotation, FileContents, FileDiffMetadata, @@ -34,10 +35,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; @@ -46,26 +48,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, @@ -79,7 +85,7 @@ export function useUnresolvedFileInstance({ const onMergeConflictAction = useStableCallback( ( payload: MergeConflictActionPayload, - instance: UnresolvedFile + instance: UnresolvedFile ) => { setState((prevState) => { const { fileDiff, actions, markerRows } = @@ -98,7 +104,10 @@ export function useUnresolvedFileInstance({ ); const controlledSelection = selectedLines !== undefined; 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 { @@ -155,6 +165,7 @@ export function useUnresolvedFileInstance({ actions, markerRows, lineAnnotations, + decorations, forceRender, }); if (selectedLines !== undefined) { @@ -175,23 +186,29 @@ export function useUnresolvedFileInstance({ return { ref, getHoveredLine, fileDiff, actions, markerRows, getInstance }; } -interface MergeUnresolvedOptionsProps { - options: UnresolvedFileReactOptions | undefined; +interface MergeUnresolvedOptionsProps { + options: UnresolvedFileReactOptions | undefined; controlledSelection: boolean; - onMergeConflictAction: UnresolvedFileOptions['onMergeConflictAction']; + onMergeConflictAction: UnresolvedFileOptions< + LAnnotation, + LDecoration + >['onMergeConflictAction']; hasConflictUtility: boolean; hasGutterRenderUtility: boolean; hasCustomHeader: boolean; } -function mergeUnresolvedOptions({ +function mergeUnresolvedOptions({ options, controlledSelection, onMergeConflictAction, hasConflictUtility, hasCustomHeader, hasGutterRenderUtility, -}: MergeUnresolvedOptionsProps): UnresolvedFileOptions { +}: MergeUnresolvedOptionsProps< + LAnnotation, + LDecoration +>): UnresolvedFileOptions { return { ...options, controlledSelection, diff --git a/packages/diffs/src/renderers/DiffHunksRenderer.ts b/packages/diffs/src/renderers/DiffHunksRenderer.ts index 719c6708e..dc3c22cd9 100644 --- a/packages/diffs/src/renderers/DiffHunksRenderer.ts +++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts @@ -21,6 +21,7 @@ import type { BaseDiffOptionsWithDefaults, CodeColumnType, CustomPreProperties, + DiffDecorationItem, DiffLineAnnotation, DiffsHighlighter, ExpansionDirections, @@ -200,7 +201,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; @@ -308,6 +312,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 2a569052e..8bdb0d7f2 100644 --- a/packages/diffs/src/renderers/FileRenderer.ts +++ b/packages/diffs/src/renderers/FileRenderer.ts @@ -17,6 +17,7 @@ import type { BaseCodeOptions, DiffsHighlighter, FileContents, + FileDecorationItem, FileHeaderRenderMode, LineAnnotation, RenderedFileASTCache, @@ -85,7 +86,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; @@ -125,6 +126,10 @@ export class FileRenderer { } } + public setDecorations( + _decorations: readonly FileDecorationItem[] + ): void {} + public cleanUp(): void { this.recycle(); this.workerManager = 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 d85ac9e0b..3625df3c8 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -469,36 +469,60 @@ 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 CodeViewFileItem = { +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 CodeViewFileItem< + LAnnotation = undefined, + LDecoration = undefined, +> = { id: string; type: 'file'; file: FileContents; - annotations?: LineAnnotation[]; + annotations?: LineAnnotation[]; + decorations?: FileDecorationItem[]; version?: number; collapsed?: boolean; }; -export type CodeViewDiffItem = { +export type CodeViewDiffItem< + LAnnotation = undefined, + LDecoration = undefined, +> = { id: string; type: 'diff'; fileDiff: FileDiffMetadata; - annotations?: DiffLineAnnotation[]; + annotations?: DiffLineAnnotation[]; + decorations?: DiffDecorationItem[]; version?: number; collapsed?: boolean; }; -export type CodeViewItem = - | CodeViewFileItem - | CodeViewDiffItem; +export type CodeViewItem = + | CodeViewFileItem + | CodeViewDiffItem; export interface CodeViewPositionScrollTarget { type: 'position'; diff --git a/packages/diffs/src/utils/areManagedSnapshotsEqual.ts b/packages/diffs/src/utils/areManagedSnapshotsEqual.ts index 100397b45..1799f6387 100644 --- a/packages/diffs/src/utils/areManagedSnapshotsEqual.ts +++ b/packages/diffs/src/utils/areManagedSnapshotsEqual.ts @@ -1,8 +1,8 @@ import type { CodeViewRenderedItem } from '../components/CodeView'; -export function areManagedSnapshotsEqual( - previous: CodeViewRenderedItem[] | undefined, - next: CodeViewRenderedItem[] | undefined +export function areManagedSnapshotsEqual( + previous: CodeViewRenderedItem[] | undefined, + next: CodeViewRenderedItem[] | undefined ): boolean { if (previous == null || next == null) { return previous === next; diff --git a/packages/diffs/src/utils/areOptionsEqual.ts b/packages/diffs/src/utils/areOptionsEqual.ts index 48ee5efce..e800ea2de 100644 --- a/packages/diffs/src/utils/areOptionsEqual.ts +++ b/packages/diffs/src/utils/areOptionsEqual.ts @@ -7,15 +7,15 @@ import type { FileOptions } from '../react'; import { areObjectsEqual } from './areObjectsEqual'; import { areThemesEqual } from './areThemesEqual'; -type AnyOptions = - | CodeViewOptions - | FileOptions - | FileDiffOptions +type AnyOptions = + | CodeViewOptions + | 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; @@ -31,8 +31,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/src/utils/getDiffHunksRendererOptions.ts b/packages/diffs/src/utils/getDiffHunksRendererOptions.ts index e07017e41..201764aed 100644 --- a/packages/diffs/src/utils/getDiffHunksRendererOptions.ts +++ b/packages/diffs/src/utils/getDiffHunksRendererOptions.ts @@ -3,8 +3,8 @@ import type { DiffHunksRendererOptions } from '../renderers/DiffHunksRenderer'; // Build the renderer option snapshot with direct property reads. CodeView item // options may inherit prototype getters, so object spread can miss values. -export function getDiffHunksRendererOptions( - options: FileDiffOptions | undefined +export function getDiffHunksRendererOptions( + options: FileDiffOptions | undefined ): DiffHunksRendererOptions { return { theme: options?.theme, diff --git a/packages/diffs/src/utils/getFileRendererOptions.ts b/packages/diffs/src/utils/getFileRendererOptions.ts index d858ebdac..1c7a54ef9 100644 --- a/packages/diffs/src/utils/getFileRendererOptions.ts +++ b/packages/diffs/src/utils/getFileRendererOptions.ts @@ -3,8 +3,8 @@ import type { FileRendererOptions } from '../renderers/FileRenderer'; // Build the renderer option snapshot with direct property reads. CodeView item // options may inherit prototype getters, so object spread can miss values. -export function getFileRendererOptions( - options: FileOptions | undefined +export function getFileRendererOptions( + options: FileOptions | undefined ): FileRendererOptions { return { theme: options?.theme, diff --git a/packages/diffs/test/CodeView.elementPooling.test.ts b/packages/diffs/test/CodeView.elementPooling.test.ts index ca6fe0485..d642543d9 100644 --- a/packages/diffs/test/CodeView.elementPooling.test.ts +++ b/packages/diffs/test/CodeView.elementPooling.test.ts @@ -285,7 +285,7 @@ describe('CodeView element pooling', () => { const { cleanup } = installDom(); const viewer = new CodeView({ disableFileHeader: true }, undefined, true); const root = createRoot(120); - const coordinator: CodeViewCoordinator = { + const coordinator: CodeViewCoordinator = { hasAnnotationRenderer: false, hasGutterRenderer: false, hasHeaderRenderers: true, diff --git a/packages/diffs/test/annotations.test.ts b/packages/diffs/test/annotations.test.ts index c8a6aa177..2eea6a39d 100644 --- a/packages/diffs/test/annotations.test.ts +++ b/packages/diffs/test/annotations.test.ts @@ -39,7 +39,7 @@ describe('Annotation Rendering', () => { { side: 'deletions', lineNumber: 25, metadata: 'old-line' }, ]; - const renderer = new DiffHunksRenderer({ + const renderer = new DiffHunksRenderer({ diffStyle: 'unified', expandUnchanged: true, }); diff --git a/packages/diffs/test/hydration.test.ts b/packages/diffs/test/hydration.test.ts index 7fd02ff5f..937b2c2a2 100644 --- a/packages/diffs/test/hydration.test.ts +++ b/packages/diffs/test/hydration.test.ts @@ -123,7 +123,9 @@ function installDomConstructors() { class SpyFile extends File { renderCalls = 0; - public override render(_props: FileRenderProps): boolean { + public override render( + _props: FileRenderProps + ): boolean { this.renderCalls += 1; return true; } @@ -136,7 +138,9 @@ class SpyFile extends File { class SpyFileDiff extends FileDiff { renderCalls = 0; - public override render(_props: FileDiffRenderProps): boolean { + public override render( + _props: FileDiffRenderProps + ): boolean { this.renderCalls += 1; return true; } @@ -150,7 +154,7 @@ class SpyUnresolvedFile extends UnresolvedFile { renderCalls = 0; public override render( - _props: UnresolvedFileRenderProps + _props: UnresolvedFileRenderProps ): boolean { this.renderCalls += 1; return true; @@ -235,7 +239,7 @@ describe('collapsed hydration', () => { const dom = installDomConstructors(); try { const instance = new SpyFile({ collapsed: true }); - const props: FileHydrateProps = { + const props: FileHydrateProps = { file, fileContainer: dom.createHydrationContainer(), }; @@ -252,7 +256,7 @@ describe('collapsed hydration', () => { const dom = installDomConstructors(); try { const instance = new SpyFile(); - const props: FileHydrateProps = { + const props: FileHydrateProps = { file, fileContainer: dom.createHydrationContainer(), }; @@ -273,7 +277,7 @@ describe('collapsed hydration', () => { disableFileHeader: true, }); const fileContainer = dom.createHydrationContainer({ header: false }); - const props: FileHydrateProps = { + const props: FileHydrateProps = { file, fileContainer, }; @@ -296,7 +300,7 @@ describe('collapsed hydration', () => { virtualizerState.virtualizer ); const fileContainer = dom.createHydrationContainer(); - const props: FileHydrateProps = { + const props: FileHydrateProps = { file, fileContainer, }; @@ -314,7 +318,7 @@ describe('collapsed hydration', () => { const dom = installDomConstructors(); try { const instance = new SpyFileDiff({ collapsed: true }); - const props: FileDiffHydrationProps = { + const props: FileDiffHydrationProps = { fileDiff, oldFile: file, newFile: file, @@ -333,7 +337,7 @@ describe('collapsed hydration', () => { const dom = installDomConstructors(); try { const instance = new SpyFileDiff(); - const props: FileDiffHydrationProps = { + const props: FileDiffHydrationProps = { fileDiff, oldFile: file, newFile: file, @@ -356,7 +360,7 @@ describe('collapsed hydration', () => { disableFileHeader: true, }); const fileContainer = dom.createHydrationContainer({ header: false }); - const props: FileDiffHydrationProps = { + const props: FileDiffHydrationProps = { fileDiff, oldFile: file, newFile: file, @@ -381,7 +385,7 @@ describe('collapsed hydration', () => { virtualizerState.virtualizer ); const fileContainer = dom.createHydrationContainer(); - const props: FileDiffHydrationProps = { + const props: FileDiffHydrationProps = { oldFile: file, newFile: { ...file, @@ -410,7 +414,7 @@ describe('collapsed hydration', () => { return undefined; }, }); - const props: UnresolvedFileHydrationProps = { + const props: UnresolvedFileHydrationProps = { file: unresolvedFile, fileContainer: dom.createHydrationContainer(), }; @@ -428,7 +432,7 @@ describe('collapsed hydration', () => { const dom = installDomConstructors(); try { const instance = new SpyUnresolvedFile(); - const props: UnresolvedFileHydrationProps = { + const props: UnresolvedFileHydrationProps = { file: unresolvedFile, fileContainer: dom.createHydrationContainer(), }; @@ -445,7 +449,7 @@ describe('collapsed hydration', () => { const dom = installDomConstructors(); try { const instance = new SpyUnresolvedFile({ collapsed: true }); - const props: UnresolvedFileHydrationProps = { + const props: UnresolvedFileHydrationProps = { file: unresolvedFile, fileContainer: dom.createHydrationContainer({ header: false }), }; @@ -471,7 +475,7 @@ describe('collapsed hydration', () => { }, }); const fileContainer = dom.createHydrationContainer({ header: false }); - const props: UnresolvedFileHydrationProps = { + const props: UnresolvedFileHydrationProps = { file: unresolvedFile, fileContainer, }; diff --git a/packages/diffs/test/themeTypeUpdates.test.ts b/packages/diffs/test/themeTypeUpdates.test.ts index 89f757286..1b384646e 100644 --- a/packages/diffs/test/themeTypeUpdates.test.ts +++ b/packages/diffs/test/themeTypeUpdates.test.ts @@ -165,7 +165,7 @@ function makeDiffItem(id: string): CodeViewDiffItem { async function waitForRenderedItems( viewer: CodeView, count: number -): Promise[]> { +): Promise[]> { for (let attempt = 0; attempt < 50; attempt++) { const renderedItems = viewer.getRenderedItems(); if (renderedItems.length === count) { From 8f30178fc694e6c7426ce64e10e175166ee22270 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Tue, 31 Mar 2026 15:56:13 -0700 Subject: [PATCH 2/5] phase 2: normalization and piping data through Some AI slop here for sure, but i think we wrangled it into a decent spot. --- packages/diffs/src/components/File.ts | 61 +++++--- packages/diffs/src/components/FileDiff.ts | 65 ++++++--- .../diffs/src/renderers/DiffHunksRenderer.ts | 29 +++- packages/diffs/src/renderers/FileRenderer.ts | 23 +++- .../src/utils/normalizeLineDecorations.ts | 130 ++++++++++++++++++ 5 files changed, 271 insertions(+), 37 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 ba7d12a11..0d0b4f83e 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -288,6 +288,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 flushManagers(): void { if (!this.managersDirty || this.pre == null) { this.managersDirty = false; @@ -315,6 +343,7 @@ export class File { this.fileContainer = undefined; this.mounted = false; this.lineAnnotations = []; + this.decorations = []; this.annotationCache.clear(); this.pre = undefined; this.bufferBefore = undefined; @@ -440,12 +469,15 @@ export class File { lineAnnotations, decorations, }: HydrationSetup): void { - this.lineAnnotations = lineAnnotations ?? this.lineAnnotations; - this.decorations = decorations ?? this.decorations; this.file = file; this.fileRenderer.setOptions(getFileRendererOptions(this.options)); this.syncInteractionOptions(); - this.fileRenderer.setDecorations(this.decorations); + this.syncRenderState({ + nextLineAnnotations: lineAnnotations, + nextDecorations: decorations, + syncAnnotations: true, + syncDecorations: true, + }); if (this.pre == null) { return; } @@ -485,16 +517,15 @@ export class File { const nextRenderRange = collapsed ? undefined : renderRange; const previousRenderRange = this.renderRange; const themeChanged = this.hasThemeChanged(); - 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 ( @@ -516,14 +547,12 @@ export class File { this.file = file; this.fileRenderer.setOptions(getFileRendererOptions(this.options)); this.syncInteractionOptions(); - 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, disableFileHeader = false } = this.options; @@ -584,7 +613,7 @@ export class File { if ( !this.canPartiallyRender( forceRender, - annotationsChanged, + annotationsChanged || decorationsChanged, didFileChange || themeChanged ) || !this.applyPartialRender(previousRenderRange, nextRenderRange) diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index 95091e051..e2846971e 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -450,6 +450,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, @@ -510,6 +538,7 @@ export class FileDiff { this.fileContainer = undefined; this.mounted = false; this.lineAnnotations = []; + this.decorations = []; this.clearAuxiliaryNodes(); this.annotationCache.clear(); this.pre = undefined; @@ -683,12 +712,18 @@ 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.syncInteractionOptions(); - this.hunksRenderer.setDecorations(this.decorations); this.hunksRenderer.hydrate(this.fileDiff); // FIXME(amadeus): not sure how to handle this yet... // this.renderSeparators(); @@ -765,7 +800,6 @@ export class FileDiff { const { collapsed = false, themeType = 'system' } = this.options; const nextRenderRange = collapsed ? undefined : renderRange; const themeChanged = this.hasThemeChanged(); - const nextDecorations = decorations; const filesDidChange = oldFile != null && newFile != null && @@ -778,9 +812,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 ( @@ -819,20 +853,17 @@ export class FileDiff { this.cachedHeaderHTML = undefined; } - if (lineAnnotations != null) { - this.setLineAnnotations(lineAnnotations); - } - if (nextDecorations != null) { - this.decorations = nextDecorations; - } + this.hunksRenderer.setOptions(this.getHunksRendererOptions(this.options)); + this.syncInteractionOptions(); + this.syncRenderState({ + nextLineAnnotations: lineAnnotations, + nextDecorations: decorations, + syncAnnotations: annotationsChanged, + syncDecorations: decorationsChanged, + }); if (this.fileDiff == null) { return false; } - this.hunksRenderer.setOptions(this.getHunksRendererOptions(this.options)); - this.syncInteractionOptions(); - - this.hunksRenderer.setLineAnnotations(this.lineAnnotations); - this.hunksRenderer.setDecorations(this.decorations); const { disableErrorHandling = false, disableFileHeader = false } = this.options; @@ -896,7 +927,7 @@ export class FileDiff { const didPartiallyRender = this.canPartiallyRender( forceRender, - annotationsChanged, + annotationsChanged || decorationsChanged, filesDidChange || diffDidChange || themeChanged ) && this.applyPartialRender({ diff --git a/packages/diffs/src/renderers/DiffHunksRenderer.ts b/packages/diffs/src/renderers/DiffHunksRenderer.ts index dc3c22cd9..f45046f7b 100644 --- a/packages/diffs/src/renderers/DiffHunksRenderer.ts +++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts @@ -63,6 +63,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 { getTrailingContextRangeSize } from '../utils/virtualDiffLayout'; @@ -214,6 +219,8 @@ export class DiffHunksRenderer< private deletionAnnotations: AnnotationLineMap = {}; private additionAnnotations: AnnotationLineMap = {}; + private deletionDecorationsByLine: NormalizedLineDecorationMap = {}; + private additionDecorationsByLine: NormalizedLineDecorationMap = {}; private computedLang: SupportedLanguages = 'text'; private renderCache: RenderedDiffASTCache | undefined; @@ -238,6 +245,8 @@ export class DiffHunksRenderer< } public recycle(): void { + this.deletionDecorationsByLine = {}; + this.additionDecorationsByLine = {}; this.highlighter = undefined; this.diff = undefined; this.clearRenderCache(); @@ -313,8 +322,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, @@ -335,6 +348,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 8bdb0d7f2..bb2825f33 100644 --- a/packages/diffs/src/renderers/FileRenderer.ts +++ b/packages/diffs/src/renderers/FileRenderer.ts @@ -46,6 +46,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'; @@ -93,6 +98,7 @@ export class FileRenderer { private renderCache: RenderedFileASTCache | undefined; private computedLang: SupportedLanguages = 'text'; private lineAnnotations: AnnotationLineMap = {}; + private decorationsByLine: NormalizedLineDecorationMap = {}; private lineCache: LineCache | undefined; constructor( @@ -127,8 +133,10 @@ export class FileRenderer { } public setDecorations( - _decorations: readonly FileDecorationItem[] - ): void {} + decorations: readonly FileDecorationItem[] + ): void { + this.decorationsByLine = normalizeFileDecorations(decorations); + } public cleanUp(): void { this.recycle(); @@ -137,6 +145,8 @@ export class FileRenderer { } public recycle(): void { + this.lineAnnotations = {}; + this.decorationsByLine = {}; this.clearRenderCache(); this.highlighter = undefined; this.workerManager?.cleanUpTasks(this); @@ -224,6 +234,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 1389b26e1dfad2207353fca00e84f92c42a4ae40 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Mon, 6 Apr 2026 17:35:19 -0700 Subject: [PATCH 3/5] decorations checkpoint... shit's mb about to get weird... --- .../diffs/src/components/UnresolvedFile.ts | 2 +- .../diffs/src/renderers/DiffHunksRenderer.ts | 69 ++- packages/diffs/src/renderers/FileRenderer.ts | 36 +- .../src/utils/getLineDecorationProperties.ts | 267 ++++++++ .../src/utils/normalizeLineDecorations.ts | 186 +++++- packages/diffs/test/decorations.test.ts | 568 ++++++++++++++++++ 6 files changed, 1107 insertions(+), 21 deletions(-) create mode 100644 packages/diffs/src/utils/getLineDecorationProperties.ts create mode 100644 packages/diffs/test/decorations.test.ts diff --git a/packages/diffs/src/components/UnresolvedFile.ts b/packages/diffs/src/components/UnresolvedFile.ts index 2d27f6f99..47797ab04 100644 --- a/packages/diffs/src/components/UnresolvedFile.ts +++ b/packages/diffs/src/components/UnresolvedFile.ts @@ -411,7 +411,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 f45046f7b..b7343cb1f 100644 --- a/packages/diffs/src/renderers/DiffHunksRenderer.ts +++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts @@ -52,6 +52,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, @@ -360,6 +366,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); } @@ -955,24 +978,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({ @@ -1023,6 +1057,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 = @@ -1069,14 +1115,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; @@ -1085,14 +1131,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; @@ -1695,8 +1741,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 bb2825f33..7eec75194 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 { @@ -37,6 +37,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, @@ -447,11 +452,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 @@ -628,3 +644,17 @@ export class FileRenderer { function isFileMassive(lineCount: number, tokenizeMaxLength: number): boolean { return lineCount > tokenizeMaxLength; } + +function withContentProperties( + lineNode: ElementContent, + contentProperties: Properties | undefined +): ElementContent { + if (lineNode.type !== 'element' || contentProperties == null) { + return lineNode; + } + return { + ...lineNode, + properties: + mergeHastProperties(lineNode.properties, contentProperties) ?? {}, + }; +} 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 d14d89f17581f98f42b0c09b255470450d058793 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Tue, 7 Apr 2026 17:55:58 -0700 Subject: [PATCH 4/5] before ai belligerance --- apps/demo/src/main.ts | 79 +++- .../diffs/src/renderers/DiffHunksRenderer.ts | 39 +- packages/diffs/src/renderers/FileRenderer.ts | 4 +- .../src/utils/getLineDecorationProperties.ts | 74 ++- packages/diffs/src/utils/hast_utils.ts | 26 +- .../src/utils/normalizeLineDecorations.ts | 138 +++++- packages/diffs/test/decorations.test.ts | 438 ++++++++++++++++++ 7 files changed, 749 insertions(+), 49 deletions(-) diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index b7d3a39a9..4a42bd7b1 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, @@ -49,8 +51,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; @@ -436,6 +438,7 @@ function renderDiff(parsedPatches: ParsedPatch[], manager?: WorkerPoolManager) { fileDiff, lineAnnotations: fileAnnotations, fileContainer, + decorations: DECORATIONS_DIFF, }); diffInstances.push(instance); hunkIndex++; @@ -729,6 +732,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, @@ -864,6 +914,7 @@ if (renderFileButton != null) { file, lineAnnotations: FAKE_LINE_ANNOTATIONS, fileContainer, + decorations: DECORATIONS, }); fileInstances.push(instance); }); @@ -974,15 +1025,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 b7343cb1f..0448f54f1 100644 --- a/packages/diffs/src/renderers/DiffHunksRenderer.ts +++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts @@ -54,6 +54,7 @@ import { getHunkSeparatorSlotName } from '../utils/getHunkSeparatorSlotName'; import { getLineAnnotationName } from '../utils/getLineAnnotationName'; import { getLineDecorationContentProperties, + getLineDecorationGutterChildren, getLineDecorationGutterProperties, mergeHastProperties, mergeNormalizedLineDecorations, @@ -151,6 +152,7 @@ export interface SplitLineDecorationProps { export interface LineDecoration { gutterLineType: LineTypes; gutterProperties?: Properties; + gutterChildren?: ElementContent[]; contentProperties?: Properties; } @@ -372,6 +374,10 @@ export class DiffHunksRenderer< ): LineDecoration { return { ...decoration, + gutterChildren: mergeElementContents( + decoration.gutterChildren, + getLineDecorationGutterChildren(lineDecorations) + ), gutterProperties: mergeHastProperties( decoration.gutterProperties, getLineDecorationGutterProperties(lineDecorations) @@ -873,11 +879,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 + ) ); }; @@ -996,7 +1009,8 @@ export class DiffHunksRenderer< ? additionLine.lineNumber : deletionLine.lineNumber, `${unifiedLineIndex},${splitLineIndex}`, - unifiedLineDecoration.gutterProperties + unifiedLineDecoration.gutterProperties, + unifiedLineDecoration.gutterChildren ); if (additionLineContent != null) { additionLineContent = withContentProperties( @@ -1122,7 +1136,8 @@ export class DiffHunksRenderer< decoratedDeletionLine.gutterLineType, deletionLine.lineNumber, `${deletionLine.unifiedLineIndex},${splitLineIndex}`, - decoratedDeletionLine.gutterProperties + decoratedDeletionLine.gutterProperties, + decoratedDeletionLine.gutterChildren ); if (deletionLineDecorated != null) { deletionLineContent = deletionLineDecorated; @@ -1138,7 +1153,8 @@ export class DiffHunksRenderer< decoratedAdditionLine.gutterLineType, additionLine.lineNumber, `${additionLine.unifiedLineIndex},${splitLineIndex}`, - decoratedAdditionLine.gutterProperties + decoratedAdditionLine.gutterProperties, + decoratedAdditionLine.gutterChildren ); if (additionLineDecorated != null) { additionLineContent = additionLineDecorated; @@ -1501,6 +1517,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 7eec75194..41355b84e 100644 --- a/packages/diffs/src/renderers/FileRenderer.ts +++ b/packages/diffs/src/renderers/FileRenderer.ts @@ -39,6 +39,7 @@ import { getHighlighterOptions } from '../utils/getHighlighterOptions'; import { getLineAnnotationName } from '../utils/getLineAnnotationName'; import { getLineDecorationContentProperties, + getLineDecorationGutterChildren, getLineDecorationGutterProperties, mergeHastProperties, } from '../utils/getLineDecorationProperties'; @@ -459,7 +460,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 8931c5b55f3ede03326d6590b70d8516df2bb02f Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Tue, 7 Apr 2026 17:56:08 -0700 Subject: [PATCH 5/5] sorting through ai belligerancy --- apps/demo/src/main.ts | 39 ++- .../src/utils/getLineDecorationProperties.ts | 78 +++-- .../src/utils/normalizeLineDecorations.ts | 77 +++++ packages/diffs/test/decorations.test.ts | 272 +++++++++++++++++- 4 files changed, 409 insertions(+), 57 deletions(-) diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index 4a42bd7b1..04f558aa7 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -246,6 +246,7 @@ function renderDiff(parsedPatches: ParsedPatch[], manager?: WorkerPoolManager) { | FileDiff | VirtualizedFileDiff; const options: FileDiffOptions = { + expandUnchanged: true, theme: DEMO_THEME, themeType, diffStyle: unified ? 'unified' : 'split', @@ -270,7 +271,7 @@ function renderDiff(parsedPatches: ParsedPatch[], manager?: WorkerPoolManager) { // expandUnchanged: true, // Hover Decoration Snippets - enableGutterUtility: true, + // enableGutterUtility: true, // onGutterUtilityClick(event) { // console.log('onGutterUtilityClick', event); // }, @@ -754,28 +755,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', }, ]; @@ -849,7 +862,7 @@ if (renderFileButton != null) { // }, // Hover Decoration Snippets - enableGutterUtility: true, + // enableGutterUtility: true, // onGutterUtilityClick(event) { // console.log('onGutterUtilityClick', event); // }, @@ -939,7 +952,7 @@ if (renderFileConflictButton != null) { overflow: wrap ? 'wrap' : 'scroll', renderAnnotation, enableLineSelection: true, - enableGutterUtility: true, + // enableGutterUtility: true, maxContextLines: 4, // Token Testing Helpers diff --git a/packages/diffs/src/utils/getLineDecorationProperties.ts b/packages/diffs/src/utils/getLineDecorationProperties.ts index 926c360b7..04cf94fe5 100644 --- a/packages/diffs/src/utils/getLineDecorationProperties.ts +++ b/packages/diffs/src/utils/getLineDecorationProperties.ts @@ -51,13 +51,20 @@ export function getLineDecorationGutterChildren( return undefined; } + const visualBarLayers = collapseBarLayersForRendering(barLayers); + return [ createHastElement({ tagName: 'span', properties: { 'data-decoration-bar-stack': '', - 'data-decoration-bar-layer-count': String(barLayers.length), - style: getLineDecorationBarStackStyle(barLayers), + 'data-decoration-bar-layer-count': String(visualBarLayers.length), + 'data-decoration-bar-overlap': + visualBarLayers.length > 1 ? '' : undefined, + 'data-decoration-bar-second': + visualBarLayers.length > 1 ? '' : undefined, + 'data-decoration-bar-third': + visualBarLayers.length > 2 ? '' : undefined, }, }), ]; @@ -131,6 +138,12 @@ export function mergeNormalizedLineDecorations( second.barLayers ); const topBar = barLayers?.at(-1); + const topBarDepth = + topBar?.sourceIndex === first.barSourceIndex + ? first.barDepth + : topBar?.sourceIndex === second.barSourceIndex + ? second.barDepth + : undefined; return { barIndices, @@ -143,7 +156,7 @@ export function mergeNormalizedLineDecorations( backgroundColor: background?.color, backgroundLineNumber: background?.lineNumber, backgroundSourceIndex: background?.sourceIndex, - barDepth: mergeDecorationDepth(first.barDepth, second.barDepth), + barDepth: topBarDepth, barLayers, backgroundDepth: mergeDecorationDepth( first.backgroundDepth, @@ -155,6 +168,8 @@ export function mergeNormalizedLineDecorations( function getLineDecorationBarProperties( decorations: NormalizedLineDecorations | undefined ): Properties | undefined { + const topmostBarEndIndices = getTopmostBarEndIndices(decorations); + return mergeHastProperties( mergeHastProperties( getLineDecorationProperties( @@ -173,42 +188,45 @@ function getLineDecorationBarProperties( 'data-decoration-bar-start', decorations?.startIndices, 'data-decoration-bar-end', - decorations?.endIndices + topmostBarEndIndices ) ); } -function getLineDecorationBarStackStyle(barLayers: VisibleBarLayer[]): string { - const serializedLayers = [...barLayers].reverse(); - const styles = [ - `--diffs-decoration-bar-layer-count:${serializedLayers.length};`, - ]; - - for (const [index, layer] of serializedLayers.entries()) { - const layerNumber = index + 1; - styles.push(`--diffs-decoration-bar-color-${layerNumber}:${layer.color};`); - styles.push( - `--diffs-decoration-bar-tier-${layerNumber}:${getBarVisualTier(layerNumber)};` - ); - styles.push( - `--diffs-decoration-bar-start-cap-${layerNumber}:${layer.showStartCap ? 1 : 0};` - ); - styles.push( - `--diffs-decoration-bar-end-cap-${layerNumber}:${layer.showEndCap ? 1 : 0};` - ); +function getTopmostBarEndIndices( + decorations: NormalizedLineDecorations | undefined +): number[] | undefined { + const topmostBarSourceIndex = decorations?.barSourceIndex; + if (topmostBarSourceIndex == null) { + return undefined; } - return styles.join(''); + return (decorations?.endIndices?.includes(topmostBarSourceIndex) ?? false) + ? [topmostBarSourceIndex] + : undefined; } -function getBarVisualTier(layerNumber: number): 1 | 2 | 3 { - if (layerNumber <= 1) { - return 1; - } - if (layerNumber === 2) { - return 2; +// When adjacent visible layers share the same bar color, render them as one +// continuous visual bar so overlap identity does not create artificial gaps. +function collapseBarLayersForRendering( + barLayers: VisibleBarLayer[] +): VisibleBarLayer[] { + const serializedLayers = [...barLayers].reverse(); + const collapsed: VisibleBarLayer[] = []; + + for (const layer of serializedLayers) { + const previousLayer = collapsed.at(-1); + if (previousLayer?.color !== layer.color) { + collapsed.push({ ...layer }); + continue; + } + + previousLayer.showStartCap = + previousLayer.showStartCap && layer.showStartCap; + previousLayer.showEndCap = previousLayer.showEndCap && layer.showEndCap; } - return 3; + + return collapsed.reverse(); } function getLineDecorationProperties( diff --git a/packages/diffs/src/utils/normalizeLineDecorations.ts b/packages/diffs/src/utils/normalizeLineDecorations.ts index 12688edae..faba2db27 100644 --- a/packages/diffs/src/utils/normalizeLineDecorations.ts +++ b/packages/diffs/src/utils/normalizeLineDecorations.ts @@ -151,6 +151,7 @@ export function normalizeFileDecorations( for (const [sourceIndex, decoration] of decorations.entries()) { applyDecorationRange(normalized, decoration, sourceIndex); } + finalizeBarDepths(normalized); return normalized; } @@ -164,6 +165,8 @@ export function normalizeDiffDecorations( for (const [sourceIndex, decoration] of decorations.entries()) { applyDecorationRange(normalized[decoration.side], decoration, sourceIndex); } + finalizeBarDepths(normalized.additions); + finalizeBarDepths(normalized.deletions); return normalized; } @@ -401,6 +404,80 @@ function cloneVisibleBarLayer(layer: VisibleBarLayer): VisibleBarLayer { return { ...layer }; } +function finalizeBarDepths(map: NormalizedLineDecorationMap): void { + const priorityBySourceIndex = new Map< + number, + { lineNumber: number; sourceIndex: number } + >(); + const higherNeighborsBySourceIndex = new Map>(); + + for (const lineDecorations of Object.values(map)) { + const barLayers = lineDecorations?.barLayers; + if (barLayers == null || barLayers.length === 0) { + continue; + } + + for (const layer of barLayers) { + if (!priorityBySourceIndex.has(layer.sourceIndex)) { + priorityBySourceIndex.set(layer.sourceIndex, { + lineNumber: layer.lineNumber, + sourceIndex: layer.sourceIndex, + }); + } + } + + for (let index = 0; index < barLayers.length - 1; index++) { + const lowerLayer = barLayers[index]; + const higherLayer = barLayers[index + 1]; + if (lowerLayer == null || higherLayer == null) { + continue; + } + + const higherNeighbors = + higherNeighborsBySourceIndex.get(lowerLayer.sourceIndex) ?? new Set(); + higherNeighborsBySourceIndex.set( + lowerLayer.sourceIndex, + higherNeighbors + ); + higherNeighbors.add(higherLayer.sourceIndex); + } + } + + const coveredDepthBySourceIndex = new Map(); + const sortedSourceIndices = [...priorityBySourceIndex.values()] + .sort((first, second) => compareDecorationPriority(second, first)) + .map(({ sourceIndex }) => sourceIndex); + + for (const sourceIndex of sortedSourceIndices) { + const higherNeighbors = higherNeighborsBySourceIndex.get(sourceIndex); + if (higherNeighbors == null || higherNeighbors.size === 0) { + coveredDepthBySourceIndex.set(sourceIndex, 0); + continue; + } + + let maxCoveredDepth = 0; + for (const higherSourceIndex of higherNeighbors) { + const higherCoveredDepth = coveredDepthBySourceIndex.get(higherSourceIndex) ?? 0; + if (higherCoveredDepth + 1 > maxCoveredDepth) { + maxCoveredDepth = higherCoveredDepth + 1; + } + } + + coveredDepthBySourceIndex.set(sourceIndex, maxCoveredDepth); + } + + for (const lineDecorations of Object.values(map)) { + const topBarSourceIndex = lineDecorations?.barSourceIndex; + if (topBarSourceIndex == null || lineDecorations == null) { + continue; + } + + const coveredCount = coveredDepthBySourceIndex.get(topBarSourceIndex) ?? 0; + lineDecorations.barDepth = + coveredCount > 0 ? getDecorationDepth(coveredCount) : undefined; + } +} + function incrementDecorationDepth( current: DecorationOverlapDepth | undefined ): DecorationOverlapDepth { diff --git a/packages/diffs/test/decorations.test.ts b/packages/diffs/test/decorations.test.ts index e9793cc1f..a0ceea7fc 100644 --- a/packages/diffs/test/decorations.test.ts +++ b/packages/diffs/test/decorations.test.ts @@ -95,10 +95,24 @@ describe('Decoration Rendering', () => { expect( gutterLine1BarStack.properties['data-decoration-bar-layer-count'] ).toBe('2'); - expect(gutterLine1BarStack.children).toHaveLength(0); - expect(gutterLine1BarStack.properties.style).toBe( - '--diffs-decoration-bar-layer-count:2;--diffs-decoration-bar-color-1:green;--diffs-decoration-bar-tier-1:1;--diffs-decoration-bar-start-cap-1:1;--diffs-decoration-bar-end-cap-1:0;--diffs-decoration-bar-color-2:red;--diffs-decoration-bar-tier-2:2;--diffs-decoration-bar-start-cap-2:1;--diffs-decoration-bar-end-cap-2:0;' + expect(gutterLine1BarStack.properties['data-decoration-bar-overlap']).toBe( + '' ); + expect(gutterLine1BarStack.properties['data-decoration-bar-second']).toBe( + '' + ); + expect( + gutterLine1BarStack.properties['data-decoration-bar-third'] + ).toBeUndefined(); + expect(gutterLine1BarStack.children).toHaveLength(0); + expectStyleContains(gutterLine1BarStack.properties.style, [ + '--diffs-decoration-bar-width-1:6px;', + '--diffs-decoration-bar-color-1:green;', + '--diffs-decoration-bar-start-radius-1:4px;', + '--diffs-decoration-bar-end-radius-1:0px;', + '--diffs-decoration-bar-width-2:10px;', + 'color-mix(in lab, red 74%, var(--diffs-bg))', + ]); expect(gutterLine1.properties['data-decoration-bar-depth']).toBe('2'); expect(gutterLine1.properties['data-decoration-bar-start']).toBe('0,1'); expect(gutterLine1.properties['data-decoration-bar-end']).toBe('0'); @@ -106,10 +120,24 @@ describe('Decoration Rendering', () => { expect( gutterLine2BarStack.properties['data-decoration-bar-layer-count'] ).toBe('2'); - expect(gutterLine2BarStack.children).toHaveLength(0); - expect(gutterLine2BarStack.properties.style).toBe( - '--diffs-decoration-bar-layer-count:2;--diffs-decoration-bar-color-1:orange;--diffs-decoration-bar-tier-1:1;--diffs-decoration-bar-start-cap-1:1;--diffs-decoration-bar-end-cap-1:1;--diffs-decoration-bar-color-2:green;--diffs-decoration-bar-tier-2:2;--diffs-decoration-bar-start-cap-2:0;--diffs-decoration-bar-end-cap-2:0;' + expect(gutterLine2BarStack.properties['data-decoration-bar-overlap']).toBe( + '' ); + expect(gutterLine2BarStack.properties['data-decoration-bar-second']).toBe( + '' + ); + expect( + gutterLine2BarStack.properties['data-decoration-bar-third'] + ).toBeUndefined(); + expect(gutterLine2BarStack.children).toHaveLength(0); + expectStyleContains(gutterLine2BarStack.properties.style, [ + '--diffs-decoration-bar-width-1:6px;', + '--diffs-decoration-bar-color-1:orange;', + '--diffs-decoration-bar-start-radius-1:4px;', + '--diffs-decoration-bar-end-radius-1:4px;', + '--diffs-decoration-bar-width-2:10px;', + 'color-mix(in lab, green 74%, var(--diffs-bg))', + ]); expect(gutterLine2.properties['data-decoration-bar-depth']).toBe('2'); expect(gutterLine2.properties['data-decoration-bar-start']).toBe('2,3'); expect(gutterLine2.properties['data-decoration-bar-end']).toBe('3'); @@ -122,10 +150,22 @@ describe('Decoration Rendering', () => { expect( gutterLine3BarStack.properties['data-decoration-bar-layer-count'] ).toBe('1'); + expect( + gutterLine3BarStack.properties['data-decoration-bar-overlap'] + ).toBeUndefined(); + expect( + gutterLine3BarStack.properties['data-decoration-bar-second'] + ).toBeUndefined(); + expect( + gutterLine3BarStack.properties['data-decoration-bar-third'] + ).toBeUndefined(); expect(gutterLine3BarStack.children).toHaveLength(0); - expect(gutterLine3BarStack.properties.style).toBe( - '--diffs-decoration-bar-layer-count:1;--diffs-decoration-bar-color-1:green;--diffs-decoration-bar-tier-1:1;--diffs-decoration-bar-start-cap-1:0;--diffs-decoration-bar-end-cap-1:1;' - ); + expectStyleContains(gutterLine3BarStack.properties.style, [ + '--diffs-decoration-bar-width-1:6px;', + '--diffs-decoration-bar-color-1:green;', + '--diffs-decoration-bar-end-radius-1:4px;', + '--diffs-decoration-bar-shadow-3:none;', + ]); expect(gutterLine3.properties['data-decoration-bar-depth']).toBe('1'); expect(gutterLine3.properties['data-decoration-bar-start']).toBeUndefined(); expect(gutterLine3.properties['data-decoration-bar-end']).toBe('1'); @@ -196,6 +236,64 @@ describe('Decoration Rendering', () => { ); }); + test('file renderer keeps the visible bar when a higher overlapping decoration has no bar', async () => { + const file = { + name: 'example.ts', + contents: ['one', 'two', 'three'].join('\n'), + }; + const decorations: FileDecorationItem[] = [ + { lineNumber: 1, endLineNumber: 3, bar: true, color: 'red' }, + { lineNumber: 2, endLineNumber: 3, background: '#111111' }, + ]; + + const renderer = new FileRenderer(); + renderer.setDecorations(decorations); + const result = await renderer.asyncRender(file); + const codeAST = renderer.renderCodeAST(result) as HASTElement[]; + const [gutter, content] = codeAST; + assertDefined(gutter, 'expected gutter column'); + assertDefined(content, 'expected content column'); + + const gutterLine2 = findElementByProperty( + gutter.children, + 'data-column-number', + 2 + ); + const contentLine2 = findElementByProperty( + content.children, + 'data-line', + 2 + ); + + assertDefined(gutterLine2, 'expected second gutter line'); + assertDefined(contentLine2, 'expected second content line'); + + const gutterLine2BarStack = findElementByProperty( + gutterLine2.children, + 'data-decoration-bar-stack', + '' + ); + + assertDefined(gutterLine2BarStack, 'expected second gutter bar stack'); + expect(gutterLine2.properties['data-decoration-bar']).toBe('0'); + expect(gutterLine2.properties['data-decoration-bar-depth']).toBe('1'); + expect(gutterLine2.properties.style).toBe( + '--diffs-decoration-bar-color:red;' + ); + expect( + gutterLine2BarStack.properties['data-decoration-bar-layer-count'] + ).toBe('1'); + expectStyleContains(gutterLine2BarStack.properties.style, [ + '--diffs-decoration-bar-color-1:red;', + '--diffs-decoration-bar-width-1:6px;', + '--diffs-decoration-bar-shadow-3:none;', + ]); + expect(contentLine2.properties['data-decoration-bg']).toBe('1'); + expect(contentLine2.properties.style).toBe( + '--diffs-decoration-bg:#111111;' + ); + }); + test('file renderer keeps one bar stack element while bar depth clamps at 3', async () => { const file = { name: 'example.ts', @@ -235,10 +333,126 @@ describe('Decoration Rendering', () => { expect( gutterLine4BarStack.properties['data-decoration-bar-layer-count'] ).toBe('4'); + expect(gutterLine4BarStack.properties['data-decoration-bar-overlap']).toBe( + '' + ); + expect(gutterLine4BarStack.properties['data-decoration-bar-second']).toBe( + '' + ); + expect(gutterLine4BarStack.properties['data-decoration-bar-third']).toBe( + '' + ); expect(gutterLine4BarStack.children).toHaveLength(0); - expect(gutterLine4BarStack.properties.style).toBe( - '--diffs-decoration-bar-layer-count:4;--diffs-decoration-bar-color-1:yellow;--diffs-decoration-bar-tier-1:1;--diffs-decoration-bar-start-cap-1:0;--diffs-decoration-bar-end-cap-1:1;--diffs-decoration-bar-color-2:green;--diffs-decoration-bar-tier-2:2;--diffs-decoration-bar-start-cap-2:0;--diffs-decoration-bar-end-cap-2:1;--diffs-decoration-bar-color-3:blue;--diffs-decoration-bar-tier-3:3;--diffs-decoration-bar-start-cap-3:0;--diffs-decoration-bar-end-cap-3:1;--diffs-decoration-bar-color-4:red;--diffs-decoration-bar-tier-4:3;--diffs-decoration-bar-start-cap-4:0;--diffs-decoration-bar-end-cap-4:1;' + expectStyleContains(gutterLine4BarStack.properties.style, [ + '--diffs-decoration-bar-width-1:6px;', + '--diffs-decoration-bar-width-2:10px;', + '--diffs-decoration-bar-width-3:14px;', + '--diffs-decoration-bar-color-3:color-mix(in lab, blue 58%, var(--diffs-bg));', + '--diffs-decoration-bar-shadow-3:0 0 0 2px var(--diffs-bg),-4px 0 0 2px var(--diffs-bg),-4px 0 0 0 color-mix(in lab, red 58%, var(--diffs-bg));', + ]); + expectStyleNotContains(gutterLine4BarStack.properties.style, [ + '--diffs-decoration-bar-color-4:', + ]); + }); + + test('diff renderer collapses overlapping same-color bars into one continuous visual bar', async () => { + const oldFile = { + name: 'example.ts', + contents: '', + }; + const newFile = { + name: 'example.ts', + contents: Array.from( + { length: 12 }, + (_, index) => `line ${index + 1}` + ).join('\n'), + }; + const diff = parseDiffFromFile(oldFile, newFile); + const decorations: DiffDecorationItem[] = [ + { + side: 'additions', + lineNumber: 2, + endLineNumber: 6, + bar: true, + background: 'red', + }, + { + side: 'additions', + lineNumber: 5, + endLineNumber: 11, + bar: true, + background: true, + }, + ]; + + const renderer = new DiffHunksRenderer({ + diffStyle: 'split', + expandUnchanged: true, + }); + renderer.setDecorations(decorations); + const result = await renderer.asyncRender(diff); + assertDefined(result.additionsGutterAST, 'expected additions gutter AST'); + + const additionsLine5 = findElementByProperty( + result.additionsGutterAST, + 'data-column-number', + 5 + ); + const additionsLine6 = findElementByProperty( + result.additionsGutterAST, + 'data-column-number', + 6 ); + + assertDefined(additionsLine5, 'expected additions gutter line 5'); + assertDefined(additionsLine6, 'expected additions gutter line 6'); + + const additionsLine5BarStack = findElementByProperty( + additionsLine5.children, + 'data-decoration-bar-stack', + '' + ); + const additionsLine6BarStack = findElementByProperty( + additionsLine6.children, + 'data-decoration-bar-stack', + '' + ); + + assertDefined( + additionsLine5BarStack, + 'expected additions line 5 bar stack' + ); + assertDefined( + additionsLine6BarStack, + 'expected additions line 6 bar stack' + ); + + expect(additionsLine5.properties['data-decoration-bar']).toBe('0,1'); + expect(additionsLine6.properties['data-decoration-bar']).toBe('0,1'); + expect(additionsLine5.properties['data-decoration-bar-depth']).toBe('2'); + expect(additionsLine6.properties['data-decoration-bar-depth']).toBe('2'); + expect( + additionsLine5BarStack.properties['data-decoration-bar-layer-count'] + ).toBe('1'); + expect( + additionsLine6BarStack.properties['data-decoration-bar-layer-count'] + ).toBe('1'); + expectStyleContains(additionsLine5BarStack.properties.style, [ + '--diffs-decoration-bar-color-1:var(--diffs-modified-base);', + '--diffs-decoration-bar-width-1:6px;', + ]); + expectStyleContains(additionsLine6BarStack.properties.style, [ + '--diffs-decoration-bar-color-1:var(--diffs-modified-base);', + '--diffs-decoration-bar-width-1:6px;', + ]); + expectStyleNotContains(additionsLine5BarStack.properties.style, [ + 'color-mix(', + '--diffs-decoration-bar-width-2:', + ]); + expectStyleNotContains(additionsLine6BarStack.properties.style, [ + 'color-mix(', + '--diffs-decoration-bar-width-2:', + ]); }); test('merged normalized decorations keep source-order identity and line-number winners', () => { @@ -832,10 +1046,23 @@ describe('Decoration Rendering', () => { expect( unifiedLine1BarStack.properties['data-decoration-bar-layer-count'] ).toBe('2'); - expect(unifiedLine1BarStack.children).toHaveLength(0); - expect(unifiedLine1BarStack.properties.style).toBe( - '--diffs-decoration-bar-layer-count:2;--diffs-decoration-bar-color-1:blue;--diffs-decoration-bar-tier-1:1;--diffs-decoration-bar-start-cap-1:1;--diffs-decoration-bar-end-cap-1:1;--diffs-decoration-bar-color-2:red;--diffs-decoration-bar-tier-2:2;--diffs-decoration-bar-start-cap-2:1;--diffs-decoration-bar-end-cap-2:1;' + expect(unifiedLine1BarStack.properties['data-decoration-bar-overlap']).toBe( + '' + ); + expect(unifiedLine1BarStack.properties['data-decoration-bar-second']).toBe( + '' ); + expect( + unifiedLine1BarStack.properties['data-decoration-bar-third'] + ).toBeUndefined(); + expect(unifiedLine1BarStack.children).toHaveLength(0); + expectStyleContains(unifiedLine1BarStack.properties.style, [ + '--diffs-decoration-bar-width-1:6px;', + '--diffs-decoration-bar-color-1:blue;', + '--diffs-decoration-bar-start-radius-2:4px;', + '--diffs-decoration-bar-end-radius-2:4px;', + 'color-mix(in lab, red 74%, var(--diffs-bg))', + ]); expect(unifiedLine1Gutter.properties['data-decoration-bar-depth']).toBe( '2' ); @@ -983,6 +1210,23 @@ function findElementByProperty( return findElementByProperties(nodes, { [property]: value }); } +function expectStyleContains(style: unknown, expectedParts: string[]): void { + expect(typeof style).toBe('string'); + for (const expectedPart of expectedParts) { + expect(style).toContain(expectedPart); + } +} + +function expectStyleNotContains( + style: unknown, + unexpectedParts: string[] +): void { + expect(typeof style).toBe('string'); + for (const unexpectedPart of unexpectedParts) { + expect(style).not.toContain(unexpectedPart); + } +} + function findElementByProperties( nodes: ElementContent[], properties: Record