Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support tree view drag and drop. #61

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions packages/core/src/browser/frontend-application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
65 changes: 65 additions & 0 deletions packages/core/src/browser/shell/application-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -190,10 +192,14 @@ export class ApplicationShell extends Widget {

private readonly tracker = new FocusTracker<Widget>();
private dragState?: WidgetDragState;
additionalDraggedUris: URI[] | undefined;

@inject(ContextKeyService)
protected readonly contextKeyService: ContextKeyService;

@inject(OpenerService)
protected readonly openerService: OpenerService;

protected readonly onDidAddWidgetEmitter = new Emitter<Widget>();
readonly onDidAddWidget = this.onDidAddWidgetEmitter.event;
protected fireDidAddWidget(widget: Widget): void {
Expand Down Expand Up @@ -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.
*/
Expand Down
19 changes: 12 additions & 7 deletions packages/filesystem/src/browser/file-tree/file-tree-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Expand Down
39 changes: 2 additions & 37 deletions packages/navigator/src/browser/navigator-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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(
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down
12 changes: 11 additions & 1 deletion packages/plugin-ext/src/common/plugin-api-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -726,16 +726,26 @@ export interface TreeViewRevealOptions {
}

export interface TreeViewsMain {
$registerTreeDataProvider(treeViewId: string): void;
$registerTreeDataProvider(treeViewId: string, dragMimetypes: string[] | undefined, dropMimetypes: string[] | undefined): void;
$readDroppedFile(contentId: string): Promise<BinaryBuffer>;
$unregisterTreeDataProvider(treeViewId: string): void;
$refresh(treeViewId: string): Promise<void>;
$reveal(treeViewId: string, elementParentChain: string[], options: TreeViewRevealOptions): Promise<any>;
$setMessage(treeViewId: string, message: string): void;
$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<UriComponents[] | undefined>;
$drop(treeViewId: string, treeItemId: string | undefined, dataTransferItems: [string, string | DataTransferFileDTO][], token: CancellationToken): Promise<void>;
$getChildren(treeViewId: string, treeItemId: string | undefined): Promise<TreeViewItem[] | undefined>;
$hasResolveTreeItem(treeViewId: string): Promise<boolean>;
$resolveTreeItem(treeViewId: string, treeItemId: string, token: CancellationToken): Promise<TreeViewItem | undefined>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) => {

Expand Down Expand Up @@ -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,
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, File> = 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`);
}
}
Loading