diff --git a/CHANGELOG.md b/CHANGELOG.md index 54d7d416ee34b..5f3a990438c54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - [task] allowed users to override any task properties other than the ones used in the task definition [#5777](https://github.com/theia-ide/theia/pull/5777) - [task] notified clients of TaskDefinitionRegistry on change [#5915](https://github.com/theia-ide/theia/pull/5915) - [outline] added `OutlineViewTreeModel` for the outline view tree widget [#5687](https://github.com/theia-ide/theia/pull/5687) +- [core] supported diagnostic marker in the tab bar [#5845](https://github.com/theia-ide/theia/pull/5845) Breaking changes: @@ -23,6 +24,7 @@ Breaking changes: - `Source Control` and `Explorer` are view containers now and previous layout data cannot be loaded for them. Because of it the layout is completely reset. - [vscode] complete support of variable substitution [#5835](https://github.com/theia-ide/theia/pull/5835) - inline `VariableQuickOpenItem` +- [core] refactored `TreeDecoration` to `WidgetDecoration` and moved it to shell, since it is a generic decoration that can be used by different types of nodes (currently by tree nodes and tabbar tabs) [#5845](https://github.com/theia-ide/theia/pull/5845) ## v0.9.0 - [core] added `theia-widget-noInfo` css class to be used by widgets when displaying no information messages [#5717](https://github.com/theia-ide/theia/pull/5717) diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index 281af7f451761..052c217402b46 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -83,6 +83,7 @@ import { ProgressClient } from '../common/progress-service-protocol'; import { ProgressService } from '../common/progress-service'; import { DispatchingProgressClient } from './progress-client'; import { ProgressStatusBarItem } from './progress-status-bar-item'; +import { TabBarDecoratorService, TabBarDecorator } from './shell/tab-bar-decorator'; export const frontendApplicationModule = new ContainerModule((bind, unbind, isBound, rebind) => { const themeService = ThemeService.get(); @@ -119,9 +120,13 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo bind(DockPanelRenderer).toSelf(); bind(TabBarRendererFactory).toFactory(context => () => { const contextMenuRenderer = context.container.get(ContextMenuRenderer); - return new TabBarRenderer(contextMenuRenderer); + const decoratorService = context.container.get(TabBarDecoratorService); + return new TabBarRenderer(contextMenuRenderer, decoratorService); }); + bindContributionProvider(bind, TabBarDecorator); + bind(TabBarDecoratorService).toSelf().inSingletonScope(); + bindContributionProvider(bind, OpenHandler); bind(DefaultOpenerService).toSelf().inSingletonScope(); bind(OpenerService).toService(DefaultOpenerService); diff --git a/packages/core/src/browser/shell/tab-bar-decorator.ts b/packages/core/src/browser/shell/tab-bar-decorator.ts new file mode 100644 index 0000000000000..f71427cf72803 --- /dev/null +++ b/packages/core/src/browser/shell/tab-bar-decorator.ts @@ -0,0 +1,88 @@ +/******************************************************************************** + * Copyright (C) 2019 Ericsson 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 { inject, injectable, named, postConstruct } from 'inversify'; +import { Event, Emitter, Disposable, DisposableCollection, ContributionProvider } from '../../common'; +import { Title, Widget } from '@phosphor/widgets'; +import { WidgetDecoration } from '../widget-decoration'; + +export const TabBarDecorator = Symbol('TabBarDecorator'); + +export interface TabBarDecorator { + + /** + * The unique identifier of the tab bar decorator. + */ + readonly id: string; + + /** + * Event that is fired when any of the available tabbar decorators has changes. + */ + readonly onDidChangeDecorations: Event; + + /** + * Decorate tabs by the underlying URI. + * @returns A map from URI of the tab to its decoration data. + */ + decorate(titles: Title[]): Map; +} + +@injectable() +export class TabBarDecoratorService implements Disposable { + + protected readonly onDidChangeDecorationsEmitter = new Emitter(); + + readonly onDidChangeDecorations = this.onDidChangeDecorationsEmitter.event; + + protected readonly toDispose = new DisposableCollection(); + + @inject(ContributionProvider) @named(TabBarDecorator) + protected readonly contributions: ContributionProvider; + + @postConstruct() + protected init(): void { + const decorators = this.contributions.getContributions(); + + decorators.forEach(decorator => { + decorator.onDidChangeDecorations(() => + this.onDidChangeDecorationsEmitter.fire(undefined) + ); + }); + } + + dispose(): void { + this.toDispose.dispose(); + } + + getDecorations(titles: Title[]): Map { + const decorators = this.contributions.getContributions(); + const changes: Map = new Map(); + for (const decorator of decorators) { + for (const [id, data] of (decorator.decorate(titles)).entries()) { + if (changes.has(id)) { + changes.get(id)!.push(data); + } else { + changes.set(id, [data]); + } + } + } + return changes; + } +} + +export namespace TabBarDecoration { + +} diff --git a/packages/core/src/browser/shell/tab-bars.ts b/packages/core/src/browser/shell/tab-bars.ts index 74de6e11c74f7..457783e42030c 100644 --- a/packages/core/src/browser/shell/tab-bars.ts +++ b/packages/core/src/browser/shell/tab-bars.ts @@ -17,7 +17,7 @@ import PerfectScrollbar from 'perfect-scrollbar'; import { TabBar, Title, Widget } from '@phosphor/widgets'; import { VirtualElement, h, VirtualDOM, ElementInlineStyle } from '@phosphor/virtualdom'; -import { MenuPath } from '../../common'; +import { DisposableCollection, MenuPath, notEmpty } from '../../common'; import { ContextMenuRenderer } from '../context-menu-renderer'; import { Signal } from '@phosphor/signaling'; import { Message } from '@phosphor/messaging'; @@ -25,6 +25,8 @@ import { ArrayExt } from '@phosphor/algorithm'; import { ElementExt } from '@phosphor/domutils'; import { TabBarToolbarRegistry, TabBarToolbar } from './tab-bar-toolbar'; import { TheiaDockPanel, MAIN_AREA_ID, BOTTOM_AREA_ID } from './theia-dock-panel'; +import { WidgetDecoration } from '../widget-decoration'; +import { TabBarDecoratorService } from './tab-bar-decorator'; /** The class name added to hidden content nodes, which are required to render vertical side bars. */ const HIDDEN_CONTENT_CLASS = 'theia-TabBar-hidden-content'; @@ -71,11 +73,20 @@ export class TabBarRenderer extends TabBar.Renderer { */ contextMenuPath?: MenuPath; + protected readonly toDispose = new DisposableCollection(); + // TODO refactor shell, rendered should only receive props with event handlers // events should be handled by clients, like ApplicationShell // right now it is mess: (1) client logic belong to renderer, (2) cyclic dependencies between renderes and clients - constructor(protected readonly contextMenuRenderer?: ContextMenuRenderer) { + constructor( + protected readonly contextMenuRenderer?: ContextMenuRenderer, + protected readonly decoratorService?: TabBarDecoratorService + ) { super(); + if (this.decoratorService) { + this.toDispose.push(this.decoratorService); + this.toDispose.push(this.decoratorService.onDidChangeDecorations(() => this.tabBar && this.tabBar.update())); + } } /** @@ -97,7 +108,7 @@ export class TabBarRenderer extends TabBar.Renderer { oncontextmenu: this.handleContextMenuEvent, ondblclick: this.handleDblClickEvent }, - this.renderIcon(data), + this.renderIcon(data, isInSidePanel), this.renderLabel(data, isInSidePanel), this.renderCloseIcon(data) ); @@ -172,6 +183,27 @@ export class TabBarRenderer extends TabBar.Renderer { return h.div({ className: 'p-TabBar-tabLabel', style }, data.title.label); } + protected getDecorations(tab: string): WidgetDecoration.Data[] { + const tabDecorations = []; + if (this.tabBar && this.decoratorService) { + const allDecorations = this.decoratorService.getDecorations([...this.tabBar.titles]); + if (allDecorations.has(tab)) { + tabDecorations.push(...allDecorations.get(tab)); + } + } + return tabDecorations; + } + + protected getDecorationData(tab: string, key: K): WidgetDecoration.Data[K][] { + return this.getDecorations(tab).filter(data => data[key] !== undefined).map(data => data[key]); + + } + + private getIconClass(iconName: string | string[], additionalClasses: string[] = []): string { + const iconClass = (typeof iconName === 'string') ? ['a', 'fa', `fa-${iconName}`] : ['a'].concat(iconName); + return iconClass.concat(additionalClasses).join(' '); + } + /** * Find duplicate labels from the currently opened tabs in the tab bar. * Return the approriate partial paths that can distinguish the identical labels. @@ -260,15 +292,54 @@ export class TabBarRenderer extends TabBar.Renderer { /** * If size information is available for the icon, set it as inline style. Tab padding * is also considered in the `top` position. + * @param data {SideBarRenderData} data used to render the tab icon. + * @param isInSidePanel {boolean} an optional check which determines if the tab is in the side-panel. */ - renderIcon(data: SideBarRenderData): VirtualElement { + renderIcon(data: SideBarRenderData, inSidePanel?: boolean): VirtualElement { let top: string | undefined; if (data.paddingTop) { top = `${data.paddingTop || 0}px`; } - const className = this.createIconClass(data); const style: ElementInlineStyle = { top }; - return h.div({ className, style }, data.title.iconLabel); + const baseClassName = this.createIconClass(data); + + const overlayIcons: VirtualElement[] = []; + const decorationData = this.getDecorationData(data.title.caption, 'iconOverlay'); + + // Check if the tab has decoration markers to be rendered on top. + if (decorationData.length > 0) { + const baseIcon: VirtualElement = h.div({ className: baseClassName, style }, data.title.iconLabel); + const wrapperClassName: string = WidgetDecoration.Styles.ICON_WRAPPER_CLASS; + const decoratorSizeClassName: string = inSidePanel ? WidgetDecoration.Styles.DECORATOR_SIDEBAR_SIZE_CLASS : WidgetDecoration.Styles.DECORATOR_SIZE_CLASS; + + decorationData + .filter(notEmpty) + .map(overlay => [overlay.position, overlay] as [WidgetDecoration.IconOverlayPosition, WidgetDecoration.IconOverlay | WidgetDecoration.IconClassOverlay]) + .forEach(([position, overlay]) => { + const iconAdditionalClasses: string[] = [decoratorSizeClassName, WidgetDecoration.IconOverlayPosition.getStyle(position, inSidePanel)]; + const overlayIconStyle = (color?: string) => { + if (color === undefined) { + return {}; + } + return { color }; + }; + // Parse the optional background (if it exists) of the overlay icon. + if (overlay.background) { + const backgroundIconClassName = this.getIconClass(overlay.background.shape, iconAdditionalClasses); + overlayIcons.push( + h.div({ key: data.title.label + '-background', className: backgroundIconClassName, style: overlayIconStyle(overlay.background.color) }) + ); + } + // Parse the overlay icon. + const overlayIcon = (overlay as WidgetDecoration.IconOverlay).icon || (overlay as WidgetDecoration.IconClassOverlay).iconClass; + const overlayIconClassName = this.getIconClass(overlayIcon, iconAdditionalClasses); + overlayIcons.push( + h.span({ key: data.title.label, className: overlayIconClassName, style: overlayIconStyle(overlay.color) }) + ); + }); + return h.div({ className: wrapperClassName, style }, [baseIcon, ...overlayIcons]); + } + return h.div({ className: baseClassName, style }, data.title.iconLabel); } protected handleContextMenuEvent = (event: MouseEvent) => { diff --git a/packages/core/src/browser/style/tree-decorators.css b/packages/core/src/browser/style/tree-decorators.css index e03bb89fcf086..d2a3c4576a374 100644 --- a/packages/core/src/browser/style/tree-decorators.css +++ b/packages/core/src/browser/style/tree-decorators.css @@ -31,6 +31,7 @@ } .theia-icon-wrapper { + top: 0px !important; position: relative; display: inline-block } @@ -42,6 +43,13 @@ width: 100%; } +.theia-decorator-sidebar-size { + transform: scale(1.2); + text-align: center; + height: 100%; + width: 100%; +} + .theia-top-right { position: absolute; bottom: 40%; @@ -54,6 +62,12 @@ left: 25%; } +.theia-bottom-right-sidebar { + position: absolute; + top: 80%; + left: 50%; +} + .theia-bottom-left { position: absolute; top: 40%; diff --git a/packages/core/src/browser/tree/tree-decorator.ts b/packages/core/src/browser/tree/tree-decorator.ts index d0c5f92142b60..83e51badd3b5b 100644 --- a/packages/core/src/browser/tree/tree-decorator.ts +++ b/packages/core/src/browser/tree/tree-decorator.ts @@ -17,6 +17,7 @@ import { injectable } from 'inversify'; import { Tree, TreeNode } from './tree'; import { Event, Emitter, Disposable, DisposableCollection, MaybePromise } from '../../common'; +import { WidgetDecoration } from '../widget-decoration'; /** * Tree decorator that can change the look and the style of the tree items within a widget. @@ -162,414 +163,21 @@ export abstract class AbstractTreeDecoratorService implements TreeDecoratorServi } /** - * Namespace for the decoration data and the styling refinements for the decorated tree nodes. + * @deprecated import from `@theia/core/lib/browser/widget-decoration` instead. */ -export namespace TreeDecoration { +export import TreeDecoration = WidgetDecoration; +export interface DecoratedTreeNode extends TreeNode { /** - * CSS styles for the tree decorators. + * The additional tree decoration data attached to the tree node itself. */ - export namespace Styles { - export const CAPTION_HIGHLIGHT_CLASS = 'theia-caption-highlight'; - export const CAPTION_PREFIX_CLASS = 'theia-caption-prefix'; - export const CAPTION_SUFFIX_CLASS = 'theia-caption-suffix'; - export const ICON_WRAPPER_CLASS = 'theia-icon-wrapper'; - export const DECORATOR_SIZE_CLASS = 'theia-decorator-size'; - export const TOP_RIGHT_CLASS = 'theia-top-right'; - export const BOTTOM_RIGHT_CLASS = 'theia-bottom-right'; - export const BOTTOM_LEFT_CLASS = 'theia-bottom-left'; - export const TOP_LEFT_CLASS = 'theia-top-left'; - } - - /** - * For the sake of simplicity, we have merged the `font-style`, `font-weight`, and the `text-decoration` together. - */ - export type FontStyle = 'normal' | 'bold' | 'italic' | 'oblique' | 'underline' | 'line-through'; - - /** - * A string that could be: - * - * - one of the browser colors, (E.g.: `blue`, `red`, `magenta`), - * - the case insensitive hexadecimal color code, (for instance, `#ee82ee`, `#20B2AA`, `#f09` ), or - * - either the `rgb()` or the `rgba()` functions. - * - * For more details, see: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value. - * - * Note, it is highly recommended to use one of the predefined colors of Theia, so the desired color will - * look nice with both the `light` and the `dark` theme too. - */ - export type Color = string; - - /** - * Encapsulates styling information of the font. - */ - export interface FontData { - - /** - * Zero to any font style. - */ - readonly style?: FontStyle | FontStyle[]; - - /** - * The color of the font. - */ - readonly color?: Color; - - } - - /** - * Arbitrary information that has to be shown either before or after the caption as a prefix or a suffix. - */ - export interface CaptionAffix { - - /** - * The text content of the prefix or the suffix. - */ - readonly data: string; - - /** - * Font data for customizing the prefix of the suffix. - */ - readonly fontData?: FontData; - - } - - export interface BaseTailDecoration { - - /** - * Optional tooltip for the tail decoration. - */ - readonly tooltip?: string; - } - - /** - * Unlike caption suffixes, tail decorations appears right-aligned after the caption and the caption suffixes (is any). - */ - export interface TailDecoration extends BaseTailDecoration { - /** - * The text content of the tail decoration. - */ - readonly data: string; - - /** - * Font data for customizing the content. - */ - readonly fontData?: FontData; - } - - export interface TailDecorationIcon extends BaseTailDecoration { - /** - * This should be the name of the Font Awesome icon with out the `fa fa-` prefix, just the name, for instance `paw`. - * For the existing icons, see here: https://fontawesome.com/v4.7.0/icons/. - */ - readonly icon: string; - - /** - * The color of the icon. - */ - readonly color?: Color; - } - - export interface TailDecorationIconClass extends BaseTailDecoration { - /** - * This should be the entire Font Awesome class array, for instance ['fa', 'fa-paw'] - * For the existing icons, see here: https://fontawesome.com/v4.7.0/icons/. - */ - readonly iconClass: string[]; - - /** - * The color of the icon. - */ - readonly color?: Color; - } - - /** - * Enumeration for the quadrant to overlay the image on. - */ - export enum IconOverlayPosition { - - /** - * Overlays the top right quarter of the original image. - */ - TOP_RIGHT, - - /** - * Overlays the bottom right of the original image. - */ - BOTTOM_RIGHT, - - /** - * Overlays the bottom left segment of the original image. - */ - BOTTOM_LEFT, - - /** - * Occupies the top left quarter of the original icon. - */ - TOP_LEFT - - } - - export namespace IconOverlayPosition { - - /** - * Returns with the CSS class style for the enum. - */ - export function getStyle(position: IconOverlayPosition): string { - switch (position) { - case IconOverlayPosition.TOP_RIGHT: return TreeDecoration.Styles.TOP_RIGHT_CLASS; - case IconOverlayPosition.BOTTOM_RIGHT: return TreeDecoration.Styles.BOTTOM_RIGHT_CLASS; - case IconOverlayPosition.BOTTOM_LEFT: return TreeDecoration.Styles.BOTTOM_LEFT_CLASS; - case IconOverlayPosition.TOP_LEFT: return TreeDecoration.Styles.TOP_LEFT_CLASS; - } - } - - } - - /** - * A shape that can be optionally rendered behind the overlay icon. Can be used to further refine colors. - */ - export interface IconOverlayBackground { - - /** - * Either `circle` or `square`. - */ - readonly shape: 'circle' | 'square'; - - /** - * The color of the background shape. - */ - readonly color?: Color; - } - - /** - * Has not effect if the tree node being decorated has no associated icon. - */ - export interface BaseOverlay { - - /** - * The position where the decoration will be placed on the top of the original icon. - */ - readonly position: IconOverlayPosition; - - /** - * The color of the overlaying icon. If not specified, then the default icon color will be used. - */ - readonly color?: Color; - - /** - * The optional background color of the overlay icon. - */ - readonly background?: IconOverlayBackground; - - } - - export interface IconOverlay extends BaseOverlay { - /** - * This should be the name of the Font Awesome icon with out the `fa fa-` prefix, just the name, for instance `paw`. - * For the existing icons, see here: https://fontawesome.com/v4.7.0/icons/. - */ - readonly icon: string; - } - - export interface IconClassOverlay extends BaseOverlay { - /** - * This should be the entire Font Awesome class array, for instance ['fa', 'fa-paw'] - * For the existing icons, see here: https://fontawesome.com/v4.7.0/icons/. - */ - readonly iconClass: string[]; - } - - /** - * The caption highlighting with the highlighted ranges and an optional background color. - */ - export interface CaptionHighlight { - - /** - * The ranges to highlight in the caption. - */ - readonly ranges: CaptionHighlight.Range[] - - /** - * The optional color of the text data that is being highlighted. Falls back to the default `mark` color values defined under a tree node segment class. - */ - readonly color?: Color; - - /** - * The optional background color of the text data that is being highlighted. - */ - readonly backgroundColor?: Color; - } - - export namespace CaptionHighlight { - - /** - * A pair of offset and length that has to be highlighted as a range. - */ - export interface Range { - - /** - * Zero based offset of the highlighted region. - */ - readonly offset: number; - - /** - * The length of the highlighted region. - */ - readonly length: number; - - } - - export namespace Range { - - /** - * `true` if the `arg` is contained in the range. The ranges are closed ranges, hence the check is inclusive. - */ - export function contains(arg: number, range: Range): boolean { - return arg >= range.offset && arg <= (range.offset + range.length); - } - - } - - /** - * The result of a caption splitting based on the highlighting information. - */ - export interface Fragment { - - /** - * The text data of the fragment. - */ - readonly data: string; - - /** - * Has to be highlighted if defined. - */ - readonly highligh?: true - - } - - /** - * Splits the `caption` argument based on the ranges from the `highlight` argument. - */ - export function split(caption: string, highlight: CaptionHighlight): Fragment[] { - const result: Fragment[] = []; - const ranges = highlight.ranges.slice(); - const containerOf = (index: number) => ranges.findIndex(range => Range.contains(index, range)); - let data = ''; - for (let i = 0; i < caption.length; i++) { - const containerIndex = containerOf(i); - if (containerIndex === -1) { - data += caption[i]; - } else { - if (data.length > 0) { - result.push({ data }); - } - const { length } = ranges.splice(containerIndex, 1).shift()!; - result.push({ data: caption.substr(i, length), highligh: true }); - data = ''; - i = i + length - 1; - } - } - if (data.length > 0) { - result.push({ data }); - } - if (ranges.length !== 0) { - throw new Error('Error occurred when splitting the caption. There was a mismatch between the caption and the corresponding highlighting ranges.'); - } - return result; - } - - } - - /** - * Encapsulates styling information that has to be applied on the tree node which we decorate. - */ - export interface Data { - - /** - * The higher number has higher priority. If not specified, treated as `0`. - * When multiple decorators are available for the same item, and decoration data cannot be merged together, - * then the higher priority item will be applied on the decorated element and the lower priority will be ignored. - */ - readonly priority?: number; - - /** - * The font data for the caption. - */ - readonly fontData?: FontData; - - /** - * The background color of the entire row. - */ - readonly backgroundColor?: Color; - - /** - * Optional, leading prefixes right before the caption. - */ - readonly captionPrefixes?: CaptionAffix[]; - - /** - * Suffixes that might come after the caption as an additional information. - */ - readonly captionSuffixes?: CaptionAffix[]; - - /** - * Optional right-aligned decorations that appear after the node caption and after the caption suffixes (is any). - */ - readonly tailDecorations?: Array; - - /** - * Custom tooltip for the decorated item. Tooltip will be appended to the original tooltip, if any. - */ - readonly tooltip?: string; - - /** - * Sets the color of the icon. Ignored if the decorated item has no icon. - */ - readonly iconColor?: Color; - - /** - * Has not effect if given, but the tree node does not have an associated image. - */ - readonly iconOverlay?: IconOverlay | IconClassOverlay; - - /** - * An array of ranges to highlight the caption. - */ - readonly highlight?: CaptionHighlight; - - } - - export namespace Data { - - /** - * Compares the decoration data based on the priority. Lowest priorities come first. - */ - export const comparePriority = (left: Data, right: Data): number => (left.priority || 0) - (right.priority || 0); - - } - + readonly decorationData: TreeDecoration.Data; +} +export namespace DecoratedTreeNode { /** - * Tree node that can be decorated explicitly, without the tree decorators. + * Type-guard for decorated tree nodes. */ - export interface DecoratedTreeNode extends TreeNode { - - /** - * The additional tree decoration data attached to the tree node itself. - */ - readonly decorationData: Data; - - } - - export namespace DecoratedTreeNode { - - /** - * Type-guard for decorated tree nodes. - */ - export function is(node: TreeNode | undefined): node is DecoratedTreeNode { - return !!node && 'decorationData' in node; - } - + export function is(node: TreeNode | undefined): node is DecoratedTreeNode { + return !!node && 'decorationData' in node; } - } diff --git a/packages/core/src/browser/tree/tree-widget.tsx b/packages/core/src/browser/tree/tree-widget.tsx index 9b910536d60ed..ad718dfcec1d5 100644 --- a/packages/core/src/browser/tree/tree-widget.tsx +++ b/packages/core/src/browser/tree/tree-widget.tsx @@ -25,7 +25,7 @@ import { TreeNode, CompositeTreeNode } from './tree'; import { TreeModel } from './tree-model'; import { ExpandableTreeNode } from './tree-expansion'; import { SelectableTreeNode, TreeSelection } from './tree-selection'; -import { TreeDecoration, TreeDecoratorService } from './tree-decorator'; +import { TreeDecoratorService, TreeDecoration, DecoratedTreeNode } from './tree-decorator'; import { notEmpty } from '../../common/objects'; import { isOSX } from '../../common/os'; import { ReactWidget } from '../widgets/react-widget'; @@ -693,7 +693,7 @@ export class TreeWidget extends ReactWidget implements StatefulWidget { protected getDecorations(node: TreeNode): TreeDecoration.Data[] { const decorations: TreeDecoration.Data[] = []; - if (TreeDecoration.DecoratedTreeNode.is(node)) { + if (DecoratedTreeNode.is(node)) { decorations.push(node.decorationData); } if (this.decorations.has(node.id)) { diff --git a/packages/core/src/browser/widget-decoration.ts b/packages/core/src/browser/widget-decoration.ts new file mode 100644 index 0000000000000..5c4c38e3c3fcf --- /dev/null +++ b/packages/core/src/browser/widget-decoration.ts @@ -0,0 +1,340 @@ +/******************************************************************************** + * Copyright (C) 2019 Ericsson 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 + ********************************************************************************/ + +/** + * Namespace for the decoration data and the styling refinements for the decorated widgets. + */ +export namespace WidgetDecoration { + + /** + * CSS styles for the decorators. + */ + export namespace Styles { + export const CAPTION_HIGHLIGHT_CLASS = 'theia-caption-highlight'; + export const CAPTION_PREFIX_CLASS = 'theia-caption-prefix'; + export const CAPTION_SUFFIX_CLASS = 'theia-caption-suffix'; + export const ICON_WRAPPER_CLASS = 'theia-icon-wrapper'; + export const DECORATOR_SIZE_CLASS = 'theia-decorator-size'; + export const DECORATOR_SIDEBAR_SIZE_CLASS = 'theia-decorator-sidebar-size'; + export const TOP_RIGHT_CLASS = 'theia-top-right'; + export const BOTTOM_RIGHT_CLASS = 'theia-bottom-right'; + export const BOTTOM_RIGHT_SIDEBAR_CLASS = 'theia-bottom-right-sidebar'; + export const BOTTOM_LEFT_CLASS = 'theia-bottom-left'; + export const TOP_LEFT_CLASS = 'theia-top-left'; + } + /** + * For the sake of simplicity, we have merged the `font-style`, `font-weight`, and the `text-decoration` together. + */ + export type FontStyle = 'normal' | 'bold' | 'italic' | 'oblique' | 'underline' | 'line-through'; + /** + * A string that could be: + * + * - one of the browser colors, (E.g.: `blue`, `red`, `magenta`), + * - the case insensitive hexadecimal color code, (for instance, `#ee82ee`, `#20B2AA`, `#f09` ), or + * - either the `rgb()` or the `rgba()` functions. + * + * For more details, see: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value. + * + * Note, it is highly recommended to use one of the predefined colors of Theia, so the desired color will + * look nice with both the `light` and the `dark` theme too. + */ + export type Color = string; + /** + * Encapsulates styling information of the font. + */ + export interface FontData { + /** + * Zero to any font style. + */ + readonly style?: FontStyle | FontStyle[]; + /** + * The color of the font. + */ + readonly color?: Color; + } + /** + * Arbitrary information that has to be shown either before or after the caption as a prefix or a suffix. + */ + export interface CaptionAffix { + /** + * The text content of the prefix or the suffix. + */ + readonly data: string; + /** + * Font data for customizing the prefix of the suffix. + */ + readonly fontData?: FontData; + } + export interface BaseTailDecoration { + /** + * Optional tooltip for the tail decoration. + */ + readonly tooltip?: string; + } + /** + * Unlike caption suffixes, tail decorations appears right-aligned after the caption and the caption suffixes (is any). + */ + export interface TailDecoration extends BaseTailDecoration { + /** + * The text content of the tail decoration. + */ + readonly data: string; + /** + * Font data for customizing the content. + */ + readonly fontData?: FontData; + } + export interface TailDecorationIcon extends BaseTailDecoration { + /** + * This should be the name of the Font Awesome icon with out the `fa fa-` prefix, just the name, for instance `paw`. + * For the existing icons, see here: https://fontawesome.com/v4.7.0/icons/. + */ + readonly icon: string; + /** + * The color of the icon. + */ + readonly color?: Color; + } + export interface TailDecorationIconClass extends BaseTailDecoration { + /** + * This should be the entire Font Awesome class array, for instance ['fa', 'fa-paw'] + * For the existing icons, see here: https://fontawesome.com/v4.7.0/icons/. + */ + readonly iconClass: string[]; + /** + * The color of the icon. + */ + readonly color?: Color; + } + /** + * Enumeration for the quadrant to overlay the image on. + */ + export enum IconOverlayPosition { + /** + * Overlays the top right quarter of the original image. + */ + TOP_RIGHT, + /** + * Overlays the bottom right of the original image. + */ + BOTTOM_RIGHT, + /** + * Overlays the bottom left segment of the original image. + */ + BOTTOM_LEFT, + /** + * Occupies the top left quarter of the original icon. + */ + TOP_LEFT + } + export namespace IconOverlayPosition { + /** + * Returns with the CSS class style for the enum. + */ + export function getStyle(position: IconOverlayPosition, inSideBar?: boolean): string { + switch (position) { + case IconOverlayPosition.TOP_RIGHT: + return WidgetDecoration.Styles.TOP_RIGHT_CLASS; + case IconOverlayPosition.BOTTOM_RIGHT: + return inSideBar ? WidgetDecoration.Styles.BOTTOM_RIGHT_SIDEBAR_CLASS : WidgetDecoration.Styles.BOTTOM_RIGHT_CLASS; + case IconOverlayPosition.BOTTOM_LEFT: + return WidgetDecoration.Styles.BOTTOM_LEFT_CLASS; + case IconOverlayPosition.TOP_LEFT: + return WidgetDecoration.Styles.TOP_LEFT_CLASS; + } + } + } + /** + * A shape that can be optionally rendered behind the overlay icon. Can be used to further refine colors. + */ + export interface IconOverlayBackground { + /** + * Either `circle` or `square`. + */ + readonly shape: 'circle' | 'square'; + /** + * The color of the background shape. + */ + readonly color?: Color; + } + /** + * Has not effect if the widget being decorated has no associated icon. + */ + export interface BaseOverlay { + /** + * The position where the decoration will be placed on the top of the original icon. + */ + readonly position: IconOverlayPosition; + /** + * The color of the overlaying icon. If not specified, then the default icon color will be used. + */ + readonly color?: Color; + /** + * The optional background color of the overlay icon. + */ + readonly background?: IconOverlayBackground; + } + export interface IconOverlay extends BaseOverlay { + /** + * This should be the name of the Font Awesome icon with out the `fa fa-` prefix, just the name, for instance `paw`. + * For the existing icons, see here: https://fontawesome.com/v4.7.0/icons/. + */ + readonly icon: string; + } + export interface IconClassOverlay extends BaseOverlay { + /** + * This should be the entire Font Awesome class array, for instance ['fa', 'fa-paw'] + * For the existing icons, see here: https://fontawesome.com/v4.7.0/icons/. + */ + readonly iconClass: string[]; + } + /** + * The caption highlighting with the highlighted ranges and an optional background color. + */ + export interface CaptionHighlight { + /** + * The ranges to highlight in the caption. + */ + readonly ranges: CaptionHighlight.Range[]; + /** + * The optional color of the text data that is being highlighted. Falls back to the default `mark` color values defined under a widget segment class. + */ + readonly color?: Color; + /** + * The optional background color of the text data that is being highlighted. + */ + readonly backgroundColor?: Color; + } + export namespace CaptionHighlight { + /** + * A pair of offset and length that has to be highlighted as a range. + */ + export interface Range { + /** + * Zero based offset of the highlighted region. + */ + readonly offset: number; + /** + * The length of the highlighted region. + */ + readonly length: number; + } + export namespace Range { + /** + * `true` if the `arg` is contained in the range. The ranges are closed ranges, hence the check is inclusive. + */ + export function contains(arg: number, range: Range): boolean { + return arg >= range.offset && arg <= (range.offset + range.length); + } + } + /** + * The result of a caption splitting based on the highlighting information. + */ + export interface Fragment { + /** + * The text data of the fragment. + */ + readonly data: string; + /** + * Has to be highlighted if defined. + */ + readonly highligh?: true; + } + /** + * Splits the `caption` argument based on the ranges from the `highlight` argument. + */ + export function split(caption: string, highlight: CaptionHighlight): Fragment[] { + const result: Fragment[] = []; + const ranges = highlight.ranges.slice(); + const containerOf = (index: number) => ranges.findIndex(range => Range.contains(index, range)); + let data = ''; + for (let i = 0; i < caption.length; i++) { + const containerIndex = containerOf(i); + if (containerIndex === -1) { + data += caption[i]; + } else { + if (data.length > 0) { + result.push({ data }); + } + const { length } = ranges.splice(containerIndex, 1).shift()!; + result.push({ data: caption.substr(i, length), highligh: true }); + data = ''; + i = i + length - 1; + } + } + if (data.length > 0) { + result.push({ data }); + } + if (ranges.length !== 0) { + throw new Error('Error occurred when splitting the caption. There was a mismatch between the caption and the corresponding highlighting ranges.'); + } + return result; + } + } + /** + * Encapsulates styling information that has to be applied on the widget which we decorate. + */ + export interface Data { + /** + * The higher number has higher priority. If not specified, treated as `0`. + * When multiple decorators are available for the same item, and decoration data cannot be merged together, + * then the higher priority item will be applied on the decorated element and the lower priority will be ignored. + */ + readonly priority?: number; + /** + * The font data for the caption. + */ + readonly fontData?: FontData; + /** + * The background color of the entire row. + */ + readonly backgroundColor?: Color; + /** + * Optional, leading prefixes right before the caption. + */ + readonly captionPrefixes?: CaptionAffix[]; + /** + * Suffixes that might come after the caption as an additional information. + */ + readonly captionSuffixes?: CaptionAffix[]; + /** + * Optional right-aligned decorations that appear after the widget caption and after the caption suffixes (is any). + */ + readonly tailDecorations?: Array; + /** + * Custom tooltip for the decorated item. Tooltip will be appended to the original tooltip, if any. + */ + readonly tooltip?: string; + /** + * Sets the color of the icon. Ignored if the decorated item has no icon. + */ + readonly iconColor?: Color; + /** + * Has not effect if given, but the widget does not have an associated image. + */ + readonly iconOverlay?: IconOverlay | IconClassOverlay; + /** + * An array of ranges to highlight the caption. + */ + readonly highlight?: CaptionHighlight; + } + export namespace Data { + /** + * Compares the decoration data based on the priority. Lowest priorities come first. + */ + export const comparePriority = (left: Data, right: Data): number => (left.priority || 0) - (right.priority || 0); + } +} diff --git a/packages/markers/src/browser/problem/problem-frontend-module.ts b/packages/markers/src/browser/problem/problem-frontend-module.ts index 31999e902b756..8672bb064b223 100644 --- a/packages/markers/src/browser/problem/problem-frontend-module.ts +++ b/packages/markers/src/browser/problem/problem-frontend-module.ts @@ -25,10 +25,16 @@ import { ProblemManager } from './problem-manager'; import { WidgetFactory } from '@theia/core/lib/browser/widget-manager'; import { NavigatorTreeDecorator } from '@theia/navigator/lib/browser/navigator-decorator-service'; import { ProblemDecorator } from './problem-decorator'; +import { ProblemTabBarDecorator } from './problem-tabbar-decorator'; import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { ProblemLayoutVersion3Migration } from './problem-layout-migrations'; +import { TabBarDecorator } from '@theia/core/lib/browser/shell/tab-bar-decorator'; +import { bindProblemPreferences } from './problem-preferences'; +import '../../../src/browser/style/index.css'; export default new ContainerModule(bind => { + bindProblemPreferences(bind); + bind(ProblemManager).toSelf().inSingletonScope(); bind(ProblemWidget).toDynamicValue(ctx => @@ -46,4 +52,6 @@ export default new ContainerModule(bind => { bind(ProblemDecorator).toSelf().inSingletonScope(); bind(NavigatorTreeDecorator).toService(ProblemDecorator); + bind(ProblemTabBarDecorator).toSelf().inSingletonScope(); + bind(TabBarDecorator).toService(ProblemTabBarDecorator); }); diff --git a/packages/markers/src/browser/problem/problem-preferences.ts b/packages/markers/src/browser/problem/problem-preferences.ts new file mode 100644 index 0000000000000..5d6ad05e621eb --- /dev/null +++ b/packages/markers/src/browser/problem/problem-preferences.ts @@ -0,0 +1,47 @@ +/******************************************************************************** + * Copyright (C) 2019 Ericsson 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 { interfaces } from 'inversify'; +import { createPreferenceProxy, PreferenceProxy, PreferenceService, PreferenceContribution, PreferenceSchema } from '@theia/core/lib/browser'; + +export const ProblemConfigSchema: PreferenceSchema = { + 'type': 'object', + 'properties': { + 'problem.decorations.tabbar.enabled': { + 'type': 'boolean', + 'description': 'Show problem decorators (diagnostic markers) in the tab bars.', + 'default': true + } + } +}; + +export interface ProblemConfiguration { + 'problem.decorations.tabbar.enabled': boolean +} + +export const ProblemPrefenreces = Symbol('ProblemPreferences'); +export type ProblemPrefenreces = PreferenceProxy; + +export const createProblemPreferences = (preferences: PreferenceService): ProblemPrefenreces => + createPreferenceProxy(preferences, ProblemConfigSchema); + +export const bindProblemPreferences = (bind: interfaces.Bind): void => { + bind(ProblemPrefenreces).toDynamicValue(ctx => { + const preferences = ctx.container.get(PreferenceService); + return createProblemPreferences(preferences); + }); + bind(PreferenceContribution).toConstantValue({ schema: ProblemConfigSchema }); +}; diff --git a/packages/markers/src/browser/problem/problem-tabbar-decorator.ts b/packages/markers/src/browser/problem/problem-tabbar-decorator.ts new file mode 100644 index 0000000000000..9dc3eb3de4656 --- /dev/null +++ b/packages/markers/src/browser/problem/problem-tabbar-decorator.ts @@ -0,0 +1,201 @@ +/******************************************************************************** + * Copyright (C) 2019 Ericsson 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 { inject, injectable, postConstruct } from 'inversify'; +import { Diagnostic, DiagnosticSeverity } from 'vscode-languageserver-types'; +import URI from '@theia/core/lib/common/uri'; +import { notEmpty } from '@theia/core/lib/common/objects'; +import { Event, Emitter } from '@theia/core/lib/common/event'; +import { Title, Widget } from '@phosphor/widgets'; +import { WidgetDecoration } from '@theia/core/lib/browser/widget-decoration'; +import { TabBarDecorator } from '@theia/core/lib/browser/shell/tab-bar-decorator'; +import { Marker } from '../../common/marker'; +import { ProblemManager } from './problem-manager'; +import { WorkspaceService } from '@theia/workspace/lib/browser'; +import { ProblemPrefenreces, ProblemConfiguration } from './problem-preferences'; +import { PreferenceChangeEvent } from '@theia/core/lib/browser'; +@injectable() +export class ProblemTabBarDecorator implements TabBarDecorator { + + readonly id = 'theia-problem-tabbar-decorator'; + + protected emitter: Emitter; + + @inject(ProblemPrefenreces) + protected readonly preferences: ProblemPrefenreces; + + @inject(ProblemManager) + protected readonly problemManager: ProblemManager; + + @inject(WorkspaceService) + protected readonly workspaceService: WorkspaceService; + + @postConstruct() + protected init(): void { + this.emitter = new Emitter(); + this.problemManager.onDidChangeMarkers(() => this.fireDidChangeDecorations()); + this.preferences.onPreferenceChanged(event => this.handlePreferenceChange(event)); + } + + decorate(titles: Title[]): Map { + return this.collectDecorators(titles); + } + + get onDidChangeDecorations(): Event { + return this.emitter.event; + } + + protected fireDidChangeDecorations(): void { + this.emitter.fire(undefined); + } + + /** + * Handles changes in prefenrence. + * @param {PreferenceChangeEvent} event - The event of the preference change. + */ + protected async handlePreferenceChange(event: PreferenceChangeEvent): Promise { + const { preferenceName } = event; + if (preferenceName === 'problem.decorations.tabbar.enabled') { + this.fireDidChangeDecorations(); + } + } + + /** + * Collects decorators for the tabs. + * @returns {Map} A map from the tab URI to the tab decoration data. + */ + protected collectDecorators(titles: Title[]): Map { + const result: Map> = new Map(); + if (this.preferences['problem.decorations.tabbar.enabled']) { + const markers = this.groupMarkersByURI(this.collectMarkers()); + for (const title of titles) { + // Ensure the title.caption does not contain illegal characters for URI. + try { + const fileUri: URI = new URI(title.caption); + const marker = markers.get(fileUri.withScheme('file').toString()); + if (marker) { + result.set(title.caption, marker); + } + } catch (e) { + } + } + } + const urlDecoratorMap = new Map(Array.from(result.entries()) + .map(entry => [entry[0], this.toDecorator(entry[1])] as [string, WidgetDecoration.Data])); + return urlDecoratorMap; + } + + /** + * Groups markers by the URI of the editor they decorate. + * @param {Marker[]} markers - all diagnostic markers collected. + * @returns {Map>} A map from URI of the editor to its diagnostic markers. + */ + protected groupMarkersByURI(markers: Marker[]): Map> { + const result: Map> = new Map(); + for (const [uri, marker] of new Map(markers.map(m => [new URI(m.uri), m] as [URI, Marker])).entries()) { + const uriString = uri.toString(); + result.set(uriString, marker); + } + return result; + } + + /** + * Collects all diagnostic markers from the problem manager. + * @returns {Marker[]} An array of diagnostic markers. + */ + protected collectMarkers(): Marker[] { + return Array.from(this.problemManager.getUris()) + .map(str => new URI(str)) + .map(uri => this.problemManager.findMarkers({ uri })) + .map(markers => markers.sort(this.compare.bind(this)).shift()) + .filter(notEmpty) + .filter(this.filterMarker.bind(this)); + } + + /** + * Converts a diagnostic marker to a decorator. + * @param {Marker} marker - a diagnostic marker. + * @returns {WidgetDecoration.Data} The decoration data. + */ + protected toDecorator(marker: Marker): WidgetDecoration.Data { + const position = WidgetDecoration.IconOverlayPosition.BOTTOM_RIGHT; + const icon = this.getOverlayIcon(marker); + const color = this.getOverlayIconColor(marker); + return { + iconOverlay: { + position, + icon, + color, + background: { + shape: 'circle', + color: 'var(--theia-layout-color0)' + } + } + }; + } + + /** + * Gets the appropriate overlay icon for decoration. + * @param {Marker} marker - a diagnostic marker. + * @returns {string} A string representing the overlay icon class. + */ + protected getOverlayIcon(marker: Marker): string { + const { severity } = marker.data; + switch (severity) { + case 1: return 'times-circle'; + case 2: return 'exclamation-circle'; + case 3: return 'info-circle'; + default: return 'hand-o-up'; + } + } + + /** + * Gets the appropriate overlay icon color for decoration. + * @param {Marker} marker - a diagnostic marker. + * @returns {WidgetDecoration.Color} The decoration color. + */ + protected getOverlayIconColor(marker: Marker): WidgetDecoration.Color { + const { severity } = marker.data; + switch (severity) { + case 1: return 'var(--theia-error-color0)'; + case 2: return 'var(--theia-warn-color0)'; + case 3: return 'var(--theia-info-color0)'; + default: return 'var(--theia-success-color0)'; + } + } + + /** + * Filters the diagnostic marker by its severity. + * @param {Marker} marker - a diagnostic marker. + * @returns {boolean} Whether the diagnostic marker is of `Error`, `Warning`, or `Information` severity. + */ + protected filterMarker(marker: Marker): boolean { + const { severity } = marker.data; + return severity === DiagnosticSeverity.Error + || severity === DiagnosticSeverity.Warning + || severity === DiagnosticSeverity.Information; + } + + /** + * Compares the severity of two diagnostic markers. + * @param {Marker} left - a diagnostic marker to be compared. + * @param {Marker} right - a diagnostic marker to be compared. + * @returns {number} Number indicating which marker takes priority (`left` if negative, `right` if positive). + */ + protected compare(left: Marker, right: Marker): number { + return (left.data.severity || Number.MAX_SAFE_INTEGER) - (right.data.severity || Number.MAX_SAFE_INTEGER); + } +} diff --git a/packages/typehierarchy/src/browser/tree/typehierarchy-tree.ts b/packages/typehierarchy/src/browser/tree/typehierarchy-tree.ts index 11c2ac019ea63..25f8fe6f52bb9 100644 --- a/packages/typehierarchy/src/browser/tree/typehierarchy-tree.ts +++ b/packages/typehierarchy/src/browser/tree/typehierarchy-tree.ts @@ -18,7 +18,7 @@ import { injectable } from 'inversify'; import { v4 } from 'uuid'; import URI from '@theia/core/lib/common/uri'; import { Location } from '@theia/editor/lib/browser/editor'; -import { TreeDecoration } from '@theia/core/lib/browser/tree/tree-decorator'; +import { TreeDecoration, DecoratedTreeNode } from '@theia/core/lib/browser/tree/tree-decorator'; import { TreeImpl, TreeNode, CompositeTreeNode, ExpandableTreeNode, SelectableTreeNode } from '@theia/core/lib/browser/tree'; import { TypeHierarchyItem } from '@theia/languages/lib/browser/typehierarchy/typehierarchy-protocol'; import { TypeHierarchyDirection, ResolveTypeHierarchyItemParams } from '@theia/languages/lib/browser/typehierarchy/typehierarchy-protocol'; @@ -113,7 +113,7 @@ export namespace TypeHierarchyTree { } - export interface Node extends CompositeTreeNode, ExpandableTreeNode, SelectableTreeNode, TreeDecoration.DecoratedTreeNode { + export interface Node extends CompositeTreeNode, ExpandableTreeNode, SelectableTreeNode, DecoratedTreeNode { readonly item: TypeHierarchyItem; resolved: boolean; }