diff --git a/extensions/vscode-api-tests/package.json b/extensions/vscode-api-tests/package.json index 16655a6202c..d3ab3f4d794 100644 --- a/extensions/vscode-api-tests/package.json +++ b/extensions/vscode-api-tests/package.json @@ -10,6 +10,7 @@ "customEditorMove", "diffCommand", "documentFiltersExclusive", + "documentPaste", "editorInsets", "extensionRuntime", "extensionsAny", diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/documentPaste.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/documentPaste.test.ts new file mode 100644 index 00000000000..d566a37645f --- /dev/null +++ b/extensions/vscode-api-tests/src/singlefolder-tests/documentPaste.test.ts @@ -0,0 +1,190 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { assertNoRpc, createRandomFile, usingDisposables } from '../utils'; + +const textPlain = 'text/plain'; + +(vscode.env.uiKind === vscode.UIKind.Web ? suite.skip : suite)('vscode API - Copy Paste', () => { + + teardown(assertNoRpc); + + test('Copy should be able to overwrite text/plain', usingDisposables(async (disposables) => { + const file = await createRandomFile('$abcde@'); + const doc = await vscode.workspace.openTextDocument(file); + + const editor = await vscode.window.showTextDocument(doc); + editor.selections = [new vscode.Selection(0, 1, 0, 6)]; + + disposables.push(vscode.languages.registerDocumentPasteEditProvider({ language: 'plaintext' }, new class implements vscode.DocumentPasteEditProvider { + async prepareDocumentPaste(_document: vscode.TextDocument, _ranges: readonly vscode.Range[], dataTransfer: vscode.DataTransfer, _token: vscode.CancellationToken): Promise { + const existing = dataTransfer.get(textPlain); + if (existing) { + const str = await existing.asString(); + const reversed = reverseString(str); + dataTransfer.set(textPlain, new vscode.DataTransferItem(reversed)); + } + } + }, { copyMimeTypes: [textPlain] })); + + await vscode.commands.executeCommand('editor.action.clipboardCopyAction'); + const newDocContent = getNextDocumentText(disposables, doc); + await vscode.commands.executeCommand('editor.action.clipboardPasteAction'); + assert.strictEqual(await newDocContent, '$edcba@'); + })); + + test('Copy with empty selection should copy entire line', usingDisposables(async (disposables) => { + const file = await createRandomFile('abc\ndef'); + const doc = await vscode.workspace.openTextDocument(file); + await vscode.window.showTextDocument(doc); + + disposables.push(vscode.languages.registerDocumentPasteEditProvider({ language: 'plaintext' }, new class implements vscode.DocumentPasteEditProvider { + async prepareDocumentPaste(_document: vscode.TextDocument, _ranges: readonly vscode.Range[], dataTransfer: vscode.DataTransfer, _token: vscode.CancellationToken): Promise { + const existing = dataTransfer.get(textPlain); + if (existing) { + const str = await existing.asString(); + // text/plain includes the trailing new line in this case + // On windows, this will always be `\r\n` even if the document uses `\n` + const eol = str.match(/\r?\n$/); + const reversed = reverseString(str.slice(0, -eol![0].length)); + dataTransfer.set(textPlain, new vscode.DataTransferItem(reversed + '\n')); + } + } + }, { copyMimeTypes: [textPlain] })); + + await vscode.commands.executeCommand('editor.action.clipboardCopyAction'); + const newDocContent = getNextDocumentText(disposables, doc); + await vscode.commands.executeCommand('editor.action.clipboardPasteAction'); + assert.strictEqual(await newDocContent, `cba\nabc\ndef`); + })); + + test('Copy with multiple selections should get all selections', usingDisposables(async (disposables) => { + const file = await createRandomFile('111\n222\n333'); + const doc = await vscode.workspace.openTextDocument(file); + const editor = await vscode.window.showTextDocument(doc); + + editor.selections = [ + new vscode.Selection(0, 0, 0, 3), + new vscode.Selection(2, 0, 2, 3), + ]; + + disposables.push(vscode.languages.registerDocumentPasteEditProvider({ language: 'plaintext' }, new class implements vscode.DocumentPasteEditProvider { + async prepareDocumentPaste(document: vscode.TextDocument, ranges: readonly vscode.Range[], dataTransfer: vscode.DataTransfer, _token: vscode.CancellationToken): Promise { + const existing = dataTransfer.get(textPlain); + if (existing) { + const selections = ranges.map(range => document.getText(range)); + dataTransfer.set(textPlain, new vscode.DataTransferItem(`(${ranges.length})${selections.join(' ')}`)); + } + } + }, { copyMimeTypes: [textPlain] })); + + await vscode.commands.executeCommand('editor.action.clipboardCopyAction'); + editor.selections = [new vscode.Selection(0, 0, 0, 0)]; + const newDocContent = getNextDocumentText(disposables, doc); + await vscode.commands.executeCommand('editor.action.clipboardPasteAction'); + + assert.strictEqual(await newDocContent, `(2)111 333111\n222\n333`); + })); + + test('Earlier invoked copy providers should win when writing values', usingDisposables(async (disposables) => { + const file = await createRandomFile('abc\ndef'); + const doc = await vscode.workspace.openTextDocument(file); + + const editor = await vscode.window.showTextDocument(doc); + editor.selections = [new vscode.Selection(0, 0, 0, 3)]; + + const callOrder: string[] = []; + const a_id = 'a'; + const b_id = 'b'; + + let providerAResolve: () => void; + const providerAFinished = new Promise(resolve => providerAResolve = resolve); + + disposables.push(vscode.languages.registerDocumentPasteEditProvider({ language: 'plaintext' }, new class implements vscode.DocumentPasteEditProvider { + async prepareDocumentPaste(_document: vscode.TextDocument, _ranges: readonly vscode.Range[], dataTransfer: vscode.DataTransfer, _token: vscode.CancellationToken): Promise { + callOrder.push(a_id); + dataTransfer.set(textPlain, new vscode.DataTransferItem('a')); + providerAResolve(); + } + }, { copyMimeTypes: [textPlain] })); + + // Later registered providers will be called first + disposables.push(vscode.languages.registerDocumentPasteEditProvider({ language: 'plaintext' }, new class implements vscode.DocumentPasteEditProvider { + async prepareDocumentPaste(_document: vscode.TextDocument, _ranges: readonly vscode.Range[], dataTransfer: vscode.DataTransfer, _token: vscode.CancellationToken): Promise { + callOrder.push(b_id); + + // Wait for the first provider to finish even though we were called first. + // This tests that resulting order does not depend on the order the providers + // return in. + await providerAFinished; + + dataTransfer.set(textPlain, new vscode.DataTransferItem('b')); + } + }, { copyMimeTypes: [textPlain] })); + + await vscode.commands.executeCommand('editor.action.clipboardCopyAction'); + const newDocContent = getNextDocumentText(disposables, doc); + await vscode.commands.executeCommand('editor.action.clipboardPasteAction'); + assert.strictEqual(await newDocContent, 'b\ndef'); + + // Confirm provider call order is what we expected + assert.deepStrictEqual(callOrder, [b_id, a_id]); + })); + + test('Copy providers should not be able to effect the data transfer of another', usingDisposables(async (disposables) => { + const file = await createRandomFile('abc\ndef'); + const doc = await vscode.workspace.openTextDocument(file); + + const editor = await vscode.window.showTextDocument(doc); + editor.selections = [new vscode.Selection(0, 0, 0, 3)]; + + + let providerAResolve: () => void; + const providerAFinished = new Promise(resolve => providerAResolve = resolve); + + disposables.push(vscode.languages.registerDocumentPasteEditProvider({ language: 'plaintext' }, new class implements vscode.DocumentPasteEditProvider { + async prepareDocumentPaste(_document: vscode.TextDocument, _ranges: readonly vscode.Range[], dataTransfer: vscode.DataTransfer, _token: vscode.CancellationToken): Promise { + dataTransfer.set(textPlain, new vscode.DataTransferItem('xyz')); + providerAResolve(); + } + }, { copyMimeTypes: [textPlain] })); + + disposables.push(vscode.languages.registerDocumentPasteEditProvider({ language: 'plaintext' }, new class implements vscode.DocumentPasteEditProvider { + async prepareDocumentPaste(_document: vscode.TextDocument, _ranges: readonly vscode.Range[], dataTransfer: vscode.DataTransfer, _token: vscode.CancellationToken): Promise { + + // Wait for the first provider to finish + await providerAFinished; + + // We we access the data transfer here, we should not see changes made by the first provider + const entry = dataTransfer.get(textPlain); + const str = await entry!.asString(); + dataTransfer.set(textPlain, new vscode.DataTransferItem(reverseString(str))); + } + }, { copyMimeTypes: [textPlain] })); + + await vscode.commands.executeCommand('editor.action.clipboardCopyAction'); + const newDocContent = getNextDocumentText(disposables, doc); + await vscode.commands.executeCommand('editor.action.clipboardPasteAction'); + assert.strictEqual(await newDocContent, 'cba\ndef'); + + })); +}); + +function reverseString(str: string) { + return str.split("").reverse().join(""); +} + +function getNextDocumentText(disposables: vscode.Disposable[], doc: vscode.TextDocument): Promise { + return new Promise(resolve => { + disposables.push(vscode.workspace.onDidChangeTextDocument(e => { + if (e.document === doc) { + resolve(doc.getText()); + } + })); + }); +} + diff --git a/extensions/vscode-api-tests/src/utils.ts b/extensions/vscode-api-tests/src/utils.ts index 28cd422ce40..5c9e4a10b8a 100644 --- a/extensions/vscode-api-tests/src/utils.ts +++ b/extensions/vscode-api-tests/src/utils.ts @@ -62,6 +62,17 @@ export function disposeAll(disposables: vscode.Disposable[]) { vscode.Disposable.from(...disposables).dispose(); } +export function usingDisposables(fn: (this: Mocha.Context, store: vscode.Disposable[]) => Promise) { + return async function (this: Mocha.Context): Promise { + const disposables: vscode.Disposable[] = []; + try { + return await fn.call(this, disposables); + } finally { + disposeAll(disposables); + } + }; +} + export function delay(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); } diff --git a/src/vs/editor/common/languages.ts b/src/vs/editor/common/languages.ts index 88eb8d4707c..3057394cd82 100644 --- a/src/vs/editor/common/languages.ts +++ b/src/vs/editor/common/languages.ts @@ -799,11 +799,11 @@ export interface DocumentPasteEditProvider { readonly id?: string; readonly copyMimeTypes?: readonly string[]; - readonly pasteMimeTypes: readonly string[]; + readonly pasteMimeTypes?: readonly string[]; prepareDocumentPaste?(model: model.ITextModel, ranges: readonly IRange[], dataTransfer: IReadonlyVSDataTransfer, token: CancellationToken): Promise; - provideDocumentPasteEdits(model: model.ITextModel, ranges: readonly IRange[], dataTransfer: IReadonlyVSDataTransfer, token: CancellationToken): Promise; + provideDocumentPasteEdits?(model: model.ITextModel, ranges: readonly IRange[], dataTransfer: IReadonlyVSDataTransfer, token: CancellationToken): Promise; } /** diff --git a/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteController.ts b/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteController.ts index fd517c78fc7..2c049547401 100644 --- a/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteController.ts +++ b/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteController.ts @@ -133,7 +133,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi return; } - ranges = ranges.map(range => new Range(range.startLineNumber, 0, range.startLineNumber, model.getLineLength(range.startLineNumber))); + ranges = [new Range(ranges[0].startLineNumber, 1, ranges[0].startLineNumber, 1 + model.getLineLength(ranges[0].startLineNumber))]; } const toCopy = this._editor._getViewModel()?.getPlainTextToCopy(selections, enableEmptySelectionClipboard, platform.isWindows); @@ -219,7 +219,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi const allProviders = this._languageFeaturesService.documentPasteEditProvider .ordered(model) - .filter(provider => provider.pasteMimeTypes.some(type => matchesMimeType(type, allPotentialMimeTypes))); + .filter(provider => provider.pasteMimeTypes?.some(type => matchesMimeType(type, allPotentialMimeTypes))); if (!allProviders.length) { return; } @@ -253,7 +253,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi } // Filter out any providers the don't match the full data transfer we will send them. - const supportedProviders = allProviders.filter(provider => isSupportedProvider(provider, dataTransfer)); + const supportedProviders = allProviders.filter(provider => isSupportedPasteProvider(provider, dataTransfer)); if (!supportedProviders.length || (supportedProviders.length === 1 && supportedProviders[0].id === 'text') // Only our default text provider is active ) { @@ -300,7 +300,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi } // Filter out any providers the don't match the full data transfer we will send them. - const supportedProviders = allProviders.filter(provider => isSupportedProvider(provider, dataTransfer)); + const supportedProviders = allProviders.filter(provider => isSupportedPasteProvider(provider, dataTransfer)); const providerEdits = await this.getPasteEdits(supportedProviders, dataTransfer, model, selections, tokenSource.token); if (tokenSource.token.isCancellationRequested) { @@ -392,7 +392,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi private async getPasteEdits(providers: readonly DocumentPasteEditProvider[], dataTransfer: VSDataTransfer, model: ITextModel, selections: readonly Selection[], token: CancellationToken): Promise { const result = await raceCancellation( Promise.all( - providers.map(provider => provider.provideDocumentPasteEdits(model, selections, dataTransfer, token)) + providers.map(provider => provider.provideDocumentPasteEdits?.(model, selections, dataTransfer, token)) ).then(coalesce), token); result?.sort((a, b) => b.priority - a.priority); @@ -420,6 +420,6 @@ export class CopyPasteController extends Disposable implements IEditorContributi } } -function isSupportedProvider(provider: DocumentPasteEditProvider, dataTransfer: VSDataTransfer): boolean { - return provider.pasteMimeTypes.some(type => dataTransfer.matches(type)); +function isSupportedPasteProvider(provider: DocumentPasteEditProvider, dataTransfer: VSDataTransfer): boolean { + return Boolean(provider.pasteMimeTypes?.some(type => dataTransfer.matches(type))); } diff --git a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts index ec6ca6460dd..c540d14cc76 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts @@ -930,9 +930,10 @@ class MainThreadPasteEditProvider implements languages.DocumentPasteEditProvider private readonly dataTransfers = new DataTransferFileCache(); public readonly copyMimeTypes?: readonly string[]; - public readonly pasteMimeTypes: readonly string[]; + public readonly pasteMimeTypes?: readonly string[]; readonly prepareDocumentPaste?: languages.DocumentPasteEditProvider['prepareDocumentPaste']; + readonly provideDocumentPasteEdits?: languages.DocumentPasteEditProvider['provideDocumentPasteEdits']; constructor( private readonly handle: number, @@ -962,27 +963,29 @@ class MainThreadPasteEditProvider implements languages.DocumentPasteEditProvider return dataTransferOut; }; } - } - async provideDocumentPasteEdits(model: ITextModel, selections: Selection[], dataTransfer: IReadonlyVSDataTransfer, token: CancellationToken) { - const request = this.dataTransfers.add(dataTransfer); - try { - const dataTransferDto = await typeConvert.DataTransfer.from(dataTransfer); - if (token.isCancellationRequested) { - return; - } + if (metadata.supportsPaste) { + this.provideDocumentPasteEdits = async (model: ITextModel, selections: Selection[], dataTransfer: IReadonlyVSDataTransfer, token: CancellationToken) => { + const request = this.dataTransfers.add(dataTransfer); + try { + const dataTransferDto = await typeConvert.DataTransfer.from(dataTransfer); + if (token.isCancellationRequested) { + return; + } - const result = await this._proxy.$providePasteEdits(this.handle, request.id, model.uri, selections, dataTransferDto, token); - if (!result) { - return; - } + const result = await this._proxy.$providePasteEdits(this.handle, request.id, model.uri, selections, dataTransferDto, token); + if (!result) { + return; + } - return { - ...result, - additionalEdit: result.additionalEdit ? reviveWorkspaceEditDto(result.additionalEdit, this._uriIdentService, dataId => this.resolveFileData(request.id, dataId)) : undefined, + return { + ...result, + additionalEdit: result.additionalEdit ? reviveWorkspaceEditDto(result.additionalEdit, this._uriIdentService, dataId => this.resolveFileData(request.id, dataId)) : undefined, + }; + } finally { + request.dispose(); + } }; - } finally { - request.dispose(); } } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index e8ef98db5ae..f0221c117c3 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1830,8 +1830,9 @@ export type ITypeHierarchyItemDto = Dto; export interface IPasteEditProviderMetadataDto { readonly supportsCopy: boolean; + readonly supportsPaste: boolean; readonly copyMimeTypes?: readonly string[]; - readonly pasteMimeTypes: readonly string[]; + readonly pasteMimeTypes?: readonly string[]; } export interface IPasteEditDto { diff --git a/src/vs/workbench/api/common/extHostLanguageFeatures.ts b/src/vs/workbench/api/common/extHostLanguageFeatures.ts index ec19cf5d7c0..366c5bc2568 100644 --- a/src/vs/workbench/api/common/extHostLanguageFeatures.ts +++ b/src/vs/workbench/api/common/extHostLanguageFeatures.ts @@ -530,6 +530,10 @@ class DocumentPasteEditProvider { } async providePasteEdits(requestId: number, resource: URI, ranges: IRange[], dataTransferDto: extHostProtocol.DataTransferDTO, token: CancellationToken): Promise { + if (!this._provider.provideDocumentPasteEdits) { + return; + } + const doc = this._documents.getDocument(resource); const vscodeRanges = ranges.map(range => typeConvert.Range.to(range)); @@ -2420,6 +2424,7 @@ export class ExtHostLanguageFeatures implements extHostProtocol.ExtHostLanguageF this._adapter.set(handle, new AdapterData(new DocumentPasteEditProvider(this._proxy, this._documents, provider, handle, extension), extension)); this._proxy.$registerPasteEditProvider(handle, this._transformDocumentSelector(selector, extension), { supportsCopy: !!provider.prepareDocumentPaste, + supportsPaste: !!provider.provideDocumentPasteEdits, copyMimeTypes: metadata.copyMimeTypes, pasteMimeTypes: metadata.pasteMimeTypes, }); diff --git a/src/vscode-dts/vscode.proposed.documentPaste.d.ts b/src/vscode-dts/vscode.proposed.documentPaste.d.ts index fbebc1081dd..3ede35eacf5 100644 --- a/src/vscode-dts/vscode.proposed.documentPaste.d.ts +++ b/src/vscode-dts/vscode.proposed.documentPaste.d.ts @@ -37,7 +37,7 @@ declare module 'vscode' { * * @return Optional workspace edit that applies the paste. Return undefined to use standard pasting. */ - provideDocumentPasteEdits(document: TextDocument, ranges: readonly Range[], dataTransfer: DataTransfer, token: CancellationToken): ProviderResult; + provideDocumentPasteEdits?(document: TextDocument, ranges: readonly Range[], dataTransfer: DataTransfer, token: CancellationToken): ProviderResult; } /** @@ -98,7 +98,7 @@ declare module 'vscode' { * Note that {@link DataTransferFile} entries are only created when dropping content from outside the editor, such as * from the operating system. */ - readonly pasteMimeTypes: readonly string[]; + readonly pasteMimeTypes?: readonly string[]; } namespace languages {