diff --git a/packages/core/package.json b/packages/core/package.json index 06bdc98945dd3..35b6794c99b5c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -55,6 +55,7 @@ "perfect-scrollbar": "^1.3.0", "react": "^16.8.0", "react-dom": "^16.8.0", + "react-tooltip": "^4.2.21", "react-virtualized": "^9.20.0", "reconnecting-websocket": "^4.2.0", "reflect-metadata": "^0.1.10", diff --git a/packages/core/src/browser/core-preferences.ts b/packages/core/src/browser/core-preferences.ts index 7b9a663f39438..2e868c95158c6 100644 --- a/packages/core/src/browser/core-preferences.ts +++ b/packages/core/src/browser/core-preferences.ts @@ -124,6 +124,11 @@ export const corePreferenceSchema: PreferenceSchema = { default: 'onHover', description: 'Controls whether the tree should render indent guides.' }, + 'workbench.hover.delay': { + type: 'number', + default: isOSX ? 1500 : 500, + description: 'Controls the delay in milliseconds after which the hover is shown for workbench items (ex. some extension provided tree view items).' + }, } }; @@ -142,6 +147,7 @@ export interface CoreConfiguration { 'workbench.silentNotifications': boolean; 'workbench.statusBar.visible': boolean; 'workbench.tree.renderIndentGuides': 'onHover' | 'none' | 'always'; + 'workbench.hover.delay': number; } export const CorePreferenceContribution = Symbol('CorePreferenceContribution'); diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index e4d3ab0381215..8cedce2ce5df2 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -118,6 +118,7 @@ import { DefaultBreadcrumbRenderer, } from './breadcrumbs'; import { RendererHost } from './widgets'; +import { TooltipService, TooltipServiceImpl } from './tooltip-service'; export { bindResourceProvider, bindMessageService, bindPreferenceService }; @@ -186,6 +187,9 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo bind(CommandOpenHandler).toSelf().inSingletonScope(); bind(OpenHandler).toService(CommandOpenHandler); + bind(TooltipServiceImpl).toSelf().inSingletonScope(); + bind(TooltipService).toService(TooltipServiceImpl); + bindContributionProvider(bind, ApplicationShellLayoutMigration); bind(ApplicationShellLayoutMigration).toConstantValue({ layoutVersion: 2.0, diff --git a/packages/core/src/browser/frontend-application.ts b/packages/core/src/browser/frontend-application.ts index 102f422f79b0b..567d4c32f3549 100644 --- a/packages/core/src/browser/frontend-application.ts +++ b/packages/core/src/browser/frontend-application.ts @@ -25,6 +25,7 @@ import { FrontendApplicationStateService } from './frontend-application-state'; import { preventNavigation, parseCssTime, animationFrame } from './browser'; import { CorePreferences } from './core-preferences'; import { WindowService } from './window/window-service'; +import { TooltipService } from './tooltip-service'; /** * Clients can implement to get a callback for contributing widgets to a shell on start. @@ -100,6 +101,9 @@ export class FrontendApplication { @inject(WindowService) protected readonly windowsService: WindowService; + @inject(TooltipService) + protected readonly tooltipService: TooltipService; + constructor( @inject(CommandRegistry) protected readonly commands: CommandRegistry, @inject(MenuModelRegistry) protected readonly menus: MenuModelRegistry, @@ -130,6 +134,7 @@ export class FrontendApplication { const host = await this.getHost(); this.attachShell(host); + this.attachTooltip(host); await animationFrame(); this.stateService.state = 'attached_shell'; @@ -221,6 +226,13 @@ export class FrontendApplication { Widget.attach(this.shell, host, ref); } + /** + * Attach the tooltip container to the host element. + */ + protected attachTooltip(host: HTMLElement): void { + this.tooltipService.attachTo(host); + } + /** * If a startup indicator is present, it is first hidden with the `theia-hidden` CSS class and then * removed after a while. The delay until removal is taken from the CSS transition duration. diff --git a/packages/core/src/browser/index.ts b/packages/core/src/browser/index.ts index 9bc188c6fcef8..caa4f76d459b6 100644 --- a/packages/core/src/browser/index.ts +++ b/packages/core/src/browser/index.ts @@ -40,3 +40,4 @@ export * from './diff-uris'; export * from './core-preferences'; export * from './view-container'; export * from './breadcrumbs'; +export * from './tooltip-service'; diff --git a/packages/core/src/browser/style/index.css b/packages/core/src/browser/style/index.css index 51feecfab5f6a..093dd6409b3be 100644 --- a/packages/core/src/browser/style/index.css +++ b/packages/core/src/browser/style/index.css @@ -263,3 +263,4 @@ button.secondary[disabled], .theia-button.secondary[disabled] { @import './quick-title-bar.css'; @import './progress-bar.css'; @import './breadcrumbs.css'; +@import './tooltip.css'; diff --git a/packages/core/src/browser/style/tooltip.css b/packages/core/src/browser/style/tooltip.css new file mode 100644 index 0000000000000..f8fbc809ca0eb --- /dev/null +++ b/packages/core/src/browser/style/tooltip.css @@ -0,0 +1,28 @@ +/******************************************************************************** + * Copyright (C) 2021 Arm 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 + ********************************************************************************/ + +.theia-tooltip { + color: var(--theia-list-hoverForeground) !important; + background: var(--theia-list-hoverBackground) !important; + border: 1px solid !important; + border-color: var(--theia-list-hoverForeground) !important; +} + +/* Hide tooltip arrow */ +.theia-tooltip::before, +.theia-tooltip::after { + border: none !important; +} diff --git a/packages/core/src/browser/tooltip-service.tsx b/packages/core/src/browser/tooltip-service.tsx new file mode 100644 index 0000000000000..79874bf008587 --- /dev/null +++ b/packages/core/src/browser/tooltip-service.tsx @@ -0,0 +1,98 @@ +/******************************************************************************** + * Copyright (C) 2021 Arm and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { injectable, inject, optional, postConstruct } from 'inversify'; +import * as React from 'react'; +import ReactTooltip from 'react-tooltip'; +import { ReactRenderer, RendererHost } from './widgets/react-renderer'; +import { CorePreferences } from './core-preferences'; +import { DisposableCollection } from '../common/disposable'; +import { v4 } from 'uuid'; + +export const TooltipService = Symbol('TooltipService'); + +export interface TooltipService { + tooltipId: string; + attachTo(host: HTMLElement): void; + update(fullRender?: boolean): void; +} + +/** + * Attributes to be added to an HTML element to enable + * rich HTML tooltip rendering + */ +export interface TooltipAttributes { + /** + * HTML to render in the tooltip. + */ + 'data-tip': string; + /** + * The ID of the tooltip renderer. Should be TOOLTIP_ID. + */ + 'data-for': string; +} + +const DELAY_PREFERENCE = 'workbench.hover.delay'; + +@injectable() +export class TooltipServiceImpl extends ReactRenderer implements TooltipService { + + @inject(CorePreferences) + protected readonly corePreferences: CorePreferences; + + public readonly tooltipId: string; + protected rendered = false; + protected toDispose: DisposableCollection = new DisposableCollection(); + + constructor( + @inject(RendererHost) @optional() host?: RendererHost + ) { + super(host); + this.tooltipId = v4(); + } + + @postConstruct() + protected init(): void { + this.toDispose.push(this.corePreferences.onPreferenceChanged(preference => { + if (preference.preferenceName === DELAY_PREFERENCE) { + this.update(true); + } + })); + } + + public attachTo(host: HTMLElement): void { + host.appendChild(this.host); + } + + public update(fullRender = false): void { + if (fullRender || !this.rendered) { + this.render(); + this.rendered = true; + } + + ReactTooltip.rebuild(); + } + + protected doRender(): React.ReactNode { + const hoverDelay = this.corePreferences.get(DELAY_PREFERENCE); + return ; + } + + public dispose(): void { + this.toDispose.dispose(); + super.dispose(); + } +} diff --git a/packages/plugin-ext/package.json b/packages/plugin-ext/package.json index c27a4f974d8a3..7fadf5b7aa8a7 100644 --- a/packages/plugin-ext/package.json +++ b/packages/plugin-ext/package.json @@ -26,6 +26,7 @@ "@theia/terminal": "1.18.0", "@theia/timeline": "1.18.0", "@theia/workspace": "1.18.0", + "@types/markdown-it": "*", "@types/mime": "^2.0.1", "decompress": "^4.2.1", "escape-html": "^1.0.3", @@ -34,6 +35,7 @@ "jsonc-parser": "^2.2.0", "lodash.clonedeep": "^4.5.0", "macaddress": "^0.2.9", + "markdown-it": "^8.4.0", "mime": "^2.4.4", "ps-tree": "^1.2.0", "request": "^2.82.0", diff --git a/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx b/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx index 6d37509b74ad5..2bf0c98a2b5be 100644 --- a/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx +++ b/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx @@ -29,7 +29,9 @@ import { TREE_NODE_SEGMENT_GROW_CLASS, TREE_NODE_TAIL_CLASS, TreeModelImpl, - TreeViewWelcomeWidget + TreeViewWelcomeWidget, + TooltipService, + TooltipAttributes } from '@theia/core/lib/browser'; import { TreeViewItem, TreeViewItemCollapsibleState } from '../../../common/plugin-api-rpc'; import { MenuPath, MenuModelRegistry, ActionMenuNode } from '@theia/core/lib/common/menu'; @@ -42,6 +44,8 @@ import { MessageService } from '@theia/core/lib/common/message-service'; import { View } from '../../../common/plugin-protocol'; import CoreURI from '@theia/core/lib/common/uri'; import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; +import * as markdownit from 'markdown-it'; +import { isMarkdownString } from '../../../plugin/markdown-string'; export const TREE_NODE_HYPERLINK = 'theia-TreeNodeHyperlink'; export const VIEW_ITEM_CONTEXT_MENU: MenuPath = ['view-item-context-menu']; @@ -245,6 +249,9 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { @inject(ContextKeyService) protected readonly contextKeyService: ContextKeyService; + @inject(TooltipService) + protected readonly tooltipService: TooltipService; + protected readonly onDidChangeVisibilityEmitter = new Emitter(); readonly onDidChangeVisibility = this.onDidChangeVisibilityEmitter.event; @@ -274,13 +281,32 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { classes.push(TREE_NODE_SEGMENT_GROW_CLASS); } const className = classes.join(' '); - const title = node.tooltip || - (node.resourceUri && this.labelProvider.getLongName(new CoreURI(node.resourceUri))) - || this.toNodeName(node); - const attrs = this.decorateCaption(node, { - className, id: node.id, - title - }); + + let attrs: React.HTMLAttributes & Partial = { + ...this.decorateCaption(node, {}), + className, + id: node.id + }; + + if (node.tooltip && isMarkdownString(node.tooltip)) { + // Render markdown in custom tooltip + const tooltip = markdownit().render(node.tooltip.value); + + attrs = { + ...attrs, + 'data-tip': tooltip, + 'data-for': this.tooltipService.tooltipId + }; + } else { + const title = node.tooltip || + (node.resourceUri && this.labelProvider.getLongName(new CoreURI(node.resourceUri))) + || this.toNodeName(node); + + attrs = { + ...attrs, + title + }; + } const children: React.ReactNode[] = []; const caption = this.toNodeName(node); @@ -444,7 +470,9 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { } protected render(): React.ReactNode { - return React.createElement('div', this.createContainerAttributes(), this.renderSearchInfo(), this.renderTree(this.model)); + const node = React.createElement('div', this.createContainerAttributes(), this.renderSearchInfo(), this.renderTree(this.model)); + this.tooltipService.update(); + return node; } protected renderSearchInfo(): React.ReactNode { diff --git a/packages/vsx-registry/package.json b/packages/vsx-registry/package.json index d71129584e079..a11e68ed5af5b 100644 --- a/packages/vsx-registry/package.json +++ b/packages/vsx-registry/package.json @@ -10,9 +10,11 @@ "@theia/plugin-ext-vscode": "1.18.0", "@theia/preferences": "1.18.0", "@theia/workspace": "1.18.0", + "@types/markdown-it": "*", "@types/bent": "^7.0.1", "@types/showdown": "^1.7.1", "bent": "^7.1.0", + "markdown-it": "^8.4.0", "p-debounce": "^2.1.0", "requestretry": "^3.1.0", "semver": "^5.4.1", diff --git a/packages/vsx-registry/src/browser/vsx-extension.tsx b/packages/vsx-registry/src/browser/vsx-extension.tsx index d5f1113a06d6b..a16b1dc65ba4e 100644 --- a/packages/vsx-registry/src/browser/vsx-extension.tsx +++ b/packages/vsx-registry/src/browser/vsx-extension.tsx @@ -14,6 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import * as markdownit from 'markdown-it'; import * as React from '@theia/core/shared/react'; import * as DOMPurify from '@theia/core/shared/dompurify'; import { injectable, inject } from '@theia/core/shared/inversify'; @@ -28,7 +29,7 @@ import { Endpoint } from '@theia/core/lib/browser/endpoint'; import { VSXEnvironment } from '../common/vsx-environment'; import { VSXExtensionsSearchModel } from './vsx-extensions-search-model'; import { MenuPath } from '@theia/core/lib/common'; -import { codicon, ContextMenuRenderer } from '@theia/core/lib/browser'; +import { codicon, ContextMenuRenderer, TooltipService } from '@theia/core/lib/browser'; import { VSXExtensionNamespaceAccess, VSXUser } from '@theia/ovsx-client/lib/ovsx-types'; export const EXTENSIONS_CONTEXT_MENU: MenuPath = ['extensions_context_menu']; @@ -112,6 +113,9 @@ export class VSXExtension implements VSXExtensionData, TreeElement { @inject(VSXExtensionsSearchModel) readonly search: VSXExtensionsSearchModel; + @inject(TooltipService) + readonly tooltipService: TooltipService; + protected readonly data: Partial = {}; get uri(): URI { @@ -183,7 +187,14 @@ export class VSXExtension implements VSXExtensionData, TreeElement { } get version(): string | undefined { - return this.getData('version'); + let version = this.getData('version'); + + // Ensure version begins with a 'v' + if (version && !version.startsWith('v')) { + version = `v${version}`; + } + + return version; } get averageRating(): number | undefined { @@ -244,6 +255,28 @@ export class VSXExtension implements VSXExtensionData, TreeElement { return this.getData('publishedBy'); } + get tooltipId(): string { + return this.tooltipService.tooltipId; + } + + get tooltip(): string { + let md = `__${this.displayName}__ ${this.version}\n\n${this.description}\n_____\n\nPublisher: ${this.publisher}`; + + if (this.license) { + md += ` \rLicense: ${this.license}`; + } + + if (this.downloadCount) { + md += ` \rDownload count: ${downloadCompactFormatter.format(this.downloadCount)}`; + } + + if (this.averageRating) { + md += ` \rAverage Rating: ${this.averageRating.toFixed(1)}`; + } + + return markdownit().render(md); + } + protected _busy = 0; get busy(): boolean { return !!this._busy; @@ -316,7 +349,9 @@ export class VSXExtension implements VSXExtensionData, TreeElement { } render(): React.ReactNode { - return ; + const node = ; + this.tooltipService.update(); + return node; } } @@ -379,8 +414,9 @@ const downloadCompactFormatter = new Intl.NumberFormat(undefined, { notation: 'c export class VSXExtensionComponent extends AbstractVSXExtensionComponent { render(): React.ReactNode { - const { iconUrl, publisher, displayName, description, version, downloadCount, averageRating } = this.props.extension; - return
+ const { iconUrl, publisher, displayName, description, version, downloadCount, averageRating, tooltipId, tooltip } = this.props.extension; + + return
{iconUrl ? :
} diff --git a/yarn.lock b/yarn.lock index e3a63f50e35be..525718df4b64d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9705,6 +9705,14 @@ react-perfect-scrollbar@^1.5.3: perfect-scrollbar "^1.5.0" prop-types "^15.6.1" +react-tooltip@^4.2.21: + version "4.2.21" + resolved "https://registry.yarnpkg.com/react-tooltip/-/react-tooltip-4.2.21.tgz#840123ed86cf33d50ddde8ec8813b2960bfded7f" + integrity sha512-zSLprMymBDowknr0KVDiJ05IjZn9mQhhg4PRsqln0OZtURAJ1snt1xi5daZfagsh6vfsziZrc9pErPTDY1ACig== + dependencies: + prop-types "^15.7.2" + uuid "^7.0.3" + react-virtualized@^9.20.0: version "9.22.3" resolved "https://registry.npmjs.org/react-virtualized/-/react-virtualized-9.22.3.tgz#f430f16beb0a42db420dbd4d340403c0de334421" @@ -11723,6 +11731,11 @@ uuid@^3.3.2, uuid@^3.3.3: resolved "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== +uuid@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-7.0.3.tgz#c5c9f2c8cf25dc0a372c4df1441c41f5bd0c680b" + integrity sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg== + uuid@^8.0.0, uuid@^8.3.2: version "8.3.2" resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"