// ***************************************************************************** // Copyright (C) 2018-2019 Red Hat, Inc. 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 { injectable, inject, postConstruct } from '@theia/core/shared/inversify'; import { TreeViewsExt, TreeViewItemCollapsibleState, TreeViewItem, TreeViewItemReference, ThemeIcon, DataTransferFileDTO } from '../../../common/plugin-api-rpc'; import { Command } from '../../../common/plugin-api-rpc-model'; import { TreeNode, NodeProps, SelectableTreeNode, ExpandableTreeNode, CompositeTreeNode, TreeImpl, TREE_NODE_SEGMENT_CLASS, TREE_NODE_SEGMENT_GROW_CLASS, TREE_NODE_TAIL_CLASS, TreeModelImpl, TreeViewWelcomeWidget, TooltipAttributes, TreeSelection, HoverService, ApplicationShell, KeybindingRegistry } from '@theia/core/lib/browser'; import { MenuPath, MenuModelRegistry, CommandMenu, AcceleratorSource } from '@theia/core/lib/common/menu'; import * as React from '@theia/core/shared/react'; import { PluginSharedStyle } from '../plugin-shared-style'; 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 { 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 { AccessibilityInformation } from '@theia/plugin'; import { ColorRegistry } from '@theia/core/lib/browser/color-registry'; import { DecoratedTreeNode } from '@theia/core/lib/browser/tree/tree-decorator'; import { WidgetDecoration } from '@theia/core/lib/browser/widget-decoration'; import { CancellationTokenSource, CancellationToken, Mutable } 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']; export const VIEW_ITEM_INLINE_MENU: MenuPath = ['view-item-context-menu', 'inline']; export interface SelectionEventHandler { readonly node: SelectableTreeNode; readonly contextSelection: boolean; } export interface TreeViewNode extends SelectableTreeNode, DecoratedTreeNode { contextValue?: string; command?: Command; resourceUri?: string; themeIcon?: ThemeIcon; tooltip?: string | MarkdownString; // eslint-disable-next-line @typescript-eslint/no-explicit-any description?: string | boolean | any; accessibilityInformation?: AccessibilityInformation; } export namespace TreeViewNode { export function is(arg: TreeNode | undefined): arg is TreeViewNode { return !!arg && SelectableTreeNode.is(arg) && DecoratedTreeNode.is(arg); } } export class ResolvableTreeViewNode implements TreeViewNode { contextValue?: string; command?: Command; resourceUri?: string; themeIcon?: ThemeIcon; tooltip?: string | MarkdownString; // eslint-disable-next-line @typescript-eslint/no-explicit-any description?: string | boolean | any; accessibilityInformation?: AccessibilityInformation; selected: boolean; focus?: boolean; id: string; name?: string; icon?: string; visible?: boolean; parent: Readonly; previousSibling?: TreeNode; nextSibling?: TreeNode; busy?: number; decorationData: WidgetDecoration.Data; resolve: ((token: CancellationToken) => Promise); private _resolved = false; private resolving: Deferred | undefined; constructor(treeViewNode: Partial, resolve: (token: CancellationToken) => Promise) { mixin(this, treeViewNode); this.resolve = async (token: CancellationToken) => { if (this.resolving) { return this.resolving.promise; } if (!this._resolved) { this.resolving = new Deferred(); const resolvedTreeItem = await resolve(token); if (resolvedTreeItem) { this.command = this.command ?? resolvedTreeItem.command; this.tooltip = this.tooltip ?? resolvedTreeItem.tooltip; } this.resolving.resolve(); this.resolving = undefined; } if (!token.isCancellationRequested) { this._resolved = true; } }; } reset(): void { this._resolved = false; this.resolving = undefined; this.command = undefined; this.tooltip = undefined; } get resolved(): boolean { return this._resolved; } } export class ResolvableCompositeTreeViewNode extends ResolvableTreeViewNode implements CompositeTreeViewNode { expanded: boolean; children: readonly TreeNode[]; constructor( treeViewNode: Pick & Partial, resolve: (token: CancellationToken) => Promise) { super(treeViewNode, resolve); this.expanded = treeViewNode.expanded; this.children = treeViewNode.children; } } export interface CompositeTreeViewNode extends TreeViewNode, ExpandableTreeNode, CompositeTreeNode { // eslint-disable-next-line @typescript-eslint/no-explicit-any description?: string | boolean | any; } export namespace CompositeTreeViewNode { export function is(arg: TreeNode | undefined): arg is CompositeTreeViewNode { return TreeViewNode.is(arg) && ExpandableTreeNode.is(arg) && CompositeTreeNode.is(arg); } } @injectable() export class TreeViewWidgetOptions { id: string; manageCheckboxStateManually: boolean | undefined; showCollapseAll: boolean | undefined; multiSelect: boolean | undefined; dragMimeTypes: string[] | undefined; dropMimeTypes: string[] | undefined; } @injectable() export class PluginTree extends TreeImpl { @inject(PluginSharedStyle) protected readonly sharedStyle: PluginSharedStyle; @inject(TreeViewWidgetOptions) protected readonly options: TreeViewWidgetOptions; @inject(MessageService) protected readonly notification: MessageService; protected readonly onDidChangeWelcomeStateEmitter: Emitter = new Emitter(); readonly onDidChangeWelcomeState = this.onDidChangeWelcomeStateEmitter.event; private _proxy: TreeViewsExt | undefined; private _viewInfo: View | undefined; private _isEmpty: boolean; private _hasTreeItemResolve: Promise = Promise.resolve(false); set proxy(proxy: TreeViewsExt | undefined) { this._proxy = proxy; if (proxy) { this._hasTreeItemResolve = proxy.$hasResolveTreeItem(this.options.id); } else { this._hasTreeItemResolve = Promise.resolve(false); } } get proxy(): TreeViewsExt | undefined { return this._proxy; } get hasTreeItemResolve(): Promise { return this._hasTreeItemResolve; } set viewInfo(viewInfo: View) { this._viewInfo = viewInfo; } get isEmpty(): boolean { return this._isEmpty; } protected override async resolveChildren(parent: CompositeTreeNode): Promise { if (!this._proxy) { return super.resolveChildren(parent); } const children = await this.fetchChildren(this._proxy, parent); const hasResolve = await this.hasTreeItemResolve; return children.map(value => hasResolve ? this.createResolvableTreeNode(value, parent) : this.createTreeNode(value, parent)); } protected async fetchChildren(proxy: TreeViewsExt, parent: CompositeTreeNode): Promise { try { 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) { this.onDidChangeWelcomeStateEmitter.fire(); } return children || []; } catch (e) { if (e) { 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 []; } } protected createTreeNode(item: TreeViewItem, parent: CompositeTreeNode): TreeNode { const update: Partial = this.createTreeNodeUpdate(item); const node = this.getNode(item.id); if (item.collapsibleState !== undefined && item.collapsibleState !== TreeViewItemCollapsibleState.None) { if (CompositeTreeViewNode.is(node)) { return Object.assign(node, update); } return Object.assign({ id: item.id, parent, visible: true, selected: false, expanded: TreeViewItemCollapsibleState.Expanded === item.collapsibleState, children: [], command: item.command }, update); } if (TreeViewNode.is(node) && !ExpandableTreeNode.is(node)) { return Object.assign(node, update, { command: item.command }); } return Object.assign({ id: item.id, parent, visible: true, selected: false, command: item.command, }, update); } override markAsChecked(node: Mutable, checked: boolean): void { function findParentsToChange(child: TreeNode, nodes: TreeNode[]): void { if ((child.parent?.checkboxInfo !== undefined && child.parent.checkboxInfo.checked !== checked) && (!checked || !child.parent.children.some(candidate => candidate !== child && candidate.checkboxInfo?.checked === false))) { nodes.push(child.parent); findParentsToChange(child.parent, nodes); } } function findChildrenToChange(parent: TreeNode, nodes: TreeNode[]): void { if (CompositeTreeNode.is(parent)) { parent.children.forEach(child => { if (child.checkboxInfo !== undefined && child.checkboxInfo.checked !== checked) { nodes.push(child); } findChildrenToChange(child, nodes); }); } } const nodesToChange = [node]; if (!this.options.manageCheckboxStateManually) { findParentsToChange(node, nodesToChange); findChildrenToChange(node, nodesToChange); } nodesToChange.forEach(n => n.checkboxInfo!.checked = checked); this.onDidUpdateEmitter.fire(nodesToChange); this.proxy?.$checkStateChanged(this.options.id, [{ id: node.id, checked: checked }]); } /** Creates a resolvable tree node. If a node already exists, reset it because the underlying TreeViewItem might have been disposed in the backend. */ protected createResolvableTreeNode(item: TreeViewItem, parent: CompositeTreeNode): TreeNode { const update: Partial = this.createTreeNodeUpdate(item); const node = this.getNode(item.id); // Node is a composite node that might contain children if (item.collapsibleState !== undefined && item.collapsibleState !== TreeViewItemCollapsibleState.None) { // Reuse existing composite node and reset it if (node instanceof ResolvableCompositeTreeViewNode) { node.reset(); return Object.assign(node, update); } // Create new composite node const compositeNode = Object.assign({ id: item.id, parent, visible: true, selected: false, expanded: TreeViewItemCollapsibleState.Expanded === item.collapsibleState, children: [], command: item.command }, update); return new ResolvableCompositeTreeViewNode(compositeNode, async (token: CancellationToken) => this._proxy?.$resolveTreeItem(this.options.id, item.id, token)); } // Node is a leaf // Reuse existing node and reset it. if (node instanceof ResolvableTreeViewNode && !ExpandableTreeNode.is(node)) { node.reset(); return Object.assign(node, update); } const treeNode = Object.assign({ id: item.id, parent, visible: true, selected: false, command: item.command, }, update); 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.fromComponents(item.resourceUri).toString(); const themeIcon = item.themeIcon ? item.themeIcon : item.collapsibleState !== TreeViewItemCollapsibleState.None ? { id: 'folder' } : undefined; return { name: item.label, decorationData, icon, description: item.description, themeIcon, resourceUri, tooltip: item.tooltip, contextValue: item.contextValue, command: item.command, checkboxInfo: item.checkboxInfo, accessibilityInformation: item.accessibilityInformation, }; } protected toDecorationData(item: TreeViewItem): WidgetDecoration.Data { let decoration: WidgetDecoration.Data = {}; if (item.highlights) { const highlight = { ranges: item.highlights.map(h => ({ offset: h[0], length: h[1] - h[0] })) }; decoration = { highlight }; } return decoration; } protected toIconClass(item: TreeViewItem): string | undefined { if (item.icon) { return 'fa ' + item.icon; } if (item.iconUrl) { const reference = this.sharedStyle.toIconClass(item.iconUrl); this.toDispose.push(reference); return reference.object.iconClass; } return undefined; } } @injectable() export class PluginTreeModel extends TreeModelImpl { @inject(PluginTree) protected override readonly tree: PluginTree; set proxy(proxy: TreeViewsExt | undefined) { this.tree.proxy = proxy; } get proxy(): TreeViewsExt | undefined { return this.tree.proxy; } get hasTreeItemResolve(): Promise { return this.tree.hasTreeItemResolve; } set viewInfo(viewInfo: View) { this.tree.viewInfo = viewInfo; } get isTreeEmpty(): boolean { return this.tree.isEmpty; } get onDidChangeWelcomeState(): Event { return this.tree.onDidChangeWelcomeState; } override doOpenNode(node: TreeNode): void { super.doOpenNode(node); if (node instanceof ResolvableTreeViewNode) { node.resolve(CancellationToken.None); } } } @injectable() export class TreeViewWidget extends TreeViewWelcomeWidget { async refresh(items?: string[]): Promise { if (items) { for (const id of items) { const node = this.model.getNode(id); if (CompositeTreeNode.is(node)) { await this.model.refresh(node); } }; } else { this.model.refresh(); } } protected _contextSelection = false; @inject(ApplicationShell) protected readonly applicationShell: ApplicationShell; @inject(MenuModelRegistry) protected readonly menus: MenuModelRegistry; @inject(KeybindingRegistry) protected readonly keybindings: KeybindingRegistry; @inject(ContextKeyService) protected readonly contextKeys: ContextKeyService; @inject(TreeViewWidgetOptions) readonly options: TreeViewWidgetOptions; @inject(PluginTreeModel) override readonly model: PluginTreeModel; @inject(ContextKeyService) protected readonly contextKeyService: ContextKeyService; @inject(HoverService) protected readonly hoverService: HoverService; @inject(ColorRegistry) protected readonly colorRegistry: ColorRegistry; @inject(DnDFileContentStore) protected readonly dndFileContentStore: DnDFileContentStore; protected treeDragType: string; protected readonly expansionTimeouts: Map = new Map(); @postConstruct() protected override init(): void { super.init(); 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.toDispose.push(this.keybindings.onKeybindingsChanged(() => this.update())); this.treeDragType = `application/vnd.code.tree.${this.id.toLowerCase()}`; } get showCollapseAll(): boolean { return this.options.showCollapseAll || false; } protected override renderIcon(node: TreeNode, props: NodeProps): React.ReactNode { const icon = this.toNodeIcon(node); if (icon) { let style: React.CSSProperties | undefined; if (TreeViewNode.is(node) && node.themeIcon?.color) { const color = this.colorRegistry.getCurrentColor(node.themeIcon.color.id); if (color) { style = { color }; } } return
; } return undefined; } protected override renderCaption(node: TreeViewNode, props: NodeProps): React.ReactNode { const classes = [TREE_NODE_SEGMENT_CLASS]; if (!this.hasTrailingSuffixes(node)) { classes.push(TREE_NODE_SEGMENT_GROW_CLASS); } const className = classes.join(' '); let attrs: React.HTMLAttributes & Partial = { ...this.decorateCaption(node, {}), className, id: node.id }; if (node.accessibilityInformation) { attrs = { ...attrs, 'aria-label': node.accessibilityInformation.label, 'role': node.accessibilityInformation.role }; } if (!node.tooltip && node instanceof ResolvableTreeViewNode) { let configuredTip = false; let source: CancellationTokenSource | undefined; attrs = { ...attrs, onMouseLeave: () => source?.cancel(), onMouseEnter: async event => { const target = event.currentTarget; // event.currentTarget will be null after awaiting node resolve() if (configuredTip) { if (MarkdownString.is(node.tooltip)) { this.hoverService.requestHover({ content: node.tooltip, target: event.target as HTMLElement, position: 'right' }); } return; } if (!node.resolved) { source = new CancellationTokenSource(); const token = source.token; await node.resolve(token); if (token.isCancellationRequested) { return; } } if (MarkdownString.is(node.tooltip)) { this.hoverService.requestHover({ content: node.tooltip, target: event.target as HTMLElement, position: 'right' }); } else { const title = node.tooltip || (node.resourceUri && this.labelProvider.getLongName(new URI(node.resourceUri))) || this.toNodeName(node); target.title = title; } configuredTip = true; } }; } else if (MarkdownString.is(node.tooltip)) { attrs = { ...attrs, onMouseEnter: event => { this.hoverService.requestHover({ content: node.tooltip!, target: event.target as HTMLElement, position: 'right' }); } }; } else { const title = node.tooltip || (node.resourceUri && this.labelProvider.getLongName(new URI(node.resourceUri))) || this.toNodeName(node); attrs = { ...attrs, title }; } const children: React.ReactNode[] = []; const caption = this.toNodeName(node); const highlight = this.getDecorationData(node, 'highlight')[0]; if (highlight) { children.push(this.toReactNode(caption, highlight)); } const searchHighlight = this.searchHighlights && this.searchHighlights.get(node.id); if (searchHighlight) { children.push(...this.toReactNode(caption, searchHighlight)); } else if (!highlight) { children.push(caption); } const description = this.toNodeDescription(node); if (description) { children.push({description}); } 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.handleDragEnter(node, event); attrs.onDragLeave = event => this.handleDragLeave(node, event); attrs.onDragOver = event => this.handleDragOver(event); } return attrs; } handleDragLeave(node: TreeViewNode, event: React.DragEvent): void { const timeout = this.expansionTimeouts.get(node.id); if (typeof timeout !== 'undefined') { console.debug(`dragleave ${node.id} canceling timeout`); clearTimeout(timeout); this.expansionTimeouts.delete(node.id); } } handleDragEnter(node: TreeViewNode, event: React.DragEvent): void { console.debug(`dragenter ${node.id}`); if (ExpandableTreeNode.is(node)) { console.debug(`dragenter ${node.id} starting timeout`); this.expansionTimeouts.set(node.id, window.setTimeout(() => { console.debug(`dragenter ${node.id} timeout reached`); this.model.expandNode(node); }, 500)); } } protected override createContainerAttributes(): React.HTMLAttributes { const attrs = super.createContainerAttributes(); if (this.options.dropMimeTypes) { attrs.onDrop = event => this.handleDropEvent(undefined, event); attrs.onDragOver = event => this.handleDragOver(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(uri => URI.fromComponents(uri))); } }); } handleDragEnd(node: TreeViewNode, event: React.DragEvent): void { this.applicationShell.clearAdditionalDraggedEditorUris(); this.model.proxy!.$dragEnd(this.id); } handleDragOver(event: React.DragEvent): void { const hasFiles = (items: DataTransferItemList) => { for (let i = 0; i < items.length; i++) { if (items[i].kind === 'file') { return true; } } return false; }; if (event.dataTransfer) { const canDrop = event.dataTransfer.types.some(type => this.options.dropMimeTypes!.includes(type)) || event.dataTransfer.types.includes(this.treeDragType) || this.options.dropMimeTypes!.includes('files') && hasFiles(event.dataTransfer.items); if (canDrop) { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; } else { event.dataTransfer.dropEffect = 'none'; } event.stopPropagation(); } } protected handleDropEvent(node: TreeViewNode | undefined, 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 path = window.electronTheiaCore.getPathForFile(f); const uri = path ? { scheme: 'file', path: 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.includes(this.treeDragType)) { event.preventDefault(); event.stopPropagation(); this.model.proxy?.$drop(this.id, node?.id, items, CancellationToken.None).finally(() => { for (const file of files) { this.dndFileContentStore.removeFile(file); } }); files = []; } } catch (e) { for (const file of files) { this.dndFileContentStore.removeFile(file); } throw e; } } } protected override renderTailDecorations(treeViewNode: TreeViewNode, props: NodeProps): React.ReactNode { return this.contextKeys.with({ view: this.id, viewItem: treeViewNode.contextValue }, () => { const menu = this.menus.getMenu(VIEW_ITEM_INLINE_MENU); const args = this.toContextMenuArgs(treeViewNode); const inlineCommands = menu?.children.filter((item): item is CommandMenu => CommandMenu.is(item)) || []; const tailDecorations = super.renderTailDecorations(treeViewNode, props); return {inlineCommands.length > 0 &&
{inlineCommands.map((item, index) => this.renderInlineCommand(item, index, this.focusService.hasFocus(treeViewNode), args))}
} {tailDecorations !== undefined &&
{tailDecorations}
}
; }); } toTreeViewItemReference(treeNode: TreeNode): TreeViewItemReference { return { viewId: this.id, itemId: treeNode.id }; } protected resolveKeybindingForCommand(command: string | undefined): string { let result = ''; if (command) { const bindings = this.keybindings.getKeybindingsForCommand(command); let found = false; if (bindings && bindings.length > 0) { bindings.forEach(binding => { if (!found && this.keybindings.isEnabledInScope(binding, this.node)) { found = true; result = ` (${this.keybindings.acceleratorFor(binding, '+')})`; } }); } } return result; } // eslint-disable-next-line @typescript-eslint/no-explicit-any protected renderInlineCommand(actionMenuNode: CommandMenu, index: number, tabbable: boolean, args: any[]): React.ReactNode { const nodePath = [...VIEW_ITEM_INLINE_MENU, actionMenuNode.id]; if (!actionMenuNode.icon || !actionMenuNode.isVisible(nodePath, this.contextKeys, undefined)) { return false; } const className = [TREE_NODE_SEGMENT_CLASS, TREE_NODE_TAIL_CLASS, actionMenuNode.icon, ACTION_ITEM, 'theia-tree-view-inline-action'].join(' '); const tabIndex = tabbable ? 0 : undefined; const titleString = actionMenuNode.label + (AcceleratorSource.is(actionMenuNode) ? actionMenuNode.getAccelerator(undefined).join('+') : ''); return
{ e.stopPropagation(); actionMenuNode.run(nodePath, ...args); }} />; } protected override toContextMenuArgs(target: SelectableTreeNode): [TreeViewItemReference, TreeViewItemReference[]] | [TreeViewItemReference] { if (this.options.multiSelect) { return [this.toTreeViewItemReference(target), this.model.selectedNodes.map(node => this.toTreeViewItemReference(node))]; } else { return [this.toTreeViewItemReference(target)]; } } override setFlag(flag: Widget.Flag): void { super.setFlag(flag); if (flag === Widget.Flag.IsVisible) { this.onDidChangeVisibilityEmitter.fire(this.isVisible); } } override clearFlag(flag: Widget.Flag): void { super.clearFlag(flag); if (flag === Widget.Flag.IsVisible) { this.onDidChangeVisibilityEmitter.fire(this.isVisible); } } override handleEnter(event: KeyboardEvent): void { super.handleEnter(event); this.tryExecuteCommand(); } protected override tapNode(node?: TreeNode): void { super.tapNode(node); this.findCommands(node).then(commandMap => { if (commandMap.size > 0) { this.tryExecuteCommandMap(commandMap); } else if (node && this.isExpandable(node)) { this.model.toggleNodeExpansion(node); } }); } // execute TreeItem.command if present protected async tryExecuteCommand(node?: TreeNode): Promise { this.tryExecuteCommandMap(await this.findCommands(node)); } protected tryExecuteCommandMap(commandMap: Map): void { commandMap.forEach((args, commandId) => { this.commands.executeCommand(commandId, ...args); }); } protected async findCommands(node?: TreeNode): Promise> { const commandMap = new Map(); const treeNodes = (node ? [node] : this.model.selectedNodes) as TreeViewNode[]; if (await this.model.hasTreeItemResolve) { const cancellationToken = new CancellationTokenSource().token; // Resolve all resolvable nodes that don't have a command and haven't been resolved. const allResolved = Promise.all(treeNodes.map(maybeNeedsResolve => { if (!maybeNeedsResolve.command && maybeNeedsResolve instanceof ResolvableTreeViewNode && !maybeNeedsResolve.resolved) { return maybeNeedsResolve.resolve(cancellationToken).catch(err => { console.error(`Failed to resolve tree item '${maybeNeedsResolve.id}'`, err); }); } return Promise.resolve(maybeNeedsResolve); })); // Only need to wait but don't need the values because tree items are resolved in place. await allResolved; } for (const treeNode of treeNodes) { if (treeNode && treeNode.command) { commandMap.set(treeNode.command.id, treeNode.command.arguments || []); } } return commandMap; } private _message: string | undefined; get message(): string | undefined { return this._message; } set message(message: string | undefined) { this._message = message; this.update(); } protected override render(): React.ReactNode { return React.createElement('div', this.createContainerAttributes(), this.renderSearchInfo(), this.renderTree(this.model)); } protected renderSearchInfo(): React.ReactNode { if (this._message) { return
{this._message}
; } return undefined; } override shouldShowWelcomeView(): boolean { return (this.model.proxy === undefined || this.model.isTreeEmpty) && this.message === undefined; } protected override handleContextMenuEvent(node: TreeNode | undefined, event: React.MouseEvent): void { if (SelectableTreeNode.is(node)) { // Keep the selection for the context menu, if the widget support multi-selection and the right click happens on an already selected node. if (!this.props.multiSelect || !node.selected) { const type = !!this.props.multiSelect && this.hasCtrlCmdMask(event) ? TreeSelection.SelectionType.TOGGLE : TreeSelection.SelectionType.DEFAULT; this.model.addSelection({ node, type }); } this.focusService.setFocus(node); const contextMenuPath = this.props.contextMenuPath; if (contextMenuPath) { const { x, y } = event.nativeEvent; const args = this.toContextMenuArgs(node); const contextKeyService = this.contextKeyService.createOverlay([ ['viewItem', (TreeViewNode.is(node) && node.contextValue) || undefined], ['view', this.options.id] ]); setTimeout(() => this.contextMenuRenderer.render({ menuPath: contextMenuPath, anchor: { x, y }, args, contextKeyService, context: event.currentTarget }), 10); } } event.stopPropagation(); event.preventDefault(); } }