diff --git a/packages/core/src/browser/frontend-application.ts b/packages/core/src/browser/frontend-application.ts index 3be0d1f878139..b3e1d3bc6c7fe 100644 --- a/packages/core/src/browser/frontend-application.ts +++ b/packages/core/src/browser/frontend-application.ts @@ -224,10 +224,18 @@ export class FrontendApplication { document.body.addEventListener('wheel', preventNavigation, { passive: false }); } // Prevent the default browser behavior when dragging and dropping files into the window. - window.addEventListener('dragover', event => { + document.addEventListener('dragenter', event => { + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'none'; + } event.preventDefault(); }, false); - window.addEventListener('drop', event => { + document.addEventListener('dragover', event => { + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'none'; + } event.preventDefault(); + }, false); + document.addEventListener('drop', event => { event.preventDefault(); }, false); diff --git a/packages/core/src/browser/shell/application-shell.ts b/packages/core/src/browser/shell/application-shell.ts index c4e491b041b92..a897a9a1eb471 100644 --- a/packages/core/src/browser/shell/application-shell.ts +++ b/packages/core/src/browser/shell/application-shell.ts @@ -41,6 +41,8 @@ import { Deferred } from '../../common/promise-util'; import { SaveResourceService } from '../save-resource-service'; import { nls } from '../../common/nls'; import { SecondaryWindowHandler } from '../secondary-window-handler'; +import URI from '../../common/uri'; +import { OpenerService } from '../opener-service'; /** The class name added to ApplicationShell instances. */ const APPLICATION_SHELL_CLASS = 'theia-ApplicationShell'; @@ -190,10 +192,14 @@ export class ApplicationShell extends Widget { private readonly tracker = new FocusTracker(); private dragState?: WidgetDragState; + additionalDraggedUris: URI[] | undefined; @inject(ContextKeyService) protected readonly contextKeyService: ContextKeyService; + @inject(OpenerService) + protected readonly openerService: OpenerService; + protected readonly onDidAddWidgetEmitter = new Emitter(); readonly onDidAddWidget = this.onDidAddWidgetEmitter.event; protected fireDidAddWidget(widget: Widget): void { @@ -498,9 +504,68 @@ export class ApplicationShell extends Widget { dockPanel.id = MAIN_AREA_ID; dockPanel.widgetAdded.connect((_, widget) => this.fireDidAddWidget(widget)); dockPanel.widgetRemoved.connect((_, widget) => this.fireDidRemoveWidget(widget)); + + const openUri = async (fileUri: URI) => { + try { + const opener = await this.openerService.getOpener(fileUri); + opener.open(fileUri); + } catch (e) { + console.info(`no opener found for '${fileUri}'`); + } + }; + + dockPanel.node.addEventListener('drop', event => { + if (event.dataTransfer) { + const uris = this.additionalDraggedUris || ApplicationShell.getDraggedEditorUris(event.dataTransfer); + if (uris.length > 0) { + uris.forEach(openUri); + } else if (event.dataTransfer.files?.length > 0) { + // the files were dragged from the outside the workspace + Array.from(event.dataTransfer.files).forEach(async file => { + if (file.path) { + const fileUri = URI.fromComponents({ + scheme: 'file', + path: file.path, + authority: '', + query: '', + fragment: '' + }); + openUri(fileUri); + } + }); + } + } + }); + const handler = (e: DragEvent) => { + if (e.dataTransfer) { + e.dataTransfer.dropEffect = 'link'; + e.preventDefault(); + e.stopPropagation(); + } + }; + dockPanel.node.addEventListener('dragover', handler); + dockPanel.node.addEventListener('dragenter', handler); + return dockPanel; } + addAdditionalDraggedEditorUris(uris: URI[]): void { + this.additionalDraggedUris = uris; + } + + clearAdditionalDraggedEditorUris(): void { + this.additionalDraggedUris = undefined; + } + + static getDraggedEditorUris(dataTransfer: DataTransfer): URI[] { + const data = dataTransfer.getData('theia-editor-dnd'); + return data ? data.split('\n').map(entry => new URI(entry)) : []; + } + + static setDraggedEditorUris(dataTransfer: DataTransfer, uris: URI[]): void { + dataTransfer.setData('theia-editor-dnd', uris.map(uri => uri.toString()).join('\r\n')); + } + /** * Create the dock panel in the bottom shell area. */ diff --git a/packages/filesystem/src/browser/file-tree/file-tree-widget.tsx b/packages/filesystem/src/browser/file-tree/file-tree-widget.tsx index 6d83ca0d29f84..2833ad411549a 100644 --- a/packages/filesystem/src/browser/file-tree/file-tree-widget.tsx +++ b/packages/filesystem/src/browser/file-tree/file-tree-widget.tsx @@ -25,6 +25,7 @@ import { FileUploadService } from '../file-upload-service'; import { DirNode, FileStatNode, FileStatNodeData } from './file-tree'; import { FileTreeModel } from './file-tree-model'; import { IconThemeService } from '@theia/core/lib/browser/icon-theme-service'; +import { ApplicationShell } from '@theia/core/lib/browser/shell'; import { FileStat, FileType } from '../../common/files'; import { isOSX } from '@theia/core'; @@ -119,14 +120,18 @@ export class FileTreeWidget extends CompressedTreeWidget { protected handleDragStartEvent(node: TreeNode, event: React.DragEvent): void { event.stopPropagation(); - let selectedNodes; - if (this.model.selectedNodes.find(selected => TreeNode.equals(selected, node))) { - selectedNodes = [...this.model.selectedNodes]; - } else { - selectedNodes = [node]; - } - this.setSelectedTreeNodesAsData(event.dataTransfer, node, selectedNodes); if (event.dataTransfer) { + let selectedNodes; + if (this.model.selectedNodes.find(selected => TreeNode.equals(selected, node))) { + selectedNodes = [...this.model.selectedNodes]; + } else { + selectedNodes = [node]; + } + this.setSelectedTreeNodesAsData(event.dataTransfer, node, selectedNodes); + const uris = selectedNodes.filter(n => FileStatNode.is(n)).map(n => (n as FileStatNode).fileStat.resource); + if (uris.length > 0) { + ApplicationShell.setDraggedEditorUris(event.dataTransfer, uris); + } let label: string; if (selectedNodes.length === 1) { label = this.toNodeName(node); diff --git a/packages/navigator/src/browser/navigator-widget.tsx b/packages/navigator/src/browser/navigator-widget.tsx index 405a7e6233b95..eec62679f8d16 100644 --- a/packages/navigator/src/browser/navigator-widget.tsx +++ b/packages/navigator/src/browser/navigator-widget.tsx @@ -18,16 +18,14 @@ import { injectable, inject, postConstruct } from '@theia/core/shared/inversify' import { Message } from '@theia/core/shared/@phosphor/messaging'; import URI from '@theia/core/lib/common/uri'; import { CommandService } from '@theia/core/lib/common'; -import { Key, TreeModel, SelectableTreeNode, OpenerService, ContextMenuRenderer, ExpandableTreeNode, TreeProps, TreeNode } from '@theia/core/lib/browser'; -import { FileNode, DirNode } from '@theia/filesystem/lib/browser'; +import { Key, TreeModel, ContextMenuRenderer, ExpandableTreeNode, TreeProps, TreeNode } from '@theia/core/lib/browser'; +import { DirNode } from '@theia/filesystem/lib/browser'; import { WorkspaceService, WorkspaceCommands } from '@theia/workspace/lib/browser'; -import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell'; import { WorkspaceNode, WorkspaceRootNode } from './navigator-tree'; import { FileNavigatorModel } from './navigator-model'; import { isOSX, environment } from '@theia/core'; import * as React from '@theia/core/shared/react'; import { NavigatorContextKeyService } from './navigator-context-key-service'; -import { FileNavigatorCommands } from './file-navigator-commands'; import { nls } from '@theia/core/lib/common/nls'; import { AbstractNavigatorTreeWidget } from './abstract-navigator-tree-widget'; @@ -38,10 +36,8 @@ export const CLASS = 'theia-Files'; @injectable() export class FileNavigatorWidget extends AbstractNavigatorTreeWidget { - @inject(ApplicationShell) protected readonly shell: ApplicationShell; @inject(CommandService) protected readonly commandService: CommandService; @inject(NavigatorContextKeyService) protected readonly contextKeyService: NavigatorContextKeyService; - @inject(OpenerService) protected readonly openerService: OpenerService; @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; constructor( @@ -97,36 +93,6 @@ export class FileNavigatorWidget extends AbstractNavigatorTreeWidget { } } - protected enableDndOnMainPanel(): void { - const mainPanelNode = this.shell.mainPanel.node; - this.addEventListener(mainPanelNode, 'drop', async ({ dataTransfer }) => { - const treeNodes = dataTransfer && this.getSelectedTreeNodesFromData(dataTransfer) || []; - if (treeNodes.length > 0) { - treeNodes.filter(FileNode.is).forEach(treeNode => { - if (!SelectableTreeNode.isSelected(treeNode)) { - this.model.toggleNode(treeNode); - } - }); - this.commandService.executeCommand(FileNavigatorCommands.OPEN.id); - } else if (dataTransfer && dataTransfer.files?.length > 0) { - // the files were dragged from the outside the workspace - Array.from(dataTransfer.files).forEach(async file => { - const fileUri = new URI(file.path); - const opener = await this.openerService.getOpener(fileUri); - opener.open(fileUri); - }); - } - }); - const handler = (e: DragEvent) => { - if (e.dataTransfer) { - e.dataTransfer.dropEffect = 'link'; - e.preventDefault(); - } - }; - this.addEventListener(mainPanelNode, 'dragover', handler); - this.addEventListener(mainPanelNode, 'dragenter', handler); - } - override getContainerTreeNode(): TreeNode | undefined { const root = this.model.root; if (this.workspaceService.isMultiRootWorkspaceOpened) { @@ -153,7 +119,6 @@ export class FileNavigatorWidget extends AbstractNavigatorTreeWidget { super.onAfterAttach(msg); this.addClipboardListener(this.node, 'copy', e => this.handleCopy(e)); this.addClipboardListener(this.node, 'paste', e => this.handlePaste(e)); - this.enableDndOnMainPanel(); } protected handleCopy(event: ClipboardEvent): void { diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts index 6f8ec9fd68506..6055c1ad91988 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts @@ -726,7 +726,8 @@ export interface TreeViewRevealOptions { } export interface TreeViewsMain { - $registerTreeDataProvider(treeViewId: string): void; + $registerTreeDataProvider(treeViewId: string, dragMimetypes: string[] | undefined, dropMimetypes: string[] | undefined): void; + $readDroppedFile(contentId: string): Promise; $unregisterTreeDataProvider(treeViewId: string): void; $refresh(treeViewId: string): Promise; $reveal(treeViewId: string, elementParentChain: string[], options: TreeViewRevealOptions): Promise; @@ -734,8 +735,17 @@ export interface TreeViewsMain { $setTitle(treeViewId: string, title: string): void; $setDescription(treeViewId: string, description: string): void; } +export class DataTransferFileDTO { + constructor(readonly name: string, readonly contentId: string, readonly uri?: UriComponents) { } + + static is(value: string | DataTransferFileDTO): value is DataTransferFileDTO { + return !(typeof value === 'string'); + } +} export interface TreeViewsExt { + $dragStarted(treeViewId: string, treeItemIds: string[], token: CancellationToken): Promise; + $drop(treeViewId: string, treeItemId: string, dataTransferItems: [string, string | DataTransferFileDTO][], token: CancellationToken): Promise; $getChildren(treeViewId: string, treeItemId: string | undefined): Promise; $hasResolveTreeItem(treeViewId: string): Promise; $resolveTreeItem(treeViewId: string, treeItemId: string, token: CancellationToken): Promise; diff --git a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts index 024cb87870599..2e325179fb8d4 100644 --- a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts +++ b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts @@ -50,7 +50,7 @@ import { SelectionProviderCommandContribution } from './selection-provider-comma import { ViewColumnService } from './view-column-service'; import { ViewContextKeyService } from './view/view-context-key-service'; import { PluginViewWidget, PluginViewWidgetIdentifier } from './view/plugin-view-widget'; -import { TreeViewWidgetIdentifier, VIEW_ITEM_CONTEXT_MENU, PluginTree, TreeViewWidget, PluginTreeModel } from './view/tree-view-widget'; +import { TreeViewWidgetOptions, VIEW_ITEM_CONTEXT_MENU, PluginTree, TreeViewWidget, PluginTreeModel } from './view/tree-view-widget'; import { RPCProtocol } from '../../common/rpc-protocol'; import { LanguagesMainFactory, OutputChannelRegistryFactory } from '../../common'; import { LanguagesMainImpl } from './languages-main'; @@ -80,6 +80,7 @@ import { bindTreeViewDecoratorUtilities, TreeViewDecoratorService } from './view import { CodeEditorWidgetUtil } from './menus/vscode-theia-menu-mappings'; import { PluginMenuCommandAdapter } from './menus/plugin-menu-command-adapter'; import './theme-icon-override'; +import { DnDFileContentStore } from './view/dnd-file-content-store'; export default new ContainerModule((bind, unbind, isBound, rebind) => { @@ -143,9 +144,10 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bindTreeViewDecoratorUtilities(bind); bind(PluginTreeViewNodeLabelProvider).toSelf().inSingletonScope(); bind(LabelProviderContribution).toService(PluginTreeViewNodeLabelProvider); + bind(DnDFileContentStore).toSelf().inSingletonScope(); bind(WidgetFactory).toDynamicValue(({ container }) => ({ id: PLUGIN_VIEW_DATA_FACTORY_ID, - createWidget: (identifier: TreeViewWidgetIdentifier) => { + createWidget: (identifier: TreeViewWidgetOptions) => { const props = { contextMenuPath: VIEW_ITEM_CONTEXT_MENU, expandOnlyOnExpansionToggleClick: true, @@ -161,7 +163,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { widget: TreeViewWidget, decoratorService: TreeViewDecoratorService }); - child.bind(TreeViewWidgetIdentifier).toConstantValue(identifier); + child.bind(TreeViewWidgetOptions).toConstantValue(identifier); return child.get(TreeWidget); } })).inSingletonScope(); diff --git a/packages/plugin-ext/src/main/browser/view/dnd-file-content-store.ts b/packages/plugin-ext/src/main/browser/view/dnd-file-content-store.ts new file mode 100644 index 0000000000000..11b92ced0f734 --- /dev/null +++ b/packages/plugin-ext/src/main/browser/view/dnd-file-content-store.ts @@ -0,0 +1,41 @@ +// ***************************************************************************** +// Copyright (C) 2022 ST Microelectronics and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { injectable } from '@theia/core/shared/inversify'; + +@injectable() +export class DnDFileContentStore { + static id: number = 0; + private files: Map = new Map(); + addFile(f: File): string { + const id = (DnDFileContentStore.id++).toString(); + this.files.set(id, f); + return id; + } + + removeFile(id: string): boolean { + return this.files.delete(id); + } + + getFile(id: string): File { + const file = this.files.get(id); + if (file) { + return file; + } + + throw new Error(`File with id ${id} not found in dnd operation`); + } +} diff --git a/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx b/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx index 9355365f4047f..33450ce0d528e 100644 --- a/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx +++ b/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx @@ -14,9 +14,8 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** -import { URI } from '@theia/core/shared/vscode-uri'; import { injectable, inject, postConstruct } from '@theia/core/shared/inversify'; -import { TreeViewsExt, TreeViewItemCollapsibleState, TreeViewItem, TreeViewSelection, ThemeIcon } from '../../../common/plugin-api-rpc'; +import { TreeViewsExt, TreeViewItemCollapsibleState, TreeViewItem, TreeViewSelection, ThemeIcon, DataTransferFileDTO } from '../../../common/plugin-api-rpc'; import { Command } from '../../../common/plugin-api-rpc-model'; import { TreeNode, @@ -32,7 +31,8 @@ import { TreeViewWelcomeWidget, TooltipAttributes, TreeSelection, - HoverService + HoverService, + ApplicationShell } from '@theia/core/lib/browser'; import { MenuPath, MenuModelRegistry, ActionMenuNode } from '@theia/core/lib/common/menu'; import * as React from '@theia/core/shared/react'; @@ -41,7 +41,7 @@ import { ACTION_ITEM, Widget } from '@theia/core/lib/browser/widgets/widget'; import { Emitter, Event } from '@theia/core/lib/common/event'; import { MessageService } from '@theia/core/lib/common/message-service'; import { View } from '../../../common/plugin-protocol'; -import CoreURI from '@theia/core/lib/common/uri'; +import CoreURI, { URI } from '@theia/core/lib/common/uri'; import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; import { MarkdownString } from '@theia/core/lib/common/markdown-rendering'; import { LabelParser } from '@theia/core/lib/browser/label-parser'; @@ -52,6 +52,7 @@ import { WidgetDecoration } from '@theia/core/lib/browser/widget-decoration'; import { CancellationTokenSource, CancellationToken } from '@theia/core/lib/common'; import { mixin } from '../../../common/types'; import { Deferred } from '@theia/core/lib/common/promise-util'; +import { DnDFileContentStore } from './dnd-file-content-store'; export const TREE_NODE_HYPERLINK = 'theia-TreeNodeHyperlink'; export const VIEW_ITEM_CONTEXT_MENU: MenuPath = ['view-item-context-menu']; @@ -161,8 +162,10 @@ export namespace CompositeTreeViewNode { } @injectable() -export class TreeViewWidgetIdentifier { +export class TreeViewWidgetOptions { id: string; + dragMimeTypes: string[] | undefined; + dropMimeTypes: string[] | undefined; } @injectable() @@ -171,8 +174,8 @@ export class PluginTree extends TreeImpl { @inject(PluginSharedStyle) protected readonly sharedStyle: PluginSharedStyle; - @inject(TreeViewWidgetIdentifier) - protected readonly identifier: TreeViewWidgetIdentifier; + @inject(TreeViewWidgetOptions) + protected readonly options: TreeViewWidgetOptions; @inject(MessageService) protected readonly notification: MessageService; @@ -188,7 +191,7 @@ export class PluginTree extends TreeImpl { set proxy(proxy: TreeViewsExt | undefined) { this._proxy = proxy; if (proxy) { - this._hasTreeItemResolve = proxy.$hasResolveTreeItem(this.identifier.id); + this._hasTreeItemResolve = proxy.$hasResolveTreeItem(this.options.id); } else { this._hasTreeItemResolve = Promise.resolve(false); } @@ -220,7 +223,7 @@ export class PluginTree extends TreeImpl { protected async fetchChildren(proxy: TreeViewsExt, parent: CompositeTreeNode): Promise { try { - const children = await proxy.$getChildren(this.identifier.id, parent.id); + const children = await proxy.$getChildren(this.options.id, parent.id); const oldEmpty = this._isEmpty; this._isEmpty = !parent.id && (!children || children.length === 0); if (oldEmpty !== this._isEmpty) { @@ -229,8 +232,8 @@ export class PluginTree extends TreeImpl { return children || []; } catch (e) { if (e) { - console.error(`Failed to fetch children for '${this.identifier.id}'`, e); - const label = this._viewInfo ? this._viewInfo.name : this.identifier.id; + console.error(`Failed to fetch children for '${this.options.id}'`, e); + const label = this._viewInfo ? this._viewInfo.name : this.options.id; this.notification.error(`${label}: ${e.message}`); } return []; @@ -288,7 +291,7 @@ export class PluginTree extends TreeImpl { children: [], command: item.command }, update); - return new ResolvableCompositeTreeViewNode(compositeNode, async (token: CancellationToken) => this._proxy?.$resolveTreeItem(this.identifier.id, item.id, token)); + return new ResolvableCompositeTreeViewNode(compositeNode, async (token: CancellationToken) => this._proxy?.$resolveTreeItem(this.options.id, item.id, token)); } // Node is a leaf @@ -304,13 +307,13 @@ export class PluginTree extends TreeImpl { selected: false, command: item.command, }, update); - return new ResolvableTreeViewNode(treeNode, async (token: CancellationToken) => this._proxy?.$resolveTreeItem(this.identifier.id, item.id, token)); + return new ResolvableTreeViewNode(treeNode, async (token: CancellationToken) => this._proxy?.$resolveTreeItem(this.options.id, item.id, token)); } protected createTreeNodeUpdate(item: TreeViewItem): Partial { const decorationData = this.toDecorationData(item); const icon = this.toIconClass(item); - const resourceUri = item.resourceUri && URI.revive(item.resourceUri).toString(); + const resourceUri = item.resourceUri && URI.fromComponents(item.resourceUri).toString(); const themeIcon = item.themeIcon ? item.themeIcon : item.collapsibleState !== TreeViewItemCollapsibleState.None ? { id: 'folder' } : undefined; return { name: item.label, @@ -393,14 +396,17 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { protected _contextSelection = false; + @inject(ApplicationShell) + protected readonly applicationShell: ApplicationShell; + @inject(MenuModelRegistry) protected readonly menus: MenuModelRegistry; @inject(ContextKeyService) protected readonly contextKeys: ContextKeyService; - @inject(TreeViewWidgetIdentifier) - readonly identifier: TreeViewWidgetIdentifier; + @inject(TreeViewWidgetOptions) + readonly options: TreeViewWidgetOptions; @inject(PluginTreeModel) override readonly model: PluginTreeModel; @@ -417,16 +423,22 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { @inject(ColorRegistry) protected readonly colorRegistry: ColorRegistry; + @inject(DnDFileContentStore) + protected readonly dndFileContentStore: DnDFileContentStore; + + protected treeDragType: string; + @postConstruct() protected override init(): void { super.init(); - this.id = this.identifier.id; + this.id = this.options.id; this.addClass('theia-tree-view'); this.node.style.height = '100%'; this.model.onDidChangeWelcomeState(this.update, this); this.toDispose.push(this.model.onDidChangeWelcomeState(this.update, this)); this.toDispose.push(this.onDidChangeVisibilityEmitter); this.toDispose.push(this.contextKeyService.onDidChange(() => this.update())); + this.treeDragType = `application/vnd.code.tree.${this.id.toLowerCase()}`; } protected override renderIcon(node: TreeNode, props: NodeProps): React.ReactNode { @@ -546,6 +558,115 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { return
{...children}
; } + protected override createNodeAttributes(node: TreeViewNode, props: NodeProps): React.Attributes & React.HTMLAttributes { + const attrs = super.createNodeAttributes(node, props); + + if (this.options.dragMimeTypes) { + attrs.onDragStart = event => this.handleDragStartEvent(node, event); + attrs.onDragEnd = event => this.handleDragEnd(node, event); + attrs.draggable = true; + } + + if (this.options.dropMimeTypes) { + attrs.onDrop = event => this.handleDropEvent(node, event); + attrs.onDragEnter = event => this.handleDragEnterOver(node, event); + attrs.onDragOver = event => this.handleDragEnterOver(node, event); + } + + return attrs; + } + + protected handleDragStartEvent(node: TreeViewNode, event: React.DragEvent): void { + event.dataTransfer!.setData(this.treeDragType, ''); + let selectedNodes: TreeViewNode[] = []; + if (this.model.selectedNodes.find(selected => TreeNode.equals(selected, node))) { + selectedNodes = this.model.selectedNodes.filter(TreeViewNode.is); + } else { + selectedNodes = [node]; + } + + this.options.dragMimeTypes!.forEach(type => { + if (type === 'text/uri-list') { + ApplicationShell.setDraggedEditorUris(event.dataTransfer, selectedNodes.filter(n => n.resourceUri).map(n => new URI(n.resourceUri))); + } else { + event.dataTransfer.setData(type, ''); + } + }); + + this.model.proxy!.$dragStarted(this.options.id, selectedNodes.map(selected => selected.id), CancellationToken.None).then(maybeUris => { + if (maybeUris) { + this.applicationShell.addAdditionalDraggedEditorUris(maybeUris.map(CoreURI.fromComponents)); + } + }); + } + + handleDragEnd(node: TreeViewNode, event: React.DragEvent): void { + this.applicationShell.clearAdditionalDraggedEditorUris(); + } + + handleDragEnterOver(node: TreeViewNode, event: React.DragEvent): void { + if (event.dataTransfer) { + const canDrop = event.dataTransfer.types.some(type => event.dataTransfer.types.indexOf(type) >= 0) || + event.dataTransfer.types.indexOf(this.treeDragType) > 0 || + this.options.dropMimeTypes!.indexOf('files') > 0 && event.dataTransfer.files.length > 0; + if (canDrop) { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + } else { + event.dataTransfer.dropEffect = 'none'; + } + event.stopPropagation(); + } + } + + protected handleDropEvent(node: TreeViewNode, event: React.DragEvent): void { + if (event.dataTransfer) { + const items: [string, string | DataTransferFileDTO][] = []; + let files: string[] = []; + try { + for (let i = 0; i < event.dataTransfer.items.length; i++) { + const transferItem = event.dataTransfer.items[i]; + if (transferItem.type !== this.treeDragType) { + // do not pass the artificial drag data to the extension + const f = event.dataTransfer.items[i].getAsFile(); + if (f) { + const fileId = this.dndFileContentStore.addFile(f); + files.push(fileId); + const uri = f.path ? { + scheme: 'file', + path: f.path, + authority: '', + query: '', + fragment: '' + } : undefined; + items.push([transferItem.type, new DataTransferFileDTO(f.name, fileId, uri)]); + } else { + const textData = event.dataTransfer.getData(transferItem.type); + if (textData) { + items.push([transferItem.type, textData]); + } + } + } + } + if (items.length > 0 || event.dataTransfer.types.indexOf(this.treeDragType) >= 0) { + event.preventDefault(); + event.stopPropagation(); + const filesCopy = [...files]; + this.model.proxy?.$drop(this.id, node.id, items, CancellationToken.None).then(() => { + for (const file of filesCopy) { + this.dndFileContentStore.removeFile(file); + } + }); + files = []; + } + } finally { + for (const file of files) { + this.dndFileContentStore.removeFile(file); + } + } + } + } + protected override renderTailDecorations(node: TreeViewNode, props: NodeProps): React.ReactNode { return this.contextKeys.with({ view: this.id, viewItem: node.contextValue }, () => { const menu = this.menus.getMenu(VIEW_ITEM_INLINE_MENU); @@ -688,7 +809,7 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { const args = this.toContextMenuArgs(node); const contextKeyService = this.contextKeyService.createOverlay([ ['viewItem', (TreeViewNode.is(node) && node.contextValue) || undefined], - ['view', this.identifier.id] + ['view', this.options.id] ]); setTimeout(() => this.contextMenuRenderer.render({ menuPath: contextMenuPath, diff --git a/packages/plugin-ext/src/main/browser/view/tree-views-main.ts b/packages/plugin-ext/src/main/browser/view/tree-views-main.ts index fcfbec0f1d6c2..b951d2deb0052 100644 --- a/packages/plugin-ext/src/main/browser/view/tree-views-main.ts +++ b/packages/plugin-ext/src/main/browser/view/tree-views-main.ts @@ -26,8 +26,10 @@ import { } from '@theia/core/lib/browser'; import { ViewContextKeyService } from './view-context-key-service'; import { Disposable, DisposableCollection } from '@theia/core'; -import { TreeViewWidget, TreeViewNode, PluginTreeModel } from './tree-view-widget'; +import { TreeViewWidget, TreeViewNode, PluginTreeModel, TreeViewWidgetOptions } from './tree-view-widget'; import { PluginViewWidget } from './plugin-view-widget'; +import { BinaryBuffer } from '@theia/core/lib/common/buffer'; +import { DnDFileContentStore } from './dnd-file-content-store'; export class TreeViewsMainImpl implements TreeViewsMain, Disposable { @@ -35,6 +37,7 @@ export class TreeViewsMainImpl implements TreeViewsMain, Disposable { private readonly viewRegistry: PluginViewRegistry; private readonly contextKeys: ViewContextKeyService; private readonly widgetManager: WidgetManager; + private readonly fileContentStore: DnDFileContentStore; private readonly treeViewProviders = new Map(); @@ -48,15 +51,22 @@ export class TreeViewsMainImpl implements TreeViewsMain, Disposable { this.contextKeys = this.container.get(ViewContextKeyService); this.widgetManager = this.container.get(WidgetManager); + this.fileContentStore = this.container.get(DnDFileContentStore); } dispose(): void { this.toDispose.dispose(); } - async $registerTreeDataProvider(treeViewId: string): Promise { + async $registerTreeDataProvider(treeViewId: string, dragMimetypes: string[] | undefined, dropMimetypes: string[] | undefined): Promise { this.treeViewProviders.set(treeViewId, this.viewRegistry.registerViewDataProvider(treeViewId, async ({ state, viewInfo }) => { - const widget = await this.widgetManager.getOrCreateWidget(PLUGIN_VIEW_DATA_FACTORY_ID, { id: treeViewId }); + const options: TreeViewWidgetOptions = { + id: treeViewId, + dragMimeTypes: dragMimetypes, + dropMimeTypes: dropMimetypes + }; + + const widget = await this.widgetManager.getOrCreateWidget(PLUGIN_VIEW_DATA_FACTORY_ID, options); widget.model.viewInfo = viewInfo; if (state) { widget.restoreState(state); @@ -94,6 +104,12 @@ export class TreeViewsMainImpl implements TreeViewsMain, Disposable { } } + async $readDroppedFile(contentId: string): Promise { + const file = this.fileContentStore.getFile(contentId); + const buffer = await file.arrayBuffer(); + return BinaryBuffer.wrap(new Uint8Array(buffer)); + } + async $refresh(treeViewId: string): Promise { const viewPanel = await this.viewRegistry.getView(treeViewId); const widget = viewPanel && viewPanel.widgets[0]; diff --git a/packages/plugin-ext/src/plugin/plugin-context.ts b/packages/plugin-ext/src/plugin/plugin-context.ts index 260aaf08ac1d3..24a420a3bae30 100644 --- a/packages/plugin-ext/src/plugin/plugin-context.ts +++ b/packages/plugin-ext/src/plugin/plugin-context.ts @@ -92,6 +92,8 @@ import { CodeActionTriggerKind, TextDocumentSaveReason, CodeAction, + DataTransferItem, + DataTransfer, TreeItem, TreeItemCollapsibleState, DocumentSymbol, @@ -510,7 +512,7 @@ export function createAPIFactory( registerTreeDataProvider(viewId: string, treeDataProvider: theia.TreeDataProvider): Disposable { return treeViewsExt.registerTreeDataProvider(plugin, viewId, treeDataProvider); }, - createTreeView(viewId: string, options: { treeDataProvider: theia.TreeDataProvider }): theia.TreeView { + createTreeView(viewId: string, options: theia.TreeViewOptions): theia.TreeView { return treeViewsExt.createTreeView(plugin, viewId, options); }, withScmProgress(task: (progress: theia.Progress) => Thenable) { @@ -1075,7 +1077,7 @@ export function createAPIFactory( notebook: theia.NotebookDocument, controller: theia.NotebookController ): (void | Thenable) { }, - onDidChangeSelectedNotebooks: () => Disposable.create(() => {}), + onDidChangeSelectedNotebooks: () => Disposable.create(() => { }), updateNotebookAffinity: (notebook: theia.NotebookDocument, affinity: theia.NotebookControllerAffinity) => undefined, dispose: () => undefined, }; @@ -1086,7 +1088,7 @@ export function createAPIFactory( ) { return { rendererId, - onDidReceiveMessage: () => Disposable.create(() => {} ), + onDidReceiveMessage: () => Disposable.create(() => { }), postMessage: () => Promise.resolve({}), }; }, @@ -1179,6 +1181,8 @@ export function createAPIFactory( CodeActionTriggerKind, TextDocumentSaveReason, CodeAction, + DataTransferItem, + DataTransfer, TreeItem, TreeItemCollapsibleState, SymbolKind, diff --git a/packages/plugin-ext/src/plugin/tree/tree-views.ts b/packages/plugin-ext/src/plugin/tree/tree-views.ts index 9e4954ccf09ef..849b6b8559670 100644 --- a/packages/plugin-ext/src/plugin/tree/tree-views.ts +++ b/packages/plugin-ext/src/plugin/tree/tree-views.ts @@ -18,18 +18,20 @@ import { TreeDataProvider, TreeView, TreeViewExpansionEvent, TreeItem, TreeItemLabel, - TreeViewSelectionChangeEvent, TreeViewVisibilityChangeEvent, CancellationToken + TreeViewSelectionChangeEvent, TreeViewVisibilityChangeEvent, CancellationToken, TreeDragAndDropController, DataTransferFile } from '@theia/plugin'; // TODO: extract `@theia/util` for event, disposable, cancellation and common types // don't use @theia/core directly from plugin host import { Emitter } from '@theia/core/lib/common/event'; import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; -import { Disposable as PluginDisposable, ThemeIcon } from '../types-impl'; -import { Plugin, PLUGIN_RPC_CONTEXT, TreeViewsExt, TreeViewsMain, TreeViewItem, TreeViewRevealOptions } from '../../common/plugin-api-rpc'; +import { DataTransfer, DataTransferItem, Disposable as PluginDisposable, ThemeIcon } from '../types-impl'; +import { Plugin, PLUGIN_RPC_CONTEXT, TreeViewsExt, TreeViewsMain, TreeViewItem, TreeViewRevealOptions, DataTransferFileDTO } from '../../common/plugin-api-rpc'; import { RPCProtocol } from '../../common/rpc-protocol'; import { CommandRegistryImpl, CommandsConverter } from '../command-registry'; import { TreeViewSelection } from '../../common'; import { PluginIconPath } from '../plugin-icon-path'; +import { URI } from '@theia/core/shared/vscode-uri'; +import { UriComponents } from '@theia/core/lib/common/uri'; export class TreeViewsExtImpl implements TreeViewsExt { @@ -50,6 +52,13 @@ export class TreeViewsExtImpl implements TreeViewsExt { } }); } + $dragStarted(treeViewId: string, treeItemIds: string[], token: CancellationToken): Promise { + return this.getTreeView(treeViewId).onDragStarted(treeItemIds, token); + } + + $drop(treeViewId: string, treeItemId: string, dataTransferItems: [string, string | DataTransferFileDTO][], token: CancellationToken): Promise { + return this.getTreeView(treeViewId).handleDrop!(treeItemId, dataTransferItems, token); + } registerTreeDataProvider(plugin: Plugin, treeViewId: string, treeDataProvider: TreeDataProvider): PluginDisposable { const treeView = this.createTreeView(plugin, treeViewId, { treeDataProvider }); @@ -60,12 +69,12 @@ export class TreeViewsExtImpl implements TreeViewsExt { }); } - createTreeView(plugin: Plugin, treeViewId: string, options: { treeDataProvider: TreeDataProvider }): TreeView { + createTreeView(plugin: Plugin, treeViewId: string, options: { treeDataProvider: TreeDataProvider, dragAndDropController?: TreeDragAndDropController }): TreeView { if (!options || !options.treeDataProvider) { throw new Error('Options with treeDataProvider is mandatory'); } - const treeView = new TreeViewExtImpl(plugin, treeViewId, options.treeDataProvider, this.proxy, this.commandRegistry.converter); + const treeView = new TreeViewExtImpl(plugin, treeViewId, options.treeDataProvider, options.dragAndDropController, this.proxy, this.commandRegistry.converter); this.treeViews.set(treeViewId, treeView); return { @@ -187,6 +196,8 @@ class TreeViewExtImpl implements Disposable { private readonly nodes = new Map>(); private pendingRefresh = Promise.resolve(); + private localDataTransfer: DataTransfer = new DataTransfer(); + private readonly toDispose = new DisposableCollection( Disposable.create(() => this.clearAll()), this.onDidExpandElementEmitter, @@ -199,10 +210,15 @@ class TreeViewExtImpl implements Disposable { private plugin: Plugin, private treeViewId: string, private treeDataProvider: TreeDataProvider, + private dragAndDropController: TreeDragAndDropController | undefined, private proxy: TreeViewsMain, readonly commandsConverter: CommandsConverter) { - proxy.$registerTreeDataProvider(treeViewId); + const dragTypes = dragAndDropController?.dragMimeTypes ? [...dragAndDropController.dragMimeTypes] : undefined; + const dropTypes = dragAndDropController?.dropMimeTypes ? [...dragAndDropController.dropMimeTypes] : undefined; + + proxy.$registerTreeDataProvider(treeViewId, dragTypes, dropTypes); + this.toDispose.push(Disposable.create(() => this.proxy.$unregisterTreeDataProvider(treeViewId))); if (treeDataProvider.onDidChangeTreeData) { @@ -535,4 +551,56 @@ class TreeViewExtImpl implements Disposable { } } + async onDragStarted(treeItemIds: string[], token: CancellationToken): Promise { + const treeItems: T[] = []; + for (const id of treeItemIds) { + const item = this.getTreeItem(id); + if (item) { + treeItems.push(item); + } + } + if (this.dragAndDropController && this.dragAndDropController.handleDrag) { + this.localDataTransfer.clear(); + await this.dragAndDropController.handleDrag(treeItems, this.localDataTransfer, token); + const uriList = await this.localDataTransfer.get('text/uri-list')?.asString(); + if (uriList) { + return uriList.split('\n').map(str => URI.parse(str)); + } + } + return undefined; + } + + async handleDrop(treeItemId: string, dataTransferItems: [string, string | DataTransferFileDTO][], token: CancellationToken): Promise { + const treeItem = this.getTreeItem(treeItemId); + const dropTransfer = new DataTransfer(); + if (this.dragAndDropController && this.dragAndDropController.handleDrop) { + this.localDataTransfer.forEach((item, type) => { + dropTransfer.set(type, item); + }); + for (const [type, item] of dataTransferItems) { + // prefer the item the plugin has set in `onDragStarted`; + if (!dropTransfer.has(type)) { + if (typeof item === 'string') { + dropTransfer.set(type, new DataTransferItem(item)); + } else { + const file: DataTransferFile = { + name: item.name, + data: () => this.proxy.$readDroppedFile(item.contentId).then(buffer => buffer.buffer), + uri: item.uri ? URI.revive(item.uri) : undefined + }; + + const fileItem = new class extends DataTransferItem { + override asFile(): DataTransferFile | undefined { + return file; + } + }(file); + + dropTransfer.set(type, fileItem); + } + } + } + + return Promise.resolve(this.dragAndDropController.handleDrop(treeItem, dropTransfer, token)); + } + } } diff --git a/packages/plugin-ext/src/plugin/types-impl.ts b/packages/plugin-ext/src/plugin/types-impl.ts index 8ee6d9ad6af7a..3a5ac429299f9 100644 --- a/packages/plugin-ext/src/plugin/types-impl.ts +++ b/packages/plugin-ext/src/plugin/types-impl.ts @@ -1753,6 +1753,55 @@ export class WorkspaceEdit implements theia.WorkspaceEdit { } } +export class DataTransferItem { + asString(): Thenable { + return Promise.resolve(typeof this.value === 'string' ? this.value : JSON.stringify(this.value)); + } + + asFile(): theia.DataTransferFile | undefined { + return undefined; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(readonly value: any) { + } +} + +/** + * A map containing a mapping of the mime type of the corresponding transferred data. + * + * Drag and drop controllers that implement {@link TreeDragAndDropController.handleDrag `handleDrag`} can add additional mime types to the + * data transfer. These additional mime types will only be included in the `handleDrop` when the the drag was initiated from + * an element in the same drag and drop controller. + */ +@es5ClassCompat +export class DataTransfer implements Iterable<[mimeType: string, item: DataTransferItem]> { + private items: Map = new Map(); + get(mimeType: string): DataTransferItem | undefined { + return this.items.get(mimeType); + } + set(mimeType: string, value: DataTransferItem): void { + this.items.set(mimeType, value); + } + + has(mimeType: string): boolean { + return this.items.has(mimeType); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + forEach(callbackfn: (item: DataTransferItem, mimeType: string, dataTransfer: DataTransfer) => void, thisArg?: any): void { + this.items.forEach((item, mimetype) => { + callbackfn.apply(thisArg, [item, mimetype, this]); + }); + } + [Symbol.iterator](): IterableIterator<[mimeType: string, item: DataTransferItem]> { + return this.items[Symbol.iterator](); + } + + clear(): void { + this.items.clear(); + } +} @es5ClassCompat export class TreeItem { diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts index 1c52a360727c2..bf14fd86d5475 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -5522,6 +5522,11 @@ export module '@theia/plugin' { * Whether to show collapse all action or not. */ showCollapseAll?: boolean; + + /** + * An optional interface to implement drag and drop in the tree view. + */ + dragAndDropController?: TreeDragAndDropController; } /** @@ -5560,6 +5565,165 @@ export module '@theia/plugin' { } + /** + * A file associated with a {@linkcode DataTransferItem}. + */ + export interface DataTransferFile { + /** + * The name of the file. + */ + readonly name: string; + + /** + * The full file path of the file. + * + * May be `undefined` on web. + */ + readonly uri?: Uri; + + /** + * The full file contents of the file. + */ + data(): Thenable; + } + + /** + * Encapsulates data transferred during drag and drop operations. + */ + export class DataTransferItem { + /** + * Get a string representation of this item. + * + * If {@linkcode DataTransferItem.value} is an object, this returns the result of json stringifying {@linkcode DataTransferItem.value} value. + */ + asString(): Thenable; + + /** + * Try getting the {@link DataTransferFile file} associated with this data transfer item. + * + * Note that the file object is only valid for the scope of the drag and drop operation. + * + * @returns The file for the data transfer or `undefined` if the item is either not a file or the + * file data cannot be accessed. + */ + asFile(): DataTransferFile | undefined; + + /** + * Custom data stored on this item. + * + * You can use `value` to share data across operations. The original object can be retrieved so long as the extension that + * created the `DataTransferItem` runs in the same extension host. + */ + readonly value: any; + + /** + * @param value Custom data stored on this item. Can be retrieved using {@linkcode DataTransferItem.value}. + */ + constructor(value: any); + } + + /** + * A map containing a mapping of the mime type of the corresponding transferred data. + * + * Drag and drop controllers that implement {@link TreeDragAndDropController.handleDrag `handleDrag`} can add additional mime types to the + * data transfer. These additional mime types will only be included in the `handleDrop` when the the drag was initiated from + * an element in the same drag and drop controller. + */ + export class DataTransfer implements Iterable<[mimeType: string, item: DataTransferItem]> { + /** + * Retrieves the data transfer item for a given mime type. + * + * @param mimeType The mime type to get the data transfer item for, such as `text/plain` or `image/png`. + * + * Special mime types: + * - `text/uri-list` — A string with `toString()`ed Uris separated by `\r\n`. To specify a cursor position in the file, + * set the Uri's fragment to `L3,5`, where 3 is the line number and 5 is the column number. + */ + get(mimeType: string): DataTransferItem | undefined; + + /** + * Sets a mime type to data transfer item mapping. + * @param mimeType The mime type to set the data for. + * @param value The data transfer item for the given mime type. + */ + set(mimeType: string, value: DataTransferItem): void; + + /** + * Allows iteration through the data transfer items. + * + * @param callbackfn Callback for iteration through the data transfer items. + * @param thisArg The `this` context used when invoking the handler function. + */ + forEach(callbackfn: (item: DataTransferItem, mimeType: string, dataTransfer: DataTransfer) => void, thisArg?: any): void; + + /** + * Get a new iterator with the `[mime, item]` pairs for each element in this data transfer. + */ + [Symbol.iterator](): IterableIterator<[mimeType: string, item: DataTransferItem]>; + } + + /** + * Provides support for drag and drop in `TreeView`. + */ + export interface TreeDragAndDropController { + + /** + * The mime types that the {@link TreeDragAndDropController.handleDrop `handleDrop`} method of this `DragAndDropController` supports. + * This could be well-defined, existing, mime types, and also mime types defined by the extension. + * + * To support drops from trees, you will need to add the mime type of that tree. + * This includes drops from within the same tree. + * The mime type of a tree is recommended to be of the format `application/vnd.code.tree.`. + * + * Use the special `files` mime type to support all types of dropped files {@link DataTransferFile files}, regardless of the file's actual mime type. + * + * To learn the mime type of a dragged item: + * 1. Set up your `DragAndDropController` + * 2. Use the Developer: Set Log Level... command to set the level to "Debug" + * 3. Open the developer tools and drag the item with unknown mime type over your tree. The mime types will be logged to the developer console + * + * Note that mime types that cannot be sent to the extension will be omitted. + */ + readonly dropMimeTypes: readonly string[]; + + /** + * The mime types that the {@link TreeDragAndDropController.handleDrag `handleDrag`} method of this `TreeDragAndDropController` may add to the tree data transfer. + * This could be well-defined, existing, mime types, and also mime types defined by the extension. + * + * The recommended mime type of the tree (`application/vnd.code.tree.`) will be automatically added. + */ + readonly dragMimeTypes: readonly string[]; + + /** + * When the user starts dragging items from this `DragAndDropController`, `handleDrag` will be called. + * Extensions can use `handleDrag` to add their {@link DataTransferItem `DataTransferItem`} items to the drag and drop. + * + * When the items are dropped on **another tree item** in **the same tree**, your `DataTransferItem` objects + * will be preserved. Use the recommended mime type for the tree (`application/vnd.code.tree.`) to add + * tree objects in a data transfer. See the documentation for `DataTransferItem` for how best to take advantage of this. + * + * To add a data transfer item that can be dragged into the editor, use the application specific mime type "text/uri-list". + * The data for "text/uri-list" should be a string with `toString()`ed Uris separated by newlines. To specify a cursor position in the file, + * set the Uri's fragment to `L3,5`, where 3 is the line number and 5 is the column number. + * + * @param source The source items for the drag and drop operation. + * @param dataTransfer The data transfer associated with this drag. + * @param token A cancellation token indicating that drag has been cancelled. + */ + handleDrag?(source: readonly T[], dataTransfer: DataTransfer, token: CancellationToken): Thenable | void; + + /** + * Called when a drag and drop action results in a drop on the tree that this `DragAndDropController` belongs to. + * + * Extensions should fire {@link TreeDataProvider.onDidChangeTreeData onDidChangeTreeData} for any elements that need to be refreshed. + * + * @param dataTransfer The data transfer items of the source of the drag. + * @param target The target tree element that the drop is occurring on. When undefined, the target is the root. + * @param token A cancellation token indicating that the drop has been cancelled. + */ + handleDrop?(target: T | undefined, dataTransfer: DataTransfer, token: CancellationToken): Thenable | void; + } + /** * Represents a Tree view */