diff --git a/examples/api-samples/src/browser/api-samples-frontend-module.ts b/examples/api-samples/src/browser/api-samples-frontend-module.ts index 59bbb0a6160cc..69285187a7797 100644 --- a/examples/api-samples/src/browser/api-samples-frontend-module.ts +++ b/examples/api-samples/src/browser/api-samples-frontend-module.ts @@ -33,6 +33,7 @@ import { bindSampleFileSystemCapabilitiesCommands } from './file-system/sample-f import { bindChatNodeToolbarActionContribution } from './chat/chat-node-toolbar-action-contribution'; import { bindAskAndContinueChatAgentContribution } from './chat/ask-and-continue-chat-agent-contribution'; import { bindChangeSetChatAgentContribution } from './chat/change-set-chat-agent-contribution'; +import { bindTreeViewExample } from './tree-widget/treeview-example-frontend-module'; export default new ContainerModule(( bind: interfaces.Bind, @@ -56,4 +57,5 @@ export default new ContainerModule(( bindTestSample(bind); bindSampleFileSystemCapabilitiesCommands(bind); rebindOVSXClientFactory(rebind); + bindTreeViewExample(bind); }); diff --git a/examples/api-samples/src/browser/tree-widget/decorator/treeview-example-decoration-service.ts b/examples/api-samples/src/browser/tree-widget/decorator/treeview-example-decoration-service.ts new file mode 100644 index 0000000000000..45f3c37bfdb57 --- /dev/null +++ b/examples/api-samples/src/browser/tree-widget/decorator/treeview-example-decoration-service.ts @@ -0,0 +1,28 @@ +// ***************************************************************************** +// Copyright (C) 2025 Stefan Winkler 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +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'); + +@injectable() +export class TreeviewExampleDecorationService extends AbstractTreeDecoratorService { + constructor(@inject(ContributionProvider) @named(TreeviewExampleDecorator) protected readonly contributions: ContributionProvider) { + super(contributions.getContributions()); + } +} diff --git a/examples/api-samples/src/browser/tree-widget/decorator/treeview-example-demo-decorator.ts b/examples/api-samples/src/browser/tree-widget/decorator/treeview-example-demo-decorator.ts new file mode 100644 index 0000000000000..b2273d6939f82 --- /dev/null +++ b/examples/api-samples/src/browser/tree-widget/decorator/treeview-example-demo-decorator.ts @@ -0,0 +1,61 @@ +// ***************************************************************************** +// Copyright (C) 2025 Stefan Winkler 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +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'; + +@injectable() +export class TreeviewExampleDemoDecorator implements TreeDecorator { + id = 'TreeviewExampleDecorator'; + + protected readonly emitter = new Emitter<(tree: Tree) => Map>(); + + get onDidChangeDecorations(): Event<(tree: Tree) => Map> { + return this.emitter.event; + } + + decorations(tree: Tree): MaybePromise> { + const result = new Map(); + + if (tree.root === undefined) { + return result; + } + for (const treeNode of new DepthFirstTreeIterator(tree.root)) { + if (ExampleTreeLeaf.is(treeNode)) { + const amount = treeNode.data.quantity || 0; + if (amount > 4) { + result.set(treeNode.id, { + iconOverlay: { + position: WidgetDecoration.IconOverlayPosition.BOTTOM_RIGHT, + iconClass: ['fa', 'fa-check-circle'], + color: 'green' + } + }); + } else { + result.set(treeNode.id, { + backgroundColor: 'red', + captionSuffixes: [{ data: 'Warning: low stock', fontData: { style: 'italic' } }] + }); + } + } + } + return result; + } +} diff --git a/examples/api-samples/src/browser/tree-widget/treeview-example-frontend-module.ts b/examples/api-samples/src/browser/tree-widget/treeview-example-frontend-module.ts new file mode 100644 index 0000000000000..cbb0138a405b1 --- /dev/null +++ b/examples/api-samples/src/browser/tree-widget/treeview-example-frontend-module.ts @@ -0,0 +1,72 @@ +// ***************************************************************************** +// Copyright (C) 2025 Stefan Winkler 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { bindContributionProvider } from '@theia/core'; +import { bindViewContribution, createTreeContainer, LabelProviderContribution, WidgetFactory } from '@theia/core/lib/browser'; +import { Container, 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. + * + * (in a standalone extension, this would be `export default new ContainerModule(bind => { ... })`) + * + * @param bind the binding function + */ +export function bindTreeViewExample(bind: interfaces.Bind): void { + 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/examples/api-samples/src/browser/tree-widget/treeview-example-label-provider.ts b/examples/api-samples/src/browser/tree-widget/treeview-example-label-provider.ts new file mode 100644 index 0000000000000..a09f42f4aaf48 --- /dev/null +++ b/examples/api-samples/src/browser/tree-widget/treeview-example-label-provider.ts @@ -0,0 +1,127 @@ +// ***************************************************************************** +// Copyright (C) 2025 Stefan Winkler 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +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/examples/api-samples/src/browser/tree-widget/treeview-example-model.ts b/examples/api-samples/src/browser/tree-widget/treeview-example-model.ts new file mode 100644 index 0000000000000..d47e66a6ed446 --- /dev/null +++ b/examples/api-samples/src/browser/tree-widget/treeview-example-model.ts @@ -0,0 +1,218 @@ +// ***************************************************************************** +// Copyright (C) 2025 Stefan Winkler 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +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/examples/api-samples/src/browser/tree-widget/treeview-example-tree.ts b/examples/api-samples/src/browser/tree-widget/treeview-example-tree.ts new file mode 100644 index 0000000000000..1fd37760ba06c --- /dev/null +++ b/examples/api-samples/src/browser/tree-widget/treeview-example-tree.ts @@ -0,0 +1,56 @@ +// ***************************************************************************** +// Copyright (C) 2025 Stefan Winkler 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +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/examples/api-samples/src/browser/tree-widget/treeview-example-view-contribution.ts b/examples/api-samples/src/browser/tree-widget/treeview-example-view-contribution.ts new file mode 100644 index 0000000000000..ba380186d2c97 --- /dev/null +++ b/examples/api-samples/src/browser/tree-widget/treeview-example-view-contribution.ts @@ -0,0 +1,91 @@ +// ***************************************************************************** +// Copyright (C) 2025 Stefan Winkler 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +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/examples/api-samples/src/browser/tree-widget/treeview-example-widget.css b/examples/api-samples/src/browser/tree-widget/treeview-example-widget.css new file mode 100644 index 0000000000000..495beb8f6012d --- /dev/null +++ b/examples/api-samples/src/browser/tree-widget/treeview-example-widget.css @@ -0,0 +1,19 @@ +/******************************************************************************** + * Copyright (C) 2025 Stefan Winkler 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-only WITH Classpath-exception-2.0 + ********************************************************************************/ + +.theia-example-tree-node .a { + padding-right: 4px +} diff --git a/examples/api-samples/src/browser/tree-widget/treeview-example-widget.tsx b/examples/api-samples/src/browser/tree-widget/treeview-example-widget.tsx new file mode 100644 index 0000000000000..7d705abe52d95 --- /dev/null +++ b/examples/api-samples/src/browser/tree-widget/treeview-example-widget.tsx @@ -0,0 +1,233 @@ +// ***************************************************************************** +// Copyright (C) 2025 Stefan Winkler 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +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/tree-widget/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); + } + } +}