Add drop feedback UX (#179434)

For #179430

Adds two new UX components:

- An inline progress icon shown when a drop operation takes over 500ms. This replaces the notification. You can click on it to cancel the drop

- Post drop, a drop feedback icon that lets you drop the file in a different way. This lets you drop the file as plain text for instance instead of as a markdown link
This commit is contained in:
Matt Bierner
2023-04-11 13:48:24 -07:00
committed by GitHub
parent 5d3f960b67
commit e9262678fa
22 changed files with 654 additions and 140 deletions
@@ -16,7 +16,8 @@
"Programming Languages"
],
"enabledApiProposals": [
"documentPaste"
"documentPaste",
"dropMetadata"
],
"activationEvents": [
"onLanguage:markdown",
@@ -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<vscode.DocumentPasteEdit | undefined> {
@@ -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<vscode.SnippetString | undefined> {
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 {
@@ -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"
]
}
@@ -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;
}
}
+23 -3
View File
@@ -4701,10 +4701,16 @@ class EditorWrappingInfoComputer extends ComputedEditorOption<EditorOption.wrapp
*/
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';
}
/**
@@ -4715,7 +4721,7 @@ export type EditorDropIntoEditorOptions = Readonly<Required<IDropIntoEditorOptio
class EditorDropIntoEditor extends BaseEditorOption<EditorOption.dropIntoEditor, IDropIntoEditorOptions, EditorDropIntoEditorOptions> {
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<EditorOption.dropIntoEditor,
default: defaults.enabled,
markdownDescription: nls.localize('dropIntoEditor.enabled', "Controls whether you can drag and drop a file into a text editor by holding down `shift` (instead of opening the file in an editor)."),
},
'editor.dropIntoEditor.showDropSelector': {
type: 'string',
markdownDescription: nls.localize('dropIntoEditor.showDropSelector', "Controls if a widget is shown when dropping files into the editor. This widget lets you control how the file is dropped."),
enum: [
'afterDrop',
'never'
],
enumDescriptions: [
nls.localize('dropIntoEditor.showDropSelector.afterDrop', "Show the drop selector widget after a file is dropped into the editor."),
nls.localize('dropIntoEditor.showDropSelector.never', "Never show the drop selector widget. Instead the default drop provider is always used."),
],
default: 'afterDrop',
},
}
);
}
@@ -4734,7 +4753,8 @@ class EditorDropIntoEditor extends BaseEditorOption<EditorOption.dropIntoEditor,
}
const input = _input as IDropIntoEditorOptions;
return {
enabled: boolean(input.enabled, this.defaultValue.enabled)
enabled: boolean(input.enabled, this.defaultValue.enabled),
showDropSelector: stringSet(input.showDropSelector, this.defaultValue.showDropSelector, ['afterDrop', 'never']),
};
}
}
+1 -1
View File
@@ -545,7 +545,7 @@ export interface IEditorDecorationsCollection {
/**
* Replace all previous decorations with `newDecorations`.
*/
set(newDecorations: IModelDeltaDecoration[]): void;
set(newDecorations: readonly IModelDeltaDecoration[]): string[];
/**
* Remove all previous decorations.
*/
+5
View File
@@ -1917,6 +1917,8 @@ export enum ExternalUriOpenerPriority {
* @internal
*/
export interface DocumentOnDropEdit {
readonly label: string;
insertText: string | { snippet: string };
additionalEdit?: WorkspaceEdit;
}
@@ -1925,5 +1927,8 @@ export interface DocumentOnDropEdit {
* @internal
*/
export interface DocumentOnDropEditProvider {
readonly id?: string;
readonly dropMimeTypes?: readonly string[];
provideDocumentOnDropEdits(model: model.ITextModel, position: IPosition, dataTransfer: VSDataTransfer, token: CancellationToken): ProviderResult<DocumentOnDropEdit>;
}
+1 -1
View File
@@ -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[];
}
/**
@@ -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<DocumentOnDropEdit | undefined> {
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<DocumentOnDropEdit | undefined> {
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));
}
}
@@ -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<void> };
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<VSDataTransfer> {
private async extractDataTransferData(dragEvent: DragEvent): Promise<VSDataTransfer> {
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<DocumentOnDropEdit | undefined> {
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<void> {
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);
@@ -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 */
}
@@ -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<InlineDropProgressWidget>();
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();
}
}
@@ -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;
}
@@ -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<PostDropWidget>());
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();
}
}
+7 -2
View File
@@ -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 {
@@ -907,8 +907,8 @@ export class MainThreadLanguageFeatures extends Disposable implements MainThread
private readonly _documentOnDropEditProviders = new Map<number, MainThreadDocumentOnDropEditProvider>();
$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<languages.DocumentOnDropEdit | null | undefined> {
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),
};
@@ -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);
}
};
@@ -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<VSBuffer>;
$resolveDocumentOnDropFileData(handle: number, requestId: number, dataId: string): Promise<VSBuffer>;
$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;
}
@@ -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<extHostProtocol.IDocumentOnDropEditDto | undefined> {
@@ -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);
}
@@ -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',
+32
View File
@@ -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;
}
}