/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; import sinon from 'sinon'; import { Dimension } from '../../../../base/browser/dom.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; import { Event, ValueWithChangeEvent } from '../../../../base/common/event.js'; import { autorun, waitForState } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { mock } from '../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { AccessibilitySupport } from '../../../../platform/accessibility/common/accessibility.js'; import { IAccessibilitySignalService } from '../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; import { IActionViewItemService, NullActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { IMenu, IMenuService } from '../../../../platform/actions/common/actions.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; import { emptyProgressRunner, IEditorProgressService } from '../../../../platform/progress/common/progress.js'; import { InMemoryStorageService, IStorageService } from '../../../../platform/storage/common/storage.js'; import { IDiffProviderFactoryService } from '../../../browser/widget/diffEditor/diffProviderFactoryService.js'; import { DiffEditorWidget } from '../../../browser/widget/diffEditor/diffEditorWidget.js'; import { RefCounted } from '../../../browser/widget/diffEditor/utils.js'; import { DiffItemSource, IDocumentDiffItem, IMultiDiffEditorModel } from '../../../browser/widget/multiDiffEditor/model.js'; import { getMultiDiffEditorVariantConfiguration, MultiDiffEditorVariant } from '../../../browser/widget/multiDiffEditor/multiDiffEditorOptions.js'; import { MultiDiffEditorWidget } from '../../../browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; import { IWorkbenchUIElementFactory } from '../../../browser/widget/multiDiffEditor/workbenchUIElementFactory.js'; import { EditorOption } from '../../../common/config/editorOptions.js'; import { IDocumentDiff, IDocumentDiffProvider } from '../../../common/diff/documentDiffProvider.js'; import { EditorContextKeys } from '../../../common/editorContextKeys.js'; import { instantiateTextModel } from '../../common/testTextModel.js'; import { TestDiffProviderFactoryService } from '../diff/testDiffProviderFactoryService.js'; import { createCodeEditorServices } from '../testCodeEditor.js'; suite('MultiDiffEditorWidget', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); teardown(() => { sinon.restore(); }); test('uses closed variant configurations', () => { assert.deepStrictEqual({ standard: getMultiDiffEditorVariantConfiguration(MultiDiffEditorVariant.Standard), compact: getMultiDiffEditorVariantConfiguration(MultiDiffEditorVariant.Compact), }, { standard: { className: 'multiDiffEditor-standard', horizontalInsets: { left: 9, right: 9 }, headerHeight: 40, contentBottomPadding: 0, headerClickToCollapse: false, }, compact: { className: 'multiDiffEditor-compact', horizontalInsets: { left: 0, right: 0 }, headerHeight: 32, contentBottomPadding: 8, headerClickToCollapse: true, }, }); }); test('models bottom padding as trailing scroll content', () => { const services = new ServiceCollection(); services.set(IAccessibilitySignalService, new class extends mock() { }()); services.set(IActionViewItemService, new NullActionViewItemService()); services.set(IEditorProgressService, new class extends mock() { }()); services.set(IDiffProviderFactoryService, new TestDiffProviderFactoryService()); services.set(IStorageService, disposables.add(new InMemoryStorageService())); services.set(IMenuService, new class extends mock() { override createMenu(): IMenu { return new class extends mock() { override readonly onDidChange = Event.None; override getActions() { return []; } override dispose(): void { } }(); } }()); const instantiationService = createCodeEditorServices(disposables, services); const container = document.createElement('div'); const widget = instantiationService.createInstance( MultiDiffEditorWidget, container, {} satisfies IWorkbenchUIElementFactory, { variant: MultiDiffEditorVariant.Standard }, ); widget.layout(new Dimension(800, 200)); const initialState = widget.getLayoutDebugState().get(); widget.setPaddingBottom(24); try { const state = widget.getLayoutDebugState().get(); assert.deepStrictEqual({ logicalScrollHeightDelta: state.layout.logicalScrollHeight - initialState.layout.logicalScrollHeight, scrollHeightDelta: state.scrollDimensions.scrollHeight - initialState.scrollDimensions.scrollHeight, diffItems: state.items.length, }, { logicalScrollHeightDelta: 24, scrollHeightDelta: 24, diffItems: 0, }); } finally { widget.dispose(); } }); test('renders binary files as a placeholder', async () => { const services = new ServiceCollection(); services.set(IAccessibilitySignalService, new class extends mock() { }()); services.set(IActionViewItemService, new NullActionViewItemService()); services.set(IEditorProgressService, new class extends mock() { }()); services.set(IDiffProviderFactoryService, new TestDiffProviderFactoryService()); services.set(IStorageService, disposables.add(new InMemoryStorageService())); services.set(IMenuService, new class extends mock() { override createMenu(): IMenu { return new class extends mock() { override readonly onDidChange = Event.None; override getActions() { return []; } override dispose(): void { } }(); } }()); const instantiationService = createCodeEditorServices(disposables, services); const originalUri = URI.parse('inmemory://original/image.png'); const modifiedUri = URI.parse('inmemory://modified/image.png'); const documentItem = RefCounted.createOfNonDisposable({ original: new DiffItemSource(originalUri, undefined), modified: new DiffItemSource(modifiedUri, undefined), }, { dispose() { } }); const model: IMultiDiffEditorModel = { documents: ValueWithChangeEvent.const([documentItem]), }; let openedDiff: { original: URI; modified: URI } | undefined; const container = document.createElement('div'); const widget = instantiationService.createInstance( MultiDiffEditorWidget, container, { openDiffEditor: (original, modified) => openedDiff = { original, modified }, } satisfies IWorkbenchUIElementFactory, { variant: MultiDiffEditorVariant.Standard }, ); widget.layout(new Dimension(800, 600)); const viewModel = widget.createViewModel(model); await waitForState(viewModel.items, items => items.length === 1); widget.setViewModel(viewModel); widget.reveal({ original: originalUri, modified: modifiedUri }, { highlight: false }); await waitForState(widget.getLayoutDebugState(), state => state.items[0]?.hasTemplate === true); try { const placeholder = widget.getRootElement().querySelector('.binary-file-placeholder'); const editor = widget.getRootElement().querySelector('.editorContainer'); const openDiffButton = placeholder?.querySelector('.monaco-button'); const focusSpy = sinon.spy(Button.prototype, 'focus'); const canFocusActiveItem = widget.focus(); openDiffButton?.click(); assert.deepStrictEqual({ text: placeholder?.textContent, display: placeholder?.style.display, tabIndex: placeholder?.tabIndex, role: placeholder?.getAttribute('role'), ariaLabel: placeholder?.getAttribute('aria-label'), openDiffButtonText: openDiffButton?.textContent, openDiffButtonSecondary: openDiffButton?.classList.contains('secondary'), openDiffButtonFocused: focusSpy.calledOnce, openedOriginalUri: openedDiff?.original.toString(), openedModifiedUri: openedDiff?.modified.toString(), editorDisplay: editor?.style.display, itemHeight: widget.getLayoutDebugState().get().items[0].verticalState.contentHeight, canFocusActiveItem, findsDocumentItem: widget.findDocumentDiffItem(modifiedUri) === documentItem.object, hasCodeEditorForBinaryResource: widget.tryGetCodeEditor(modifiedUri) !== undefined, }, { text: 'Binary file changedOpen Diff', display: 'grid', tabIndex: -1, role: 'group', ariaLabel: 'Binary file changed', openDiffButtonText: 'Open Diff', openDiffButtonSecondary: true, openDiffButtonFocused: true, openedOriginalUri: originalUri.toString(), openedModifiedUri: modifiedUri.toString(), editorDisplay: 'none', itemHeight: 140, canFocusActiveItem: true, findsDocumentItem: true, hasCodeEditorForBinaryResource: false, }); } finally { widget.setViewModel(undefined); viewModel.dispose(); widget.dispose(); documentItem.dispose(); } }); test('applies document and responsive layout options before attaching the diff model', async () => { const services = new ServiceCollection(); services.set(IAccessibilitySignalService, new class extends mock() { }()); services.set(IActionViewItemService, new NullActionViewItemService()); services.set(IEditorProgressService, new class extends mock() { }()); services.set(IDiffProviderFactoryService, new TestDiffProviderFactoryService()); services.set(IStorageService, disposables.add(new InMemoryStorageService())); services.set(IMenuService, new class extends mock() { override createMenu(): IMenu { return new class extends mock() { override readonly onDidChange = Event.None; override getActions() { return []; } override dispose(): void { } }(); } }()); const instantiationService = createCodeEditorServices(disposables, services); const originalUri = URI.parse('inmemory://original/test.js'); const modifiedUri = URI.parse('inmemory://modified/test.js'); const original = disposables.add(instantiateTextModel(instantiationService, 'const value = 1;', undefined, undefined, originalUri)); const modified = disposables.add(instantiateTextModel(instantiationService, 'const value = 2;', undefined, undefined, modifiedUri)); const documentItem = RefCounted.createOfNonDisposable({ original: new DiffItemSource(originalUri, original), modified: new DiffItemSource(modifiedUri, modified), options: { accessibilitySupport: 'off' }, }, { dispose() { } }); const model: IMultiDiffEditorModel = { documents: ValueWithChangeEvent.const([documentItem]), }; const updateOptionsSpy = sinon.spy(DiffEditorWidget.prototype, 'updateOptions'); const setDiffModelSpy = sinon.spy(DiffEditorWidget.prototype, 'setDiffModel'); const container = document.createElement('div'); const widget = instantiationService.createInstance( MultiDiffEditorWidget, container, {} satisfies IWorkbenchUIElementFactory, { variant: MultiDiffEditorVariant.Standard }, ); widget.setViewMode('automatic'); widget.layout(new Dimension(800, 600)); const viewModel = widget.createViewModel(model); await waitForState(viewModel.items, items => items.length === 1); widget.setViewModel(viewModel); widget.reveal({ original: originalUri, modified: modifiedUri }, { highlight: false }); try { const activeControl = widget.getActiveControl(); const renderSideBySideWhenNarrow = activeControl?.renderSideBySide; const automaticLayoutWhenNarrow = widget.getContextKeyService().getContextKeyValue(EditorContextKeys.diffEditorAutomaticRenderSideBySide.key); widget.layout(new Dimension(1000, 600)); assert.deepStrictEqual({ configuredAccessibilitySupport: updateOptionsSpy.firstCall.args[0].accessibilitySupport, configuredRenderSideBySide: updateOptionsSpy.firstCall.args[0].renderSideBySide, configuredUseInlineViewWhenSpaceIsLimited: updateOptionsSpy.firstCall.args[0].useInlineViewWhenSpaceIsLimited, renderSideBySideWhenNarrow, renderSideBySideWhenWide: activeControl?.renderSideBySide, automaticLayoutWhenNarrow, automaticLayoutWhenWide: widget.getContextKeyService().getContextKeyValue(EditorContextKeys.diffEditorAutomaticRenderSideBySide.key), optionsAppliedBeforeModel: updateOptionsSpy.calledBefore(setDiffModelSpy), effectiveAccessibilitySupport: activeControl?.getModifiedEditor().getOption(EditorOption.accessibilitySupport), }, { configuredAccessibilitySupport: 'off', configuredRenderSideBySide: true, configuredUseInlineViewWhenSpaceIsLimited: true, renderSideBySideWhenNarrow: false, renderSideBySideWhenWide: true, automaticLayoutWhenNarrow: false, automaticLayoutWhenWide: true, optionsAppliedBeforeModel: true, effectiveAccessibilitySupport: AccessibilitySupport.Disabled, }); } finally { widget.setViewModel(undefined); viewModel.dispose(); widget.dispose(); documentItem.dispose(); } }); test('uses the taller side while binding a deleted file', async () => { const services = new ServiceCollection(); services.set(IAccessibilitySignalService, new class extends mock() { }()); services.set(IActionViewItemService, new NullActionViewItemService()); services.set(IEditorProgressService, new class extends mock() { }()); services.set(IDiffProviderFactoryService, new TestDiffProviderFactoryService()); services.set(IStorageService, disposables.add(new InMemoryStorageService())); services.set(IMenuService, new class extends mock() { override createMenu(): IMenu { return new class extends mock() { override readonly onDidChange = Event.None; override getActions() { return []; } override dispose(): void { } }(); } }()); const instantiationService = createCodeEditorServices(disposables, services); const originalUri = URI.parse('inmemory://original/deleted.js'); const originalContent = Array.from({ length: 64 }, (_, index) => `line ${index}`).join('\n'); const original = disposables.add(instantiateTextModel(instantiationService, originalContent, undefined, undefined, originalUri)); const documentItem = RefCounted.createOfNonDisposable({ original: new DiffItemSource(originalUri, original), modified: undefined, options: { accessibilitySupport: 'off' }, }, { dispose() { } }); const model: IMultiDiffEditorModel = { documents: ValueWithChangeEvent.const([documentItem]), }; const container = document.createElement('div'); const widget = instantiationService.createInstance( MultiDiffEditorWidget, container, {} satisfies IWorkbenchUIElementFactory, { variant: MultiDiffEditorVariant.Standard }, ); widget.layout(new Dimension(800, 600)); const viewModel = widget.createViewModel(model); await waitForState(viewModel.items, items => items.length === 1); await waitForState(viewModel.items.get()[0].diffEditorViewModel.isDiffUpToDate, value => value); const observedHeights: number[] = []; const observer = autorun(reader => { const item = widget.getLayoutDebugState().read(reader).items[0]; if (item?.hasTemplate) { observedHeights.push(item.verticalState.contentHeight); } }); try { widget.setViewModel(viewModel); widget.reveal({ original: originalUri, modified: undefined }, { highlight: false }); await waitForState(widget.getLayoutDebugState(), state => state.items[0]?.hasTemplate); const item = widget.getLayoutDebugState().get().items[0]; const expectedHeight = widget.getActiveControl()!.getOriginalEditor().getContentHeight() + 40; assert.deepStrictEqual({ minimumObservedHeight: Math.min(...observedHeights), finalHeight: item.verticalState.contentHeight, }, { minimumObservedHeight: expectedHeight, finalHeight: expectedHeight, }); } finally { observer.dispose(); widget.setViewModel(undefined); viewModel.dispose(); widget.dispose(); documentItem.dispose(); } }); test('preserves expanded height when a collapsed template is recycled', async () => { const services = new ServiceCollection(); services.set(IAccessibilitySignalService, new class extends mock() { }()); services.set(IActionViewItemService, new NullActionViewItemService()); services.set(IEditorProgressService, new class extends mock() { override show() { return emptyProgressRunner; } }()); services.set(IDiffProviderFactoryService, new PendingDiffProviderFactoryService()); services.set(IStorageService, disposables.add(new InMemoryStorageService())); services.set(IMenuService, new class extends mock() { override createMenu(): IMenu { return new class extends mock() { override readonly onDidChange = Event.None; override getActions() { return []; } override dispose(): void { } }(); } }()); const instantiationService = createCodeEditorServices(disposables, services); const documentItems: RefCounted[] = []; const originalUris: URI[] = []; const modifiedUris: URI[] = []; for (let index = 0; index < 3; index++) { const originalUri = URI.parse(`inmemory://original/test-${index}.js`); const modifiedUri = URI.parse(`inmemory://modified/test-${index}.js`); const original = disposables.add(instantiateTextModel(instantiationService, '', undefined, undefined, originalUri)); const modified = disposables.add(instantiateTextModel(instantiationService, 'const value = 1;', undefined, undefined, modifiedUri)); documentItems.push(RefCounted.createOfNonDisposable({ original: new DiffItemSource(originalUri, original), modified: new DiffItemSource(modifiedUri, modified), options: { accessibilitySupport: 'off' }, }, { dispose() { } })); originalUris.push(originalUri); modifiedUris.push(modifiedUri); } const model: IMultiDiffEditorModel = { documents: ValueWithChangeEvent.const(documentItems), }; const container = document.createElement('div'); const widget = instantiationService.createInstance( MultiDiffEditorWidget, container, {} satisfies IWorkbenchUIElementFactory, { variant: MultiDiffEditorVariant.Standard }, ); widget.layout(new Dimension(800, 200)); const viewModel = widget.createViewModel(model); await waitForState(viewModel.items, items => items.length === documentItems.length); viewModel.items.get()[0].lastTemplateData.set({ expandedContentHeight: 2000, selections: undefined }, undefined); widget.setViewModel(viewModel); widget.reveal({ original: originalUris[0], modified: modifiedUris[0] }, { highlight: false }); const observedLastItemHeights: number[] = []; const observer = autorun(reader => { const lastItem = widget.getLayoutDebugState().read(reader).items.at(-1); if (lastItem?.hasTemplate) { observedLastItemHeights.push(lastItem.verticalState.contentHeight); } }); try { const firstItem = widget.getLayoutDebugState().get().items[0]; viewModel.items.get()[0].collapsed.set(true, undefined); widget.reveal({ original: originalUris.at(-1), modified: modifiedUris.at(-1) }, { highlight: false }); const cachedFirstItemHeight = viewModel.items.get()[0].lastTemplateData.get().expandedContentHeight; widget.reveal({ original: originalUris[0], modified: modifiedUris[0] }, { highlight: false }); viewModel.items.get()[0].collapsed.set(false, undefined); assert.deepStrictEqual({ firstItemHasTemplate: firstItem.hasTemplate, firstItemHeight: firstItem.verticalState.contentHeight, cachedFirstItemHeight, firstObservedLastItemHeight: observedLastItemHeights[0], restoredFirstItemHeight: widget.getLayoutDebugState().get().items[0].verticalState.contentHeight, }, { firstItemHasTemplate: true, firstItemHeight: 2000, cachedFirstItemHeight: 2000, firstObservedLastItemHeight: 500, restoredFirstItemHeight: 2000, }); } finally { observer.dispose(); widget.setViewModel(undefined); viewModel.dispose(); widget.dispose(); for (const documentItem of documentItems) { documentItem.dispose(); } } }); }); class PendingDiffProviderFactoryService implements IDiffProviderFactoryService { declare readonly _serviceBrand: undefined; createDiffProvider(): IDocumentDiffProvider { return new class extends mock() { override readonly onDidChange = Event.None; override computeDiff(): Promise { return new Promise(() => { }); } }(); } }