UNPKG

@theia/core

Version:

Theia is a cloud & desktop IDE framework implemented in TypeScript.

1,245 lines 50.3 kB
"use strict"; // ***************************************************************************** // Copyright (C) 2018 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 WITH Classpath-exception-2.0 // ***************************************************************************** var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var __param = (this && this.__param) || function (paramIndex, decorator) { return function (target, key) { decorator(target, key, paramIndex); } }; var TreeWidget_1; Object.defineProperty(exports, "__esModule", { value: true }); exports.TreeWidget = exports.defaultTreeProps = exports.TreeProps = exports.TREE_NODE_INDENT_GUIDE_CLASS = exports.TREE_NODE_CAPTION_CLASS = exports.COMPOSITE_TREE_NODE_CLASS = exports.EXPANDABLE_TREE_NODE_CLASS = exports.TREE_NODE_SEGMENT_GROW_CLASS = exports.TREE_NODE_SEGMENT_CLASS = exports.TREE_NODE_TAIL_CLASS = exports.TREE_NODE_INFO_CLASS = exports.TREE_NODE_CONTENT_CLASS = exports.TREE_NODE_CLASS = exports.TREE_CONTAINER_CLASS = exports.TREE_CLASS = void 0; const inversify_1 = require("inversify"); const common_1 = require("../../common"); const keys_1 = require("../keyboard/keys"); const context_menu_renderer_1 = require("../context-menu-renderer"); const widgets_1 = require("../widgets"); const tree_1 = require("./tree"); const tree_model_1 = require("./tree-model"); const tree_expansion_1 = require("./tree-expansion"); const tree_selection_1 = require("./tree-selection"); const tree_decorator_1 = require("./tree-decorator"); const objects_1 = require("../../common/objects"); const os_1 = require("../../common/os"); const react_widget_1 = require("../widgets/react-widget"); const React = require("react"); const react_virtuoso_1 = require("react-virtuoso"); const tree_iterator_1 = require("./tree-iterator"); const search_box_1 = require("./search-box"); const tree_search_1 = require("./tree-search"); const domutils_1 = require("@phosphor/domutils"); const tree_widget_selection_1 = require("./tree-widget-selection"); const label_provider_1 = require("../label-provider"); const core_preferences_1 = require("../core-preferences"); const tree_focus_service_1 = require("./tree-focus-service"); const react_1 = require("react"); const debounce = require('lodash.debounce'); exports.TREE_CLASS = 'theia-Tree'; exports.TREE_CONTAINER_CLASS = 'theia-TreeContainer'; exports.TREE_NODE_CLASS = 'theia-TreeNode'; exports.TREE_NODE_CONTENT_CLASS = 'theia-TreeNodeContent'; exports.TREE_NODE_INFO_CLASS = 'theia-TreeNodeInfo'; exports.TREE_NODE_TAIL_CLASS = 'theia-TreeNodeTail'; exports.TREE_NODE_SEGMENT_CLASS = 'theia-TreeNodeSegment'; exports.TREE_NODE_SEGMENT_GROW_CLASS = 'theia-TreeNodeSegmentGrow'; exports.EXPANDABLE_TREE_NODE_CLASS = 'theia-ExpandableTreeNode'; exports.COMPOSITE_TREE_NODE_CLASS = 'theia-CompositeTreeNode'; exports.TREE_NODE_CAPTION_CLASS = 'theia-TreeNodeCaption'; exports.TREE_NODE_INDENT_GUIDE_CLASS = 'theia-tree-node-indent'; exports.TreeProps = Symbol('TreeProps'); /** * The default tree properties. */ exports.defaultTreeProps = { leftPadding: 8, expansionTogglePadding: 22 }; let TreeWidget = TreeWidget_1 = class TreeWidget extends react_widget_1.ReactWidget { constructor(props, model, contextMenuRenderer) { super(); this.props = props; this.model = model; this.contextMenuRenderer = contextMenuRenderer; this.decorations = new Map(); this.shouldScrollToRow = true; this.rows = new Map(); this.updateRows = debounce(() => this.doUpdateRows(), 10); this.scheduleUpdateScrollToRow = debounce(this.updateScrollToRow); /** * Update tree decorations. * - Updating decorations are debounced in order to limit the number of expensive updates. */ this.updateDecorations = debounce(() => this.doUpdateDecorations(), 150); this.ScrollingRowRenderer = ({ rows }) => { (0, react_1.useEffect)(() => this.scrollToSelected()); return React.createElement(React.Fragment, null, rows.map(row => React.createElement("div", { key: row.index }, this.renderNodeRow(row)))); }; this.scrollArea = this.node; /** * Render the node row. */ this.renderNodeRow = (row) => this.doRenderNodeRow(row); /** * Toggle the node. */ this.toggle = (event) => this.doToggle(event); /** * Handle the double-click mouse event on the expansion toggle. */ this.handleExpansionToggleDblClickEvent = (event) => this.doHandleExpansionToggleDblClickEvent(event); this.scrollOptions = { suppressScrollX: true, minScrollbarLength: 35 }; this.addClass(exports.TREE_CLASS); this.node.tabIndex = 0; } init() { if (this.props.search) { this.searchBox = this.searchBoxFactory(Object.assign(Object.assign({}, search_box_1.SearchBoxProps.DEFAULT), { showButtons: true, showFilter: true })); this.searchBox.node.addEventListener('focus', () => { this.node.focus(); }); this.toDispose.pushAll([ this.searchBox, this.searchBox.onTextChange(async (data) => { await this.treeSearch.filter(data); this.searchHighlights = this.treeSearch.getHighlights(); this.searchBox.updateHighlightInfo({ filterText: data, total: this.rows.size, matched: this.searchHighlights.size }); this.update(); }), this.searchBox.onClose(data => this.treeSearch.filter(undefined)), this.searchBox.onNext(() => { // Enable next selection if there are currently highlights. if (this.searchHighlights.size > 1) { this.model.selectNextNode(); } }), this.searchBox.onPrevious(() => { // Enable previous selection if there are currently highlights. if (this.searchHighlights.size > 1) { this.model.selectPrevNode(); } }), this.searchBox.onFilterToggled(e => { this.updateRows(); }), this.treeSearch, this.treeSearch.onFilteredNodesChanged(nodes => { if (this.searchBox.isFiltering) { this.updateRows(); } const node = nodes.find(tree_selection_1.SelectableTreeNode.is); if (node) { this.model.selectNode(node); } }), ]); } this.toDispose.pushAll([ this.model, this.model.onChanged(() => this.updateRows()), this.model.onSelectionChanged(() => this.scheduleUpdateScrollToRow({ resize: false })), this.focusService.onDidChangeFocus(() => this.scheduleUpdateScrollToRow({ resize: false })), this.model.onDidChangeBusy(() => this.update()), this.model.onNodeRefreshed(() => this.updateDecorations()), this.model.onExpansionChanged(() => this.updateDecorations()), this.decoratorService, this.decoratorService.onDidChangeDecorations(() => this.updateDecorations()), this.labelProvider.onDidChange(e => { for (const row of this.rows.values()) { if (e.affects(row)) { this.update(); return; } } }) ]); setTimeout(() => { this.updateRows(); this.updateDecorations(); }); if (this.props.globalSelection) { this.toDispose.pushAll([ this.model.onSelectionChanged(() => { if (this.node.contains(document.activeElement)) { this.updateGlobalSelection(); } }), this.focusService.onDidChangeFocus(focus => { if (focus && this.node.contains(document.activeElement) && this.model.selectedNodes[0] !== focus && this.model.selectedNodes.includes(focus)) { this.updateGlobalSelection(); } }), common_1.Disposable.create(() => { const selection = this.selectionService.selection; if (tree_widget_selection_1.TreeWidgetSelection.isSource(selection, this)) { this.selectionService.selection = undefined; } }) ]); } this.toDispose.push(this.corePreferences.onPreferenceChanged(preference => { if (preference.preferenceName === 'workbench.tree.renderIndentGuides') { this.update(); } })); } /** * Update the global selection for the tree. */ updateGlobalSelection() { this.selectionService.selection = tree_widget_selection_1.TreeWidgetSelection.create(this); } doUpdateRows() { const root = this.model.root; const rowsToUpdate = []; if (root) { const depths = new Map(); let index = 0; for (const node of new tree_iterator_1.TopDownTreeIterator(root, { pruneCollapsed: true, pruneSiblings: true })) { if (this.shouldDisplayNode(node)) { const depth = this.getDepthForNode(node, depths); if (tree_1.CompositeTreeNode.is(node)) { depths.set(node, depth); } rowsToUpdate.push([node.id, this.toNodeRow(node, index++, depth)]); } } } this.rows = new Map(rowsToUpdate); this.updateScrollToRow(); } getDepthForNode(node, depths) { const parentDepth = depths.get(node.parent); return parentDepth === undefined ? 0 : tree_1.TreeNode.isVisible(node.parent) ? parentDepth + 1 : parentDepth; } toNodeRow(node, index, depth) { return { node, index, depth }; } shouldDisplayNode(node) { var _a; return tree_1.TreeNode.isVisible(node) && (!((_a = this.searchBox) === null || _a === void 0 ? void 0 : _a.isFiltering) || this.treeSearch.passesFilters(node)); } /** * Update the `scrollToRow`. * @param updateOptions the tree widget force update options. */ updateScrollToRow() { this.scrollToRow = this.getScrollToRow(); this.update(); } /** * Get the `scrollToRow`. * * @returns the `scrollToRow` if available. */ getScrollToRow() { var _a; if (!this.shouldScrollToRow) { return undefined; } const { focusedNode } = this.focusService; return focusedNode && ((_a = this.rows.get(focusedNode.id)) === null || _a === void 0 ? void 0 : _a.index); } async doUpdateDecorations() { this.decorations = await this.decoratorService.getDecorations(this.model); this.update(); } onActivateRequest(msg) { super.onActivateRequest(msg); this.node.focus({ preventScroll: true }); } /** * Actually focus the tree node. */ doFocus() { if (!this.model.selectedNodes.length) { const node = this.getNodeToFocus(); if (tree_selection_1.SelectableTreeNode.is(node)) { this.model.selectNode(node); } } } /** * Get the tree node to focus. * * @returns the node to focus if available. */ getNodeToFocus() { const { focusedNode } = this.focusService; if (focusedNode) { return focusedNode; } const { root } = this.model; if (tree_selection_1.SelectableTreeNode.isVisible(root)) { return root; } return this.model.getNextSelectableNode(root); } onUpdateRequest(msg) { if (!this.isAttached || !this.isVisible) { return; } super.onUpdateRequest(msg); } onResize(msg) { super.onResize(msg); this.update(); } render() { return React.createElement('div', this.createContainerAttributes(), this.renderTree(this.model)); } /** * Create the container attributes for the widget. */ createContainerAttributes() { const classNames = [exports.TREE_CONTAINER_CLASS]; if (!this.rows.size) { classNames.push('empty'); } if (this.model.selectedNodes.length === 0 && !this.focusService.focusedNode) { classNames.push('focused'); } return { className: classNames.join(' '), onContextMenu: event => this.handleContextMenuEvent(this.getContainerTreeNode(), event) }; } /** * Get the container tree node. * * @returns the tree node for the container if available. */ getContainerTreeNode() { return this.model.root; } /** * Render the tree widget. * @param model the tree model. */ renderTree(model) { if (model.root) { const rows = Array.from(this.rows.values()); if (this.props.virtualized === false) { return React.createElement(this.ScrollingRowRenderer, { rows: rows }); } return React.createElement(TreeWidget_1.View, { ref: view => this.view = (view || undefined), width: this.node.offsetWidth, height: this.node.offsetHeight, rows: rows, renderNodeRow: this.renderNodeRow, scrollToRow: this.scrollToRow }); } // eslint-disable-next-line no-null/no-null return null; } /** * Scroll to the selected tree node. */ scrollToSelected() { if (this.props.scrollIfActive === true && !this.node.contains(document.activeElement)) { return; } const focus = this.node.getElementsByClassName(widgets_1.FOCUS_CLASS)[0]; if (focus) { domutils_1.ElementExt.scrollIntoViewIfNeeded(this.scrollArea, focus); } else { const selected = this.node.getElementsByClassName(widgets_1.SELECTED_CLASS)[0]; if (selected) { domutils_1.ElementExt.scrollIntoViewIfNeeded(this.scrollArea, selected); } } } /** * Actually render the node row. */ doRenderNodeRow({ node, depth }) { return React.createElement(React.Fragment, null, this.renderIndent(node, { depth }), this.renderNode(node, { depth })); } /** * Render the tree node given the node properties. * @param node the tree node. * @param props the node properties. */ renderIcon(node, props) { // eslint-disable-next-line no-null/no-null return null; } /** * Actually toggle the tree node. * @param event the mouse click event. */ doToggle(event) { const nodeId = event.currentTarget.getAttribute('data-node-id'); if (nodeId) { const node = this.model.getNode(nodeId); if (node && this.props.expandOnlyOnExpansionToggleClick) { if (this.isExpandable(node) && !this.hasShiftMask(event) && !this.hasCtrlCmdMask(event)) { this.model.toggleNodeExpansion(node); } } else { this.handleClickEvent(node, event); } } event.stopPropagation(); } /** * Render the node expansion toggle. * @param node the tree node. * @param props the node properties. */ renderExpansionToggle(node, props) { if (!this.isExpandable(node)) { // eslint-disable-next-line no-null/no-null return null; } const classes = [exports.TREE_NODE_SEGMENT_CLASS, widgets_1.EXPANSION_TOGGLE_CLASS]; if (!node.expanded) { classes.push(widgets_1.COLLAPSED_CLASS); } if (node.busy) { classes.push(widgets_1.BUSY_CLASS, ...widgets_1.CODICON_LOADING_CLASSES); } else { classes.push(...widgets_1.CODICON_TREE_ITEM_CLASSES); } const className = classes.join(' '); return React.createElement("div", { "data-node-id": node.id, className: className, onClick: this.toggle, onDoubleClick: this.handleExpansionToggleDblClickEvent }); } /** * Render the tree node caption given the node properties. * @param node the tree node. * @param props the node properties. */ renderCaption(node, props) { const attrs = this.getCaptionAttributes(node, props); const children = this.getCaptionChildren(node, props); return React.createElement('div', attrs, children); } getCaptionAttributes(node, props) { const tooltip = this.getDecorationData(node, 'tooltip').filter(objects_1.notEmpty).join(' • '); const classes = [exports.TREE_NODE_SEGMENT_CLASS]; if (!this.hasTrailingSuffixes(node)) { classes.push(exports.TREE_NODE_SEGMENT_GROW_CLASS); } const className = classes.join(' '); let attrs = this.decorateCaption(node, { className, id: node.id }); if (tooltip.length > 0) { attrs = Object.assign(Object.assign({}, attrs), { title: tooltip }); } return attrs; } getCaptionChildren(node, props) { const children = []; 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) : undefined; if (searchHighlight) { children.push(...this.toReactNode(caption, searchHighlight)); } else if (!highlight) { children.push(caption); } return children; } /** * Update the node given the caption and highlight. * @param caption the caption. * @param highlight the tree decoration caption highlight. */ toReactNode(caption, highlight) { let style = {}; if (highlight.color) { style = Object.assign(Object.assign({}, style), { color: highlight.color }); } if (highlight.backgroundColor) { style = Object.assign(Object.assign({}, style), { backgroundColor: highlight.backgroundColor }); } const createChildren = (fragment, index) => { const { data } = fragment; if (fragment.highlight) { return React.createElement("mark", { className: tree_decorator_1.TreeDecoration.Styles.CAPTION_HIGHLIGHT_CLASS, style: style, key: index }, data); } else { return data; } }; return tree_decorator_1.TreeDecoration.CaptionHighlight.split(caption, highlight).map(createChildren); } /** * Decorate the tree caption. * @param node the tree node. * @param attrs the additional attributes. */ decorateCaption(node, attrs) { const style = this.getDecorationData(node, 'fontData') .filter(objects_1.notEmpty) .reverse() .map(fontData => this.applyFontStyles({}, fontData)) .reduce((acc, current) => (Object.assign(Object.assign({}, acc), current)), {}); return Object.assign(Object.assign({}, attrs), { style }); } /** * Determine if the tree node contains trailing suffixes. * @param node the tree node. * * @returns `true` if the tree node contains trailing suffices. */ hasTrailingSuffixes(node) { return this.getDecorationData(node, 'captionSuffixes').filter(objects_1.notEmpty).reduce((acc, current) => acc.concat(current), []).length > 0; } /** * Apply font styles to the tree. * @param original the original css properties. * @param fontData the optional `fontData`. */ applyFontStyles(original, fontData) { if (fontData === undefined) { return original; } const modified = Object.assign({}, original); // make a copy to mutate const { color, style } = fontData; if (color) { modified.color = color; } if (style) { (Array.isArray(style) ? style : [style]).forEach(s => { switch (s) { case 'bold': modified.fontWeight = s; break; case 'normal': case 'oblique': case 'italic': modified.fontStyle = s; break; case 'underline': case 'line-through': modified.textDecoration = s; break; default: throw new Error(`Unexpected font style: "${s}".`); } }); } return modified; } /** * Render caption affixes for the given tree node. * @param node the tree node. * @param props the node properties. * @param affixKey the affix key. */ renderCaptionAffixes(node, props, affixKey) { const suffix = affixKey === 'captionSuffixes'; const affixClass = suffix ? tree_decorator_1.TreeDecoration.Styles.CAPTION_SUFFIX_CLASS : tree_decorator_1.TreeDecoration.Styles.CAPTION_PREFIX_CLASS; const classes = [exports.TREE_NODE_SEGMENT_CLASS, affixClass]; const affixes = this.getDecorationData(node, affixKey).filter(objects_1.notEmpty).reduce((acc, current) => acc.concat(current), []); const children = []; for (let i = 0; i < affixes.length; i++) { const affix = affixes[i]; if (suffix && i === affixes.length - 1) { classes.push(exports.TREE_NODE_SEGMENT_GROW_CLASS); } const style = this.applyFontStyles({}, affix.fontData); const className = classes.join(' '); const key = node.id + '_' + i; const attrs = { className, style, key }; children.push(React.createElement('div', attrs, affix.data)); } return React.createElement(React.Fragment, null, children); } /** * Decorate the tree node icon. * @param node the tree node. * @param icon the icon. */ decorateIcon(node, icon) { if (!icon) { return; } const overlayIcons = []; // if multiple overlays have the same overlay.position attribute, we'll de-duplicate those and only process the first one from the decoration array const seenPositions = new Set(); const overlays = this.getDecorationData(node, 'iconOverlay').filter(objects_1.notEmpty); for (const overlay of overlays) { if (!seenPositions.has(overlay.position)) { seenPositions.add(overlay.position); const iconClasses = [tree_decorator_1.TreeDecoration.Styles.DECORATOR_SIZE_CLASS, tree_decorator_1.TreeDecoration.IconOverlayPosition.getStyle(overlay.position)]; const style = (color) => color === undefined ? {} : { color }; if (overlay.background) { overlayIcons.push(React.createElement("span", { key: node.id + 'bg', className: this.getIconClass(overlay.background.shape, iconClasses), style: style(overlay.background.color) })); } const overlayIcon = 'icon' in overlay ? overlay.icon : overlay.iconClass; overlayIcons.push(React.createElement("span", { key: node.id, className: this.getIconClass(overlayIcon, iconClasses), style: style(overlay.color) })); } } if (overlayIcons.length > 0) { return React.createElement("div", { className: tree_decorator_1.TreeDecoration.Styles.ICON_WRAPPER_CLASS }, icon, overlayIcons); } return icon; } /** * Render the tree node tail decorations. * @param node the tree node. * @param props the node properties. */ renderTailDecorations(node, props) { const tailDecorations = this.getDecorationData(node, 'tailDecorations').reduce((acc, current) => acc.concat(current), []); if (tailDecorations.length === 0) { return; } return this.renderTailDecorationsForNode(node, props, tailDecorations); } renderTailDecorationsForNode(node, props, tailDecorations) { let dotDecoration; const otherDecorations = []; tailDecorations.reverse().forEach(decoration => { if (tree_decorator_1.TreeDecoration.TailDecoration.isDotDecoration(decoration)) { dotDecoration || (dotDecoration = decoration); } else if (decoration.data || decoration.icon || decoration.iconClass) { otherDecorations.push(decoration); } }); const decorationsToRender = dotDecoration ? [dotDecoration, ...otherDecorations] : otherDecorations; return React.createElement(React.Fragment, null, decorationsToRender.map((decoration, index) => { const { tooltip, data, fontData, color, icon, iconClass } = decoration; const iconToRender = icon !== null && icon !== void 0 ? icon : iconClass; const className = [exports.TREE_NODE_SEGMENT_CLASS, exports.TREE_NODE_TAIL_CLASS, 'flex'].join(' '); const style = fontData ? this.applyFontStyles({}, fontData) : color ? { color } : undefined; const content = data ? data : iconToRender ? React.createElement("span", { key: node.id + 'icon' + index, className: this.getIconClass(iconToRender, iconToRender === 'circle' ? [tree_decorator_1.TreeDecoration.Styles.DECORATOR_SIZE_CLASS] : []) }) : ''; return React.createElement("div", { key: node.id + className + index, className: className, style: style, title: tooltip }, content, index !== decorationsToRender.length - 1 ? ',' : ''); })); } /** * Determine the classes to use for an icon * - Assumes a Font Awesome name when passed a single string, otherwise uses the passed string array * @param iconName the icon name or list of icon names. * @param additionalClasses additional CSS classes. * * @returns the icon class name. */ getIconClass(iconName, additionalClasses = []) { const iconClass = (typeof iconName === 'string') ? ['a', 'fa', `fa-${iconName}`] : ['a'].concat(iconName); return iconClass.concat(additionalClasses).join(' '); } /** * Render indent for the file tree based on the depth * @param node the tree node. * @param depth the depth of the tree node. */ renderIndent(node, props) { const renderIndentGuides = this.corePreferences['workbench.tree.renderIndentGuides']; if (renderIndentGuides === 'none') { return undefined; } const indentDivs = []; let current = node; let depth = props.depth; while (current && depth) { const classNames = [exports.TREE_NODE_INDENT_GUIDE_CLASS]; if (this.needsActiveIndentGuideline(current)) { classNames.push('active'); } else { classNames.push(renderIndentGuides === 'onHover' ? 'hover' : 'always'); } const paddingLeft = this.getDepthPadding(depth); indentDivs.unshift(React.createElement("div", { key: depth, className: classNames.join(' '), style: { paddingLeft: `${paddingLeft}px` } })); current = current.parent; depth--; } return indentDivs; } needsActiveIndentGuideline(node) { const parent = node.parent; if (!parent || !this.isExpandable(parent)) { return false; } if (tree_selection_1.SelectableTreeNode.isSelected(parent)) { return true; } if (parent.expanded) { for (const sibling of parent.children) { if (tree_selection_1.SelectableTreeNode.isSelected(sibling) && !(this.isExpandable(sibling) && sibling.expanded)) { return true; } } } return false; } /** * Render the node given the tree node and node properties. * @param node the tree node. * @param props the node properties. */ renderNode(node, props) { if (!tree_1.TreeNode.isVisible(node)) { return undefined; } const attributes = this.createNodeAttributes(node, props); const content = React.createElement("div", { className: exports.TREE_NODE_CONTENT_CLASS }, this.renderExpansionToggle(node, props), this.decorateIcon(node, this.renderIcon(node, props)), this.renderCaptionAffixes(node, props, 'captionPrefixes'), this.renderCaption(node, props), this.renderCaptionAffixes(node, props, 'captionSuffixes'), this.renderTailDecorations(node, props)); return React.createElement('div', attributes, content); } /** * Create node attributes for the tree node given the node properties. * @param node the tree node. * @param props the node properties. */ createNodeAttributes(node, props) { const className = this.createNodeClassNames(node, props).join(' '); const style = this.createNodeStyle(node, props); return { className, style, onClick: event => this.handleClickEvent(node, event), onDoubleClick: event => this.handleDblClickEvent(node, event), onContextMenu: event => this.handleContextMenuEvent(node, event), }; } /** * Create the node class names. * @param node the tree node. * @param props the node properties. * * @returns the list of tree node class names. */ createNodeClassNames(node, props) { const classNames = [exports.TREE_NODE_CLASS]; if (tree_1.CompositeTreeNode.is(node)) { classNames.push(exports.COMPOSITE_TREE_NODE_CLASS); } if (this.isExpandable(node)) { classNames.push(exports.EXPANDABLE_TREE_NODE_CLASS); } if (this.rowIsSelected(node, props)) { classNames.push(widgets_1.SELECTED_CLASS); } if (this.focusService.hasFocus(node)) { classNames.push(widgets_1.FOCUS_CLASS); } return classNames; } rowIsSelected(node, props) { return tree_selection_1.SelectableTreeNode.isSelected(node); } /** * Get the default node style. * @param node the tree node. * @param props the node properties. * * @returns the CSS properties if available. */ getDefaultNodeStyle(node, props) { const paddingLeft = this.getPaddingLeft(node, props) + 'px'; return { paddingLeft }; } getPaddingLeft(node, props) { return this.getDepthPadding(props.depth) + (this.needsExpansionTogglePadding(node) ? this.props.expansionTogglePadding : 0); } /** * If the node is a composite, a toggle will be rendered. * Otherwise we need to add the width and the left, right padding => 18px */ needsExpansionTogglePadding(node) { return !this.isExpandable(node); } /** * Create the tree node style. * @param node the tree node. * @param props the node properties. */ createNodeStyle(node, props) { return this.decorateNodeStyle(node, this.getDefaultNodeStyle(node, props)); } /** * Decorate the node style. * @param node the tree node. * @param style the optional CSS properties. * * @returns the CSS styles if available. */ decorateNodeStyle(node, style) { const backgroundColor = this.getDecorationData(node, 'backgroundColor').filter(objects_1.notEmpty).shift(); if (backgroundColor) { style = Object.assign(Object.assign({}, (style || {})), { backgroundColor }); } return style; } /** * Determine if the tree node is expandable. * @param node the tree node. * * @returns `true` if the tree node is expandable. */ isExpandable(node) { return tree_expansion_1.ExpandableTreeNode.is(node); } /** * Get the tree node decorations. * @param node the tree node. * * @returns the list of tree decoration data. */ getDecorations(node) { const decorations = []; if (tree_decorator_1.DecoratedTreeNode.is(node)) { decorations.push(node.decorationData); } if (this.decorations.has(node.id)) { decorations.push(...this.decorations.get(node.id)); } return decorations.sort(tree_decorator_1.TreeDecoration.Data.comparePriority); } /** * Get the tree decoration data for the given key. * @param node the tree node. * @param key the tree decoration data key. * * @returns the tree decoration data at the given key. */ getDecorationData(node, key) { return this.getDecorations(node).filter(data => data[key] !== undefined).map(data => data[key]); } /** * Get the scroll container. */ getScrollContainer() { this.toDisposeOnDetach.push(common_1.Disposable.create(() => { const { scrollTop, scrollLeft } = this.node; this.lastScrollState = { scrollTop, scrollLeft }; })); if (this.lastScrollState) { const { scrollTop, scrollLeft } = this.lastScrollState; this.node.scrollTop = scrollTop; this.node.scrollLeft = scrollLeft; } return this.node; } onAfterAttach(msg) { const up = [ keys_1.Key.ARROW_UP, keys_1.KeyCode.createKeyCode({ first: keys_1.Key.ARROW_UP, modifiers: [keys_1.KeyModifier.Shift] }) ]; const down = [ keys_1.Key.ARROW_DOWN, keys_1.KeyCode.createKeyCode({ first: keys_1.Key.ARROW_DOWN, modifiers: [keys_1.KeyModifier.Shift] }) ]; if (this.props.search) { if (this.searchBox.isAttached) { widgets_1.Widget.detach(this.searchBox); } widgets_1.UnsafeWidgetUtilities.attach(this.searchBox, this.node.parentElement); this.addKeyListener(this.node, this.searchBox.keyCodePredicate.bind(this.searchBox), this.searchBox.handle.bind(this.searchBox)); this.toDisposeOnDetach.push(common_1.Disposable.create(() => { widgets_1.Widget.detach(this.searchBox); })); } super.onAfterAttach(msg); this.addKeyListener(this.node, keys_1.Key.ARROW_LEFT, event => this.handleLeft(event)); this.addKeyListener(this.node, keys_1.Key.ARROW_RIGHT, event => this.handleRight(event)); this.addKeyListener(this.node, up, event => this.handleUp(event)); this.addKeyListener(this.node, down, event => this.handleDown(event)); this.addKeyListener(this.node, keys_1.Key.ENTER, event => this.handleEnter(event)); this.addKeyListener(this.node, keys_1.Key.SPACE, event => this.handleSpace(event)); this.addKeyListener(this.node, keys_1.Key.ESCAPE, event => this.handleEscape(event)); // eslint-disable-next-line @typescript-eslint/no-explicit-any this.addEventListener(this.node, 'ps-scroll-y', (e) => { if (this.view && this.view.list) { const { scrollTop } = e.target; this.view.list.scrollTo({ top: scrollTop }); } }); } /** * Handle the `left arrow` keyboard event. * @param event the `left arrow` keyboard event. */ async handleLeft(event) { if (!!this.props.multiSelect && (this.hasCtrlCmdMask(event) || this.hasShiftMask(event))) { return; } if (!await this.model.collapseNode()) { this.model.selectParent(); } } /** * Handle the `right arrow` keyboard event. * @param event the `right arrow` keyboard event. */ async handleRight(event) { if (!!this.props.multiSelect && (this.hasCtrlCmdMask(event) || this.hasShiftMask(event))) { return; } if (!await this.model.expandNode()) { this.model.selectNextNode(); } } /** * Handle the `up arrow` keyboard event. * @param event the `up arrow` keyboard event. */ handleUp(event) { if (!!this.props.multiSelect && this.hasShiftMask(event)) { this.model.selectPrevNode(tree_selection_1.TreeSelection.SelectionType.RANGE); } else { this.model.selectPrevNode(); } this.node.focus(); } /** * Handle the `down arrow` keyboard event. * @param event the `down arrow` keyboard event. */ handleDown(event) { if (!!this.props.multiSelect && this.hasShiftMask(event)) { this.model.selectNextNode(tree_selection_1.TreeSelection.SelectionType.RANGE); } else { this.model.selectNextNode(); } this.node.focus(); } /** * Handle the `enter key` keyboard event. * - `enter` opens the tree node. * @param event the `enter key` keyboard event. */ handleEnter(event) { this.model.openNode(); } /** * Handle the `space key` keyboard event. * - By default should be similar to a single-click action. * @param event the `space key` keyboard event. */ handleSpace(event) { const { focusedNode } = this.focusService; if (!this.props.multiSelect || (!event.ctrlKey && !event.metaKey && !event.shiftKey)) { this.tapNode(focusedNode); } } handleEscape(event) { if (this.model.selectedNodes.length <= 1) { this.focusService.setFocus(undefined); this.node.focus(); } this.model.clearSelection(); } /** * Handle the single-click mouse event. * @param node the tree node if available. * @param event the mouse single-click event. */ handleClickEvent(node, event) { if (node) { event.stopPropagation(); const shiftMask = this.hasShiftMask(event); const ctrlCmdMask = this.hasCtrlCmdMask(event); if (this.props.multiSelect && (shiftMask || ctrlCmdMask) && tree_selection_1.SelectableTreeNode.is(node)) { if (shiftMask) { this.model.selectRange(node); } else if (ctrlCmdMask) { this.model.toggleNode(node); } } else { this.tapNode(node); } } } /** * The effective handler of an unmodified single-click event. */ tapNode(node) { if (tree_selection_1.SelectableTreeNode.is(node)) { this.model.selectNode(node); } if (node && !this.props.expandOnlyOnExpansionToggleClick && this.isExpandable(node)) { this.model.toggleNodeExpansion(node); } } /** * Handle the double-click mouse event. * @param node the tree node if available. * @param event the double-click mouse event. */ handleDblClickEvent(node, event) { this.model.openNode(node); event.stopPropagation(); } /** * Handle the context menu click event. * - The context menu click event is triggered by the right-click. * @param node the tree node if available. * @param event the right-click mouse event. */ handleContextMenuEvent(node, event) { if (tree_selection_1.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) ? tree_selection_1.TreeSelection.SelectionType.TOGGLE : tree_selection_1.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); setTimeout(() => this.contextMenuRenderer.render({ menuPath: contextMenuPath, anchor: { x, y }, args }), 10); } } event.stopPropagation(); event.preventDefault(); } /** * Actually handle the double-click mouse event on the expansion toggle. * @param event the double-click mouse event. */ doHandleExpansionToggleDblClickEvent(event) { if (this.props.expandOnlyOnExpansionToggleClick) { // Ignore the double-click event. event.stopPropagation(); } } /** * Convert the tree node to context menu arguments. * @param node the selectable tree node. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any toContextMenuArgs(node) { return undefined; } /** * Determine if the tree modifier aware event has a `ctrlcmd` mask. * @param event the tree modifier aware event. * * @returns `true` if the tree modifier aware event contains the `ctrlcmd` mask. */ hasCtrlCmdMask(event) { return os_1.isOSX ? event.metaKey : event.ctrlKey; } /** * Determine if the tree modifier aware event has a `shift` mask. * @param event the tree modifier aware event. * * @returns `true` if the tree modifier aware event contains the `shift` mask. */ hasShiftMask(event) { // Ctrl/Cmd mask overrules the Shift mask. if (this.hasCtrlCmdMask(event)) { return false; } return event.shiftKey; } /** * Deflate the tree node for storage. * @param node the tree node. */ deflateForStorage(node) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const copy = Object.assign({}, node); if (copy.parent) { delete copy.parent; } if ('previousSibling' in copy) { delete copy.previousSibling; } if ('nextSibling' in copy) { delete copy.nextSibling; } if ('busy' in copy) { delete copy.busy; } if (tree_1.CompositeTreeNode.is(node)) { copy.children = []; for (const child of node.children) { copy.children.push(this.deflateForStorage(child)); } } return copy; } /** * Inflate the tree node from storage. * @param node the tree node. * @param parent the optional tree node. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any inflateFromStorage(node, parent) { if (node.selected) { node.selected = false; } if (parent) { node.parent = parent; } if (Array.isArray(node.children)) { for (const child of node.children) { this.inflateFromStorage(child, node); } } return node; } /** * Store the tree state. */ storeState() { var _a; const decorations = this.decoratorService.deflateDecorators(this.decorations); let state = { decorations }; if (this.model.root) { state = Object.assign(Object.assign({}, state), { root: this.deflateForStorage(this.model.root), model: this.model.storeState(), focusedNodeId: (_a = this.focusService.focusedNode) === null || _a === void 0 ? void 0 : _a.id }); } return state; } /** * Restore the state. * @param oldState the old state object. */ restoreState(oldState) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const { root, decorations, model, focusedNodeId } = oldState; if (root) { this.model.root = this.inflateFromStorage(root); } if (decorations) { this.decorations = this.decoratorService.inflateDecorators(decorations); } if (model) { this.model.restoreState(model); } if (focusedNodeId) { const candidate = this.model.getNode(focusedNodeId); if (tree_selection_1.SelectableTreeNode.is(candidate)) { this.focusService.setFocus(candidate); } } } toNodeIcon(node) { return this.labelProvider.getIcon(node); } toNodeName(node) { return this.labelProvider.getName(node); } toNodeDescription(node) { return this.labelProvider.getLongName(node); } getDepthPadding(depth) { return depth * this.props.leftPadding; } }; __decorate([ (0, inversify_1.inject)(tree_decorator_1.TreeDecoratorService), __metadata("design:type", Object) ], TreeWidget.prototype, "decoratorService", void 0); __decorate([ (0, inversify_1.inject)(tree_search_1.TreeSearch), __metadata("design:type", tree_search_1.TreeSearch) ], TreeWidget.prototype, "treeSearch", void 0); __decorate([ (0, inversify_1.inject)(search_box_1.SearchBoxFactory), __metadata("design:type", Function) ], TreeWidget.prototype, "searchBoxFactory", void 0); __decorate([ (0, inversify_1.inject)(tree_focus_service_1.TreeFocusService), __metadata("design:type", Object) ], TreeWidget.prototype, "focusService", void 0); __decorate([ (0, inversify_1.inject)(common_1.SelectionService), __metadata("design:type", common_1.SelectionService) ], TreeWidget.prototype, "selectionService", void 0); __decorate([ (0, inversify_1.inject)(label_provider_1.LabelProvider), __metadata("design:type", label_provider_1.LabelProvider) ], TreeWidget.prototype, "labelProvider", void 0); __decorate([ (0, inversify_1.inject)(core_preferences_1.CorePreferences), __metadata("design:type", Object) ], TreeWidget.prototype, "corePreferences", void 0); __decorate([ (0, inversify_1.postConstruct)(), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", void 0) ], TreeWidget.prototype, "init", null); TreeWidget = TreeWidget_1 = __decorate([ (0, inversify_1.injectable)(), __param(0, (0, inversify_1.inject)(exports.TreeProps)), __param(1, (0, inversify_1.inject)(tree_model_1.TreeModel)), __param(2, (0, inversify_1.inject)(context_menu_renderer_1.ContextMenuRenderer)), __metadata("design:paramtypes", [Object, Object, context_menu_renderer_1.ContextMenuRenderer]) ], TreeWidget); exports.TreeWidget = TreeWidget; (function (TreeWidget) { class View extends React.Component { render() { const { rows, width, height, scrollToRow } = this.props; return React.createElement(react_virtuoso_1.Virtuoso, { ref: list => { this.list = (list || undefined); if (this.list && scrollToRow !== undefined) { this.list.scrollIntoView({ index: scrollToRow, align: 'center' }); } }, totalCount: rows.length, itemContent: index => this.props.renderNodeRow(rows[index]), width: width, h