From cfd9d685560e6c39c52f4be388b650fb386bc588 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra <13396919+cheenamalhotra@users.noreply.github.com> Date: Wed, 28 Jun 2023 17:50:20 -0700 Subject: [PATCH] Fix treeView to render icons (#23530) --- .../contrib/views/browser/treeView.ts | 355 +++++++++++++++--- 1 file changed, 294 insertions(+), 61 deletions(-) diff --git a/src/sql/workbench/contrib/views/browser/treeView.ts b/src/sql/workbench/contrib/views/browser/treeView.ts index 9b3bc7af2e..79aa58760b 100644 --- a/src/sql/workbench/contrib/views/browser/treeView.ts +++ b/src/sql/workbench/contrib/views/browser/treeView.ts @@ -4,20 +4,20 @@ *--------------------------------------------------------------------------------------------*/ import { Event, Emitter } from 'vs/base/common/event'; -import { IDisposable, Disposable } from 'vs/base/common/lifecycle'; +import { IDisposable, Disposable, DisposableStore } from 'vs/base/common/lifecycle'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IAction, ActionRunner } from 'vs/base/common/actions'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; -import { IMenuService, MenuId, MenuItemAction, registerAction2, Action2, SubmenuItemAction } from 'vs/platform/actions/common/actions'; +import { IMenuService, MenuId, MenuItemAction, registerAction2, Action2, SubmenuItemAction, MenuRegistry, IMenu } from 'vs/platform/actions/common/actions'; import { MenuEntryActionViewItem, createAndFillInContextMenuActions, SubmenuEntryActionViewItem } from 'vs/platform/actions/browser/menuEntryActionViewItem'; import { IContextKeyService, ContextKeyExpr, ContextKeyEqualsExpr, RawContextKey, IContextKey } from 'vs/platform/contextkey/common/contextkey'; -import { TreeItemCollapsibleState, ITreeViewDataProvider, TreeViewItemHandleArg, ITreeItemLabel, IViewDescriptorService, ViewContainer, ViewContainerLocation, IViewBadge } from 'vs/workbench/common/views'; +import { TreeItemCollapsibleState, ITreeViewDataProvider, TreeViewItemHandleArg, ITreeItemLabel, IViewDescriptorService, ViewContainer, ViewContainerLocation, IViewBadge, ResolvableTreeItem, TreeCommand } from 'vs/workbench/common/views'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { INotificationService } from 'vs/platform/notification/common/notification'; import { IProgressService } from 'vs/platform/progress/common/progress'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; -import { ICommandService } from 'vs/platform/commands/common/commands'; +import { CommandsRegistry, ICommandService } from 'vs/platform/commands/common/commands'; import * as DOM from 'vs/base/browser/dom'; import { ResourceLabels, IResourceLabel } from 'vs/workbench/browser/labels'; import { ActionBar, IActionViewItemProvider } from 'vs/base/browser/ui/actionbar/actionbar'; @@ -44,6 +44,14 @@ import { NodeContextKey } from 'sql/workbench/contrib/views/browser/nodeContext' import { ActionViewItem } from 'vs/base/browser/ui/actionbar/actionViewItems'; import { ColorScheme } from 'vs/platform/theme/common/theme'; import { ThemeIcon } from 'vs/base/common/themables'; +import { IHoverDelegate, IHoverDelegateOptions } from 'vs/base/browser/ui/iconLabel/iconHoverDelegate'; +import { CheckboxStateHandler, TreeItemCheckbox } from 'vs/workbench/browser/parts/views/checkbox'; +import { renderMarkdownAsPlaintext } from 'vs/base/browser/markdownRenderer'; +import { ITooltipMarkdownString } from 'vs/base/browser/ui/iconLabel/iconLabelHover'; +import { IMarkdownString } from 'vs/base/common/htmlContent'; +import { IHoverService } from 'vs/workbench/services/hover/browser/hover'; +import { CancellationToken } from 'vscode'; +import { ITreeViewsService } from 'vs/workbench/services/views/browser/treeViewsService'; class Root implements ITreeItem { label = { label: 'root' }; @@ -53,6 +61,18 @@ class Root implements ITreeItem { children: ITreeItem[] | undefined = undefined; } +function isTreeCommandEnabled(treeCommand: TreeCommand, contextKeyService: IContextKeyService): boolean { + const command = CommandsRegistry.getCommand(treeCommand.originalId ? treeCommand.originalId : treeCommand.id); + if (command) { + const commandAction = MenuRegistry.getCommand(command.id); + const precondition = commandAction && commandAction.precondition; + if (precondition) { + return contextKeyService.contextMatchesRules(precondition); + } + } + return true; +} + const noDataProviderMessage = localize('no-dataprovider', "There is no data provider registered that can provide view data."); class Tree extends WorkbenchAsyncDataTree { } @@ -397,6 +417,67 @@ export class TreeView extends Disposable implements ITreeView { this._register(focusTracker.onDidBlur(() => this.focused = false)); } + private updateCheckboxes(items: ITreeItem[]) { + const additionalItems: ITreeItem[] = []; + + if (!this.manuallyManageCheckboxes) { + for (const item of items) { + if (item.checkbox !== undefined) { + + function checkChildren(currentItem: ITreeItem) { + for (const child of (currentItem.children ?? [])) { + if (child.checkbox !== undefined && currentItem.checkbox !== undefined) { + child.checkbox.isChecked = currentItem.checkbox.isChecked; + additionalItems.push(child); + checkChildren(child); + } + } + } + checkChildren(item); + + const visitedParents: Set = new Set(); + function checkParents(currentItem: ITreeItem) { + if (currentItem.parent && (currentItem.parent.checkbox !== undefined) && currentItem.parent.children) { + if (visitedParents.has(currentItem.parent)) { + return; + } else { + visitedParents.add(currentItem.parent); + } + + let someUnchecked = false; + let someChecked = false; + for (const child of currentItem.parent.children) { + if (someUnchecked && someChecked) { + break; + } + if (child.checkbox !== undefined) { + if (child.checkbox.isChecked) { + someChecked = true; + } else { + someUnchecked = true; + } + } + } + if (someChecked && !someUnchecked) { + currentItem.parent.checkbox.isChecked = true; + additionalItems.push(currentItem.parent); + checkParents(currentItem.parent); + } else if (someUnchecked && !someChecked) { + currentItem.parent.checkbox.isChecked = false; + additionalItems.push(currentItem.parent); + checkParents(currentItem.parent); + } + } + } + checkParents(item); + } + } + } + items = items.concat(additionalItems); + items.forEach(item => this.tree?.rerender(item)); + this._onDidChangeCheckboxState.fire(items); + } + private createTree() { const actionViewItemProvider = (action: IAction) => { if (action instanceof MenuItemAction) { @@ -411,7 +492,11 @@ export class TreeView extends Disposable implements ITreeView { this.treeLabels = this._register(this.instantiationService.createInstance(ResourceLabels, this)); const dataSource = this.instantiationService.createInstance(TreeDataSource, this, this.id, (task: Promise) => this.progressService.withProgress({ location: this.id }, () => task)); const aligner = new Aligner(this.themeService); - const renderer = this.instantiationService.createInstance(TreeRenderer, this.id, treeMenus, this.treeLabels, actionViewItemProvider, aligner); + const checkboxStateHandler = this._register(new CheckboxStateHandler()); + this._register(checkboxStateHandler.onDidChangeCheckboxState(items => { + this.updateCheckboxes(items); + })); + const renderer = this.instantiationService.createInstance(TreeRenderer, this.id, treeMenus, this.treeLabels, actionViewItemProvider, aligner, checkboxStateHandler); const widgetAriaLabel = this._title; this.tree = this._register(this.instantiationService.createInstance(Tree, this.id, this.treeContainer, new TreeViewDelegate(), [renderer], @@ -760,11 +845,13 @@ registerThemingParticipant((theme, collector) => { }); interface ITreeExplorerTemplateData { - elementDisposable: IDisposable; - container: HTMLElement; - resourceLabel: IResourceLabel; - icon: HTMLElement; - actionBar: ActionBar; + readonly elementDisposable: DisposableStore; + readonly container: HTMLElement; + readonly resourceLabel: IResourceLabel; + readonly icon: HTMLElement; + readonly checkboxContainer: HTMLElement; + checkbox?: TreeItemCheckbox; + readonly actionBar: ActionBar; } class TreeRenderer extends Disposable implements ITreeRenderer { @@ -772,6 +859,10 @@ class TreeRenderer extends Disposable implements ITreeRenderer, ITreeExplorerTemplateData>(); + constructor( private treeViewId: string, @@ -779,11 +870,21 @@ class TreeRenderer extends Disposable implements ITreeRenderer this.hoverService.showHover(options), + delay: this.configurationService.getValue('workbench.hover.delay') + }; + this._register(this.themeService.onDidFileIconThemeChange(() => this.rerender())); + this._register(this.themeService.onDidColorThemeChange(() => this.rerender())); } get templateId(): string { @@ -797,34 +898,59 @@ class TreeRenderer extends Disposable implements ITreeRenderer => { + return new Promise((resolve) => { + node.resolve(token).then(() => resolve(node.tooltip)); + }); + }, + markdownNotSupportedFallback: resource ? undefined : (label ?? '') // Passing undefined as the fallback for a resource falls back to the old native hover + }; } renderElement(element: ITreeNode, index: number, templateData: ITreeExplorerTemplateData): void { - templateData.elementDisposable.dispose(); const node = element.element; const resource = node.resourceUri ? URI.revive(node.resourceUri) : null; - const treeItemLabel: ITreeItemLabel | undefined = node.label ? node.label : resource ? { label: basename(resource) } : undefined; + const treeItemLabel: ITreeItemLabel | undefined = node.label ? node.label : (resource ? { label: basename(resource) } : undefined); const description = isString(node.description) ? node.description : resource && node.description === true ? this.labelService.getUriLabel(dirname(resource), { relative: true }) : undefined; const label = treeItemLabel ? treeItemLabel.label : undefined; const matches = (treeItemLabel && treeItemLabel.highlights && label) ? treeItemLabel.highlights.map(([start, end]) => { - if ((Math.abs(start) > label.length) || (Math.abs(end) >= label.length)) { - return ({ start: 0, end: 0 }); - } if (start < 0) { start = label.length + start; } if (end < 0) { end = label.length + end; } + if ((start >= label.length) || (end > label.length)) { + return ({ start: 0, end: 0 }); + } if (start > end) { const swap = start; start = end; @@ -832,56 +958,146 @@ class TreeRenderer extends Disposable implements ITreeRenderer('explorer.decorations'); - templateData.resourceLabel.setResource({ name: label, description, resource: resource ? resource : URI.parse('missing:_icon_resource') }, { fileKind: this.getFileKind(node), title, hideIcon: !!iconUrl, fileDecorations, extraClasses: ['custom-view-tree-node-item-resourceLabel'], matches: matches ? matches : createMatches(element.filterData) }); - } else { - templateData.resourceLabel.setResource({ name: label, description }, { title, hideIcon: true, extraClasses: ['custom-view-tree-node-item-resourceLabel'], matches: matches ? matches : createMatches(element.filterData) }); + let commandEnabled = true; + if (node.command) { + commandEnabled = isTreeCommandEnabled(node.command, this.contextKeyService); } - templateData.icon.title = title ? title : ''; + this.renderCheckbox(node, templateData); - if (iconUrl || sqlIcon) { + if (resource) { + const fileDecorations = this.configurationService.getValue<{ colors: boolean; badges: boolean }>('explorer.decorations'); + const labelResource = resource ? resource : URI.parse('missing:_icon_resource'); + templateData.resourceLabel.setResource({ name: label, description, resource: labelResource }, { + fileKind: this.getFileKind(node), + title, + hideIcon: this.shouldHideResourceLabelIcon(iconUrl, node.themeIcon), + fileDecorations, + extraClasses: ['custom-view-tree-node-item-resourceLabel'], + matches: matches ? matches : createMatches(element.filterData), + strikethrough: treeItemLabel?.strikethrough, + disabledCommand: !commandEnabled, + labelEscapeNewLines: true + }); + } else { + templateData.resourceLabel.setResource({ name: label, description }, { + title, + hideIcon: true, + extraClasses: ['custom-view-tree-node-item-resourceLabel'], + matches: matches ? matches : createMatches(element.filterData), + strikethrough: treeItemLabel?.strikethrough, + disabledCommand: !commandEnabled, + labelEscapeNewLines: true + }); + } + + if (iconUrl) { templateData.icon.className = 'custom-view-tree-node-item-icon'; - if (sqlIcon) { - templateData.icon.classList.toggle(sqlIcon, !!sqlIcon); // tracked change - } - templateData.icon.classList.toggle('icon', !!sqlIcon); - templateData.icon.style.backgroundImage = iconUrl ? DOM.asCSSUrl(iconUrl) : ''; + templateData.icon.style.backgroundImage = DOM.asCSSUrl(iconUrl); } else { let iconClass: string | undefined; - if (node.themeIcon && !this.isFileKindThemeIcon(node.themeIcon)) { + if (this.shouldShowThemeIcon(!!resource, node.themeIcon)) { iconClass = ThemeIcon.asClassName(node.themeIcon); + if (node.themeIcon.color) { + templateData.icon.style.color = this.themeService.getColorTheme().getColor(node.themeIcon.color.id)?.toString() ?? ''; + } } templateData.icon.className = iconClass ? `custom-view-tree-node-item-icon ${iconClass}` : ''; templateData.icon.style.backgroundImage = ''; } + if (!commandEnabled) { + templateData.icon.className = templateData.icon.className + ' disabled'; + if (templateData.container.parentElement) { + templateData.container.parentElement.className = templateData.container.parentElement.className + ' disabled'; + } + } + templateData.actionBar.context = { $treeViewId: this.treeViewId, $treeItemHandle: node.handle }; - templateData.actionBar.push(this.menus.getResourceActions(node), { icon: true, label: false }); + + const menuActions = this.menus.getResourceActions(node); + if (menuActions.menu) { + templateData.elementDisposable.add(menuActions.menu); + } + templateData.actionBar.push(menuActions.actions, { icon: true, label: false }); + if (this._actionRunner) { templateData.actionBar.actionRunner = this._actionRunner; } this.setAlignment(templateData.container, node); - templateData.elementDisposable = (this.themeService.onDidFileIconThemeChange(() => this.setAlignment(templateData.container, node))); + this.treeViewsService.addRenderedTreeItemElement(node, templateData.container); + + // remember rendered element + this._renderedElements.set(element, templateData); + } + + private rerender() { + // As we add items to the map during this call we can't directly use the map in the for loop + // but have to create a copy of the keys first + const keys = new Set(this._renderedElements.keys()); + for (const key of keys) { + const value = this._renderedElements.get(key); + if (value) { + this.disposeElement(key, 0, value); + this.renderElement(key, 0, value); + } + } + } + + private renderCheckbox(node: ITreeItem, templateData: ITreeExplorerTemplateData) { + if (node.checkbox) { + // The first time we find a checkbox we want to rerender the visible tree to adapt the alignment + if (!this._hasCheckbox) { + this._hasCheckbox = true; + this.rerender(); + } + if (!templateData.checkbox) { + const checkbox = new TreeItemCheckbox(templateData.checkboxContainer, this.checkboxStateHandler); + templateData.checkbox = checkbox; + } + templateData.checkbox.render(node); + } + else if (templateData.checkbox) { + templateData.checkbox.dispose(); + templateData.checkbox = undefined; + } } private setAlignment(container: HTMLElement, treeItem: ITreeItem) { - container.parentElement!.classList.toggle('align-icon-with-twisty', this.aligner.alignIconWithTwisty(treeItem)); + container.parentElement!.classList.toggle('align-icon-with-twisty', !this._hasCheckbox && this.aligner.alignIconWithTwisty(treeItem)); + } + + private shouldHideResourceLabelIcon(iconUrl: URI | undefined, icon: ThemeIcon | undefined): boolean { + // We always hide the resource label in favor of the iconUrl when it's provided. + // When `ThemeIcon` is provided, we hide the resource label icon in favor of it only if it's a not a file icon. + return (!!iconUrl || (!!icon && !this.isFileKindThemeIcon(icon))); + } + + private shouldShowThemeIcon(hasResource: boolean, icon: ThemeIcon | undefined): icon is ThemeIcon { + if (!icon) { + return false; + } + + // If there's a resource and the icon is a file icon, then the icon (or lack thereof) will already be coming from the + // icon theme and should use whatever the icon theme has provided. + return !(hasResource && this.isFileKindThemeIcon(icon)); + } + + private isFolderThemeIcon(icon: ThemeIcon | undefined): boolean { + return icon?.id === FolderThemeIcon.id; } private isFileKindThemeIcon(icon: ThemeIcon | undefined): boolean { if (icon) { - return icon.id === FileThemeIcon.id || icon.id === FolderThemeIcon.id; + return icon.id === FileThemeIcon.id || this.isFolderThemeIcon(icon); } else { return false; } @@ -900,7 +1116,13 @@ class TreeRenderer extends Disposable implements ITreeRenderer, index: number, templateData: ITreeExplorerTemplateData): void { - templateData.elementDisposable.dispose(); + templateData.elementDisposable.clear(); + + this._renderedElements.delete(resource); + this.treeViewsService.removeRenderedTreeItemElement(resource.element); + + templateData.checkbox?.dispose(); + templateData.checkbox = undefined; } disposeTemplate(templateData: ITreeExplorerTemplateData): void { @@ -991,49 +1213,60 @@ class MultipleSelectionActionRunner extends ActionRunner { } class TreeMenus extends Disposable implements IDisposable { + private contextKeyService: IContextKeyService | undefined; + private _onDidChange = new Emitter(); + public readonly onDidChange = this._onDidChange.event; constructor( private id: string, - @IContextKeyService private readonly contextKeyService: IContextKeyService, @IMenuService private readonly menuService: IMenuService ) { super(); } - getResourceActions(element: ITreeItem): IAction[] { - return this.mergeActions([ // tracked change - this.getActions(MenuId.ViewItemContext, { key: 'viewItem', value: element.contextValue }).primary, - this.getActions(MenuId.DataExplorerContext, { key: 'viewItem', value: element.contextValue }).primary - ]); + /** + * Caller is now responsible for disposing of the menu! + */ + getResourceActions(element: ITreeItem): { menu?: IMenu; actions: IAction[] } { + const actions = this.getActions(MenuId.ViewItemContext, element, true); + return { menu: actions.menu, actions: actions.primary }; } getResourceContextActions(element: ITreeItem): IAction[] { - return this.mergeActions([ // tracked change - this.getActions(MenuId.ViewItemContext, { key: 'viewItem', value: element.contextValue }).secondary, - this.getActions(MenuId.DataExplorerContext, { key: 'viewItem', value: element.contextValue }).secondary - ]); + return this.getActions(MenuId.ViewItemContext, element).secondary; } - private mergeActions(actions: IAction[][]): IAction[] { - return actions.reduce((p, c) => p.concat(...c.filter(a => p.findIndex(x => x.id === a.id) === -1)), [] as IAction[]); + public setContextKeyService(service: IContextKeyService) { + this.contextKeyService = service; } - private getActions(menuId: MenuId, context: { key: string, value?: string }): { primary: IAction[]; secondary: IAction[]; } { + private getActions(menuId: MenuId, element: ITreeItem, listen: boolean = false): { menu?: IMenu; primary: IAction[]; secondary: IAction[] } { + if (!this.contextKeyService) { + return { primary: [], secondary: [] }; + } + const contextKeyService = this.contextKeyService.createOverlay([ ['view', this.id], - [context.key, context.value] + ['viewItem', element.contextValue] ]); const menu = this.menuService.createMenu(menuId, contextKeyService); const primary: IAction[] = []; const secondary: IAction[] = []; - const result = { primary, secondary }; + const result = { primary, secondary, menu }; createAndFillInContextMenuActions(menu, { shouldForwardArgs: true }, result, 'inline'); - - menu.dispose(); - + if (listen) { + this._register(menu.onDidChange(() => this._onDidChange.fire(element))); + } else { + menu.dispose(); + } return result; } + + override dispose() { + this.contextKeyService = undefined; + super.dispose(); + } } export class CustomTreeView extends TreeView {