diff --git a/AGENTS.md b/AGENTS.md index dd6fcc95f..b29d2d7ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,6 +141,10 @@ We use project references between packages and apps. - Keep comments concrete and behavior-focused. Good comments usually explain what data is being transformed, what invariant is being checked, or what the helper is protecting against. +- After changing boolean logic or invalidation paths, simplify the final control + flow before calling the work done. If code is already inside `if (foo)`, don't + keep `|| foo` in assignments inside that block. Prefer direct values that make + the invariant obvious. ## Performance diff --git a/packages/diffs/src/components/CodeView.ts b/packages/diffs/src/components/CodeView.ts index 01f1723bd..41d9326d1 100644 --- a/packages/diffs/src/components/CodeView.ts +++ b/packages/diffs/src/components/CodeView.ts @@ -2,6 +2,7 @@ import { CORE_CSS_ATTRIBUTE, DEFAULT_CODE_VIEW_FILE_METRICS, DEFAULT_CODE_VIEW_LAYOUT, + DEFAULT_COLLAPSED_CONTEXT_THRESHOLD, DEFAULT_SMOOTH_SCROLL_SETTINGS, DEFAULT_THEMES, DIFFS_DEVELOPMENT_BUILD, @@ -26,6 +27,7 @@ import type { CodeViewScrollBehavior, CodeViewScrollTarget, HunkSeparators, + PendingCodeViewLayoutReset, SelectedLineRange, SelectionSide, SmoothScrollSettings, @@ -33,6 +35,7 @@ import type { VirtualWindowSpecs, } from '../types'; import { areObjectsEqual } from '../utils/areObjectsEqual'; +import { areOptionsEqual } from '../utils/areOptionsEqual'; import { areSelectionsEqual } from '../utils/areSelectionsEqual'; import { areThemesEqual } from '../utils/areThemesEqual'; import { createWindowFromScrollPosition } from '../utils/createWindowFromScrollPosition'; @@ -95,6 +98,8 @@ interface AdvancedVirtualizedBaseItem { element: HTMLElement | undefined; /** Last controlled version observed for this record. */ version: number | undefined; + /** Last CodeView option revision this item rendered with. */ + renderedOptionsRevision: number; } interface CodeViewDiffItemContext< @@ -277,6 +282,8 @@ const CODE_VIEW_FILE_OPTION_KEYS = [ 'disableErrorHandling', ] as const; +type CodeViewFileOptionKeys = (typeof CODE_VIEW_FILE_OPTION_KEYS)[number]; + type CodeViewPassThroughOptions = Pick< FileDiffOptions, CodeViewDiffOptionKeys @@ -349,6 +356,54 @@ type CodeViewSharedCallbackKeys = type CodeViewSelectionCallbackKeys = (typeof CODE_VIEW_SELECTION_CALLBACK_KEYS)[number]; +const CODE_VIEW_ITEM_OPTIONS_STATE = Symbol('CodeView.itemOptionsState'); + +type CodeViewItemCallbackCache = Partial< + Record +>; + +// Each item gets a tiny state record and an options object whose properties +// come from a shared prototype. This avoids retaining dozens of getter closures +// and property descriptors per item while still letting the item instance read +// the latest CodeView options whenever it renders. +interface CodeViewItemOptionsState { + // Store the id instead of the item object so item -> instance -> options does + // not form a strong cycle back to the item context. The id also lets + // updateItemId() keep reused instances pointed at the current record. + id: string; + // Callback wrappers are only needed when a renderer/interaction path reads a + // callback option, so this cache stays absent for plain CodeView items. + callbackCache?: CodeViewItemCallbackCache; +} + +type CodeViewItemOptions< + LAnnotation, + TMode extends CodeViewMode, +> = CodeViewModeOptions & { + [CODE_VIEW_ITEM_OPTIONS_STATE]: CodeViewItemOptionsState; +}; + +function defineOptionsState( + options: CodeViewModeOptions, + state: CodeViewItemOptionsState +): void { + // Keep the state hidden from option enumeration. Renderer option builders + // should copy known keys explicitly and must not depend on object spread. + Object.defineProperty(options, CODE_VIEW_ITEM_OPTIONS_STATE, { + configurable: false, + enumerable: false, + value: state, + }); +} + +function getItemOptionsState( + options: CodeViewModeOptions +): CodeViewItemOptionsState { + return (options as CodeViewItemOptions)[ + CODE_VIEW_ITEM_OPTIONS_STATE + ]; +} + type CodeViewSharedCallbackOptions = { [TKey in CodeViewSharedCallbackKeys]?: CodeViewOptionCallback< LAnnotation, @@ -363,6 +418,23 @@ type CodeViewSelectionCallbackOptions = { >; }; +function defineItemOption( + target: TOptions, + key: TKey, + get: (receiver: TOptions) => TOptions[TKey] +): void { + // These accessors usually live on the shared prototype. Passing `this` to the + // getter lets one shared accessor resolve per-item state from the receiving + // options object instead of closing over an individual item. + Object.defineProperty(target, key, { + configurable: false, + enumerable: true, + get() { + return get(this as TOptions); + }, + }); +} + export interface CodeViewOptions extends CodeViewPassThroughOptions, @@ -462,6 +534,8 @@ export class CodeView { CodeViewContextItem > = new Map(); private layoutDirtyIndex: number | undefined; + private pendingLayoutReset: PendingCodeViewLayoutReset | undefined; + private renderOptionsRevision = 0; private slotCoordinator: CodeViewCoordinator | undefined; private slotSnapshot: CodeViewRenderedItem[] | undefined; private scrollListeners: Set> = new Set(); @@ -485,6 +559,8 @@ export class CodeView { stickyBottom: -1, }; private itemMetricsCache: VirtualFileMetrics = DEFAULT_CODE_VIEW_FILE_METRICS; + private readonly fileOptionsPrototype: FileOptions; + private readonly diffOptionsPrototype: FileDiffOptions; // 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. @@ -531,6 +607,8 @@ export class CodeView { ) { this.options = options; this.computeMetricsCache(options.itemMetrics); + this.fileOptionsPrototype = this.createFileOptionsPrototype(); + this.diffOptionsPrototype = this.createDiffOptionsPrototype(); this.workerManager = workerManager; this.isContainerManaged = isContainerManaged; @@ -685,6 +763,7 @@ export class CodeView { if (this.root != null) { throw new Error('CodeView.setup: already setup'); } + this.workerManager?.subscribeToThemeChanges(this); this.root = root; this.root.style.overflowAnchor = 'none'; this.container ??= document.createElement('div'); @@ -757,6 +836,7 @@ export class CodeView { this.idToItem.clear(); this.instanceToItem.clear(); this.layoutDirtyIndex = undefined; + this.pendingLayoutReset = undefined; this.stickyContainer.textContent = ''; this.stickyOffset.style.height = ''; this.container?.style.removeProperty('height'); @@ -782,6 +862,7 @@ export class CodeView { this.reset(); this.clearElementPool(); this.restoreScrollInteractions(); + this.workerManager?.unsubscribeToThemeChanges(this); this.resizeObserver?.disconnect(); this.resizeObserver = undefined; this.root?.removeEventListener('scroll', this.handleScroll); @@ -1021,11 +1102,7 @@ export class CodeView { this.idToItem.delete(oldId); item.item.id = newId; this.idToItem.set(newId, item); - if (item.type === 'diff') { - item.instance.setOptions(this.createOptions(item.item)); - } else { - item.instance.setOptions(this.createOptions(item.item)); - } + this.updateItemOptionsId(item.instance.options, newId); if (this.selectedLines?.id === oldId) { this.selectedLines = { ...this.selectedLines, id: newId }; @@ -1114,48 +1191,59 @@ export class CodeView { ); } + public onThemeChange(): void { + this.clearElementPool(); + } + public setOptions(options: CodeViewOptions | undefined): void { if (options == null) { return; } this.capturePendingLayoutAnchor(); + const { options: prevOptions } = this; const previousLayout = this.getLayout(); const { itemMetricsCache: previousItemMetrics } = this; - if (shouldClearPool(this.options, options)) { + if (shouldClearPool(prevOptions, options)) { this.clearElementPool(); } - // NOTE(amadeus): This is also something that's probably ridiculously - // expensive to pull off, and we should probably figure out some way to - // incrementally version/render stuff + this.options = options; const nextItemMetrics = this.computeMetricsCache(options.itemMetrics); const itemMetricsChanged = !areObjectsEqual( previousItemMetrics, nextItemMetrics ); - if (!areObjectsEqual(previousLayout, this.getLayout())) { + const layoutChanged = !areObjectsEqual(previousLayout, this.getLayout()); + if (layoutChanged) { this.syncLayout(); } - for (let index = 0; index < this.items.length; index++) { - const item = this.items[index]; - if (item == null) { - throw new Error('CodeView.setOptions: invalid item index'); - } - if (itemMetricsChanged) { - item.instance.setMetrics(nextItemMetrics, true); - } - if (item.type === 'diff') { - item.instance.setOptions(this.createOptions(item.item)); - } else { - item.instance.setOptions(this.createOptions(item.item)); - } + const itemLayoutChanged = + itemMetricsChanged || hasItemLayoutOptionChanged(prevOptions, options); + if (itemLayoutChanged) { + const previousReset = this.pendingLayoutReset; + this.pendingLayoutReset = { + metrics: itemMetricsChanged ? nextItemMetrics : previousReset?.metrics, + resetFileLayoutCache: true, + resetDiffLayoutCache: true, + includeEstimatedDiffHeights: + previousReset?.includeEstimatedDiffHeights === true || + itemMetricsChanged || + hasCodeViewDiffEstimateOptionChanged(prevOptions, options), + }; + } + + if (layoutChanged || itemLayoutChanged) { + this.markLayoutDirtyFromIndex(0); + this.scrollDirty = true; + } + + if (!areOptionsEqual(prevOptions, options)) { + this.renderOptionsRevision++; } - this.markLayoutDirtyFromIndex(0); - this.scrollDirty = true; if (!this.isContainerManaged && this.items.length > 0) { this.render(); } @@ -1304,69 +1392,46 @@ export class CodeView { ): CodeViewContextItem { const { itemMetricsCache: itemMetrics } = this; if (input.type === 'diff') { + const instance = new VirtualizedFileDiff( + this.createDiffOptions(input.id), + this, + itemMetrics, + this.workerManager, + this.isContainerManaged + ); return { type: 'diff', item: input, version: input.version, index, - instance: new VirtualizedFileDiff( - this.createOptions(input), - this, - itemMetrics, - this.workerManager, - this.isContainerManaged - ), top, height: 0, element: undefined, + renderedOptionsRevision: this.renderOptionsRevision, + instance, } satisfies CodeViewDiffItemContext; } + const instance = new VirtualizedFile( + this.createFileOptions(input.id), + this, + itemMetrics, + this.workerManager, + this.isContainerManaged + ); return { type: 'file', item: input, version: input.version, index, - instance: new VirtualizedFile( - this.createOptions(input), - this, - itemMetrics, - this.workerManager, - this.isContainerManaged - ), top, height: 0, element: undefined, + renderedOptionsRevision: this.renderOptionsRevision, + instance, } satisfies CodeViewFileItemContext; } - private getItemById( - itemId: string - ): CodeViewContextItem | undefined { - const item = this.idToItem.get(itemId); - if (item == null) { - console.error(`CodeView.getItemById: unknown item id "${itemId}"`); - } - return item; - } - - private getItemByMode( - itemId: string, - mode: TMode - ): CodeViewModeItemContext | undefined { - const item = this.getItemById(itemId); - if (item == null) { - return undefined; - } - if (item.type !== mode) { - console.error( - `CodeView.getItemByMode: item id "${itemId}" is not a ${mode}` - ); - return undefined; - } - return item as CodeViewModeItemContext; - } - private applySelectedLines( selection: CodeViewLineSelection | null, options?: SelectionWriteOptions @@ -1429,131 +1494,241 @@ export class CodeView { } } - private wrapCallbackWithContext< - TMode extends CodeViewMode, - TArgs extends unknown[], - TResult, - >( - mode: TMode, - itemId: string, - callback: ( - ...args: [...TArgs, CodeViewModeItemContext] - ) => TResult - ): (...args: TArgs) => TResult | undefined { - return (...args: TArgs) => { - const item = this.getItemByMode(itemId, mode); - if (item == null) { - return undefined; - } - return callback(...args, item); + // CodeView owns advanced option invalidation. These item option facades only + // 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; + + for (const key of CODE_VIEW_FILE_OPTION_KEYS) { + defineItemOption, CodeViewFileOptionKeys>( + prototype, + key, + () => this.options[key] + ); + } + + defineItemOption( + prototype, + 'stickyHeader', + () => this.options.stickyHeaders + ); + defineItemOption( + prototype, + 'collapsed', + (receiver) => + this.getItemOptions(getItemOptionsState(receiver), 'file')?.item + .collapsed === true + ); + + for (const key of CODE_VIEW_SHARED_CALLBACK_KEYS) { + this.defineItemSharedCallback(prototype, 'file', key); + } + for (const key of CODE_VIEW_SELECTION_CALLBACK_KEYS) { + this.defineItemSelectionCallback(prototype, 'file', key); + } + + return prototype; + } + + private createDiffOptionsPrototype(): FileDiffOptions { + const prototype = {} as FileDiffOptions; + + for (const key of CODE_VIEW_DIFF_OPTION_KEYS) { + defineItemOption, CodeViewDiffOptionKeys>( + prototype, + key, + () => this.options[key] + ); + } + + defineItemOption( + prototype, + 'stickyHeader', + () => this.options.stickyHeaders + ); + defineItemOption( + prototype, + 'hunkSeparators', + () => this.options.hunkSeparators + ); + defineItemOption( + prototype, + 'collapsed', + (receiver) => + this.getItemOptions(getItemOptionsState(receiver), 'diff')?.item + .collapsed === true + ); + + for (const key of CODE_VIEW_SHARED_CALLBACK_KEYS) { + this.defineItemSharedCallback(prototype, 'diff', key); + } + for (const key of CODE_VIEW_SELECTION_CALLBACK_KEYS) { + this.defineItemSelectionCallback(prototype, 'diff', key); + } + + return prototype; + } + + 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 state: CodeViewItemOptionsState = { + id, }; + defineOptionsState(options, state); + return options; + } + + 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 state: CodeViewItemOptionsState = { + id, + }; + defineOptionsState(options, state); + return options; + } + + private updateItemOptionsId( + options: FileOptions | FileDiffOptions, + id: string + ): void { + getItemOptionsState(options).id = id; } - private getWrappedOptionCallback< + private getItemOptions( + state: CodeViewItemOptionsState, + mode: TMode + ): CodeViewModeItemContext | undefined { + const item = this.idToItem.get(state.id); + if (item == null || item.type !== mode) { + return undefined; + } + return item as CodeViewModeItemContext; + } + + private defineItemSharedCallback< TMode extends CodeViewMode, TKey extends CodeViewSharedCallbackKeys, >( + options: CodeViewModeOptions, mode: TMode, - key: TKey, - itemId: string - ): CodeViewModeOptions[TKey] | undefined { - const callback = this.options[key] as - | CodeViewModeOptionCallback - | undefined; - if (callback == null) { - return undefined; - } - return this.wrapCallbackWithContext( - mode, - itemId, - callback as CodeViewModeInternalOptionCallback - ) as CodeViewModeOptions[TKey] | undefined; + key: TKey + ): void { + defineItemOption( + options as Record< + TKey, + CodeViewModeOptions[TKey] | undefined + >, + key, + (receiver) => { + const current = this.options[key] as + | CodeViewModeOptionCallback + | undefined; + if (current == null) { + return undefined; + } + + const state = getItemOptionsState( + receiver as CodeViewModeOptions + ); + // Allocate wrapper storage only once a callback option is actually + // observed. Most large CodeViews never read these callback properties. + const callbackCache = (state.callbackCache ??= {}); + let wrapped = callbackCache[key] as + | CodeViewModeOptions[TKey] + | undefined; + if (wrapped == null) { + wrapped = ((...args: unknown[]) => { + const latest = this.getItemOptions(state, mode); + if (latest == null) { + return undefined; + } + const callback = this.options[key] as + | CodeViewModeInternalOptionCallback + | undefined; + return ( + callback as ((...callbackArgs: unknown[]) => unknown) | undefined + )?.(...args, latest); + }) as CodeViewModeOptions[TKey]; + + callbackCache[key] = wrapped; + } + + return wrapped; + } + ); } - private getWrappedSelectionOptionCallback< + private defineItemSelectionCallback< TMode extends CodeViewMode, TKey extends CodeViewSelectionCallbackKeys, >( + options: CodeViewModeOptions, mode: TMode, - key: TKey, - itemId: string - ): CodeViewModeOptions[TKey] | undefined { - if (this.options.enableLineSelection !== true) { - return undefined; - } - const callback = this.options[key] as - | (( - range: SelectedLineRange | null, - context: CodeViewModeItemContext - ) => unknown) - | undefined; - return ((range: SelectedLineRange | null) => { - const item = this.getItemByMode(itemId, mode); - if (item == null) { - return undefined; - } - const selection = range == null ? null : { id: itemId, range }; - if (this.options.controlledSelection !== true) { - if (range != null || this.selectedLines?.id === itemId) { - this.applySelectedLines(selection, { notify: false }); + key: TKey + ): void { + defineItemOption( + options as Record< + TKey, + CodeViewModeOptions[TKey] | undefined + >, + key, + (receiver) => { + if (this.options.enableLineSelection !== true) { + return undefined; } - } - this.options.onSelectedLinesChange?.(selection); - return callback?.(range, item); - }) as CodeViewModeOptions[TKey] | undefined; - } - - private createOptions( - item: CodeViewFileItem - ): FileOptions; - private createOptions( - item: CodeViewDiffItem - ): FileDiffOptions; - private createOptions( - item: CodeViewItem - ): FileOptions | FileDiffOptions { - const { id: itemId, type: mode } = item; - const options = - mode === 'file' - ? ({ - stickyHeader: this.options.stickyHeaders, - } satisfies FileOptions) - : ({ - stickyHeader: this.options.stickyHeaders, - hunkSeparators: this.options.hunkSeparators, - } satisfies FileDiffOptions); - // NOTE(amadeus): Hacks on hacks... - const target = options as Record; - const passThroughKeys = - mode === 'file' ? CODE_VIEW_FILE_OPTION_KEYS : CODE_VIEW_DIFF_OPTION_KEYS; - - for (const key of passThroughKeys) { - const value = this.options[key]; - if (value !== undefined) { - target[key] = value; - } - } - target.collapsed = item.collapsed === true; - for (const key of CODE_VIEW_SHARED_CALLBACK_KEYS) { - const callback = this.getWrappedOptionCallback(mode, key, itemId); - if (callback !== undefined) { - target[key] = callback; - } - } + const state = getItemOptionsState( + receiver as CodeViewModeOptions + ); + // Selection callbacks also use the per-item lazy cache. The wrapper + // owns CodeView selection synchronization and then delegates to the + // latest user callback, if one exists. + const callbackCache = (state.callbackCache ??= {}); + let wrapped = callbackCache[key] as + | CodeViewModeOptions[TKey] + | undefined; + if (wrapped == null) { + wrapped = ((range: SelectedLineRange | null) => { + const latest = this.getItemOptions(state, mode); + if (latest == null) { + return undefined; + } - for (const key of CODE_VIEW_SELECTION_CALLBACK_KEYS) { - const callback = this.getWrappedSelectionOptionCallback( - mode, - key, - itemId - ); - if (callback !== undefined) { - target[key] = callback; - } - } + const selection = + range == null ? null : { id: latest.item.id, range }; + if (this.options.controlledSelection !== true) { + if (range != null || this.selectedLines?.id === latest.item.id) { + this.applySelectedLines(selection, { notify: false }); + } + } - return options; + this.options.onSelectedLinesChange?.(selection); + + const callback = this.options[key] as + | (( + nextRange: SelectedLineRange | null, + context: CodeViewModeItemContext + ) => unknown) + | undefined; + return callback?.(range, latest); + }) as CodeViewModeOptions[TKey]; + + callbackCache[key] = wrapped; + } + + return wrapped; + } + ); } /** @@ -1734,11 +1909,7 @@ export class CodeView { item.item = nextItem; item.version = nextItem.version; - if (item.type === 'diff') { - item.instance.setOptions(this.createOptions(item.item)); - } else { - item.instance.setOptions(this.createOptions(item.item)); - } + item.renderedOptionsRevision = -1; return true; } @@ -2242,8 +2413,9 @@ export class CodeView { // If any item marked itself as difty, we should re-compute everything // after it and then force a new scroll top correction if we aren't already if (this.layoutDirtyIndex != null) { - this.recomputeLayout(this.layoutDirtyIndex); + this.recomputeLayout(this.layoutDirtyIndex, this.pendingLayoutReset); this.layoutDirtyIndex = undefined; + this.pendingLayoutReset = undefined; computeScrollCorrection = true; } @@ -2362,6 +2534,7 @@ export class CodeView { syncRenderedItemOrder(this.stickyContainer, item.element, prevElement); instance.virtualizedSetup(); if (renderItem(item, item.element)) { + item.renderedOptionsRevision = this.renderOptionsRevision; updatedItems.add(item); } prevElement = item.element; @@ -2369,7 +2542,10 @@ export class CodeView { // Otherwise kick off a render as necessary else { syncRenderedItemOrder(this.stickyContainer, item.element, prevElement); - if (renderItem(item)) { + const forceRender = + item.renderedOptionsRevision !== this.renderOptionsRevision; + if (renderItem(item, undefined, forceRender)) { + item.renderedOptionsRevision = this.renderOptionsRevision; updatedItems.add(item); } prevElement = item.element; @@ -2974,7 +3150,10 @@ export class CodeView { * onward is remeasured so downstream positions and total scroll height stay * consistent after inserts, removals, or versioned item updates. */ - private recomputeLayout(startIndex = 0): void { + private recomputeLayout( + startIndex = 0, + reset: PendingCodeViewLayoutReset | undefined + ): void { if (this.items.length === 0) { this.scrollHeight = 0; return; @@ -2997,9 +3176,17 @@ export class CodeView { } item.top = runningTop; if (item.type === 'diff') { - item.height = item.instance.prepareVirtualizedItem(item.item.fileDiff); + item.height = item.instance.prepareCodeViewItem( + item.item.fileDiff, + runningTop, + reset + ); } else { - item.height = item.instance.prepareVirtualizedItem(item.item.file); + item.height = item.instance.prepareCodeViewItem( + item.item.file, + runningTop, + reset + ); } runningTop += item.height; if (index < this.items.length - 1) { @@ -3037,9 +3224,9 @@ function prepareItemInstance( ): number { item.instance.cleanUp(true); if (item.type === 'diff') { - return item.instance.prepareVirtualizedItem(item.item.fileDiff); + return item.instance.prepareCodeViewItem(item.item.fileDiff, item.top); } else { - return item.instance.prepareVirtualizedItem(item.item.file); + return item.instance.prepareCodeViewItem(item.item.file, item.top); } } @@ -3058,6 +3245,51 @@ function shouldClearPool( ); } +function hasItemLayoutOptionChanged( + previousOptions: CodeViewOptions, + nextOptions: CodeViewOptions +): boolean { + return ( + (previousOptions.overflow ?? 'scroll') !== + (nextOptions.overflow ?? 'scroll') || + (previousOptions.disableLineNumbers ?? false) !== + (nextOptions.disableLineNumbers ?? false) || + (previousOptions.disableFileHeader ?? false) !== + (nextOptions.disableFileHeader ?? false) || + previousOptions.unsafeCSS !== nextOptions.unsafeCSS || + (previousOptions.diffStyle ?? 'split') !== + (nextOptions.diffStyle ?? 'split') || + (previousOptions.diffIndicators ?? 'bars') !== + (nextOptions.diffIndicators ?? 'bars') || + (previousOptions.hunkSeparators ?? 'line-info') !== + (nextOptions.hunkSeparators ?? 'line-info') || + (previousOptions.expandUnchanged ?? false) !== + (nextOptions.expandUnchanged ?? false) || + (previousOptions.collapsedContextThreshold ?? + DEFAULT_COLLAPSED_CONTEXT_THRESHOLD) !== + (nextOptions.collapsedContextThreshold ?? + DEFAULT_COLLAPSED_CONTEXT_THRESHOLD) + ); +} + +function hasCodeViewDiffEstimateOptionChanged( + previousOptions: CodeViewOptions, + nextOptions: CodeViewOptions +): boolean { + return ( + (previousOptions.disableFileHeader ?? false) !== + (nextOptions.disableFileHeader ?? false) || + (previousOptions.hunkSeparators ?? 'line-info') !== + (nextOptions.hunkSeparators ?? 'line-info') || + (previousOptions.expandUnchanged ?? false) !== + (nextOptions.expandUnchanged ?? false) || + (previousOptions.collapsedContextThreshold ?? + DEFAULT_COLLAPSED_CONTEXT_THRESHOLD) !== + (nextOptions.collapsedContextThreshold ?? + DEFAULT_COLLAPSED_CONTEXT_THRESHOLD) + ); +} + function isPooledShadowChild(child: Element): boolean { if (child instanceof SVGElement) { return true; @@ -3089,13 +3321,15 @@ function formatSelectedLinePoint( function renderItem( item: CodeViewContextItem, - fileContainer?: HTMLElement + fileContainer?: HTMLElement, + forceRender = false ): boolean { if (item.type === 'diff') { return item.instance.render({ deferManagers: true, fileContainer, fileDiff: item.item.fileDiff, + forceRender, lineAnnotations: item.item.annotations, }); } else { @@ -3103,6 +3337,7 @@ function renderItem( deferManagers: true, fileContainer, file: item.item.file, + forceRender, lineAnnotations: item.item.annotations, }); } diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index f75354b3a..e5e11c6b2 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -46,6 +46,7 @@ import { wrapThemeCSS, wrapUnsafeCSS, } from '../utils/cssWrappers'; +import { getFileRendererOptions } from '../utils/getFileRendererOptions'; import { getLineAnnotationName } from '../utils/getLineAnnotationName'; import { getOrCreateCodeNode } from '../utils/getOrCreateCodeNode'; import { upsertHostThemeStyle } from '../utils/hostTheme'; @@ -190,11 +191,19 @@ export class File { }); } + public onThemeChange(): void { + this.rerender(); + } + public setOptions(options: FileOptions | undefined): void { if (options == null) return; this.options = options; this.cachedHeaderHTML = undefined; - this.interactionManager.setOptions(pluckInteractionOptions(options)); + this.syncInteractionOptions(); + } + + protected syncInteractionOptions(): void { + this.interactionManager.setOptions(pluckInteractionOptions(this.options)); } private mergeOptions(options: Partial>): void { @@ -404,11 +413,8 @@ export class File { }: HydrationSetup): void { this.lineAnnotations = lineAnnotations ?? this.lineAnnotations; this.file = file; - this.fileRenderer.setOptions({ - ...this.options, - headerRenderMode: - this.options.renderCustomHeader != null ? 'custom' : 'default', - }); + this.fileRenderer.setOptions(getFileRendererOptions(this.options)); + this.syncInteractionOptions(); if (this.pre == null) { return; } @@ -469,11 +475,8 @@ export class File { this.cachedHeaderHTML = undefined; } this.file = file; - this.fileRenderer.setOptions({ - ...this.options, - headerRenderMode: - this.options.renderCustomHeader != null ? 'custom' : 'default', - }); + this.fileRenderer.setOptions(getFileRendererOptions(this.options)); + this.syncInteractionOptions(); if (lineAnnotations != null) { this.setLineAnnotations(lineAnnotations); } diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index 38ba91c4d..77da31cb1 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -60,6 +60,7 @@ import { wrapThemeCSS, wrapUnsafeCSS, } from '../utils/cssWrappers'; +import { getDiffHunksRendererOptions } from '../utils/getDiffHunksRendererOptions'; import { getLineAnnotationName } from '../utils/getLineAnnotationName'; import { getOrCreateCodeNode } from '../utils/getOrCreateCodeNode'; import { upsertHostThemeStyle } from '../utils/hostTheme'; @@ -249,15 +250,7 @@ export class FileDiff { protected getHunksRendererOptions( options: FileDiffOptions ): DiffHunksRendererOptions { - return { - ...options, - headerRenderMode: - options.renderCustomHeader != null ? 'custom' : 'default', - hunkSeparators: - typeof options.hunkSeparators === 'function' - ? 'custom' - : options.hunkSeparators, - }; + return getDiffHunksRendererOptions(options); } protected createHunksRenderer( @@ -361,12 +354,16 @@ export class FileDiff { this.options = options; this.cachedHeaderHTML = undefined; this.hunksRenderer.setOptions(this.getHunksRendererOptions(options)); + this.syncInteractionOptions(); + } + + protected syncInteractionOptions(): void { this.interactionManager.setOptions( pluckInteractionOptions( - options, - typeof options.hunkSeparators === 'function' || - (options.hunkSeparators ?? 'line-info') === 'line-info' || - options.hunkSeparators === 'line-info-basic' + this.options, + typeof this.options.hunkSeparators === 'function' || + (this.options.hunkSeparators ?? 'line-info') === 'line-info' || + this.options.hunkSeparators === 'line-info-basic' ? this.handleExpandHunk : undefined, this.getLineIndex @@ -653,6 +650,7 @@ export class FileDiff { return; } + this.syncInteractionOptions(); this.hunksRenderer.hydrate(this.fileDiff); // FIXME(amadeus): not sure how to handle this yet... // this.renderSeparators(); @@ -675,6 +673,10 @@ export class FileDiff { this.render({ forceRender: true, renderRange: this.renderRange }); } + public onThemeChange(): void { + this.rerender(); + } + // This wrapper must stay separate from `expandHunk` because subclasses like // `VirtualizedFileDiff` replace `expandHunk` with their own instance field // after `super()` returns. `InteractionManager` is created in this base @@ -777,6 +779,7 @@ export class FileDiff { return false; } this.hunksRenderer.setOptions(this.getHunksRendererOptions(this.options)); + this.syncInteractionOptions(); this.hunksRenderer.setLineAnnotations(this.lineAnnotations); diff --git a/packages/diffs/src/components/VirtualizedFile.ts b/packages/diffs/src/components/VirtualizedFile.ts index 81bbe119b..74136eea0 100644 --- a/packages/diffs/src/components/VirtualizedFile.ts +++ b/packages/diffs/src/components/VirtualizedFile.ts @@ -2,9 +2,11 @@ import { DEFAULT_VIRTUAL_FILE_METRICS } from '../constants'; import type { FileContents, NumericScrollLineAnchor, + PendingCodeViewLayoutReset, RenderRange, RenderWindow, StickySpecs, + ThemeTypes, VirtualFileMetrics, } from '../types'; import { areObjectsEqual } from '../utils/areObjectsEqual'; @@ -65,6 +67,7 @@ export class VirtualizedFile< private isSetup: boolean = false; private layoutDirty = true; private forceRenderOverride: true | undefined; + private currentCollapsed: boolean | undefined; constructor( options: FileOptions | undefined, @@ -98,6 +101,12 @@ export class VirtualizedFile< } override setOptions(options: FileOptions | undefined): void { + if (this.isAdvancedMode()) { + throw new Error( + 'VirtualizedFile.setOptions cannot be used inside CodeView. Update CodeView options instead.' + ); + } + if (options == null) return; const { options: previousOptions } = this; const optionsChanged = !areOptionsEqual(previousOptions, options); @@ -113,16 +122,32 @@ export class VirtualizedFile< if (optionsChanged) { this.forceRenderOverride = true; } - if (optionsChanged && this.isSimpleMode()) { + if (optionsChanged) { this.virtualizer.instanceChanged(this, layoutChanged); } } + override setThemeType(themeType: ThemeTypes): void { + if (this.isAdvancedMode()) { + throw new Error( + 'VirtualizedFile.setThemeType cannot be used inside CodeView. Update CodeView options instead.' + ); + } + + super.setThemeType(themeType); + } + private resetLayoutCache(recompute = false): void { this.layoutDirty = true; - this.cache.heights.clear(); - this.cache.checkpoints = []; - this.renderRange = undefined; + if (this.cache.heights.size > 0) { + this.cache.heights.clear(); + } + if (this.cache.checkpoints.length > 0) { + this.cache.checkpoints.length = 0; + } + if (this.renderRange != null) { + this.renderRange = undefined; + } // NOTE(amadeus): In CodeView we intentionally batch computes to all happen // at the same time, so we shouldn't trigger this there. if (recompute && this.isSimpleMode()) { @@ -224,12 +249,32 @@ export class VirtualizedFile< // its virtualized top, and returning an approximate height. This method is // called while downstream items are being re-positioned, so later changes // should keep clean instances on a cached-height fast path. - public prepareVirtualizedItem(file: FileContents): number { + public prepareCodeViewItem( + file: FileContents, + top: number, + reset?: PendingCodeViewLayoutReset + ): number { + let shouldResetLayoutCache = reset?.resetFileLayoutCache === true; + if (reset?.metrics != null) { + this.metrics = reset.metrics; + shouldResetLayoutCache = true; + } + + const { collapsed = false } = this.options; + if (this.currentCollapsed !== collapsed) { + this.currentCollapsed = collapsed; + shouldResetLayoutCache = true; + } + + if (shouldResetLayoutCache) { + this.resetLayoutCache(); + } + if (this.file !== file) { this.layoutDirty = true; } this.file = file; - this.top = this.getVirtualizedTop(); + this.top = top; this.computeApproximateSize(); return this.height; } diff --git a/packages/diffs/src/components/VirtualizedFileDiff.ts b/packages/diffs/src/components/VirtualizedFileDiff.ts index 868971fc0..c051f98b8 100644 --- a/packages/diffs/src/components/VirtualizedFileDiff.ts +++ b/packages/diffs/src/components/VirtualizedFileDiff.ts @@ -2,24 +2,34 @@ import { DEFAULT_COLLAPSED_CONTEXT_THRESHOLD } from '../constants'; import type { ExpansionDirections, FileDiffMetadata, + Hunk, HunkSeparators, NumericScrollLineAnchor, + PendingCodeViewLayoutReset, RenderRange, RenderWindow, SelectionSide, StickySpecs, + ThemeTypes, VirtualFileMetrics, } from '../types'; +import { areDiffTargetsEqual } from '../utils/areDiffTargetsEqual'; import { areObjectsEqual } from '../utils/areObjectsEqual'; import { areOptionsEqual } from '../utils/areOptionsEqual'; +import { computeEstimatedDiffHeights } from '../utils/computeEstimatedDiffHeights'; import { computeVirtualFileMetrics, - getDefaultHunkSeparatorHeight, getVirtualFileHeaderRegion, getVirtualFilePaddingBottom, } from '../utils/computeVirtualFileMetrics'; import { iterateOverDiff } from '../utils/iterateOverDiff'; import { parseDiffFromFile } from '../utils/parseDiffFromFile'; +import { + type ExpandedRegionResult, + getExpandedRegion, + getLeadingHunkSeparatorLayout, + getTrailingHunkSeparatorLayout, +} from '../utils/virtualDiffLayout'; import type { WorkerPoolManager } from '../worker'; import type { CodeView } from './CodeView'; import { @@ -29,13 +39,6 @@ import { } from './FileDiff'; import type { Virtualizer } from './Virtualizer'; -interface ExpandedRegionSpecs { - fromStart: number; - fromEnd: number; - collapsedLines: number; - renderAll: boolean; -} - interface DiffLayoutCheckpoint { renderedLineIndex: number; lineIndex: number; @@ -43,9 +46,14 @@ interface DiffLayoutCheckpoint { } interface DiffLayoutCache { - // Sparse map: view-specific line index -> measured height. Only stores lines - // that differ from what is returned by `getLineHeight`. - heights: Map; + // Sparse map: view-specific line index -> measured height delta from the + // baseline line height. Only stores lines that differ from the estimate. + heightDeltas: Map; + measuredHeightDeltaTotal: number; + // Baseline estimated heights for the active diff content. These are preserved + // across style/collapse toggles and cleared only when estimate inputs change. + estimatedSplitHeight: number | undefined; + estimatedUnifiedHeight: number | undefined; // Sparse measured positions used to resume deep geometry scans near a target // diff line, rendered row, or scroll offset instead of replaying layout from // the first hunk. @@ -54,6 +62,11 @@ interface DiffLayoutCache { totalLines: number; } +interface ResetLayoutCacheOptions { + forceSimpleRecompute?: boolean; + includeEstimatedHeights?: boolean; +} + const LAYOUT_CHECKPOINT_INTERVAL = 5_000; let instanceId = -1; @@ -67,7 +80,10 @@ export class VirtualizedFileDiff< public height: number = 0; private metrics: VirtualFileMetrics; private cache: DiffLayoutCache = { - heights: new Map(), + heightDeltas: new Map(), + measuredHeightDeltaTotal: 0, + estimatedSplitHeight: undefined, + estimatedUnifiedHeight: undefined, checkpoints: [], totalLines: 0, }; @@ -76,6 +92,7 @@ export class VirtualizedFileDiff< private virtualizer: Virtualizer | CodeView; private layoutDirty = true; private forceRenderOverride: true | undefined; + private currentCollapsed: boolean | undefined; constructor( options: FileDiffOptions | undefined, @@ -99,21 +116,30 @@ export class VirtualizedFileDiff< } this.metrics = nextMetrics; - this.resetLayoutCache(); + this.resetLayoutCache({ includeEstimatedHeights: true }); } // Get the height for a line, using cached value if available. // If not cached and hasMetadataLine is true, adds lineHeight for the metadata. private getLineHeight(lineIndex: number, hasMetadataLine = false): number { - const cached = this.cache.heights.get(lineIndex); - if (cached != null) { - return cached; - } + return ( + this.getEstimatedLineHeight(hasMetadataLine) + + (this.cache.heightDeltas.get(lineIndex) ?? 0) + ); + } + + private getEstimatedLineHeight(hasMetadataLine = false): number { const multiplier = hasMetadataLine ? 2 : 1; return this.metrics.lineHeight * multiplier; } 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); @@ -123,7 +149,13 @@ export class VirtualizedFileDiff< super.setOptions(options); if (layoutChanged) { - this.resetLayoutCache(true); + this.resetLayoutCache({ + forceSimpleRecompute: true, + includeEstimatedHeights: hasDiffEstimateOptionChanged( + previousOptions, + options + ), + }); } // Any option can affect rendered DOM; only layout-affecting options clear // the measured height cache above. @@ -135,15 +167,43 @@ export class VirtualizedFileDiff< } } - private resetLayoutCache(recompute = false): void { + override setThemeType(themeType: ThemeTypes): void { + if (this.isAdvancedMode()) { + throw new Error( + 'VirtualizedFileDiff.setThemeType cannot be used inside CodeView. Update CodeView options instead.' + ); + } + + super.setThemeType(themeType); + } + + private resetLayoutCache({ + forceSimpleRecompute = false, + includeEstimatedHeights = false, + }: ResetLayoutCacheOptions = {}): void { this.layoutDirty = true; - this.cache.heights.clear(); - this.cache.checkpoints = []; - this.cache.totalLines = 0; - this.renderRange = undefined; + if (this.cache.heightDeltas.size > 0) { + this.cache.heightDeltas.clear(); + } + if (this.cache.measuredHeightDeltaTotal !== 0) { + this.cache.measuredHeightDeltaTotal = 0; + } + if (this.cache.checkpoints.length > 0) { + this.cache.checkpoints.length = 0; + } + if (this.cache.totalLines !== 0) { + this.cache.totalLines = 0; + } + if (includeEstimatedHeights) { + this.cache.estimatedSplitHeight = undefined; + this.cache.estimatedUnifiedHeight = undefined; + } + if (this.renderRange != null) { + this.renderRange = undefined; + } // NOTE(amadeus): In CodeView we intentionally batch computes to all happen // at the same time, so we shouldn't trigger this there. - if (recompute && this.isSimpleMode()) { + if (forceSimpleRecompute && this.isSimpleMode()) { this.computeApproximateSize(); } } @@ -206,24 +266,20 @@ export class VirtualizedFileDiff< measuredHeight += line.nextElementSibling.getBoundingClientRect().height; } - const expectedHeight = this.getLineHeight(lineIndex, hasMetadata); + const estimatedHeight = this.getEstimatedLineHeight(hasMetadata); + const previousDelta = this.cache.heightDeltas.get(lineIndex) ?? 0; + const nextDelta = measuredHeight - estimatedHeight; - if (measuredHeight === expectedHeight) { + if (nextDelta === previousDelta) { continue; } hasHeightChange = true; - // Line is back to standard height (e.g., after window resize) - // Remove from cache - if ( - measuredHeight === - this.metrics.lineHeight * (hasMetadata ? 2 : 1) - ) { - this.cache.heights.delete(lineIndex); - } - // Non-standard height, cache it - else { - this.cache.heights.set(lineIndex, measuredHeight); + this.cache.measuredHeightDeltaTotal += nextDelta - previousDelta; + if (nextDelta === 0) { + this.cache.heightDeltas.delete(lineIndex); + } else { + this.cache.heightDeltas.set(lineIndex, nextDelta); } } } @@ -248,12 +304,36 @@ export class VirtualizedFileDiff< // its virtualized top, and returning an approximate height. This method is // called while downstream items are being re-positioned, so later changes // should keep clean instances on a cached-height fast path. - public prepareVirtualizedItem(fileDiff: FileDiffMetadata): number { - if (this.fileDiff !== fileDiff) { - this.resetLayoutCache(); + public prepareCodeViewItem( + fileDiff: FileDiffMetadata, + top: number, + reset?: PendingCodeViewLayoutReset + ): number { + const targetChanged = !areDiffTargetsEqual(this.fileDiff, fileDiff); + let shouldResetLayoutCache = + reset?.resetDiffLayoutCache === true || targetChanged; + let includeEstimatedHeights = + targetChanged || + (reset?.resetDiffLayoutCache === true && + reset.includeEstimatedDiffHeights); + + if (reset?.metrics != null) { + this.metrics = computeVirtualFileMetrics(reset.metrics); + shouldResetLayoutCache = true; + includeEstimatedHeights = true; + } + + const { collapsed = false } = this.options; + if (this.currentCollapsed !== collapsed) { + this.currentCollapsed = collapsed; + shouldResetLayoutCache = true; + } + + if (shouldResetLayoutCache) { + this.resetLayoutCache({ includeEstimatedHeights }); } this.fileDiff = fileDiff; - this.top = this.getVirtualizedTop(); + this.top = top; this.computeApproximateSize(); return this.height; } @@ -279,10 +359,9 @@ export class VirtualizedFileDiff< } = this.options; const diffStyle = this.getDiffStyle(); const hunkSeparators = this.getHunkSeparatorType(); - const hunkSeparatorHeight = this.getHunkSeparatorHeight(hunkSeparators); - const separatorGap = this.getSeparatorGap(hunkSeparators); const targetLineIndex = diffStyle === 'split' ? targetLineIndexes[1] : targetLineIndexes[0]; + this.approximateLayoutCheckpoints(); const checkpoint = this.getLayoutCheckpointBeforeLineIndex(targetLineIndex); let top = checkpoint?.top ?? @@ -320,28 +399,27 @@ export class VirtualizedFileDiff< ); } - if ( - collapsedBefore > 0 && - this.hasLeadingHunkSeparator( + if (collapsedBefore > 0) { + const separator = getLeadingHunkSeparatorLayout({ + type: hunkSeparators, + metrics: this.metrics, hunkIndex, - hunk?.hunkSpecs, - hunkSeparators - ) - ) { - if (hunkIndex > 0) { - top += separatorGap; - } - if ( - targetLineIndex >= lineIndex - collapsedBefore && - targetLineIndex < lineIndex - ) { - position = { - top, - height: hunkSeparatorHeight, - }; - return true; + hunkSpecs: hunk?.hunkSpecs, + }); + if (separator != null) { + top += separator.gapBefore; + if ( + targetLineIndex >= lineIndex - collapsedBefore && + targetLineIndex < lineIndex + ) { + position = { + top, + height: separator.height, + }; + return true; + } + top += separator.height + separator.gapAfter; } - top += hunkSeparatorHeight + separatorGap; } const lineHeight = this.getLineHeight( @@ -357,21 +435,24 @@ export class VirtualizedFileDiff< } top += lineHeight; - if ( - collapsedAfter > 0 && - this.hasTrailingHunkSeparator(hunkSeparators) - ) { - if ( - targetLineIndex > lineIndex && - targetLineIndex <= lineIndex + collapsedAfter - ) { - position = { - top: top + separatorGap, - height: hunkSeparatorHeight, - }; - return true; + if (collapsedAfter > 0) { + const separator = getTrailingHunkSeparatorLayout({ + type: hunkSeparators, + metrics: this.metrics, + }); + if (separator != null) { + if ( + targetLineIndex > lineIndex && + targetLineIndex <= lineIndex + collapsedAfter + ) { + position = { + top: top + separator.gapBefore, + height: separator.height, + }; + return true; + } + top += separator.totalHeight; } - top += separatorGap + hunkSeparatorHeight; } return false; @@ -400,9 +481,8 @@ export class VirtualizedFileDiff< const diffStyle = this.getDiffStyle(); const hunkSeparators = this.getHunkSeparatorType(); - const hunkSeparatorHeight = this.getHunkSeparatorHeight(hunkSeparators); - const separatorGap = this.getSeparatorGap(hunkSeparators); + this.approximateLayoutCheckpoints(); const checkpoint = this.getLayoutCheckpointBeforeTop(localViewportTop); let top = checkpoint?.top ?? @@ -439,18 +519,16 @@ export class VirtualizedFileDiff< ); } - if ( - collapsedBefore > 0 && - this.hasLeadingHunkSeparator( + if (collapsedBefore > 0) { + const separator = getLeadingHunkSeparatorLayout({ + type: hunkSeparators, + metrics: this.metrics, hunkIndex, - hunk?.hunkSpecs, - hunkSeparators - ) - ) { - if (hunkIndex > 0) { - top += separatorGap; + hunkSpecs: hunk?.hunkSpecs, + }); + if (separator != null) { + top += separator.totalHeight; } - top += hunkSeparatorHeight + separatorGap; } if (top >= localViewportTop) { @@ -478,11 +556,14 @@ export class VirtualizedFileDiff< ); top += lineHeight; - if ( - collapsedAfter > 0 && - this.hasTrailingHunkSeparator(hunkSeparators) - ) { - top += separatorGap + hunkSeparatorHeight; + if (collapsedAfter > 0) { + const separator = getTrailingHunkSeparatorLayout({ + type: hunkSeparators, + metrics: this.metrics, + }); + if (separator != null) { + top += separator.totalHeight; + } } return false; @@ -528,7 +609,7 @@ export class VirtualizedFileDiff< this.getSimpleVirtualizer()?.disconnect(this.fileContainer); } if (!recycle) { - this.resetLayoutCache(); + this.resetLayoutCache({ includeEstimatedHeights: true }); } this.isSetup = false; super.cleanUp(recycle); @@ -544,9 +625,8 @@ export class VirtualizedFileDiff< direction, expansionLineCountOverride ); - this.layoutDirty = true; + this.resetLayoutCache({ includeEstimatedHeights: true }); this.computeApproximateSize(); - this.renderRange = undefined; this.virtualizer.instanceChanged(this, true); }; @@ -577,9 +657,8 @@ export class VirtualizedFileDiff< this.virtualizer.instanceChanged(this, false); } - // Compute the approximate size of the file using cached line heights. - // Uses lineHeight for lines without cached measurements. - // We should probably optimize this if there are no custom line heights... + // Compute the approximate size from the cached baseline estimate plus any + // measured height deltas observed in rendered rows. // The reason we refer to this as `approximate size` is because heights my // dynamically change for a number of reasons so we can never be fully sure // if the height is 100% accurate @@ -598,21 +677,11 @@ export class VirtualizedFileDiff< return; } - const { - disableFileHeader = false, - expandUnchanged = false, - collapsed = false, - collapsedContextThreshold = DEFAULT_COLLAPSED_CONTEXT_THRESHOLD, - } = this.options; - const diffStyle = this.getDiffStyle(); - const hunkSeparators = this.getHunkSeparatorType(); - const hunkSeparatorHeight = this.getHunkSeparatorHeight(hunkSeparators); - const separatorGap = this.getSeparatorGap(hunkSeparators); + const { disableFileHeader = false, collapsed = false } = this.options; const headerRegion = getVirtualFileHeaderRegion( this.metrics, disableFileHeader ); - const paddingBottom = getVirtualFilePaddingBottom(this.metrics); this.height += headerRegion; if (collapsed) { @@ -620,85 +689,80 @@ export class VirtualizedFileDiff< return; } - let renderedLineIndex = 0; - iterateOverDiff({ - diff: this.fileDiff, - diffStyle, - expandedHunks: expandUnchanged - ? true - : this.hunksRenderer.getExpandedHunksMap(), - collapsedContextThreshold, - callback: ({ - hunkIndex, - hunk, - collapsedBefore, - collapsedAfter, - deletionLine, - additionLine, - }) => { - const splitLineIndex = - additionLine != null - ? additionLine.splitLineIndex - : deletionLine.splitLineIndex; - const unifiedLineIndex = - additionLine != null - ? additionLine.unifiedLineIndex - : deletionLine.unifiedLineIndex; - const hasMetadata = - (additionLine?.noEOFCR ?? false) || (deletionLine?.noEOFCR ?? false); - const lineIndex = - diffStyle === 'split' ? splitLineIndex : unifiedLineIndex; - this.addLayoutCheckpoint(renderedLineIndex, lineIndex, this.height); - if ( - collapsedBefore > 0 && - this.hasLeadingHunkSeparator( - hunkIndex, - hunk?.hunkSpecs, - hunkSeparators - ) - ) { - if (hunkIndex > 0) { - this.height += separatorGap; - } - this.height += hunkSeparatorHeight + separatorGap; - } + this.height = + this.getActiveEstimatedHeight() + this.cache.measuredHeightDeltaTotal; - this.height += this.getLineHeight(lineIndex, hasMetadata); + if (shouldValidateSize && !isFirstCompute) { + this.validateComputedHeight(); + } + this.layoutDirty = false; + } - if ( - collapsedAfter > 0 && - this.hasTrailingHunkSeparator(hunkSeparators) - ) { - this.height += separatorGap + hunkSeparatorHeight; - } - renderedLineIndex++; - }, + private getActiveEstimatedHeight(): number { + this.ensureEstimatedDiffHeights(); + const estimatedHeight = + this.getDiffStyle() === 'split' + ? this.cache.estimatedSplitHeight + : this.cache.estimatedUnifiedHeight; + if (estimatedHeight == null) { + throw new Error( + 'VirtualizedFileDiff.getActiveEstimatedHeight: missing estimated height' + ); + } + return estimatedHeight; + } + + private ensureEstimatedDiffHeights(): void { + if (this.fileDiff == null) { + this.cache.estimatedSplitHeight = undefined; + this.cache.estimatedUnifiedHeight = undefined; + return; + } + if ( + this.cache.estimatedSplitHeight != null && + this.cache.estimatedUnifiedHeight != null + ) { + return; + } + + const { + disableFileHeader = false, + expandUnchanged = false, + collapsedContextThreshold = DEFAULT_COLLAPSED_CONTEXT_THRESHOLD, + } = this.options; + const { splitHeight, unifiedHeight } = computeEstimatedDiffHeights({ + fileDiff: this.fileDiff, + metrics: this.metrics, + disableFileHeader, + hunkSeparators: this.getHunkSeparatorType(), + expandUnchanged, + expandedHunks: this.hunksRenderer.getExpandedHunksMap(), + collapsedContextThreshold, }); - this.cache.totalLines = renderedLineIndex; + this.cache.estimatedSplitHeight = splitHeight; + this.cache.estimatedUnifiedHeight = unifiedHeight; + } - // Bottom padding - if (this.fileDiff.hunks.length > 0) { - this.height += paddingBottom; + private validateComputedHeight(): void { + if (this.fileContainer == null || this.fileDiff == null) { + return; } - if (this.fileContainer != null && shouldValidateSize && !isFirstCompute) { - const rect = this.fileContainer.getBoundingClientRect(); - if (rect.height !== this.height) { - console.log( - 'VirtualizedFileDiff.computeApproximateSize: computed height doesnt match', - { - name: this.fileDiff.name, - elementHeight: rect.height, - computedHeight: this.height, - } - ); - } else { - console.log( - 'VirtualizedFileDiff.computeApproximateSize: computed height IS CORRECT' - ); - } + const rect = this.fileContainer.getBoundingClientRect(); + if (rect.height !== this.height) { + console.log( + 'VirtualizedFileDiff.computeApproximateSize: computed height doesnt match', + { + name: this.fileDiff.name, + elementHeight: rect.height, + computedHeight: this.height, + } + ); + } else { + console.log( + 'VirtualizedFileDiff.computeApproximateSize: computed height IS CORRECT' + ); } - this.layoutDirty = false; } override render({ @@ -817,52 +881,180 @@ export class VirtualizedFileDiff< return getOptionHunkSeparatorType(this.options.hunkSeparators); } - private getHunkSeparatorHeight(type = this.getHunkSeparatorType()): number { - return ( - this.metrics.hunkSeparatorHeight ?? getDefaultHunkSeparatorHeight(type) - ); - } + private approximateLayoutCheckpoints(): void { + if ( + this.cache.checkpoints.length > 0 || + this.fileDiff == null || + this.fileDiff.hunks.length === 0 || + this.options.collapsed === true + ) { + return; + } - private getSeparatorGap(type = this.getHunkSeparatorType()): number { - return type === 'simple' || - type === 'metadata' || - type === 'line-info-basic' - ? 0 - : this.metrics.spacing; - } + const { + disableFileHeader = false, + expandUnchanged = false, + collapsedContextThreshold = DEFAULT_COLLAPSED_CONTEXT_THRESHOLD, + } = this.options; + const diffStyle = this.getDiffStyle(); + const hunkSeparators = this.getHunkSeparatorType(); + const expandedHunks = expandUnchanged + ? true + : this.hunksRenderer.getExpandedHunksMap(); + const heightDeltaPrefix = createHeightDeltaPrefix(this.cache.heightDeltas); + let top = getVirtualFileHeaderRegion(this.metrics, disableFileHeader); + let renderedLineIndex = 0; - private hasLeadingHunkSeparator( - hunkIndex: number, - hunkSpecs: string | undefined, - type = this.getHunkSeparatorType() - ): boolean { - switch (type) { - case 'simple': - return hunkIndex > 0; - case 'metadata': - return hunkSpecs != null; - case 'line-info': - case 'line-info-basic': - case 'custom': - return true; - } - } + const processRows = ({ + rowCount, + startLineIndex, + preSeparatorHeight = 0, + postSeparatorHeight = 0, + metadataOffsets = [], + }: { + rowCount: number; + startLineIndex: number; + preSeparatorHeight?: number; + postSeparatorHeight?: number; + metadataOffsets?: number[]; + }) => { + if (rowCount <= 0) { + return; + } - private hasTrailingHunkSeparator( - type = this.getHunkSeparatorType() - ): boolean { - return type !== 'simple' && type !== 'metadata'; - } + const blockStart = renderedLineIndex; + const blockEnd = renderedLineIndex + rowCount; + let nextCheckpoint = getNextCheckpointIndex(blockStart); + while (nextCheckpoint < blockEnd) { + const offset = nextCheckpoint - blockStart; + const checkpointTop = + top + + (offset > 0 ? preSeparatorHeight : 0) + + offset * this.metrics.lineHeight + + countMetadataOffsetsBefore(metadataOffsets, offset) * + this.metrics.lineHeight + + sumHeightDeltas( + heightDeltaPrefix, + startLineIndex, + startLineIndex + offset + ); + this.cache.checkpoints.push({ + renderedLineIndex: nextCheckpoint, + lineIndex: startLineIndex + offset, + top: checkpointTop, + }); + nextCheckpoint += LAYOUT_CHECKPOINT_INTERVAL; + } - private addLayoutCheckpoint( - renderedLineIndex: number, - lineIndex: number, - top: number - ): void { - if (renderedLineIndex % LAYOUT_CHECKPOINT_INTERVAL !== 0) { - return; + top += + preSeparatorHeight + + rowCount * this.metrics.lineHeight + + metadataOffsets.length * this.metrics.lineHeight + + sumHeightDeltas( + heightDeltaPrefix, + startLineIndex, + startLineIndex + rowCount + ) + + postSeparatorHeight; + renderedLineIndex = blockEnd; + }; + + for ( + let hunkIndex = 0; + hunkIndex < this.fileDiff.hunks.length; + hunkIndex++ + ) { + const hunk = this.fileDiff.hunks[hunkIndex]; + if (hunk == null) { + throw new Error( + 'VirtualizedFileDiff.approximateLayoutCheckpoints: invalid hunk index' + ); + } + + const leadingRegion = getExpandedRegion({ + isPartial: this.fileDiff.isPartial, + rangeSize: hunk.collapsedBefore, + expandedHunks, + hunkIndex, + collapsedContextThreshold, + }); + const leadingSeparatorHeight = + leadingRegion.collapsedLines > 0 + ? (getLeadingHunkSeparatorLayout({ + type: hunkSeparators, + metrics: this.metrics, + hunkIndex, + hunkSpecs: hunk.hunkSpecs, + })?.totalHeight ?? 0) + : 0; + + processRows({ + rowCount: leadingRegion.fromStart, + startLineIndex: + (diffStyle === 'split' + ? hunk.splitLineStart + : hunk.unifiedLineStart) - leadingRegion.rangeSize, + }); + + let pendingLeadingSeparatorHeight = leadingSeparatorHeight; + processRows({ + rowCount: leadingRegion.fromEnd, + startLineIndex: + (diffStyle === 'split' + ? hunk.splitLineStart + : hunk.unifiedLineStart) - leadingRegion.fromEnd, + preSeparatorHeight: pendingLeadingSeparatorHeight, + }); + if (leadingRegion.fromEnd > 0) { + pendingLeadingSeparatorHeight = 0; + } + + const trailingRegion = getTrailingExpandedRegion({ + fileDiff: this.fileDiff, + hunk, + hunkIndex, + expandedHunks, + collapsedContextThreshold, + }); + const trailingSeparatorHeight = + trailingRegion != null && trailingRegion.collapsedLines > 0 + ? (getTrailingHunkSeparatorLayout({ + type: hunkSeparators, + metrics: this.metrics, + })?.totalHeight ?? 0) + : 0; + const trailingExpandedCount = + trailingRegion != null + ? trailingRegion.fromStart + trailingRegion.fromEnd + : 0; + + const hunkBodyRowCount = + diffStyle === 'split' ? hunk.splitLineCount : hunk.unifiedLineCount; + const hunkBodyStartLineIndex = + diffStyle === 'split' ? hunk.splitLineStart : hunk.unifiedLineStart; + processRows({ + rowCount: hunkBodyRowCount, + startLineIndex: hunkBodyStartLineIndex, + preSeparatorHeight: pendingLeadingSeparatorHeight, + postSeparatorHeight: + trailingExpandedCount === 0 ? trailingSeparatorHeight : 0, + metadataOffsets: getHunkMetadataOffsets({ + diffStyle, + hunk, + rowCount: hunkBodyRowCount, + }), + }); + + if (trailingRegion != null && trailingExpandedCount > 0) { + processRows({ + rowCount: trailingExpandedCount, + startLineIndex: hunkBodyStartLineIndex + hunkBodyRowCount, + postSeparatorHeight: trailingSeparatorHeight, + }); + } } - this.cache.checkpoints.push({ renderedLineIndex, lineIndex, top }); + + this.cache.totalLines = renderedLineIndex; } // Find the nearest sparse layout checkpoint at or before an active @@ -942,44 +1134,6 @@ export class VirtualizedFileDiff< return undefined; } - private getExpandedRegion( - isPartial: boolean, - hunkIndex: number, - rangeSize: number - ): ExpandedRegionSpecs { - if (rangeSize <= 0 || isPartial) { - return { - fromStart: 0, - fromEnd: 0, - collapsedLines: Math.max(rangeSize, 0), - renderAll: false, - }; - } - const { - expandUnchanged = false, - collapsedContextThreshold = DEFAULT_COLLAPSED_CONTEXT_THRESHOLD, - } = this.options; - if (expandUnchanged || rangeSize <= collapsedContextThreshold) { - return { - fromStart: rangeSize, - fromEnd: 0, - collapsedLines: 0, - renderAll: true, - }; - } - const region = this.hunksRenderer.getExpandedHunk(hunkIndex); - const fromStart = Math.min(Math.max(region.fromStart, 0), rangeSize); - const fromEnd = Math.min(Math.max(region.fromEnd, 0), rangeSize); - const expandedCount = fromStart + fromEnd; - const renderAll = expandedCount >= rangeSize; - return { - fromStart, - fromEnd, - collapsedLines: Math.max(rangeSize - expandedCount, 0), - renderAll, - }; - } - private getExpandedLineCount( fileDiff: FileDiffMetadata, diffStyle: 'split' | 'unified' @@ -993,16 +1147,26 @@ export class VirtualizedFileDiff< return count; } + const { + expandUnchanged = false, + collapsedContextThreshold = DEFAULT_COLLAPSED_CONTEXT_THRESHOLD, + } = this.options; + const expandedHunks = expandUnchanged + ? true + : this.hunksRenderer.getExpandedHunksMap(); + for (const [hunkIndex, hunk] of fileDiff.hunks.entries()) { const hunkCount = diffStyle === 'split' ? hunk.splitLineCount : hunk.unifiedLineCount; count += hunkCount; const collapsedBefore = Math.max(hunk.collapsedBefore, 0); - const { fromStart, fromEnd, renderAll } = this.getExpandedRegion( - fileDiff.isPartial, + const { fromStart, fromEnd, renderAll } = getExpandedRegion({ + isPartial: fileDiff.isPartial, + rangeSize: collapsedBefore, + expandedHunks, hunkIndex, - collapsedBefore - ); + collapsedContextThreshold, + }); if (collapsedBefore > 0) { count += renderAll ? collapsedBefore : fromStart + fromEnd; } @@ -1023,11 +1187,13 @@ export class VirtualizedFileDiff< } const trailingRangeSize = Math.min(additionRemaining, deletionRemaining); if (lastHunk != null && trailingRangeSize > 0) { - const { fromStart, renderAll } = this.getExpandedRegion( - fileDiff.isPartial, - fileDiff.hunks.length, - trailingRangeSize - ); + const { fromStart, renderAll } = getExpandedRegion({ + isPartial: fileDiff.isPartial, + rangeSize: trailingRangeSize, + expandedHunks, + hunkIndex: fileDiff.hunks.length, + collapsedContextThreshold, + }); count += renderAll ? trailingRangeSize : fromStart; } } @@ -1048,9 +1214,8 @@ export class VirtualizedFileDiff< const { hunkLineCount, lineHeight } = this.metrics; const diffStyle = this.getDiffStyle(); const hunkSeparators = this.getHunkSeparatorType(); - const hunkSeparatorHeight = this.getHunkSeparatorHeight(hunkSeparators); const fileHeight = this.height; - const lineCount = + let lineCount = this.cache.totalLines > 0 ? this.cache.totalLines : this.getExpandedLineCount(fileDiff, diffStyle); @@ -1081,6 +1246,10 @@ export class VirtualizedFileDiff< bufferAfter: 0, }; } + + this.approximateLayoutCheckpoints(); + lineCount = this.cache.totalLines > 0 ? this.cache.totalLines : lineCount; + const estimatedTargetLines = Math.ceil( Math.max(bottom - top, 0) / lineHeight ); @@ -1092,7 +1261,6 @@ export class VirtualizedFileDiff< const hunkOffsets: number[] = []; // Halfway between top & bottom, represented as an absolute position const viewportCenter = (top + bottom) / 2; - const separatorGap = this.getSeparatorGap(hunkSeparators); // Start the scan before the viewport so we collect hunk offsets that may be // needed for bufferBefore. This only chooses the scan origin; the returned // render range is still computed from the visible window below. @@ -1133,17 +1301,16 @@ export class VirtualizedFileDiff< : deletionLine.unifiedLineIndex; const hasMetadata = (additionLine?.noEOFCR ?? false) || (deletionLine?.noEOFCR ?? false); - const gapAdjustment = - collapsedBefore > 0 && - this.hasLeadingHunkSeparator( - hunkIndex, - hunk?.hunkSpecs, - hunkSeparators - ) - ? hunkSeparatorHeight + - separatorGap + - (hunkIndex > 0 ? separatorGap : 0) - : 0; + const leadingSeparator = + collapsedBefore > 0 + ? getLeadingHunkSeparatorLayout({ + type: hunkSeparators, + metrics: this.metrics, + hunkIndex, + hunkSpecs: hunk?.hunkSpecs, + }) + : undefined; + const gapAdjustment = leadingSeparator?.totalHeight ?? 0; absoluteLineTop += gapAdjustment; @@ -1196,11 +1363,12 @@ export class VirtualizedFileDiff< currentLine++; absoluteLineTop += lineHeight; - if ( - collapsedAfter > 0 && - this.hasTrailingHunkSeparator(hunkSeparators) - ) { - absoluteLineTop += hunkSeparatorHeight + separatorGap; + if (collapsedAfter > 0) { + absoluteLineTop += + getTrailingHunkSeparatorLayout({ + type: hunkSeparators, + metrics: this.metrics, + })?.totalHeight ?? 0; } return false; @@ -1263,6 +1431,157 @@ export class VirtualizedFileDiff< } } +interface HeightDeltaPrefix { + lineIndexes: number[]; + prefixTotals: number[]; +} + +function createHeightDeltaPrefix( + heightDeltas: Map +): HeightDeltaPrefix { + const entries = Array.from(heightDeltas).sort((a, b) => a[0] - b[0]); + const lineIndexes: number[] = []; + const prefixTotals = [0]; + let total = 0; + for (const [lineIndex, delta] of entries) { + lineIndexes.push(lineIndex); + total += delta; + prefixTotals.push(total); + } + return { lineIndexes, prefixTotals }; +} + +function sumHeightDeltas( + { lineIndexes, prefixTotals }: HeightDeltaPrefix, + startLineIndex: number, + endLineIndex: number +): number { + if (startLineIndex >= endLineIndex || lineIndexes.length === 0) { + return 0; + } + const start = lowerBound(lineIndexes, startLineIndex); + const end = lowerBound(lineIndexes, endLineIndex); + return (prefixTotals[end] ?? 0) - (prefixTotals[start] ?? 0); +} + +function lowerBound(values: number[], target: number): number { + let low = 0; + let high = values.length; + while (low < high) { + const mid = (low + high) >> 1; + const value = values[mid]; + if (value == null) { + throw new Error('VirtualizedFileDiff: invalid prefix index'); + } + if (value < target) { + low = mid + 1; + } else { + high = mid; + } + } + return low; +} + +function getNextCheckpointIndex(renderedLineIndex: number): number { + return ( + Math.ceil(renderedLineIndex / LAYOUT_CHECKPOINT_INTERVAL) * + LAYOUT_CHECKPOINT_INTERVAL + ); +} + +function countMetadataOffsetsBefore( + metadataOffsets: number[], + offset: number +): number { + let count = 0; + for (const metadataOffset of metadataOffsets) { + if (metadataOffset < offset) { + count++; + } + } + return count; +} + +function getHunkMetadataOffsets({ + diffStyle, + hunk, + rowCount, +}: { + diffStyle: 'split' | 'unified'; + hunk: Hunk; + rowCount: number; +}): number[] { + if (rowCount <= 0 || (!hunk.noEOFCRAdditions && !hunk.noEOFCRDeletions)) { + return []; + } + + const lastContent = hunk.hunkContent.at(-1); + if (lastContent == null) { + return []; + } + + if (lastContent.type === 'context') { + return [rowCount - 1]; + } + + const splitCount = Math.max(lastContent.deletions, lastContent.additions); + const unifiedCount = lastContent.deletions + lastContent.additions; + if (diffStyle === 'split') { + return splitCount > 0 && (hunk.noEOFCRAdditions || hunk.noEOFCRDeletions) + ? [rowCount - 1] + : []; + } + + const offsets: number[] = []; + const contentStartOffset = rowCount - unifiedCount; + if (lastContent.deletions > 0 && hunk.noEOFCRDeletions) { + offsets.push(contentStartOffset + lastContent.deletions - 1); + } + if (lastContent.additions > 0 && hunk.noEOFCRAdditions) { + offsets.push(rowCount - 1); + } + return offsets; +} + +function getTrailingExpandedRegion({ + fileDiff, + hunk, + hunkIndex, + expandedHunks, + collapsedContextThreshold, +}: { + fileDiff: FileDiffMetadata; + hunk: Hunk; + hunkIndex: number; + expandedHunks: Parameters[0]['expandedHunks']; + collapsedContextThreshold: number; +}): ExpandedRegionResult | undefined { + if (hunkIndex !== fileDiff.hunks.length - 1 || !hasFinalHunk(fileDiff)) { + return undefined; + } + + const additionRemaining = + fileDiff.additionLines.length - + (hunk.additionLineIndex + hunk.additionCount); + const deletionRemaining = + fileDiff.deletionLines.length - + (hunk.deletionLineIndex + hunk.deletionCount); + + if (additionRemaining !== deletionRemaining) { + throw new Error( + `VirtualizedFileDiff: trailing context mismatch (additions=${additionRemaining}, deletions=${deletionRemaining}) for ${fileDiff.name}` + ); + } + + return getExpandedRegion({ + isPartial: fileDiff.isPartial, + rangeSize: Math.min(additionRemaining, deletionRemaining), + expandedHunks, + hunkIndex: fileDiff.hunks.length, + collapsedContextThreshold, + }); +} + function hasDiffLayoutOptionChanged( previousOptions: FileDiffOptions, nextOptions: FileDiffOptions @@ -1291,6 +1610,24 @@ function hasDiffLayoutOptionChanged( ); } +function hasDiffEstimateOptionChanged( + previousOptions: FileDiffOptions, + nextOptions: FileDiffOptions +): boolean { + return ( + (previousOptions.disableFileHeader ?? false) !== + (nextOptions.disableFileHeader ?? false) || + (previousOptions.hunkSeparators ?? 'line-info') !== + (nextOptions.hunkSeparators ?? 'line-info') || + (previousOptions.expandUnchanged ?? false) !== + (nextOptions.expandUnchanged ?? false) || + (previousOptions.collapsedContextThreshold ?? + DEFAULT_COLLAPSED_CONTEXT_THRESHOLD) !== + (nextOptions.collapsedContextThreshold ?? + DEFAULT_COLLAPSED_CONTEXT_THRESHOLD) + ); +} + function getOptionHunkSeparatorType( hunkSeparators: FileDiffOptions['hunkSeparators'] | undefined ): HunkSeparators { diff --git a/packages/diffs/src/renderers/DiffHunksRenderer.ts b/packages/diffs/src/renderers/DiffHunksRenderer.ts index a01e3c718..e014b1b75 100644 --- a/packages/diffs/src/renderers/DiffHunksRenderer.ts +++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts @@ -1076,9 +1076,24 @@ export class DiffHunksRenderer { } } - const noEOFCRDeletion = deletionLine?.noEOFCR ?? false; - const noEOFCRAddition = additionLine?.noEOFCR ?? false; + const isFinalSplitHunkRow = + diffStyle === 'split' && + hunk != null && + splitLineIndex === hunk.splitLineStart + hunk.splitLineCount - 1; + const splitNoEOFCRDeletion = isFinalSplitHunkRow + ? hunk.noEOFCRDeletions + : false; + const splitNoEOFCRAddition = isFinalSplitHunkRow + ? hunk.noEOFCRAdditions + : false; + const noEOFCRDeletion = + (deletionLine?.noEOFCR ?? false) || splitNoEOFCRDeletion; + const noEOFCRAddition = + (additionLine?.noEOFCR ?? false) || splitNoEOFCRAddition; if (noEOFCRAddition || noEOFCRDeletion) { + if (diffStyle === 'split') { + pendingSplitContext.flush(); + } if (noEOFCRDeletion) { const noEOFType = type === 'context' || type === 'context-expanded' diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index b8972b490..342f18eb5 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -789,6 +789,13 @@ export interface VirtualFileMetrics { paddingBottom?: number; } +export interface PendingCodeViewLayoutReset { + metrics?: VirtualFileMetrics; + resetFileLayoutCache: boolean; + resetDiffLayoutCache: boolean; + includeEstimatedDiffHeights: boolean; +} + export interface CodeViewLayout { /** Top padding applied to the CodeView sticky container offset. */ paddingTop: number; diff --git a/packages/diffs/src/utils/computeEstimatedDiffHeights.ts b/packages/diffs/src/utils/computeEstimatedDiffHeights.ts new file mode 100644 index 000000000..b7344d6af --- /dev/null +++ b/packages/diffs/src/utils/computeEstimatedDiffHeights.ts @@ -0,0 +1,192 @@ +import type { + ChangeContent, + FileDiffMetadata, + Hunk, + HunkExpansionRegion, + HunkSeparators, + VirtualFileMetrics, +} from '../types'; +import { + getVirtualFileHeaderRegion, + getVirtualFilePaddingBottom, +} from './computeVirtualFileMetrics'; +import { + getExpandedRegion, + getLeadingHunkSeparatorLayout, + getTrailingHunkSeparatorLayout, +} from './virtualDiffLayout'; + +export interface ComputeEstimatedDiffHeightsOptions { + fileDiff: FileDiffMetadata; + metrics: VirtualFileMetrics; + disableFileHeader: boolean; + hunkSeparators: HunkSeparators; + expandUnchanged: boolean; + expandedHunks: Map | true | undefined; + collapsedContextThreshold: number; +} + +export interface EstimatedDiffHeights { + splitHeight: number; + unifiedHeight: number; +} + +// Computes both split and unified baseline heights from hunk-level metadata so +// callers can avoid replaying the detailed rendered-line iterator. +export function computeEstimatedDiffHeights({ + fileDiff, + metrics, + disableFileHeader, + hunkSeparators, + expandUnchanged, + expandedHunks: configuredExpandedHunks, + collapsedContextThreshold, +}: ComputeEstimatedDiffHeightsOptions): EstimatedDiffHeights { + let splitHeight = getVirtualFileHeaderRegion(metrics, disableFileHeader); + let unifiedHeight = splitHeight; + const expandedHunks = expandUnchanged ? true : configuredExpandedHunks; + const finalHunkIndex = fileDiff.hunks.length - 1; + + for (let hunkIndex = 0; hunkIndex < fileDiff.hunks.length; hunkIndex++) { + const hunk = fileDiff.hunks[hunkIndex]; + if (hunk == null) { + throw new Error('computeEstimatedDiffHeights: invalid hunk index'); + } + + const leadingRegion = getExpandedRegion({ + isPartial: fileDiff.isPartial, + rangeSize: hunk.collapsedBefore, + expandedHunks, + hunkIndex, + collapsedContextThreshold, + }); + const leadingExpandedHeight = + (leadingRegion.fromStart + leadingRegion.fromEnd) * metrics.lineHeight; + splitHeight += leadingExpandedHeight; + unifiedHeight += leadingExpandedHeight; + + if (leadingRegion.collapsedLines > 0) { + const separatorHeight = + getLeadingHunkSeparatorLayout({ + type: hunkSeparators, + metrics, + hunkIndex, + hunkSpecs: hunk.hunkSpecs, + })?.totalHeight ?? 0; + splitHeight += separatorHeight; + unifiedHeight += separatorHeight; + } + + splitHeight += hunk.splitLineCount * metrics.lineHeight; + unifiedHeight += hunk.unifiedLineCount * metrics.lineHeight; + + const metadataLineCounts = getNoNewlineMetadataLineCounts(hunk); + splitHeight += metadataLineCounts.split * metrics.lineHeight; + unifiedHeight += metadataLineCounts.unified * metrics.lineHeight; + + if (hunkIndex === finalHunkIndex && hasFinalCollapsedHunk(fileDiff)) { + const trailingRegion = getExpandedRegion({ + isPartial: fileDiff.isPartial, + rangeSize: getTrailingRangeSize(fileDiff, hunk), + expandedHunks, + hunkIndex: fileDiff.hunks.length, + collapsedContextThreshold, + }); + const trailingExpandedHeight = + (trailingRegion.fromStart + trailingRegion.fromEnd) * + metrics.lineHeight; + splitHeight += trailingExpandedHeight; + unifiedHeight += trailingExpandedHeight; + + if (trailingRegion.collapsedLines > 0) { + const separatorHeight = + getTrailingHunkSeparatorLayout({ + type: hunkSeparators, + metrics, + })?.totalHeight ?? 0; + splitHeight += separatorHeight; + unifiedHeight += separatorHeight; + } + } + } + + if (fileDiff.hunks.length > 0) { + const paddingBottom = getVirtualFilePaddingBottom(metrics); + splitHeight += paddingBottom; + unifiedHeight += paddingBottom; + } + + return { splitHeight, unifiedHeight }; +} + +function getNoNewlineMetadataLineCounts(hunk: Hunk): { + split: number; + unified: number; +} { + if (!hunk.noEOFCRAdditions && !hunk.noEOFCRDeletions) { + return { split: 0, unified: 0 }; + } + + const lastContent = hunk.hunkContent.at(-1); + if (lastContent == null) { + return { split: 0, unified: 0 }; + } + + if (lastContent.type === 'context') { + const metadataRows = lastContent.lines > 0 ? 1 : 0; + return { split: metadataRows, unified: metadataRows }; + } + + return getChangeNoNewlineMetadataLineCounts(hunk, lastContent); +} + +function getChangeNoNewlineMetadataLineCounts( + hunk: Hunk, + content: ChangeContent +): { split: number; unified: number } { + const unified = + (content.deletions > 0 && hunk.noEOFCRDeletions ? 1 : 0) + + (content.additions > 0 && hunk.noEOFCRAdditions ? 1 : 0); + const splitDeletionHasMetadata = + content.deletions > 0 && hunk.noEOFCRDeletions; + const splitAdditionHasMetadata = + content.additions > 0 && hunk.noEOFCRAdditions; + const split = splitDeletionHasMetadata || splitAdditionHasMetadata ? 1 : 0; + + return { split, unified }; +} + +function hasFinalCollapsedHunk(fileDiff: FileDiffMetadata): boolean { + const lastHunk = fileDiff.hunks.at(-1); + if ( + lastHunk == null || + fileDiff.isPartial || + fileDiff.additionLines.length === 0 || + fileDiff.deletionLines.length === 0 + ) { + return false; + } + + return ( + lastHunk.additionLineIndex + lastHunk.additionCount < + fileDiff.additionLines.length || + lastHunk.deletionLineIndex + lastHunk.deletionCount < + fileDiff.deletionLines.length + ); +} + +function getTrailingRangeSize(fileDiff: FileDiffMetadata, hunk: Hunk): number { + const additionRemaining = + fileDiff.additionLines.length - + (hunk.additionLineIndex + hunk.additionCount); + const deletionRemaining = + fileDiff.deletionLines.length - + (hunk.deletionLineIndex + hunk.deletionCount); + + if (additionRemaining !== deletionRemaining) { + throw new Error( + `computeEstimatedDiffHeights: trailing context mismatch (additions=${additionRemaining}, deletions=${deletionRemaining}) for ${fileDiff.name}` + ); + } + return Math.min(additionRemaining, deletionRemaining); +} diff --git a/packages/diffs/src/utils/getDiffHunksRendererOptions.ts b/packages/diffs/src/utils/getDiffHunksRendererOptions.ts new file mode 100644 index 000000000..e07017e41 --- /dev/null +++ b/packages/diffs/src/utils/getDiffHunksRendererOptions.ts @@ -0,0 +1,37 @@ +import type { FileDiffOptions } from '../components/FileDiff'; +import type { DiffHunksRendererOptions } from '../renderers/DiffHunksRenderer'; + +// Build the renderer option snapshot with direct property reads. CodeView item +// options may inherit prototype getters, so object spread can miss values. +export function getDiffHunksRendererOptions( + options: FileDiffOptions | undefined +): DiffHunksRendererOptions { + return { + theme: options?.theme, + disableLineNumbers: options?.disableLineNumbers, + overflow: options?.overflow, + collapsed: options?.collapsed, + disableFileHeader: options?.disableFileHeader, + disableVirtualizationBuffers: options?.disableVirtualizationBuffers, + stickyHeader: options?.stickyHeader, + preferredHighlighter: options?.preferredHighlighter, + useCSSClasses: options?.useCSSClasses, + useTokenTransformer: options?.useTokenTransformer, + tokenizeMaxLineLength: options?.tokenizeMaxLineLength, + tokenizeMaxLength: options?.tokenizeMaxLength, + diffStyle: options?.diffStyle, + diffIndicators: options?.diffIndicators, + disableBackground: options?.disableBackground, + hunkSeparators: + typeof options?.hunkSeparators === 'function' + ? 'custom' + : options?.hunkSeparators, + expandUnchanged: options?.expandUnchanged, + collapsedContextThreshold: options?.collapsedContextThreshold, + lineDiffType: options?.lineDiffType, + maxLineDiffLength: options?.maxLineDiffLength, + expansionLineCount: options?.expansionLineCount, + headerRenderMode: + options?.renderCustomHeader != null ? 'custom' : 'default', + }; +} diff --git a/packages/diffs/src/utils/getFileRendererOptions.ts b/packages/diffs/src/utils/getFileRendererOptions.ts new file mode 100644 index 000000000..d858ebdac --- /dev/null +++ b/packages/diffs/src/utils/getFileRendererOptions.ts @@ -0,0 +1,27 @@ +import type { FileOptions } from '../components/File'; +import type { FileRendererOptions } from '../renderers/FileRenderer'; + +// Build the renderer option snapshot with direct property reads. CodeView item +// options may inherit prototype getters, so object spread can miss values. +export function getFileRendererOptions( + options: FileOptions | undefined +): FileRendererOptions { + return { + theme: options?.theme, + disableLineNumbers: options?.disableLineNumbers, + overflow: options?.overflow, + themeType: options?.themeType, + collapsed: options?.collapsed, + disableFileHeader: options?.disableFileHeader, + disableVirtualizationBuffers: options?.disableVirtualizationBuffers, + stickyHeader: options?.stickyHeader, + preferredHighlighter: options?.preferredHighlighter, + useCSSClasses: options?.useCSSClasses, + useTokenTransformer: options?.useTokenTransformer, + tokenizeMaxLineLength: options?.tokenizeMaxLineLength, + tokenizeMaxLength: options?.tokenizeMaxLength, + unsafeCSS: options?.unsafeCSS, + headerRenderMode: + options?.renderCustomHeader != null ? 'custom' : 'default', + }; +} diff --git a/packages/diffs/src/utils/iterateOverDiff.ts b/packages/diffs/src/utils/iterateOverDiff.ts index 34b9c8c14..293eaae2d 100644 --- a/packages/diffs/src/utils/iterateOverDiff.ts +++ b/packages/diffs/src/utils/iterateOverDiff.ts @@ -5,6 +5,7 @@ import type { Hunk, HunkExpansionRegion, } from '../types'; +import { getExpandedRegion } from './virtualDiffLayout'; export interface DiffLineMetadata { unifiedLineIndex: number; @@ -227,13 +228,13 @@ export function iterateOverDiff({ break; } - const leadingRegion = getExpandedRegion( - diff.isPartial, - hunk.collapsedBefore, + const leadingRegion = getExpandedRegion({ + isPartial: diff.isPartial, + rangeSize: hunk.collapsedBefore, expandedHunks, hunkIndex, - collapsedContextThreshold - ); + collapsedContextThreshold, + }); // We only create a trailing region if it's the last hunk const trailingRegion = (() => { if (hunk !== state.finalHunk || !hasFinalCollapsedHunk(diff)) { @@ -252,14 +253,14 @@ export function iterateOverDiff({ ); } const trailingRangeSize = Math.min(additionRemaining, deletionRemaining); - return getExpandedRegion( - diff.isPartial, - trailingRangeSize, + return getExpandedRegion({ + isPartial: diff.isPartial, + rangeSize: trailingRangeSize, expandedHunks, // hunkIndex for trailing region - diff.hunks.length, - collapsedContextThreshold - ); + hunkIndex: diff.hunks.length, + collapsedContextThreshold, + }); })(); const expandedLineCount = leadingRegion.fromStart + leadingRegion.fromEnd; @@ -731,26 +732,26 @@ function getHunkPrefixCounts({ throw new Error('iterateOverDiff: invalid hunk summary index'); } - const leadingRegion = getExpandedRegion( - diff.isPartial, - hunk.collapsedBefore, + const leadingRegion = getExpandedRegion({ + isPartial: diff.isPartial, + rangeSize: hunk.collapsedBefore, expandedHunks, - index, - collapsedContextThreshold - ); + hunkIndex: index, + collapsedContextThreshold, + }); const leadingCount = leadingRegion.fromStart + leadingRegion.fromEnd; splitCount += leadingCount + hunk.splitLineCount; unifiedCount += leadingCount + hunk.unifiedLineCount; if (index === finalHunkIndex && hasFinalCollapsedHunk(diff)) { const trailingRangeSize = getTrailingRangeSize(diff, hunk); - const trailingRegion = getExpandedRegion( - diff.isPartial, - trailingRangeSize, + const trailingRegion = getExpandedRegion({ + isPartial: diff.isPartial, + rangeSize: trailingRangeSize, expandedHunks, - diff.hunks.length, - collapsedContextThreshold - ); + hunkIndex: diff.hunks.length, + collapsedContextThreshold, + }); const trailingCount = trailingRegion.fromStart + trailingRegion.fromEnd; splitCount += trailingCount; unifiedCount += trailingCount; @@ -822,50 +823,6 @@ function getTrailingRangeSize(diff: FileDiffMetadata, hunk: Hunk): number { return Math.min(additionRemaining, deletionRemaining); } -interface ExpandedRegionResult { - fromStart: number; - fromEnd: number; - rangeSize: number; - collapsedLines: number; -} - -function getExpandedRegion( - isPartial: boolean, - rangeSize: number, - expandedHunks: Map | true | undefined, - hunkIndex: number, - collapsedContextThreshold: number -): ExpandedRegionResult { - rangeSize = Math.max(rangeSize, 0); - if (rangeSize === 0 || isPartial) { - return { - fromStart: 0, - fromEnd: 0, - rangeSize, - collapsedLines: Math.max(rangeSize, 0), - }; - } - if (expandedHunks === true || rangeSize <= collapsedContextThreshold) { - return { - fromStart: rangeSize, - fromEnd: 0, - rangeSize, - collapsedLines: 0, - }; - } - const region = expandedHunks?.get(hunkIndex); - const fromStart = Math.min(Math.max(region?.fromStart ?? 0, 0), rangeSize); - const fromEnd = Math.min(Math.max(region?.fromEnd ?? 0, 0), rangeSize); - const expandedCount = fromStart + fromEnd; - const renderAll = expandedCount >= rangeSize; - return { - fromStart: renderAll ? rangeSize : fromStart, - fromEnd: renderAll ? 0 : fromEnd, - rangeSize, - collapsedLines: Math.max(rangeSize - expandedCount, 0), - }; -} - function hasFinalCollapsedHunk(diff: FileDiffMetadata): boolean { const lastHunk = diff.hunks.at(-1); if ( diff --git a/packages/diffs/src/utils/virtualDiffLayout.ts b/packages/diffs/src/utils/virtualDiffLayout.ts new file mode 100644 index 000000000..bfea6890e --- /dev/null +++ b/packages/diffs/src/utils/virtualDiffLayout.ts @@ -0,0 +1,172 @@ +import type { + HunkExpansionRegion, + HunkSeparators, + VirtualFileMetrics, +} from '../types'; +import { getDefaultHunkSeparatorHeight } from './computeVirtualFileMetrics'; + +export interface ExpandedRegionResult { + fromStart: number; + fromEnd: number; + rangeSize: number; + collapsedLines: number; + renderAll: boolean; +} + +export interface GetExpandedRegionProps { + isPartial: boolean; + rangeSize: number; + expandedHunks: Map | true | undefined; + hunkIndex: number; + collapsedContextThreshold: number; +} + +export interface HunkSeparatorLayout { + height: number; + gapBefore: number; + gapAfter: number; + totalHeight: number; +} + +interface HunkSeparatorBaseProps { + type: HunkSeparators; + metrics: VirtualFileMetrics; +} + +interface LeadingHunkSeparatorLayoutProps extends HunkSeparatorBaseProps { + hunkIndex: number; + hunkSpecs: string | undefined; +} + +// Converts a collapsed unchanged range into the slices that should render near +// the start and end of that range for the active hunk expansion state. +export function getExpandedRegion({ + isPartial, + rangeSize, + expandedHunks, + hunkIndex, + collapsedContextThreshold, +}: GetExpandedRegionProps): ExpandedRegionResult { + const normalizedRangeSize = Math.max(rangeSize, 0); + if (normalizedRangeSize === 0 || isPartial) { + return { + fromStart: 0, + fromEnd: 0, + rangeSize: normalizedRangeSize, + collapsedLines: normalizedRangeSize, + renderAll: false, + }; + } + + if ( + expandedHunks === true || + normalizedRangeSize <= collapsedContextThreshold + ) { + return { + fromStart: normalizedRangeSize, + fromEnd: 0, + rangeSize: normalizedRangeSize, + collapsedLines: 0, + renderAll: true, + }; + } + + const region = expandedHunks?.get(hunkIndex); + const fromStart = Math.min( + Math.max(region?.fromStart ?? 0, 0), + normalizedRangeSize + ); + const fromEnd = Math.min( + Math.max(region?.fromEnd ?? 0, 0), + normalizedRangeSize + ); + const expandedCount = fromStart + fromEnd; + const renderAll = expandedCount >= normalizedRangeSize; + return { + fromStart: renderAll ? normalizedRangeSize : fromStart, + fromEnd: renderAll ? 0 : fromEnd, + rangeSize: normalizedRangeSize, + collapsedLines: Math.max(normalizedRangeSize - expandedCount, 0), + renderAll, + }; +} + +export function getHunkSeparatorHeight({ + type, + metrics, +}: HunkSeparatorBaseProps): number { + return metrics.hunkSeparatorHeight ?? getDefaultHunkSeparatorHeight(type); +} + +export function getHunkSeparatorGap({ + type, + metrics, +}: HunkSeparatorBaseProps): number { + return type === 'simple' || type === 'metadata' || type === 'line-info-basic' + ? 0 + : metrics.spacing; +} + +export function hasLeadingHunkSeparator({ + type, + hunkIndex, + hunkSpecs, +}: Omit): boolean { + switch (type) { + case 'simple': + return hunkIndex > 0; + case 'metadata': + return hunkSpecs != null; + case 'line-info': + case 'line-info-basic': + case 'custom': + return true; + } +} + +export function hasTrailingHunkSeparator(type: HunkSeparators): boolean { + return type !== 'simple' && type !== 'metadata'; +} + +// Mirrors the renderer/CSS spacing rules for the separator shown before a hunk. +export function getLeadingHunkSeparatorLayout({ + type, + metrics, + hunkIndex, + hunkSpecs, +}: LeadingHunkSeparatorLayoutProps): HunkSeparatorLayout | undefined { + if (!hasLeadingHunkSeparator({ type, hunkIndex, hunkSpecs })) { + return undefined; + } + + const height = getHunkSeparatorHeight({ type, metrics }); + const gap = getHunkSeparatorGap({ type, metrics }); + const gapBefore = hunkIndex > 0 ? gap : 0; + const gapAfter = gap; + return { + height, + gapBefore, + gapAfter, + totalHeight: gapBefore + height + gapAfter, + }; +} + +// Mirrors the renderer/CSS spacing rules for the separator shown after the last +// hunk when trailing unchanged context is collapsed. +export function getTrailingHunkSeparatorLayout({ + type, + metrics, +}: HunkSeparatorBaseProps): HunkSeparatorLayout | undefined { + if (!hasTrailingHunkSeparator(type)) { + return undefined; + } + + const height = getHunkSeparatorHeight({ type, metrics }); + const gapBefore = getHunkSeparatorGap({ type, metrics }); + return { + height, + gapBefore, + gapAfter: 0, + totalHeight: gapBefore + height, + }; +} diff --git a/packages/diffs/src/worker/WorkerPoolManager.ts b/packages/diffs/src/worker/WorkerPoolManager.ts index ddfdd50d4..b996124dc 100644 --- a/packages/diffs/src/worker/WorkerPoolManager.ts +++ b/packages/diffs/src/worker/WorkerPoolManager.ts @@ -82,7 +82,7 @@ interface ManagedWorker { } interface ThemeSubscriber { - rerender(): void; + onThemeChange(): void; } type RenderTask = RenderFileTask | RenderDiffTask; @@ -253,7 +253,7 @@ export class WorkerPoolManager { this.fileCache.clear(); for (const instance of this.themeSubscribers) { - instance.rerender(); + instance.onThemeChange(); } } catch (error) { if ( diff --git a/packages/diffs/test/CodeView.interactionOptions.test.ts b/packages/diffs/test/CodeView.interactionOptions.test.ts new file mode 100644 index 000000000..a3790b993 --- /dev/null +++ b/packages/diffs/test/CodeView.interactionOptions.test.ts @@ -0,0 +1,333 @@ +import { describe, expect, test } from 'bun:test'; +import { JSDOM } from 'jsdom'; + +import { CodeView } from '../src/components/CodeView'; +import { DEFAULT_THEMES } from '../src/constants'; +import type { CodeViewItem, FileContents } from '../src/types'; + +function installDom() { + const dom = new JSDOM('', { + url: 'http://localhost', + }); + const originalValues = { + cancelAnimationFrame: Reflect.get(globalThis, 'cancelAnimationFrame'), + document: Reflect.get(globalThis, 'document'), + Element: Reflect.get(globalThis, 'Element'), + HTMLDivElement: Reflect.get(globalThis, 'HTMLDivElement'), + HTMLElement: Reflect.get(globalThis, 'HTMLElement'), + HTMLPreElement: Reflect.get(globalThis, 'HTMLPreElement'), + HTMLStyleElement: Reflect.get(globalThis, 'HTMLStyleElement'), + MouseEvent: Reflect.get(globalThis, 'MouseEvent'), + Node: Reflect.get(globalThis, 'Node'), + PointerEvent: Reflect.get(globalThis, 'PointerEvent'), + requestAnimationFrame: Reflect.get(globalThis, 'requestAnimationFrame'), + ResizeObserver: Reflect.get(globalThis, 'ResizeObserver'), + SVGElement: Reflect.get(globalThis, 'SVGElement'), + window: Reflect.get(globalThis, 'window'), + }; + + class MockPointerEvent extends dom.window.MouseEvent { + pointerId: number; + pointerType: string; + + constructor(type: string, init: PointerEventInit = {}) { + super(type, { + bubbles: true, + cancelable: true, + composed: true, + ...init, + }); + this.pointerId = init.pointerId ?? 1; + this.pointerType = init.pointerType ?? 'mouse'; + } + } + + class MockResizeObserver { + observe(_target: Element): void {} + unobserve(_target: Element): void {} + disconnect(): void {} + } + + let nextFrameId = 0; + const frames = new Map>(); + + Object.assign(globalThis, { + cancelAnimationFrame: ((id: number) => { + const timeout = frames.get(id); + if (timeout != null) { + clearTimeout(timeout); + frames.delete(id); + } + }) as typeof cancelAnimationFrame, + document: dom.window.document, + Element: dom.window.Element, + HTMLDivElement: dom.window.HTMLDivElement, + HTMLElement: dom.window.HTMLElement, + HTMLPreElement: dom.window.HTMLPreElement, + HTMLStyleElement: dom.window.HTMLStyleElement, + MouseEvent: dom.window.MouseEvent, + Node: dom.window.Node, + PointerEvent: MockPointerEvent, + requestAnimationFrame: ((callback: FrameRequestCallback) => { + const id = ++nextFrameId; + const timeout = setTimeout(() => { + frames.delete(id); + callback(performance.now()); + }, 0); + frames.set(id, timeout); + return id; + }) as typeof requestAnimationFrame, + ResizeObserver: MockResizeObserver, + SVGElement: dom.window.SVGElement, + window: dom.window, + }); + Object.assign(dom.window, { PointerEvent: MockPointerEvent }); + + return { + cleanup() { + for (const timeout of frames.values()) { + clearTimeout(timeout); + } + frames.clear(); + + for (const [key, value] of Object.entries(originalValues)) { + if (value === undefined) { + Reflect.deleteProperty(globalThis, key); + } else { + Object.assign(globalThis, { [key]: value }); + } + } + dom.window.close(); + }, + }; +} + +function createRoot(): HTMLDivElement { + const root = document.createElement('div'); + root.scrollTo = (options?: ScrollToOptions | number, y?: number) => { + root.scrollTop = + typeof options === 'number' ? (y ?? 0) : (options?.top ?? root.scrollTop); + }; + Object.defineProperty(root, 'getBoundingClientRect', { + value: () => ({ + bottom: 400, + height: 400, + left: 0, + right: 800, + top: 0, + width: 800, + x: 0, + y: 0, + toJSON() { + return {}; + }, + }), + }); + document.body.appendChild(root); + return root; +} + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function makeFile(name: string, lineCount: number): FileContents { + return { + name, + contents: Array.from( + { length: lineCount }, + (_, index) => `line ${index + 1}` + ).join('\n'), + }; +} + +function makeFileItem(id: string, lineCount = 8): CodeViewItem { + return { + id, + type: 'file', + file: makeFile(`${id}.txt`, lineCount), + }; +} + +async function renderFileItem(viewer: CodeView, item = makeFileItem('file')) { + viewer.setItems([item]); + viewer.render(true); + await wait(0); +} + +function getRenderedPre(viewer: CodeView): HTMLPreElement { + const [renderedItem] = viewer.getRenderedItems(); + expect(renderedItem).toBeDefined(); + const pre = renderedItem?.element.shadowRoot?.querySelector('pre'); + expect(pre).toBeInstanceOf(HTMLPreElement); + return pre as HTMLPreElement; +} + +function getLineElement(pre: HTMLPreElement, lineNumber: number): HTMLElement { + const line = pre.querySelector(`[data-line="${lineNumber}"]`); + expect(line).toBeInstanceOf(HTMLElement); + return line as HTMLElement; +} + +function getNumberElement( + pre: HTMLPreElement, + lineNumber: number +): HTMLElement { + const number = pre.querySelector(`[data-column-number="${lineNumber}"]`); + expect(number).toBeInstanceOf(HTMLElement); + return number as HTMLElement; +} + +describe('CodeView interaction option updates', () => { + test('enables line clicks for an already-rendered file item', async () => { + const { cleanup } = installDom(); + const clickedLines: number[] = []; + const viewer = new CodeView({ + disableFileHeader: true, + theme: DEFAULT_THEMES, + }); + + try { + viewer.setup(createRoot()); + await renderFileItem(viewer); + + let pre = getRenderedPre(viewer); + expect(pre.hasAttribute('data-interactive-lines')).toBe(false); + + viewer.setOptions({ + disableFileHeader: true, + theme: DEFAULT_THEMES, + onLineClick: (props: { lineNumber: number }) => { + clickedLines.push(props.lineNumber); + }, + }); + await wait(0); + + pre = getRenderedPre(viewer); + expect(pre.hasAttribute('data-interactive-lines')).toBe(true); + getLineElement(pre, 1).dispatchEvent( + new window.MouseEvent('click', { + bubbles: true, + cancelable: true, + composed: true, + }) + ); + + expect(clickedLines).toEqual([1]); + } finally { + viewer.cleanUp(); + await wait(0); + cleanup(); + } + }); + + test('enables line selection attributes for an already-rendered file item', async () => { + const { cleanup } = installDom(); + const viewer = new CodeView({ + disableFileHeader: true, + theme: DEFAULT_THEMES, + }); + + try { + viewer.setup(createRoot()); + await renderFileItem(viewer); + + let pre = getRenderedPre(viewer); + expect(pre.hasAttribute('data-interactive-line-numbers')).toBe(false); + + viewer.setOptions({ + disableFileHeader: true, + enableLineSelection: true, + theme: DEFAULT_THEMES, + }); + await wait(0); + + pre = getRenderedPre(viewer); + expect(pre.hasAttribute('data-interactive-line-numbers')).toBe(true); + } finally { + viewer.cleanUp(); + await wait(0); + cleanup(); + } + }); + + test('enables hover highlighting for an already-rendered file item', async () => { + const { cleanup } = installDom(); + const viewer = new CodeView({ + disableFileHeader: true, + theme: DEFAULT_THEMES, + }); + + try { + viewer.setup(createRoot()); + await renderFileItem(viewer); + + viewer.setOptions({ + disableFileHeader: true, + lineHoverHighlight: 'both', + theme: DEFAULT_THEMES, + }); + await wait(0); + + const pre = getRenderedPre(viewer); + const line = getLineElement(pre, 1); + const number = getNumberElement(pre, 1); + line.dispatchEvent( + new window.PointerEvent('pointermove', { + bubbles: true, + composed: true, + pointerType: 'mouse', + }) + ); + + expect(line.hasAttribute('data-hovered')).toBe(true); + expect(number.hasAttribute('data-hovered')).toBe(true); + } finally { + viewer.cleanUp(); + await wait(0); + cleanup(); + } + }); + + test('enables custom gutter utility setup for an already-rendered file item', async () => { + const { cleanup } = installDom(); + const viewer = new CodeView({ + disableFileHeader: true, + theme: DEFAULT_THEMES, + }); + + try { + viewer.setup(createRoot()); + await renderFileItem(viewer); + + viewer.setOptions({ + disableFileHeader: true, + enableGutterUtility: true, + renderGutterUtility: () => document.createElement('button'), + theme: DEFAULT_THEMES, + }); + await wait(0); + + const pre = getRenderedPre(viewer); + const number = getNumberElement(pre, 1); + number.dispatchEvent( + new window.PointerEvent('pointermove', { + bubbles: true, + composed: true, + pointerType: 'mouse', + }) + ); + + expect(number.querySelector('[data-gutter-utility-slot]')).not.toBeNull(); + expect( + viewer + .getRenderedItems()[0] + .element.querySelector('[slot="gutter-utility-slot"]') + ).not.toBeNull(); + } finally { + viewer.cleanUp(); + await wait(0); + cleanup(); + } + }); +}); diff --git a/packages/diffs/test/DiffHunksRendererVirtualization.test.ts b/packages/diffs/test/DiffHunksRendererVirtualization.test.ts index 148ab7ce5..e93238eb9 100644 --- a/packages/diffs/test/DiffHunksRendererVirtualization.test.ts +++ b/packages/diffs/test/DiffHunksRendererVirtualization.test.ts @@ -1,4 +1,5 @@ import { afterAll, describe, expect, test } from 'bun:test'; +import type { ElementContent } from 'hast'; import { DiffHunksRenderer, @@ -8,6 +9,7 @@ import { import { fileNew, fileOld } from './mocks'; import { assertDefined, + collectAllElements, countRenderedLines, extractLineNumbers, findBufferElements, @@ -32,6 +34,30 @@ describe('DiffHunksRenderer - Virtualization', () => { diffStyle: 'split', }); + function countNoNewlineElements(ast: ElementContent[]): number { + return collectAllElements(ast).filter( + (node) => 'data-no-newline' in node.properties + ).length; + } + + function getTopLevelNodeKinds(ast: ElementContent[]): string[] { + return ast.map((node) => { + if (node.type !== 'element') { + return 'other'; + } + if ('data-content-buffer' in node.properties) { + return 'buffer'; + } + if ('data-no-newline' in node.properties) { + return 'no-newline'; + } + if (node.properties['data-line'] != null) { + return 'line'; + } + return 'other'; + }); + } + // Diff structure from fileOld/fileNew: // - 14 hunks total // - Total unified lines: 514 @@ -98,6 +124,62 @@ describe('DiffHunksRenderer - Virtualization', () => { }); }); + describe('no-newline metadata', () => { + test('renders deletion-side metadata when deletions are shorter in split mode', async () => { + const fileDiff = parseDiffFromFile( + { name: 'deletion-shorter.txt', contents: 'same\nold-final' }, + { name: 'deletion-shorter.txt', contents: 'same\nnew-a\nnew-b\n' } + ); + const result = await new DiffHunksRenderer({ + diffStyle: 'split', + }).asyncRender(fileDiff); + + assertDefined( + result.deletionsContentAST, + 'deletionsContentAST should be defined' + ); + assertDefined( + result.additionsContentAST, + 'additionsContentAST should be defined' + ); + expect(countNoNewlineElements(result.deletionsContentAST)).toBe(1); + expect(countNoNewlineElements(result.additionsContentAST)).toBe(0); + expect( + getTopLevelNodeKinds(result.deletionsContentAST).slice(-2) + ).toEqual(['buffer', 'no-newline']); + expect( + getTopLevelNodeKinds(result.additionsContentAST).slice(-1) + ).toEqual(['buffer']); + }); + + test('renders addition-side metadata when additions are shorter in split mode', async () => { + const fileDiff = parseDiffFromFile( + { name: 'addition-shorter.txt', contents: 'same\nold-a\nold-b\n' }, + { name: 'addition-shorter.txt', contents: 'same\nnew-final' } + ); + const result = await new DiffHunksRenderer({ + diffStyle: 'split', + }).asyncRender(fileDiff); + + assertDefined( + result.deletionsContentAST, + 'deletionsContentAST should be defined' + ); + assertDefined( + result.additionsContentAST, + 'additionsContentAST should be defined' + ); + expect(countNoNewlineElements(result.deletionsContentAST)).toBe(0); + expect(countNoNewlineElements(result.additionsContentAST)).toBe(1); + expect( + getTopLevelNodeKinds(result.deletionsContentAST).slice(-1) + ).toEqual(['buffer']); + expect( + getTopLevelNodeKinds(result.additionsContentAST).slice(-2) + ).toEqual(['buffer', 'no-newline']); + }); + }); + describe('line count math', () => { test('2.1: No windowing - full render', async () => { const unifiedResult = await unifiedRenderer.asyncRender(fileDiff, { diff --git a/packages/diffs/test/computeEstimatedDiffHeights.test.ts b/packages/diffs/test/computeEstimatedDiffHeights.test.ts new file mode 100644 index 000000000..69997c810 --- /dev/null +++ b/packages/diffs/test/computeEstimatedDiffHeights.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from 'bun:test'; + +import { DEFAULT_COLLAPSED_CONTEXT_THRESHOLD } from '../src/constants'; +import type { FileDiffMetadata, VirtualFileMetrics } from '../src/types'; +import { + computeEstimatedDiffHeights, + type ComputeEstimatedDiffHeightsOptions, +} from '../src/utils/computeEstimatedDiffHeights'; +import { parseDiffFromFile } from '../src/utils/parseDiffFromFile'; + +const metrics: VirtualFileMetrics = { + hunkLineCount: 2, + lineHeight: 10, + diffHeaderHeight: 30, + spacing: 4, +}; + +function createTwoHunkDiff(): FileDiffMetadata { + const oldLines = Array.from({ length: 140 }, (_, index) => `${index + 1}`); + const newLines = oldLines.map((line, index) => { + if (index === 39) return 'changed-40'; + if (index === 99) return 'changed-100'; + return line; + }); + + return parseDiffFromFile( + { name: 'two-hunks.ts', contents: `${oldLines.join('\n')}\n` }, + { name: 'two-hunks.ts', contents: `${newLines.join('\n')}\n` } + ); +} + +function compute( + fileDiff: FileDiffMetadata, + options: Partial< + Omit + > & { metrics?: VirtualFileMetrics } = {} +) { + const { metrics: overrideMetrics = metrics, ...rest } = options; + return computeEstimatedDiffHeights({ + fileDiff, + metrics: overrideMetrics, + disableFileHeader: false, + hunkSeparators: 'line-info', + expandUnchanged: false, + expandedHunks: undefined, + collapsedContextThreshold: DEFAULT_COLLAPSED_CONTEXT_THRESHOLD, + ...rest, + }); +} + +describe('computeEstimatedDiffHeights', () => { + test('returns only the top region when a diff has no hunks', () => { + const fileDiff = parseDiffFromFile( + { name: 'same.ts', contents: 'one\n' }, + { name: 'same.ts', contents: 'one\n' } + ); + + expect( + compute(fileDiff, { + metrics: { ...metrics, paddingTop: 6, paddingBottom: 13 }, + }) + ).toEqual({ + splitHeight: 36, + unifiedHeight: 36, + }); + }); + + test('computes split and unified heights with no-newline metadata rows', () => { + const fileDiff = parseDiffFromFile( + { name: 'no-newline.ts', contents: 'one\ntwo' }, + { name: 'no-newline.ts', contents: 'one\nTWO' } + ); + + expect(compute(fileDiff)).toEqual({ + splitHeight: 64, + unifiedHeight: 84, + }); + }); + + test('computes split metadata when the no-newline deletion side is shorter', () => { + const fileDiff = parseDiffFromFile( + { name: 'deletion-shorter.ts', contents: 'same\nold-final' }, + { name: 'deletion-shorter.ts', contents: 'same\nnew-a\nnew-b\n' } + ); + + expect(compute(fileDiff)).toEqual({ + splitHeight: 74, + unifiedHeight: 84, + }); + }); + + test('computes split metadata when the no-newline addition side is shorter', () => { + const fileDiff = parseDiffFromFile( + { name: 'addition-shorter.ts', contents: 'same\nold-a\nold-b\n' }, + { name: 'addition-shorter.ts', contents: 'same\nnew-final' } + ); + + expect(compute(fileDiff)).toEqual({ + splitHeight: 74, + unifiedHeight: 84, + }); + }); + + test('accounts for collapsed leading and trailing line-info separators', () => { + const fileDiff = createTwoHunkDiff(); + + expect(compute(fileDiff)).toEqual({ + splitHeight: 326, + unifiedHeight: 346, + }); + }); + + test('preserves current simple separator behavior', () => { + const fileDiff = createTwoHunkDiff(); + + expect(compute(fileDiff, { hunkSeparators: 'simple' })).toEqual({ + splitHeight: 218, + unifiedHeight: 238, + }); + }); + + test('expands unchanged context as rows without separators', () => { + const fileDiff = createTwoHunkDiff(); + + expect(compute(fileDiff, { expandUnchanged: true })).toEqual({ + splitHeight: 1434, + unifiedHeight: 1454, + }); + }); + + test('accounts for partially expanded leading context', () => { + const fileDiff = createTwoHunkDiff(); + const expandedHunks = new Map([[0, { fromStart: 2, fromEnd: 3 }]]); + + expect(compute(fileDiff, { expandedHunks })).toEqual({ + splitHeight: 376, + unifiedHeight: 396, + }); + }); + + test('does not estimate trailing collapsed context for partial diffs', () => { + const fileDiff = { ...createTwoHunkDiff(), isPartial: true }; + + expect(compute(fileDiff)).toEqual({ + splitHeight: 290, + unifiedHeight: 310, + }); + }); + + test('reserves metadata separators only for hunk specs', () => { + const fileDiff = createTwoHunkDiff(); + + expect(compute(fileDiff, { hunkSeparators: 'metadata' })).toEqual({ + splitHeight: 278, + unifiedHeight: 298, + }); + }); +}); diff --git a/packages/diffs/test/sparseLayoutCheckpoints.test.ts b/packages/diffs/test/sparseLayoutCheckpoints.test.ts index cc234db18..bb94a15bd 100644 --- a/packages/diffs/test/sparseLayoutCheckpoints.test.ts +++ b/packages/diffs/test/sparseLayoutCheckpoints.test.ts @@ -105,7 +105,7 @@ describe('sparse layout checkpoints', () => { metrics ); - instance.prepareVirtualizedItem(file); + instance.prepareCodeViewItem(file, 0); expect(instance.getLinePosition(10_000)?.top).toBe( metrics.diffHeaderHeight + 9_999 * metrics.lineHeight @@ -125,7 +125,7 @@ describe('sparse layout checkpoints', () => { metrics ); - instance.prepareVirtualizedItem(diff); + instance.prepareCodeViewItem(diff, 0); const expectedTop = metrics.diffHeaderHeight + 9_999 * metrics.lineHeight; expect(instance.getLinePosition(10_000, 'additions')?.top).toBe( @@ -160,7 +160,7 @@ describe('sparse layout checkpoints', () => { } const instance = new VirtualizedFileDiff({}, virtualizer, metrics); - instance.prepareVirtualizedItem(diff); + instance.prepareCodeViewItem(diff, 0); expect( instance.getLinePosition(secondHunk.additionStart - 2, 'additions') @@ -181,7 +181,7 @@ describe('sparse layout checkpoints', () => { metrics ); - instance.prepareVirtualizedItem(createLargeFile()); + instance.prepareCodeViewItem(createLargeFile(), 0); instance.setOptions({ disableVirtualizationBuffers: true }); expect(layoutDirtyCalls).toEqual([false]); @@ -203,7 +203,7 @@ describe('sparse layout checkpoints', () => { metrics ); - instance.prepareVirtualizedItem(parseDiffFromFile(oldFile, newFile)); + instance.prepareCodeViewItem(parseDiffFromFile(oldFile, newFile), 0); instance.setOptions({ diffIndicators: 'classic' }); expect(layoutDirtyCalls).toEqual([true]); diff --git a/packages/diffs/test/themeTypeUpdates.test.ts b/packages/diffs/test/themeTypeUpdates.test.ts index 9676340d7..89f757286 100644 --- a/packages/diffs/test/themeTypeUpdates.test.ts +++ b/packages/diffs/test/themeTypeUpdates.test.ts @@ -12,6 +12,8 @@ import { FileDiff, parseDiffFromFile, type RenderRange, + VirtualizedFile, + VirtualizedFileDiff, } from '../src'; function installDom() { @@ -321,6 +323,24 @@ describe('themeType updates', () => { } }); + test('CodeView-owned virtualized items reject direct themeType changes', () => { + const { cleanup } = installDom(); + try { + const viewer = new CodeView(); + const file = new VirtualizedFile({}, viewer); + const diff = new VirtualizedFileDiff({}, viewer); + + expect(() => file.setThemeType('dark')).toThrow( + 'VirtualizedFile.setThemeType cannot be used inside CodeView. Update CodeView options instead.' + ); + expect(() => diff.setThemeType('dark')).toThrow( + 'VirtualizedFileDiff.setThemeType cannot be used inside CodeView. Update CodeView options instead.' + ); + } finally { + cleanup(); + } + }); + test('File.render applies themeType changes during partial renders', async () => { const { cleanup } = installDom(); let instance: File | undefined; diff --git a/packages/diffs/test/virtualDiffLayout.test.ts b/packages/diffs/test/virtualDiffLayout.test.ts new file mode 100644 index 000000000..8a78e6baf --- /dev/null +++ b/packages/diffs/test/virtualDiffLayout.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, test } from 'bun:test'; + +import type { HunkSeparators, VirtualFileMetrics } from '../src/types'; +import { + getExpandedRegion, + getLeadingHunkSeparatorLayout, + getTrailingHunkSeparatorLayout, +} from '../src/utils/virtualDiffLayout'; + +const metrics: VirtualFileMetrics = { + hunkLineCount: 2, + lineHeight: 10, + diffHeaderHeight: 30, + spacing: 4, +}; + +describe('virtual diff layout helpers', () => { + describe('getExpandedRegion', () => { + test('keeps collapsed ranges collapsed by default', () => { + expect( + getExpandedRegion({ + isPartial: false, + rangeSize: 10, + expandedHunks: undefined, + hunkIndex: 1, + collapsedContextThreshold: 1, + }) + ).toEqual({ + fromStart: 0, + fromEnd: 0, + rangeSize: 10, + collapsedLines: 10, + renderAll: false, + }); + }); + + test('expands all lines for expandUnchanged or small ranges', () => { + expect( + getExpandedRegion({ + isPartial: false, + rangeSize: 10, + expandedHunks: true, + hunkIndex: 1, + collapsedContextThreshold: 1, + }) + ).toEqual({ + fromStart: 10, + fromEnd: 0, + rangeSize: 10, + collapsedLines: 0, + renderAll: true, + }); + + expect( + getExpandedRegion({ + isPartial: false, + rangeSize: 1, + expandedHunks: undefined, + hunkIndex: 1, + collapsedContextThreshold: 1, + }) + ).toEqual({ + fromStart: 1, + fromEnd: 0, + rangeSize: 1, + collapsedLines: 0, + renderAll: true, + }); + }); + + test('clamps explicit expansion regions to the collapsed range', () => { + const expandedHunks = new Map([[1, { fromStart: 3, fromEnd: 20 }]]); + + expect( + getExpandedRegion({ + isPartial: false, + rangeSize: 10, + expandedHunks, + hunkIndex: 1, + collapsedContextThreshold: 1, + }) + ).toEqual({ + fromStart: 10, + fromEnd: 0, + rangeSize: 10, + collapsedLines: 0, + renderAll: true, + }); + }); + + test('keeps partial diffs collapsed even with expansion state', () => { + expect( + getExpandedRegion({ + isPartial: true, + rangeSize: 10, + expandedHunks: true, + hunkIndex: 1, + collapsedContextThreshold: 1, + }) + ).toEqual({ + fromStart: 0, + fromEnd: 0, + rangeSize: 10, + collapsedLines: 10, + renderAll: false, + }); + }); + }); + + describe('separator layouts', () => { + test('preserves current leading separator rules', () => { + const cases: [ + type: HunkSeparators, + hunkIndex: number, + hunkSpecs: string | undefined, + totalHeight: number | undefined, + ][] = [ + ['simple', 0, '@@ -1 +1 @@', undefined], + ['simple', 1, '@@ -1 +1 @@', 4], + ['metadata', 0, undefined, undefined], + ['metadata', 0, '@@ -1 +1 @@', 32], + ['line-info', 0, '@@ -1 +1 @@', 36], + ['line-info', 1, '@@ -1 +1 @@', 40], + ['line-info-basic', 0, '@@ -1 +1 @@', 32], + ['custom', 0, '@@ -1 +1 @@', 36], + ['custom', 1, '@@ -1 +1 @@', 40], + ]; + + for (const [type, hunkIndex, hunkSpecs, totalHeight] of cases) { + expect( + getLeadingHunkSeparatorLayout({ + type, + metrics, + hunkIndex, + hunkSpecs, + })?.totalHeight + ).toBe(totalHeight); + } + }); + + test('preserves current trailing separator rules', () => { + const cases: [type: HunkSeparators, totalHeight: number | undefined][] = [ + ['simple', undefined], + ['metadata', undefined], + ['line-info', 36], + ['line-info-basic', 32], + ['custom', 36], + ]; + + for (const [type, totalHeight] of cases) { + expect( + getTrailingHunkSeparatorLayout({ type, metrics })?.totalHeight + ).toBe(totalHeight); + } + }); + + test('uses custom hunk separator height metrics', () => { + expect( + getLeadingHunkSeparatorLayout({ + type: 'line-info', + metrics: { ...metrics, hunkSeparatorHeight: 12 }, + hunkIndex: 1, + hunkSpecs: '@@ -1 +1 @@', + })?.totalHeight + ).toBe(20); + }); + }); +}); diff --git a/packages/diffs/test/virtualFileMetricsPadding.test.ts b/packages/diffs/test/virtualFileMetricsPadding.test.ts index b9d8dc9cd..3a0475409 100644 --- a/packages/diffs/test/virtualFileMetricsPadding.test.ts +++ b/packages/diffs/test/virtualFileMetricsPadding.test.ts @@ -70,7 +70,7 @@ function createVirtualizedFile( ...baseMetrics, ...metrics, }); - instance.prepareVirtualizedItem(file); + instance.prepareCodeViewItem(file, 0); return instance; } @@ -86,7 +86,7 @@ function createVirtualizedFileDiff( ...baseMetrics, ...metrics, }); - instance.prepareVirtualizedItem(fileDiff); + instance.prepareCodeViewItem(fileDiff, 0); return instance; } @@ -177,6 +177,54 @@ describe('virtual file padding metrics', () => { ); }); + test('does not add paddingBottom when a diff has no hunks', () => { + const fileDiff = parseDiffFromFile( + { name: 'same.ts', contents: 'one\n' }, + { name: 'same.ts', contents: 'one\n' } + ); + const instance = new VirtualizedFileDiff({}, virtualizer, { + ...baseMetrics, + paddingTop: 6, + paddingBottom: 13, + }); + + instance.prepareCodeViewItem(fileDiff, 0); + + expect(fileDiff.hunks.length).toBe(0); + expect(instance.getVirtualizedHeight()).toBe( + baseMetrics.diffHeaderHeight + 6 + ); + }); + + test('uses only the top region when collapsed', () => { + const fileDiff = createTwoHunkDiff(); + const [firstHunk] = fileDiff.hunks; + if (firstHunk == null) { + throw new Error('Expected a hunk'); + } + const instance = new VirtualizedFileDiff( + { collapsed: true }, + virtualizer, + { + ...baseMetrics, + paddingTop: 6, + paddingBottom: 13, + } + ); + + instance.prepareCodeViewItem(fileDiff, 0); + + expect(instance.getVirtualizedHeight()).toBe( + baseMetrics.diffHeaderHeight + 6 + ); + expect( + instance.getLinePosition(firstHunk.additionStart, 'additions') + ).toEqual({ + top: baseMetrics.diffHeaderHeight + 6, + height: 0, + }); + }); + test('keeps hunk separator gaps based on spacing', () => { const fileDiff = parseDiffFromFile( { @@ -200,7 +248,7 @@ describe('virtual file padding metrics', () => { paddingTop: 50, paddingBottom: 60, }); - instance.prepareVirtualizedItem(fileDiff); + instance.prepareCodeViewItem(fileDiff, 0); const [firstHunk, secondHunk] = fileDiff.hunks; if (firstHunk == null || secondHunk == null) { @@ -218,6 +266,141 @@ describe('virtual file padding metrics', () => { ); }); + test('keeps current line-info separator estimates for first, middle, and trailing collapsed context', () => { + const fileDiff = createTwoHunkDiff(); + const [firstHunk, secondHunk] = fileDiff.hunks; + if (firstHunk == null || secondHunk == null) { + throw new Error('Expected two hunks'); + } + const instance = new VirtualizedFileDiff( + { hunkSeparators: 'line-info' }, + virtualizer, + codeViewLikeMetrics + ); + const separatorHeight = 32; + const firstSeparatorHeight = + separatorHeight + codeViewLikeMetrics.spacing; + const middleSeparatorHeight = + codeViewLikeMetrics.spacing + + separatorHeight + + codeViewLikeMetrics.spacing; + const trailingSeparatorHeight = + codeViewLikeMetrics.spacing + separatorHeight; + const hunkLineHeight = + (firstHunk.splitLineCount + secondHunk.splitLineCount) * + codeViewLikeMetrics.lineHeight; + + instance.prepareCodeViewItem(fileDiff, 0); + + expect(firstHunk.collapsedBefore).toBeGreaterThan(0); + expect(secondHunk.collapsedBefore).toBeGreaterThan(0); + expect( + instance.getLinePosition(firstHunk.additionStart, 'additions')?.top + ).toBe(codeViewLikeMetrics.diffHeaderHeight + firstSeparatorHeight); + expect( + instance.getLinePosition(secondHunk.additionStart, 'additions')?.top + ).toBe( + codeViewLikeMetrics.diffHeaderHeight + + firstSeparatorHeight + + firstHunk.splitLineCount * codeViewLikeMetrics.lineHeight + + middleSeparatorHeight + ); + expect(instance.getVirtualizedHeight()).toBe( + codeViewLikeMetrics.diffHeaderHeight + + firstSeparatorHeight + + hunkLineHeight + + middleSeparatorHeight + + trailingSeparatorHeight + + codeViewLikeMetrics.spacing + ); + }); + + test('keeps current line-info-basic separator estimates without spacing gaps', () => { + const fileDiff = createTwoHunkDiff(); + const [firstHunk, secondHunk] = fileDiff.hunks; + if (firstHunk == null || secondHunk == null) { + throw new Error('Expected two hunks'); + } + const instance = new VirtualizedFileDiff( + { hunkSeparators: 'line-info-basic' }, + virtualizer, + codeViewLikeMetrics + ); + const separatorHeight = 32; + const hunkLineHeight = + (firstHunk.splitLineCount + secondHunk.splitLineCount) * + codeViewLikeMetrics.lineHeight; + + instance.prepareCodeViewItem(fileDiff, 0); + + expect( + instance.getLinePosition(firstHunk.additionStart, 'additions')?.top + ).toBe(codeViewLikeMetrics.diffHeaderHeight + separatorHeight); + expect( + instance.getLinePosition(secondHunk.additionStart, 'additions')?.top + ).toBe( + codeViewLikeMetrics.diffHeaderHeight + + separatorHeight + + firstHunk.splitLineCount * codeViewLikeMetrics.lineHeight + + separatorHeight + ); + expect(instance.getVirtualizedHeight()).toBe( + codeViewLikeMetrics.diffHeaderHeight + + separatorHeight + + hunkLineHeight + + separatorHeight + + separatorHeight + + codeViewLikeMetrics.spacing + ); + }); + + test('keeps current custom separator estimates aligned with line-info gaps', () => { + const fileDiff = createTwoHunkDiff(); + const [firstHunk, secondHunk] = fileDiff.hunks; + if (firstHunk == null || secondHunk == null) { + throw new Error('Expected two hunks'); + } + const instance = new VirtualizedFileDiff( + { hunkSeparators: () => undefined }, + virtualizer, + codeViewLikeMetrics + ); + const separatorHeight = 32; + const firstSeparatorHeight = + separatorHeight + codeViewLikeMetrics.spacing; + const middleSeparatorHeight = + codeViewLikeMetrics.spacing + + separatorHeight + + codeViewLikeMetrics.spacing; + const trailingSeparatorHeight = + codeViewLikeMetrics.spacing + separatorHeight; + const hunkLineHeight = + (firstHunk.splitLineCount + secondHunk.splitLineCount) * + codeViewLikeMetrics.lineHeight; + + instance.prepareCodeViewItem(fileDiff, 0); + + expect( + instance.getLinePosition(firstHunk.additionStart, 'additions')?.top + ).toBe(codeViewLikeMetrics.diffHeaderHeight + firstSeparatorHeight); + expect( + instance.getLinePosition(secondHunk.additionStart, 'additions')?.top + ).toBe( + codeViewLikeMetrics.diffHeaderHeight + + firstSeparatorHeight + + firstHunk.splitLineCount * codeViewLikeMetrics.lineHeight + + middleSeparatorHeight + ); + expect(instance.getVirtualizedHeight()).toBe( + codeViewLikeMetrics.diffHeaderHeight + + firstSeparatorHeight + + hunkLineHeight + + middleSeparatorHeight + + trailingSeparatorHeight + + codeViewLikeMetrics.spacing + ); + }); + test('uses built-in simple separator measurements with CodeView metrics', () => { const fileDiff = createTwoHunkDiff(); const [firstHunk, secondHunk] = fileDiff.hunks; @@ -230,7 +413,7 @@ describe('virtual file padding metrics', () => { codeViewLikeMetrics ); - instance.prepareVirtualizedItem(fileDiff); + instance.prepareCodeViewItem(fileDiff, 0); expect(firstHunk.collapsedBefore).toBeGreaterThan(0); expect(secondHunk.collapsedBefore).toBeGreaterThan(0); @@ -265,7 +448,7 @@ describe('virtual file padding metrics', () => { codeViewLikeMetrics ); - instance.prepareVirtualizedItem(fileDiff); + instance.prepareCodeViewItem(fileDiff, 0); expect( instance.getLinePosition(secondHunk.additionStart, 'additions')?.top ).toBe( @@ -298,7 +481,7 @@ describe('virtual file padding metrics', () => { codeViewLikeMetrics ); - instance.prepareVirtualizedItem(fileDiff); + instance.prepareCodeViewItem(fileDiff, 0); expect(firstHunk.collapsedBefore).toBeGreaterThan(0); expect(secondHunk.collapsedBefore).toBeGreaterThan(0); diff --git a/packages/diffs/test/virtualizedFileDiffEstimatedHeights.test.ts b/packages/diffs/test/virtualizedFileDiffEstimatedHeights.test.ts new file mode 100644 index 000000000..8788724f5 --- /dev/null +++ b/packages/diffs/test/virtualizedFileDiffEstimatedHeights.test.ts @@ -0,0 +1,392 @@ +import { describe, expect, test } from 'bun:test'; + +import { VirtualizedFileDiff } from '../src/components/VirtualizedFileDiff'; +import { DEFAULT_CODE_VIEW_FILE_METRICS } from '../src/constants'; +import type { FileDiffMetadata, VirtualFileMetrics } from '../src/types'; +import { parseDiffFromFile } from '../src/utils/parseDiffFromFile'; + +const metrics: VirtualFileMetrics = { + ...DEFAULT_CODE_VIEW_FILE_METRICS, + hunkLineCount: 2, + lineHeight: 10, + diffHeaderHeight: 30, + spacing: 4, +}; + +const virtualizer = { + type: 'simple', + config: {}, + connect() {}, + disconnect() {}, + getWindowSpecs() { + return { top: 0, bottom: 1000 }; + }, + getOffsetInScrollContainer() { + return 0; + }, + instanceChanged() {}, + isInstanceVisible() { + return true; + }, +} as never; + +interface InspectableVirtualizedFileDiff { + cache: { + heightDeltas: Map; + measuredHeightDeltaTotal: number; + estimatedSplitHeight: number | undefined; + estimatedUnifiedHeight: number | undefined; + checkpoints: unknown[]; + totalLines: number; + }; + fileContainer: HTMLElement | undefined; + codeAdditions: HTMLElement | undefined; +} + +function inspect( + instance: VirtualizedFileDiff +): InspectableVirtualizedFileDiff { + return instance as unknown as InspectableVirtualizedFileDiff; +} + +function createTwoHunkDiff(cacheKey = 'base'): FileDiffMetadata { + const oldLines = Array.from({ length: 140 }, (_, index) => `${index + 1}`); + const newLines = oldLines.map((line, index) => { + if (index === 39) return `${cacheKey}-changed-40`; + if (index === 99) return `${cacheKey}-changed-100`; + return line; + }); + + return parseDiffFromFile( + { + name: 'two-hunks.ts', + contents: `${oldLines.join('\n')}\n`, + cacheKey: `${cacheKey}:old`, + }, + { + name: 'two-hunks.ts', + contents: `${newLines.join('\n')}\n`, + cacheKey: `${cacheKey}:new`, + } + ); +} + +function createLargeExpandedDiff(): FileDiffMetadata { + const oldLines = Array.from({ length: 12_000 }, (_, index) => `${index + 1}`); + const newLines = oldLines.map((line, index) => + index === 5_999 ? 'changed-6000' : line + ); + + return parseDiffFromFile( + { name: 'large.ts', contents: `${oldLines.join('\n')}\n` }, + { name: 'large.ts', contents: `${newLines.join('\n')}\n` } + ); +} + +function createHugeSingleBlockDiff(lineCount: number): FileDiffMetadata { + return { + name: 'huge.ts', + type: 'change', + hunks: [ + { + collapsedBefore: 0, + additionStart: 1, + additionCount: lineCount, + additionLines: 0, + additionLineIndex: 0, + deletionStart: 1, + deletionCount: lineCount, + deletionLines: 0, + deletionLineIndex: 0, + hunkContent: [ + { + type: 'context', + lines: lineCount, + additionLineIndex: 0, + deletionLineIndex: 0, + }, + ], + splitLineStart: 0, + splitLineCount: lineCount, + unifiedLineStart: 0, + unifiedLineCount: lineCount, + noEOFCRDeletions: false, + noEOFCRAdditions: false, + }, + ], + splitLineCount: lineCount, + unifiedLineCount: lineCount, + isPartial: true, + deletionLines: [], + additionLines: [], + }; +} + +class FakeHTMLElement { + public children: FakeHTMLElement[] = []; + public dataset: Record = {}; + public nextElementSibling: FakeHTMLElement | undefined; + + constructor(private readonly getHeight = () => 0) {} + + public append(...elements: FakeHTMLElement[]): void { + this.children.push(...elements); + } + + public getBoundingClientRect(): DOMRect { + return { height: this.getHeight() } as DOMRect; + } +} + +function installFakeHTMLElement() { + const originalValues = { + HTMLElement: Reflect.get(globalThis, 'HTMLElement'), + }; + + Object.assign(globalThis, { + HTMLElement: FakeHTMLElement, + }); + + return { + cleanup() { + for (const [key, value] of Object.entries(originalValues)) { + if (value === undefined) { + Reflect.deleteProperty(globalThis, key); + } else { + Object.assign(globalThis, { [key]: value }); + } + } + }, + }; +} + +function createMeasuredCodeGroup( + lineIndex: string, + getMeasuredHeight: () => number +): HTMLElement { + const group = new FakeHTMLElement(); + const gutter = new FakeHTMLElement(); + const content = new FakeHTMLElement(); + const line = new FakeHTMLElement(getMeasuredHeight); + line.dataset.lineIndex = lineIndex; + content.append(line); + group.append(gutter, content); + return group as unknown as HTMLElement; +} + +describe('VirtualizedFileDiff estimated height cache', () => { + test('computes split and unified estimates together on first prepare', () => { + const instance = new VirtualizedFileDiff({}, virtualizer, metrics); + + instance.prepareCodeViewItem(createTwoHunkDiff(), 0); + + expect(inspect(instance).cache.estimatedSplitHeight).toBe(326); + expect(inspect(instance).cache.estimatedUnifiedHeight).toBe(346); + expect(inspect(instance).cache.measuredHeightDeltaTotal).toBe(0); + expect(inspect(instance).cache.totalLines).toBe(0); + expect(inspect(instance).cache.checkpoints).toEqual([]); + expect(instance.getVirtualizedHeight()).toBe(326); + }); + + test('keeps estimates and measurements for an equivalent diff cache key', () => { + const fileDiff = createTwoHunkDiff('same'); + const equivalentFileDiff = { + ...fileDiff, + hunks: [...fileDiff.hunks], + }; + const instance = new VirtualizedFileDiff({}, virtualizer, metrics); + + instance.prepareCodeViewItem(fileDiff, 0); + inspect(instance).cache.estimatedSplitHeight = 123; + inspect(instance).cache.estimatedUnifiedHeight = 456; + inspect(instance).cache.heightDeltas.set(0, 7); + inspect(instance).cache.measuredHeightDeltaTotal = 7; + instance.prepareCodeViewItem(equivalentFileDiff, 0); + + expect(inspect(instance).cache.estimatedSplitHeight).toBe(123); + expect(inspect(instance).cache.estimatedUnifiedHeight).toBe(456); + expect(inspect(instance).cache.heightDeltas.get(0)).toBe(7); + expect(inspect(instance).cache.measuredHeightDeltaTotal).toBe(7); + }); + + test('clears estimates and measurements for changed diff content', () => { + const instance = new VirtualizedFileDiff({}, virtualizer, metrics); + + instance.prepareCodeViewItem(createTwoHunkDiff('first'), 0); + inspect(instance).cache.estimatedSplitHeight = 123; + inspect(instance).cache.estimatedUnifiedHeight = 456; + inspect(instance).cache.heightDeltas.set(0, 7); + inspect(instance).cache.measuredHeightDeltaTotal = 7; + instance.prepareCodeViewItem(createTwoHunkDiff('second'), 0); + + expect(inspect(instance).cache.estimatedSplitHeight).toBe(326); + expect(inspect(instance).cache.estimatedUnifiedHeight).toBe(346); + expect(inspect(instance).cache.heightDeltas.size).toBe(0); + expect(inspect(instance).cache.measuredHeightDeltaTotal).toBe(0); + }); + + test('reuses paired estimates across split and unified style changes', () => { + const instance = new VirtualizedFileDiff({}, virtualizer, metrics); + + instance.prepareCodeViewItem(createTwoHunkDiff(), 0); + inspect(instance).cache.heightDeltas.set(0, 7); + inspect(instance).cache.measuredHeightDeltaTotal = 7; + expect(instance.getLinePosition(40, 'additions')).toBeDefined(); + expect(inspect(instance).cache.checkpoints.length).toBeGreaterThan(0); + instance.setOptions({ diffStyle: 'unified' }); + + expect(inspect(instance).cache.estimatedSplitHeight).toBe(326); + expect(inspect(instance).cache.estimatedUnifiedHeight).toBe(346); + expect(inspect(instance).cache.heightDeltas.size).toBe(0); + expect(inspect(instance).cache.measuredHeightDeltaTotal).toBe(0); + expect(inspect(instance).cache.checkpoints).toEqual([]); + expect(inspect(instance).cache.totalLines).toBe(0); + expect(instance.getVirtualizedHeight()).toBe(346); + + instance.setOptions({ diffStyle: 'split' }); + + expect(inspect(instance).cache.estimatedSplitHeight).toBe(326); + expect(inspect(instance).cache.estimatedUnifiedHeight).toBe(346); + expect(instance.getVirtualizedHeight()).toBe(326); + }); + + test('keeps paired estimates across collapse changes', () => { + const instance = new VirtualizedFileDiff({}, virtualizer, metrics); + + instance.prepareCodeViewItem(createTwoHunkDiff(), 0); + inspect(instance).cache.heightDeltas.set(0, 7); + inspect(instance).cache.measuredHeightDeltaTotal = 7; + expect(instance.getLinePosition(40, 'additions')).toBeDefined(); + expect(inspect(instance).cache.checkpoints.length).toBeGreaterThan(0); + instance.setOptions({ collapsed: true }); + + expect(inspect(instance).cache.estimatedSplitHeight).toBe(326); + expect(inspect(instance).cache.estimatedUnifiedHeight).toBe(346); + expect(inspect(instance).cache.heightDeltas.size).toBe(0); + expect(inspect(instance).cache.measuredHeightDeltaTotal).toBe(0); + expect(inspect(instance).cache.checkpoints).toEqual([]); + expect(inspect(instance).cache.totalLines).toBe(0); + expect(instance.getVirtualizedHeight()).toBe(metrics.diffHeaderHeight); + + instance.setOptions({ collapsed: false }); + + expect(inspect(instance).cache.estimatedSplitHeight).toBe(326); + expect(inspect(instance).cache.estimatedUnifiedHeight).toBe(346); + expect(instance.getVirtualizedHeight()).toBe(326); + }); + + test('recomputes paired estimates when hunk expansion changes', () => { + const instance = new VirtualizedFileDiff({}, virtualizer, metrics); + + instance.prepareCodeViewItem(createTwoHunkDiff(), 0); + inspect(instance).cache.heightDeltas.set(0, 7); + inspect(instance).cache.measuredHeightDeltaTotal = 7; + expect(instance.getLinePosition(40, 'additions')).toBeDefined(); + expect(inspect(instance).cache.checkpoints.length).toBeGreaterThan(0); + instance.expandHunk(0, 'down', 5); + + expect(inspect(instance).cache.estimatedSplitHeight).toBe(376); + expect(inspect(instance).cache.estimatedUnifiedHeight).toBe(396); + expect(inspect(instance).cache.heightDeltas.size).toBe(0); + expect(inspect(instance).cache.measuredHeightDeltaTotal).toBe(0); + expect(inspect(instance).cache.checkpoints).toEqual([]); + expect(inspect(instance).cache.totalLines).toBe(0); + expect(instance.getVirtualizedHeight()).toBe(376); + }); + + test('applies measured height deltas without replaying full diff layout', () => { + const { cleanup } = installFakeHTMLElement(); + try { + const instance = new VirtualizedFileDiff( + { overflow: 'wrap' }, + virtualizer, + metrics + ); + let measuredHeight = 17; + + instance.prepareCodeViewItem(createTwoHunkDiff(), 0); + inspect(instance).fileContainer = + new FakeHTMLElement() as unknown as HTMLElement; + inspect(instance).codeAdditions = createMeasuredCodeGroup( + '0,0', + () => measuredHeight + ); + + expect(instance.reconcileHeights()).toBe(true); + expect(inspect(instance).cache.heightDeltas.get(0)).toBe(7); + expect(inspect(instance).cache.measuredHeightDeltaTotal).toBe(7); + expect(inspect(instance).cache.totalLines).toBe(0); + expect(inspect(instance).cache.checkpoints).toEqual([]); + expect(instance.getVirtualizedHeight()).toBe(333); + + measuredHeight = 10; + + expect(instance.reconcileHeights()).toBe(true); + expect(inspect(instance).cache.heightDeltas.size).toBe(0); + expect(inspect(instance).cache.measuredHeightDeltaTotal).toBe(0); + expect(inspect(instance).cache.totalLines).toBe(0); + expect(inspect(instance).cache.checkpoints).toEqual([]); + expect(instance.getVirtualizedHeight()).toBe(326); + } finally { + cleanup(); + } + }); + + test('builds layout checkpoints lazily for deep geometry lookups', () => { + const instance = new VirtualizedFileDiff( + { expandUnchanged: true }, + virtualizer, + metrics + ); + + instance.prepareCodeViewItem(createLargeExpandedDiff(), 0); + const estimatedHeight = instance.getVirtualizedHeight(); + + expect(inspect(instance).cache.totalLines).toBe(0); + expect(inspect(instance).cache.checkpoints).toEqual([]); + + expect(instance.getLinePosition(10_000, 'additions')).toBeDefined(); + + expect(instance.getVirtualizedHeight()).toBe(estimatedHeight); + expect(inspect(instance).cache.totalLines).toBeGreaterThan(10_000); + expect(inspect(instance).cache.checkpoints.length).toBeGreaterThan(1); + }); + + test('builds layout checkpoints lazily for deep render windows', () => { + const instance = new VirtualizedFileDiff( + { expandUnchanged: true }, + virtualizer, + metrics + ); + + instance.prepareCodeViewItem(createLargeExpandedDiff(), 0); + const estimatedHeight = instance.getVirtualizedHeight(); + + expect(inspect(instance).cache.totalLines).toBe(0); + expect(inspect(instance).cache.checkpoints).toEqual([]); + + expect( + instance.getAdvancedStickySpecs({ top: 100_000, bottom: 100_500 }) + ).toBeDefined(); + + expect(instance.getVirtualizedHeight()).toBe(estimatedHeight); + expect(inspect(instance).cache.totalLines).toBeGreaterThan(10_000); + expect(inspect(instance).cache.checkpoints.length).toBeGreaterThan(1); + }); + + test('checkpoint generation jumps through large uniform blocks', () => { + const instance = new VirtualizedFileDiff({}, virtualizer, metrics); + const lineCount = 1_000_000; + + instance.prepareCodeViewItem(createHugeSingleBlockDiff(lineCount), 0); + + expect(instance.getLinePosition(900_000, 'additions')).toEqual({ + top: metrics.diffHeaderHeight + 899_999 * metrics.lineHeight, + height: metrics.lineHeight, + }); + expect(inspect(instance).cache.totalLines).toBe(lineCount); + expect(inspect(instance).cache.checkpoints.length).toBe( + Math.floor((lineCount - 1) / 5_000) + 1 + ); + }); +});