From 30e8da8a0fb454004ee00ef9be4eb91518d52c5a Mon Sep 17 00:00:00 2001 From: Alex Ross Date: Tue, 21 Dec 2021 11:25:10 +0100 Subject: [PATCH] Enable tree drag and drop across trees (#139567) --- .../api/browser/mainThreadTreeViews.ts | 20 +++++-- .../workbench/api/common/extHost.protocol.ts | 5 +- .../workbench/api/common/extHostTreeViews.ts | 58 +++++++++++++++---- .../workbench/browser/parts/views/treeView.ts | 43 ++++++++++++-- src/vs/workbench/common/views.ts | 3 +- .../common/treeViewsDragAndDropService.ts | 36 ++++++++++++ .../browser/api/mainThreadTreeViews.test.ts | 2 +- .../vscode.proposed.treeViewDragAndDrop.d.ts | 14 +++-- 8 files changed, 151 insertions(+), 30 deletions(-) create mode 100644 src/vs/workbench/services/views/common/treeViewsDragAndDropService.ts diff --git a/src/vs/workbench/api/browser/mainThreadTreeViews.ts b/src/vs/workbench/api/browser/mainThreadTreeViews.ts index 747c2a4ddea..ab4bcbff2af 100644 --- a/src/vs/workbench/api/browser/mainThreadTreeViews.ts +++ b/src/vs/workbench/api/browser/mainThreadTreeViews.ts @@ -32,14 +32,14 @@ export class MainThreadTreeViews extends Disposable implements MainThreadTreeVie this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostTreeViews); } - async $registerTreeViewDataProvider(treeViewId: string, options: { showCollapseAll: boolean, canSelectMany: boolean, dragAndDropMimeTypes: string[] | undefined }): Promise { + async $registerTreeViewDataProvider(treeViewId: string, options: { showCollapseAll: boolean, canSelectMany: boolean, dragAndDropMimeTypes: string[] | undefined, hasWillDrop: boolean }): Promise { this.logService.trace('MainThreadTreeViews#$registerTreeViewDataProvider', treeViewId, options); this.extensionService.whenInstalledExtensionsRegistered().then(() => { const dataProvider = new TreeViewDataProvider(treeViewId, this._proxy, this.notificationService); this._dataProviders.set(treeViewId, dataProvider); const dndController = options.dragAndDropMimeTypes - ? new TreeViewDragAndDropController(treeViewId, options.dragAndDropMimeTypes, this._proxy) : undefined; + ? new TreeViewDragAndDropController(treeViewId, options.dragAndDropMimeTypes, options.hasWillDrop, this._proxy) : undefined; const viewer = this.getTreeView(treeViewId); if (viewer) { // Order is important here. The internal tree isn't created until the dataProvider is set. @@ -169,10 +169,22 @@ class TreeViewDragAndDropController implements ITreeViewDragAndDropController { constructor(private readonly treeViewId: string, readonly supportedMimeTypes: string[], + readonly hasWillDrop: boolean, private readonly _proxy: ExtHostTreeViewsShape) { } - async onDrop(dataTransfer: ITreeDataTransfer, targetTreeItem: ITreeItem, sourceTreeId?: string, sourceTreeItemHandles?: string[]): Promise { - return this._proxy.$onDrop(this.treeViewId, await TreeDataTransferConverter.toTreeDataTransferDTO(dataTransfer), targetTreeItem.handle, sourceTreeId, sourceTreeItemHandles); + async onDrop(dataTransfer: ITreeDataTransfer, targetTreeItem: ITreeItem, operationUuid?: string, sourceTreeId?: string, sourceTreeItemHandles?: string[]): Promise { + return this._proxy.$onDrop(this.treeViewId, await TreeDataTransferConverter.toTreeDataTransferDTO(dataTransfer), targetTreeItem.handle, operationUuid, sourceTreeId, sourceTreeItemHandles); + } + + async onWillDrop(sourceTreeItemHandles: string[], operationUuid: string): Promise { + if (!this.hasWillDrop) { + return; + } + const additionalTransferItems = await this._proxy.$onWillDrop(this.treeViewId, sourceTreeItemHandles, operationUuid); + if (!additionalTransferItems) { + return; + } + return TreeDataTransferConverter.toITreeDataTransfer(additionalTransferItems); } } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 0492b6b9ed4..baddd5c8e24 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -295,7 +295,7 @@ export interface MainThreadTextEditorsShape extends IDisposable { } export interface MainThreadTreeViewsShape extends IDisposable { - $registerTreeViewDataProvider(treeViewId: string, options: { showCollapseAll: boolean, canSelectMany: boolean, dragAndDropMimeTypes: string[] | undefined }): Promise; + $registerTreeViewDataProvider(treeViewId: string, options: { showCollapseAll: boolean, canSelectMany: boolean, dragAndDropMimeTypes: string[] | undefined, hasWillDrop: boolean }): Promise; $refresh(treeViewId: string, itemsToRefresh?: { [treeItemHandle: string]: ITreeItem; }): Promise; $reveal(treeViewId: string, itemInfo: { item: ITreeItem, parentChain: ITreeItem[] } | undefined, options: IRevealOptions): Promise; $setMessage(treeViewId: string, message: string): void; @@ -1274,7 +1274,8 @@ export interface ExtHostDocumentsAndEditorsShape { export interface ExtHostTreeViewsShape { $getChildren(treeViewId: string, treeItemHandle?: string): Promise; - $onDrop(destinationViewId: string, treeDataTransfer: TreeDataTransferDTO, newParentTreeItemHandle: string, sourceViewId?: string, sourceTreeItemHandles?: string[]): Promise; + $onDrop(destinationViewId: string, treeDataTransfer: TreeDataTransferDTO, newParentTreeItemHandle: string, operationUuid?: string, sourceViewId?: string, sourceTreeItemHandles?: string[]): Promise; + $onWillDrop(sourceViewId: string, sourceTreeItemHandles: string[], operationUuid: string): Promise; $setExpanded(treeViewId: string, treeItemHandle: string, expanded: boolean): void; $setSelection(treeViewId: string, treeItemHandles: string[]): void; $setVisible(treeViewId: string, visible: boolean): void; diff --git a/src/vs/workbench/api/common/extHostTreeViews.ts b/src/vs/workbench/api/common/extHostTreeViews.ts index 09281009481..65f37e41626 100644 --- a/src/vs/workbench/api/common/extHostTreeViews.ts +++ b/src/vs/workbench/api/common/extHostTreeViews.ts @@ -10,7 +10,7 @@ import { URI } from 'vs/base/common/uri'; import { Emitter, Event } from 'vs/base/common/event'; import { Disposable, DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; import { ExtHostTreeViewsShape, MainThreadTreeViewsShape } from './extHost.protocol'; -import { ITreeItem, TreeViewItemHandleArg, ITreeItemLabel, IRevealOptions } from 'vs/workbench/common/views'; +import { ITreeItem, TreeViewItemHandleArg, ITreeItemLabel, IRevealOptions, ITreeDataTransfer } from 'vs/workbench/common/views'; import { ExtHostCommands, CommandsConverter } from 'vs/workbench/api/common/extHostCommands'; import { asPromise } from 'vs/base/common/async'; import { TreeItemCollapsibleState, ThemeIcon, MarkdownString as MarkdownStringType } from 'vs/workbench/api/common/extHostTypes'; @@ -23,6 +23,7 @@ import { IMarkdownString } from 'vs/base/common/htmlContent'; import { CancellationTokenSource } from 'vs/base/common/cancellation'; import { Command } from 'vs/editor/common/modes'; import { TreeDataTransferConverter, TreeDataTransferDTO } from 'vs/workbench/api/common/shared/treeDataTransfer'; +import { ITreeViewsDragAndDropService, TreeViewsDragAndDropService } from 'vs/workbench/services/views/common/treeViewsDragAndDropService'; type TreeItemHandle = string; @@ -49,6 +50,7 @@ function toTreeItemLabel(label: any, extension: IExtensionDescription): ITreeIte export class ExtHostTreeViews implements ExtHostTreeViewsShape { private treeViews: Map> = new Map>(); + private treeDragAndDropService: ITreeViewsDragAndDropService = new TreeViewsDragAndDropService(); constructor( private _proxy: MainThreadTreeViewsShape, @@ -86,7 +88,8 @@ export class ExtHostTreeViews implements ExtHostTreeViewsShape { throw new Error('Options with treeDataProvider is mandatory'); } const dragAndDropMimeTypes = options.dragAndDropController?.supportedMimeTypes; - const registerPromise = this._proxy.$registerTreeViewDataProvider(viewId, { showCollapseAll: !!options.showCollapseAll, canSelectMany: !!options.canSelectMany, dragAndDropMimeTypes }); + const hasWillDrop = !!options.dragAndDropController?.onWillDrop; + const registerPromise = this._proxy.$registerTreeViewDataProvider(viewId, { showCollapseAll: !!options.showCollapseAll, canSelectMany: !!options.canSelectMany, dragAndDropMimeTypes, hasWillDrop }); const treeView = this.createExtHostTreeView(viewId, options, extension); return { get onDidCollapseElement() { return treeView.onDidCollapseElement; }, @@ -129,7 +132,8 @@ export class ExtHostTreeViews implements ExtHostTreeViewsShape { return treeView.getChildren(treeItemHandle); } - async $onDrop(destinationViewId: string, treeDataTransferDTO: TreeDataTransferDTO, newParentItemHandle: string, sourceViewId?: string, sourceTreeItemHandles?: string[]): Promise { + async $onDrop(destinationViewId: string, treeDataTransferDTO: TreeDataTransferDTO, newParentItemHandle: string, + operationUuid?: string, sourceViewId?: string, sourceTreeItemHandles?: string[]): Promise { const treeView = this.treeViews.get(destinationViewId); if (!treeView) { return Promise.reject(new Error(localize('treeView.notRegistered', 'No tree view with id \'{0}\' registered.', destinationViewId))); @@ -137,18 +141,46 @@ export class ExtHostTreeViews implements ExtHostTreeViewsShape { const treeDataTransfer = TreeDataTransferConverter.toITreeDataTransfer(treeDataTransferDTO); if ((sourceViewId === destinationViewId) && sourceTreeItemHandles) { - const additionalTransferItems = await treeView.onWillDrop(sourceTreeItemHandles); - if (additionalTransferItems) { - additionalTransferItems.forEach((value, key) => { - if (value) { - treeDataTransfer.set(key, value); - } - }); - } + await this.addAdditionalTransferItems(treeDataTransfer, treeView, sourceTreeItemHandles, operationUuid); } return treeView.onDrop(treeDataTransfer, newParentItemHandle); } + private async addAdditionalTransferItems(treeDataTransfer: ITreeDataTransfer, treeView: ExtHostTreeView, + sourceTreeItemHandles: string[], operationUuid?: string): Promise { + const existingTransferOperation = this.treeDragAndDropService.removeDragOperationTransfer(operationUuid); + let additionalTransferItems: vscode.TreeDataTransfer | undefined; + if (existingTransferOperation) { + additionalTransferItems = await existingTransferOperation; + } else if (operationUuid && treeView.onWillDrop) { + const willDropPromise = treeView.onWillDrop(sourceTreeItemHandles); + this.treeDragAndDropService.addDragOperationTransfer(operationUuid, willDropPromise); + additionalTransferItems = await willDropPromise; + } + if (additionalTransferItems) { + additionalTransferItems.forEach((value, key) => { + if (value) { + treeDataTransfer.set(key, value); + } + }); + } + return treeDataTransfer; + } + + async $onWillDrop(sourceViewId: string, sourceTreeItemHandles: string[], operationUuid: string): Promise { + const treeView = this.treeViews.get(sourceViewId); + if (!treeView) { + return Promise.reject(new Error(localize('treeView.notRegistered', 'No tree view with id \'{0}\' registered.', sourceViewId))); + } + + const treeDataTransfer = await this.addAdditionalTransferItems(new Map(), treeView, sourceTreeItemHandles, operationUuid); + if (!treeDataTransfer) { + return; + } + + return TreeDataTransferConverter.toTreeDataTransferDTO(treeDataTransfer); + } + async $hasResolve(treeViewId: string): Promise { const treeView = this.treeViews.get(treeViewId); if (!treeView) { @@ -418,6 +450,10 @@ class ExtHostTreeView extends Disposable { return this.dndController.onWillDrop(extensionTreeItems); } + get hasOnWillDrop(): boolean { + return !!this.dndController?.onWillDrop; + } + async onDrop(treeDataTransfer: vscode.TreeDataTransfer, targetHandleOrNode: TreeItemHandle): Promise { const target = this.getExtensionElement(targetHandleOrNode); if (!target) { diff --git a/src/vs/workbench/browser/parts/views/treeView.ts b/src/vs/workbench/browser/parts/views/treeView.ts index 2da83d7dc2b..0128ad7d9bb 100644 --- a/src/vs/workbench/browser/parts/views/treeView.ts +++ b/src/vs/workbench/browser/parts/views/treeView.ts @@ -59,6 +59,8 @@ import { isPromiseCanceledError } from 'vs/base/common/errors'; import { ElementsDragAndDropData } from 'vs/base/browser/ui/list/listView'; import { CodeDataTransfers, fillEditorsDragData } from 'vs/workbench/browser/dnd'; import { Schemas } from 'vs/base/common/network'; +import { ITreeViewsDragAndDropService } from 'vs/workbench/services/views/common/treeViewsDragAndDropService'; +import { generateUuid } from 'vs/base/common/uuid'; export class TreeViewPane extends ViewPane { @@ -1234,12 +1236,15 @@ interface TreeDragSourceInfo { itemHandles: string[]; } +const TREE_DRAG_UUID_MIME = 'tree-dnd'; + export class CustomTreeViewDragAndDrop implements ITreeDragAndDrop { private readonly treeMimeType: string; constructor( private readonly treeId: string, @ILabelService private readonly labelService: ILabelService, - @IInstantiationService private readonly instantiationService: IInstantiationService) { + @IInstantiationService private readonly instantiationService: IInstantiationService, + @ITreeViewsDragAndDropService private readonly treeViewsDragAndDropService: ITreeViewsDragAndDropService) { this.treeMimeType = `tree/${treeId.toLowerCase()}`; } @@ -1248,6 +1253,15 @@ export class CustomTreeViewDragAndDrop implements ITreeDragAndDrop { this.dndController = controller; } + private addExtensionProvidedTransferTypes(originalEvent: DragEvent, itemHandles: string[]) { + if (!originalEvent.dataTransfer || !this.dndController) { + return; + } + const uuid = generateUuid(); + this.treeViewsDragAndDropService.addDragOperationTransfer(uuid, this.dndController.onWillDrop(itemHandles, uuid)); + originalEvent.dataTransfer.setData(TREE_DRAG_UUID_MIME, uuid); + } + private addResourceInfoToTransfer(originalEvent: DragEvent, resources: URI[]) { if (resources.length && originalEvent.dataTransfer) { // Apply some datatransfer types to allow for dragging the element outside of the application @@ -1277,6 +1291,7 @@ export class CustomTreeViewDragAndDrop implements ITreeDragAndDrop { } }); this.addResourceInfoToTransfer(originalEvent, resources); + this.addExtensionProvidedTransferTypes(originalEvent, sourceInfo.itemHandles); originalEvent.dataTransfer.setData(this.treeMimeType, JSON.stringify(sourceInfo)); } @@ -1319,7 +1334,8 @@ export class CustomTreeViewDragAndDrop implements ITreeDragAndDrop { } async drop(data: IDragAndDropData, targetNode: ITreeItem | undefined, targetIndex: number | undefined, originalEvent: DragEvent): Promise { - if (!originalEvent.dataTransfer || !this.dndController || !targetNode) { + const dndController = this.dndController; + if (!originalEvent.dataTransfer || !dndController || !targetNode) { return; } const treeDataTransfer: ITreeDataTransfer = new Map(); @@ -1331,6 +1347,7 @@ export class CustomTreeViewDragAndDrop implements ITreeDragAndDrop { }, 0); let treeSourceInfo: TreeDragSourceInfo | undefined; + let willDropUuid: string | undefined; await new Promise(resolve => { function decrementStringCount() { stringCount--; @@ -1339,17 +1356,18 @@ export class CustomTreeViewDragAndDrop implements ITreeDragAndDrop { } } - const dndController = this.dndController; - if (!originalEvent.dataTransfer || !dndController || !targetNode) { + if (!originalEvent.dataTransfer || !targetNode) { return; } for (const dataItem of originalEvent.dataTransfer.items) { const type = dataItem.type; if (dataItem.kind === 'string') { - if ((type === this.treeMimeType) || (dndController.supportedMimeTypes.indexOf(type) >= 0)) { + if ((type === this.treeMimeType) || (type === TREE_DRAG_UUID_MIME) || (dndController.supportedMimeTypes.indexOf(type) >= 0)) { dataItem.getAsString(dataValue => { if (type === this.treeMimeType) { treeSourceInfo = JSON.parse(dataValue); + } else if (type === TREE_DRAG_UUID_MIME) { + willDropUuid = dataValue; } else { treeDataTransfer.set(type, { asString: () => Promise.resolve(dataValue) @@ -1363,6 +1381,19 @@ export class CustomTreeViewDragAndDrop implements ITreeDragAndDrop { } } }); - return this.dndController.onDrop(treeDataTransfer, targetNode, treeSourceInfo?.id, treeSourceInfo?.itemHandles); + + const additionalWillDropPromise = this.treeViewsDragAndDropService.removeDragOperationTransfer(willDropUuid); + if (!additionalWillDropPromise) { + return dndController.onDrop(treeDataTransfer, targetNode, willDropUuid, treeSourceInfo?.id, treeSourceInfo?.itemHandles); + } + return additionalWillDropPromise.then(additionalDataTransfer => { + if (additionalDataTransfer) { + for (const item of additionalDataTransfer.entries()) { + treeDataTransfer.set(item[0], item[1]); + } + } + return dndController.onDrop(treeDataTransfer, targetNode, willDropUuid, treeSourceInfo?.id, treeSourceInfo?.itemHandles); + }); + } } diff --git a/src/vs/workbench/common/views.ts b/src/vs/workbench/common/views.ts index aa2cf21f1ae..17dcd8a9a31 100644 --- a/src/vs/workbench/common/views.ts +++ b/src/vs/workbench/common/views.ts @@ -840,7 +840,8 @@ export interface ITreeViewDataProvider { export interface ITreeViewDragAndDropController { readonly supportedMimeTypes: string[]; - onDrop(elements: ITreeDataTransfer, target: ITreeItem, sourceTreeId?: string, sourceTreeItemHandles?: string[]): Promise; + onWillDrop(sourceTreeItemHandles: string[], operationUuid: string): Promise; + onDrop(elements: ITreeDataTransfer, target: ITreeItem, operationUuid?: string, sourceTreeId?: string, sourceTreeItemHandles?: string[]): Promise; } export interface IEditableData { diff --git a/src/vs/workbench/services/views/common/treeViewsDragAndDropService.ts b/src/vs/workbench/services/views/common/treeViewsDragAndDropService.ts new file mode 100644 index 00000000000..4ed58cd6c64 --- /dev/null +++ b/src/vs/workbench/services/views/common/treeViewsDragAndDropService.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { ITreeDataTransfer } from 'vs/workbench/common/views'; + +export const ITreeViewsDragAndDropService = createDecorator>('treeViewsDragAndDropService'); +export interface ITreeViewsDragAndDropService { + readonly _serviceBrand: undefined; + + removeDragOperationTransfer(uuid: string | undefined): Promise | undefined; + addDragOperationTransfer(uuid: string, transferPromise: Promise): void; +} + +export class TreeViewsDragAndDropService implements ITreeViewsDragAndDropService { + _serviceBrand: undefined; + private _dragOperations: Map> = new Map(); + + removeDragOperationTransfer(uuid: string | undefined): Promise | undefined { + if ((uuid && this._dragOperations.has(uuid))) { + const operation = this._dragOperations.get(uuid); + this._dragOperations.delete(uuid); + return operation; + } + return undefined; + } + + addDragOperationTransfer(uuid: string, transferPromise: Promise): void { + this._dragOperations.set(uuid, transferPromise); + } +} + +registerSingleton(ITreeViewsDragAndDropService, TreeViewsDragAndDropService); diff --git a/src/vs/workbench/test/browser/api/mainThreadTreeViews.test.ts b/src/vs/workbench/test/browser/api/mainThreadTreeViews.test.ts index 9f72491c290..25fc668da16 100644 --- a/src/vs/workbench/test/browser/api/mainThreadTreeViews.test.ts +++ b/src/vs/workbench/test/browser/api/mainThreadTreeViews.test.ts @@ -73,7 +73,7 @@ suite('MainThreadHostTreeView', function () { } drain(): any { return null; } }, new TestViewsService(), new TestNotificationService(), testExtensionService, new NullLogService()); - mainThreadTreeViews.$registerTreeViewDataProvider(testTreeViewId, { showCollapseAll: false, canSelectMany: false, dragAndDropMimeTypes: [] }); + mainThreadTreeViews.$registerTreeViewDataProvider(testTreeViewId, { showCollapseAll: false, canSelectMany: false, dragAndDropMimeTypes: [], hasWillDrop: false }); await testExtensionService.whenInstalledExtensionsRegistered(); }); diff --git a/src/vscode-dts/vscode.proposed.treeViewDragAndDrop.d.ts b/src/vscode-dts/vscode.proposed.treeViewDragAndDrop.d.ts index d05457879a3..cd2889c7cf1 100644 --- a/src/vscode-dts/vscode.proposed.treeViewDragAndDrop.d.ts +++ b/src/vscode-dts/vscode.proposed.treeViewDragAndDrop.d.ts @@ -82,21 +82,25 @@ declare module 'vscode' { export interface DragAndDropController { /** - * The mime types that this `DragAndDropController` supports. This could be well-defined, existing, mime types, + * The mime types that the `drop` method of this `DragAndDropController` supports. This could be well-defined, existing, mime types, * and also mime types defined by the extension that are returned in the `TreeDataTransfer` from `onWillDrop`. + * + * Each tree will automatically support drops from it's own `DragAndDropController`. To support drops from other trees, + * you will need to add the mime type of that tree. The mime type of a tree is of the format `tree/treeidlowercase`. */ readonly supportedMimeTypes: string[]; /** - * When the user drops an item from this DragAndDropController on **another tree item** in **the same tree**, - * `onWillDrop` will be called with the dropped tree items. This is the DragAndDropController's opportunity to - * package the data from the dropped tree item into whatever format they want the target tree item to receive. + * When the user starts dragging items from this `DragAndDropController`, `onWillDrop` will be called. + * Extensions can use `onWillDrop` to add their `TreeDataTransferItem`s to the drag and drop. + * + * When the items are dropped on **another tree item** in **the same tree**, your `TreeDataTransferItem` objects + * will be preserved. See the documentation for `TreeDataTransferItem` for how best to take advantage of this. * * The returned `TreeDataTransfer` will be merged with the original`TreeDataTransfer` for the operation. * * @param source The source items for the drag and drop operation. */ - // TODO@api I think this can be more generic, tho still constraint, e.g have something that works everywhere within VS Code onWillDrop?(source: T[]): Thenable; /**