From 6c7de4fa6e066e5fc64494b8b472cdcb8353520a Mon Sep 17 00:00:00 2001 From: Stefan Winkler Date: Thu, 13 Mar 2025 13:39:47 +0100 Subject: [PATCH 1/3] Add the tree-widget example to the generator Signed-Off-By: Stefan Winkler --- README.md | 1 + src/app/index.ts | 26 ++- templates/tree-widget/README.md | 11 + .../treeview-example-decoration-service.ts | 16 ++ .../treeview-example-demo-decorator.ts | 63 +++++ .../styles/treeview-example-widget.css | 3 + .../treeview-example-frontend-module.ts | 52 +++++ .../treeview-example-label-provider.ts | 111 +++++++++ .../tree-widget/treeview-example-model.ts | 202 ++++++++++++++++ .../tree-widget/treeview-example-tree.ts | 40 ++++ .../treeview-example-view-contribution.ts | 75 ++++++ .../tree-widget/treeview-example-widget.tsx | 217 ++++++++++++++++++ 12 files changed, 816 insertions(+), 1 deletion(-) create mode 100644 templates/tree-widget/README.md create mode 100644 templates/tree-widget/decorator/treeview-example-decoration-service.ts create mode 100644 templates/tree-widget/decorator/treeview-example-demo-decorator.ts create mode 100644 templates/tree-widget/styles/treeview-example-widget.css create mode 100644 templates/tree-widget/treeview-example-frontend-module.ts create mode 100644 templates/tree-widget/treeview-example-label-provider.ts create mode 100644 templates/tree-widget/treeview-example-model.ts create mode 100644 templates/tree-widget/treeview-example-tree.ts create mode 100644 templates/tree-widget/treeview-example-view-contribution.ts create mode 100644 templates/tree-widget/treeview-example-widget.tsx diff --git a/README.md b/README.md index f264f84..5f57715 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ The generator allows to generate an example extension that is directly part of t | `hello-world` | Creates a simple extension which provides a command and menu item which displays a message | [readme](https://github.com/eclipse-theia/generator-theia-extension/blob/master/templates/hello-world/README.md) | | `widget` | Creates the basis for a simple widget including a toggle command, alert message and button displaying a message. The template also contains an example unit test. | [readme](https://github.com/eclipse-theia/generator-theia-extension/blob/master/templates/widget/README.md) | | `labelprovider` | Creates a simple extension which adds a custom label (with icon) for .my files | [readme](https://github.com/eclipse-theia/generator-theia-extension/blob/master/templates/labelprovider/README.md) | +| `tree-widget` | Creates a tree view extension | [readme](https://github.com/eclipse-theia/generator-theia-extension/blob/master/templates/tree-widget/README.md) | | `tree-editor` | Creates a tree editor extension | [readme](https://github.com/eclipse-theia/generator-theia-extension/blob/master/templates/tree-editor/README.md) | | `empty` | Creates a simple, minimal extension | [readme](https://github.com/eclipse-theia/generator-theia-extension/blob/master/templates/empty/README.md) | | `backend` | Creates a backend communication extension | [readme](https://github.com/eclipse-theia/generator-theia-extension/blob/master/templates/backend/README.md) | diff --git a/src/app/index.ts b/src/app/index.ts index 4d5d473..b1d6c33 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -31,6 +31,7 @@ enum ExtensionType { Widget = 'widget', LabelProvider = 'labelprovider', TreeEditor = 'tree-editor', + TreeWidget = 'tree-widget', Empty = 'empty', Backend = 'backend', DiagramEditor = 'diagram-editor', @@ -186,6 +187,7 @@ module.exports = class TheiaExtension extends Base { { value: ExtensionType.HelloWorld, name: 'Hello World' }, { value: ExtensionType.Widget, name: 'Widget (with unit tests)' }, { value: ExtensionType.LabelProvider, name: 'LabelProvider' }, + { value: ExtensionType.TreeWidget, name: 'TreeWidget View' }, { value: ExtensionType.TreeEditor, name: 'TreeEditor' }, { value: ExtensionType.Backend, name: 'Backend Communication' }, { value: ExtensionType.Empty, name: 'Empty' }, @@ -511,6 +513,28 @@ module.exports = class TheiaExtension extends Base { ); } + /** TreeWidget */ + if (this.params.extensionType === ExtensionType.TreeWidget) { + ['treeview-example-widget.tsx', + 'treeview-example-view-contribution.ts', + 'treeview-example-tree.ts', + 'treeview-example-model.ts', + 'treeview-example-label-provider.ts', + 'README.md', + 'styles', + 'decorator'].forEach((file) => + this.fs.copyTpl( + this.templatePath(`tree-widget/${file}`), + this.extensionPath(`src/browser/${file}`), + { params: this.params } + )); + + this.fs.copyTpl( + this.templatePath('tree-widget/treeview-example-frontend-module.ts'), + this.extensionPath(`src/browser/${this.params.extensionPath}-frontend-module.ts`), + ); + } + /** DiagramEditor */ if (this.params.extensionType === ExtensionType.DiagramEditor) { const baseDir = `./glsp-examples-${glspExamplesRepositoryTag}`; @@ -576,7 +600,7 @@ module.exports = class TheiaExtension extends Base { process.exit(code); } }); - } else { + } else { command.on('close', function(code: number){ if (code !== 0 ) { process.exit(code); diff --git a/templates/tree-widget/README.md b/templates/tree-widget/README.md new file mode 100644 index 0000000..a1cdb72 --- /dev/null +++ b/templates/tree-widget/README.md @@ -0,0 +1,11 @@ +# Example TreeWidget implementation + +The example extension demonstrates how to contribute a TreeWidget based view and how to use and customize the different features provided by the Theia `TreeWidget` framework. + +## How to use the label provider example + +In the running application, from the Command Palette, execute _View: Toggle Example Tree View ..._. + +## Further Reading + +This example is accompanied by a tutorial that is available [here](https://theia-ide.org/docs/tree_widget/). diff --git a/templates/tree-widget/decorator/treeview-example-decoration-service.ts b/templates/tree-widget/decorator/treeview-example-decoration-service.ts new file mode 100644 index 0000000..105c73f --- /dev/null +++ b/templates/tree-widget/decorator/treeview-example-decoration-service.ts @@ -0,0 +1,16 @@ +import { ContributionProvider } from '@theia/core'; +import { AbstractTreeDecoratorService, TreeDecorator } from '@theia/core/lib/browser'; +import { inject, injectable, named } from '@theia/core/shared/inversify'; + +export const TreeviewExampleDecorator = Symbol('TreeviewExampleDecorator'); + +/** + * The TreeDecoratorService which manages the TreeDecorator contributions for our tree widget implementation. + * (Every tree widget has its own TreeDecoratorService instance to manage decorations specifically for that widget.) + */ +@injectable() +export class TreeviewExampleDecorationService extends AbstractTreeDecoratorService { + constructor(@inject(ContributionProvider) @named(TreeviewExampleDecorator) protected readonly contributions: ContributionProvider) { + super(contributions.getContributions()); + } +} diff --git a/templates/tree-widget/decorator/treeview-example-demo-decorator.ts b/templates/tree-widget/decorator/treeview-example-demo-decorator.ts new file mode 100644 index 0000000..e91cec9 --- /dev/null +++ b/templates/tree-widget/decorator/treeview-example-demo-decorator.ts @@ -0,0 +1,63 @@ +import { Emitter, MaybePromise } from '@theia/core'; +import { DepthFirstTreeIterator, Tree, TreeDecorator } from '@theia/core/lib/browser'; +import { WidgetDecoration } from '@theia/core/lib/browser/widget-decoration'; +import { Event } from '@theia/core/lib/common'; +import { injectable } from '@theia/core/shared/inversify'; +import { ExampleTreeLeaf } from '../treeview-example-model'; + +/** + * Example TreeDecorator implementation for our tree widget. + */ +@injectable() +export class TreeviewExampleDemoDecorator implements TreeDecorator { + /** Decorator id - required by the TreeDecorator interface */ + id = 'TreeviewExampleDecorator'; + + /** Event Emitter for when the decorations change - required by the TreeDecorator interface */ + protected readonly emitter = new Emitter<(tree: Tree) => Map>(); + get onDidChangeDecorations(): Event<(tree: Tree) => Map> { + return this.emitter.event; + } + + /** + * The actual decoration calculation. + * + * In contrast to label providers, decorators provide decorations for the complete tree at once. + * + * @param tree the tree to decorate. + * @returns a Map of node IDs mapped to decorations. + */ + decorations(tree: Tree): MaybePromise> { + const result = new Map(); + + if (tree.root === undefined) { + return result; + } + + // iterate the tree + for (const treeNode of new DepthFirstTreeIterator(tree.root)) { + // in our case, we only decorate leaf nodes + if (ExampleTreeLeaf.is(treeNode)) { + // we distinguish between high and low stock levels based on the quantity + const amount = treeNode.data.quantity || 0; + if (amount > 4) { + // we use a green checkmark icon decoration for high stock levels + result.set(treeNode.id, { + iconOverlay: { + position: WidgetDecoration.IconOverlayPosition.BOTTOM_RIGHT, + iconClass: ['fa', 'fa-check-circle'], + color: 'green' + } + }); + } else { + // for low stock levels, we use a red background color and a warning text suffix + result.set(treeNode.id, { + backgroundColor: 'red', + captionSuffixes: [{ data: 'Warning: low stock', fontData: { style: 'italic' } }] + }); + } + } + } + return result; + } +} diff --git a/templates/tree-widget/styles/treeview-example-widget.css b/templates/tree-widget/styles/treeview-example-widget.css new file mode 100644 index 0000000..f52ac32 --- /dev/null +++ b/templates/tree-widget/styles/treeview-example-widget.css @@ -0,0 +1,3 @@ +.theia-example-tree-node .a { + padding-right: 4px +} diff --git a/templates/tree-widget/treeview-example-frontend-module.ts b/templates/tree-widget/treeview-example-frontend-module.ts new file mode 100644 index 0000000..00b2801 --- /dev/null +++ b/templates/tree-widget/treeview-example-frontend-module.ts @@ -0,0 +1,52 @@ +import { bindContributionProvider } from '@theia/core'; +import { bindViewContribution, createTreeContainer, LabelProviderContribution, WidgetFactory } from '@theia/core/lib/browser'; +import { Container, ContainerModule, interfaces } from '@theia/core/shared/inversify'; +import { TreeviewExampleDecorationService, TreeviewExampleDecorator } from './decorator/treeview-example-decoration-service'; +import { TreeviewExampleDemoDecorator } from './decorator/treeview-example-demo-decorator'; +import { TreeViewExampleLabelProvider } from './treeview-example-label-provider'; +import { TreeViewExampleModel } from './treeview-example-model'; +import { TreeviewExampleTree } from './treeview-example-tree'; +import { TreeviewExampleViewContribution } from './treeview-example-view-contribution'; +import { TREEVIEW_EXAMPLE_CONTEXT_MENU, TreeViewExampleWidget } from './treeview-example-widget'; + +/** + * Frontend contribution bindings. + */ +export default new ContainerModule(bind => { + bindViewContribution(bind, TreeviewExampleViewContribution); + + bind(WidgetFactory).toDynamicValue(ctx => ({ + id: TreeViewExampleWidget.ID, + createWidget: () => createTreeViewExampleViewContainer(ctx.container).get(TreeViewExampleWidget) + })).inSingletonScope(); + + bind(TreeViewExampleModel).toSelf().inSingletonScope(); + bind(LabelProviderContribution).to(TreeViewExampleLabelProvider); + + bind(TreeviewExampleDemoDecorator).toSelf().inSingletonScope(); + bind(TreeviewExampleDecorator).toService(TreeviewExampleDemoDecorator); +}); + +/** + * Create the child container which contains the `TreeViewExampleWidget` and all its collaborators + * in an isolated child container so the bound services affect only the `TreeViewExampleWidget` + * + * @param parent the parent container + * @returns the new child container + */ +function createTreeViewExampleViewContainer(parent: interfaces.Container): Container { + const child = createTreeContainer(parent, { + tree: TreeviewExampleTree, + model: TreeViewExampleModel, + widget: TreeViewExampleWidget, + props: { + contextMenuPath: TREEVIEW_EXAMPLE_CONTEXT_MENU, + multiSelect: false, + search: true, + expandOnlyOnExpansionToggleClick: false + }, + decoratorService: TreeviewExampleDecorationService, + }); + bindContributionProvider(child, TreeviewExampleDecorator); + return child; +} diff --git a/templates/tree-widget/treeview-example-label-provider.ts b/templates/tree-widget/treeview-example-label-provider.ts new file mode 100644 index 0000000..62856f7 --- /dev/null +++ b/templates/tree-widget/treeview-example-label-provider.ts @@ -0,0 +1,111 @@ +import { Emitter, Event } from '@theia/core'; +import { DidChangeLabelEvent, LabelProviderContribution, TreeNode } from '@theia/core/lib/browser'; +import { injectable } from '@theia/core/shared/inversify'; +import { ExampleTreeLeaf, ExampleTreeNode } from './treeview-example-model'; + +/** + * Provider for labels and icons for the `TreeViewExampleWidget` + */ +@injectable() +export class TreeViewExampleLabelProvider implements LabelProviderContribution { + /** + * Emitter for the event that is emitted when the label of a tree item changes. + */ + protected readonly onDidChangeEmitter = new Emitter(); + + /** + * Decides whether this label provider can provide labels for the given object (in this case only + * nodes in the TreeViewExampleWidget tree). + * + * @param element the element to consider + * @returns 0 if this label provider cannot handle the element, otherwise a positive integer indicating a + * priority. The framework chooses the provider with the highest priority for the given element. + */ + canHandle(element: object): number { + if ((ExampleTreeNode.is(element) || ExampleTreeLeaf.is(element))) { + return 100; + } + return 0; + } + + /** + * Provides the name for the given tree node. + * + * This example demonstrates a name that is partially resolved asynchronously. + * Whenever a name is requested for an `ExampleTreeLeaf` for the first time, a timer + * is scheduled. After the timer resolves, the quantity from the model is reported. + * In the meantime, a "calculating..." label is shown. + * + * This works by emitting a label change event when the Promise is resolved. + * + * @param element the element for which the name shall be retrieved + * @returns the name of this element + */ + getName(element: object): string | undefined { + // simple implementation for nodes: + if (ExampleTreeNode.is(element)) { + return element.data.name; + } + + // in case of leaves, we simulate asynchronous retrieval + if (ExampleTreeLeaf.is(element)) { + if (!element.quantityLabel) { + // if the quantityLabel is not yet set (not even 'calculating ...'), we schedule its retrieval + // by simulating a delay using setTimeout(). + element.quantityLabel = 'calculating ...'; + element.quantityLabelPromise = new Promise(resolve => setTimeout(() => resolve(`${element.data.quantity}`), 1000)); + + // after the detail has been retrieved, set the quantityLabel to its final value and emit a change event + element.quantityLabelPromise.then(quantity => { + element.quantityLabel = quantity; + this.fireNodeChange(element); + }); + } + + // assemble the complete name from its parts + const orderedLabel = element.data.backOrdered ? ' - more are ordered' : ''; + return element.data.name + ` (${element.quantityLabel + orderedLabel})`; + } + + // this should not happen, because the canHandle() would only return >0 for the tree node types + return undefined; + } + + /** + * Provides an icon (in this case, a fontawesome icon name without the fa- prefix, as the TreeWidget provides built-in support + * for fontawesome icons). + * + * @param element the element for which to provide the icon + * @returns the icon + */ + getIcon(element: object): string | undefined { + if (ExampleTreeNode.is(element)) { + return 'folder'; + } + if (ExampleTreeLeaf.is(element)) { + return 'smile-o'; + } + + return undefined; + } + + /** + * Fire the node change event. + * + * @param node the node that has been changed + */ + fireNodeChange(node: TreeNode): void { + this.onDidChangeEmitter.fire({ + // The element here is the tree row which has a `node` property + // Since we know exactly which node we have changed, we can match the changed node with the tree row's node + affects: (element: object) => 'node' in element && element.node === node + }); + } + + /** + * Accessor for the emitter (defined by the LabelProviderContribution interface) + */ + get onDidChange(): Event { + return this.onDidChangeEmitter.event; + } +} diff --git a/templates/tree-widget/treeview-example-model.ts b/templates/tree-widget/treeview-example-model.ts new file mode 100644 index 0000000..fa603ef --- /dev/null +++ b/templates/tree-widget/treeview-example-model.ts @@ -0,0 +1,202 @@ +import { CompositeTreeNode, ExpandableTreeNode, SelectableTreeNode, TreeModelImpl, TreeNode } from '@theia/core/lib/browser'; +import { injectable, postConstruct } from '@theia/core/shared/inversify'; + +/** well-known ID for the root node in our tree */ +export const ROOT_NODE_ID = 'treeview-example-root'; + +/** + * Interface for the "business model". + * + * (Note: this could be more elaborated, using different interfaces for containers and concrete items, but for this demonstration, + * we keep the model like this...) + */ +export interface Item { + name: string; // name of the category/container or item + children?: Item[]; // the directly contained items; only defined for categories/containers + quantity?: number; // the quantity of items available (to demonstrate decoration, ...); only defined for items + backOrdered?: boolean; // whether this item was backordered (to demonstrate checkboxes); only defined for items +} + +/** + * Function to map a given item to a tree node + */ +export namespace Item { + export function toTreeNode(item: Item): ExampleTreeNode | ExampleTreeLeaf { + if (item.children) { + return { + id: item.name, + data: item, + expanded: false, + children: [], + parent: undefined, + type: 'node', + selected: false + }; + } else { + return { + id: item.name, + data: item, + parent: undefined, + type: 'leaf', + checkboxInfo: { + checked: item.backOrdered, + } + }; + } + } +} + +/** Interface for an container node (having children), along with a type-checking function */ +export interface ExampleTreeNode extends ExpandableTreeNode, SelectableTreeNode { + data: Item; + type: 'node'; +} +export namespace ExampleTreeNode { + export function is(candidate: object): candidate is ExampleTreeNode { + return ExpandableTreeNode.is(candidate) && 'type' in candidate && candidate.type === 'node'; + } +} + +/** Interface for a leaf node, along with a type-checking function */ +export interface ExampleTreeLeaf extends TreeNode { + data: Item; + quantityLabel?: string; + quantityLabelPromise?: Promise; + type: 'leaf'; +} +export namespace ExampleTreeLeaf { + export function is(candidate: object): candidate is ExampleTreeLeaf { + return TreeNode.is(candidate) && 'type' in candidate && candidate.type === 'leaf'; + } +} + +/** + * Example data to initialize the "business model" + */ +const EXAMPLE_DATA: Item[] = [{ + name: 'Fruits', + children: [ + { + name: 'Apples', + children: [ + { name: 'Golden Delicious', quantity: 4 }, + { name: 'Gala', quantity: 3 }, + ] + }, + { + name: 'Oranges', + children: [ + { name: 'Clementine', quantity: 2 }, + { name: 'Navel', quantity: 5 }, + ] + } + ] +}, +{ + name: 'Vegetables', + children: [ + { name: 'Carrot', quantity: 10 }, + { name: 'Zucchini', quantity: 6 }, + { name: 'Broccoli', quantity: 8 }, + ] +} +]; + +/** + * The Tree Model for the tree. + * + * This class contains the bridge between business model and tree model and realizes operations on the data. + */ +@injectable() +export class TreeViewExampleModel extends TreeModelImpl { + + /** + * Initialize the tree model from the business model + */ + @postConstruct() + protected override init(): void { + super.init(); + + // create the root node + const root: CompositeTreeNode = { + id: ROOT_NODE_ID, + parent: undefined, + children: [], + visible: false // do not show the root node in the UI + }; + + // populate the direct children + EXAMPLE_DATA.map(item => Item.toTreeNode(item)) + .forEach(node => CompositeTreeNode.addChild(root, node)); + + // set the root node as root of the tree + // This will also initialize the ID-node-map in the tree, so this should be called + // after populating the children. + this.tree.root = root; + } + + /** + * This is executed when a tree item's checkbox is checked/unchecked. + * + * For this example, the check state is applied to the business model (backOrdered property). + * + * @param node the affected node + * @param checked the new state of the checkbox + */ + override markAsChecked(node: TreeNode, checked: boolean): void { + if (ExampleTreeLeaf.is(node)) { + node.data.backOrdered = checked; + } + super.markAsChecked(node, checked); + } + + /** + * Logic to add a new child item to the given parent. + * + * For simplicity, we use a static/constant child, so we don't have to implement UI to ask the user for the name etc. + * Note that because of the TreeNode.id initialization to Item.name, this method should only be called once. Otherwise + * we end up with multiple tree items with the same ID, which is not desirable. + * + * So in practice, the id should be calculated in a better way... + * + * @param parent the parent of the new item + */ + public addItem(parent: TreeNode): void { + if (ExampleTreeNode.is(parent)) { + const newItem: Item = { name: 'Watermelon', quantity: 4 }; + parent.data.children?.push(newItem); + // since we have modified the tree structure, we need to refresh the parent node + this.tree.refresh(parent); + } + } + + /** + * Logic to move an leaf node to a new container node. + * + * This is used in the Drag & Drop demonstration code to move a dragged item. + * + * @param nodeIdToReparent the node ID of the leaf node to move + * @param targetNode the new parent of the leaf node + */ + public reparent(nodeIdToReparent: string, targetNode: ExampleTreeNode): void { + // resolve the ID to the actual node (using the ID-to-node map of the tree) + const nodeToReparent = this.tree.getNode(nodeIdToReparent); + + // get the original parent + const sourceParent = nodeToReparent?.parent; + if (nodeToReparent && ExampleTreeLeaf.is(nodeToReparent) + && sourceParent && ExampleTreeNode.is(sourceParent)) { + // find the nodeToReparent in the sourceParent's children + const indexInCurrentParent = sourceParent.data.children!.indexOf(nodeToReparent.data); + if (indexInCurrentParent !== -1) { + // remove the node from its old location (in the business model) + sourceParent.data.children?.splice(indexInCurrentParent, 1); + // add the node to its new location (in the business model) + targetNode.data.children?.push(nodeToReparent.data); + // trigger refreshes so that the tree is updated according to the structural changes made + this.tree.refresh(sourceParent); + this.tree.refresh(targetNode); + } + } + } +} diff --git a/templates/tree-widget/treeview-example-tree.ts b/templates/tree-widget/treeview-example-tree.ts new file mode 100644 index 0000000..fa58c43 --- /dev/null +++ b/templates/tree-widget/treeview-example-tree.ts @@ -0,0 +1,40 @@ +import { CompositeTreeNode, TreeImpl, TreeNode } from '@theia/core/lib/browser'; +import { ExampleTreeNode, Item, ROOT_NODE_ID } from './treeview-example-model'; + +/** + * Tree implementation. + * + * We override this to enable lazy child node resolution on node expansion. + */ +export class TreeviewExampleTree extends TreeImpl { + /** + * Resolves children of the given parent node. + * + * @param parent the node for which to provide the children + * @returns a new array of child tree nodes for the given parent node. + */ + override resolveChildren(parent: CompositeTreeNode): Promise { + // root children are initialized once and never change, so we just return a copy of the original children + if (parent.id === ROOT_NODE_ID) { + return Promise.resolve([...parent.children]); + } + + // non-container nodes do not have children, so we return an empty array + if (!ExampleTreeNode.is(parent)) { + return Promise.resolve([]); + } + + // performance optimization - if the children are resolved already and the number of children is still correct + // we reuse the already resolved items. + // Note: In a real application this comparison might require more logic, because if a child is replaced by a + // different one or if children are reordered, this code would not work... + if (parent.children.length === parent.data.children?.length) { + return Promise.resolve([...parent.children]); + } + + // simulate asynchronous loading of children. In the UI we can see a busy marker when we expand a node because of this: + return new Promise(resolve => { + setTimeout(() => resolve(parent.data.children!.map(Item.toTreeNode)), 2000); + }); + } +} diff --git a/templates/tree-widget/treeview-example-view-contribution.ts b/templates/tree-widget/treeview-example-view-contribution.ts new file mode 100644 index 0000000..eb69015 --- /dev/null +++ b/templates/tree-widget/treeview-example-view-contribution.ts @@ -0,0 +1,75 @@ +import { Command, CommandRegistry, MenuModelRegistry } from '@theia/core'; +import { AbstractViewContribution } from '@theia/core/lib/browser'; +import { injectable } from '@theia/core/shared/inversify'; +import { ExampleTreeNode } from './treeview-example-model'; +import { TREEVIEW_EXAMPLE_CONTEXT_MENU, TreeViewExampleWidget } from './treeview-example-widget'; + +/** Definition of a command to show the TreeView Example View */ +export const OpenTreeviewExampleView: Command = { + id: 'theia-examples:treeview-example-view-command-id' +}; + +/** Definition of a command to add a new child (to demonstrate context menus) */ +export const TreeviewExampleTreeAddItem: Command = { + id: 'theia-examples:treeview-example-tree-add-item-command-id', + label: 'Example Tree View: Add New Child' +}; + +/** + * Contribution of the `TreeViewExampleWidget` + */ +@injectable() +export class TreeviewExampleViewContribution extends AbstractViewContribution { + constructor() { + super({ + widgetId: TreeViewExampleWidget.ID, + widgetName: TreeViewExampleWidget.LABEL, + defaultWidgetOptions: { + area: 'right' // Can be 'left', 'right', 'bottom', 'main' + }, + toggleCommandId: OpenTreeviewExampleView.id + }); + } + + override registerCommands(commands: CommandRegistry): void { + super.registerCommands(commands); + + // register the "Open View" command + commands.registerCommand(OpenTreeviewExampleView, { + execute: () => super.openView({ activate: false, reveal: true }) + }); + + // register the "Add child item" command + commands.registerCommand(TreeviewExampleTreeAddItem, { + execute: () => { + // get the TreeViewExampleWidget + const widget = this.tryGetWidget(); + if (widget) { + // get the selected item + const parent = widget.model.selectedNodes[0]; + if (parent) { + // call the addItem logic + widget.model.addItem(parent); + } + } + }, + isVisible: () => { + // access the TreeViewExampleWidget + const widget = this.tryGetWidget(); + // only show the command if an ExampleTreeNode is selected + return !!(widget && widget.model.selectedNodes.length > 0 && ExampleTreeNode.is(widget.model.selectedNodes[0])); + } + }); + } + + override registerMenus(menus: MenuModelRegistry): void { + super.registerMenus(menus); + + // add the "Add Child" menu item to the context menu + menus.registerMenuAction([...TREEVIEW_EXAMPLE_CONTEXT_MENU, '_1'], + { + commandId: TreeviewExampleTreeAddItem.id, + label: 'Add Child' + }); + } +} diff --git a/templates/tree-widget/treeview-example-widget.tsx b/templates/tree-widget/treeview-example-widget.tsx new file mode 100644 index 0000000..9fcb0d6 --- /dev/null +++ b/templates/tree-widget/treeview-example-widget.tsx @@ -0,0 +1,217 @@ +import { Disposable, DisposableCollection, MenuPath, MessageService } from '@theia/core'; +import { ContextMenuRenderer, NodeProps, TreeModel, TreeNode, TreeProps, TreeWidget } from '@theia/core/lib/browser'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import * as React from '@theia/core/shared/react'; +import '../../src/browser/styles/treeview-example-widget.css'; +import { ExampleTreeLeaf, ExampleTreeNode, TreeViewExampleModel } from './treeview-example-model'; + +/** Well-known constant for the context menu path */ +export const TREEVIEW_EXAMPLE_CONTEXT_MENU: MenuPath = ['theia-examples:treeview-example-context-menu']; + +/** Implementation of the Tree Widget */ +@injectable() +export class TreeViewExampleWidget extends TreeWidget { + /** The ID of the view */ + static readonly ID = 'theia-examples:treeview-example-view'; + /** The label of the view */ + static readonly LABEL = 'Example Tree View'; + + /** Used in Drag & Drop code to remember and cancel deferred expansion of hovered nodes */ + protected readonly toCancelNodeExpansion = new DisposableCollection(); + + /** The MessageService to demonstrate the action when a user opens (double-clicks) a node */ + @inject(MessageService) private readonly messageService: MessageService; + + constructor( + @inject(TreeProps) public override readonly props: TreeProps, + @inject(TreeModel) public override readonly model: TreeViewExampleModel, + @inject(ContextMenuRenderer) contextMenuRenderer: ContextMenuRenderer + ) { + super(props, model, contextMenuRenderer); + + // set the general properties for the view + this.id = TreeViewExampleWidget.ID; + this.title.label = TreeViewExampleWidget.LABEL; + this.title.caption = TreeViewExampleWidget.LABEL; + this.title.closable = true; + this.title.iconClass = 'fa fa-smile-o'; + + // register action on double-click / ENTER key + this.toDispose.push(this.model.onOpenNode((node: TreeNode) => { + if (ExampleTreeLeaf.is(node) || ExampleTreeNode.is(node)) { + this.messageService.info(`Example node ${node.data.name} was opened.`); + } + })); + } + + /** + * Enable icon rendering. + * + * The super implementation is currently empty. + * This implementation is taken from `file-tree-widget.tsx`. + * + * @param node the node to render + * @param props the node props (currently transporting the depth of the item in the tree) + * @returns + */ + protected override renderIcon(node: TreeNode, props: NodeProps): React.ReactNode { + const icon = this.getIconClass(this.toNodeIcon(node)); + if (icon) { + return
; + } + return super.renderIcon(node, props); + } + + /** + * Provide CSS class names for a given tree node. + * + * In our example, we append our own CSS class to all nodes. See/modify the included CSS file for the corresponding style. + * + * @param node the node to render + * @param props the node props (currently transporting the depth of the item in the tree) + * @returns the node's CSS classes + */ + protected override createNodeClassNames(node: TreeNode, props: NodeProps): string[] { + return super.createNodeClassNames(node, props).concat('theia-example-tree-node'); + } + + /** + * Provide node element attributes for a given tree node. + * + * In our example, we use this to add Drag & Drop event handlers to the tree nodes. + * + * Note: the Drag & Drop code has been taken and adapted from `file-tree-widget.tsx` + * + * @param node the node to render + * @param props the node props (currently transporting the depth of the item in the tree) + * @returns the HTML element attributes. + */ + protected override createNodeAttributes(node: TreeNode, props: NodeProps): React.Attributes & React.HTMLAttributes { + return { + ...super.createNodeAttributes(node, props), + ...this.getNodeDragHandlers(node), + }; + } + + /** + * Returns HTML attributes to install Drag & Drop event handlers for the given tree node. + * + * Note: the Drag & Drop code has been taken and adapted from `file-tree-widget.tsx` + * + * @param node the tree node + * @returns the drag event handlers to be used as additional HTML element attributes + */ + protected getNodeDragHandlers(node: TreeNode): React.Attributes & React.HtmlHTMLAttributes { + return { + onDragStart: event => this.handleDragStartEvent(node, event), + onDragEnter: event => this.handleDragEnterEvent(node, event), + onDragOver: event => this.handleDragOverEvent(node, event), + onDragLeave: event => this.handleDragLeaveEvent(node, event), + onDrop: event => this.handleDropEvent(node, event), + draggable: ExampleTreeLeaf.is(node), + }; + } + + /** + * Handler for the _dragStart_ event. + * + * Stores the ID of the dragged tree node in the Drag & Drop data. + * + * @param node the tree node + * @param event the event + */ + protected handleDragStartEvent(node: TreeNode, event: React.DragEvent): void { + event.stopPropagation(); + if (event.dataTransfer) { + event.dataTransfer.setData('tree-node', node.id); + } + } + + /** + * Handler for the _dragOver_ event. + * + * Registers deferred tree expansion that shall be triggered if the user hovers over an expandable tree item for + * some time. + * + * @param node the tree node + * @param event the event + */ + protected handleDragOverEvent(node: TreeNode | undefined, event: React.DragEvent): void { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'move'; + + if (!this.toCancelNodeExpansion.disposed) { + return; + } + + const timer = setTimeout(() => { + if (!!node && ExampleTreeNode.is(node) && !node.expanded) { + this.model.expandNode(node); + } + }, 500); + this.toCancelNodeExpansion.push(Disposable.create(() => clearTimeout(timer))); + } + + /** + * Handler for the _dragEnter_ event. + * + * Cancels any pending deferred tree extension, selects the current target node to highlight it in the UI, and + * sets the Drag & Drop indicator to "move". + * + * @param node the tree node + * @param event the event + */ + protected handleDragEnterEvent(node: TreeNode | undefined, event: React.DragEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.toCancelNodeExpansion.dispose(); + + let target = node; + if (target && ExampleTreeLeaf.is(target)) { + target = target.parent; + } + + if (!!target && ExampleTreeNode.is(target) && !target.selected) { + this.model.selectNode(target); + } + } + + /** + * Handler for the _dragLeave_ event. + * + * Cancels any pending deferred tree extension. + * + * @param node the tree node + * @param event the event + */ + protected handleDragLeaveEvent(node: TreeNode | undefined, event: React.DragEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.toCancelNodeExpansion.dispose(); + } + + /** + * Handler for the _drop_ event. + * + * Calls the code to move the dragged node to the new parent. + * + * @param node the tree node + * @param event the event + */ + protected async handleDropEvent(node: TreeNode | undefined, event: React.DragEvent): Promise { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'move'; + + let target = node; + if (target && ExampleTreeLeaf.is(target)) { + target = target.parent; + } + + if (!!target && ExampleTreeNode.is(target)) { + const draggedNodeId = event.dataTransfer.getData('tree-node'); + this.model.reparent(draggedNodeId, target); + } + } +} From 8ec2897bea41c64764bba4e9116967b47d81f42b Mon Sep 17 00:00:00 2001 From: Stefan Winkler Date: Wed, 23 Apr 2025 15:14:55 +0200 Subject: [PATCH 2/3] Address review issues Co-authored-by: Martin Fleck Signed-off-by: Stefan Winkler --- src/app/index.ts | 1 + .../treeview-example-frontend-module.ts | 2 + .../treeview-example-label-provider.ts | 13 ++- .../tree-widget/treeview-example-model.ts | 49 +---------- .../treeview-example-tree-item-factory.ts | 81 +++++++++++++++++++ .../tree-widget/treeview-example-tree.ts | 23 +++--- .../treeview-example-view-contribution.ts | 9 +-- .../tree-widget/treeview-example-widget.tsx | 1 + 8 files changed, 110 insertions(+), 69 deletions(-) create mode 100644 templates/tree-widget/treeview-example-tree-item-factory.ts diff --git a/src/app/index.ts b/src/app/index.ts index 90df788..f192042 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -477,6 +477,7 @@ module.exports = class TheiaExtension extends Base { ['treeview-example-widget.tsx', 'treeview-example-view-contribution.ts', 'treeview-example-tree.ts', + 'treeview-example-tree-item-factory.ts', 'treeview-example-model.ts', 'treeview-example-label-provider.ts', 'README.md', diff --git a/templates/tree-widget/treeview-example-frontend-module.ts b/templates/tree-widget/treeview-example-frontend-module.ts index 00b2801..a566e34 100644 --- a/templates/tree-widget/treeview-example-frontend-module.ts +++ b/templates/tree-widget/treeview-example-frontend-module.ts @@ -8,6 +8,7 @@ import { TreeViewExampleModel } from './treeview-example-model'; import { TreeviewExampleTree } from './treeview-example-tree'; import { TreeviewExampleViewContribution } from './treeview-example-view-contribution'; import { TREEVIEW_EXAMPLE_CONTEXT_MENU, TreeViewExampleWidget } from './treeview-example-widget'; +import { TreeViewExampleTreeItemFactory } from './treeview-example-tree-item-factory'; /** * Frontend contribution bindings. @@ -48,5 +49,6 @@ function createTreeViewExampleViewContainer(parent: interfaces.Container): Conta decoratorService: TreeviewExampleDecorationService, }); bindContributionProvider(child, TreeviewExampleDecorator); + child.bind(TreeViewExampleTreeItemFactory).toSelf().inSingletonScope(); return child; } diff --git a/templates/tree-widget/treeview-example-label-provider.ts b/templates/tree-widget/treeview-example-label-provider.ts index 62856f7..9aaf390 100644 --- a/templates/tree-widget/treeview-example-label-provider.ts +++ b/templates/tree-widget/treeview-example-label-provider.ts @@ -1,4 +1,5 @@ import { Emitter, Event } from '@theia/core'; +import { wait } from '@theia/core/lib/common/promise-util' import { DidChangeLabelEvent, LabelProviderContribution, TreeNode } from '@theia/core/lib/browser'; import { injectable } from '@theia/core/shared/inversify'; import { ExampleTreeLeaf, ExampleTreeNode } from './treeview-example-model'; @@ -51,15 +52,13 @@ export class TreeViewExampleLabelProvider implements LabelProviderContribution { if (ExampleTreeLeaf.is(element)) { if (!element.quantityLabel) { // if the quantityLabel is not yet set (not even 'calculating ...'), we schedule its retrieval - // by simulating a delay using setTimeout(). + // by simulating a delay using wait(). In practice, you would call an expensive function returnung an + // actual promise instead of calling wait(). element.quantityLabel = 'calculating ...'; - element.quantityLabelPromise = new Promise(resolve => setTimeout(() => resolve(`${element.data.quantity}`), 1000)); - - // after the detail has been retrieved, set the quantityLabel to its final value and emit a change event - element.quantityLabelPromise.then(quantity => { - element.quantityLabel = quantity; + wait(1000).then(() => { + element.quantityLabel = `${element.data.quantity}`; this.fireNodeChange(element); - }); + }); } // assemble the complete name from its parts diff --git a/templates/tree-widget/treeview-example-model.ts b/templates/tree-widget/treeview-example-model.ts index fa603ef..d3d2b16 100644 --- a/templates/tree-widget/treeview-example-model.ts +++ b/templates/tree-widget/treeview-example-model.ts @@ -1,51 +1,10 @@ import { CompositeTreeNode, ExpandableTreeNode, SelectableTreeNode, TreeModelImpl, TreeNode } from '@theia/core/lib/browser'; -import { injectable, postConstruct } from '@theia/core/shared/inversify'; +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; +import { Item, TreeViewExampleTreeItemFactory } from './treeview-example-tree-item-factory'; /** well-known ID for the root node in our tree */ export const ROOT_NODE_ID = 'treeview-example-root'; -/** - * Interface for the "business model". - * - * (Note: this could be more elaborated, using different interfaces for containers and concrete items, but for this demonstration, - * we keep the model like this...) - */ -export interface Item { - name: string; // name of the category/container or item - children?: Item[]; // the directly contained items; only defined for categories/containers - quantity?: number; // the quantity of items available (to demonstrate decoration, ...); only defined for items - backOrdered?: boolean; // whether this item was backordered (to demonstrate checkboxes); only defined for items -} - -/** - * Function to map a given item to a tree node - */ -export namespace Item { - export function toTreeNode(item: Item): ExampleTreeNode | ExampleTreeLeaf { - if (item.children) { - return { - id: item.name, - data: item, - expanded: false, - children: [], - parent: undefined, - type: 'node', - selected: false - }; - } else { - return { - id: item.name, - data: item, - parent: undefined, - type: 'leaf', - checkboxInfo: { - checked: item.backOrdered, - } - }; - } - } -} - /** Interface for an container node (having children), along with a type-checking function */ export interface ExampleTreeNode extends ExpandableTreeNode, SelectableTreeNode { data: Item; @@ -61,7 +20,6 @@ export namespace ExampleTreeNode { export interface ExampleTreeLeaf extends TreeNode { data: Item; quantityLabel?: string; - quantityLabelPromise?: Promise; type: 'leaf'; } export namespace ExampleTreeLeaf { @@ -109,6 +67,7 @@ const EXAMPLE_DATA: Item[] = [{ */ @injectable() export class TreeViewExampleModel extends TreeModelImpl { + @inject(TreeViewExampleTreeItemFactory) private readonly itemFactory: TreeViewExampleTreeItemFactory; /** * Initialize the tree model from the business model @@ -126,7 +85,7 @@ export class TreeViewExampleModel extends TreeModelImpl { }; // populate the direct children - EXAMPLE_DATA.map(item => Item.toTreeNode(item)) + EXAMPLE_DATA.map(item => this.itemFactory.toTreeNode(item)) .forEach(node => CompositeTreeNode.addChild(root, node)); // set the root node as root of the tree diff --git a/templates/tree-widget/treeview-example-tree-item-factory.ts b/templates/tree-widget/treeview-example-tree-item-factory.ts new file mode 100644 index 0000000..36e7bcf --- /dev/null +++ b/templates/tree-widget/treeview-example-tree-item-factory.ts @@ -0,0 +1,81 @@ +import { injectable } from '@theia/core/shared/inversify'; +import { ExampleTreeLeaf, ExampleTreeNode } from './treeview-example-model'; + +/** + * Interface for the "business model". + * + * (Note: this could be more elaborated, using different interfaces for containers and concrete items, but for this demonstration, + * we keep the model like this...) + */ +export interface Item { + name: string; // name of the category/container or item + children?: Item[]; // the directly contained items; only defined for categories/containers + quantity?: number; // the quantity of items available (to demonstrate decoration, ...); only defined for items + backOrdered?: boolean; // whether this item was backordered (to demonstrate checkboxes); only defined for items +} + +/** + * This class encapsulates the logic for mapping business model items to tree nodes. + */ +@injectable() +export class TreeViewExampleTreeItemFactory { + /** + * Counter that for each item name stores the next id number to assign for that name, + * so that all tree items get a unique id + */ + private readonly idCounter = new Map(); + + /** + * Create a new tree node for the tree model from the given item. + * + * @param item the item to map to a tree node + * @returns the tree node representing the given item + */ + public toTreeNode(item: Item): ExampleTreeNode | ExampleTreeLeaf { + if (item.children) { + return { + id: this.toTreeNodeId(item), + data: item, + expanded: false, + children: [], + parent: undefined, + type: 'node', + selected: false + }; + } else { + return { + id: this.toTreeNodeId(item), + data: item, + parent: undefined, + type: 'leaf', + checkboxInfo: { + checked: item.backOrdered, + } + }; + } + } + + /** + * Calculate a unique id for a given tree item by using the item's name and appending a unique counter. + * + * @param item the item to calculate the id for + * @returns the unique id for the given item in the form "{name}-{counter}" + */ + private toTreeNodeId(item: Item): string { + const key = item.name; + + // get the next counter for this item's name (or use 0 if this is the first occurrence) + let count: number; + if (this.idCounter.has(key)) { + count = this.idCounter.get(key)!; + } + else { + count = 0; + } + + // store the new counter for this item's name + this.idCounter.set(key, count + 1); + // return the unique id in the form "{name}-{counter}" + return `${key}-${count}`; + } +} diff --git a/templates/tree-widget/treeview-example-tree.ts b/templates/tree-widget/treeview-example-tree.ts index fa58c43..07de327 100644 --- a/templates/tree-widget/treeview-example-tree.ts +++ b/templates/tree-widget/treeview-example-tree.ts @@ -1,5 +1,8 @@ import { CompositeTreeNode, TreeImpl, TreeNode } from '@theia/core/lib/browser'; -import { ExampleTreeNode, Item, ROOT_NODE_ID } from './treeview-example-model'; +import { wait } from '@theia/core/lib/common/promise-util'; +import { inject } from '@theia/core/shared/inversify'; +import { ExampleTreeNode, ROOT_NODE_ID } from './treeview-example-model'; +import { TreeViewExampleTreeItemFactory } from './treeview-example-tree-item-factory'; /** * Tree implementation. @@ -7,21 +10,23 @@ import { ExampleTreeNode, Item, ROOT_NODE_ID } from './treeview-example-model'; * We override this to enable lazy child node resolution on node expansion. */ export class TreeviewExampleTree extends TreeImpl { + @inject(TreeViewExampleTreeItemFactory) private readonly itemFactory: TreeViewExampleTreeItemFactory; + /** * Resolves children of the given parent node. * * @param parent the node for which to provide the children * @returns a new array of child tree nodes for the given parent node. */ - override resolveChildren(parent: CompositeTreeNode): Promise { + override async resolveChildren(parent: CompositeTreeNode): Promise { // root children are initialized once and never change, so we just return a copy of the original children if (parent.id === ROOT_NODE_ID) { - return Promise.resolve([...parent.children]); + return [...parent.children]; } // non-container nodes do not have children, so we return an empty array if (!ExampleTreeNode.is(parent)) { - return Promise.resolve([]); + return []; } // performance optimization - if the children are resolved already and the number of children is still correct @@ -29,12 +34,12 @@ export class TreeviewExampleTree extends TreeImpl { // Note: In a real application this comparison might require more logic, because if a child is replaced by a // different one or if children are reordered, this code would not work... if (parent.children.length === parent.data.children?.length) { - return Promise.resolve([...parent.children]); + return [...parent.children]; } - // simulate asynchronous loading of children. In the UI we can see a busy marker when we expand a node because of this: - return new Promise(resolve => { - setTimeout(() => resolve(parent.data.children!.map(Item.toTreeNode)), 2000); - }); + // simulate asynchronous loading of children. In the UI we can see a busy marker when we expand a node because of this. + // (in practice, we would call an expensive function to fetch the children and return the corresponding promise) + await wait(2000); + return (parent.data.children ?? []).map(i => this.itemFactory.toTreeNode(i)); } } diff --git a/templates/tree-widget/treeview-example-view-contribution.ts b/templates/tree-widget/treeview-example-view-contribution.ts index eb69015..33844dd 100644 --- a/templates/tree-widget/treeview-example-view-contribution.ts +++ b/templates/tree-widget/treeview-example-view-contribution.ts @@ -24,9 +24,7 @@ export class TreeviewExampleViewContribution extends AbstractViewContribution super.openView({ activate: false, reveal: true }) - }); - // register the "Add child item" command commands.registerCommand(TreeviewExampleTreeAddItem, { execute: () => { diff --git a/templates/tree-widget/treeview-example-widget.tsx b/templates/tree-widget/treeview-example-widget.tsx index 9fcb0d6..5a022d0 100644 --- a/templates/tree-widget/treeview-example-widget.tsx +++ b/templates/tree-widget/treeview-example-widget.tsx @@ -42,6 +42,7 @@ export class TreeViewExampleWidget extends TreeWidget { this.messageService.info(`Example node ${node.data.name} was opened.`); } })); + this.toDispose.push(this.toCancelNodeExpansion); } /** From f1f5c78d90a5a1981c77778f544b0e6baac125c0 Mon Sep 17 00:00:00 2001 From: Stefan Winkler Date: Mon, 28 Apr 2025 09:16:58 +0200 Subject: [PATCH 3/3] Added comment about Theia bug which prevents proper reflection of checkbox state changes in the UI Signed-off-by: Stefan Winkler --- .../tree-widget/treeview-example-tree-item-factory.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/templates/tree-widget/treeview-example-tree-item-factory.ts b/templates/tree-widget/treeview-example-tree-item-factory.ts index 36e7bcf..05e985b 100644 --- a/templates/tree-widget/treeview-example-tree-item-factory.ts +++ b/templates/tree-widget/treeview-example-tree-item-factory.ts @@ -48,6 +48,13 @@ export class TreeViewExampleTreeItemFactory { data: item, parent: undefined, type: 'leaf', + + /* NOTE! + * The checkboxInfo property can be used to add a checkbox to the tree node. + * But at the moment (Theia 1.60.x), there is an issue with the UI in which the + * checkbox state is not properly reflected after the user clicks it. + * See https://github.com/eclipse-theia/theia/issues/15521 for details. + */ checkboxInfo: { checked: item.backOrdered, }