diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 57ff4f689ed..19ce7e98f4b 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -16,7 +16,8 @@ "Programming Languages" ], "enabledApiProposals": [ - "documentPaste" + "documentPaste", + "dropMetadata" ], "activationEvents": [ "onLanguage:markdown", diff --git a/extensions/markdown-language-features/src/languageFeatures/copyFiles/copyPaste.ts b/extensions/markdown-language-features/src/languageFeatures/copyFiles/copyPaste.ts index 1babe16107d..888967aaeef 100644 --- a/extensions/markdown-language-features/src/languageFeatures/copyFiles/copyPaste.ts +++ b/extensions/markdown-language-features/src/languageFeatures/copyFiles/copyPaste.ts @@ -45,7 +45,7 @@ class PasteEditProvider implements vscode.DocumentPasteEditProvider { } const snippet = await tryGetUriListSnippet(document, dataTransfer, token); - return snippet ? new vscode.DocumentPasteEdit(snippet) : undefined; + return snippet ? new vscode.DocumentPasteEdit(snippet.snippet) : undefined; } private async _makeCreateImagePasteEdit(document: vscode.TextDocument, file: vscode.DataTransferFile, token: vscode.CancellationToken): Promise { diff --git a/extensions/markdown-language-features/src/languageFeatures/copyFiles/dropIntoEditor.ts b/extensions/markdown-language-features/src/languageFeatures/copyFiles/dropIntoEditor.ts index 02bd1ee80b4..899fd9a106c 100644 --- a/extensions/markdown-language-features/src/languageFeatures/copyFiles/dropIntoEditor.ts +++ b/extensions/markdown-language-features/src/languageFeatures/copyFiles/dropIntoEditor.ts @@ -38,12 +38,23 @@ export function registerDropIntoEditorSupport(selector: vscode.DocumentSelector) } const snippet = await tryGetUriListSnippet(document, dataTransfer, token); - return snippet ? new vscode.DocumentDropEdit(snippet) : undefined; + if (!snippet) { + return undefined; + } + + const edit = new vscode.DocumentDropEdit(snippet.snippet); + edit.label = snippet.label; + return edit; } + }, { + id: 'vscode.markdown.insertLink', + dropMimeTypes: [ + 'text/uri-list' + ] }); } -export async function tryGetUriListSnippet(document: vscode.TextDocument, dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise { +export async function tryGetUriListSnippet(document: vscode.TextDocument, dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise<{ snippet: vscode.SnippetString; label: string } | undefined> { const urlList = await dataTransfer.get('text/uri-list')?.asString(); if (!urlList || token.isCancellationRequested) { return undefined; @@ -58,7 +69,17 @@ export async function tryGetUriListSnippet(document: vscode.TextDocument, dataTr } } - return createUriListSnippet(document, uris); + const snippet = createUriListSnippet(document, uris); + if (!snippet) { + return undefined; + } + + return { + snippet: snippet, + label: uris.length > 1 + ? vscode.l10n.t('Insert uri links') + : vscode.l10n.t('Insert uri link') + }; } interface UriListSnippetOptions { diff --git a/extensions/markdown-language-features/tsconfig.json b/extensions/markdown-language-features/tsconfig.json index 75edc8fdacf..6bbe1c80767 100644 --- a/extensions/markdown-language-features/tsconfig.json +++ b/extensions/markdown-language-features/tsconfig.json @@ -6,6 +6,7 @@ "include": [ "src/**/*", "../../src/vscode-dts/vscode.d.ts", - "../../src/vscode-dts/vscode.proposed.documentPaste.d.ts" + "../../src/vscode-dts/vscode.proposed.documentPaste.d.ts", + "../../src/vscode-dts/vscode.proposed.dropMetadata.d.ts" ] } diff --git a/src/vs/editor/browser/widget/codeEditorWidget.ts b/src/vs/editor/browser/widget/codeEditorWidget.ts index d3bca556647..181b2c279e4 100644 --- a/src/vs/editor/browser/widget/codeEditorWidget.ts +++ b/src/vs/editor/browser/widget/codeEditorWidget.ts @@ -2242,7 +2242,7 @@ class EditorDecorationsCollection implements editorCommon.IEditorDecorationsColl this.set([]); } - public set(newDecorations: IModelDeltaDecoration[]): void { + public set(newDecorations: readonly IModelDeltaDecoration[]): string[] { try { this._isChangingDecorations = true; this._editor.changeDecorations((accessor) => { @@ -2251,6 +2251,7 @@ class EditorDecorationsCollection implements editorCommon.IEditorDecorationsColl } finally { this._isChangingDecorations = false; } + return this._decorationIds; } } diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index 4e0ba79f54a..4539a771481 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -4701,10 +4701,16 @@ class EditorWrappingInfoComputer extends ComputedEditorOption { constructor() { - const defaults: EditorDropIntoEditorOptions = { enabled: true }; + const defaults: EditorDropIntoEditorOptions = { enabled: true, showDropSelector: 'afterDrop' }; super( EditorOption.dropIntoEditor, 'dropIntoEditor', defaults, { @@ -4724,6 +4730,19 @@ class EditorDropIntoEditor extends BaseEditorOption; } diff --git a/src/vs/editor/common/model.ts b/src/vs/editor/common/model.ts index 631a77849d0..c58e1f81458 100644 --- a/src/vs/editor/common/model.ts +++ b/src/vs/editor/common/model.ts @@ -307,7 +307,7 @@ export interface IModelDecorationsChangeAccessor { * @param newDecorations Array describing what decorations should result after the call. * @return An array containing the new decorations identifiers. */ - deltaDecorations(oldDecorations: string[], newDecorations: IModelDeltaDecoration[]): string[]; + deltaDecorations(oldDecorations: readonly string[], newDecorations: readonly IModelDeltaDecoration[]): string[]; } /** diff --git a/src/vs/editor/contrib/dropIntoEditor/browser/defaultOnDropProviders.ts b/src/vs/editor/contrib/dropIntoEditor/browser/defaultOnDropProviders.ts new file mode 100644 index 00000000000..bf43583d995 --- /dev/null +++ b/src/vs/editor/contrib/dropIntoEditor/browser/defaultOnDropProviders.ts @@ -0,0 +1,109 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from 'vs/base/common/cancellation'; +import { UriList, VSDataTransfer } from 'vs/base/common/dataTransfer'; +import { Mimes } from 'vs/base/common/mime'; +import { Schemas } from 'vs/base/common/network'; +import { relativePath } from 'vs/base/common/resources'; +import { URI } from 'vs/base/common/uri'; +import { IPosition } from 'vs/editor/common/core/position'; +import { DocumentOnDropEdit, DocumentOnDropEditProvider } from 'vs/editor/common/languages'; +import { ITextModel } from 'vs/editor/common/model'; +import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; +import { localize } from 'vs/nls'; +import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; + +class DefaultTextDropProvider implements DocumentOnDropEditProvider { + + readonly id = 'text'; + readonly dropMimeTypes = [Mimes.text, 'text']; + + async provideDocumentOnDropEdits(_model: ITextModel, _position: IPosition, dataTransfer: VSDataTransfer, _token: CancellationToken): Promise { + const textEntry = dataTransfer.get('text') ?? dataTransfer.get(Mimes.text); + if (textEntry) { + const text = await textEntry.asString(); + return { + label: localize('defaultDropProvider.text.label', "Drop as plain text"), + insertText: text + }; + } + + return undefined; + } +} + +class DefaultUriListDropProvider implements DocumentOnDropEditProvider { + + readonly id = 'uri'; + readonly dropMimeTypes = [Mimes.uriList]; + + constructor( + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService + ) { } + + async provideDocumentOnDropEdits(_model: ITextModel, _position: IPosition, dataTransfer: VSDataTransfer, _token: CancellationToken): Promise { + const urlListEntry = dataTransfer.get(Mimes.uriList); + if (urlListEntry) { + const urlList = await urlListEntry.asString(); + const entry = this.getUriListInsertText(urlList); + if (entry) { + return { + label: entry.count > 1 + ? localize('defaultDropProvider.uri.label', "Drop as uri") + : localize('defaultDropProvider.uriList.label', "Drop as uri list"), + insertText: entry.snippet + }; + } + } + + return undefined; + } + + private getUriListInsertText(strUriList: string): { snippet: string; count: number } | undefined { + const entries: { readonly uri: URI; readonly originalText: string }[] = []; + for (const entry of UriList.parse(strUriList)) { + try { + entries.push({ uri: URI.parse(entry), originalText: entry }); + } catch { + // noop + } + } + + if (!entries.length) { + return; + } + + const snippet = entries + .map(({ uri, originalText }) => { + const root = this._workspaceContextService.getWorkspaceFolder(uri); + if (root) { + const rel = relativePath(root.uri, uri); + if (rel) { + return rel; + } + } + + return uri.scheme === Schemas.file ? uri.fsPath : originalText; + }) + .join(' '); + + return { snippet, count: entries.length }; + } +} + + +let registeredDefaultProviders = false; +export function registerDefaultDropProviders( + languageFeaturesService: ILanguageFeaturesService, + workspaceContextService: IWorkspaceContextService, +) { + if (!registeredDefaultProviders) { + registeredDefaultProviders = true; + + languageFeaturesService.documentOnDropEditProvider.register('*', new DefaultTextDropProvider()); + languageFeaturesService.documentOnDropEditProvider.register('*', new DefaultUriListDropProvider(workspaceContextService)); + } +} diff --git a/src/vs/editor/contrib/dropIntoEditor/browser/dropIntoEditorContribution.ts b/src/vs/editor/contrib/dropIntoEditor/browser/dropIntoEditorContribution.ts index 3a6e79a50b6..538f5e95fa5 100644 --- a/src/vs/editor/contrib/dropIntoEditor/browser/dropIntoEditorContribution.ts +++ b/src/vs/editor/contrib/dropIntoEditor/browser/dropIntoEditorContribution.ts @@ -3,14 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { raceCancellation } from 'vs/base/common/async'; +import { coalesce } from 'vs/base/common/arrays'; +import { CancelablePromise, createCancelablePromise, raceCancellation } from 'vs/base/common/async'; import { CancellationToken } from 'vs/base/common/cancellation'; -import { UriList, VSDataTransfer } from 'vs/base/common/dataTransfer'; +import { VSDataTransfer } from 'vs/base/common/dataTransfer'; import { Disposable } from 'vs/base/common/lifecycle'; -import { Mimes } from 'vs/base/common/mime'; -import { Schemas } from 'vs/base/common/network'; -import { relativePath } from 'vs/base/common/resources'; -import { URI } from 'vs/base/common/uri'; import { addExternalEditorsDropData, toVSDataTransfer } from 'vs/editor/browser/dnd'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { EditorContributionInstantiation, registerEditorContribution } from 'vs/editor/browser/editorExtensions'; @@ -18,32 +15,44 @@ import { IBulkEditService, ResourceTextEdit } from 'vs/editor/browser/services/b import { IPosition } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; import { IEditorContribution } from 'vs/editor/common/editorCommon'; -import { DocumentOnDropEdit, DocumentOnDropEditProvider, WorkspaceEdit } from 'vs/editor/common/languages'; -import { ITextModel } from 'vs/editor/common/model'; +import { DocumentOnDropEdit, WorkspaceEdit } from 'vs/editor/common/languages'; +import { TrackedRangeStickiness } from 'vs/editor/common/model'; import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; +import { DropProgressManager as DropProgressWidgetManager } from 'vs/editor/contrib/dropIntoEditor/browser/dropProgressWidget'; +import { PostDropWidgetManager } from 'vs/editor/contrib/dropIntoEditor/browser/postDropWidget'; import { CodeEditorStateFlag, EditorStateCancellationTokenSource } from 'vs/editor/contrib/editorState/browser/editorState'; import { SnippetParser } from 'vs/editor/contrib/snippet/browser/snippetParser'; -import { localize } from 'vs/nls'; -import { IProgressService, ProgressLocation } from 'vs/platform/progress/common/progress'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; +import { registerDefaultDropProviders } from './defaultOnDropProviders'; +import { EditorOption } from 'vs/editor/common/config/editorOptions'; export class DropIntoEditorController extends Disposable implements IEditorContribution { public static readonly ID = 'editor.contrib.dropIntoEditorController'; + private operationIdPool = 0; + private _currentOperation?: { readonly id: number; readonly promise: CancelablePromise }; + + private readonly _dropProgressWidgetManager: DropProgressWidgetManager; + private readonly _postDropWidgetManager: PostDropWidgetManager; + constructor( editor: ICodeEditor, @IBulkEditService private readonly _bulkEditService: IBulkEditService, @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, - @IProgressService private readonly _progressService: IProgressService, + @IInstantiationService instantiationService: IInstantiationService, @IWorkspaceContextService workspaceContextService: IWorkspaceContextService, ) { super(); + this._dropProgressWidgetManager = this._register(new DropProgressWidgetManager(editor, instantiationService)); + this._postDropWidgetManager = this._register(new PostDropWidgetManager(editor, instantiationService)); + this._register(editor.onDropIntoEditor(e => this.onDropIntoEditor(editor, e.position, e.event))); - this._languageFeaturesService.documentOnDropEditProvider.register('*', new DefaultOnDropProvider(workspaceContextService)); + registerDefaultDropProviders(this._languageFeaturesService, workspaceContextService); } private async onDropIntoEditor(editor: ICodeEditor, position: IPosition, dragEvent: DragEvent) { @@ -51,70 +60,67 @@ export class DropIntoEditorController extends Disposable implements IEditorContr return; } - const model = editor.getModel(); - const initialModelVersion = model.getVersionId(); + this._currentOperation?.promise.cancel(); + this._dropProgressWidgetManager.clear(); - const ourDataTransfer = await this.extractDataTransferData(dragEvent); - if (ourDataTransfer.size === 0) { - return; - } + editor.focus(); + editor.setPosition(position); - if (editor.getModel().getVersionId() !== initialModelVersion) { - return; - } + const id = this.operationIdPool++; - const tokenSource = new EditorStateCancellationTokenSource(editor, CodeEditorStateFlag.Value); - try { - const providers = this._languageFeaturesService.documentOnDropEditProvider.ordered(model); + const p = createCancelablePromise(async (token) => { + const tokenSource = new EditorStateCancellationTokenSource(editor, CodeEditorStateFlag.Value, undefined, token); - const providerEdit = await this._progressService.withProgress({ - location: ProgressLocation.Notification, - delay: 750, - title: localize('dropProgressTitle', "Running drop handlers..."), - cancellable: true, - }, () => { - return raceCancellation((async () => { - for (const provider of providers) { - const edit = await provider.provideDocumentOnDropEdits(model, position, ourDataTransfer, tokenSource.token); - if (tokenSource.token.isCancellationRequested) { - return undefined; - } - if (edit) { - return edit; - } - } - return undefined; - })(), tokenSource.token); - }, () => { - tokenSource.cancel(); + this._dropProgressWidgetManager.setAtPosition(position, { + cancel: () => tokenSource.cancel() }); - if (tokenSource.token.isCancellationRequested || editor.getModel().getVersionId() !== initialModelVersion) { - return; - } + try { + const ourDataTransfer = await this.extractDataTransferData(dragEvent); + if (ourDataTransfer.size === 0 || tokenSource.token.isCancellationRequested) { + return; + } - if (providerEdit) { - const snippet = typeof providerEdit.insertText === 'string' ? SnippetParser.escape(providerEdit.insertText) : providerEdit.insertText.snippet; - const combinedWorkspaceEdit: WorkspaceEdit = { - edits: [ - new ResourceTextEdit(model.uri, { - range: new Range(position.lineNumber, position.column, position.lineNumber, position.column), - text: snippet, - insertAsSnippet: true, - }), - ...(providerEdit.additionalEdit?.edits ?? []) - ] - }; - editor.focus(); - await this._bulkEditService.apply(combinedWorkspaceEdit, { editor }); - return; + const model = editor.getModel(); + if (!model) { + return; + } + + const providers = this._languageFeaturesService.documentOnDropEditProvider + .ordered(model) + .filter(provider => { + if (!provider.dropMimeTypes) { + // Keep all providers that don't specify mime types + return true; + } + return provider.dropMimeTypes.some(mime => ourDataTransfer.has(mime)); + }); + + const possibleDropEdits = await raceCancellation(Promise.all(providers.map(provider => { + return provider.provideDocumentOnDropEdits(model, position, ourDataTransfer, tokenSource.token); + })), tokenSource.token); + if (tokenSource.token.isCancellationRequested) { + return; + } + + if (possibleDropEdits) { + // Pass in the parent token here as it tracks cancelling the entire drop operation. + await this.applyDropResult(editor, position, 0, coalesce(possibleDropEdits), token); + } + } finally { + tokenSource.dispose(); + + if (this._currentOperation?.id === id) { + this._dropProgressWidgetManager.clear(); + this._currentOperation = undefined; + } } - } finally { - tokenSource.dispose(); - } + }); + + this._currentOperation = { id, promise: p }; } - public async extractDataTransferData(dragEvent: DragEvent): Promise { + private async extractDataTransferData(dragEvent: DragEvent): Promise { if (!dragEvent.dataTransfer) { return new VSDataTransfer(); } @@ -123,63 +129,54 @@ export class DropIntoEditorController extends Disposable implements IEditorContr addExternalEditorsDropData(textEditorDataTransfer, dragEvent); return textEditorDataTransfer; } -} -class DefaultOnDropProvider implements DocumentOnDropEditProvider { - - constructor( - @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, - ) { } - - async provideDocumentOnDropEdits(_model: ITextModel, _position: IPosition, dataTransfer: VSDataTransfer, _token: CancellationToken): Promise { - const urlListEntry = dataTransfer.get(Mimes.uriList); - if (urlListEntry) { - const urlList = await urlListEntry.asString(); - const snippet = this.getUriListInsertText(urlList); - if (snippet) { - return { insertText: snippet }; - } - } - - const textEntry = dataTransfer.get('text') ?? dataTransfer.get(Mimes.text); - if (textEntry) { - const text = await textEntry.asString(); - return { insertText: text }; - } - - return undefined; - } - - private getUriListInsertText(strUriList: string): string | undefined { - const entries: { readonly uri: URI; readonly originalText: string }[] = []; - for (const entry of UriList.parse(strUriList)) { - try { - entries.push({ uri: URI.parse(entry), originalText: entry }); - } catch { - // noop - } - } - - if (!entries.length) { + private async applyDropResult(editor: ICodeEditor, position: IPosition, selectedEditIndex: number, allEdits: readonly DocumentOnDropEdit[], token: CancellationToken): Promise { + const model = editor.getModel(); + if (!model) { return; } - return entries - .map(({ uri, originalText }) => { - const root = this._workspaceContextService.getWorkspaceFolder(uri); - if (root) { - const rel = relativePath(root.uri, uri); - if (rel) { - return rel; - } - } + const edit = allEdits[selectedEditIndex]; + if (!edit) { + return; + } - return uri.scheme === Schemas.file ? uri.fsPath : originalText; - }) - .join(' '); + const snippet = typeof edit.insertText === 'string' ? SnippetParser.escape(edit.insertText) : edit.insertText.snippet; + const combinedWorkspaceEdit: WorkspaceEdit = { + edits: [ + new ResourceTextEdit(model.uri, { + range: Range.fromPositions(position), + text: snippet, + insertAsSnippet: true, + }), + ...(edit.additionalEdit?.edits ?? []) + ] + }; + + // Use a decoration to track edits around the cursor + const editTrackingDecoration = model.deltaDecorations([], [{ + range: Range.fromPositions(position), + options: { description: 'drop-line-suffix', stickiness: TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges } + }]); + + const editResult = await this._bulkEditService.apply(combinedWorkspaceEdit, { editor, token }); + + const editRange = model.getDecorationRange(editTrackingDecoration[0]); + model.deltaDecorations(editTrackingDecoration, []); + + if (editResult.isApplied && allEdits.length > 1) { + const options = editor.getOptions().get(EditorOption.dropIntoEditor); + if (options.showDropSelector === 'afterDrop') { + this._postDropWidgetManager.show(editRange ?? Range.fromPositions(position), { + activeEditIndex: selectedEditIndex, + allEdits: allEdits, + }, async (newEditIndex) => { + await model.undo(); + this.applyDropResult(editor, position, newEditIndex, allEdits, token); + }); + } + } } } - registerEditorContribution(DropIntoEditorController.ID, DropIntoEditorController, EditorContributionInstantiation.BeforeFirstInteraction); - diff --git a/src/vs/editor/contrib/dropIntoEditor/browser/dropProgressWidget.css b/src/vs/editor/contrib/dropIntoEditor/browser/dropProgressWidget.css new file mode 100644 index 00000000000..f33beb406e8 --- /dev/null +++ b/src/vs/editor/contrib/dropIntoEditor/browser/dropProgressWidget.css @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.drop-into-editor-progress-decoration { + display: inline-block; + width: 1em; + height: 1em; +} + +.inline-drop-progress-widget { + display: flex !important; + justify-content: center; + align-items: center; +} + +.inline-drop-progress-widget .icon { + font-size: 80% !important; +} + +.inline-drop-progress-widget:hover .icon { + font-size: 90% !important; + animation: none; +} + +.inline-drop-progress-widget:hover .icon::before { + content: "\ea76"; /* codicon-x */ +} diff --git a/src/vs/editor/contrib/dropIntoEditor/browser/dropProgressWidget.ts b/src/vs/editor/contrib/dropIntoEditor/browser/dropProgressWidget.ts new file mode 100644 index 00000000000..c6367d4111a --- /dev/null +++ b/src/vs/editor/contrib/dropIntoEditor/browser/dropProgressWidget.ts @@ -0,0 +1,147 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from 'vs/base/browser/dom'; +import { disposableTimeout } from 'vs/base/common/async'; +import { Codicon } from 'vs/base/common/codicons'; +import { Disposable, MutableDisposable } from 'vs/base/common/lifecycle'; +import { noBreakWhitespace } from 'vs/base/common/strings'; +import { ThemeIcon } from 'vs/base/common/themables'; +import 'vs/css!./dropProgressWidget'; +import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition } from 'vs/editor/browser/editorBrowser'; +import { EditorOption } from 'vs/editor/common/config/editorOptions'; +import { IPosition } from 'vs/editor/common/core/position'; +import { Range } from 'vs/editor/common/core/range'; +import { IEditorDecorationsCollection } from 'vs/editor/common/editorCommon'; +import { TrackedRangeStickiness } from 'vs/editor/common/model'; +import { ModelDecorationOptions } from 'vs/editor/common/model/textModel'; +import { localize } from 'vs/nls'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; + +const dropIntoEditorProgress = ModelDecorationOptions.register({ + description: 'drop-into-editor-progress', + stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, + showIfCollapsed: true, + after: { + content: noBreakWhitespace, + inlineClassName: 'drop-into-editor-progress-decoration', + inlineClassNameAffectsLetterSpacing: true, + } +}); + +interface DropProgressDelegate { + cancel(): void; +} + +class InlineDropProgressWidget extends Disposable implements IContentWidget { + private static readonly ID = 'editor.widget.dropProgressWidget'; + + allowEditorOverflow = false; + suppressMouseDown = true; + + private domNode!: HTMLElement; + + constructor( + private readonly editor: ICodeEditor, + private readonly range: Range, + private readonly delegate: DropProgressDelegate, + ) { + super(); + + this.create(); + + this.editor.addContentWidget(this); + this.editor.layoutContentWidget(this); + } + + private create(): void { + this.domNode = dom.$('.inline-drop-progress-widget'); + this.domNode.role = 'button'; + this.domNode.title = localize('dropIntoEditorProgress', "Running drop handlers. Click to cancel"); + + const iconElement = dom.$('span.icon'); + this.domNode.append(iconElement); + + iconElement.classList.add(...ThemeIcon.asClassNameArray(Codicon.loading), 'codicon-modifier-spin'); + + const updateSize = () => { + const lineHeight = this.editor.getOption(EditorOption.lineHeight); + this.domNode.style.height = `${lineHeight}px`; + this.domNode.style.width = `${Math.ceil(0.8 * lineHeight)}px`; + }; + updateSize(); + + this._register(this.editor.onDidChangeConfiguration(c => { + if (c.hasChanged(EditorOption.fontSize) || c.hasChanged(EditorOption.lineHeight)) { + updateSize(); + } + })); + + this._register(dom.addDisposableListener(this.domNode, dom.EventType.CLICK, e => { + this.delegate.cancel(); + })); + } + + getId(): string { + return InlineDropProgressWidget.ID; + } + + getDomNode(): HTMLElement { + return this.domNode; + } + + getPosition(): IContentWidgetPosition | null { + return { + position: { lineNumber: this.range.startLineNumber, column: this.range.startColumn }, + preference: [ContentWidgetPositionPreference.EXACT] + }; + } + + override dispose(): void { + super.dispose(); + this.editor.removeContentWidget(this); + } +} + +export class DropProgressManager extends Disposable { + + /** Delay before showing the progress widget */ + private readonly _showDelay = 500; // ms + private readonly _showPromise = this._register(new MutableDisposable()); + + private readonly _currentDecorations: IEditorDecorationsCollection; + private readonly _currentWidget = new MutableDisposable(); + + constructor( + private readonly _editor: ICodeEditor, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + + this._currentDecorations = _editor.createDecorationsCollection(); + } + + public setAtPosition(position: IPosition, delegate: DropProgressDelegate) { + this.clear(); + + this._showPromise.value = disposableTimeout(() => { + const range = new Range(position.lineNumber, position.column, position.lineNumber, position.column); + const decorationIds = this._currentDecorations.set([{ + range: range, + options: dropIntoEditorProgress, + }]); + + if (decorationIds.length > 0) { + this._currentWidget.value = this._instantiationService.createInstance(InlineDropProgressWidget, this._editor, range, delegate); + } + }, this._showDelay); + } + + public clear() { + this._showPromise.clear(); + this._currentDecorations.clear(); + this._currentWidget.clear(); + } +} diff --git a/src/vs/editor/contrib/dropIntoEditor/browser/postDropWidget.css b/src/vs/editor/contrib/dropIntoEditor/browser/postDropWidget.css new file mode 100644 index 00000000000..6ca56766a67 --- /dev/null +++ b/src/vs/editor/contrib/dropIntoEditor/browser/postDropWidget.css @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.post-drop-widget .monaco-button { + padding: 0; +} diff --git a/src/vs/editor/contrib/dropIntoEditor/browser/postDropWidget.ts b/src/vs/editor/contrib/dropIntoEditor/browser/postDropWidget.ts new file mode 100644 index 00000000000..0e17255193d --- /dev/null +++ b/src/vs/editor/contrib/dropIntoEditor/browser/postDropWidget.ts @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from 'vs/base/browser/dom'; +import { Button } from 'vs/base/browser/ui/button/button'; +import { toAction } from 'vs/base/common/actions'; +import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import 'vs/css!./dropProgressWidget'; +import 'vs/css!./postDropWidget'; +import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition } from 'vs/editor/browser/editorBrowser'; +import { Range } from 'vs/editor/common/core/range'; +import { DocumentOnDropEdit } from 'vs/editor/common/languages'; +import { localize } from 'vs/nls'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { defaultButtonStyles } from 'vs/platform/theme/browser/defaultStyles'; + + +interface DropEditSet { + readonly activeEditIndex: number; + readonly allEdits: readonly DocumentOnDropEdit[]; +} + +class PostDropWidget extends Disposable implements IContentWidget { + private static readonly ID = 'editor.widget.postDropWidget'; + + readonly allowEditorOverflow = false; + readonly suppressMouseDown = true; + + private domNode!: HTMLElement; + + constructor( + private readonly editor: ICodeEditor, + private readonly range: Range, + private readonly edits: DropEditSet, + private readonly onSelectNewEdit: (editIndex: number) => void, + @IContextMenuService private readonly _contextMenuService: IContextMenuService, + ) { + super(); + + this.create(); + + this.editor.addContentWidget(this); + this.editor.layoutContentWidget(this); + + this._register(toDisposable((() => this.editor.removeContentWidget(this)))); + + this._register(this.editor.onDidChangeCursorPosition(e => { + if (!range.containsPosition(e.position)) { + this.dispose(); + } + })); + } + + private create(): void { + this.domNode = dom.$('.post-drop-widget'); + + const button = this._register(new Button(this.domNode, { + title: localize('postDropWidgetTile', "Drop options..."), + supportIcons: true, + ...defaultButtonStyles, + })); + button.label = '$(clippy)'; + + this._register(dom.addDisposableListener(this.domNode, dom.EventType.CLICK, e => { + this._contextMenuService.showContextMenu({ + getAnchor: () => { + const pos = dom.getDomNodePagePosition(button.element); + return { x: pos.left + pos.width, y: pos.top + pos.height }; + }, + getActions: () => { + return this.edits.allEdits.map((edit, i) => toAction({ + id: '', + label: edit.label, + checked: i === this.edits.activeEditIndex, + run: () => this.onSelectNewEdit(i), + })); + } + }); + })); + } + + getId(): string { + return PostDropWidget.ID; + } + + getDomNode(): HTMLElement { + return this.domNode; + } + + getPosition(): IContentWidgetPosition | null { + return { + position: this.range.getEndPosition(), + preference: [ContentWidgetPositionPreference.BELOW] + }; + } +} + +export class PostDropWidgetManager extends Disposable { + + private readonly _currentWidget = this._register(new MutableDisposable()); + + constructor( + private readonly _editor: ICodeEditor, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + + this._register(_editor.onDidChangeModelContent(() => this.clear())); + } + + public show(range: Range, edits: DropEditSet, onDidSelectEdit: (newIndex: number) => void) { + this.clear(); + + if (this._editor.hasModel()) { + this._currentWidget.value = this._instantiationService.createInstance(PostDropWidget, this._editor, range, edits, onDidSelectEdit); + } + } + + public clear() { + this._currentWidget?.clear(); + } +} diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index eb0a1280d9e..4d3dedddba6 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -2805,7 +2805,7 @@ declare namespace monaco.editor { /** * Replace all previous decorations with `newDecorations`. */ - set(newDecorations: IModelDeltaDecoration[]): void; + set(newDecorations: readonly IModelDeltaDecoration[]): string[]; /** * Remove all previous decorations. */ @@ -4621,10 +4621,15 @@ declare namespace monaco.editor { */ export interface IDropIntoEditorOptions { /** - * Enable the dropping into editor. + * Enable dropping into editor. * Defaults to true. */ enabled?: boolean; + /** + * Controls if a widget is shown after a drop. + * Defaults to 'afterDrop'. + */ + showDropSelector?: 'afterDrop' | 'never'; } export enum EditorOption { diff --git a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts index 69e8143eef8..da489bb88a4 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts @@ -907,8 +907,8 @@ export class MainThreadLanguageFeatures extends Disposable implements MainThread private readonly _documentOnDropEditProviders = new Map(); - $registerDocumentOnDropEditProvider(handle: number, selector: IDocumentFilterDto[]): void { - const provider = new MainThreadDocumentOnDropEditProvider(handle, this._proxy, this._uriIdentService); + $registerDocumentOnDropEditProvider(handle: number, selector: IDocumentFilterDto[], metadata?: { id: string; dropMimeTypes?: string[] }): void { + const provider = new MainThreadDocumentOnDropEditProvider(handle, metadata, this._proxy, this._uriIdentService); this._documentOnDropEditProviders.set(handle, provider); this._registrations.set(handle, combinedDisposable( this._languageFeaturesService.documentOnDropEditProvider.register(selector, provider), @@ -990,11 +990,18 @@ class MainThreadDocumentOnDropEditProvider implements languages.DocumentOnDropEd private readonly dataTransfers = new DataTransferCache(); + readonly id?: string; + readonly dropMimeTypes?: readonly string[]; + constructor( private readonly handle: number, + metadata: { id: string; dropMimeTypes?: readonly string[] } | undefined, private readonly _proxy: ExtHostLanguageFeaturesShape, @IUriIdentityService private readonly _uriIdentService: IUriIdentityService - ) { } + ) { + this.id = metadata?.id; + this.dropMimeTypes = metadata?.dropMimeTypes; + } async provideDocumentOnDropEdits(model: ITextModel, position: IPosition, dataTransfer: VSDataTransfer, token: CancellationToken): Promise { const request = this.dataTransfers.add(dataTransfer); @@ -1005,6 +1012,7 @@ class MainThreadDocumentOnDropEditProvider implements languages.DocumentOnDropEd return undefined; } return { + label: edit.label, insertText: edit.insertText, additionalEdit: reviveWorkspaceEditDto(edit.additionalEdit, this._uriIdentService), }; diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 5eb7eaf0fda..6b1dff49933 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -599,8 +599,8 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I createLanguageStatusItem(id: string, selector: vscode.DocumentSelector): vscode.LanguageStatusItem { return extHostLanguages.createLanguageStatusItem(extension, id, selector); }, - registerDocumentDropEditProvider(selector: vscode.DocumentSelector, provider: vscode.DocumentDropEditProvider): vscode.Disposable { - return extHostLanguageFeatures.registerDocumentOnDropEditProvider(extension, selector, provider); + registerDocumentDropEditProvider(selector: vscode.DocumentSelector, provider: vscode.DocumentDropEditProvider, metadata?: vscode.DocumentDropEditProviderMetadata): vscode.Disposable { + return extHostLanguageFeatures.registerDocumentOnDropEditProvider(extension, selector, provider, isProposedApiEnabled(extension, 'dropMetadata') ? metadata : undefined); } }; diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 818ef20a56d..09355a18613 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -407,7 +407,7 @@ export interface MainThreadLanguageFeaturesShape extends IDisposable { $registerSelectionRangeProvider(handle: number, selector: IDocumentFilterDto[]): void; $registerCallHierarchyProvider(handle: number, selector: IDocumentFilterDto[]): void; $registerTypeHierarchyProvider(handle: number, selector: IDocumentFilterDto[]): void; - $registerDocumentOnDropEditProvider(handle: number, selector: IDocumentFilterDto[]): void; + $registerDocumentOnDropEditProvider(handle: number, selector: IDocumentFilterDto[], metadata?: { id: string; dropMimeTypes?: readonly string[] }): void; $resolvePasteFileData(handle: number, requestId: number, dataId: string): Promise; $resolveDocumentOnDropFileData(handle: number, requestId: number, dataId: string): Promise; $setLanguageConfiguration(handle: number, languageId: string, configuration: ILanguageConfigurationDto): void; @@ -1817,6 +1817,7 @@ export interface IPasteEditDto { } export interface IDocumentOnDropEditDto { + label: string; insertText: string | { snippet: string }; additionalEdit?: IWorkspaceEditDto; } diff --git a/src/vs/workbench/api/common/extHostLanguageFeatures.ts b/src/vs/workbench/api/common/extHostLanguageFeatures.ts index 1bb47b948c3..96d19495803 100644 --- a/src/vs/workbench/api/common/extHostLanguageFeatures.ts +++ b/src/vs/workbench/api/common/extHostLanguageFeatures.ts @@ -35,6 +35,7 @@ import { isCancellationError, NotImplementedError } from 'vs/base/common/errors' import { raceCancellationError } from 'vs/base/common/async'; import { checkProposedApiEnabled, isProposedApiEnabled } from 'vs/workbench/services/extensions/common/extensions'; import { IExtHostTelemetry } from 'vs/workbench/api/common/extHostTelemetry'; +import { localize } from 'vs/nls'; // --- adapter @@ -1728,6 +1729,7 @@ class DocumentOnDropEditAdapter { private readonly _documents: ExtHostDocuments, private readonly _provider: vscode.DocumentDropEditProvider, private readonly _handle: number, + private readonly _extension: IExtensionDescription, ) { } async provideDocumentOnDropEdits(requestId: number, uri: URI, position: IPosition, dataTransferDto: extHostProtocol.DataTransferDTO, token: CancellationToken): Promise { @@ -1742,6 +1744,7 @@ class DocumentOnDropEditAdapter { return undefined; } return { + label: edit.label ?? localize('defaultDropLabel', "Drop using '{0}' extension", this._extension.displayName || this._extension.name), insertText: typeof edit.insertText === 'string' ? edit.insertText : { snippet: edit.insertText.value }, additionalEdit: edit.additionalEdit ? typeConvert.WorkspaceEdit.from(edit.additionalEdit, undefined) : undefined, }; @@ -2380,10 +2383,10 @@ export class ExtHostLanguageFeatures implements extHostProtocol.ExtHostLanguageF // --- Document on drop - registerDocumentOnDropEditProvider(extension: IExtensionDescription, selector: vscode.DocumentSelector, provider: vscode.DocumentDropEditProvider) { + registerDocumentOnDropEditProvider(extension: IExtensionDescription, selector: vscode.DocumentSelector, provider: vscode.DocumentDropEditProvider, metadata?: vscode.DocumentDropEditProviderMetadata) { const handle = this._nextHandle(); - this._adapter.set(handle, new AdapterData(new DocumentOnDropEditAdapter(this._proxy, this._documents, provider, handle), extension)); - this._proxy.$registerDocumentOnDropEditProvider(handle, this._transformDocumentSelector(selector)); + this._adapter.set(handle, new AdapterData(new DocumentOnDropEditAdapter(this._proxy, this._documents, provider, handle, extension), extension)); + this._proxy.$registerDocumentOnDropEditProvider(handle, this._transformDocumentSelector(selector), metadata); return this._createDisposable(handle); } diff --git a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts index 75ef2592428..c7422d3e8cb 100644 --- a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts +++ b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts @@ -27,6 +27,7 @@ export const allApiProposals = Object.freeze({ diffContentOptions: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.diffContentOptions.d.ts', documentFiltersExclusive: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.documentFiltersExclusive.d.ts', documentPaste: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.documentPaste.d.ts', + dropMetadata: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.dropMetadata.d.ts', editSessionIdentityProvider: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.editSessionIdentityProvider.d.ts', editorInsets: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.editorInsets.d.ts', envShellEvent: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.envShellEvent.d.ts', diff --git a/src/vscode-dts/vscode.proposed.dropMetadata.d.ts b/src/vscode-dts/vscode.proposed.dropMetadata.d.ts new file mode 100644 index 00000000000..1a441377644 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.dropMetadata.d.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + // https://github.com/microsoft/vscode/issues/179430 + + export interface DocumentDropEdit { + /** + * Human readable label that describes the edit. + */ + label?: string; + } + + export interface DocumentDropEditProviderMetadata { + /** + * Unique identifier for the provider. + */ + readonly id: string; + + /** + * List of data transfer types that the provider supports. + */ + readonly dropMimeTypes?: readonly string[]; + } + + export namespace languages { + export function registerDocumentDropEditProvider(selector: DocumentSelector, provider: DocumentDropEditProvider, metadata?: DocumentDropEditProviderMetadata): Disposable; + } +}