microsoft/vscode · #334043
Show binary files in multi-diff editors
src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts66 + / 4 −
@@ -8,13 +8,15 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { BugIndicatingError } from '../../../../base/common/errors.js'; import { DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { autorun, derived, globalTransaction, IObservable, observableValue } from '../../../../base/common/observable.js';+import { localize } from '../../../../nls.js'; import { createActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; import { IContextKeyService, type IScopedContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { EditorContextKeys } from '../../../common/editorContextKeys.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js';+import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { IDiffEditorOptions } from '../../../common/config/editorOptions.js'; import { OffsetRange } from '../../../common/core/ranges/offsetRange.js'; import { observableCodeEditor } from '../../observableCodeEditor.js';@@ -24,6 +26,8 @@ import { ActionRunnerWithContext } from './utils.js'; import { IVirtualizedItemBindingContext, VirtualizedItemBinding, VirtualizedItemTemplate } from './virtualizedItemManager.js'; import { IWorkbenchUIElementFactory, MultiDiffEditorItemLabelKind } from './workbenchUIElementFactory.js'; +export const binaryFilePlaceholderContentHeight = 100;+ export class DiffEditorItemTemplate extends VirtualizedItemTemplate<DocumentDiffItemViewModel, DiffEditorItemBinding> { private readonly _viewModel; @@ -46,11 +50,13 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate<DocumentDiff private readonly isModifedFocused; private readonly isOriginalFocused;+ private readonly isBinaryFilePlaceholderFocused; public readonly isFocused; private readonly _resourceLabel; private readonly _resourceLabel2;+ private readonly _openBinaryDiffButton: Button | undefined; private readonly _verticalStateUpdate = this._register(new MutableDisposable()); private _observedEditorContentHeight = 500; private _isSettingData = false;@@ -91,6 +97,7 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate<DocumentDiff return { maxScroll: scroll2, width: this._originalWidth.read(reader) }; } });+ const binaryFileChangedLabel = localize('binaryFileChanged', "Binary file changed"); this._elements = h('div.multiDiffEntry', [ h('div.header@header', [ h('div.header-content', [@@ -108,6 +115,12 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate<DocumentDiff h('div.editorParent', [ h('div.editorContainer@editor'),+ h('div.binary-file-placeholder@binaryFilePlaceholder', { role: 'group', 'aria-label': binaryFileChangedLabel }, [+ h('div.binary-file-placeholder-content', [+ h('span', [binaryFileChangedLabel]),+ h('div.binary-file-placeholder-actions@binaryFilePlaceholderActions'),+ ]),+ ]), ]) ]) as Record<string, HTMLElement>; this.editor = this._register(this._instantiationService.createInstance(DiffEditorWidget, this._elements.editor, {@@ -125,7 +138,28 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate<DocumentDiff })); this.isModifedFocused = observableCodeEditor(this.editor.getModifiedEditor()).isFocused; this.isOriginalFocused = observableCodeEditor(this.editor.getOriginalEditor()).isFocused;- this.isFocused = derived(this, reader => this.isModifedFocused.read(reader) || this.isOriginalFocused.read(reader));+ this.isBinaryFilePlaceholderFocused = observableValue(this, false);+ const binaryFilePlaceholderFocus = this._register(trackFocus(this._elements.binaryFilePlaceholder));+ this._register(binaryFilePlaceholderFocus.onDidFocus(() => this.isBinaryFilePlaceholderFocused.set(true, undefined)));+ this._register(binaryFilePlaceholderFocus.onDidBlur(() => this.isBinaryFilePlaceholderFocused.set(false, undefined)));+ this.isFocused = derived(this, reader =>+ this.isModifedFocused.read(reader)+ || this.isOriginalFocused.read(reader)+ || this.isBinaryFilePlaceholderFocused.read(reader)+ );+ this._elements.binaryFilePlaceholder.tabIndex = 0;+ if (this._workbenchUIElementFactory.openDiffEditor) {+ this._openBinaryDiffButton = this._register(new Button(this._elements.binaryFilePlaceholderActions, { ...defaultButtonStyles, secondary: true }));+ this._openBinaryDiffButton.label = localize('openBinaryDiff', "Open Diff");+ this._register(this._openBinaryDiffButton.onDidClick(() => {+ const item = this._viewModel.get();+ if (item?.originalUri && item.modifiedUri) {+ this._workbenchUIElementFactory.openDiffEditor?.(item.originalUri, item.modifiedUri);+ }+ }));+ } else {+ this._openBinaryDiffButton = undefined;+ } this._resourceLabel = this._workbenchUIElementFactory.createResourceLabel ? this._register(this._workbenchUIElementFactory.createResourceLabel(this._elements.primaryPath, MultiDiffEditorItemLabelKind.Primary)) : undefined;@@ -202,7 +236,13 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate<DocumentDiff this._register(autorun(reader => { const collapsed = this._collapsed.read(reader);- this._elements.editor.style.display = collapsed ? 'none' : 'block';+ const item = this._viewModel.read(reader);+ const isBinary = item?.isBinary === true;+ const canOpenDiff = !!(item?.originalUri && item.modifiedUri && this._openBinaryDiffButton);+ this._elements.editor.style.display = collapsed || isBinary ? 'none' : 'block';+ this._elements.binaryFilePlaceholder.style.display = !collapsed && isBinary ? 'grid' : 'none';+ this._elements.binaryFilePlaceholder.tabIndex = canOpenDiff ? -1 : 0;+ this._elements.binaryFilePlaceholderActions.style.display = canOpenDiff ? '' : 'none'; if (this._workbenchUIElementFactory.headerClickToCollapse) { this._elements.header.setAttribute('aria-expanded', String(!collapsed)); }@@ -224,7 +264,7 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate<DocumentDiff this._originalContentWidth.set(this.editor.getOriginalEditor().getContentWidth(), tx); }); const viewModel = this._viewModel.get();- if (this._isSettingData || !viewModel?.diffEditorViewModel.isDiffUpToDate.get()) {+ if (this._isSettingData || viewModel?.isBinary || !viewModel?.diffEditorViewModel.isDiffUpToDate.get()) { return; } this._observedEditorContentHeight = e.contentHeight;@@ -356,7 +396,9 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate<DocumentDiff } const value = item.documentDiffItem;- const editorContentHeight = Math.max(0, Math.max(initialSize, item.lastTemplateData.get().expandedContentHeight) - this._outerEditorHeight);+ const editorContentHeight = item.isBinary+ ? binaryFilePlaceholderContentHeight+ : Math.max(0, Math.max(initialSize, item.lastTemplateData.get().expandedContentHeight) - this._outerEditorHeight); this._observedEditorContentHeight = editorContentHeight; this._isSettingData = true; try {@@ -394,6 +436,9 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate<DocumentDiff this._isSettingData = false; } this._dataStore.add(autorun(reader => {+ if (item.isBinary) {+ return;+ } const viewModel = item.diffEditorViewModel; if (!viewModel.isDiffUpToDate.read(reader)) { return;@@ -470,6 +515,15 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate<DocumentDiff this._elements.root.style.visibility = 'hidden'; // Some editor parts are still visible } + public focusBinaryFilePlaceholder(): void {+ const item = this._viewModel.get();+ if (item?.originalUri && item.modifiedUri && this._openBinaryDiffButton) {+ this._openBinaryDiffButton.focus();+ } else {+ this._elements.binaryFilePlaceholder.focus();+ }+ }+ public unbind(item: DocumentDiffItemViewModel): void { if (this._viewModel.get() !== item) { throw new BugIndicatingError('Cannot unbind a diff editor template from a different item');@@ -512,6 +566,14 @@ export class DiffEditorItemBinding extends VirtualizedItemBinding<DocumentDiffIt return this._template.getExpandedContentHeight(); } + focus(): void {+ if (this.item.isBinary) {+ this._template.focusBinaryFilePlaceholder();+ } else {+ this.editor.focus();+ }+ }+ override dispose(): void { if (this._store.isDisposed) { return;src/vs/editor/browser/widget/multiDiffEditor/model.ts13 + / 2 −
@@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Event, IValueWithChangeEvent } from '../../../../base/common/event.js';+import { URI } from '../../../../base/common/uri.js'; import { RefCounted } from '../diffEditor/utils.js'; import { IDiffEditorOptions } from '../../../common/config/editorOptions.js'; import { ITextModel } from '../../../common/model.js';@@ -14,16 +15,26 @@ export interface IMultiDiffEditorModel { readonly contextKeys?: Record<string, ContextKeyValue>; } +/**+ * A resource participating on one side of a document diff.+ */+export class DiffItemSource {+ constructor(+ public readonly uri: URI,+ public readonly textModel: ITextModel | undefined,+ ) { }+}+ export interface IDocumentDiffItem { /** * undefined if the file was created. */- readonly original: ITextModel | undefined;+ readonly original: DiffItemSource | undefined; /** * undefined if the file was deleted. */- readonly modified: ITextModel | undefined;+ readonly modified: DiffItemSource | undefined; readonly options?: IDiffEditorOptions; readonly onOptionsDidChange?: Event<void>; readonly contextKeys?: Record<string, ContextKeyValue>;src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts7 + / 2 −
@@ -135,6 +135,11 @@ export class DocumentDiffItemViewModel extends Disposable { public get originalUri(): URI | undefined { return this.documentDiffItem.original?.uri; } public get modifiedUri(): URI | undefined { return this.documentDiffItem.modified?.uri; }+ public get isBinary(): boolean {+ const { original, modified } = this.documentDiffItem;+ return (original !== undefined && original.textModel === undefined)+ || (modified !== undefined && modified.textModel === undefined);+ } public readonly isActive: IObservable<boolean> = derived(this, reader => this._editorViewModel.activeDiffItem.read(reader) === this); public readonly isFirst: IObservable<boolean> = derived(this, reader => this._editorViewModel.items.read(reader)[0] === this);@@ -188,8 +193,8 @@ export class DocumentDiffItemViewModel extends Disposable { } const diffEditorViewModelStore = new DisposableStore();- const originalTextModel = this.documentDiffItem.original ?? diffEditorViewModelStore.add(this._modelService.createModel('', null));- const modifiedTextModel = this.documentDiffItem.modified ?? diffEditorViewModelStore.add(this._modelService.createModel('', null));+ const originalTextModel = this.documentDiffItem.original?.textModel ?? diffEditorViewModelStore.add(this._modelService.createModel('', null));+ const modifiedTextModel = this.documentDiffItem.modified?.textModel ?? diffEditorViewModelStore.add(this._modelService.createModel('', null)); diffEditorViewModelStore.add(this._documentDiffItemRef.createNewRef(this)); this.diffEditorViewModelRef = this._register(RefCounted.createWithDisposable(src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts4 + / 0 −
@@ -127,6 +127,10 @@ export class MultiDiffEditorWidget extends Disposable { public readonly onDidChangeActiveControl = Event.fromObservableLight(this._activeControl); + public focus(): boolean {+ return this._widgetImpl.get().focus();+ }+ public getViewState(): IMultiDiffEditorViewState { return this._widgetImpl.get().getViewState(); }src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts32 + / 13 −
@@ -23,7 +23,7 @@ import { EditorContextKeys } from '../../../common/editorContextKeys.js'; import { ICodeEditor } from '../../editorBrowser.js'; import { CompressedVirtualizedScrollView, ICompressedVirtualizedScrollItem, ICompressedVirtualizedScrollItemContext } from './compressedVirtualizedScrollView.js'; import { ICompressedVirtualizedScrollLayout } from './compressedVirtualizedScrollLayout.js';-import { DiffEditorItemBinding, DiffEditorItemTemplate } from './diffEditorItemTemplate.js';+import { binaryFilePlaceholderContentHeight, DiffEditorItemBinding, DiffEditorItemTemplate } from './diffEditorItemTemplate.js'; import { IDocumentDiffItem } from './model.js'; import { formatDiffItemKey, formatUri, ILoggedDiffItem, MultiDiffEditorLogger } from './multiDiffEditorLogging.js'; import { DocumentDiffItemViewModel, MultiDiffEditorViewModel } from './multiDiffEditorViewModel.js';@@ -100,9 +100,18 @@ export class MultiDiffEditorWidgetImpl extends Disposable { const manager = this._register(new VirtualizedItemManager<DocumentDiffItemViewModel, DiffEditorItemBinding, DiffEditorItemTemplate>(sourceItems, context, { getId: item => item, getTemplateId: () => 'diffEditor',- getUnboundSize: item => derived(item, reader => item.collapsed.read(reader)- ? this._workbenchUIElementFactory.diffEditorItemHeaderHeight ?? 40- : item.lastTemplateData.read(reader).expandedContentHeight),+ getUnboundSize: item => derived(item, reader => {+ const headerHeight = this._workbenchUIElementFactory.diffEditorItemHeaderHeight ?? 40;+ if (item.collapsed.read(reader)) {+ return headerHeight;+ }+ if (item.isBinary) {+ return headerHeight+ + (this._workbenchUIElementFactory.diffEditorItemContentBottomPadding ?? 0)+ + binaryFilePlaceholderContentHeight;+ }+ return item.lastTemplateData.read(reader).expandedContentHeight;+ }), createTemplate: () => this._instantiationService.createInstance( DiffEditorItemTemplate, context.contentDomNode,@@ -516,30 +525,40 @@ export class MultiDiffEditorWidgetImpl extends Disposable { viewModel.activeDiffItem.setCache(target, undefined); if (!this._preserveFocusOnLoad) {- this._viewItemsInfo.get().getItem(target).template.get()?.editor.focus();+ this._viewItemsInfo.get().getItem(target).binding.get()?.focus(); } return true; } public findDocumentDiffItem(resource: URI): IDocumentDiffItem | undefined { const item = this._viewItems.get().find(v =>- v.viewModel.diffEditorViewModel.model.modified.uri.toString() === resource.toString()- || v.viewModel.diffEditorViewModel.model.original.uri.toString() === resource.toString()+ v.viewModel.modifiedUri?.toString() === resource.toString()+ || v.viewModel.originalUri?.toString() === resource.toString() ); return item?.viewModel.documentDiffItem; } + public focus(): boolean {+ const activeDiffItem = this._viewModel.get()?.activeDiffItem.get();+ if (!activeDiffItem) {+ return false;+ }+ const binding = this._viewItemsInfo.get().getItem(activeDiffItem).binding.get();+ binding?.focus();+ return binding !== undefined;+ }+ public tryGetCodeEditor(resource: URI): { diffEditor: IDiffEditor; editor: ICodeEditor } | undefined { const item = this._viewItems.get().find(v =>- v.viewModel.diffEditorViewModel.model.modified.uri.toString() === resource.toString()- || v.viewModel.diffEditorViewModel.model.original.uri.toString() === resource.toString()+ v.viewModel.modifiedUri?.toString() === resource.toString()+ || v.viewModel.originalUri?.toString() === resource.toString() ); const editor = item?.template.get()?.editor;- if (!editor) {+ if (!editor || item.viewModel.isBinary) { return undefined; } - if (item.viewModel.diffEditorViewModel.model.modified.uri.toString() === resource.toString()) {+ if (item.viewModel.modifiedUri?.toString() === resource.toString()) { return { diffEditor: editor, editor: editor.getModifiedEditor() }; } else { return { diffEditor: editor, editor: editor.getOriginalEditor() };@@ -617,7 +636,7 @@ export class MultiDiffEditorWidgetImpl extends Disposable { } } if (focusEditor) {- editor?.focus();+ item.binding.get()?.focus(); } } @@ -786,7 +805,7 @@ class VirtualizedViewItem extends Disposable implements ILoggedDiffItem, ICompre } public override toString(): string {- return `VirtualViewItem(${this.viewModel.documentDiffItem.modified?.uri.toString()})`;+ return `VirtualViewItem(${this.viewModel.modifiedUri?.toString() ?? this.viewModel.originalUri?.toString()})`; } public getKey(): string {src/vs/editor/browser/widget/multiDiffEditor/style.css19 + / 0 −
@@ -154,5 +154,24 @@ .editorContainer { flex: 1; }++ .binary-file-placeholder {+ display: none;+ flex: 1;+ place-items: center;+ color: var(--vscode-descriptionForeground);++ &:focus-visible {+ outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder);+ outline-offset: calc(-1 * var(--vscode-strokeThickness));+ }++ .binary-file-placeholder-content {+ display: flex;+ flex-direction: column;+ align-items: center;+ gap: var(--vscode-spacing-size80);+ }+ } } }src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts3 + / 0 −
@@ -46,6 +46,9 @@ export interface IWorkbenchUIElementFactory { /** Handles a middle-click on an entry header. Returns whether the event was handled. */ handleHeaderMiddleClick?(resource: URI): boolean; + /** Opens an entry in a standalone diff editor. */+ openDiffEditor?(original: URI, modified: URI): void;+ /** * Optional override for how individual actions render in the per-file header * toolbar (`MenuId.MultiDiffEditorFileToolbar`). Return `undefined` to fallsrc/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts98 + / 6 −
@@ -6,6 +6,7 @@ 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';@@ -21,7 +22,7 @@ import { InMemoryStorageService, IStorageService } from '../../../../platform/st 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 { IDocumentDiffItem, IMultiDiffEditorModel } from '../../../browser/widget/multiDiffEditor/model.js';+import { DiffItemSource, IDocumentDiffItem, IMultiDiffEditorModel } from '../../../browser/widget/multiDiffEditor/model.js'; import { MultiDiffEditorWidget } from '../../../browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; import { IWorkbenchUIElementFactory } from '../../../browser/widget/multiDiffEditor/workbenchUIElementFactory.js'; import { EditorOption } from '../../../common/config/editorOptions.js';@@ -82,6 +83,97 @@ suite('MultiDiffEditorWidget', () => { } }); + test('renders binary files as a placeholder', async () => {+ const services = new ServiceCollection();+ services.set(IAccessibilitySignalService, new class extends mock<IAccessibilitySignalService>() { }());+ services.set(IActionViewItemService, new NullActionViewItemService());+ services.set(IEditorProgressService, new class extends mock<IEditorProgressService>() { }());+ services.set(IDiffProviderFactoryService, new TestDiffProviderFactoryService());+ services.set(IStorageService, disposables.add(new InMemoryStorageService()));+ services.set(IMenuService, new class extends mock<IMenuService>() {+ override createMenu(): IMenu {+ return new class extends mock<IMenu>() {+ 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<IDocumentDiffItem>({+ 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,+ undefined,+ );+ 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<HTMLElement>('.binary-file-placeholder');+ const editor = widget.getRootElement().querySelector<HTMLElement>('.editorContainer');+ const openDiffButton = placeholder?.querySelector<HTMLElement>('.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<IAccessibilitySignalService>() { }());@@ -105,8 +197,8 @@ suite('MultiDiffEditorWidget', () => { 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<IDocumentDiffItem>({- original,- modified,+ original: new DiffItemSource(originalUri, original),+ modified: new DiffItemSource(modifiedUri, modified), options: { accessibilitySupport: 'off' }, }, { dispose() { } }); const model: IMultiDiffEditorModel = {@@ -181,7 +273,7 @@ suite('MultiDiffEditorWidget', () => { 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<IDocumentDiffItem>({- original,+ original: new DiffItemSource(originalUri, original), modified: undefined, options: { accessibilitySupport: 'off' }, }, { dispose() { } });@@ -259,8 +351,8 @@ suite('MultiDiffEditorWidget', () => { 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<IDocumentDiffItem>({- original,- modified,+ original: new DiffItemSource(originalUri, original),+ modified: new DiffItemSource(modifiedUri, modified), options: { accessibilitySupport: 'off' }, }, { dispose() { } })); originalUris.push(originalUri);src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts11 + / 6 −
@@ -83,6 +83,7 @@ class SessionChangesUIElementFactory implements IWorkbenchUIElementFactory { @ICommandService private readonly commandService: ICommandService, @IChangesViewService private readonly changesViewService: IChangesViewService, @IInstantiationService private readonly instantiationService: IInstantiationService,+ @IEditorService private readonly editorService: IEditorService, ) { } createResourceLabel(element: HTMLElement, kind: MultiDiffEditorItemLabelKind): IResourceLabel {@@ -111,6 +112,14 @@ class SessionChangesUIElementFactory implements IWorkbenchUIElementFactory { } return undefined; }++ openDiffEditor(original: URI, modified: URI): void {+ void this.editorService.openEditor({+ original: { resource: original },+ modified: { resource: modified },+ options: { pinned: true },+ });+ } } class SessionChangesResourceLabel extends Disposable implements IResourceLabel {@@ -436,20 +445,16 @@ export class SessionChangesEditor extends AbstractEditorWithViewState<IMultiDiff return; } - const control = widget.getActiveControl();- if (control) {- control.focus();+ if (widget.focus()) { return; } // The active file's diff editor may not be rendered yet (e.g. the editor // part was just revealed from a hidden state), so getActiveControl() is // undefined. Focus it as soon as it becomes available. this._pendingFocus.value = widget.onDidChangeActiveControl(() => {- const activeControl = widget.getActiveControl();- if (activeControl) {+ if (widget.focus()) { this._pendingFocus.clear();- activeControl.focus(); } }); }src/vs/sessions/contrib/changes/test/browser/agentsDiffEditor.fixture.ts9 + / 3 −
@@ -17,7 +17,7 @@ import { mock } from '../../../../../base/test/common/mock.js'; import { MultiDiffEditorWidget } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; import { IDiffProviderFactoryService } from '../../../../../editor/browser/widget/diffEditor/diffProviderFactoryService.js'; import { RefCounted } from '../../../../../editor/browser/widget/diffEditor/utils.js';-import { IDocumentDiffItem } from '../../../../../editor/browser/widget/multiDiffEditor/model.js';+import { DiffItemSource, IDocumentDiffItem } from '../../../../../editor/browser/widget/multiDiffEditor/model.js'; import { IResourceLabel, IWorkbenchUIElementFactory } from '../../../../../editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.js'; import { TestDiffProviderFactoryService } from '../../../../../editor/test/browser/diff/testDiffProviderFactoryService.js'; import { IMenu, IMenuActionOptions, IMenuService, MenuId, MenuItemAction } from '../../../../../platform/actions/common/actions.js';@@ -260,8 +260,14 @@ async function renderAgentsDiffEditor({ container, disposableStore, disposableSt const secondOriginal = textModels.add(createTextModel(instantiationService, 'export function count() {\n\treturn 1;\n}', URI.file('/workspace/src/second.original.ts'), 'typescript')); const secondModified = textModels.add(createTextModel(instantiationService, 'export function count() {\n\treturn 2;\n}', URI.file('/workspace/src/second.ts'), 'typescript')); - const first = RefCounted.createOfNonDisposable<IDocumentDiffItem>({ original: firstOriginal, modified: firstModified }, { dispose() { } });- const second = RefCounted.createOfNonDisposable<IDocumentDiffItem>({ original: secondOriginal, modified: secondModified }, { dispose() { } });+ const first = RefCounted.createOfNonDisposable<IDocumentDiffItem>({+ original: new DiffItemSource(firstOriginal.uri, firstOriginal),+ modified: new DiffItemSource(firstModified.uri, firstModified),+ }, { dispose() { } });+ const second = RefCounted.createOfNonDisposable<IDocumentDiffItem>({+ original: new DiffItemSource(secondOriginal.uri, secondOriginal),+ modified: new DiffItemSource(secondModified.uri, secondModified),+ }, { dispose() { } }); const widget = disposableStackStore.add(instantiationService.createInstance( MultiDiffEditorWidget, editorInstance,src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.ts10 + / 1 −
@@ -150,7 +150,7 @@ export class MultiDiffEditor extends AbstractEditorWithViewState<IMultiDiffEdito override focus(): void { super.focus(); - this._multiDiffEditorWidget?.getActiveControl()?.focus();+ this._multiDiffEditorWidget?.focus(); } override hasFocus(): boolean {@@ -248,6 +248,7 @@ class WorkbenchUIElementFactory implements IWorkbenchUIElementFactory { constructor( @IInstantiationService private readonly _instantiationService: IInstantiationService, @IContextKeyService contextKeyService: IContextKeyService,+ @IEditorService private readonly editorService: IEditorService, ) { this.headerClickToCollapse = IsSessionsWindowContext.getValue(contextKeyService) === true; }@@ -267,4 +268,12 @@ class WorkbenchUIElementFactory implements IWorkbenchUIElementFactory { } }; }++ openDiffEditor(original: URI, modified: URI): void {+ void this.editorService.openEditor({+ original: { resource: original },+ modified: { resource: modified },+ options: { pinned: true },+ });+ } }src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.ts22 + / 12 −
@@ -16,7 +16,7 @@ import { ThemeIcon } from '../../../../base/common/themables.js'; import { isDefined, isObject } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; import { RefCounted } from '../../../../editor/browser/widget/diffEditor/utils.js';-import { IDocumentDiffItem, IMultiDiffEditorModel } from '../../../../editor/browser/widget/multiDiffEditor/model.js';+import { DiffItemSource, IDocumentDiffItem, IMultiDiffEditorModel } from '../../../../editor/browser/widget/multiDiffEditor/model.js'; import { MultiDiffEditorViewModel } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.js'; import { IDiffEditorOptions } from '../../../../editor/common/config/editorOptions.js'; import { IResolvedTextEditorModel, ITextModelService } from '../../../../editor/common/services/resolverService.js';@@ -28,10 +28,15 @@ import { IEditorConfiguration } from '../../../browser/parts/editor/textEditor.j import { DEFAULT_EDITOR_ASSOCIATION, EditorInputCapabilities, EditorInputWithOptions, GroupIdentifier, IEditorSerializer, IResourceMultiDiffEditorInput, IRevertOptions, ISaveOptions, IUntypedEditorInput } from '../../../common/editor.js'; import { EditorInput, IEditorCloseHandler } from '../../../common/editor/editorInput.js'; import { IEditorResolverService, RegisteredEditorPriority } from '../../../services/editor/common/editorResolverService.js';-import { ILanguageSupport, ITextFileEditorModel, ITextFileService } from '../../../services/textfile/common/textfiles.js';+import { ILanguageSupport, ITextFileEditorModel, ITextFileService, TextFileOperationError, TextFileOperationResult } from '../../../services/textfile/common/textfiles.js'; import { MultiDiffEditorIcon } from './icons.contribution.js'; import { IMultiDiffSourceResolverService, IResolvedMultiDiffSource, MultiDiffEditorItem } from './multiDiffSourceResolverService.js'; +function isBinaryTextFileOperationError(error: unknown): error is TextFileOperationError {+ return TextFileOperationError.isTextFileOperationError(error)+ && error.textFileOperationResult === TextFileOperationResult.FILE_IS_BINARY;+}+ export class MultiDiffEditorInput extends EditorInput implements ILanguageSupport { public static fromResourceMultiDiffEditorInput(input: IResourceMultiDiffEditorInput, instantiationService: IInstantiationService): MultiDiffEditorInput { if (!input.multiDiffSource && !input.resources) {@@ -184,7 +189,7 @@ export class MultiDiffEditorInput extends EditorInput implements ILanguageSuppor const activeDiffItem = this._viewModel.requireValue().activeDiffItem.get(); const value = activeDiffItem?.documentDiffItem; if (!value) { return; }- const target = value.modified ?? value.original;+ const target = (value.modified ?? value.original)?.textModel; if (!target) { return; } target.setLanguage(languageId, source); }@@ -226,25 +231,27 @@ export class MultiDiffEditorInput extends EditorInput implements ILanguageSuppor return undefined; } - let errorResult: PromiseRejectedResult | undefined;- if (originalResult.status === 'rejected') {- errorResult = originalResult;- } else if (modifiedResult.status === 'rejected') {- errorResult = modifiedResult;- }+ const errorResults = [originalResult, modifiedResult].filter((result): result is PromiseRejectedResult => result.status === 'rejected');+ const errorResult = errorResults.find(result => !isBinaryTextFileOperationError(result.reason)); if (errorResult) { multiDiffItemStore.dispose();- // e.g. "File seems to be binary and cannot be opened as text" console.error(errorResult.reason); onUnexpectedError(errorResult.reason); return undefined; } + const isBinary = errorResults.length > 0;+ if (isBinary) {+ multiDiffItemStore.clear();+ original = undefined;+ modified = undefined;+ }+ const uri = (r.modifiedUri ?? r.originalUri)!; const result: IDocumentDiffItemWithMultiDiffEditorItem = { multiDiffEditorItem: r,- original: original?.object.textEditorModel,- modified: modified?.object.textEditorModel,+ original: r.originalUri ? new DiffItemSource(r.originalUri, original?.object.textEditorModel) : undefined,+ modified: r.modifiedUri ? new DiffItemSource(r.modifiedUri, modified?.object.textEditorModel) : undefined, contextKeys: r.contextKeys, get options() { return {@@ -321,6 +328,9 @@ export class MultiDiffEditorInput extends EditorInput implements ILanguageSuppor const items = this._viewModel.currentValue?.items.get(); if (items) { await Promise.all(items.map(async item => {+ if (item.isBinary) {+ return;+ } const model = item.diffEditorViewModel.model; const handleOriginal = model.original.uri.scheme !== Schemas.untitled && this._textFileService.isDirty(model.original.uri); // match diff editor behaviour src/vs/workbench/contrib/multiDiffEditor/test/browser/multiDiffEditorInput.test.ts69 + / 1 −
@@ -12,10 +12,14 @@ import { observableValue, ValueWithChangeEventFromObservable } from '../../../.. import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';+import { IDiffProviderFactoryService } from '../../../../../editor/browser/widget/diffEditor/diffProviderFactoryService.js'; import { IResolvedTextEditorModel, ITextModelService } from '../../../../../editor/common/services/resolverService.js'; import { ITextResourceConfigurationService } from '../../../../../editor/common/services/textResourceConfiguration.js';+import { TestDiffProviderFactoryService } from '../../../../../editor/test/browser/diff/testDiffProviderFactoryService.js';+import { createCodeEditorServices } from '../../../../../editor/test/browser/testCodeEditor.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';-import { ITextFileEditorModelManager, ITextFileService } from '../../../../services/textfile/common/textfiles.js';+import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js';+import { ITextFileEditorModelManager, ITextFileService, TextFileOperationError, TextFileOperationResult } from '../../../../services/textfile/common/textfiles.js'; import { MultiDiffEditorInput } from '../../browser/multiDiffEditorInput.js'; import { IMultiDiffSourceResolverService, MultiDiffEditorItem } from '../../browser/multiDiffSourceResolverService.js'; @@ -99,4 +103,68 @@ suite('MultiDiffEditorInput', () => { await assert.rejects(viewModelPromise, CancellationError); assert.strictEqual(referenceDisposed, true); });++ test('keeps binary resources in the multi diff model', async () => {+ const originalUri = URI.parse('file:///original.png');+ const modifiedUri = URI.parse('file:///modified.png');+ const textModelService = new class extends mock<ITextModelService>() {+ override createModelReference(): Promise<IReference<IResolvedTextEditorModel>> {+ return Promise.reject(new TextFileOperationError('binary', TextFileOperationResult.FILE_IS_BINARY));+ }+ }();+ const textResourceConfigurationService = new class extends mock<ITextResourceConfigurationService>() {+ override readonly onDidChangeConfiguration = Event.None;+ override getValue<T>(): T { return {} as T; }+ }();+ let saveCallCount = 0;+ const textFileService = new class extends mock<ITextFileService>() {+ override readonly files = new class extends mock<ITextFileEditorModelManager>() {+ override readonly onDidChangeDirty = Event.None;+ }();+ override save(): Promise<undefined> {+ saveCallCount++;+ return Promise.resolve(undefined);+ }+ }();+ const services = new ServiceCollection();+ services.set(IDiffProviderFactoryService, new TestDiffProviderFactoryService());+ const instantiationService = createCodeEditorServices(disposables, services);+ const input = disposables.add(new MultiDiffEditorInput(+ URI.parse('multi-diff-editor:test'),+ 'Test',+ [new MultiDiffEditorItem(originalUri, modifiedUri, undefined)],+ false,+ textModelService,+ textResourceConfigurationService,+ instantiationService,+ new class extends mock<IMultiDiffSourceResolverService>() { }(),+ textFileService,+ ));++ const viewModel = await input.getViewModel();+ const item = viewModel.items.get()[0];+ await input.save(1);++ assert.deepStrictEqual({+ itemCount: viewModel.items.get().length,+ originalUri: item.originalUri?.toString(),+ modifiedUri: item.modifiedUri?.toString(),+ isBinary: item.isBinary,+ originalSourceUri: item.documentDiffItem.original?.uri.toString(),+ modifiedSourceUri: item.documentDiffItem.modified?.uri.toString(),+ hasOriginalTextModel: item.documentDiffItem.original?.textModel !== undefined,+ hasModifiedTextModel: item.documentDiffItem.modified?.textModel !== undefined,+ saveCallCount,+ }, {+ itemCount: 1,+ originalUri: originalUri.toString(),+ modifiedUri: modifiedUri.toString(),+ isBinary: true,+ originalSourceUri: originalUri.toString(),+ modifiedSourceUri: modifiedUri.toString(),+ hasOriginalTextModel: false,+ hasModifiedTextModel: false,+ saveCallCount: 0,+ });+ }); });src/vs/workbench/test/browser/componentFixtures/editor/multiDiffEditor.fixture.ts9 + / 3 −
@@ -9,7 +9,7 @@ import { DisposableStore, toDisposable } from '../../../../../base/common/lifecy import { URI } from '../../../../../base/common/uri.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { createTimeout, timeout } from '../../../../../base/common/async.js';-import { IDocumentDiffItem, IMultiDiffEditorModel } from '../../../../../editor/browser/widget/multiDiffEditor/model.js';+import { DiffItemSource, IDocumentDiffItem, IMultiDiffEditorModel } from '../../../../../editor/browser/widget/multiDiffEditor/model.js'; import { RefCounted } from '../../../../../editor/browser/widget/diffEditor/utils.js'; import { IDiffProviderFactoryService } from '../../../../../editor/browser/widget/diffEditor/diffProviderFactoryService.js'; import { TestDiffProviderFactoryService } from '../../../../../editor/test/browser/diff/testDiffProviderFactoryService.js';@@ -63,7 +63,10 @@ function renderMultiDiffEditorHideOriginalLineNumbers({ container, disposableSto const textModels = disposableStackStore.add(new DisposableStore()); const original = textModels.add(createTextModel(instantiationService, ORIGINAL_HIDDEN, URI.parse('inmemory://original/settings.ts'), 'typescript')); const modified = textModels.add(createTextModel(instantiationService, MODIFIED_HIDDEN, URI.parse('inmemory://modified/settings.ts'), 'typescript'));- const doc = RefCounted.createOfNonDisposable<IDocumentDiffItem>({ original, modified }, { dispose() { } });+ const doc = RefCounted.createOfNonDisposable<IDocumentDiffItem>({+ original: new DiffItemSource(original.uri, original),+ modified: new DiffItemSource(modified.uri, modified),+ }, { dispose() { } }); const widget = disposableStackStore.add(createMultiDiffEditorFixtureWidget(instantiationService, container, { hideOriginalLineNumbers: true,@@ -162,7 +165,10 @@ function renderMultiDiffEditorDocumentSwap() { const makeDoc = (origText: string, modText: string, name: string) => { const original = textModels.add(createTextModel(instantiationService, origText, URI.parse(`inmemory://original/${name}`), 'typescript')); const modified = textModels.add(createTextModel(instantiationService, modText, URI.parse(`inmemory://modified/${name}`), 'typescript'));- return RefCounted.createOfNonDisposable<IDocumentDiffItem>({ original, modified }, { dispose() { } });+ return RefCounted.createOfNonDisposable<IDocumentDiffItem>({+ original: new DiffItemSource(original.uri, original),+ modified: new DiffItemSource(modified.uri, modified),+ }, { dispose() { } }); }; // Each document has exactly one line change.src/vs/workbench/test/browser/componentFixtures/editor/multiDiffEditorFixtureUtils.ts13 + / 4 −
@@ -10,7 +10,7 @@ import { mock } from '../../../../../base/test/common/mock.js'; import { RefCounted } from '../../../../../editor/browser/widget/diffEditor/utils.js'; import { IDiffProviderFactoryService } from '../../../../../editor/browser/widget/diffEditor/diffProviderFactoryService.js'; import { MultiDiffEditorWidget } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js';-import { IDocumentDiffItem } from '../../../../../editor/browser/widget/multiDiffEditor/model.js';+import { DiffItemSource, IDocumentDiffItem } from '../../../../../editor/browser/widget/multiDiffEditor/model.js'; import { IResourceLabel as IMultiDiffResourceLabel, IWorkbenchUIElementFactory } from '../../../../../editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.js'; import { IDiffEditorOptions } from '../../../../../editor/common/config/editorOptions.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';@@ -140,8 +140,17 @@ export function createMultiDiffEditorFixtureDocuments(instantiationService: Test const original3 = textModels.add(createTextModel(instantiationService, originalCode3, URI.parse('inmemory://original/server.ts'), 'typescript')); const modified3 = textModels.add(createTextModel(instantiationService, modifiedCode3, URI.parse('inmemory://modified/server.ts'), 'typescript')); return {- doc1: RefCounted.createOfNonDisposable<IDocumentDiffItem>({ original: original1, modified: modified1 }, { dispose() { } }),- doc2: RefCounted.createOfNonDisposable<IDocumentDiffItem>({ original: original2, modified: modified2 }, { dispose() { } }),- doc3: RefCounted.createOfNonDisposable<IDocumentDiffItem>({ original: original3, modified: modified3 }, { dispose() { } }),+ doc1: RefCounted.createOfNonDisposable<IDocumentDiffItem>({+ original: new DiffItemSource(original1.uri, original1),+ modified: new DiffItemSource(modified1.uri, modified1),+ }, { dispose() { } }),+ doc2: RefCounted.createOfNonDisposable<IDocumentDiffItem>({+ original: new DiffItemSource(original2.uri, original2),+ modified: new DiffItemSource(modified2.uri, modified2),+ }, { dispose() { } }),+ doc3: RefCounted.createOfNonDisposable<IDocumentDiffItem>({+ original: new DiffItemSource(original3.uri, original3),+ modified: new DiffItemSource(modified3.uri, modified3),+ }, { dispose() { } }), }; }