diff --git a/packages/plugin-ext/src/api/plugin-api.ts b/packages/plugin-ext/src/api/plugin-api.ts index c520c0b714cae..7795735fbac42 100644 --- a/packages/plugin-ext/src/api/plugin-api.ts +++ b/packages/plugin-ext/src/api/plugin-api.ts @@ -313,7 +313,7 @@ export interface QuickOpenMain { export interface WorkspaceMain { $pickWorkspaceFolder(options: WorkspaceFolderPickOptionsMain): Promise; $startFileSearch(includePattern: string, excludePatternOrDisregardExcludes: string | false, - maxResults: number | undefined, token: theia.CancellationToken): PromiseLike; + maxResults: number | undefined, token: theia.CancellationToken): PromiseLike; } @@ -326,6 +326,50 @@ export interface DialogsMain { $showSaveDialog(options: SaveDialogOptionsMain): Promise; } +export interface TreeViewsMain { + $registerTreeDataProvider(treeViewId: string): void; + $refresh(treeViewId: string): void; + $reveal(treeViewId: string): void; +} + +export interface TreeViewsExt { + $getChildren(treeViewId: string, treeItemId: string | undefined): Promise; + $setExpanded(treeViewId: string, treeItemId: string, expanded: boolean): Promise; + $setSelection(treeViewId: string, treeItemId: string): Promise; +} + +export class TreeViewItem { + + id: string; + + label: string; + + icon?: string; + + tooltip?: string; + + collapsibleState?: TreeViewItemCollapsibleState; + +} + +/** + * Collapsible state of the tree item + */ +export enum TreeViewItemCollapsibleState { + /** + * Determines an item can be neither collapsed nor expanded. Implies it has no children. + */ + None = 0, + /** + * Determines an item is collapsed + */ + Collapsed = 1, + /** + * Determines an item is expanded + */ + Expanded = 2 +} + export interface WindowStateExt { $onWindowStateChanged(focus: boolean): void; } @@ -694,6 +738,7 @@ export const PLUGIN_RPC_CONTEXT = { STATUS_BAR_MESSAGE_REGISTRY_MAIN: >createProxyIdentifier('StatusBarMessageRegistryMain'), ENV_MAIN: createProxyIdentifier('EnvMain'), TERMINAL_MAIN: createProxyIdentifier('TerminalServiceMain'), + TREE_VIEWS_MAIN: createProxyIdentifier('TreeViewsMain'), PREFERENCE_REGISTRY_MAIN: createProxyIdentifier('PreferenceRegistryMain'), OUTPUT_CHANNEL_REGISTRY_MAIN: >createProxyIdentifier('OutputChannelRegistryMain'), LANGUAGES_MAIN: createProxyIdentifier('LanguagesMain'), @@ -709,6 +754,7 @@ export const MAIN_RPC_CONTEXT = { EDITORS_AND_DOCUMENTS_EXT: createProxyIdentifier('EditorsAndDocumentsExt'), DOCUMENTS_EXT: createProxyIdentifier('DocumentsExt'), TERMINAL_EXT: createProxyIdentifier('TerminalServiceExt'), + TREE_VIEWS_EXT: createProxyIdentifier('TreeViewsExt'), PREFERENCE_REGISTRY_EXT: createProxyIdentifier('PreferenceRegistryExt'), LANGUAGES_EXT: createProxyIdentifier('LanguagesExt'), }; diff --git a/packages/plugin-ext/src/main/browser/main-context.ts b/packages/plugin-ext/src/main/browser/main-context.ts index deed8fbdcc3e3..a54487c90f672 100644 --- a/packages/plugin-ext/src/main/browser/main-context.ts +++ b/packages/plugin-ext/src/main/browser/main-context.ts @@ -29,6 +29,7 @@ import { OutputChannelRegistryMainImpl } from './output-channel-registry-main'; import { TerminalServiceMainImpl } from './terminal-main'; import { LanguagesMainImpl } from './languages-main'; import { DialogsMainImpl } from './dialogs-main'; +import { TreeViewsMainImpl } from './view/tree-views-main'; export function setUpPluginApi(rpc: RPCProtocol, container: interfaces.Container): void { const commandRegistryMain = new CommandRegistryMainImpl(rpc, container); @@ -60,9 +61,12 @@ export function setUpPluginApi(rpc: RPCProtocol, container: interfaces.Container const envMain = new EnvMainImpl(rpc, container); rpc.set(PLUGIN_RPC_CONTEXT.ENV_MAIN, envMain); - const terminalMain = new TerminalServiceMainImpl(container, rpc); + const terminalMain = new TerminalServiceMainImpl(rpc, container); rpc.set(PLUGIN_RPC_CONTEXT.TERMINAL_MAIN, terminalMain); + const treeViewsMain = new TreeViewsMainImpl(rpc, container); + rpc.set(PLUGIN_RPC_CONTEXT.TREE_VIEWS_MAIN, treeViewsMain); + const outputChannelRegistryMain = new OutputChannelRegistryMainImpl(container); rpc.set(PLUGIN_RPC_CONTEXT.OUTPUT_CHANNEL_REGISTRY_MAIN, outputChannelRegistryMain); diff --git a/packages/plugin-ext/src/main/browser/style/view-registry.css b/packages/plugin-ext/src/main/browser/style/view-registry.css index 040788e886968..34c30f127dd29 100644 --- a/packages/plugin-ext/src/main/browser/style/view-registry.css +++ b/packages/plugin-ext/src/main/browser/style/view-registry.css @@ -69,13 +69,13 @@ box-sizing: border-box; } -.theia-views-container-section-control[role='opened']:before { +.theia-views-container-section-control[opened='true']:before { font-family: FontAwesome; font-size: calc(var(--theia-content-font-size) * 0.8); content: "\F0D7"; } -.theia-views-container-section-control[role='closed']:before { +.theia-views-container-section-control[opened='false']:before { font-family: FontAwesome; font-size: calc(var(--theia-content-font-size) * 0.8); content: "\F0DA"; @@ -95,12 +95,34 @@ .theia-views-container-section-content { min-height: 30px; - text-align: center; color: var(--theia-ui-font-color3); font-size: var(--theia-ui-font-size3); - padding: 20px 0px; + position: relative; + + width: 100%; + height: 300px; } -.theia-views-container-section-content[role='closed'] { +.theia-views-container-section-content[opened='false'] { display: none; } + +.theia-views-container-section-content > div { + position: absolute; + width: 100%; + height: 100%; +} + +.theia-views-container-section-content .tree-view-icon { + max-height: calc(var(--theia-content-font-size) * 0.8); + line-height: 0.8em; + +} + +.theia-views-container-section-content .tree-view-icon:before { + font-size: calc(var(--theia-content-font-size) * 0.8); + text-align: center; + margin-right: 4px; + top: -1px; + position: relative; +} diff --git a/packages/plugin-ext/src/main/browser/terminal-main.ts b/packages/plugin-ext/src/main/browser/terminal-main.ts index c956fc63437c3..5874a6a0d50fb 100644 --- a/packages/plugin-ext/src/main/browser/terminal-main.ts +++ b/packages/plugin-ext/src/main/browser/terminal-main.ts @@ -34,7 +34,7 @@ export class TerminalServiceMainImpl implements TerminalServiceMain { private terminalNumber = 0; private readonly TERM_ID_PREFIX = 'plugin-terminal-'; - constructor(container: interfaces.Container, rpc: RPCProtocol) { + constructor(rpc: RPCProtocol, container: interfaces.Container) { this.terminalService = container.get(TerminalService); this.shell = container.get(ApplicationShell); this.extProxy = rpc.getProxy(MAIN_RPC_CONTEXT.TERMINAL_EXT); diff --git a/packages/plugin-ext/src/main/browser/view/tree-views-main.tsx b/packages/plugin-ext/src/main/browser/view/tree-views-main.tsx new file mode 100644 index 0000000000000..5dee54b2f1bc0 --- /dev/null +++ b/packages/plugin-ext/src/main/browser/view/tree-views-main.tsx @@ -0,0 +1,247 @@ +/******************************************************************************** + * Copyright (C) 2018 Red Hat, Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { interfaces, injectable, inject, Container } from 'inversify'; +import { MAIN_RPC_CONTEXT, TreeViewsMain, TreeViewsExt } from '../../../api/plugin-api'; +import { RPCProtocol } from '@theia/plugin-ext/src/api/rpc-protocol'; +import { ViewRegistry } from './view-registry'; +import { Message } from '@phosphor/messaging'; + +import { + TreeWidget, + ContextMenuRenderer, + TreeModel, + TreeNode, + NodeProps, + TreeProps, + createTreeContainer, + SelectableTreeNode, + ExpandableTreeNode, + CompositeTreeNode, + TreeImpl, + Tree +} from '@theia/core/lib/browser'; + +import { TreeViewItem, TreeViewItemCollapsibleState } from '../../../api/plugin-api'; + +import * as ReactDOM from 'react-dom'; +import * as React from 'react'; + +export class TreeViewsMainImpl implements TreeViewsMain { + + private proxy: TreeViewsExt; + + /** + * key: Tree View ID + * value: TreeViewDataProviderMain + */ + private dataProviders: Map = new Map(); + + private treeViewWidgets: Map = new Map(); + + private viewRegistry: ViewRegistry; + + constructor(rpc: RPCProtocol, private container: interfaces.Container) { + this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.TREE_VIEWS_EXT); + this.viewRegistry = container.get(ViewRegistry); + } + + $registerTreeDataProvider(treeViewId: string): void { + const dataProvider = new TreeViewDataProviderMain(treeViewId, this.proxy); + this.dataProviders.set(treeViewId, dataProvider); + + const treeViewContainer = this.createTreeViewContainer(dataProvider); + const treeViewWidget = treeViewContainer.get(TreeViewWidget); + + this.treeViewWidgets.set(treeViewId, treeViewWidget); + + this.viewRegistry.onRegisterTreeView(treeViewId, treeViewWidget); + + this.handleTreeEvents(treeViewId, treeViewWidget); + } + + $refresh(treeViewId: string): void { + const treeViewWidget = this.treeViewWidgets.get(treeViewId); + if (treeViewWidget) { + treeViewWidget.model.refresh(); + } + } + + $reveal(treeViewId: string): void { + } + + createTreeViewContainer(dataProvider: TreeViewDataProviderMain): Container { + const child = createTreeContainer(this.container); + + child.bind(TreeViewDataProviderMain).toConstantValue(dataProvider); + + child.unbind(TreeImpl); + child.bind(PluginTree).toSelf(); + child.rebind(Tree).toDynamicValue(ctx => ctx.container.get(PluginTree)); + + child.unbind(TreeWidget); + child.bind(TreeViewWidget).toSelf(); + + return child; + } + + handleTreeEvents(treeViewId: string, treeViewWidget: TreeViewWidget) { + treeViewWidget.model.onExpansionChanged(event => { + this.proxy.$setExpanded(treeViewId, event.id, event.expanded); + }); + + treeViewWidget.model.onSelectionChanged(event => { + if (event.length === 1) { + this.proxy.$setSelection(treeViewId, event[0].id); + } + }); + } + +} + +export interface TreeViewFolderNode extends SelectableTreeNode, ExpandableTreeNode, CompositeTreeNode { +} + +export interface TreeViewFileNode extends SelectableTreeNode { +} + +export class TreeViewDataProviderMain { + + constructor(private treeViewId: string, private proxy: TreeViewsExt) { + } + + createFolderNode(item: TreeViewItem): TreeViewFolderNode { + let expanded = false; + if (TreeViewItemCollapsibleState.Expanded === item.collapsibleState) { + expanded = true; + } + + return { + id: item.id, + parent: undefined, + name: item.label, + icon: item.icon, + description: item.tooltip, + visible: true, + selected: false, + expanded, + children: [] + }; + } + + createFileNode(item: TreeViewItem): TreeViewFileNode { + return { + id: item.id, + name: item.label, + icon: item.icon, + description: item.tooltip, + parent: undefined, + visible: true, + selected: false, + }; + } + + /** + * Creates TreeNode + * + * @param item tree view item from the ext + */ + createTreeNode(item: TreeViewItem): TreeNode { + if ('collapsibleState' in item) { + if (TreeViewItemCollapsibleState.Expanded === item.collapsibleState) { + return this.createFolderNode(item); + } else if (TreeViewItemCollapsibleState.Collapsed === item.collapsibleState) { + return this.createFolderNode(item); + } + } + + return this.createFileNode(item); + } + + async resolveChildren(itemId: string): Promise { + const children = await this.proxy.$getChildren(this.treeViewId, itemId); + + if (children) { + return children.map(value => this.createTreeNode(value)); + } + + return []; + } + +} + +@injectable() +export class TreeViewWidget extends TreeWidget { + + constructor( + @inject(TreeProps) readonly treeProps: TreeProps, + @inject(TreeModel) readonly model: TreeModel, + @inject(ContextMenuRenderer) readonly contextMenuRenderer: ContextMenuRenderer, + @inject(TreeViewDataProviderMain) readonly dataProvider: TreeViewDataProviderMain) { + + super(treeProps, model, contextMenuRenderer); + } + + protected onAfterAttach(msg: Message): void { + super.onAfterAttach(msg); + + setTimeout(() => { + // Set root node + const node = { + id: '', + parent: undefined, + name: '', + visible: false, + expanded: true, + selected: false, + children: [] + }; + + this.model.root = node; + }); + } + + public updateWidget() { + this.updateRows(); + + // Need to wait for 20 miliseconds until rows become updated. + setTimeout(() => { + ReactDOM.render({this.render()}, this.node, () => this.onRender.dispose()); + }, 20); + } + + renderIcon(node: TreeNode, props: NodeProps): React.ReactNode { + if (node.icon) { + return
; + } + + return undefined; + } + +} + +@injectable() +export class PluginTree extends TreeImpl { + + constructor(@inject(TreeViewDataProviderMain) private readonly dataProvider: TreeViewDataProviderMain) { + super(); + } + + protected async resolveChildren(parent: CompositeTreeNode): Promise { + return this.dataProvider.resolveChildren(parent.id); + } + +} diff --git a/packages/plugin-ext/src/main/browser/view/view-registry.ts b/packages/plugin-ext/src/main/browser/view/view-registry.ts index eec8a9e3bbde7..ad1abf63b74aa 100644 --- a/packages/plugin-ext/src/main/browser/view/view-registry.ts +++ b/packages/plugin-ext/src/main/browser/view/view-registry.ts @@ -20,6 +20,7 @@ import { ApplicationShell } from '@theia/core/lib/browser'; import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; import { Widget } from '@theia/core/lib/browser/widgets/widget'; import { ViewsContainerWidget } from './views-container-widget'; +import { TreeViewWidget } from './tree-views-main'; export interface ViewContainerRegistry { container: ViewContainer; @@ -36,18 +37,23 @@ export class ViewRegistry { @inject(FrontendApplicationStateService) protected applicationStateService: FrontendApplicationStateService; - containers: ViewContainerRegistry[] = new Array(); + private containers: ViewContainerRegistry[] = new Array(); + + private containersWidgets: Map = new Map(); + + private treeViewWidgets: Map = new Map(); @postConstruct() init() { this.applicationStateService.reachedState('ready').then(() => { this.showContainers(); + this.showTreeViewWidgets(); }); } getArea(location: string): ApplicationShell.Area { switch (location) { - case 'right': return'right'; + case 'right': return 'right'; case 'bottom': return 'bottom'; case 'top': return 'top'; } @@ -79,9 +85,9 @@ export class ViewRegistry { // Show views containers this.containers.forEach(registry => { const widget = new ViewsContainerWidget(registry.container, registry.views); - const tabBar = this.applicationShell.getTabBarFor(widget); - // const area = this.applicationShell.getAreaFor(widget); + this.containersWidgets.set(registry.container.id, widget); + const tabBar = this.applicationShell.getTabBarFor(widget); if (!tabBar) { const widgetArgs: ApplicationShell.WidgetOptions = { area: registry.area @@ -97,4 +103,19 @@ export class ViewRegistry { } } + onRegisterTreeView(treeViewid: string, treeViewWidget: TreeViewWidget) { + this.treeViewWidgets.set(treeViewid, treeViewWidget); + } + + showTreeViewWidgets(): void { + this.treeViewWidgets.forEach((treeViewWidget, treeViewId) => { + this.containersWidgets.forEach((viewsContainerWidget, viewsContainerId) => { + if (viewsContainerWidget.hasView(treeViewId)) { + viewsContainerWidget.addWidget(treeViewId, treeViewWidget); + this.applicationShell.activateWidget(viewsContainerWidget.id); + } + }); + }); + } + } diff --git a/packages/plugin-ext/src/main/browser/view/views-container-widget.ts b/packages/plugin-ext/src/main/browser/view/views-container-widget.ts new file mode 100644 index 0000000000000..43fa090660254 --- /dev/null +++ b/packages/plugin-ext/src/main/browser/view/views-container-widget.ts @@ -0,0 +1,202 @@ +/******************************************************************************** + * Copyright (C) 2018 Red Hat, Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { ViewContainer, View } from '../../../common'; +import { TreeViewWidget } from './tree-views-main'; +import { BaseWidget, Widget } from '@theia/core/lib/browser'; + +export function createElement(className?: string): HTMLDivElement { + const div = document.createElement('div'); + if (className) { + div.classList.add(className); + } + return div; +} + +export interface SectionParams { + view: View, + container: ViewsContainerWidget +} + +export class ViewsContainerWidget extends BaseWidget { + + private sections: Map = new Map(); + + sectionTitle: HTMLElement; + + constructor(protected viewContainer: ViewContainer, + protected views: View[]) { + super(); + + this.id = `views-container-widget-${viewContainer.id}`; + this.title.closable = true; + this.title.caption = this.title.label = viewContainer.title; + + this.addClass('theia-views-container'); + + // create container title + this.sectionTitle = createElement('theia-views-container-title'); + this.sectionTitle.innerText = viewContainer.title; + this.node.appendChild(this.sectionTitle); + + // update sections + const instance = this; + + this.views.forEach(view => { + const section = new ViewContainerSection(view, instance); + this.sections.set(view.id, section); + this.node.appendChild(section.node); + }); + } + + public hasView(viewId: string): boolean { + const result = this.views.find(view => view.id === viewId); + return result !== undefined; + } + + public addWidget(viewId: string, viewWidget: TreeViewWidget) { + const section = this.sections.get(viewId); + if (section) { + section.addViewWidget(viewWidget); + this.updateDimensions(); + } + } + + protected onResize(msg: Widget.ResizeMessage): void { + super.onResize(msg); + this.updateDimensions(); + } + + public updateDimensions() { + let visibleSections = 0; + let availableHeight = this.node.offsetHeight; + + availableHeight -= this.sectionTitle.offsetHeight; + + // Determine available space for sections and how much sections are opened + this.sections.forEach((section, key) => { + availableHeight -= section.header.offsetHeight; + + if (section.opened) { + visibleSections++; + } + }); + + // Do nothing if there is no opened sections + if (visibleSections === 0) { + return; + } + + // Get section height + const sectionHeight = availableHeight / visibleSections; + + // Update height of opened sections + this.sections.forEach((section, key) => { + if (section.opened) { + section.content.style.height = sectionHeight + 'px'; + } + }); + + setTimeout(() => { + // Update content of visible sections + this.sections.forEach((section, key) => { + if (section.opened) { + section.update(); + } + }); + }, 1); + } + +} + +export class ViewContainerSection { + + node: HTMLDivElement; + + header: HTMLDivElement; + control: HTMLDivElement; + title: HTMLDivElement; + content: HTMLDivElement; + + opened: boolean = true; + + private viewWidget: TreeViewWidget; + + constructor(public view: View, protected container: ViewsContainerWidget) { + this.node = createElement('theia-views-container-section'); + + this.createTitle(); + this.createContent(); + } + + createTitle() { + this.header = createElement('theia-views-container-section-title'); + this.node.appendChild(this.header); + + this.control = createElement('theia-views-container-section-control'); + this.control.setAttribute('opened', '' + this.opened); + this.header.appendChild(this.control); + + this.title = createElement('theia-views-container-section-label'); + this.title.innerText = this.view.name; + this.header.appendChild(this.title); + + this.header.onclick = () => { this.handleClick(); }; + } + + createContent() { + this.content = createElement('theia-views-container-section-content'); + this.content.setAttribute('opened', '' + this.opened); + this.node.appendChild(this.content); + + this.content.innerHTML = '
' + this.view.name + '
'; + } + + handleClick() { + this.opened = !this.opened; + + this.control.setAttribute('opened', '' + this.opened); + this.content.setAttribute('opened', '' + this.opened); + + this.container.updateDimensions(); + + setTimeout(() => { + if (this.opened) { + this.update(); + } + }, 1); + } + + addViewWidget(viewWidget: TreeViewWidget) { + this.content.innerHTML = ''; + + this.viewWidget = viewWidget; + Widget.attach(viewWidget, this.content); + + viewWidget.model.onChanged(e => { + this.update(); + }); + + this.update(); + } + + update() { + if (this.viewWidget) { + this.viewWidget.updateWidget(); + } + } + +} diff --git a/packages/plugin-ext/src/main/browser/view/views-container-widget.tsx b/packages/plugin-ext/src/main/browser/view/views-container-widget.tsx deleted file mode 100644 index 89fa57bcc2d5c..0000000000000 --- a/packages/plugin-ext/src/main/browser/view/views-container-widget.tsx +++ /dev/null @@ -1,78 +0,0 @@ -/******************************************************************************** - * Copyright (C) 2018 Red Hat, Inc. and others. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * This Source Code may also be made available under the following Secondary - * Licenses when the conditions for such availability set forth in the Eclipse - * Public License v. 2.0 are satisfied: GNU General Public License, version 2 - * with the GNU Classpath Exception which is available at - * https://www.gnu.org/software/classpath/license.html. - * - * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 - ********************************************************************************/ - -import { ViewContainer, View } from '../../../common'; -import { ReactWidget } from '@theia/core/lib/browser/widgets/react-widget'; -import * as React from 'react'; - -export class ViewContainerSection extends React.Component { - - constructor(protected view: View) { - super(view); - - this.state = {opened: 'opened'}; - } - - public handleOnClick() { - if ('opened' === this.state.opened) { - this.setState({opened: 'closed'}); - } else { - this.setState({opened: 'opened'}); - } - } - - public render() { - const title = -
this.handleOnClick()}> -
-
{this.view.name}
-
; - - const content = -
{this.props.name}
; - - return
- {title} - {content} -
; - } -} - -export class ViewsContainerWidget extends ReactWidget { - - constructor(protected viewContainer: ViewContainer, - protected views: View[]) { - super(); - - this.id = `views-container-widget-${viewContainer.id}`; - this.title.closable = true; - this.title.caption = this.title.label = viewContainer.title; - - this.addClass('theia-views-container'); - - this.update(); - } - - protected render(): React.ReactNode { - const list = this.views.map(view => ); - - return -
{this.viewContainer.title}
- {...list} -
; - } - -} diff --git a/packages/plugin-ext/src/plugin/plugin-context.ts b/packages/plugin-ext/src/plugin/plugin-context.ts index 3c7a55764e970..871803641b24d 100644 --- a/packages/plugin-ext/src/plugin/plugin-context.ts +++ b/packages/plugin-ext/src/plugin/plugin-context.ts @@ -37,6 +37,7 @@ import { EndOfLine, SnippetString, ThemeColor, + ThemeIcon, TextEditorRevealType, TextEditorLineNumbersStyle, DecorationRangeBehavior, @@ -64,9 +65,11 @@ import { CodeActionTrigger, TextDocumentSaveReason, CodeAction, + TreeItem, + TreeItemCollapsibleState, SymbolKind, DocumentSymbol, - SymbolInformation, + SymbolInformation } from './types-impl'; import { EditorsAndDocumentsExtImpl } from './editors-and-documents'; import { TextEditorsExtImpl } from './text-editors'; @@ -81,6 +84,7 @@ import { fromDocumentSelector } from './type-converters'; import { DialogsExtImpl } from './dialogs'; import { CancellationToken } from '@theia/core/lib/common/cancellation'; import { MarkdownString } from './markdown-string'; +import { TreeViewsExtImpl } from './tree/tree-views'; export function createAPIFactory( rpc: RPCProtocol, @@ -88,7 +92,7 @@ export function createAPIFactory( envExt: EnvExtImpl, preferenceRegistryExt: PreferenceRegistryExtImpl): PluginAPIFactory { - const commandRegistryExt = rpc.set(MAIN_RPC_CONTEXT.COMMAND_REGISTRY_EXT, new CommandRegistryImpl(rpc)); + const commandRegistry = rpc.set(MAIN_RPC_CONTEXT.COMMAND_REGISTRY_EXT, new CommandRegistryImpl(rpc)); const quickOpenExt = rpc.set(MAIN_RPC_CONTEXT.QUICK_OPEN_EXT, new QuickOpenExtImpl(rpc)); const dialogsExt = new DialogsExtImpl(rpc); const messageRegistryExt = new MessageRegistryExt(rpc); @@ -101,16 +105,17 @@ export function createAPIFactory( const terminalExt = rpc.set(MAIN_RPC_CONTEXT.TERMINAL_EXT, new TerminalServiceExtImpl(rpc)); const outputChannelRegistryExt = new OutputChannelRegistryExt(rpc); const languagesExt = rpc.set(MAIN_RPC_CONTEXT.LANGUAGES_EXT, new LanguagesExtImpl(rpc, documents)); + const treeViewsExt = rpc.set(MAIN_RPC_CONTEXT.TREE_VIEWS_EXT, new TreeViewsExtImpl(rpc, commandRegistry)); return function (plugin: InternalPlugin): typeof theia { const commands: typeof theia.commands = { // tslint:disable-next-line:no-any registerCommand(command: theia.Command, handler?: (...args: any[]) => T | Thenable): Disposable { - return commandRegistryExt.registerCommand(command, handler); + return commandRegistry.registerCommand(command, handler); }, // tslint:disable-next-line:no-any executeCommand(commandId: string, ...args: any[]): PromiseLike { - return commandRegistryExt.executeCommand(commandId, args); + return commandRegistry.executeCommand(commandId, args); }, // tslint:disable-next-line:no-any registerTextEditorCommand(command: theia.Command, callback: (textEditor: theia.TextEditor, edit: theia.TextEditorEdit, ...arg: any[]) => void): Disposable { @@ -118,7 +123,7 @@ export function createAPIFactory( }, // tslint:disable-next-line:no-any registerHandler(commandId: string, handler: (...args: any[]) => any): Disposable { - return commandRegistryExt.registerHandler(commandId, handler); + return commandRegistry.registerHandler(commandId, handler); } }; @@ -210,7 +215,6 @@ export function createAPIFactory( onDidChangeWindowState(listener, thisArg?, disposables?): theia.Disposable { return windowStateExt.onDidChangeWindowState(listener, thisArg, disposables); }, - createTerminal(nameOrOptions: theia.TerminalOptions | (string | undefined), shellPath?: string, shellArgs?: string[]): theia.Terminal { return terminalExt.createTerminal(nameOrOptions, shellPath, shellArgs); }, @@ -220,9 +224,14 @@ export function createAPIFactory( set onDidCloseTerminal(event: theia.Event) { terminalExt.onDidCloseTerminal = event; }, - createTextEditorDecorationType(options: theia.DecorationRenderOptions): theia.TextEditorDecorationType { return editors.createTextEditorDecorationType(options); + }, + registerTreeDataProvider(viewId: string, treeDataProvider: theia.TreeDataProvider): Disposable { + return treeViewsExt.registerTreeDataProvider(viewId, treeDataProvider); + }, + createTreeView(viewId: string, options: { treeDataProvider: theia.TreeDataProvider }): theia.TreeView { + return treeViewsExt.createTreeView(viewId, options); } }; @@ -248,11 +257,9 @@ export function createAPIFactory( onDidOpenTextDocument(listener, thisArg?, disposables?) { return documents.onDidAddDocument(listener, thisArg, disposables); }, - onDidSaveTextDocument(listener, thisArg?, disposables?) { return documents.onDidSaveTextDocument(listener, thisArg, disposables); }, - getConfiguration(section?, resource?): theia.WorkspaceConfiguration { return preferenceRegistryExt.getConfiguration(section, resource); }, @@ -360,7 +367,6 @@ export function createAPIFactory( registerDocumentSymbolProvider(selector: theia.DocumentSelector, provider: theia.DocumentSymbolProvider): theia.Disposable { return languagesExt.registerDocumentSymbolProvider(selector, provider); } - }; const plugins: typeof theia.plugins = { @@ -401,6 +407,7 @@ export function createAPIFactory( TextEditorCursorStyle, TextEditorLineNumbersStyle, ThemeColor, + ThemeIcon, SnippetString, DecorationRangeBehavior, OverviewRulerLane, @@ -427,9 +434,11 @@ export function createAPIFactory( CodeActionTrigger, TextDocumentSaveReason, CodeAction, + TreeItem, + TreeItemCollapsibleState, SymbolKind, DocumentSymbol, - SymbolInformation, + SymbolInformation }; }; } diff --git a/packages/plugin-ext/src/plugin/tree/tree-views.ts b/packages/plugin-ext/src/plugin/tree/tree-views.ts new file mode 100644 index 0000000000000..80038f4b97374 --- /dev/null +++ b/packages/plugin-ext/src/plugin/tree/tree-views.ts @@ -0,0 +1,267 @@ +/******************************************************************************** + * Copyright (C) 2018 Red Hat, Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { TreeDataProvider, TreeView, TreeViewExpansionEvent } from '@theia/plugin'; +import { Emitter } from '@theia/core/lib/common/event'; +import { Disposable } from '../types-impl'; +import { PLUGIN_RPC_CONTEXT, TreeViewsExt, TreeViewsMain, TreeViewItem } from '../../api/plugin-api'; +import { RPCProtocol } from '../../api/rpc-protocol'; +import { CommandRegistryImpl } from '../command-registry'; + +export class TreeViewsExtImpl implements TreeViewsExt { + + private proxy: TreeViewsMain; + + private treeViews: Map> = new Map>(); + + constructor(rpc: RPCProtocol, private commandRegistry: CommandRegistryImpl) { + this.proxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.TREE_VIEWS_MAIN); + } + + registerTreeDataProvider(treeViewId: string, treeDataProvider: TreeDataProvider): Disposable { + const treeView = this.createTreeView(treeViewId, { treeDataProvider }); + + return Disposable.create(() => { + this.treeViews.delete(treeViewId); + treeView.dispose(); + }); + } + + createTreeView(treeViewId: string, options: { treeDataProvider: TreeDataProvider }): TreeView { + if (!options || !options.treeDataProvider) { + throw new Error('Options with treeDataProvider is mandatory'); + } + + const treeView = new TreeViewExtImpl(treeViewId, options.treeDataProvider, this.proxy, this.commandRegistry); + this.treeViews.set(treeViewId, treeView); + + return { + get onDidExpandElement() { + return treeView.onDidExpandElement; + }, + + get onDidCollapseElement() { + return treeView.onDidCollapseElement; + }, + + get selection() { + return treeView.selectedElements; + }, + + reveal: (element: T, _options: { select?: boolean }): Thenable => treeView.reveal(element, _options), + + dispose: () => { + this.treeViews.delete(treeViewId); + treeView.dispose(); + } + }; + + } + + async $getChildren(treeViewId: string, treeItemId: string): Promise { + const treeView = this.treeViews.get(treeViewId); + if (!treeView) { + throw new Error('No tree view with id' + treeViewId); + } + + return treeView.getChildren(treeItemId); + } + + async $setExpanded(treeViewId: string, treeItemId: string, expanded: boolean): Promise { + const treeView = this.treeViews.get(treeViewId); + if (!treeView) { + throw new Error('No tree view with id' + treeViewId); + } + + if (expanded) { + return treeView.onExpanded(treeItemId); + } else { + return treeView.onCollapsed(treeItemId); + } + } + + async $setSelection(treeViewId: string, treeItemId: string): Promise { + const treeView = this.treeViews.get(treeViewId); + if (!treeView) { + throw new Error('No tree view with id' + treeViewId); + } + + treeView.onSelectionChanged(treeItemId); + } + +} + +class TreeViewExtImpl extends Disposable { + + private onDidExpandElementEmmiter: Emitter> = new Emitter>(); + public readonly onDidExpandElement = this.onDidExpandElementEmmiter.event; + + private onDidCollapseElementEmmiter: Emitter> = new Emitter>(); + public readonly onDidCollapseElement = this.onDidCollapseElementEmmiter.event; + + private selection: T[] = []; + get selectedElements(): T[] { return this.selection; } + + private cache: Map = new Map(); + + constructor(treeViewId: string, + private treeDataProvider: TreeDataProvider, + proxy: TreeViewsMain, + private commandRegistry: CommandRegistryImpl) { + + super(() => { + this.dispose(); + }); + + proxy.$registerTreeDataProvider(treeViewId); + + if (treeDataProvider.onDidChangeTreeData) { + treeDataProvider.onDidChangeTreeData((e: T) => { + proxy.$refresh(treeViewId); + }); + } + } + + dispose() { + } + + async reveal(element: T, options?: { select?: boolean }): Promise { + // temporary reply with OK + await this.delay(1000); + } + + async delay(miliseconds: number): Promise { + return new Promise(resolve => { + setTimeout(() => { + resolve(); + }, miliseconds); + }); + } + + idCounter: number = 0; + + generateId(): string { + this.idCounter++; + return 'item-' + this.idCounter; + } + + async getChildren(treeItemId: string): Promise { + // get element from a cache + const cachedElement: T | undefined = this.cache.get(treeItemId); + + // ask data provider for children for cached element + const result = await this.treeDataProvider.getChildren(cachedElement); + + if (result) { + const treeItems: TreeViewItem[] = []; + const promises = result.map(async value => { + + // Generate the ID + // ID is used for caching the element + const id = this.generateId(); + + // Add element to the cache + this.cache.set(id, value); + + // Ask data provider for a tree item for the value + // Data provider must return theia.TreeItem + const treeItem = await this.treeDataProvider.getTreeItem(value); + + // Convert theia.TreeItem to the TreeViewItem + + // Take a label + let label = treeItem.label; + + // Use resource URI if label is not set + if (!label && treeItem.resourceUri) { + label = treeItem.resourceUri.path.toString(); + label = decodeURIComponent(label); + if (label.indexOf('/') >= 0) { + label = label.substring(label.lastIndexOf('/') + 1); + } + } + + // Use item ID if item label is still not set + if (!label) { + label = id; + } + + // Take the icon + // currently only icons from font-awesome are supported + let icon = undefined; + if (typeof treeItem.iconPath === 'string') { + icon = treeItem.iconPath; + } + + const treeViewItem = { + id, + label: label, + icon, + tooltip: treeItem.tooltip, + collapsibleState: treeItem.collapsibleState + } as TreeViewItem; + + treeItems.push(treeViewItem); + }); + + await Promise.all(promises); + return treeItems; + } else { + return undefined; + } + } + + async onExpanded(treeItemId: string): Promise { + // get element from a cache + const cachedElement: T | undefined = this.cache.get(treeItemId); + + // fire an event + if (cachedElement) { + this.onDidExpandElementEmmiter.fire({ + element: cachedElement + }); + } + } + + async onCollapsed(treeItemId: string): Promise { + // get element from a cache + const cachedElement: T | undefined = this.cache.get(treeItemId); + + // fire an event + if (cachedElement) { + this.onDidCollapseElementEmmiter.fire({ + element: cachedElement + }); + } + } + + async onSelectionChanged(treeItemId: string): Promise { + // get element from a cache + const cachedElement: T | undefined = this.cache.get(treeItemId); + + if (cachedElement) { + this.selection = [cachedElement]; + + // Ask data provider for a tree item for the value + const treeItem = await this.treeDataProvider.getTreeItem(cachedElement); + + if (treeItem.command) { + this.commandRegistry.executeCommand(treeItem.command.id, treeItem.command.arguments); + } + } + } + +} diff --git a/packages/plugin-ext/src/plugin/types-impl.ts b/packages/plugin-ext/src/plugin/types-impl.ts index 93426aee59ff3..206c1ba2edfb0 100644 --- a/packages/plugin-ext/src/plugin/types-impl.ts +++ b/packages/plugin-ext/src/plugin/types-impl.ts @@ -516,6 +516,17 @@ export class ThemeColor { } } +export class ThemeIcon { + + static readonly File: ThemeIcon; + + static readonly Folder: ThemeIcon; + + private constructor(public id: string) { + } + +} + export enum TextEditorRevealType { Default = 0, InCenter = 1, @@ -928,6 +939,32 @@ export class CodeAction { } } +export class TreeItem { + + label?: string; + + id?: string; + + iconPath?: string | URI | { light: string | URI; dark: string | URI } | ThemeIcon; + + resourceUri?: URI; + + tooltip?: string | undefined; + + command?: theia.Command; + + collapsibleState?: TreeItemCollapsibleState; + + contextValue?: string; + +} + +export enum TreeItemCollapsibleState { + None = 0, + Collapsed = 1, + Expanded = 2 +} + export enum SymbolKind { File = 0, Module = 1, diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts index 9056ba52afaa6..3028b1ab92e93 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -2003,6 +2003,24 @@ declare module '@theia/plugin' { constructor(id: string); } + /** + * A reference to a named icon. Currently only [File](#ThemeIcon.File) and [Folder](#ThemeIcon.Folder) are supported. + * Using a theme icon is preferred over a custom icon as it gives theme authors the possibility to change the icons. + */ + export class ThemeIcon { + /** + * Reference to a icon representing a file. The icon is taken from the current file icon theme or a placeholder icon. + */ + static readonly File: ThemeIcon; + + /** + * Reference to a icon representing a folder. The icon is taken from the current file icon theme or a placeholder icon. + */ + static readonly Folder: ThemeIcon; + + private constructor(id: string); + } + /** * Represents the state of a window. */ @@ -2548,6 +2566,201 @@ declare module '@theia/plugin' { * @param - terminal options. */ export function createTerminal(options: TerminalOptions): Terminal; + + /** + * Register a [TreeDataProvider](#TreeDataProvider) for the view contributed using the extension point `views`. + * This will allow you to contribute data to the [TreeView](#TreeView) and update if the data changes. + * + * **Note:** To get access to the [TreeView](#TreeView) and perform operations on it, use [createTreeView](#window.createTreeView). + * + * @param viewId Id of the view contributed using the extension point `views`. + * @param treeDataProvider A [TreeDataProvider](#TreeDataProvider) that provides tree data for the view + */ + export function registerTreeDataProvider(viewId: string, treeDataProvider: TreeDataProvider): Disposable; + + /** + * Create a [TreeView](#TreeView) for the view contributed using the extension point `views`. + * @param viewId Id of the view contributed using the extension point `views`. + * @param options Options object to provide [TreeDataProvider](#TreeDataProvider) for the view. + * @returns a [TreeView](#TreeView). + */ + export function createTreeView(viewId: string, options: { treeDataProvider: TreeDataProvider }): TreeView; + + } + + /** + * The event that is fired when an element in the [TreeView](#TreeView) is expanded or collapsed + */ + export interface TreeViewExpansionEvent { + + /** + * Element that is expanded or collapsed. + */ + element: T; + + } + + /** + * Represents a Tree view + */ + export interface TreeView extends Disposable { + + /** + * Event that is fired when an element is expanded + */ + readonly onDidExpandElement: Event>; + + /** + * Event that is fired when an element is collapsed + */ + readonly onDidCollapseElement: Event>; + + /** + * Currently selected elements. + */ + readonly selection: ReadonlyArray; + + /** + * Reveal an element. By default revealed element is selected. + * + * In order to not to select, set the option `select` to `false`. + * + * **NOTE:** [TreeDataProvider](#TreeDataProvider) is required to implement [getParent](#TreeDataProvider.getParent) method to access this API. + */ + reveal(element: T, options?: { select?: boolean }): PromiseLike; + } + + /** + * A data provider that provides tree data + */ + export interface TreeDataProvider { + /** + * An optional event to signal that an element or root has changed. + * This will trigger the view to update the changed element/root and its children recursively (if shown). + * To signal that root has changed, do not pass any argument or pass `undefined` or `null`. + */ + onDidChangeTreeData?: Event; + + /** + * Get [TreeItem](#TreeItem) representation of the `element` + * + * @param element The element for which [TreeItem](#TreeItem) representation is asked for. + * @return [TreeItem](#TreeItem) representation of the element + */ + getTreeItem(element: T): TreeItem | PromiseLike; + + /** + * Get the children of `element` or root if no element is passed. + * + * @param element The element from which the provider gets children. Can be `undefined`. + * @return Children of `element` or root if no element is passed. + */ + getChildren(element?: T): ProviderResult; + + /** + * Optional method to return the parent of `element`. + * Return `null` or `undefined` if `element` is a child of root. + * + * **NOTE:** This method should be implemented in order to access [reveal](#TreeView.reveal) API. + * + * @param element The element for which the parent has to be returned. + * @return Parent of `element`. + */ + getParent?(element: T): ProviderResult; + } + + export class TreeItem { + /** + * A human-readable string describing this item. When `falsy`, it is derived from [resourceUri](#TreeItem.resourceUri). + */ + label?: string; + + /** + * Optional id for the tree item that has to be unique across tree. The id is used to preserve the selection and expansion state of the tree item. + * + * If not provided, an id is generated using the tree item's label. **Note** that when labels change, ids will change and that selection and expansion state cannot be kept stable anymore. + */ + id?: string; + + /** + * The icon path or [ThemeIcon](#ThemeIcon) for the tree item. + * When `falsy`, [Folder Theme Icon](#ThemeIcon.Folder) is assigned, if item is collapsible otherwise [File Theme Icon](#ThemeIcon.File). + * When a [ThemeIcon](#ThemeIcon) is specified, icon is derived from the current file icon theme for the specified theme icon using [resourceUri](#TreeItem.resourceUri) (if provided). + */ + iconPath?: string | Uri | { light: string | Uri; dark: string | Uri } | ThemeIcon; + + /** + * The [uri](#Uri) of the resource representing this item. + * + * Will be used to derive the [label](#TreeItem.label), when it is not provided. + * Will be used to derive the icon from current icon theme, when [iconPath](#TreeItem.iconPath) has [ThemeIcon](#ThemeIcon) value. + */ + resourceUri?: Uri; + + /** + * The tooltip text when you hover over this item. + */ + tooltip?: string | undefined; + + /** + * The [command](#Command) which should be run when the tree item is selected. + */ + command?: Command; + + /** + * [TreeItemCollapsibleState](#TreeItemCollapsibleState) of the tree item. + */ + collapsibleState?: TreeItemCollapsibleState; + + /** + * Context value of the tree item. This can be used to contribute item specific actions in the tree. + * For example, a tree item is given a context value as `folder`. When contributing actions to `view/item/context` + * using `menus` extension point, you can specify context value for key `viewItem` in `when` expression like `viewItem == folder`. + * ``` + * "contributes": { + * "menus": { + * "view/item/context": [ + * { + * "command": "extension.deleteFolder", + * "when": "viewItem == folder" + * } + * ] + * } + * } + * ``` + * This will show action `extension.deleteFolder` only for items with `contextValue` is `folder`. + */ + contextValue?: string; + + /** + * @param label A human-readable string describing this item + * @param collapsibleState [TreeItemCollapsibleState](#TreeItemCollapsibleState) of the tree item. Default is [TreeItemCollapsibleState.None](#TreeItemCollapsibleState.None) + */ + // constructor(label: string, collapsibleState?: TreeItemCollapsibleState); + + /** + * @param resourceUri The [uri](#Uri) of the resource representing this item. + * @param collapsibleState [TreeItemCollapsibleState](#TreeItemCollapsibleState) of the tree item. Default is [TreeItemCollapsibleState.None](#TreeItemCollapsibleState.None) + */ + // constructor(resourceUri: Uri, collapsibleState?: TreeItemCollapsibleState); + } + + /** + * Collapsible state of the tree item + */ + export enum TreeItemCollapsibleState { + /** + * Determines an item can be neither collapsed nor expanded. Implies it has no children. + */ + None = 0, + /** + * Determines an item is collapsed + */ + Collapsed = 1, + /** + * Determines an item is expanded + */ + Expanded = 2 } /**