// *****************************************************************************
// Copyright (C) 2017 TypeFox 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as React from '@theia/core/shared/react';
import { injectable, inject } from '@theia/core/shared/inversify';
import { DisposableCollection, Disposable } from '@theia/core/lib/common/disposable';
import URI from '@theia/core/lib/common/uri';
import { UriSelection } from '@theia/core/lib/common/selection';
import { isCancelled } from '@theia/core/lib/common/cancellation';
import { ContextMenuRenderer, NodeProps, TreeProps, TreeNode, CompositeTreeNode, CompressedTreeWidget, CompressedNodeProps } from '@theia/core/lib/browser';
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';
import { FileUploadService } from '../../common/upload/file-upload';
export const FILE_TREE_CLASS = 'theia-FileTree';
export const FILE_STAT_NODE_CLASS = 'theia-FileStatNode';
export const DIR_NODE_CLASS = 'theia-DirNode';
export const FILE_STAT_ICON_CLASS = 'theia-FileStatIcon';
@injectable()
export class FileTreeWidget extends CompressedTreeWidget {
protected readonly toCancelNodeExpansion = new DisposableCollection();
@inject(FileUploadService)
protected readonly uploadService: FileUploadService;
@inject(IconThemeService)
protected readonly iconThemeService: IconThemeService;
constructor(
@inject(TreeProps) props: TreeProps,
@inject(FileTreeModel) override readonly model: FileTreeModel,
@inject(ContextMenuRenderer) contextMenuRenderer: ContextMenuRenderer
) {
super(props, model, contextMenuRenderer);
this.addClass(FILE_TREE_CLASS);
this.toDispose.push(this.toCancelNodeExpansion);
}
protected override createNodeClassNames(node: TreeNode, props: NodeProps): string[] {
const classNames = super.createNodeClassNames(node, props);
if (FileStatNode.is(node)) {
classNames.push(FILE_STAT_NODE_CLASS);
}
if (DirNode.is(node)) {
classNames.push(DIR_NODE_CLASS);
}
return classNames;
}
protected override renderIcon(node: TreeNode, props: NodeProps): React.ReactNode {
const icon = this.toNodeIcon(node);
if (icon) {
return
;
}
// eslint-disable-next-line no-null/no-null
return null;
}
protected override createContainerAttributes(): React.HTMLAttributes {
const attrs = super.createContainerAttributes();
return {
...attrs,
onDragEnter: event => this.handleDragEnterEvent(this.model.root, event),
onDragOver: event => this.handleDragOverEvent(this.model.root, event),
onDragLeave: event => this.handleDragLeaveEvent(this.model.root, event),
onDrop: event => this.handleDropEvent(this.model.root, event)
};
}
protected override createNodeAttributes(node: TreeNode, props: NodeProps): React.Attributes & React.HTMLAttributes {
return {
...super.createNodeAttributes(node, props),
...this.getNodeDragHandlers(node, props),
title: this.getNodeTooltip(node)
};
}
protected getNodeTooltip(node: TreeNode): string | undefined {
const operativeNode = this.compressionService.getCompressionChain(node)?.tail() ?? node;
const uri = UriSelection.getUri(operativeNode);
return uri ? uri.path.fsPath() : undefined;
}
protected override getCaptionChildEventHandlers(node: TreeNode, props: CompressedNodeProps): React.Attributes & React.HtmlHTMLAttributes {
return {
...super.getCaptionChildEventHandlers(node, props),
...this.getNodeDragHandlers(node, props),
};
}
protected getNodeDragHandlers(node: TreeNode, props: CompressedNodeProps): React.Attributes & React.HtmlHTMLAttributes {
return {
onDragStart: event => this.handleDragStartEvent(node, event),
onDragEnter: event => this.handleDragEnterEvent(node, event),
onDragOver: event => this.handleDragOverEvent(node, event),
onDragLeave: event => this.handleDragLeaveEvent(node, event),
onDrop: event => this.handleDropEvent(node, event),
draggable: FileStatNode.is(node),
};
}
protected handleDragStartEvent(node: TreeNode, event: React.DragEvent): void {
event.stopPropagation();
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(FileStatNode.is).map(n => n.fileStat.resource);
if (uris.length > 0) {
ApplicationShell.setDraggedEditorUris(event.dataTransfer, uris);
}
let label: string;
if (selectedNodes.length === 1) {
label = this.toNodeName(node);
} else {
label = String(selectedNodes.length);
}
const dragImage = document.createElement('div');
dragImage.className = 'theia-file-tree-drag-image';
dragImage.textContent = label;
document.body.appendChild(dragImage);
event.dataTransfer.setDragImage(dragImage, -10, -10);
setTimeout(() => document.body.removeChild(dragImage), 0);
}
}
protected handleDragEnterEvent(node: TreeNode | undefined, event: React.DragEvent): void {
event.preventDefault();
event.stopPropagation();
this.toCancelNodeExpansion.dispose();
const containing = DirNode.getContainingDir(node);
if (!!containing && !containing.selected) {
this.model.selectNode(containing);
}
}
protected handleDragOverEvent(node: TreeNode | undefined, event: React.DragEvent): void {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = this.getDropEffect(event);
if (!this.toCancelNodeExpansion.disposed) {
return;
}
const timer = setTimeout(() => {
const containing = DirNode.getContainingDir(node);
if (!!containing && !containing.expanded) {
this.model.expandNode(containing);
}
}, 500);
this.toCancelNodeExpansion.push(Disposable.create(() => clearTimeout(timer)));
}
protected handleDragLeaveEvent(node: TreeNode | undefined, event: React.DragEvent): void {
event.preventDefault();
event.stopPropagation();
this.toCancelNodeExpansion.dispose();
}
protected async handleDropEvent(node: TreeNode | undefined, event: React.DragEvent): Promise {
try {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = this.getDropEffect(event);
const containing = this.getDropTargetDirNode(node);
if (containing) {
const resources = this.getSelectedTreeNodesFromData(event.dataTransfer);
if (resources.length > 0) {
for (const treeNode of resources) {
if (event.dataTransfer.dropEffect === 'copy' && FileStatNode.is(treeNode)) {
await this.model.copy(treeNode.uri, containing);
} else {
await this.model.move(treeNode, containing);
}
}
} else {
await this.uploadService.upload(containing.uri, { source: event.dataTransfer });
}
}
} catch (e) {
if (!isCancelled(e)) {
console.error(e);
}
}
}
protected getDropTargetDirNode(node: TreeNode | undefined): DirNode | undefined {
if (CompositeTreeNode.is(node) && node.id === 'WorkspaceNodeId') {
if (node.children.length === 1) {
return DirNode.getContainingDir(node.children[0]);
} else if (node.children.length > 1) {
// move file to the last root folder in multi-root scenario
return DirNode.getContainingDir(node.children[node.children.length - 1]);
}
}
return DirNode.getContainingDir(node);
}
protected getDropEffect(event: React.DragEvent): 'copy' | 'move' {
const isCopy = isOSX ? event.altKey : event.ctrlKey;
return isCopy ? 'copy' : 'move';
}
protected setTreeNodeAsData(data: DataTransfer, node: TreeNode): void {
data.setData('tree-node', node.id);
}
protected setSelectedTreeNodesAsData(data: DataTransfer, sourceNode: TreeNode, relatedNodes: TreeNode[]): void {
this.setTreeNodeAsData(data, sourceNode);
data.setData('selected-tree-nodes', JSON.stringify(relatedNodes.map(node => node.id)));
}
protected getTreeNodeFromData(data: DataTransfer): TreeNode | undefined {
const id = data.getData('tree-node');
return this.model.getNode(id);
}
protected getSelectedTreeNodesFromData(data: DataTransfer): TreeNode[] {
const resources = data.getData('selected-tree-nodes');
if (!resources) {
return [];
}
const ids: string[] = JSON.parse(resources);
return ids.map(id => this.model.getNode(id)).filter(node => node !== undefined) as TreeNode[];
}
protected get hidesExplorerArrows(): boolean {
const theme = this.iconThemeService.getDefinition(this.iconThemeService.current);
return !!theme && !!theme.hidesExplorerArrows;
}
protected override renderExpansionToggle(node: TreeNode, props: NodeProps): React.ReactNode {
if (this.hidesExplorerArrows) {
// eslint-disable-next-line no-null/no-null
return null;
}
return super.renderExpansionToggle(node, props);
}
protected override getPaddingLeft(node: TreeNode, props: NodeProps): number {
if (this.hidesExplorerArrows) {
// additional left padding instead of top-level expansion toggle
return super.getPaddingLeft(node, props) + this.props.leftPadding;
}
return super.getPaddingLeft(node, props);
}
protected override needsExpansionTogglePadding(node: TreeNode): boolean {
const theme = this.iconThemeService.getDefinition(this.iconThemeService.current);
if (theme && (theme.hidesExplorerArrows || (theme.hasFileIcons && !theme.hasFolderIcons))) {
return false;
}
return super.needsExpansionTogglePadding(node);
}
protected override deflateForStorage(node: TreeNode): object {
const deflated = super.deflateForStorage(node);
if (FileStatNode.is(node) && FileStatNodeData.is(deflated)) {
deflated.uri = node.uri.toString();
delete deflated['fileStat'];
deflated.stat = FileStat.toStat(node.fileStat);
}
return deflated;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected override inflateFromStorage(node: any, parent?: TreeNode): TreeNode {
if (FileStatNodeData.is(node)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fileStatNode: FileStatNode = node as any;
const resource = new URI(node.uri);
fileStatNode.uri = resource;
let stat: typeof node['stat'];
// in order to support deprecated FileStat
if (node.fileStat) {
stat = {
type: node.fileStat.isDirectory ? FileType.Directory : FileType.File,
mtime: node.fileStat.mtime,
size: node.fileStat.size
};
delete node['fileStat'];
} else if (node.stat) {
stat = node.stat;
delete node['stat'];
}
if (stat) {
fileStatNode.fileStat = FileStat.fromStat(resource, stat);
}
}
const inflated = super.inflateFromStorage(node, parent);
if (DirNode.is(inflated)) {
inflated.fileStat.children = [];
for (const child of inflated.children) {
if (FileStatNode.is(child)) {
inflated.fileStat.children.push(child.fileStat);
}
}
}
return inflated;
}
protected override getDepthPadding(depth: number): number {
// add additional depth so file nodes are rendered with padding in relation to the top level root node.
return super.getDepthPadding(depth + 1);
}
}