From 6f9e0029a10845b515ff50dc0b58986d407fd77f 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 | 10 +- apps/diffshub/components/DiffsHubSidebar.tsx | 2 +- apps/diffshub/components/DiffsHubViewer.tsx | 10 +- apps/diffshub/components/ReviewUI.tsx | 4 +- apps/diffshub/components/ThemedCodeView.tsx | 4 +- apps/diffshub/components/ThemedFile.tsx | 9 +- apps/diffshub/components/ThemedFileDiff.tsx | 9 +- apps/diffshub/components/WorkerPoolStatus.tsx | 6 +- apps/diffshub/components/usePatchLoader.ts | 6 +- .../diffshub/lib/test/reactOverrides.test.tsx | 2 +- apps/docs/app/(diffs)/_home/mockData.ts | 5 +- apps/docs/components/docs/DocsCodeExample.tsx | 13 +- packages/diffs/src/components/CodeView.ts | 503 +++++++++++------- packages/diffs/src/components/File.ts | 65 ++- packages/diffs/src/components/FileDiff.ts | 74 ++- .../diffs/src/components/UnresolvedFile.ts | 73 ++- .../diffs/src/components/VirtualizedFile.ts | 23 +- .../src/components/VirtualizedFileDiff.ts | 38 +- .../src/components/VirtulizerDevelopment.d.ts | 2 +- packages/diffs/src/react/CodeView.tsx | 322 ++++++----- packages/diffs/src/react/File.tsx | 6 +- packages/diffs/src/react/FileDiff.tsx | 9 +- packages/diffs/src/react/MultiFileDiff.tsx | 16 +- packages/diffs/src/react/PatchDiff.tsx | 9 +- packages/diffs/src/react/UnresolvedFile.tsx | 28 +- packages/diffs/src/react/types.ts | 12 +- .../src/react/utils/renderDiffChildren.tsx | 41 +- .../src/react/utils/renderFileChildren.tsx | 29 +- .../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 | 159 ++++-- packages/diffs/src/ssr/preloadFile.ts | 38 +- packages/diffs/src/ssr/preloadPatchFile.ts | 21 +- packages/diffs/src/types.ts | 46 +- .../src/utils/areManagedSnapshotsEqual.ts | 12 +- packages/diffs/src/utils/areOptionsEqual.ts | 18 +- .../src/utils/getDiffHunksRendererOptions.ts | 4 +- .../diffs/src/utils/getFileRendererOptions.ts | 4 +- .../test/CodeView.elementPooling.test.ts | 2 +- .../diffs/test/CodeView.headerFooter.test.ts | 2 +- packages/diffs/test/annotations.test.ts | 2 +- packages/diffs/test/hydration.test.ts | 38 +- .../test/reactCodeViewEditorOptions.test.ts | 20 +- .../diffs/test/reactEditorOptions.test.ts | 8 +- .../diffs/test/reactPartialHydration.test.ts | 10 +- packages/diffs/test/themeTypeUpdates.test.ts | 2 +- 50 files changed, 1168 insertions(+), 674 deletions(-) diff --git a/apps/demo/src/codeViewDemo.ts b/apps/demo/src/codeViewDemo.ts index 27ba2d8e7..f0dfc9679 100644 --- a/apps/demo/src/codeViewDemo.ts +++ b/apps/demo/src/codeViewDemo.ts @@ -40,7 +40,7 @@ interface CodeViewDraftCommentMetadata { interface CodeViewDemoInstance { instance: CodeView; - options: CodeViewOptions; + options: CodeViewOptions; } type CodeViewDemoAnnotation = @@ -48,13 +48,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 { @@ -93,7 +93,7 @@ export function renderDemoCodeView( setupCodeViewWrapper(wrapper); const items = createCodeViewItems(parsedPatches); - const options: CodeViewOptions = { + const options: CodeViewOptions = { // __devOnlyValidateItemHeights: true, theme, themeType, diff --git a/apps/diffshub/components/DiffsHubSidebar.tsx b/apps/diffshub/components/DiffsHubSidebar.tsx index 7b490e079..991be72af 100644 --- a/apps/diffshub/components/DiffsHubSidebar.tsx +++ b/apps/diffshub/components/DiffsHubSidebar.tsx @@ -71,7 +71,7 @@ interface DiffsHubSidebarProps { source: DiffsHubFileTreeSource; streaming: boolean; themeCycle: ThemeCycleControls; - viewerRef: RefObject | null>; + viewerRef: RefObject | null>; } export const DiffsHubSidebar = memo(function DiffsHubSidebar({ diff --git a/apps/diffshub/components/DiffsHubViewer.tsx b/apps/diffshub/components/DiffsHubViewer.tsx index 91b4791e2..2b4f2643b 100644 --- a/apps/diffshub/components/DiffsHubViewer.tsx +++ b/apps/diffshub/components/DiffsHubViewer.tsx @@ -40,7 +40,7 @@ function getNextItemVersion(item: CodeViewItem): number { } function updateViewerDiffItem( - viewer: CodeViewHandle, + viewer: CodeViewHandle, itemId: string, updateItem: (item: CodeViewDiffItem) => boolean ): CodeViewDiffItem | undefined { @@ -73,7 +73,7 @@ interface DiffsHubViewerProps { lineNumbers: boolean; scrollRef: RefObject; themeType: ThemeTypes; - viewerRef: RefObject | null>; + viewerRef: RefObject | null>; initialItems: CodeViewItem[]; loadDiffFiles?: FileDiffContentsLoader; onLineLinkChange(selection: CodeViewLineSelection | null): void; @@ -139,7 +139,7 @@ export const DiffsHubViewer = memo(function DiffsHubViewer({ ); const handleViewerRef = useStableCallback( - (viewer: CodeViewHandle | null) => { + (viewer: CodeViewHandle | null) => { viewerRef.current = viewer; if (viewer != null) { onViewerReady(); @@ -424,7 +424,7 @@ export const DiffsHubViewer = memo(function DiffsHubViewer({ // 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. @@ -453,7 +453,7 @@ export const DiffsHubViewer = memo(function DiffsHubViewer({ onLineSelectionEnd(range, context) { handleLineSelectionEnd(range, context.item); }, - }) satisfies CodeViewOptions, + }) satisfies CodeViewOptions, [ diffIndicators, diffStyle, diff --git a/apps/diffshub/components/ReviewUI.tsx b/apps/diffshub/components/ReviewUI.tsx index 3a656f098..36c0b84a4 100644 --- a/apps/diffshub/components/ReviewUI.tsx +++ b/apps/diffshub/components/ReviewUI.tsx @@ -135,7 +135,9 @@ function ReviewUIInner({ domain, initialUrl, path }: ReviewUIProps) { }); const scrollRef = useRef(null); - const viewerRef = useRef | null>(null); + const viewerRef = useRef | null>( + null + ); const loadDiffFiles = useMemo( () => domain == null && hasGitHubToken diff --git a/apps/diffshub/components/ThemedCodeView.tsx b/apps/diffshub/components/ThemedCodeView.tsx index 4fdd45ca7..32ea83c7c 100644 --- a/apps/diffshub/components/ThemedCodeView.tsx +++ b/apps/diffshub/components/ThemedCodeView.tsx @@ -13,7 +13,7 @@ import type { DiffThemeInput } from '@/lib/theme/diffThemeProps'; type ThemedCodeViewComponent = ( props: CodeViewProps & { - ref?: Ref>; + ref?: Ref>; theme?: DiffThemeInput; } ) => React.JSX.Element; @@ -27,7 +27,7 @@ export const ThemedCodeView: ThemedCodeViewComponent = < theme, ...props }: CodeViewProps & { - ref?: Ref>; + ref?: Ref>; theme?: DiffThemeInput; }): React.JSX.Element => { const diffTheme = useDiffThemeProps(theme); diff --git a/apps/diffshub/components/ThemedFile.tsx b/apps/diffshub/components/ThemedFile.tsx index 6e40b0203..058d0e962 100644 --- a/apps/diffshub/components/ThemedFile.tsx +++ b/apps/diffshub/components/ThemedFile.tsx @@ -7,9 +7,10 @@ import { useDiffThemeProps } from './useDiffThemeProps'; import { useWorkerDiffTheme } from './useWorkerDiffTheme'; import type { DiffThemeInput } from '@/lib/theme/diffThemeProps'; -interface ThemedFileProps< - LAnnotation = undefined, -> extends FileProps { +interface ThemedFileProps extends FileProps< + LAnnotation, + undefined +> { // Names-now override (omitted => follow the provider/source). theme?: DiffThemeInput; } @@ -33,7 +34,7 @@ export function ThemedFile({ [diffTheme, options] ); return ( - + {...props} disableWorkerPool={disableWorkerPool} options={themedOptions} diff --git a/apps/diffshub/components/ThemedFileDiff.tsx b/apps/diffshub/components/ThemedFileDiff.tsx index 02e083577..c00b2ebaf 100644 --- a/apps/diffshub/components/ThemedFileDiff.tsx +++ b/apps/diffshub/components/ThemedFileDiff.tsx @@ -7,9 +7,10 @@ import { useDiffThemeProps } from './useDiffThemeProps'; import { useWorkerDiffTheme } from './useWorkerDiffTheme'; import type { DiffThemeInput } from '@/lib/theme/diffThemeProps'; -interface ThemedFileDiffProps< - LAnnotation = undefined, -> extends FileDiffProps { +interface ThemedFileDiffProps extends FileDiffProps< + LAnnotation, + undefined +> { // Names-now override (omitted => follow the provider/source). theme?: DiffThemeInput; } @@ -33,7 +34,7 @@ export function ThemedFileDiff({ [diffTheme, options] ); return ( - + {...props} disableWorkerPool={disableWorkerPool} options={themedOptions} diff --git a/apps/diffshub/components/WorkerPoolStatus.tsx b/apps/diffshub/components/WorkerPoolStatus.tsx index 9643693d3..72391922e 100644 --- a/apps/diffshub/components/WorkerPoolStatus.tsx +++ b/apps/diffshub/components/WorkerPoolStatus.tsx @@ -36,7 +36,7 @@ class AutoScrollTester { private direction = 1; constructor( - private viewerRef: RefObject | null>, + private viewerRef: RefObject | null>, private onStateChange?: (running: boolean) => unknown ) {} @@ -102,7 +102,7 @@ interface WorkerPoolStatusProps { expanded: boolean; onToggle(): void; themeCycle: ThemeCycleControls; - viewerRef: RefObject | null>; + viewerRef: RefObject | null>; } export const WorkerPoolStatus = memo(function WorkerPoolStatus({ @@ -146,7 +146,7 @@ interface StatsDisplayProps { onToggle(): void; stats: WorkerStats; themeCycle: ThemeCycleControls; - viewerRef: RefObject | null>; + viewerRef: RefObject | null>; } // Map worker pool status to a single icon component + color so the legend row diff --git a/apps/diffshub/components/usePatchLoader.ts b/apps/diffshub/components/usePatchLoader.ts index 87537c975..25745efa9 100644 --- a/apps/diffshub/components/usePatchLoader.ts +++ b/apps/diffshub/components/usePatchLoader.ts @@ -60,7 +60,7 @@ interface UsePatchLoaderOptions { githubTokenVersion?: number | 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 applyDiffsHubLineHashTarget( - viewer: CodeViewHandle, + viewer: CodeViewHandle, target: DiffsHubLineHashTarget ): boolean { const item = viewer.getItem(target.itemId); @@ -566,7 +566,7 @@ function applyDiffsHubLineHashTarget( } function applyDiffsHubItemIdRename( - viewer: CodeViewHandle | null, + viewer: CodeViewHandle | null, rename: DiffsHubItemIdRename ): void { viewer?.updateItemId(rename.oldId, rename.newId); diff --git a/apps/diffshub/lib/test/reactOverrides.test.tsx b/apps/diffshub/lib/test/reactOverrides.test.tsx index eeceeeb6c..6fc9245d7 100644 --- a/apps/diffshub/lib/test/reactOverrides.test.tsx +++ b/apps/diffshub/lib/test/reactOverrides.test.tsx @@ -100,7 +100,7 @@ describe('React themed component overrides', () => { test('ThemedCodeView preserves caller themeType while applying the active theme pair', async () => { const container = document.createElement('div'); document.body.append(container); - const codeViewRef = createRef>(); + const codeViewRef = createRef>(); let root: Root | undefined; await act(async () => { diff --git a/apps/docs/app/(diffs)/_home/mockData.ts b/apps/docs/app/(diffs)/_home/mockData.ts index a48b92ded..d584eaa7d 100644 --- a/apps/docs/app/(diffs)/_home/mockData.ts +++ b/apps/docs/app/(diffs)/_home/mockData.ts @@ -14,7 +14,10 @@ import { GENERATED_AUI_SESSIONS } from './mockData.generated'; // attached editor. Sharing one constant also keeps the server and client // diffStyle in lockstep so the prerendered HTML always matches what the client // renders. -export const AUI_DIFF_OPTIONS: DiffBasePropsReact['options'] = { +export const AUI_DIFF_OPTIONS: DiffBasePropsReact< + undefined, + undefined +>['options'] = { theme: DEFAULT_THEMES, themeType: 'dark', disableFileHeader: true, 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; // Everything the React layer portals into, published together so a single store // subscription drives per-item slots AND the global header/footer. -export interface CodeViewSlotSnapshot { +export interface CodeViewSlotSnapshot { // Rendered items that need React-managed slot content (per-item headers, // annotations, gutter utilities), or undefined when none. - items: CodeViewRenderedItem[] | undefined; + items: CodeViewRenderedItem[] | undefined; // The always-rendered header/footer host elements React portals into, or // undefined when the corresponding renderCodeViewHeader/Footer callback is not // set. Because these live in the snapshot, a host mounting/unmounting changes @@ -190,18 +192,18 @@ export interface CodeViewLineSelection { range: SelectedLineRange; } -export interface CodeViewCoordinator { +export interface CodeViewCoordinator { hasHeaderRenderers: boolean; hasAnnotationRenderer: boolean; hasGutterRenderer: boolean; onSnapshotChange( - snapshot: CodeViewSlotSnapshot | undefined + snapshot: CodeViewSlotSnapshot | undefined ): void; } -export type CodeViewScrollListener = ( +export type CodeViewScrollListener = ( scrollTop: number, - viewer: CodeView + viewer: CodeView ) => void; type OverloadCallbackArgs = TCallback extends ( @@ -218,51 +220,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, - TKey extends keyof FileOptions, + 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]> + >; }; export const CODE_VIEW_DIFF_OPTION_KEYS = [ @@ -322,8 +337,8 @@ export const CODE_VIEW_FILE_OPTION_KEYS = [ type CodeViewFileOptionKeys = (typeof CODE_VIEW_FILE_OPTION_KEYS)[number]; -type CodeViewPassThroughOptions = Pick< - FileDiffOptions, +type CodeViewPassThroughOptions = Pick< + FileDiffOptions, CodeViewDiffOptionKeys >; @@ -331,38 +346,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', @@ -417,17 +438,18 @@ interface CodeViewItemOptionsState { type CodeViewItemOptions< LAnnotation, + LDecoration, TMode extends CodeViewMode, -> = CodeViewModeOptions & { +> = CodeViewModeOptions & { [CODE_VIEW_ITEM_OPTIONS_STATE]: CodeViewItemOptionsState; }; // One document change published by an item's editor, as delivered to the // onItemEditChange/onItemEditComplete options. -interface CodeViewItemEditChange { +interface CodeViewItemEditChange { // Item snapshot from the time of the change; used as a fallback when the // session ends because the item was removed from the CodeView. - item: CodeViewItem; + item: CodeViewItem; file: FileContents; lineAnnotations: | LineAnnotation[] @@ -440,19 +462,23 @@ interface CodeViewItemEditChange { // updateItemId) and caches each document change in `lastChange` so the final // contents can be published through onItemEditComplete when the session ends // — even if the editor is detached (scrolled out) at that moment. -interface CodeViewItemEditorState { +interface CodeViewItemEditorState { id: string; - lastChange?: CodeViewItemEditChange; + lastChange?: CodeViewItemEditChange; } // Editor bookkeeping for one edit-mode item. -interface CodeViewItemEditorRecord { +interface CodeViewItemEditorRecord { editor: DiffsEditor; - state: CodeViewItemEditorState; + state: CodeViewItemEditorState; } -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 @@ -469,24 +495,30 @@ function defineOptionsState( // sometimes this can trigger on the options prototype directly which won't // have access to an internal state. This forces us to be defensive later on // which is important -function getItemOptionsState( - options: CodeViewModeOptions +function getItemOptionsState< + LAnnotation, + LDecoration, + TMode extends CodeViewMode, +>( + options: CodeViewModeOptions ): CodeViewItemOptionsState | undefined { - 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 >; }; @@ -508,11 +540,11 @@ function defineItemOption( }); } -export interface CodeViewOptions +export interface CodeViewOptions extends - CodeViewPassThroughOptions, - CodeViewSharedCallbackOptions, - CodeViewSelectionCallbackOptions { + CodeViewPassThroughOptions, + CodeViewSharedCallbackOptions, + CodeViewSelectionCallbackOptions { hunkSeparators?: Exclude; itemMetrics?: Partial; pointerEventsOnScroll?: boolean; @@ -539,7 +571,7 @@ export interface CodeViewOptions * resolved by CodeView. */ onItemEditChange?( - item: CodeViewItem, + item: CodeViewItem, file: FileContents, lineAnnotations?: | LineAnnotation[] @@ -559,7 +591,7 @@ export interface CodeViewOptions * with `edit: false`. */ onItemEditComplete?( - item: CodeViewItem, + item: CodeViewItem, file: FileContents, lineAnnotations?: | LineAnnotation[] @@ -603,10 +635,10 @@ interface SpringStepResult { velocity: number; } -function assertEditorFactory( - createEditor: CodeViewOptions['createEditor'] +function assertEditorFactory( + createEditor: CodeViewOptions['createEditor'] ): asserts createEditor is NonNullable< - CodeViewOptions['createEditor'] + CodeViewOptions['createEditor'] > { if (createEditor == null) { throw new Error( @@ -652,12 +684,12 @@ type PendingScrollTarget = | PendingRangeTarget | PendingItemTarget; -type CodeViewItemMap = Map< +type CodeViewItemMap = Map< string, - CodeViewContextItem + CodeViewContextItem >; -export class CodeView { +export class CodeView { static __STOP = false; static __lastScrollPosition = 0; @@ -667,8 +699,8 @@ export class CodeView { intersectionObserverMargin: 0, resizeDebugging: false, }; - private items: CodeViewContextItem[] = []; - private idToItem: CodeViewItemMap = new Map(); + private items: CodeViewContextItem[] = []; + private idToItem: CodeViewItemMap = new Map(); private selectedLines: CodeViewLineSelection | null = null; // One editor per edit-mode item, created lazily via options.createEditor. // Entries survive virtualization unmounts so a remounted item re-attaches @@ -676,21 +708,30 @@ export class CodeView { // bound to a mounted instance. Each record's `id` is mutable so // updateItemId can keep the editor's onChange closure resolving the // current item (mirroring updateItemOptionsId for item options state). - private itemEditors: Map> = - new Map(); + private itemEditors: Map< + string, + CodeViewItemEditorRecord + > = new Map(); private attachedEditors: Set = new Set(); // 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: CodeViewSlotSnapshot | undefined; - private scrollListeners: Set> = new Set(); + private slotCoordinator: + | CodeViewCoordinator + | undefined; + private slotSnapshot: + | CodeViewSlotSnapshot + | undefined; + private scrollListeners: Set< + CodeViewScrollListener + > = new Set(); private scrollHeight = 0; private containerHeight = -1; private scrollTop: number = 0; @@ -711,8 +752,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. @@ -766,13 +810,15 @@ 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 isReadySubscription: (() => void) | undefined; private isContainerManaged: boolean; constructor( - options: CodeViewOptions = { theme: DEFAULT_THEMES }, + options: CodeViewOptions = { + theme: DEFAULT_THEMES, + }, workerManager?: WorkerPoolManager | undefined, isContainerManaged = false ) { @@ -841,7 +887,7 @@ export class CodeView { } private validateRenderedItemHeight( - item: CodeViewContextItem + item: CodeViewContextItem ): void { if (!this.shouldValidateItemHeights() || item.element == null) { return; @@ -1280,7 +1326,9 @@ export class CodeView { return element; } - private releaseRenderedItem(item: CodeViewContextItem): void { + private releaseRenderedItem( + item: CodeViewContextItem + ): void { const { element } = item; if (element != null && this.renderedItemOwnsFocus(element)) { this.shouldFixContainerFocus = true; @@ -1475,7 +1523,9 @@ 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; } @@ -1488,7 +1538,7 @@ export class CodeView { return this.itemEditors.get(itemId)?.editor; } - 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}"`); @@ -1547,11 +1597,13 @@ export class CodeView { return true; } - public addItem(input: CodeViewItem): void { + public addItem(input: CodeViewItem): void { this.addItems([input]); } - public addItems(inputs: readonly CodeViewItem[]): void { + public addItems( + inputs: readonly CodeViewItem[] + ): void { this.appendItemsInternal(inputs); this.syncItemEditors(); this.syncSelection(); @@ -1564,7 +1616,7 @@ export class CodeView { return false; } - const nextItems: CodeViewItem[] = []; + const nextItems: CodeViewItem[] = []; for (const current of this.items) { if (current !== item) { nextItems.push(current.item); @@ -1574,8 +1626,12 @@ export class CodeView { return true; } - public setItems(items: readonly CodeViewItem[]): void { - let removedItemsById: Readonly> | undefined; + public setItems( + items: readonly CodeViewItem[] + ): void { + let removedItemsById: + | Readonly> + | undefined; if (this.items.length === 0) { this.appendItemsInternal(items); } else if (!this.tryAppendItems(items)) { @@ -1592,7 +1648,7 @@ export class CodeView { * once at the end. */ private appendItemsInternal( - inputs: readonly CodeViewItem[], + inputs: readonly CodeViewItem[], render = true ): void { if (inputs.length === 0) { @@ -1645,7 +1701,9 @@ export class CodeView { this.invalidateElementPool(); } - public setOptions(options: CodeViewOptions | undefined): void { + public setOptions( + options: CodeViewOptions | undefined + ): void { if (options == null) { return; } @@ -1710,7 +1768,8 @@ export class CodeView { } public capturePendingLayoutAnchor( - nextItems: Readonly> = this.idToItem + nextItems: Readonly> = this + .idToItem ): void { if ( this.root == null || @@ -1780,7 +1839,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 @@ -1819,13 +1880,13 @@ export class CodeView { return this.footer.element; } - 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]; @@ -1858,7 +1919,7 @@ export class CodeView { } public setSlotCoordinator( - coordinator?: CodeViewCoordinator + coordinator?: CodeViewCoordinator ): boolean { if (coordinator === this.slotCoordinator) { return false; @@ -1869,8 +1930,8 @@ export class CodeView { } public getSlotSnapshot( - coordinator: CodeViewCoordinator - ): CodeViewSlotSnapshot | undefined { + coordinator: CodeViewCoordinator + ): CodeViewSlotSnapshot | undefined { return this.buildSlotSnapshot(coordinator); } @@ -1878,8 +1939,8 @@ export class CodeView { // into a single snapshot. Returns undefined only when there is nothing for // React to portal. private buildSlotSnapshot( - coordinator: CodeViewCoordinator - ): CodeViewSlotSnapshot | undefined { + coordinator: CodeViewCoordinator + ): CodeViewSlotSnapshot | undefined { const items = getSlotItems(this.getRenderedItems(), coordinator); const { element: header } = this.header; const { element: footer } = this.footer; @@ -1890,7 +1951,7 @@ export class CodeView { } public subscribeToScroll( - listener: CodeViewScrollListener + listener: CodeViewScrollListener ): () => void { this.scrollListeners.add(listener); return () => { @@ -1899,7 +1960,9 @@ export class CodeView { } public getLocalTopForInstance( - instance: VirtualizedFile | VirtualizedFileDiff + instance: + | VirtualizedFile + | VirtualizedFileDiff ): number { const item = this.instanceToItem.get(instance); if (item == null) { @@ -1919,13 +1982,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, @@ -1942,10 +2005,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, @@ -1962,7 +2025,7 @@ export class CodeView { element: undefined, renderedOptionsRevision: this.renderOptionsRevision, instance, - } satisfies CodeViewFileItemContext; + } satisfies CodeViewFileItemContext; } private applySelectedLines( @@ -2010,7 +2073,9 @@ export class CodeView { } // Collapsing an edited item suspends editing until it expands again. - private isItemInEditMode(item: CodeViewContextItem): boolean { + private isItemInEditMode( + item: CodeViewContextItem + ): boolean { return item.item.edit === true && item.item.collapsed !== true; } @@ -2023,7 +2088,9 @@ export class CodeView { * document; the renderers keep the host's file/diff data in sync with the * session so the remount paints the edited text. */ - private attachItemEditor(item: CodeViewContextItem): void { + private attachItemEditor( + item: CodeViewContextItem + ): void { const { id } = item.item; const { createEditor } = this.options; if ( @@ -2038,13 +2105,13 @@ export class CodeView { let createdEditor = false; try { if (record == null) { - assertEditorFactory(createEditor); + assertEditorFactory(createEditor); // The onChange closure resolves the owning item through the record // state's current id (not the id captured here) so updateItemId // renames keep it pointed at the right item. It also reads the change // callback off this.options at invocation time so later setOptions // swaps aren't stranded on the callback captured at creation. - const state: CodeViewItemEditorState = { id }; + const state: CodeViewItemEditorState = { id }; const editor = createEditor({ onChange: (file, lineAnnotations) => { const latest = this.idToItem.get(state.id); @@ -2085,13 +2152,13 @@ export class CodeView { * holding. */ private syncItemEditors( - removedItems?: Readonly> + removedItems?: Readonly> ): void { if (this.itemEditors.size === 0) { return; } - const completions: CodeViewItemEditChange[] = []; + const completions: CodeViewItemEditChange[] = []; for (const [id, record] of this.itemEditors) { const item = this.idToItem.get(id); const removedItem = removedItems?.get(id); @@ -2168,15 +2235,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]); } // Mapped options: served from CodeView-level names or per-item state @@ -2204,15 +2270,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]); } // Mapped options: served from CodeView-level names or per-item state @@ -2245,12 +2313,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, }; @@ -2258,21 +2327,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 { const state = getItemOptionsState(options); @@ -2285,38 +2359,38 @@ 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 ); if (state == null) { return undefined; @@ -2325,7 +2399,7 @@ export class CodeView { // 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[]) => { @@ -2334,12 +2408,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; } @@ -2353,19 +2432,19 @@ 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) => { const state = getItemOptionsState( - receiver as CodeViewModeOptions + receiver as CodeViewModeOptions ); if (state == null) { return undefined; @@ -2375,7 +2454,7 @@ export class CodeView { // 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) => { @@ -2397,11 +2476,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; } @@ -2425,7 +2508,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}"` @@ -2441,7 +2526,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; } @@ -2490,20 +2577,22 @@ export class CodeView { * membership, or versioned item data changes. */ private reconcileItems( - items: readonly CodeViewItem[] - ): Readonly> | undefined { + items: readonly CodeViewItem[] + ): Readonly> | undefined { 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(); - const removedItemsById: CodeViewItemMap = new Map(); + const removedItemsById: CodeViewItemMap = + new Map(); let firstDirtyIndex: number | undefined; for (let index = 0; index < items.length; index++) { @@ -2584,8 +2673,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( @@ -2945,7 +3034,7 @@ export class CodeView { } private getLineScrollPosition( - item: CodeViewContextItem, + item: CodeViewContextItem, target: CodeViewLineScrollTarget ): LineScrollPosition | undefined { if (item.type === 'diff') { @@ -2956,7 +3045,7 @@ export class CodeView { } private getRangeScrollPosition( - item: CodeViewContextItem, + item: CodeViewContextItem, target: CodeViewRangeScrollTarget ): LineScrollPosition | undefined { const { range } = target; @@ -3223,7 +3312,9 @@ export class CodeView { const hostsChanged = this.reconcileHeaderFooterHosts(); let prevElement: HTMLElement | undefined; - const updatedItems = new Set>(); + const updatedItems = new Set< + CodeViewContextItem + >(); const startingIndex = this.findFirstVisibleIndex(top); const lastRenderedIndex = this.findLastVisibleIndex(bottom); @@ -3370,7 +3461,7 @@ export class CodeView { }; private flushManagers( - updatedItems: Set> + updatedItems: Set> ): void { for (const item of updatedItems) { item.instance.flushManagers(); @@ -3453,7 +3544,7 @@ export class CodeView { } private reconcileRenderedItems( - updatedItems?: Set> + updatedItems?: Set> ): void { const { firstIndex, lastIndex } = this.renderState; if (firstIndex === -1) { @@ -3674,10 +3765,12 @@ export class CodeView { */ private getScrollAnchor( scrollTop: number, - availableItems: ReadonlyMap> = this - .idToItem + availableItems: ReadonlyMap< + string, + CodeViewContextItem + > = this.idToItem ): ScrollAnchor | undefined { - let skippedItem: CodeViewContextItem | undefined; + let skippedItem: CodeViewContextItem | undefined; const { pendingLayoutAnchor } = this; if (pendingLayoutAnchor != null) { @@ -4070,8 +4163,8 @@ export class CodeView { } } -function prepareItemInstance( - item: CodeViewContextItem +function prepareItemInstance( + item: CodeViewContextItem ): number { item.instance.cleanUp(true); if (item.type === 'diff') { @@ -4091,9 +4184,9 @@ function prepareItemInstance( } } -function shouldClearPool( - previousOptions: CodeViewOptions, - nextOptions: CodeViewOptions +function shouldClearPool( + previousOptions: CodeViewOptions, + nextOptions: CodeViewOptions ): boolean { return ( !areThemesEqual( @@ -4106,9 +4199,9 @@ function shouldClearPool( ); } -function hasItemLayoutOptionChanged( - previousOptions: CodeViewOptions, - nextOptions: CodeViewOptions +function hasItemLayoutOptionChanged( + previousOptions: CodeViewOptions, + nextOptions: CodeViewOptions ): boolean { return ( (previousOptions.overflow ?? 'scroll') !== @@ -4133,9 +4226,9 @@ function hasItemLayoutOptionChanged( ); } -function hasCodeViewDiffEstimateOptionChanged( - previousOptions: CodeViewOptions, - nextOptions: CodeViewOptions +function hasCodeViewDiffEstimateOptionChanged( + previousOptions: CodeViewOptions, + nextOptions: CodeViewOptions ): boolean { return ( (previousOptions.disableFileHeader ?? false) !== @@ -4180,8 +4273,8 @@ function formatSelectedLinePoint( return `${side === 'deletions' ? 'D' : 'A'}${lineNumber}`; } -function renderItem( - item: CodeViewContextItem, +function renderItem( + item: CodeViewContextItem, fileContainer?: HTMLElement, forceRender = false ): boolean { @@ -4192,6 +4285,7 @@ function renderItem( fileDiff: item.item.fileDiff, forceRender, lineAnnotations: item.item.annotations ?? [], + decorations: item.item.decorations, }); } else { return item.instance.render({ @@ -4200,6 +4294,7 @@ function renderItem( file: item.item.file, forceRender, lineAnnotations: item.item.annotations ?? [], + decorations: item.item.decorations, }); } } @@ -4227,18 +4322,20 @@ function syncRenderedItemOrder( } } -function hasAnnotations(item: CodeViewItem): boolean { +function hasAnnotations( + item: CodeViewItem +): boolean { return (item.annotations?.length ?? 0) > 0; } -function getSlotItems( - renderedItems: CodeViewRenderedItem[], +function getSlotItems( + renderedItems: CodeViewRenderedItem[], { hasHeaderRenderers, hasAnnotationRenderer, hasGutterRenderer, - }: CodeViewCoordinator -): CodeViewRenderedItem[] | undefined { + }: CodeViewCoordinator +): CodeViewRenderedItem[] | undefined { if (renderedItems.length === 0) { return undefined; } @@ -4251,7 +4348,7 @@ function getSlotItems( return undefined; } - const slotSnapshot: CodeViewRenderedItem[] = []; + const slotSnapshot: CodeViewRenderedItem[] = []; for (const renderedItem of renderedItems) { if (hasAnnotations(renderedItem.item)) { diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 99fccac34..90ea4a74d 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -31,6 +31,7 @@ import type { DiffsTextDocument, EditorActiveLineOptions, FileContents, + FileDecorationItem, HighlightedToken, LineAnnotation, PostRenderPhase, @@ -69,7 +70,7 @@ import { DiffsContainerLoaded } from './web-components'; const EMPTY_STRINGS: string[] = ['']; -export interface FileRenderProps { +export interface FileRenderProps { file: FileContents; fileContainer?: HTMLElement; containerWrapper?: HTMLElement; @@ -77,18 +78,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; @@ -110,7 +112,7 @@ export interface FileOptions onPostRender?( node: HTMLElement, - instance: File, + instance: File, phase: PostRenderPhase ): unknown; } @@ -125,15 +127,17 @@ interface ColumnElements { content: HTMLElement; } -interface HydrationSetup { +interface HydrationSetup { file: FileContents; lineAnnotations: LineAnnotation[] | undefined; + decorations: FileDecorationItem[] | undefined; } let instanceId = -1; export class File< LAnnotation = undefined, + LDecoration = undefined, > implements DiffsEditableComponent { static LoadedCustomComponent: boolean = DiffsContainerLoaded; @@ -166,13 +170,14 @@ export class File< protected headerFilenameSuffix: 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; @@ -182,11 +187,13 @@ export class File< protected editor: DiffsEditor | undefined; constructor( - public options: FileOptions = { theme: DEFAULT_THEMES }, + public options: FileOptions = { + theme: DEFAULT_THEMES, + }, private workerManager?: WorkerPoolManager | undefined, private isContainerManaged = false ) { - this.fileRenderer = new FileRenderer( + this.fileRenderer = new FileRenderer( options, this.handleHighlightRender, this.workerManager @@ -221,7 +228,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; @@ -232,7 +241,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 }; } @@ -316,6 +327,10 @@ export class File< return { ...this.options, ...this.fileRenderer.getEffectiveCodeOptions() }; } + public setDecorations(decorations: FileDecorationItem[]): void { + this.decorations = decorations; + } + public flushManagers(): void { if (!this.managersDirty || this.pre == null) { this.managersDirty = false; @@ -401,13 +416,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; if (!this.enabled) { throw new Error( @@ -432,7 +448,7 @@ export class File< } // Otherwise orchestrate our setup. else { - this.hydrationSetup({ file, lineAnnotations }); + this.hydrationSetup({ file, lineAnnotations, decorations }); } if (!preventEmit) { this.emitPostRender(); @@ -494,11 +510,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; } @@ -609,7 +628,7 @@ export class File< ); } - public render(props: FileRenderProps): boolean { + public render(props: FileRenderProps): boolean { if (!this.enabled) { throw new Error( 'File.render: attempting to call render after cleaned up' @@ -632,8 +651,9 @@ export class File< containerWrapper, deferManagers = false, lineAnnotations, + decorations, renderRange, - }: FileRenderProps): boolean { + }: FileRenderProps): boolean { // postpone background tokenizing to next frame for avoiding UI freeze // during render this.editor?.__postponeBgTokenizeToNextFrame(); @@ -642,11 +662,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) || this.fileRenderer.hasUnkeyedFileContentsChanged(file); @@ -656,7 +682,8 @@ export class File< areRenderRangesEqual(nextRenderRange, this.renderRange) && !didFileChange && !annotationsChanged && - !themeChanged + !themeChanged && + !decorationsChanged ) { return this.applyCachedThemeState(themeType); } @@ -671,7 +698,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 294926785..a5280f06f 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -39,6 +39,7 @@ import type { BaseCodeOptions, BaseDiffOptions, CustomPreProperties, + DiffDecorationItem, DiffLineAnnotation, DiffsEditableComponent, DiffsEditor, @@ -131,7 +132,7 @@ function canHydrateDiff(fileDiff: FileDiffMetadata): boolean { ); } -export interface FileDiffRenderBaseProps { +export interface FileDiffRenderBaseProps { fileDiff?: FileDiffMetadata; deferManagers?: boolean; forceRender?: boolean; @@ -139,14 +140,15 @@ export interface FileDiffRenderBaseProps { fileContainer?: HTMLElement; containerWrapper?: HTMLElement; lineAnnotations?: DiffLineAnnotation[]; + decorations?: DiffDecorationItem[]; renderRange?: RenderRange; } -export type FileDiffRenderProps = - FileDiffRenderBaseProps & MaybeDiffFileInput; +export type FileDiffRenderProps = + FileDiffRenderBaseProps & MaybeDiffFileInput; -export type FileDiffHydrationProps = Omit< - FileDiffRenderBaseProps, +export type FileDiffHydrationProps = Omit< + FileDiffRenderBaseProps, 'fileContainer' > & MaybeDiffFileInput & { @@ -156,7 +158,10 @@ export type FileDiffHydrationProps = Omit< export type FileDiffType = 'file-diff' | 'unresolved-file'; -export interface FileDiffOptions +export interface FileDiffOptions< + LAnnotation = undefined, + LDecoration = undefined, +> extends Omit, InteractionManagerBaseOptions<'diff'> { @@ -167,7 +172,7 @@ export interface FileDiffOptions */ | (( hunk: HunkData, - instance: FileDiff + instance: FileDiff ) => HTMLElement | DocumentFragment | null | undefined); disableFileHeader?: boolean; renderHeaderPrefix?: RenderHeaderPrefixCallback; @@ -189,7 +194,7 @@ export interface FileDiffOptions onPostRender?( node: HTMLElement, - instance: FileDiff, + instance: FileDiff, phase: PostRenderPhase ): unknown; } @@ -231,15 +236,17 @@ interface PendingFileLoad { promise: Promise; } -type HydrationSetup = { +type HydrationSetup = { fileDiff: FileDiffMetadata | undefined; lineAnnotations: DiffLineAnnotation[] | undefined; + decorations: DiffDecorationItem[] | undefined; } & MaybeDiffFileInput; let instanceId = -1; export class FileDiff< LAnnotation = undefined, + LDecoration = undefined, > implements DiffsEditableComponent { // NOTE(amadeus): We sorta need this to ensure the web-component file is // properly loaded @@ -272,7 +279,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'>; @@ -280,6 +287,7 @@ export class FileDiff< protected annotationCache: Map> = new Map(); protected lineAnnotations: DiffLineAnnotation[] = []; + protected decorations: DiffDecorationItem[] = []; protected managersDirty = false; protected deletionFile?: FileContents | null; @@ -305,7 +313,9 @@ export class FileDiff< protected deferredEditorActiveLine: DeferredEditorActiveLineWrite | undefined; constructor( - public options: FileDiffOptions = { theme: DEFAULT_THEMES }, + public options: FileDiffOptions = { + theme: DEFAULT_THEMES, + }, protected workerManager?: WorkerPoolManager | undefined, protected isContainerManaged = false ) { @@ -333,14 +343,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, @@ -439,7 +449,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; @@ -461,7 +473,9 @@ export class FileDiff< ); } - private mergeOptions(options: Partial>): void { + private mergeOptions( + options: Partial> + ): void { this.options = { ...this.options, ...options }; } @@ -514,6 +528,10 @@ export class FileDiff< this.lineAnnotations = lineAnnotations; } + public setDecorations(decorations: DiffDecorationItem[]): void { + this.decorations = decorations; + } + private canPartiallyRender( forceRender: boolean, annotationsChanged: boolean, @@ -721,9 +739,10 @@ export class FileDiff< prerenderedHTML, preventEmit = false, lineAnnotations, + decorations, fileDiff, ...fileInputProps - }: FileDiffHydrationProps): void { + }: FileDiffHydrationProps): void { if (!this.enabled) { throw new Error( 'FileDiff.hydrate: attempting to call hydrate after cleaned up' @@ -754,6 +773,7 @@ export class FileDiff< ...fileInputProps, fileContainer, lineAnnotations, + decorations, fileDiff, preventEmit: true, }); @@ -763,6 +783,7 @@ export class FileDiff< this.hydrationSetup({ fileDiff, lineAnnotations, + decorations, ...fileInput, }); } @@ -841,10 +862,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 = @@ -858,6 +881,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(); @@ -984,11 +1008,12 @@ export class FileDiff< forceRender = false, preventEmit = false, lineAnnotations, + decorations, fileContainer, containerWrapper, renderRange, ...fileInputProps - }: FileDiffRenderProps): boolean { + }: FileDiffRenderProps): boolean { const fileInput = getDiffFileInput(fileInputProps, 'FileDiff.render'); const oldFile = fileInput?.oldFile; const newFile = fileInput?.newFile; @@ -1020,6 +1045,7 @@ export class FileDiff< const nextRenderRange = collapsed ? undefined : renderRange; const themeChanged = this.hasThemeChanged(); const hasFileInput = fileInput != null; + const nextDecorations = decorations; const filesDidChange = hasFileInput && (!areOptionalFilesEqual(oldFile, this.deletionFile) || @@ -1030,6 +1056,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 && @@ -1037,6 +1068,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) || @@ -1087,6 +1119,9 @@ export class FileDiff< if (lineAnnotations != null) { this.setLineAnnotations(lineAnnotations); } + if (nextDecorations != null) { + this.decorations = nextDecorations; + } if (this.fileDiff == null) { return false; } @@ -1107,6 +1142,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 20e7b13d8..81a39ec19 100644 --- a/packages/diffs/src/components/UnresolvedFile.ts +++ b/packages/diffs/src/components/UnresolvedFile.ts @@ -35,29 +35,35 @@ import { type FileDiffRenderBaseProps, } 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, @@ -68,16 +74,17 @@ export interface UnresolvedFileOptions extends Omit< export interface UnresolvedFileRenderProps< LAnnotation, -> extends FileDiffRenderBaseProps { + LDecoration, +> extends FileDiffRenderBaseProps { file?: FileContents; actions?: (MergeConflictDiffAction | undefined)[]; 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 +121,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 +138,7 @@ export class UnresolvedFile< new Map(); constructor( - public override options: UnresolvedFileOptions = { + public override options: UnresolvedFileOptions = { theme: DEFAULT_THEMES, }, workerManager?: WorkerPoolManager | undefined, @@ -141,7 +149,7 @@ export class UnresolvedFile< } override setOptions( - options: UnresolvedFileOptions | undefined + options: UnresolvedFileOptions | undefined ): void { if (options == null) { return; @@ -182,9 +190,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 @@ -193,7 +201,7 @@ export class UnresolvedFile< } protected override getHunksRendererOptions( - options: UnresolvedFileOptions + options: UnresolvedFileOptions ): UnresolvedFileHunksRendererOptions { return getUnresolvedDiffHunksRendererOptions(options, this.options); } @@ -345,13 +353,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, @@ -381,7 +392,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(); } @@ -398,13 +413,16 @@ export class UnresolvedFile< this.render({ forceRender: true, renderRange: this.renderRange }); } - override render(props: UnresolvedFileRenderProps = {}): boolean { + override render( + props: UnresolvedFileRenderProps = {} + ): boolean { const { file, fileDiff, actions, markerRows, lineAnnotations, + decorations, preventEmit = false, ...rest } = props; @@ -422,6 +440,7 @@ export class UnresolvedFile< ...rest, fileDiff: source.fileDiff, lineAnnotations, + decorations, preventEmit: true, }); if (didRender) { @@ -862,9 +881,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 { const merged = { ...baseOptions, ...options }; return { diff --git a/packages/diffs/src/components/VirtualizedFile.ts b/packages/diffs/src/components/VirtualizedFile.ts index 864c05bcb..f3ad1553b 100644 --- a/packages/diffs/src/components/VirtualizedFile.ts +++ b/packages/diffs/src/components/VirtualizedFile.ts @@ -51,9 +51,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') !== @@ -69,7 +69,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; @@ -86,8 +87,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 @@ -148,7 +149,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.' @@ -691,7 +694,7 @@ export class VirtualizedFile< forceRender = false, lineAnnotations, ...props - }: FileRenderProps): boolean { + }: FileRenderProps): boolean { const didFileChange = this.file == null || !areFilesEqual(this.file, file) || @@ -902,7 +905,9 @@ export class VirtualizedFile< return this.virtualizer.type === 'simple' ? this.virtualizer : undefined; } - private getAdvancedVirtualizer(): CodeView | undefined { + private getAdvancedVirtualizer(): + | CodeView + | undefined { return this.virtualizer.type === 'advanced' ? this.virtualizer : undefined; } diff --git a/packages/diffs/src/components/VirtualizedFileDiff.ts b/packages/diffs/src/components/VirtualizedFileDiff.ts index 76b20e160..662a7c864 100644 --- a/packages/diffs/src/components/VirtualizedFileDiff.ts +++ b/packages/diffs/src/components/VirtualizedFileDiff.ts @@ -110,7 +110,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; @@ -127,7 +128,7 @@ 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; @@ -136,8 +137,8 @@ export class VirtualizedFileDiff< private pendingExpansions: PendingExpansion[] | undefined; constructor( - options: FileDiffOptions | undefined, - virtualizer: Virtualizer | CodeView, + options: FileDiffOptions | undefined, + virtualizer: Virtualizer | CodeView, metrics?: Partial, workerManager?: WorkerPoolManager, isContainerManaged = false @@ -228,13 +229,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); @@ -1189,7 +1191,7 @@ export class VirtualizedFileDiff< forceRender = false, lineAnnotations, ...fileInputProps - }: FileDiffRenderProps = {}): boolean { + }: FileDiffRenderProps = {}): boolean { const fileInput = getDiffFileInput( fileInputProps, 'VirtualizedFileDiff.render' @@ -1340,7 +1342,9 @@ export class VirtualizedFileDiff< return this.virtualizer.type === 'simple' ? this.virtualizer : undefined; } - private getAdvancedVirtualizer(): CodeView | undefined { + private getAdvancedVirtualizer(): + | CodeView + | undefined { return this.virtualizer.type === 'advanced' ? this.virtualizer : undefined; } @@ -2092,9 +2096,9 @@ function getHunkMetadataOffsets({ return offsets; } -function hasDiffLayoutOptionChanged( - previousOptions: FileDiffOptions, - nextOptions: FileDiffOptions +function hasDiffLayoutOptionChanged( + previousOptions: FileDiffOptions, + nextOptions: FileDiffOptions ): boolean { return ( (previousOptions.diffStyle ?? 'split') !== @@ -2122,9 +2126,9 @@ function hasDiffLayoutOptionChanged( ); } -function hasDiffEstimateOptionChanged( - previousOptions: FileDiffOptions, - nextOptions: FileDiffOptions +function hasDiffEstimateOptionChanged( + previousOptions: FileDiffOptions, + nextOptions: FileDiffOptions ): boolean { return ( (previousOptions.disableFileHeader ?? false) !== @@ -2153,8 +2157,10 @@ function canHydrateCollapsedContext( ); } -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 7aee20bdc..7aee2a7af 100644 --- a/packages/diffs/src/react/CodeView.tsx +++ b/packages/diffs/src/react/CodeView.tsx @@ -49,13 +49,16 @@ type CodeViewGutterUtilityGetter = | (() => GetHoveredLineResult<'file'> | undefined) | (() => GetHoveredLineResult<'diff'> | undefined); -export type CodeViewReactOptions = Omit< - CodeViewOptions, +export type CodeViewReactOptions< + LAnnotation = undefined, + LDecoration = undefined, +> = Omit< + CodeViewOptions, 'controlledSelection' | 'createEditor' | 'onSelectedLinesChange' >; -interface CodeViewBaseProps { - options?: CodeViewReactOptions; +interface CodeViewBaseProps { + options?: CodeViewReactOptions; /** * Creation-time options passed to the nearest EditProvider factory. * CodeView supplies its item-specific change callback. @@ -67,7 +70,10 @@ interface CodeViewBaseProps { disableWorkerPool?: boolean; selectedLines?: CodeViewLineSelection | null; onSelectedLinesChange?(selection: CodeViewLineSelection | null): void; - onScroll?(scrollTop: number, viewer: CodeViewClass): void; + onScroll?( + scrollTop: number, + viewer: CodeViewClass + ): void; /** Render a non-virtualized node at the very start of the scroll content, * before the first item. Always rendered; scrolls with the content. */ renderCodeViewHeader?(): ReactNode; @@ -76,7 +82,7 @@ interface CodeViewBaseProps { renderCodeViewFooter?(): ReactNode; /** Called with the owning item on every edited-document change. */ onItemEditChange?( - item: CodeViewItem, + item: CodeViewItem, file: FileContents, lineAnnotations?: | LineAnnotation[] @@ -90,88 +96,96 @@ interface CodeViewBaseProps { * since the contents changed) along with `edit: false`. */ onItemEditComplete?( - item: CodeViewItem, + item: CodeViewItem, file: FileContents, lineAnnotations?: | LineAnnotation[] | DiffLineAnnotation[] ): void; - renderCustomHeader?(item: CodeViewItem): ReactNode; - renderHeaderPrefix?(item: CodeViewItem): ReactNode; - renderHeaderFilenameSuffix?(item: CodeViewItem): ReactNode; - renderHeaderMetadata?(item: CodeViewItem): ReactNode; + renderCustomHeader?(item: CodeViewItem): ReactNode; + renderHeaderPrefix?(item: CodeViewItem): ReactNode; + renderHeaderFilenameSuffix?( + 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; +export interface CodeViewHandle { + addItems(items: readonly CodeViewItem[]): void; + getItem(id: string): CodeViewItem | undefined; removeItem(id: string): boolean; - updateItem(item: CodeViewItem): boolean; + updateItem(item: CodeViewItem): boolean; updateItemId(oldId: string, newId: string): boolean; scrollTo(target: CodeViewScrollTarget): void; setSelectedLines(selection: CodeViewLineSelection | null): void; getSelectedLines(): CodeViewLineSelection | null; clearSelectedLines(): void; getEditor(id: string): DiffsEditor | undefined; - 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(): CodeViewSlotSnapshot | undefined; - publish(snapshot: CodeViewSlotSnapshot | undefined): void; +interface ManagedContentStore { + getSnapshot(): CodeViewSlotSnapshot | undefined; + publish( + snapshot: CodeViewSlotSnapshot | 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, @@ -182,9 +196,9 @@ function createDefaultCache( }; } -function CodeViewInner( - props: CodeViewProps, - ref: React.ForwardedRef> +function CodeViewInner( + props: CodeViewProps, + ref: React.ForwardedRef> ): React.JSX.Element { const { className, @@ -212,8 +226,8 @@ function CodeViewInner( const controlled = controlledItems !== undefined; const contextCreateEditor = useCreateEditor(); const poolManager = useContext(WorkerPoolContext); - const cachedDataRef = useRef>( - createDefaultCache(controlled) + const cachedDataRef = useRef>( + createDefaultCache(controlled) ); const hasCustomHeader = renderCustomHeader != null; const hasAnnotationRenderer = renderAnnotation != null; @@ -258,7 +272,7 @@ function CodeViewInner( ); const emitItemEditChange = useStableCallback( ( - item: CodeViewItem, + item: CodeViewItem, file: FileContents, lineAnnotations?: | LineAnnotation[] @@ -269,7 +283,7 @@ function CodeViewInner( ); const emitItemEditComplete = useStableCallback( ( - item: CodeViewItem, + item: CodeViewItem, file: FileContents, lineAnnotations?: | LineAnnotation[] @@ -314,9 +328,9 @@ function CodeViewInner( ] ); - const [slotContentStore] = useState>(() => - createSlotContentStore() - ); + const [slotContentStore] = useState< + ManagedContentStore + >(() => createSlotContentStore()); const [, forceUpdate] = useState({}); const nodeRef = useStableCallback((node: HTMLDivElement | null) => { @@ -330,7 +344,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 @@ -341,11 +357,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); } @@ -357,7 +372,7 @@ function CodeViewInner( }); const onSnapshotChange = useStableCallback( - (snapshot: CodeViewSlotSnapshot | undefined) => { + (snapshot: CodeViewSlotSnapshot | undefined) => { if (cachedDataRef.current.disableFlushSync) { slotContentStore.publish(snapshot); } else { @@ -368,34 +383,35 @@ function CodeViewInner( } ); - const slotCoordinator: CodeViewCoordinator | undefined = - useMemo(() => { - // A coordinator is needed whenever React portals anything — per-item - // slots and codeview header and footer - if ( - !hasHeaderRenderers && - !hasAnnotationRenderer && - !hasGutterRenderer && - !hasCodeViewHeader && - !hasCodeViewFooter - ) { - return undefined; - } else { - return { - hasHeaderRenderers, - hasAnnotationRenderer, - hasGutterRenderer, - onSnapshotChange, - }; - } - }, [ - onSnapshotChange, - hasAnnotationRenderer, - hasGutterRenderer, - hasHeaderRenderers, - hasCodeViewHeader, - hasCodeViewFooter, - ]); + const slotCoordinator: + | CodeViewCoordinator + | undefined = useMemo(() => { + // A coordinator is needed whenever React portals anything — per-item + // slots and codeview header and footer + if ( + !hasHeaderRenderers && + !hasAnnotationRenderer && + !hasGutterRenderer && + !hasCodeViewHeader && + !hasCodeViewFooter + ) { + return undefined; + } else { + return { + hasHeaderRenderers, + hasAnnotationRenderer, + hasGutterRenderer, + onSnapshotChange, + }; + } + }, [ + onSnapshotChange, + hasAnnotationRenderer, + hasGutterRenderer, + hasHeaderRenderers, + hasCodeViewHeader, + hasCodeViewFooter, + ]); useIsomorphicLayoutEffect(() => { return onScroll != null @@ -493,7 +509,7 @@ function CodeViewInner( // Setup the ref handler useImperativeHandle( ref, - (): CodeViewHandle => ({ + (): CodeViewHandle => ({ addItems(items) { const { controlled, instance } = cachedDataRef.current; assertUncontrolledCodeViewAction(controlled, 'addItems'); @@ -618,7 +634,7 @@ function CodeViewInner( <>
{(hasRenderers || hasCodeViewHeader || hasCodeViewFooter) && ( - + managedContentStore={slotContentStore} renderCustomHeader={renderCustomHeader} renderHeaderPrefix={renderHeaderPrefix} @@ -637,10 +653,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; } @@ -658,9 +674,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; @@ -690,8 +706,9 @@ function assertUncontrolledCodeViewAction( function createSlotContentStore< LAnnotation, ->(): ManagedContentStore { - let snapshot: CodeViewSlotSnapshot | undefined; + LDecoration, +>(): ManagedContentStore { + let snapshot: CodeViewSlotSnapshot | undefined; const listeners = new Set<() => void>(); return { @@ -717,20 +734,26 @@ function createSlotContentStore< }; } -interface CreateManagedCodeViewOptionsProps { - options: CodeViewReactOptions | undefined; +interface CreateManagedCodeViewOptionsProps { + options: CodeViewReactOptions | undefined; hasCustomHeader: boolean; hasGutterRenderer: boolean; hasCodeViewHeader: boolean; hasCodeViewFooter: boolean; onSelectedLinesChange?(selection: CodeViewLineSelection | null): void; controlledSelection: boolean; - createEditor: CodeViewOptions['createEditor']; - onItemEditChange: CodeViewOptions['onItemEditChange']; - onItemEditComplete: CodeViewOptions['onItemEditComplete']; + createEditor: CodeViewOptions['createEditor']; + onItemEditChange: CodeViewOptions< + LAnnotation, + LDecoration + >['onItemEditChange']; + onItemEditComplete: CodeViewOptions< + LAnnotation, + LDecoration + >['onItemEditComplete']; } -function createManagedCodeViewOptions({ +function createManagedCodeViewOptions({ options, hasCustomHeader, hasGutterRenderer, @@ -741,8 +764,11 @@ function createManagedCodeViewOptions({ createEditor, onItemEditChange, onItemEditComplete, -}: CreateManagedCodeViewOptionsProps): CodeViewOptions { - const managedOptions: CodeViewOptions = { +}: CreateManagedCodeViewOptionsProps< + LAnnotation, + LDecoration +>): CodeViewOptions { + const managedOptions: CodeViewOptions = { ...options, controlledSelection, onSelectedLinesChange, @@ -786,29 +812,71 @@ function createManagedCodeViewOptions({ return managedOptions; } -interface RenderCodeViewItemChildrenProps { - renderedItem: CodeViewRenderedItem; - renderCustomHeader: CodeViewBaseProps['renderCustomHeader']; - renderHeaderPrefix: CodeViewBaseProps['renderHeaderPrefix']; - renderHeaderFilenameSuffix: CodeViewBaseProps['renderHeaderFilenameSuffix']; - renderHeaderMetadata: CodeViewBaseProps['renderHeaderMetadata']; - renderAnnotation: CodeViewBaseProps['renderAnnotation']; - renderGutterUtility: CodeViewBaseProps['renderGutterUtility']; +interface RenderCodeViewItemChildrenProps { + renderedItem: CodeViewRenderedItem; + renderCustomHeader: CodeViewBaseProps< + LAnnotation, + LDecoration + >['renderCustomHeader']; + renderHeaderPrefix: CodeViewBaseProps< + LAnnotation, + LDecoration + >['renderHeaderPrefix']; + renderHeaderFilenameSuffix: CodeViewBaseProps< + LAnnotation, + LDecoration + >['renderHeaderFilenameSuffix']; + 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']; - renderHeaderFilenameSuffix: CodeViewBaseProps['renderHeaderFilenameSuffix']; - renderHeaderMetadata: CodeViewBaseProps['renderHeaderMetadata']; - renderAnnotation: CodeViewBaseProps['renderAnnotation']; - renderGutterUtility: CodeViewBaseProps['renderGutterUtility']; - renderCodeViewHeader: CodeViewBaseProps['renderCodeViewHeader']; - renderCodeViewFooter: CodeViewBaseProps['renderCodeViewFooter']; +interface SlotPortalsProps { + managedContentStore: ManagedContentStore; + renderCustomHeader: CodeViewBaseProps< + LAnnotation, + LDecoration + >['renderCustomHeader']; + renderHeaderPrefix: CodeViewBaseProps< + LAnnotation, + LDecoration + >['renderHeaderPrefix']; + renderHeaderFilenameSuffix: CodeViewBaseProps< + LAnnotation, + LDecoration + >['renderHeaderFilenameSuffix']; + renderHeaderMetadata: CodeViewBaseProps< + LAnnotation, + LDecoration + >['renderHeaderMetadata']; + renderAnnotation: CodeViewBaseProps< + LAnnotation, + LDecoration + >['renderAnnotation']; + renderGutterUtility: CodeViewBaseProps< + LAnnotation, + LDecoration + >['renderGutterUtility']; + renderCodeViewHeader: CodeViewBaseProps< + LAnnotation, + LDecoration + >['renderCodeViewHeader']; + renderCodeViewFooter: CodeViewBaseProps< + LAnnotation, + LDecoration + >['renderCodeViewFooter']; } -const SlotPortals = memo(function SlotPortals({ +const SlotPortals = memo(function SlotPortals({ managedContentStore, renderCustomHeader, renderHeaderPrefix, @@ -818,7 +886,7 @@ const SlotPortals = memo(function SlotPortals({ renderGutterUtility, renderCodeViewHeader, renderCodeViewFooter, -}: SlotPortalsProps) { +}: SlotPortalsProps) { 'use no memo'; const subscribe = useStableCallback((listener: () => void) => managedContentStore.subscribe(listener) @@ -827,7 +895,7 @@ const SlotPortals = memo(function SlotPortals({ managedContentStore.getSnapshot() ); const snapshot = useSyncExternalStore< - CodeViewSlotSnapshot | undefined + CodeViewSlotSnapshot | undefined >(subscribe, getSnapshot, getSnapshot); let itemKeys = ''; for (const item of snapshot?.items ?? []) { @@ -884,7 +952,7 @@ const SlotPortals = memo(function SlotPortals({ ); }) as SlotPortalsComponent; -function renderCodeViewItemChildren({ +function renderCodeViewItemChildren({ renderedItem, renderCustomHeader, renderHeaderPrefix, @@ -892,7 +960,7 @@ function renderCodeViewItemChildren({ 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 ad329fbfe..31f3fcafd 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, editorOptions, @@ -27,13 +28,14 @@ export function File({ renderGutterUtility, disableWorkerPool = false, edit = false, -}: FileProps): React.JSX.Element { +}: FileProps): React.JSX.Element { const { ref, getHoveredLine } = useFileInstance({ file, options, editorOptions, 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 d7009c50e..106e9c512 100644 --- a/packages/diffs/src/react/FileDiff.tsx +++ b/packages/diffs/src/react/FileDiff.tsx @@ -11,17 +11,19 @@ export type { FileDiffMetadata }; export interface FileDiffProps< LAnnotation, -> extends DiffBasePropsReact { + LDecoration, +> extends DiffBasePropsReact { fileDiff: FileDiffMetadata; disableWorkerPool?: boolean; } -export function FileDiff({ +export function FileDiff({ fileDiff, options, editorOptions, metrics, lineAnnotations, + decorations, selectedLines, className, style, @@ -34,13 +36,14 @@ export function FileDiff({ renderGutterUtility, disableWorkerPool = false, edit = false, -}: FileDiffProps): React.JSX.Element { +}: FileDiffProps): React.JSX.Element { const { ref, getHoveredLine } = useFileDiffInstance({ fileDiff, options, editorOptions, 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 fe437f2a5..0711e82bb 100644 --- a/packages/diffs/src/react/MultiFileDiff.tsx +++ b/packages/diffs/src/react/MultiFileDiff.tsx @@ -14,20 +14,25 @@ export type { FileContents }; interface MultiFileDiffBaseProps< LAnnotation, -> extends DiffBasePropsReact { + LDecoration, +> extends DiffBasePropsReact { disableWorkerPool?: boolean; } -export type MultiFileDiffProps = - MultiFileDiffBaseProps & DiffFileInput; +export type MultiFileDiffProps = + MultiFileDiffBaseProps & DiffFileInput; -export function MultiFileDiff({ +export function MultiFileDiff< + LAnnotation = undefined, + LDecoration = undefined, +>({ oldFile, newFile, options, editorOptions, metrics, lineAnnotations, + decorations, selectedLines, className, style, @@ -40,7 +45,7 @@ export function MultiFileDiff({ renderGutterUtility, disableWorkerPool = false, edit = false, -}: MultiFileDiffProps): React.JSX.Element { +}: MultiFileDiffProps): React.JSX.Element { const fileDiff = useMemo(() => { return parseDiffFromFile(oldFile, newFile, options?.parseDiffOptions); }, [oldFile, newFile, options?.parseDiffOptions]); @@ -50,6 +55,7 @@ export function MultiFileDiff({ editorOptions, 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 16417f804..af161cdd1 100644 --- a/packages/diffs/src/react/PatchDiff.tsx +++ b/packages/diffs/src/react/PatchDiff.tsx @@ -12,17 +12,19 @@ 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, editorOptions, metrics, lineAnnotations, + decorations, selectedLines, className, style, @@ -35,7 +37,7 @@ export function PatchDiff({ renderGutterUtility, disableWorkerPool = false, edit = false, -}: PatchDiffProps): React.JSX.Element { +}: PatchDiffProps): React.JSX.Element { const fileDiff = usePatch(patch); const { ref, getHoveredLine } = useFileDiffInstance({ fileDiff, @@ -43,6 +45,7 @@ export function PatchDiff({ editorOptions, 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 42a396986..1b32d8895 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' | 'edit' | 'editorOptions' > { 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, @@ -77,12 +84,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 dff3e9007..f5dda40ae 100644 --- a/packages/diffs/src/react/types.ts +++ b/packages/diffs/src/react/types.ts @@ -5,22 +5,25 @@ import type { FileDiffOptions } from '../components/FileDiff'; import type { EditorOptions } from '../edit'; 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; /** Whether this surface has an active edit session. */ edit?: boolean; /** Creation-time options passed to the nearest EditProvider factory. */ editorOptions?: EditorOptions; metrics?: VirtualFileMetrics; lineAnnotations?: DiffLineAnnotation[]; + decorations?: DiffDecorationItem[]; selectedLines?: SelectedLineRange | null; renderAnnotation?(annotations: DiffLineAnnotation): ReactNode; renderCustomHeader?(fileDiff: FileDiffMetadata): ReactNode; @@ -35,15 +38,16 @@ export interface DiffBasePropsReact { prerenderedHTML?: string; } -export interface FileProps { +export interface FileProps { file: FileContents; - options?: FileOptions; + options?: FileOptions; /** Whether this surface has an active edit session. */ edit?: boolean; /** Creation-time options passed to the nearest EditProvider factory. */ editorOptions?: EditorOptions; 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 1705b1ab1..1a6811428 100644 --- a/packages/diffs/src/react/utils/renderDiffChildren.tsx +++ b/packages/diffs/src/react/utils/renderDiffChildren.tsx @@ -17,25 +17,46 @@ import { import { GutterUtilitySlotStyles, MergeConflictSlotStyles } from '../constants'; import type { DiffBasePropsReact } from '../types'; -interface RenderDiffChildrenProps { +interface RenderDiffChildrenProps { fileDiff: FileDiffMetadata; actions?: (MergeConflictDiffAction | undefined)[]; - renderCustomHeader: DiffBasePropsReact['renderCustomHeader']; - renderHeaderPrefix: DiffBasePropsReact['renderHeaderPrefix']; - renderHeaderFilenameSuffix?: DiffBasePropsReact['renderHeaderFilenameSuffix']; - renderHeaderMetadata: DiffBasePropsReact['renderHeaderMetadata']; - renderAnnotation: DiffBasePropsReact['renderAnnotation']; - renderGutterUtility: DiffBasePropsReact['renderGutterUtility']; + renderCustomHeader: DiffBasePropsReact< + LAnnotation, + LDecoration + >['renderCustomHeader']; + renderHeaderPrefix: DiffBasePropsReact< + LAnnotation, + LDecoration + >['renderHeaderPrefix']; + renderHeaderFilenameSuffix?: DiffBasePropsReact< + LAnnotation, + LDecoration + >['renderHeaderFilenameSuffix']; + 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, @@ -48,7 +69,7 @@ export function renderDiffChildren({ lineAnnotations, getHoveredLine, getInstance, -}: RenderDiffChildrenProps): ReactNode { +}: RenderDiffChildrenProps): ReactNode { const customHeader = renderCustomHeader?.(fileDiff); const prefix = renderHeaderPrefix?.(fileDiff); const suffix = renderHeaderFilenameSuffix?.(fileDiff); diff --git a/packages/diffs/src/react/utils/renderFileChildren.tsx b/packages/diffs/src/react/utils/renderFileChildren.tsx index 000c68f18..a3ed5b95b 100644 --- a/packages/diffs/src/react/utils/renderFileChildren.tsx +++ b/packages/diffs/src/react/utils/renderFileChildren.tsx @@ -12,19 +12,28 @@ 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']; - renderHeaderFilenameSuffix?: FileProps['renderHeaderFilenameSuffix']; - renderHeaderMetadata: FileProps['renderHeaderMetadata']; - renderAnnotation: FileProps['renderAnnotation']; - lineAnnotations: FileProps['lineAnnotations']; - renderGutterUtility: FileProps['renderGutterUtility']; + renderCustomHeader: FileProps['renderCustomHeader']; + renderHeaderPrefix: FileProps['renderHeaderPrefix']; + renderHeaderFilenameSuffix?: FileProps< + LAnnotation, + LDecoration + >['renderHeaderFilenameSuffix']; + 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, @@ -34,7 +43,7 @@ export function renderFileChildren({ lineAnnotations, renderGutterUtility, getHoveredLine, -}: RenderFileChildrenProps): ReactNode { +}: RenderFileChildrenProps): ReactNode { const customHeader = renderCustomHeader?.(file); const prefix = renderHeaderPrefix?.(file); const suffix = renderHeaderFilenameSuffix?.(file); diff --git a/packages/diffs/src/react/utils/useFileDiffInstance.ts b/packages/diffs/src/react/utils/useFileDiffInstance.ts index a7a1848fb..50fe3f66e 100644 --- a/packages/diffs/src/react/utils/useFileDiffInstance.ts +++ b/packages/diffs/src/react/utils/useFileDiffInstance.ts @@ -11,6 +11,7 @@ import { VirtualizedFileDiff } from '../../components/VirtualizedFileDiff'; import type { EditorOptions } from '../../edit'; import type { GetHoveredLineResult } from '../../managers/InteractionManager'; import type { + DiffDecorationItem, DiffLineAnnotation, FileDiffMetadata, SelectedLineRange, @@ -26,11 +27,12 @@ import { useStableCallback } from './useStableCallback'; const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; -interface UseFileDiffInstanceProps { +interface UseFileDiffInstanceProps { fileDiff: FileDiffMetadata; - options: FileDiffOptions | undefined; + options: FileDiffOptions | undefined; editorOptions: EditorOptions | undefined; lineAnnotations: DiffLineAnnotation[] | undefined; + decorations: DiffDecorationItem[] | undefined; selectedLines: SelectedLineRange | null | undefined; prerenderedHTML: string | undefined; metrics?: VirtualFileMetrics; @@ -45,11 +47,12 @@ interface UseFileDiffInstanceReturn { getHoveredLine(): GetHoveredLineResult<'diff'> | undefined; } -export function useFileDiffInstance({ +export function useFileDiffInstance({ fileDiff, options, editorOptions, lineAnnotations, + decorations, selectedLines, prerenderedHTML, metrics, @@ -57,13 +60,18 @@ export function useFileDiffInstance({ hasCustomHeader, disableWorkerPool, edit, -}: UseFileDiffInstanceProps): UseFileDiffInstanceReturn { +}: UseFileDiffInstanceProps< + LAnnotation, + LDecoration +>): UseFileDiffInstanceReturn { const simpleVirtualizer = useVirtualizer(); const controlledSelection = selectedLines !== undefined; const poolManager = useContext(WorkerPoolContext); const createEditor = useCreateEditor(); const instanceRef = useRef< - FileDiff | VirtualizedFileDiff | null + | FileDiff + | VirtualizedFileDiff + | null >(null); const ref = useStableCallback((fileContainer: HTMLElement | null) => { if (fileContainer != null) { @@ -101,6 +109,7 @@ export function useFileDiffInstance({ fileDiff, fileContainer, lineAnnotations, + decorations, prerenderedHTML, }); } else { @@ -134,6 +143,7 @@ export function useFileDiffInstance({ forceRender, fileDiff, lineAnnotations, + decorations, }); if (selectedLines !== undefined) { instance.setSelectedLines(selectedLines); @@ -173,20 +183,20 @@ export function useFileDiffInstance({ }; } -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 { const needsReactOverrides = controlledSelection || hasGutterRenderUtility || hasCustomHeader; diff --git a/packages/diffs/src/react/utils/useFileInstance.ts b/packages/diffs/src/react/utils/useFileInstance.ts index 5208af962..3d03e32f5 100644 --- a/packages/diffs/src/react/utils/useFileInstance.ts +++ b/packages/diffs/src/react/utils/useFileInstance.ts @@ -12,6 +12,7 @@ import type { EditorOptions } from '../../edit'; import type { GetHoveredLineResult } from '../../managers/InteractionManager'; import type { FileContents, + FileDecorationItem, LineAnnotation, SelectedLineRange, VirtualFileMetrics, @@ -26,11 +27,12 @@ import { useStableCallback } from './useStableCallback'; const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; -interface UseFileInstanceProps { +interface UseFileInstanceProps { file: FileContents; - options: FileOptions | undefined; + options: FileOptions | undefined; editorOptions: EditorOptions | undefined; lineAnnotations: LineAnnotation[] | undefined; + decorations: FileDecorationItem[] | undefined; selectedLines: SelectedLineRange | null | undefined; prerenderedHTML: string | undefined; metrics?: VirtualFileMetrics; @@ -49,11 +51,12 @@ interface UseFileInstanceReturn { getHoveredLine(): GetHoveredLineResult<'file'> | undefined; } -export function useFileInstance({ +export function useFileInstance({ file, options, editorOptions, lineAnnotations, + decorations, selectedLines, prerenderedHTML, metrics, @@ -61,13 +64,15 @@ export function useFileInstance({ hasCustomHeader, disableWorkerPool, edit, -}: UseFileInstanceProps): UseFileInstanceReturn { +}: UseFileInstanceProps): UseFileInstanceReturn { const simpleVirtualizer = useVirtualizer(); const controlledSelection = selectedLines !== undefined; const poolManager = useContext(WorkerPoolContext); const createEditor = useCreateEditor(); const instanceRef = useRef< - File | VirtualizedFile | null + | File + | VirtualizedFile + | null >(null); const ref = useStableCallback((node: HTMLElement | null) => { if (node != null) { @@ -105,6 +110,7 @@ export function useFileInstance({ file, fileContainer: node, lineAnnotations, + decorations, prerenderedHTML, }); } else { @@ -131,7 +137,12 @@ export function useFileInstance({ newOptions !== undefined && !areOptionsEqual(instanceRef.current.options, 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); } @@ -166,19 +177,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 { const needsReactOverrides = controlledSelection || hasGutterRenderUtility || hasCustomHeader; diff --git a/packages/diffs/src/react/utils/useUnresolvedFileInstance.ts b/packages/diffs/src/react/utils/useUnresolvedFileInstance.ts index f85416be4..b6fed9760 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 useIsomorphicLayoutEffect = 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 { @@ -160,6 +170,7 @@ export function useUnresolvedFileInstance({ actions, markerRows, lineAnnotations, + decorations, forceRender, }); if (selectedLines !== undefined) { @@ -180,23 +191,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 6daaed9f0..047308846 100644 --- a/packages/diffs/src/renderers/DiffHunksRenderer.ts +++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts @@ -22,6 +22,7 @@ import type { BaseDiffOptionsWithDefaults, CodeColumnType, CustomPreProperties, + DiffDecorationItem, DiffLineAnnotation, DiffsHighlighter, DiffsTextDocument, @@ -223,7 +224,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; @@ -692,6 +696,10 @@ export class DiffHunksRenderer { diff.editSessionDirty = true; } + 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 fed6a00c9..0525d82d4 100644 --- a/packages/diffs/src/renderers/FileRenderer.ts +++ b/packages/diffs/src/renderers/FileRenderer.ts @@ -18,6 +18,7 @@ import type { DiffsHighlighter, DiffsTextDocument, FileContents, + FileDecorationItem, FileHeaderRenderMode, HighlightedToken, LineAnnotation, @@ -102,7 +103,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; @@ -151,6 +152,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 a3022aff6..384361d08 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, DiffFileInput, DiffLineAnnotation, FileContents, @@ -28,25 +29,32 @@ import { parseMergeConflictDiffFromFile } from '../utils/parseMergeConflictDiffF import { shouldUseTokenTransformer } from '../utils/shouldUseTokenTransformer'; import { renderHTML } from './renderHTML'; -interface PreloadDiffBaseOptions { - options?: FileDiffOptions; +interface PreloadDiffBaseOptions { + options?: FileDiffOptions; annotations?: DiffLineAnnotation[]; + decorations?: DiffDecorationItem[]; } -export type PreloadDiffOptions = - PreloadDiffBaseOptions & - ( - | ({ fileDiff: FileDiffMetadata } & MaybeDiffFileInput) - | ({ fileDiff?: undefined } & DiffFileInput) - ); +export type PreloadDiffOptions< + LAnnotation = undefined, + LDecoration = undefined, +> = PreloadDiffBaseOptions & + ( + | ({ fileDiff: FileDiffMetadata } & MaybeDiffFileInput) + | ({ fileDiff?: undefined } & DiffFileInput) + ); -export async function preloadDiffHTML({ +export async function preloadDiffHTML< + LAnnotation = undefined, + LDecoration = undefined, +>({ fileDiff, oldFile, newFile, options, annotations, -}: PreloadDiffOptions): Promise { + decorations, +}: PreloadDiffOptions): Promise { const fileInput = getDiffFileInput({ oldFile, newFile }, 'preloadDiffHTML'); if (fileDiff == null && fileInput != null) { fileDiff = parseDiffFromFile( @@ -60,12 +68,15 @@ export async function preloadDiffHTML({ 'preloadFileDiff: You must pass at least a fileDiff, oldFile, or newFile prop' ); } - 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), @@ -76,21 +87,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( @@ -102,26 +120,35 @@ export async function preloadUnresolvedFileHTML({ ); } -interface PreloadMultiFileDiffBaseOptions { - options?: FileDiffOptions; +interface PreloadMultiFileDiffBaseOptions { + options?: FileDiffOptions; annotations?: DiffLineAnnotation[]; + decorations?: DiffDecorationItem[]; } -export type PreloadMultiFileDiffOptions = - PreloadMultiFileDiffBaseOptions & DiffFileInput; +export type PreloadMultiFileDiffOptions< + LAnnotation = undefined, + LDecoration = undefined, +> = PreloadMultiFileDiffBaseOptions & DiffFileInput; -export type PreloadMultiFileDiffResult = - PreloadMultiFileDiffOptions & { - prerenderedHTML: string; - }; +export type PreloadMultiFileDiffResult< + LAnnotation = undefined, + LDecoration = undefined, +> = 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 > { const fileInput = getDiffFileInput( { oldFile, newFile }, @@ -136,116 +163,148 @@ export async function preloadMultiFileDiff({ ...fileInput, options, annotations, + decorations, prerenderedHTML: await preloadDiffHTML({ ...fileInput, 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' ) { @@ -270,8 +329,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 7cd6ddb20..f6e78fa34 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, @@ -9,25 +13,39 @@ import { wrapThemeCSS } from '../utils/cssWrappers'; import { shouldUseTokenTransformer } from '../utils/shouldUseTokenTransformer'; 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, // Match the client's option snapshot: token callbacks imply the // transformer, so server markup hydrates into identical client renders. @@ -40,6 +58,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)]; @@ -68,6 +89,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 96f3a87d6..bfe7b26f7 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -536,24 +536,44 @@ type OptionalMetadata = T extends undefined * Annotation rendered for a file line. Use `lineNumber: 0` to render a * file-level annotation above the first rendered file line. */ -export type LineAnnotation = { +export type LineAnnotation = { lineNumber: number; -} & OptionalMetadata; +} & OptionalMetadata; /** * Annotation rendered for one side of a diff line. Use `lineNumber: 0` to * render a side-specific file-level annotation above the first hunk/separator. */ -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; /** @@ -564,11 +584,15 @@ export type CodeViewFileItem = { edit?: 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; /** @@ -579,9 +603,9 @@ export type CodeViewDiffItem = { edit?: 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 c89779ce2..81edf064c 100644 --- a/packages/diffs/src/utils/areManagedSnapshotsEqual.ts +++ b/packages/diffs/src/utils/areManagedSnapshotsEqual.ts @@ -3,9 +3,9 @@ import type { CodeViewSlotSnapshot, } from '../components/CodeView'; -export function areManagedSnapshotsEqual( - previous: CodeViewSlotSnapshot | undefined, - next: CodeViewSlotSnapshot | undefined +export function areManagedSnapshotsEqual( + previous: CodeViewSlotSnapshot | undefined, + next: CodeViewSlotSnapshot | undefined ): boolean { if (previous == null || next == null) { return previous === next; @@ -18,9 +18,9 @@ export function areManagedSnapshotsEqual( return areRenderedItemsEqual(previous.items, next.items); } -function areRenderedItemsEqual( - previous: CodeViewRenderedItem[] | undefined, - next: CodeViewRenderedItem[] | undefined +function areRenderedItemsEqual( + 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 c8a8514c3..a37bcf8bf 100644 --- a/packages/diffs/src/utils/getDiffHunksRendererOptions.ts +++ b/packages/diffs/src/utils/getDiffHunksRendererOptions.ts @@ -4,8 +4,8 @@ import { shouldUseTokenTransformer } from './shouldUseTokenTransformer'; // 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 bfbb312b9..1f6bb0345 100644 --- a/packages/diffs/src/utils/getFileRendererOptions.ts +++ b/packages/diffs/src/utils/getFileRendererOptions.ts @@ -4,8 +4,8 @@ import { shouldUseTokenTransformer } from './shouldUseTokenTransformer'; // 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 10497d47e..a9d753aaf 100644 --- a/packages/diffs/test/CodeView.elementPooling.test.ts +++ b/packages/diffs/test/CodeView.elementPooling.test.ts @@ -354,7 +354,7 @@ describe('CodeView element pooling', () => { const { cleanup } = installDom(); const viewer = new CodeView({ disableFileHeader: true }, undefined, true); const root = createRoot({ height: 120 }); - const coordinator: CodeViewCoordinator = { + const coordinator: CodeViewCoordinator = { hasAnnotationRenderer: false, hasGutterRenderer: false, hasHeaderRenderers: true, diff --git a/packages/diffs/test/CodeView.headerFooter.test.ts b/packages/diffs/test/CodeView.headerFooter.test.ts index db5b968d3..39d17d1a1 100644 --- a/packages/diffs/test/CodeView.headerFooter.test.ts +++ b/packages/diffs/test/CodeView.headerFooter.test.ts @@ -25,7 +25,7 @@ function makeItems(count = 12, lineCount = 20): CodeViewItem[] { ); } -function headerOption(): CodeViewOptions { +function headerOption(): CodeViewOptions { return { renderCodeViewHeader: () => document.createElement('div') }; } diff --git a/packages/diffs/test/annotations.test.ts b/packages/diffs/test/annotations.test.ts index e64203992..15d279e0a 100644 --- a/packages/diffs/test/annotations.test.ts +++ b/packages/diffs/test/annotations.test.ts @@ -252,7 +252,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 c4e3621b9..89dbca2b8 100644 --- a/packages/diffs/test/hydration.test.ts +++ b/packages/diffs/test/hydration.test.ts @@ -127,7 +127,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; } @@ -140,7 +142,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; } @@ -154,7 +158,7 @@ class SpyUnresolvedFile extends UnresolvedFile { renderCalls = 0; public override render( - _props: UnresolvedFileRenderProps + _props: UnresolvedFileRenderProps ): boolean { this.renderCalls += 1; return true; @@ -266,7 +270,7 @@ describe('collapsed hydration', () => { const dom = installDomConstructors(); try { const instance = new SpyFile({ collapsed: true }); - const props: FileHydrateProps = { + const props: FileHydrateProps = { file, fileContainer: dom.createHydrationContainer(), }; @@ -283,7 +287,7 @@ describe('collapsed hydration', () => { const dom = installDomConstructors(); try { const instance = new SpyFile(); - const props: FileHydrateProps = { + const props: FileHydrateProps = { file, fileContainer: dom.createHydrationContainer(), }; @@ -304,7 +308,7 @@ describe('collapsed hydration', () => { disableFileHeader: true, }); const fileContainer = dom.createHydrationContainer({ header: false }); - const props: FileHydrateProps = { + const props: FileHydrateProps = { file, fileContainer, }; @@ -327,7 +331,7 @@ describe('collapsed hydration', () => { virtualizerState.virtualizer ); const fileContainer = dom.createHydrationContainer(); - const props: FileHydrateProps = { + const props: FileHydrateProps = { file, fileContainer, }; @@ -354,7 +358,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, @@ -373,7 +377,7 @@ describe('collapsed hydration', () => { const dom = installDomConstructors(); try { const instance = new SpyFileDiff(); - const props: FileDiffHydrationProps = { + const props: FileDiffHydrationProps = { fileDiff, oldFile: file, newFile: file, @@ -396,7 +400,7 @@ describe('collapsed hydration', () => { disableFileHeader: true, }); const fileContainer = dom.createHydrationContainer({ header: false }); - const props: FileDiffHydrationProps = { + const props: FileDiffHydrationProps = { fileDiff, oldFile: file, newFile: file, @@ -421,7 +425,7 @@ describe('collapsed hydration', () => { virtualizerState.virtualizer ); const fileContainer = dom.createHydrationContainer(); - const props: FileDiffHydrationProps = { + const props: FileDiffHydrationProps = { oldFile: file, newFile: modifiedFile, fileContainer, @@ -481,7 +485,7 @@ describe('collapsed hydration', () => { virtualizerState.virtualizer ); const fileContainer = dom.createHydrationContainer(); - const props: FileDiffHydrationProps = { + const props: FileDiffHydrationProps = { oldFile: null, newFile: modifiedFile, fileContainer, @@ -516,7 +520,7 @@ describe('collapsed hydration', () => { virtualizerState.virtualizer ); const fileContainer = dom.createHydrationContainer(); - const props: FileDiffHydrationProps = { + const props: FileDiffHydrationProps = { oldFile: file, newFile: null, fileContainer, @@ -553,7 +557,7 @@ describe('collapsed hydration', () => { return undefined; }, }); - const props: UnresolvedFileHydrationProps = { + const props: UnresolvedFileHydrationProps = { file: unresolvedFile, fileContainer: dom.createHydrationContainer(), }; @@ -571,7 +575,7 @@ describe('collapsed hydration', () => { const dom = installDomConstructors(); try { const instance = new SpyUnresolvedFile(); - const props: UnresolvedFileHydrationProps = { + const props: UnresolvedFileHydrationProps = { file: unresolvedFile, fileContainer: dom.createHydrationContainer(), }; @@ -588,7 +592,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 }), }; @@ -614,7 +618,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/reactCodeViewEditorOptions.test.ts b/packages/diffs/test/reactCodeViewEditorOptions.test.ts index fab26842c..9cba86fb8 100644 --- a/packages/diffs/test/reactCodeViewEditorOptions.test.ts +++ b/packages/diffs/test/reactCodeViewEditorOptions.test.ts @@ -40,7 +40,7 @@ afterAll(async () => { const ReactCodeViewComponent = CodeView as ComponentType< CodeViewProps & { - ref?: Ref>; + ref?: Ref>; } >; const EditProviderComponent = EditProvider as ComponentType< @@ -168,7 +168,7 @@ function makeDiffItem(id: string, edit = false): CodeViewItem { function createCodeViewElement( props: CodeViewProps & { - ref?: Ref>; + ref?: Ref>; } ): ReactElement { return createElement(ReactCodeViewComponent, { @@ -261,7 +261,7 @@ describe('React CodeView editor factory', () => { const cleanupActEnvironment = installReactActEnvironment(); const container = document.createElement('div'); document.body.appendChild(container); - const handle = createRef>(); + const handle = createRef>(); const bypassFactory = mock((_options: EditorOptions) => createTrackedEditor(_options) ); @@ -468,7 +468,7 @@ describe('React CodeView editor factory', () => { const cleanupActEnvironment = installReactActEnvironment(); const container = document.createElement('div'); document.body.appendChild(container); - const handle = createRef>(); + const handle = createRef>(); const { createEditor, editors } = createEditorHarness(); const onItemEditComplete = mock( (_item: CodeViewItem, _file: FileContents) => {} @@ -576,7 +576,7 @@ describe('React CodeView editor factory', () => { const cleanupActEnvironment = installReactActEnvironment(); const container = document.createElement('div'); document.body.appendChild(container); - const handle = createRef>(); + const handle = createRef>(); const { createEditor, editors } = createEditorHarness(); const onItemEditComplete = mock( (_item: CodeViewItem, _file: FileContents) => {} @@ -635,7 +635,7 @@ describe('React CodeView editor factory', () => { const cleanupActEnvironment = installReactActEnvironment(); const container = document.createElement('div'); document.body.appendChild(container); - const handle = createRef>(); + const handle = createRef>(); const { createEditor, editors } = createEditorHarness(); let root: Root | undefined; @@ -726,7 +726,7 @@ describe('React CodeView editor factory', () => { const cleanupActEnvironment = installReactActEnvironment(); const container = document.createElement('div'); document.body.appendChild(container); - const handle = createRef>(); + const handle = createRef>(); const { createEditor, editors } = createEditorHarness(); const items = [ makeFileItem('edited', { edit: true, lineCount: 30 }), @@ -853,7 +853,7 @@ describe('React CodeView edit completion teardown', () => { const cleanupActEnvironment = installReactActEnvironment(); const container = document.createElement('div'); document.body.appendChild(container); - const handle = createRef>(); + const handle = createRef>(); const { createEditor, editors } = createEditorHarness(); const onItemEditComplete = mock( (_item: CodeViewItem, _file: FileContents) => {} @@ -904,7 +904,7 @@ describe('React CodeView selection', () => { const cleanupActEnvironment = installReactActEnvironment(); const container = document.createElement('div'); document.body.appendChild(container); - const handle = createRef>(); + const handle = createRef>(); const { createEditor, editors } = createEditorHarness(); const items = [makeFileItem('a')]; const initialSelection = { @@ -966,7 +966,7 @@ describe('React CodeView selection', () => { const cleanupActEnvironment = installReactActEnvironment(); const container = document.createElement('div'); document.body.appendChild(container); - const handle = createRef>(); + const handle = createRef>(); const bypassOnSelectedLinesChange = mock( (_selection: CodeViewLineSelection | null) => {} ); diff --git a/packages/diffs/test/reactEditorOptions.test.ts b/packages/diffs/test/reactEditorOptions.test.ts index e9c5e6774..3789b6c80 100644 --- a/packages/diffs/test/reactEditorOptions.test.ts +++ b/packages/diffs/test/reactEditorOptions.test.ts @@ -46,19 +46,19 @@ afterAll(async () => { }); const ReactFileComponent = ReactFile as ComponentType< - ReactFileProps + ReactFileProps >; const ReactFileDiffComponent = ReactFileDiff as ComponentType< - ReactFileDiffProps + ReactFileDiffProps >; const EditProviderComponent = EditProvider as ComponentType< PropsWithChildren> >; const MultiFileDiffComponent = MultiFileDiff as ComponentType< - MultiFileDiffProps + MultiFileDiffProps >; const PatchDiffComponent = PatchDiff as ComponentType< - PatchDiffProps + PatchDiffProps >; function createEditor(options: EditorOptions): Editor { diff --git a/packages/diffs/test/reactPartialHydration.test.ts b/packages/diffs/test/reactPartialHydration.test.ts index c92ea8727..6f15c7a67 100644 --- a/packages/diffs/test/reactPartialHydration.test.ts +++ b/packages/diffs/test/reactPartialHydration.test.ts @@ -34,10 +34,10 @@ interface PartialChange { } const ReactFileDiffComponent = ReactFileDiff as ComponentType< - ReactFileDiffProps + ReactFileDiffProps >; const ReactPatchDiffComponent = PatchDiff as ComponentType< - ReactPatchDiffProps + ReactPatchDiffProps >; function createPartialChange(name = 'partial.txt'): PartialChange { @@ -141,7 +141,7 @@ describe('React partial diff hydration', () => { let root: Root | undefined; try { const { oldFile, newFile, partial } = createPartialChange('react.ts'); - const props: ReactFileDiffProps = { + const props: ReactFileDiffProps = { fileDiff: partial, options: { disableErrorHandling: true, @@ -181,7 +181,7 @@ describe('React partial diff hydration', () => { let root: Root | undefined; try { const { oldFile, newFile, patch } = createPartialChange('patch.ts'); - const props: ReactPatchDiffProps = { + const props: ReactPatchDiffProps = { patch, options: { disableErrorHandling: true, @@ -220,7 +220,7 @@ describe('React partial diff hydration', () => { try { const firstChange = createPartialChange('first.ts'); const secondChange = createPartialChange('second.ts'); - const props: ReactFileDiffProps = { + const props: ReactFileDiffProps = { fileDiff: firstChange.partial, options: { disableErrorHandling: true, diff --git a/packages/diffs/test/themeTypeUpdates.test.ts b/packages/diffs/test/themeTypeUpdates.test.ts index f1ee1def0..b8112e535 100644 --- a/packages/diffs/test/themeTypeUpdates.test.ts +++ b/packages/diffs/test/themeTypeUpdates.test.ts @@ -62,7 +62,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 2d0e9d073039f04c0a2ae2452dbba552f48ee428 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 90ea4a74d..6846f9d57 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -331,6 +331,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; @@ -371,6 +399,7 @@ export class File< this.mounted = false; if (!recycle) { this.lineAnnotations = []; + this.decorations = []; } this.clearAuxiliaryNodes(); this.pre = undefined; @@ -512,12 +541,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; } @@ -662,16 +694,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) || @@ -695,14 +726,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; @@ -763,7 +792,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 a5280f06f..a683747a6 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -532,6 +532,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, @@ -675,6 +703,7 @@ export class FileDiff< this.mounted = false; if (!recycle) { this.lineAnnotations = []; + this.decorations = []; } this.clearAuxiliaryNodes(); this.annotationCache.clear(); @@ -876,12 +905,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(); @@ -1045,7 +1080,6 @@ export class FileDiff< const nextRenderRange = collapsed ? undefined : renderRange; const themeChanged = this.hasThemeChanged(); const hasFileInput = fileInput != null; - const nextDecorations = decorations; const filesDidChange = hasFileInput && (!areOptionalFilesEqual(oldFile, this.deletionFile) || @@ -1057,9 +1091,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 ( @@ -1116,12 +1150,14 @@ 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; } @@ -1138,11 +1174,6 @@ export class FileDiff< if (expandUnchanged) { this.loadFilesIfNecessary(); } - 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; @@ -1206,7 +1237,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 047308846..1a3accb95 100644 --- a/packages/diffs/src/renderers/DiffHunksRenderer.ts +++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts @@ -80,6 +80,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 { recomputeDiffHunksForEdit, @@ -237,6 +242,8 @@ export class DiffHunksRenderer< private deletionAnnotations: AnnotationLineMap = {}; private additionAnnotations: AnnotationLineMap = {}; + private deletionDecorationsByLine: NormalizedLineDecorationMap = {}; + private additionDecorationsByLine: NormalizedLineDecorationMap = {}; private computedLang: SupportedLanguages = 'text'; private renderCache: RenderedDiffASTCache | undefined; @@ -269,6 +276,8 @@ export class DiffHunksRenderer< } public recycle(): void { + this.deletionDecorationsByLine = {}; + this.additionDecorationsByLine = {}; this.highlighter = undefined; this.diff = undefined; this.clearRenderCache(); @@ -697,8 +706,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, @@ -729,6 +742,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]; + } + private createAnnotationElement = (span: AnnotationSpan): HASTElement => { return createDefaultAnnotationElement(span); }; diff --git a/packages/diffs/src/renderers/FileRenderer.ts b/packages/diffs/src/renderers/FileRenderer.ts index 0525d82d4..ede217d9d 100644 --- a/packages/diffs/src/renderers/FileRenderer.ts +++ b/packages/diffs/src/renderers/FileRenderer.ts @@ -55,6 +55,11 @@ import { shouldRenderFileAnnotations, } from '../utils/includesFileAnnotations'; import { isFilePlainText } from '../utils/isFilePlainText'; +import { + type NormalizedLineDecorationMap, + type NormalizedLineDecorations, + normalizeFileDecorations, +} from '../utils/normalizeLineDecorations'; import { renderFileWithHighlighter } from '../utils/renderFileWithHighlighter'; import type { WorkerPoolManager } from '../worker'; @@ -110,6 +115,7 @@ export class FileRenderer { private renderCache: RenderedFileASTCache | undefined; private computedLang: SupportedLanguages = 'text'; private lineAnnotations: AnnotationLineMap = {}; + private decorationsByLine: NormalizedLineDecorationMap = {}; private lineCache: LineCache | undefined; private pendingStructuralRows: Map | undefined; private textDocumentCache = new WeakMap(); @@ -153,8 +159,10 @@ export class FileRenderer { } public setDecorations( - _decorations: readonly FileDecorationItem[] - ): void {} + decorations: readonly FileDecorationItem[] + ): void { + this.decorationsByLine = normalizeFileDecorations(decorations); + } public cleanUp(): void { this.recycle(); @@ -189,6 +197,8 @@ export class FileRenderer { } public recycle(): void { + this.lineAnnotations = {}; + this.decorationsByLine = {}; this.clearRenderCache(); this.highlighter = undefined; this.workerManager?.cleanUpTasks(this); @@ -552,6 +562,15 @@ export class FileRenderer { this.textDocumentCache.set(file, textDocument); } + 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 4591b6406284a35f829729b24b651348933c945e 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/renderers/DiffHunksRenderer.ts | 71 ++- packages/diffs/src/renderers/FileRenderer.ts | 36 +- .../src/utils/getLineDecorationProperties.ts | 267 ++++++++ .../src/utils/normalizeLineDecorations.ts | 186 +++++- packages/diffs/test/decorations.test.ts | 568 ++++++++++++++++++ 5 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/renderers/DiffHunksRenderer.ts b/packages/diffs/src/renderers/DiffHunksRenderer.ts index 1a3accb95..30c2405a2 100644 --- a/packages/diffs/src/renderers/DiffHunksRenderer.ts +++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts @@ -63,6 +63,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, @@ -754,7 +760,24 @@ export class DiffHunksRenderer< : this.additionDecorationsByLine[lineNumber]; } - private createAnnotationElement = (span: AnnotationSpan): HASTElement => { + 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); }; @@ -1433,19 +1456,30 @@ 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, isRenderCacheDirty && additionLine != null ? { 'data-line': additionLine.lineNumber, @@ -1456,7 +1490,7 @@ export class DiffHunksRenderer< } else if (deletionLineContent != null) { deletionLineContent = withContentProperties( deletionLineContent, - lineDecoration.contentProperties, + unifiedLineDecoration.contentProperties, isRenderCacheDirty && deletionLine != null ? { 'data-line': deletionLine.lineNumber, @@ -1513,6 +1547,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 = @@ -1562,7 +1608,7 @@ export class DiffHunksRenderer< if (deletionLine != null) { const deletionLineDecorated = withContentProperties( deletionLineContent, - deletionLineDecoration.contentProperties, + decoratedDeletionLine.contentProperties, isRenderCacheDirty ? { 'data-line': deletionLine.lineNumber, @@ -1572,10 +1618,10 @@ export class DiffHunksRenderer< ); pushGutterLineNumber( 'deletions', - deletionLineDecoration.gutterLineType, + decoratedDeletionLine.gutterLineType, deletionLine.lineNumber, `${deletionLine.unifiedLineIndex},${splitLineIndex}`, - deletionLineDecoration.gutterProperties + decoratedDeletionLine.gutterProperties ); if (deletionLineDecorated != null) { deletionLineContent = deletionLineDecorated; @@ -1584,7 +1630,7 @@ export class DiffHunksRenderer< if (additionLine != null) { const additionLineDecorated = withContentProperties( additionLineContent, - additionLineDecoration.contentProperties, + decoratedAdditionLine.contentProperties, isRenderCacheDirty ? { 'data-line': additionLine.lineNumber, @@ -1594,10 +1640,10 @@ export class DiffHunksRenderer< ); pushGutterLineNumber( 'additions', - additionLineDecoration.gutterLineType, + decoratedAdditionLine.gutterLineType, additionLine.lineNumber, `${additionLine.unifiedLineIndex},${splitLineIndex}`, - additionLineDecoration.gutterProperties + decoratedAdditionLine.gutterProperties ); if (additionLineDecorated != null) { additionLineContent = additionLineDecorated; @@ -2296,8 +2342,7 @@ function withContentProperties( return { ...lineNode, properties: { - ...lineNode.properties, - ...contentProperties, + ...(mergeHastProperties(lineNode.properties, contentProperties) ?? {}), ...extendProperties, }, }; diff --git a/packages/diffs/src/renderers/FileRenderer.ts b/packages/diffs/src/renderers/FileRenderer.ts index ede217d9d..afb29a000 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 { @@ -41,6 +41,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, @@ -821,11 +826,22 @@ export class FileRenderer { throw new Error(message); } + 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 @@ -1020,3 +1036,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 a597ed5a5b0c493cd3591f45077008f168dc2308 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 b2eb2f3fa..aed2e8706 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 FileDiffContentsLoader, type FileDiffOptions, @@ -60,8 +62,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; @@ -646,6 +648,7 @@ function renderDiff(parsedPatches: ParsedPatch[], manager?: WorkerPoolManager) { fileDiff, lineAnnotations: fileAnnotations, fileContainer, + decorations: DECORATIONS_DIFF, }); diffInstances.push(instance); hunkIndex++; @@ -944,6 +947,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, @@ -1207,6 +1257,7 @@ if (renderFileButton != null) { file, lineAnnotations: FAKE_LINE_ANNOTATIONS, fileContainer, + decorations: DECORATIONS, }); fileInstances.push(instance); }); @@ -1351,15 +1402,15 @@ function createToggle( // 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 30c2405a2..60a2f5a08 100644 --- a/packages/diffs/src/renderers/DiffHunksRenderer.ts +++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts @@ -65,6 +65,7 @@ import { getHunkSeparatorSlotName } from '../utils/getHunkSeparatorSlotName'; import { getLineAnnotationName } from '../utils/getLineAnnotationName'; import { getLineDecorationContentProperties, + getLineDecorationGutterChildren, getLineDecorationGutterProperties, mergeHastProperties, mergeNormalizedLineDecorations, @@ -174,6 +175,7 @@ export interface SplitLineDecorationProps { export interface LineDecoration { gutterLineType: LineTypes; gutterProperties?: Properties; + gutterChildren?: ElementContent[]; contentProperties?: Properties; } @@ -766,6 +768,10 @@ export class DiffHunksRenderer< ): LineDecoration { return { ...decoration, + gutterChildren: mergeElementContents( + decoration.gutterChildren, + getLineDecorationGutterChildren(lineDecorations) + ), gutterProperties: mergeHastProperties( decoration.gutterProperties, getLineDecorationGutterProperties(lineDecorations) @@ -1349,11 +1355,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 + ) ); }; @@ -1474,7 +1487,8 @@ export class DiffHunksRenderer< ? additionLine.lineNumber : deletionLine.lineNumber, `${unifiedLineIndex},${splitLineIndex}`, - unifiedLineDecoration.gutterProperties + unifiedLineDecoration.gutterProperties, + unifiedLineDecoration.gutterChildren ); if (additionLineContent != null) { additionLineContent = withContentProperties( @@ -1621,7 +1635,8 @@ export class DiffHunksRenderer< decoratedDeletionLine.gutterLineType, deletionLine.lineNumber, `${deletionLine.unifiedLineIndex},${splitLineIndex}`, - decoratedDeletionLine.gutterProperties + decoratedDeletionLine.gutterProperties, + decoratedDeletionLine.gutterChildren ); if (deletionLineDecorated != null) { deletionLineContent = deletionLineDecorated; @@ -1643,7 +1658,8 @@ export class DiffHunksRenderer< decoratedAdditionLine.gutterLineType, additionLine.lineNumber, `${additionLine.unifiedLineIndex},${splitLineIndex}`, - decoratedAdditionLine.gutterProperties + decoratedAdditionLine.gutterProperties, + decoratedAdditionLine.gutterChildren ); if (additionLineDecorated != null) { additionLineContent = additionLineDecorated; @@ -2096,6 +2112,19 @@ function getModifiedLinesString(lines: number) { return `${lines} unmodified line${suffix}`; } +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 afb29a000..e2a9c9e0e 100644 --- a/packages/diffs/src/renderers/FileRenderer.ts +++ b/packages/diffs/src/renderers/FileRenderer.ts @@ -43,6 +43,7 @@ import { getHighlighterOptions } from '../utils/getHighlighterOptions'; import { getLineAnnotationName } from '../utils/getLineAnnotationName'; import { getLineDecorationContentProperties, + getLineDecorationGutterChildren, getLineDecorationGutterProperties, mergeHastProperties, } from '../utils/getLineDecorationProperties'; @@ -833,7 +834,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 138ddd690f28dc69fa9303a80bf2c0b4cb909403 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 aed2e8706..578a1572f 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -420,6 +420,7 @@ function renderDiff(parsedPatches: ParsedPatch[], manager?: WorkerPoolManager) { const fileAnnotations = patchAnnotations[hunkIndex]; let isEditing = false; const options: FileDiffOptions = { + expandUnchanged: true, theme: DEMO_THEME, themeType, diffStyle: unified ? 'unified' : 'split', @@ -478,7 +479,7 @@ function renderDiff(parsedPatches: ParsedPatch[], manager?: WorkerPoolManager) { // expandUnchanged: true, // Hover Decoration Snippets - enableGutterUtility: true, + // enableGutterUtility: true, // onGutterUtilityClick(event) { // console.log('onGutterUtilityClick', event); // }, @@ -969,28 +970,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', }, ]; @@ -1190,7 +1203,7 @@ if (renderFileButton != null) { // }, // Hover Decoration Snippets - enableGutterUtility: true, + // enableGutterUtility: true, // onGutterUtilityClick(event) { // console.log('onGutterUtilityClick', event); // }, @@ -1289,7 +1302,7 @@ if (renderFileConflictButton != null) { } : null), 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