deploy: current vibn theia state
Some checks failed
Playwright Tests / Playwright Tests (ubuntu-22.04, Node.js 22.x) (push) Has been cancelled
3PP License Check / 3PP License Check (11, 22.x, ubuntu-22.04) (push) Has been cancelled
Publish packages to NPM / Perform Publishing (push) Has been cancelled

Made-with: Cursor
This commit is contained in:
2026-02-27 12:01:08 -08:00
commit 8bb5110148
3782 changed files with 640947 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: [
'../../configs/build.eslintrc.json'
],
parserOptions: {
tsconfigRootDir: __dirname,
project: 'tsconfig.json'
}
};

View File

@@ -0,0 +1,31 @@
<div align='center'>
<br />
<img src='https://raw.githubusercontent.com/eclipse-theia/theia/master/logo/theia.svg?sanitize=true' alt='theia-ext-logo' width='100px' />
<h2>ECLIPSE THEIA - TERMINAL MANAGER EXTENSION</h2>
<hr />
</div>
## Description
The `@theia/terminal-manager` extension contributes a terminal manager widget to use several terminal widgets within one view.
The extension provides setting `terminal.grouping.mode` to toggle using this instead of separate terminal views.
## Additional Information
- [Theia - GitHub](https://github.com/eclipse-theia/theia)
- [Theia - Website](https://theia-ide.org/)
## License
- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/)
- [一 (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp)
## Trademark
"Theia" is a trademark of the Eclipse Foundation
<https://www.eclipse.org/theia>

View File

@@ -0,0 +1,39 @@
{
"name": "@theia/terminal-manager",
"version": "1.68.0",
"description": "Theia - Terminal Manager Extension",
"keywords": [
"theia-extension"
],
"homepage": "https://github.com/eclipse-theia/theia",
"license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0",
"repository": {
"type": "git",
"url": "https://github.com/eclipse-theia/theia.git"
},
"files": [
"lib",
"src"
],
"scripts": {
"build": "theiaext build",
"clean": "theiaext clean",
"compile": "theiaext compile",
"lint": "theiaext lint",
"test": "theiaext test",
"watch": "theiaext watch"
},
"dependencies": {
"@theia/core": "1.68.0",
"@theia/preferences": "1.68.0",
"@theia/terminal": "1.68.0"
},
"theiaExtensions": [
{
"frontend": "lib/browser/terminal-manager-frontend-module"
}
],
"publishConfig": {
"access": "public"
}
}

View File

@@ -0,0 +1,213 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource 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.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, inject } from '@theia/core/shared/inversify';
import { CommandRegistry, PreferenceService, DisposableCollection } from '@theia/core/lib/common';
import { TerminalWidget } from '@theia/terminal/lib/browser/base/terminal-widget';
import { TerminalFrontendContribution, TerminalCommands } from '@theia/terminal/lib/browser/terminal-frontend-contribution';
import { ApplicationShell, WidgetManager, FrontendApplicationContribution, FrontendApplication } from '@theia/core/lib/browser';
import { TerminalManagerWidget } from './terminal-manager-widget';
import { TerminalManagerFrontendViewContribution } from './terminal-manager-frontend-view-contribution';
import { TerminalManagerPreferences } from './terminal-manager-preferences';
/**
* Re-registers terminal commands (e.g. new terminal) to execute them via the terminal manager
* instead of creating new, separate terminals.
*/
@injectable()
export class TerminalManagerFrontendContribution implements FrontendApplicationContribution {
@inject(TerminalFrontendContribution)
protected readonly terminalFrontendContribution: TerminalFrontendContribution;
@inject(TerminalManagerFrontendViewContribution)
protected readonly terminalManagerViewContribution: TerminalManagerFrontendViewContribution;
@inject(WidgetManager)
protected readonly widgetManager: WidgetManager;
@inject(ApplicationShell)
protected readonly shell: ApplicationShell;
@inject(PreferenceService)
protected readonly preferenceService: PreferenceService;
@inject(TerminalManagerPreferences)
protected readonly preferences: TerminalManagerPreferences;
@inject(CommandRegistry)
protected readonly commandRegistry: CommandRegistry;
protected commandHandlerDisposables = new DisposableCollection();
onStart(app: FrontendApplication): void {
this.preferenceService.ready.then(() => {
this.preferenceService.onPreferenceChanged(change => {
if (change.preferenceName === 'terminal.grouping.mode') {
this.handleTabsDisplayChange(this.preferences['terminal.grouping.mode']);
}
});
if (this.preferences.get('terminal.grouping.mode') !== 'tree') {
console.debug('Terminal tab style is not tree. Use separate terminal views.');
return;
}
console.debug('Terminal tab style is tree. Override command handlers accordingly.');
this.registerHandlers();
});
}
protected async handleTabsDisplayChange(newValue: string): Promise<void> {
if (newValue === 'tree') {
await this.migrateTerminalsToManager();
this.registerHandlers();
} else {
this.unregisterHandlers();
await this.migrateTerminalsToTabs();
}
}
/**
* Migrate terminals from tabs to terminal manager. Applies only to terminals currently in bottom panel.
*/
protected async migrateTerminalsToManager(): Promise<void> {
const bottomTerminals = this.shell.getWidgets('bottom')
.filter((w): w is TerminalWidget => w instanceof TerminalWidget);
if (bottomTerminals.length === 0) {
return;
}
const managerWidget = await this.widgetManager.getOrCreateWidget<TerminalManagerWidget>(TerminalManagerWidget.ID);
if (!(managerWidget instanceof TerminalManagerWidget)) {
return;
}
// Before terminal manager attachment to precede creation of default widget.
for (const terminal of bottomTerminals) {
managerWidget.addTerminalPage(terminal);
terminal.show(); // Clear hidden flag that may have been set by dock panel on removal.
}
await this.terminalManagerViewContribution.openView({ reveal: true });
}
/**
* Migrate terminals from the terminal manager to the bottom panel as separate tabs.
*/
protected async migrateTerminalsToTabs(): Promise<void> {
const managerWidget = this.terminalManagerViewContribution.tryGetWidget();
if (!(managerWidget instanceof TerminalManagerWidget)) {
return;
}
let toActivate: TerminalWidget | undefined = undefined;
for (const terminal of managerWidget.drainWidgets()) {
if (!terminal.isDisposed) {
toActivate ??= terminal;
this.shell.addWidget(terminal, { area: 'bottom' });
}
}
if (toActivate) {
this.shell.activateWidget(toActivate.id);
}
}
protected unregisterHandlers(): void {
this.commandHandlerDisposables.dispose();
this.commandHandlerDisposables = new DisposableCollection();
}
protected registerHandlers(): void {
this.unregisterHandlers();
this.registerCommands();
}
protected registerCommands(): void {
this.commandHandlerDisposables.push(this.commandRegistry.registerHandler(TerminalCommands.NEW.id, {
execute: async () => {
// Only create a new terminal if the view was existing as opening it automatically create a terminal
const existing = this.terminalManagerViewContribution.tryGetWidget();
const managerWidget = await this.terminalManagerViewContribution.openView({ reveal: true });
if (managerWidget instanceof TerminalManagerWidget && existing) {
const terminalWidget = await managerWidget.createTerminalWidget();
managerWidget.addTerminalPage(terminalWidget);
}
}
}));
this.commandHandlerDisposables.push(this.commandRegistry.registerHandler(TerminalCommands.NEW_ACTIVE_WORKSPACE.id, {
execute: async () => {
// Only create a new terminal if the view was existing as opening it automatically create a terminal
const existing = this.terminalManagerViewContribution.tryGetWidget();
const managerWidget = await this.terminalManagerViewContribution.openView({ reveal: true });
if (managerWidget instanceof TerminalManagerWidget && existing) {
const terminalWidget = await managerWidget.createTerminalWidget();
managerWidget.addTerminalPage(terminalWidget);
}
}
}));
this.commandHandlerDisposables.push(this.commandRegistry.registerHandler(TerminalCommands.SPLIT.id, {
execute: async () => {
const managerWidget = await this.terminalManagerViewContribution.openView({ reveal: true });
if (managerWidget instanceof TerminalManagerWidget) {
const terminalWidget = await managerWidget.createTerminalWidget();
const { model } = managerWidget.treeWidget;
const activeGroupId = model.activeGroupNode?.id;
const activePageId = model.activePageNode?.id;
if (activeGroupId) {
managerWidget.addWidgetToTerminalGroup(terminalWidget, activeGroupId);
} else if (activePageId) {
managerWidget.addTerminalGroupToPage(terminalWidget, activePageId);
} else {
managerWidget.addTerminalPage(terminalWidget);
}
}
},
isEnabled: w => w instanceof TerminalWidget || w instanceof TerminalManagerWidget,
isVisible: w => w instanceof TerminalWidget || w instanceof TerminalManagerWidget,
}));
this.commandHandlerDisposables.push(this.commandRegistry.registerHandler(TerminalCommands.TOGGLE_TERMINAL.id, {
execute: async () => {
const existing = this.terminalManagerViewContribution.tryGetWidget();
if (!existing || !(existing instanceof TerminalManagerWidget)) {
const managerWidget = await this.terminalManagerViewContribution.openView({ activate: true });
if (managerWidget instanceof TerminalManagerWidget && !this.shell.isExpanded('bottom')) {
this.shell.expandPanel('bottom');
}
return;
}
if (!existing.isAttached) {
await this.terminalManagerViewContribution.openView({ activate: true });
if (!this.shell.isExpanded('bottom')) {
this.shell.expandPanel('bottom');
}
return;
}
if (!this.shell.isExpanded('bottom')) {
this.shell.expandPanel('bottom');
this.shell.bottomPanel.activateWidget(existing);
return;
}
const active = this.shell.activeWidget;
const isManagerOrChildActive = active === existing || Array.from(existing.terminalWidgets.values()).some(widget => widget === active);
if (isManagerOrChildActive) {
this.shell.collapsePanel('bottom');
} else {
this.shell.bottomPanel.activateWidget(existing);
}
}
}));
}
}

View File

@@ -0,0 +1,64 @@
// *****************************************************************************
// Copyright (C) 2023 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ContainerModule, interfaces } from '@theia/core/shared/inversify';
import {
bindViewContribution,
WidgetFactory,
WidgetManager,
FrontendApplicationContribution,
} from '@theia/core/lib/browser';
import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import { TerminalManagerWidget } from './terminal-manager-widget';
import { TerminalManagerFrontendViewContribution } from './terminal-manager-frontend-view-contribution';
import { TerminalManagerFrontendContribution } from './terminal-manager-frontend-contribution';
import { TerminalManagerPreferenceContribution, TerminalManagerPreferences, TerminalManagerPreferenceSchema } from './terminal-manager-preferences';
import { TerminalManagerTreeWidget } from './terminal-manager-tree-widget';
import '../../src/browser/terminal-manager.css';
import { PreferenceContribution, PreferenceProxyFactory } from '@theia/core';
export default new ContainerModule((bind: interfaces.Bind) => {
bindViewContribution(bind, TerminalManagerFrontendViewContribution);
bind(TabBarToolbarContribution).toService(TerminalManagerFrontendViewContribution);
// Bind the contribution for overridden terminal commands
bind(TerminalManagerFrontendContribution).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toService(TerminalManagerFrontendContribution);
bind(WidgetFactory).toDynamicValue(({ container }) => ({
id: TerminalManagerTreeWidget.ID,
createWidget: () => TerminalManagerTreeWidget.createWidget(container),
})).inSingletonScope();
bind(WidgetFactory).toDynamicValue(({ container }) => ({
id: TerminalManagerWidget.ID,
createWidget: async () => {
const child = container.createChild();
const widgetManager = container.get(WidgetManager);
const terminalManagerTreeWidget = await widgetManager.getOrCreateWidget<TerminalManagerTreeWidget>(TerminalManagerTreeWidget.ID);
child.bind(TerminalManagerTreeWidget).toConstantValue(terminalManagerTreeWidget);
return TerminalManagerWidget.createWidget(child);
},
}));
bind(TerminalManagerPreferences).toDynamicValue(ctx => {
const factory = ctx.container.get<PreferenceProxyFactory>(PreferenceProxyFactory);
return factory(TerminalManagerPreferenceSchema);
}).inSingletonScope();
bind(TerminalManagerPreferenceContribution).toConstantValue({ schema: TerminalManagerPreferenceSchema });
bind(PreferenceContribution).toService(TerminalManagerPreferenceContribution);
});

View File

@@ -0,0 +1,345 @@
// *****************************************************************************
// Copyright (C) 2023 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, inject } from '@theia/core/shared/inversify';
import {
AbstractViewContribution,
codicon,
KeybindingContribution,
KeybindingRegistry,
MAXIMIZED_CLASS,
Widget,
} from '@theia/core/lib/browser';
import { CommandRegistry, Disposable, Event, MenuModelRegistry, nls } from '@theia/core';
import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import { BOTTOM_AREA_ID } from '@theia/core/lib/browser/shell/theia-dock-panel';
import { TerminalManagerCommands, TerminalManagerTreeTypes, TERMINAL_MANAGER_TREE_CONTEXT_MENU } from './terminal-manager-types';
import { TerminalManagerWidget } from './terminal-manager-widget';
import { TerminalManagerTreeWidget } from './terminal-manager-tree-widget';
import { ConfirmDialog, Dialog } from '@theia/core/lib/browser/dialogs';
import { TerminalManagerPreferences } from './terminal-manager-preferences';
@injectable()
export class TerminalManagerFrontendViewContribution extends AbstractViewContribution<TerminalManagerWidget>
implements TabBarToolbarContribution, KeybindingContribution {
@inject(TerminalManagerPreferences)
protected readonly preferences: TerminalManagerPreferences;
protected quickViewDisposable: Disposable | undefined;
constructor() {
super({
widgetId: TerminalManagerWidget.ID,
widgetName: nls.localize('theia/terminal-manager', 'Terminal Manager'),
defaultWidgetOptions: {
area: 'bottom',
},
});
}
override registerCommands(commands: CommandRegistry): void {
// Don't call super.registerCommands() - we manage quick view registration manually
// based on the terminal.grouping.mode preference
this.preferences.ready.then(() => {
this.updateQuickViewRegistration();
this.preferences.onPreferenceChanged(change => {
if (change.preferenceName === 'terminal.grouping.mode') {
this.updateQuickViewRegistration();
}
});
});
commands.registerCommand(TerminalManagerCommands.MANAGER_NEW_TERMINAL_GROUP, {
execute: (
...args: TerminalManagerTreeTypes.ContextMenuArgs
) => {
const nodeId = args[1];
if (TerminalManagerTreeTypes.isPageId(nodeId)) {
this.createNewTerminalGroup(nodeId);
}
},
isVisible: (
...args: TerminalManagerTreeTypes.ContextMenuArgs
) => args[0] instanceof TerminalManagerTreeWidget && TerminalManagerTreeTypes.isPageId(args[1]),
});
commands.registerCommand(TerminalManagerCommands.MANAGER_SHOW_TREE_TOOLBAR, {
execute: () => this.handleToggleTree(),
isVisible: widget => widget instanceof TerminalManagerWidget,
});
commands.registerCommand(TerminalManagerCommands.MANAGER_NEW_PAGE_BOTTOM_TOOLBAR, {
execute: () => this.createNewTerminalPage(),
isVisible: (
...args: TerminalManagerTreeTypes.ContextMenuArgs
) => args[0] instanceof TerminalManagerWidget,
});
commands.registerCommand(TerminalManagerCommands.MANAGER_DELETE_TERMINAL, {
execute: (
...args: TerminalManagerTreeTypes.ContextMenuArgs
) => TerminalManagerTreeTypes.isTerminalKey(args[1]) && this.deleteTerminalFromManager(args[1]),
isVisible: (...args: TerminalManagerTreeTypes.ContextMenuArgs) => {
const treeWidget = args[0];
const nodeId = args[1];
if (treeWidget instanceof TerminalManagerTreeWidget && TerminalManagerTreeTypes.isTerminalKey(nodeId)) {
const { model } = treeWidget;
const terminalNode = model.getNode(nodeId);
return TerminalManagerTreeTypes.isTerminalNode(terminalNode);
}
return false;
},
});
commands.registerCommand(TerminalManagerCommands.MANAGER_DELETE_PAGE, {
execute: (
...args: TerminalManagerTreeTypes.ContextMenuArgs
) => TerminalManagerTreeTypes.isPageId(args[1]) && this.deletePageFromManager(args[1]),
isVisible: (...args: TerminalManagerTreeTypes.ContextMenuArgs) => {
const widget = args[0];
return widget instanceof TerminalManagerTreeWidget && TerminalManagerTreeTypes.isPageId(args[1]) && widget.model.pages.size >= 1;
},
});
commands.registerCommand(TerminalManagerCommands.MANAGER_RENAME_TERMINAL, {
execute: (...args: TerminalManagerTreeTypes.ContextMenuArgs) => this.toggleRenameTerminalFromManager(args[1]),
isVisible: (...args: TerminalManagerTreeTypes.ContextMenuArgs) => args[0] instanceof TerminalManagerTreeWidget,
});
commands.registerCommand(TerminalManagerCommands.MANAGER_ADD_TERMINAL_TO_GROUP, {
execute: (
...args: TerminalManagerTreeTypes.ContextMenuArgs
) => {
const nodeId = args[1];
if (TerminalManagerTreeTypes.isGroupId(nodeId)) {
this.addTerminalToGroup(nodeId);
}
},
isVisible: (
...args: TerminalManagerTreeTypes.ContextMenuArgs
) => args[0] instanceof TerminalManagerTreeWidget && TerminalManagerTreeTypes.isGroupId(args[1]),
});
commands.registerCommand(TerminalManagerCommands.MANAGER_DELETE_GROUP, {
execute: (
...args: TerminalManagerTreeTypes.ContextMenuArgs
) => TerminalManagerTreeTypes.isGroupId(args[1]) && this.deleteGroupFromManager(args[1]),
isVisible: (...args: TerminalManagerTreeTypes.ContextMenuArgs) => {
const treeWidget = args[0];
const groupId = args[1];
if (treeWidget instanceof TerminalManagerTreeWidget && TerminalManagerTreeTypes.isGroupId(groupId)) {
const { model } = treeWidget;
const groupNode = model.getNode(groupId);
return TerminalManagerTreeTypes.isGroupNode(groupNode);
}
return false;
},
});
commands.registerCommand(TerminalManagerCommands.MANAGER_MAXIMIZE_BOTTOM_PANEL_TOOLBAR, {
execute: () => this.maximizeBottomPanel(),
isVisible: widget => widget instanceof Widget
&& widget.parent?.id === BOTTOM_AREA_ID
&& !this.shell.bottomPanel.hasClass(MAXIMIZED_CLASS),
});
commands.registerCommand(TerminalManagerCommands.MANAGER_MINIMIZE_BOTTOM_PANEL_TOOLBAR, {
execute: () => this.maximizeBottomPanel(),
isVisible: widget => widget instanceof Widget
&& widget.parent?.id === BOTTOM_AREA_ID
&& this.shell.bottomPanel.hasClass(MAXIMIZED_CLASS),
});
commands.registerCommand(TerminalManagerCommands.MANAGER_CLEAR_ALL, {
isVisible: widget => widget instanceof TerminalManagerWidget,
execute: async widget => {
if (widget instanceof TerminalManagerWidget) {
const PRIMARY_BUTTON = nls.localize('theia/terminal-manager/resetLayout', 'Reset Layout');
const dialogResponse = await this.confirmUserAction({
title: nls.localize('theia/terminal-manager/resetLayoutPrompt', 'Do you want to reset the terminal manager layout?'),
message: nls.localize(
'theia/terminal-manager/resetLayoutMessage',
'Once the layout is reset, it cannot be restored. Are you sure you would like to clear the layout?'),
primaryButtonText: PRIMARY_BUTTON,
});
if (dialogResponse === PRIMARY_BUTTON) {
widget.resetView();
}
}
},
});
commands.registerCommand(TerminalManagerCommands.MANAGER_OPEN_VIEW, {
execute: () => this.openView({ activate: true }),
isEnabled: () => this.isTreeMode(),
isVisible: () => this.isTreeMode(),
});
commands.registerCommand(TerminalManagerCommands.MANAGER_CLOSE_VIEW, {
isVisible: () => Boolean(this.tryGetWidget()),
isEnabled: () => Boolean(this.tryGetWidget()),
execute: () => this.closeView(),
});
}
protected isTreeMode(): boolean {
return this.preferences.get('terminal.grouping.mode') === 'tree';
}
protected updateQuickViewRegistration(): void {
if (this.isTreeMode()) {
if (!this.quickViewDisposable) {
this.quickViewDisposable = this.quickView?.registerItem({
label: this.viewLabel,
open: () => this.openView({ activate: true })
});
}
} else {
this.quickViewDisposable?.dispose();
this.quickViewDisposable = undefined;
}
}
protected async confirmUserAction(options: { title: string, message: string, primaryButtonText: string }): Promise<string | undefined> {
const dialog = new ConfirmDialog({
title: options.title,
msg: options.message,
ok: options.primaryButtonText,
cancel: Dialog.CANCEL,
});
const confirmed = await dialog.open();
return confirmed ? options.primaryButtonText : undefined;
}
protected maximizeBottomPanel(): void {
this.shell.bottomPanel.toggleMaximized();
}
protected async createNewTerminalPage(): Promise<void> {
const terminalManagerWidget = await this.widget;
const terminalWidget = await terminalManagerWidget.createTerminalWidget();
terminalManagerWidget.addTerminalPage(terminalWidget);
}
protected async createNewTerminalGroup(pageId: TerminalManagerTreeTypes.PageId): Promise<void> {
const terminalManagerWidget = await this.widget;
const terminalWidget = await terminalManagerWidget.createTerminalWidget();
terminalManagerWidget.addTerminalGroupToPage(terminalWidget, pageId);
}
protected async addTerminalToGroup(groupId: TerminalManagerTreeTypes.GroupId): Promise<void> {
const terminalManagerWidget = await this.widget;
const terminalWidget = await terminalManagerWidget.createTerminalWidget();
terminalManagerWidget.addWidgetToTerminalGroup(terminalWidget, groupId);
}
protected async handleToggleTree(): Promise<void> {
const terminalManagerWidget = await this.widget;
terminalManagerWidget.toggleTreeVisibility();
}
protected async deleteTerminalFromManager(terminalId: TerminalManagerTreeTypes.TerminalKey): Promise<void> {
const terminalManagerWidget = await this.widget;
terminalManagerWidget?.deleteTerminal(terminalId);
}
protected async deleteGroupFromManager(groupId: TerminalManagerTreeTypes.GroupId): Promise<void> {
const widget = await this.widget;
widget.deleteGroup(groupId);
}
protected async deletePageFromManager(pageId: TerminalManagerTreeTypes.PageId): Promise<void> {
const widget = await this.widget;
widget.deletePage(pageId);
}
protected async toggleRenameTerminalFromManager(entityId: TerminalManagerTreeTypes.TerminalManagerValidId): Promise<void> {
const widget = await this.widget;
widget.toggleRenameTerminal(entityId);
}
override registerMenus(menus: MenuModelRegistry): void {
super.registerMenus(menus);
menus.registerMenuAction(TERMINAL_MANAGER_TREE_CONTEXT_MENU, {
commandId: TerminalManagerCommands.MANAGER_ADD_TERMINAL_TO_GROUP.id,
order: 'a',
});
menus.registerMenuAction(TERMINAL_MANAGER_TREE_CONTEXT_MENU, {
commandId: TerminalManagerCommands.MANAGER_RENAME_TERMINAL.id,
order: 'b',
});
menus.registerMenuAction(TERMINAL_MANAGER_TREE_CONTEXT_MENU, {
commandId: TerminalManagerCommands.MANAGER_DELETE_TERMINAL.id,
order: 'c',
});
menus.registerMenuAction(TERMINAL_MANAGER_TREE_CONTEXT_MENU, {
commandId: TerminalManagerCommands.MANAGER_DELETE_PAGE.id,
order: 'c',
});
menus.registerMenuAction(TERMINAL_MANAGER_TREE_CONTEXT_MENU, {
commandId: TerminalManagerCommands.MANAGER_DELETE_GROUP.id,
order: 'c',
});
menus.registerMenuAction(TerminalManagerTreeTypes.PAGE_NODE_MENU, {
commandId: TerminalManagerCommands.MANAGER_NEW_TERMINAL_GROUP.id,
order: 'a',
});
menus.registerMenuAction(TerminalManagerTreeTypes.PAGE_NODE_MENU, {
commandId: TerminalManagerCommands.MANAGER_DELETE_PAGE.id,
order: 'b',
});
menus.registerMenuAction(TerminalManagerTreeTypes.TERMINAL_NODE_MENU, {
commandId: TerminalManagerCommands.MANAGER_DELETE_TERMINAL.id,
order: 'c',
});
menus.registerMenuAction(TerminalManagerTreeTypes.GROUP_NODE_MENU, {
commandId: TerminalManagerCommands.MANAGER_ADD_TERMINAL_TO_GROUP.id,
order: 'a',
});
menus.registerMenuAction(TerminalManagerTreeTypes.GROUP_NODE_MENU, {
commandId: TerminalManagerCommands.MANAGER_DELETE_GROUP.id,
order: 'c',
});
}
registerToolbarItems(toolbar: TabBarToolbarRegistry): void {
toolbar.registerItem({
id: TerminalManagerCommands.MANAGER_NEW_PAGE_BOTTOM_TOOLBAR.id,
command: TerminalManagerCommands.MANAGER_NEW_PAGE_BOTTOM_TOOLBAR.id,
});
toolbar.registerItem({
id: TerminalManagerCommands.MANAGER_SHOW_TREE_TOOLBAR.id,
command: TerminalManagerCommands.MANAGER_SHOW_TREE_TOOLBAR.id,
});
toolbar.registerItem({
id: TerminalManagerCommands.MANAGER_CLEAR_ALL.id,
command: TerminalManagerCommands.MANAGER_CLEAR_ALL.id,
});
const bottomPanelMaximizationChanged = Event.map(Event.filter(this.shell.onDidToggleMaximized, widget => widget === this.shell.bottomPanel), () => undefined);
toolbar.registerItem({
id: TerminalManagerCommands.MANAGER_MAXIMIZE_BOTTOM_PANEL_TOOLBAR.id,
command: TerminalManagerCommands.MANAGER_MAXIMIZE_BOTTOM_PANEL_TOOLBAR.id,
icon: codicon('chevron-up'),
onDidChange: bottomPanelMaximizationChanged,
});
toolbar.registerItem({
id: TerminalManagerCommands.MANAGER_MINIMIZE_BOTTOM_PANEL_TOOLBAR.id,
command: TerminalManagerCommands.MANAGER_MINIMIZE_BOTTOM_PANEL_TOOLBAR.id,
icon: codicon('chevron-down'),
onDidChange: bottomPanelMaximizationChanged,
});
}
override registerKeybindings(registry: KeybindingRegistry): void {
registry.registerKeybinding({
command: TerminalManagerCommands.MANAGER_MINIMIZE_BOTTOM_PANEL_TOOLBAR.id,
keybinding: 'alt+q',
});
}
}

View File

@@ -0,0 +1,52 @@
// *****************************************************************************
// Copyright (C) 2023 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { nls, PreferenceProxy, PreferenceSchema, PreferenceScope } from '@theia/core';
export const TerminalManagerPreferenceSchema: PreferenceSchema = {
properties: {
'terminal.grouping.treeViewLocation': {
'type': 'string',
'enum': ['left', 'right'],
'description': nls.localize('theia/terminal-manager/treeViewLocation', 'The location of the terminal manager\'s tree view.'
+ ' Only applies when \'terminal.grouping.mode\' is set to \'tree\'.'),
'default': 'left',
'scope': PreferenceScope.Workspace,
},
'terminal.grouping.mode': {
'type': 'string',
'enum': ['tabbed', 'tree'],
'description': nls.localize('theia/terminal-manager/tabsDisplay',
'Controls how terminals are displayed. \'tree\' shows multiple terminals in a single view with a tree view for management,'
+ '\'tabbed\' shows each terminal in its own view in a separate tab.'),
'default': 'tabbed',
'scope': PreferenceScope.Workspace,
},
},
};
export type TerminalManagerTreeViewLocation = 'left' | 'right';
export type TerminalGroupingMode = 'tabbed' | 'tree';
export interface TerminalManagerConfiguration {
'terminal.grouping.treeViewLocation': TerminalManagerTreeViewLocation;
'terminal.grouping.mode': TerminalGroupingMode;
}
export const TerminalManagerPreferences = Symbol('TerminalManagerPreferences');
export const TerminalManagerPreferenceContribution = Symbol('TerminalManagerPreferenceContribution');
export type TerminalManagerPreferences = PreferenceProxy<TerminalManagerConfiguration>;

View File

@@ -0,0 +1,340 @@
// *****************************************************************************
// Copyright (C) 2023 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, postConstruct } from '@theia/core/shared/inversify';
import { TreeModelImpl, CompositeTreeNode, SelectableTreeNode, DepthFirstTreeIterator, TreeNode } from '@theia/core/lib/browser';
import { Emitter, nls } from '@theia/core';
import { TerminalManagerTreeTypes } from './terminal-manager-types';
@injectable()
export class TerminalManagerTreeModel extends TreeModelImpl {
activePageNode: TerminalManagerTreeTypes.PageNode | undefined;
activeGroupNode: TerminalManagerTreeTypes.TerminalGroupNode | undefined;
activeTerminalNode: TerminalManagerTreeTypes.TerminalNode | undefined;
protected onDidChangeTreeSelectionEmitter = new Emitter<TerminalManagerTreeTypes.SelectionChangedEvent>();
readonly onDidChangeTreeSelection = this.onDidChangeTreeSelectionEmitter.event;
protected onDidAddPageEmitter = new Emitter<{ pageId: TerminalManagerTreeTypes.PageId, terminalKey: TerminalManagerTreeTypes.TerminalKey }>();
readonly onDidAddPage = this.onDidAddPageEmitter.event;
protected onDidDeletePageEmitter = new Emitter<TerminalManagerTreeTypes.PageId>();
readonly onDidDeletePage = this.onDidDeletePageEmitter.event;
protected onDidRenameNodeEmitter = new Emitter<TerminalManagerTreeTypes.TerminalManagerTreeNode>();
readonly onDidRenameNode = this.onDidRenameNodeEmitter.event;
protected onDidAddTerminalGroupEmitter = new Emitter<{
groupId: TerminalManagerTreeTypes.GroupId,
pageId: TerminalManagerTreeTypes.PageId,
terminalKey: TerminalManagerTreeTypes.TerminalKey,
}>();
readonly onDidAddTerminalGroup = this.onDidAddTerminalGroupEmitter.event;
protected onDidDeleteTerminalGroupEmitter = new Emitter<TerminalManagerTreeTypes.GroupId>();
readonly onDidDeleteTerminalGroup = this.onDidDeleteTerminalGroupEmitter.event;
protected onDidAddTerminalToGroupEmitter = new Emitter<{
terminalId: TerminalManagerTreeTypes.TerminalKey,
groupId: TerminalManagerTreeTypes.GroupId,
}>();
readonly onDidAddTerminalToGroup = this.onDidAddTerminalToGroupEmitter.event;
protected onDidDeleteTerminalFromGroupEmitter = new Emitter<{
terminalId: TerminalManagerTreeTypes.TerminalKey,
groupId: TerminalManagerTreeTypes.GroupId,
}>();
readonly onDidDeleteTerminalFromGroup = this.onDidDeleteTerminalFromGroupEmitter.event;
@postConstruct()
protected override init(): void {
super.init();
this.toDispose.push(this.selectionService.onSelectionChanged(selectionEvent => {
const selectedNode = selectionEvent.find(node => node.selected);
if (selectedNode) {
this.handleSelectionChanged(selectedNode);
}
}));
this.root = { id: 'root', parent: undefined, children: [], visible: false } as CompositeTreeNode;
}
addTerminalPage(
terminalKey: TerminalManagerTreeTypes.TerminalKey,
groupId: TerminalManagerTreeTypes.GroupId,
pageId: TerminalManagerTreeTypes.PageId,
): void {
const pageNode = this.createPageNode(pageId);
const groupNode = this.createGroupNode(groupId, pageId);
const terminalNode = this.createTerminalNode(terminalKey, groupId);
if (this.root && CompositeTreeNode.is(this.root)) {
this.activePageNode = pageNode;
CompositeTreeNode.addChild(groupNode, terminalNode);
CompositeTreeNode.addChild(pageNode, groupNode);
this.root = CompositeTreeNode.addChild(this.root, pageNode);
this.onDidAddPageEmitter.fire({ pageId: pageNode.id, terminalKey });
setTimeout(() => {
this.selectionService.addSelection(terminalNode);
});
}
}
protected createPageNode(pageId: TerminalManagerTreeTypes.PageId): TerminalManagerTreeTypes.PageNode {
const currentPageNumber = this.getNextPageCounter();
return {
id: pageId,
label: `${nls.localize('theia/terminal-manager/page', 'Page')} (${currentPageNumber})`,
parent: undefined,
selected: false,
children: [],
page: true,
isEditing: false,
expanded: true,
counter: currentPageNumber,
};
}
protected getNextPageCounter(): number {
return Math.max(0, ...Array.from(this.pages.values(), page => page.counter)) + 1;
}
deleteTerminalPage(pageId: TerminalManagerTreeTypes.PageId): void {
const pageNode = this.getNode(pageId);
if (TerminalManagerTreeTypes.isPageNode(pageNode) && CompositeTreeNode.is(this.root)) {
const isActive = this.activePageNode === pageNode;
this.onDidDeletePageEmitter.fire(pageNode.id);
CompositeTreeNode.removeChild(this.root, pageNode);
this.refreshWithSelection(this.root, undefined, isActive ? pageNode : undefined);
}
}
addTerminalGroup(
terminalKey: TerminalManagerTreeTypes.TerminalKey,
groupId: TerminalManagerTreeTypes.GroupId,
pageId: TerminalManagerTreeTypes.PageId,
): void {
const groupNode = this.createGroupNode(groupId, pageId);
const terminalNode = this.createTerminalNode(terminalKey, groupId);
const pageNode = this.getNode(pageId);
if (this.root && CompositeTreeNode.is(this.root) && TerminalManagerTreeTypes.isPageNode(pageNode)) {
this.onDidAddTerminalGroupEmitter.fire({ groupId: groupNode.id, pageId, terminalKey });
CompositeTreeNode.addChild(groupNode, terminalNode);
CompositeTreeNode.addChild(pageNode, groupNode);
this.refreshWithSelection(pageNode, terminalNode);
}
}
protected createGroupNode(
groupId: TerminalManagerTreeTypes.GroupId,
pageId: TerminalManagerTreeTypes.PageId,
): TerminalManagerTreeTypes.TerminalGroupNode {
const currentGroupNum = this.getNextGroupCounterForPage(pageId);
return {
id: groupId,
label: `${nls.localize('theia/terminal-manager/group', 'Group')} (${currentGroupNum})`,
parent: undefined,
selected: false,
children: [],
terminalGroup: true,
isEditing: false,
parentPageId: pageId,
expanded: true,
counter: currentGroupNum,
};
}
protected getNextGroupCounterForPage(pageId: TerminalManagerTreeTypes.PageId): number {
const page = this.pages.get(pageId);
if (page) {
return Math.max(0, ...page.children.map(group => group.counter)) + 1;
}
return 1;
}
deleteTerminalGroup(groupId: TerminalManagerTreeTypes.GroupId): void {
const groupNode = this.tree.getNode(groupId);
const parentPageNode = groupNode?.parent;
if (TerminalManagerTreeTypes.isGroupNode(groupNode) && TerminalManagerTreeTypes.isPageNode(parentPageNode)) {
if (parentPageNode.children.length === 1) {
this.deleteTerminalPage(parentPageNode.id);
} else {
const isActive = this.activeGroupNode === groupNode;
this.doDeleteTerminalGroup(groupNode, parentPageNode);
this.refreshWithSelection(parentPageNode, undefined, isActive ? groupNode : undefined);
}
}
}
protected doDeleteTerminalGroup(group: TerminalManagerTreeTypes.TerminalGroupNode, page: TerminalManagerTreeTypes.PageNode): void {
this.onDidDeleteTerminalGroupEmitter.fire(group.id);
CompositeTreeNode.removeChild(page, group);
}
addTerminal(newTerminalId: TerminalManagerTreeTypes.TerminalKey, groupId: TerminalManagerTreeTypes.GroupId): void {
const groupNode = this.getNode(groupId);
if (groupNode && TerminalManagerTreeTypes.isGroupNode(groupNode)) {
const terminalNode = this.createTerminalNode(newTerminalId, groupId);
CompositeTreeNode.addChild(groupNode, terminalNode);
this.onDidAddTerminalToGroupEmitter.fire({ terminalId: newTerminalId, groupId });
this.refreshWithSelection(undefined, terminalNode);
}
}
createTerminalNode(
terminalId: TerminalManagerTreeTypes.TerminalKey,
groupId: TerminalManagerTreeTypes.GroupId,
): TerminalManagerTreeTypes.TerminalNode {
return {
id: terminalId,
label: nls.localizeByDefault('Terminal'),
parent: undefined,
children: [],
selected: false,
terminal: true,
isEditing: false,
parentGroupId: groupId,
};
}
deleteTerminalNode(terminalId: TerminalManagerTreeTypes.TerminalKey): void {
const terminalNode = this.getNode(terminalId);
const parentGroupNode = terminalNode?.parent;
if (TerminalManagerTreeTypes.isTerminalNode(terminalNode) && TerminalManagerTreeTypes.isGroupNode(parentGroupNode)) {
if (parentGroupNode.children.length === 1) {
this.deleteTerminalGroup(parentGroupNode.id);
} else {
const isActive = this.activeTerminalNode === terminalNode;
this.doDeleteTerminalNode(terminalNode, parentGroupNode);
this.refreshWithSelection(parentGroupNode, undefined, isActive ? terminalNode : undefined);
}
}
}
protected doDeleteTerminalNode(node: TerminalManagerTreeTypes.TerminalNode, parent: TerminalManagerTreeTypes.TerminalGroupNode): void {
this.onDidDeleteTerminalFromGroupEmitter.fire({
terminalId: node.id,
groupId: parent.id,
});
CompositeTreeNode.removeChild(parent, node);
}
toggleRenameTerminal(entityId: TerminalManagerTreeTypes.TerminalManagerValidId): void {
const node = this.getNode(entityId);
if (TerminalManagerTreeTypes.isTerminalManagerTreeNode(node)) {
node.isEditing = true;
this.fireChanged();
}
}
acceptRename(nodeId: string, newName: string): void {
const node = this.getNode(nodeId);
if (TerminalManagerTreeTypes.isTerminalManagerTreeNode(node)) {
const trimmedName = newName.trim();
node.label = trimmedName === '' ? node.label : newName;
node.isEditing = false;
this.fireChanged();
this.onDidRenameNodeEmitter.fire(node);
}
}
handleSelectionChanged(selectedNode: SelectableTreeNode): void {
let activeTerminal: TerminalManagerTreeTypes.TerminalNode | undefined = undefined;
let activeGroup: TerminalManagerTreeTypes.TerminalGroupNode | undefined = undefined;
let activePage: TerminalManagerTreeTypes.PageNode | undefined = undefined;
if (TerminalManagerTreeTypes.isTerminalNode(selectedNode)) {
activeTerminal = selectedNode;
const { parent } = activeTerminal;
if (TerminalManagerTreeTypes.isGroupNode(parent)) {
activeGroup = parent;
const grandparent = activeGroup.parent;
if (TerminalManagerTreeTypes.isPageNode(grandparent)) {
activePage = grandparent;
}
} else if (TerminalManagerTreeTypes.isPageNode(parent)) {
activePage = parent;
}
} else if (TerminalManagerTreeTypes.isGroupNode(selectedNode)) {
const { parent } = selectedNode;
activeGroup = selectedNode;
if (TerminalManagerTreeTypes.isPageNode(parent)) {
activePage = parent;
}
} else if (TerminalManagerTreeTypes.isPageNode(selectedNode)) {
activePage = selectedNode;
}
this.activeTerminalNode = activeTerminal;
this.activeGroupNode = activeGroup;
this.activePageNode = activePage;
this.onDidChangeTreeSelectionEmitter.fire({
activePageId: activePage?.id,
activeTerminalId: activeTerminal?.id,
activeGroupId: activeGroup?.id,
});
}
get pages(): Map<TerminalManagerTreeTypes.PageId, TerminalManagerTreeTypes.PageNode> {
const pages = new Map<TerminalManagerTreeTypes.PageId, TerminalManagerTreeTypes.PageNode>();
if (!this.root) {
return pages;
}
for (const node of new DepthFirstTreeIterator(this.root)) {
if (TerminalManagerTreeTypes.isPageNode(node)) {
pages.set(node.id, node);
}
}
return pages;
}
getPageIdForTerminal(terminalKey: TerminalManagerTreeTypes.TerminalKey): TerminalManagerTreeTypes.PageId | undefined {
const terminalNode = this.getNode(terminalKey);
if (!TerminalManagerTreeTypes.isTerminalNode(terminalNode)) {
return undefined;
}
const { parentGroupId } = terminalNode;
const groupNode = this.getNode(parentGroupId);
if (!TerminalManagerTreeTypes.isGroupNode(groupNode)) {
return undefined;
}
return groupNode.parentPageId;
}
selectTerminalNode(terminalKey: TerminalManagerTreeTypes.TerminalKey): void {
const node = this.getNode(terminalKey);
if (node && TerminalManagerTreeTypes.isTerminalNode(node)) {
this.selectNode(node);
}
}
protected async refreshWithSelection(refreshTarget?: CompositeTreeNode, selectionTarget?: SelectableTreeNode, selectionReferent?: TreeNode): Promise<void> {
await this.refresh(refreshTarget);
if (selectionTarget) {
return this.selectNode(selectionTarget);
}
if (selectionReferent) {
const { previousSibling, nextSibling } = selectionReferent;
const toSelect = this.findSelection(previousSibling) ?? this.findSelection(nextSibling);
if (toSelect) {
this.selectNode(toSelect);
}
}
}
protected findSelection(start?: TreeNode): SelectableTreeNode | undefined {
if (!start) { return undefined; }
if (TerminalManagerTreeTypes.isTerminalNode(start)) { return start; }
if (TerminalManagerTreeTypes.isGroupNode(start)) { return start.children.at(0); }
if (TerminalManagerTreeTypes.isPageNode(start)) { return start.children.at(0)?.children.at(0); }
}
}

View File

@@ -0,0 +1,305 @@
// *****************************************************************************
// Copyright (C) 2023 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as React from '@theia/core/shared/react';
import { Container, inject, injectable, interfaces, postConstruct } from '@theia/core/shared/inversify';
import {
codicon,
CompositeTreeNode,
createTreeContainer,
Key,
Message,
NodeProps,
SelectableTreeNode,
TreeModel,
TreeNode,
TreeWidget,
TREE_NODE_INDENT_GUIDE_CLASS,
} from '@theia/core/lib/browser';
import { CommandRegistry, CompoundMenuNode, Emitter, MenuAction, MenuModelRegistry } from '@theia/core';
import { TerminalManagerTreeModel } from './terminal-manager-tree-model';
import { ReactInteraction, TerminalManagerTreeTypes, TERMINAL_MANAGER_TREE_CONTEXT_MENU } from './terminal-manager-types';
/* eslint-disable no-param-reassign */
@injectable()
export class TerminalManagerTreeWidget extends TreeWidget {
static ID = 'terminal-manager-tree-widget';
protected onDidChangeEmitter = new Emitter();
readonly onDidChange = this.onDidChangeEmitter.event;
@inject(MenuModelRegistry) protected menuRegistry: MenuModelRegistry;
@inject(TreeModel) override readonly model: TerminalManagerTreeModel;
@inject(CommandRegistry) protected commandRegistry: CommandRegistry;
static createContainer(parent: interfaces.Container): Container {
const child = createTreeContainer(
parent,
{
props: {
leftPadding: 8,
contextMenuPath: TERMINAL_MANAGER_TREE_CONTEXT_MENU,
expandOnlyOnExpansionToggleClick: true,
},
},
);
child.bind(TerminalManagerTreeModel).toSelf().inSingletonScope();
child.rebind(TreeModel).to(TerminalManagerTreeModel);
child.bind(TerminalManagerTreeWidget).toSelf().inSingletonScope();
return child;
}
static createWidget(parent: interfaces.Container): TerminalManagerTreeWidget {
return TerminalManagerTreeWidget.createContainer(parent).get(TerminalManagerTreeWidget);
}
@postConstruct()
protected override init(): void {
super.init();
this.id = 'terminal-manager-tree-widget';
this.addClass(TerminalManagerTreeWidget.ID);
this.toDispose.push(this.onDidChangeEmitter);
}
protected override toContextMenuArgs(node: SelectableTreeNode): TerminalManagerTreeTypes.ContextMenuArgs | undefined {
if (
TerminalManagerTreeTypes.isPageNode(node)
|| TerminalManagerTreeTypes.isTerminalNode(node)
|| TerminalManagerTreeTypes.isGroupNode(node)
) {
return TerminalManagerTreeTypes.toContextMenuArgs(this, node);
}
return undefined;
}
protected override renderCaption(node: TreeNode, props: NodeProps): React.ReactNode {
if (TerminalManagerTreeTypes.isTerminalManagerTreeNode(node) && !!node.isEditing) {
const label = this.toNodeName(node);
// eslint-disable-next-line @typescript-eslint/ban-types
const assignRef = (element: HTMLInputElement | null) => {
if (element) {
element.selectionStart = 0;
element.selectionEnd = label.length;
}
};
return (
<input
spellCheck={false}
type='text'
className='theia-input rename-node-input'
defaultValue={label}
onBlur={this.handleRenameOnBlur}
data-id={node.id}
onKeyDown={this.handleRenameOnKeyDown}
autoFocus={true}
ref={assignRef}
/>
);
}
return super.renderCaption(node, props);
}
protected handleRenameOnBlur = (e: React.FocusEvent<HTMLInputElement>): void => this.doHandleRenameOnBlur(e);
protected doHandleRenameOnBlur(e: React.FocusEvent<HTMLInputElement>): void {
const { value } = e.currentTarget;
const id = e.currentTarget.getAttribute('data-id');
if (id) {
this.model.acceptRename(id, value);
}
}
protected override renderExpansionToggle(node: TreeNode, props: NodeProps): React.ReactNode {
return super.renderExpansionToggle(node, props);
}
protected handleRenameOnKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => this.doHandleRenameOnKeyDown(e);
protected doHandleRenameOnKeyDown(e: React.KeyboardEvent<HTMLInputElement>): void {
const { value, defaultValue } = e.currentTarget;
const id = e.currentTarget.getAttribute('data-id');
e.stopPropagation();
if (e.key === 'Escape') {
e.preventDefault();
if (value && id) {
this.model.acceptRename(id, defaultValue);
}
} else if (e.key === 'Tab' || e.key === 'Enter') {
e.preventDefault();
if (value && id) {
this.model.acceptRename(id, value.trim());
}
}
}
// @ts-expect-error 2416 cf. https://github.com/eclipse-theia/theia/issues/11640
protected override handleLeft(event: KeyboardEvent): boolean | Promise<void> {
if ((event.target as HTMLElement).tagName === 'INPUT') { return false; };
return super.handleLeft(event);
}
// @ts-expect-error 2416 cf. https://github.com/eclipse-theia/theia/issues/11640
protected override handleRight(event: KeyboardEvent): boolean | Promise<void> {
if ((event.target as HTMLElement).tagName === 'INPUT') { return false; };
return super.handleRight(event);
}
// cf. https://github.com/eclipse-theia/theia/issues/11640
protected override handleEscape(event: KeyboardEvent): boolean | void {
if ((event.target as HTMLElement).tagName === 'INPUT') { return false; };
return super.handleEscape(event);
}
// cf. https://github.com/eclipse-theia/theia/issues/11640
protected override handleEnter(event: KeyboardEvent): boolean | void {
if ((event.target as HTMLElement).tagName === 'INPUT') { return false; };
return super.handleEnter(event);
}
// cf. https://github.com/eclipse-theia/theia/issues/11640
protected override handleSpace(event: KeyboardEvent): boolean | void {
if ((event.target as HTMLElement).tagName === 'INPUT') { return false; };
return super.handleSpace(event);
}
protected override renderTailDecorations(node: TreeNode, _props: NodeProps): React.ReactNode {
if (TerminalManagerTreeTypes.isTerminalManagerTreeNode(node)) {
const inlineActionsForNode = this.resolveInlineActionForNode(node);
return (
<div className='terminal-manager-inline-actions-container'>
<div className='terminal-manager-inline-actions'>
{inlineActionsForNode.map(({ icon, commandId, label }) => (
<span
key={commandId}
data-command-id={commandId}
data-node-id={node.id}
className={icon}
onClick={this.handleActionItemOnClick}
onKeyDown={this.handleActionItemOnClick}
role='button'
tabIndex={0}
title={label}
/>
))}
</div>
</div>
);
}
return undefined;
}
protected handleActionItemOnClick = (e: ReactInteraction<HTMLSpanElement>): void => this.doHandleActionItemOnClick(e);
protected doHandleActionItemOnClick(e: ReactInteraction<HTMLSpanElement>): void {
if ('key' in e && e.key !== Key.ENTER.code) {
return;
}
e.stopPropagation();
const commandId = e.currentTarget.getAttribute('data-command-id');
const nodeId = e.currentTarget.getAttribute('data-node-id');
if (commandId && nodeId) {
const node = this.model.getNode(nodeId);
if (TerminalManagerTreeTypes.isTerminalManagerTreeNode(node)) {
const args = TerminalManagerTreeTypes.toContextMenuArgs(this, node);
this.commandRegistry.executeCommand(commandId, ...args);
}
}
}
protected resolveInlineActionForNode(node: TerminalManagerTreeTypes.TerminalManagerTreeNode): MenuAction[] {
let menuNode: CompoundMenuNode | undefined = undefined;
const inlineActionProps: MenuAction[] = [];
if (TerminalManagerTreeTypes.isPageNode(node)) {
menuNode = this.menuRegistry.getMenu(TerminalManagerTreeTypes.PAGE_NODE_MENU);
} else if (TerminalManagerTreeTypes.isGroupNode(node)) {
menuNode = this.menuRegistry.getMenu(TerminalManagerTreeTypes.GROUP_NODE_MENU);
} else if (TerminalManagerTreeTypes.isTerminalNode(node)) {
menuNode = this.menuRegistry.getMenu(TerminalManagerTreeTypes.TERMINAL_NODE_MENU);
}
if (!menuNode) {
return [];
}
const menuItems = menuNode.children;
menuItems.forEach(item => {
const commandId = item.id;
const args = TerminalManagerTreeTypes.toContextMenuArgs(this, node);
const isVisible = this.commandRegistry.isVisible(commandId, ...args);
if (isVisible) {
const command = this.commandRegistry.getCommand(commandId);
const icon = command?.iconClass ? command.iconClass : '';
const label = command?.label ? command.label : '';
inlineActionProps.push({ icon, label, commandId });
}
});
return inlineActionProps;
}
protected override renderIcon(node: TreeNode, _props: NodeProps): React.ReactNode {
if (TerminalManagerTreeTypes.isTerminalNode(node)) {
return <span className={`${codicon('terminal')}`} />;
} else if (TerminalManagerTreeTypes.isPageNode(node)) {
return <span className={`${codicon('terminal-tmux')}`} />;
} else if (TerminalManagerTreeTypes.isGroupNode(node)) {
return <span className={`${codicon('split-horizontal')}`} />;
}
return undefined;
}
protected override toNodeName(node: TerminalManagerTreeTypes.TerminalManagerTreeNode): string {
return node.label ?? 'node.id';
}
protected override onUpdateRequest(msg: Message): void {
super.onUpdateRequest(msg);
this.onDidChangeEmitter.fire(undefined);
}
protected override renderIndent(node: TreeNode, props: NodeProps): React.ReactNode {
const renderIndentGuides = this.corePreferences['workbench.tree.renderIndentGuides'];
if (renderIndentGuides === 'none') {
return undefined;
}
const indentDivs: React.ReactNode[] = [];
let current: TreeNode | undefined = node;
let { depth } = props;
while (current && depth) {
const classNames: string[] = [TREE_NODE_INDENT_GUIDE_CLASS];
if (this.needsActiveIndentGuideline(current)) {
classNames.push('active');
} else {
classNames.push(renderIndentGuides === 'onHover' ? 'hover' : 'always');
}
const paddingLeft = this.props.leftPadding * depth;
indentDivs.unshift(<div
key={depth}
className={classNames.join(' ')}
style={{
paddingLeft: `${paddingLeft}px`,
}}
/>);
current = current.parent;
depth -= 1;
}
return indentDivs;
}
protected override getDepthForNode(node: TreeNode, depths: Map<CompositeTreeNode | undefined, number>): number {
const parentDepth = depths.get(node.parent);
if (TerminalManagerTreeTypes.isTerminalNode(node) && parentDepth === undefined) {
return 1;
}
return super.getDepthForNode(node, depths);
}
}

View File

@@ -0,0 +1,174 @@
// *****************************************************************************
// Copyright (C) 2023 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { Command, MenuPath } from '@theia/core';
import {
SelectableTreeNode,
CompositeTreeNode,
SplitPanel,
codicon,
ExpandableTreeNode,
Widget,
} from '@theia/core/lib/browser';
import { TerminalWidgetFactoryOptions, TerminalWidgetImpl } from '@theia/terminal/lib/browser/terminal-widget-impl';
export namespace TerminalManagerCommands {
export const MANAGER_NEW_TERMINAL_GROUP = Command.toLocalizedCommand({
id: 'terminal:new-in-manager-toolbar',
category: 'Terminal Manager',
label: 'Create New Terminal Group',
iconClass: codicon('split-horizontal'),
}, 'theia/terminal-manager/createNewTerminalGroup');
export const MANAGER_DELETE_TERMINAL = Command.toLocalizedCommand({
id: 'terminal:delete-terminal',
category: 'Terminal Manager',
label: 'Delete Terminal',
iconClass: codicon('trash'),
}, 'theia/terminal-manager/deleteTerminal');
export const MANAGER_RENAME_TERMINAL = Command.toLocalizedCommand({
id: 'terminal: rename-terminal',
category: 'Terminal Manager',
label: 'Rename',
iconClass: codicon('edit'),
}, 'theia/terminal-manager/rename');
export const MANAGER_NEW_PAGE_BOTTOM_TOOLBAR = Command.toLocalizedCommand({
id: 'terminal:new-manager-page',
category: 'Terminal Manager',
label: 'Create New Terminal Page',
iconClass: codicon('new-file'),
}, 'theia/terminal-manager/createNewTerminalPage');
export const MANAGER_DELETE_PAGE = Command.toLocalizedCommand({
id: 'terminal:delete-page',
category: 'Terminal Manager',
label: 'Delete Page',
iconClass: codicon('trash'),
}, 'theia/terminal-manager/deletePage');
export const MANAGER_ADD_TERMINAL_TO_GROUP = Command.toLocalizedCommand({
id: 'terminal:manager-split-horizontal',
category: 'Terminal Manager',
label: 'Add terminal to group',
iconClass: codicon('split-vertical'),
}, 'theia/terminal-manager/addTerminalToGroup');
export const MANAGER_DELETE_GROUP = Command.toLocalizedCommand({
id: 'terminal:manager-delete-group',
category: 'Terminal Manager',
label: 'Delete Group',
iconClass: codicon('trash'),
}, 'theia/terminal-manager/deleteGroup');
export const MANAGER_SHOW_TREE_TOOLBAR = Command.toLocalizedCommand({
id: 'terminal:manager-toggle-tree',
category: 'Terminal Manager',
label: 'Toggle Tree View',
iconClass: codicon('list-tree'),
}, 'theia/terminal-manager/toggleTreeView');
export const MANAGER_MAXIMIZE_BOTTOM_PANEL_TOOLBAR = Command.toLocalizedCommand({
id: 'terminal:manager-maximize-bottom-panel',
category: 'Terminal Manager',
label: 'Maximize Bottom Panel',
}, 'theia/terminal-manager/maximizeBottomPanel');
export const MANAGER_MINIMIZE_BOTTOM_PANEL_TOOLBAR = Command.toLocalizedCommand({
id: 'terminal:manager-minimize-bottom-panel',
category: 'Terminal Manager',
label: 'Minimize Bottom Panel',
}, 'theia/terminal-manager/minimizeBottomPanel');
export const MANAGER_CLEAR_ALL = Command.toLocalizedCommand({
id: 'terminal:manager-clear-all',
category: 'Terminal Manager',
label: 'Reset Terminal Manager Layout',
iconClass: codicon('trash'),
}, 'theia/terminal-manager/resetTerminalManagerLayout');
export const MANAGER_OPEN_VIEW = Command.toLocalizedCommand({
id: 'terminal:open-manager',
category: 'View',
label: 'Open Terminal Manager',
}, 'theia/terminal-manager/openTerminalManager');
export const MANAGER_CLOSE_VIEW = Command.toLocalizedCommand({
id: 'terminal:close-manager',
category: 'View',
label: 'Close Terminal Manager',
}, 'theia/terminal-manager/closeTerminalManager');
}
export const TERMINAL_MANAGER_TREE_CONTEXT_MENU = ['terminal-manager-tree-context-menu'];
export namespace TerminalManagerTreeTypes {
export type TerminalKey = `terminal-${string}`;
export const generateTerminalKey = (widget: TerminalWidgetImpl): TerminalKey => {
const { created } = widget.options as TerminalWidgetFactoryOptions;
return `terminal-${created}`;
};
export const isTerminalKey = (obj: unknown): obj is TerminalKey => typeof obj === 'string' && obj.startsWith('terminal-');
export interface TerminalNode extends SelectableTreeNode, CompositeTreeNode {
terminal: true;
isEditing: boolean;
label: string;
id: TerminalKey;
parentGroupId: GroupId;
}
export type GroupId = `group-${string}`;
export const isGroupId = (obj: unknown): obj is GroupId => typeof obj === 'string' && obj.startsWith('group-');
export interface GroupSplitPanel extends SplitPanel {
id: GroupId;
widgets: readonly TerminalWidgetImpl[];
}
export interface TerminalGroupNode extends SelectableTreeNode, ExpandableTreeNode {
terminalGroup: true;
isEditing: boolean;
label: string;
id: GroupId;
parentPageId: PageId;
counter: number;
children: readonly TerminalNode[]
}
export type PageId = `page-${string}`;
export const isPageId = (obj: unknown): obj is PageId => typeof obj === 'string' && obj.startsWith('page-');
export interface PageSplitPanel extends SplitPanel {
id: PageId;
widgets: readonly GroupSplitPanel[];
}
export interface PageNode extends SelectableTreeNode, ExpandableTreeNode {
page: true;
children: TerminalGroupNode[];
isEditing: boolean;
label: string;
id: PageId;
counter: number;
}
export type TerminalManagerTreeNode = PageNode | TerminalNode | TerminalGroupNode;
export type TerminalManagerValidId = PageId | TerminalKey | GroupId;
export const isPageNode = (obj: unknown): obj is PageNode => !!obj && typeof obj === 'object' && 'page' in obj;
export const isTerminalNode = (obj: unknown): obj is TerminalNode => !!obj && typeof obj === 'object' && 'terminal' in obj;
export const isGroupNode = (obj: unknown): obj is TerminalGroupNode => !!obj && typeof obj === 'object' && 'terminalGroup' in obj;
export const isTerminalManagerTreeNode = (
obj: unknown,
): obj is PageNode | TerminalNode => isPageNode(obj) || isTerminalNode(obj) || isGroupNode(obj);
export interface SelectionChangedEvent {
activePageId: PageId | undefined;
activeTerminalId: TerminalKey | undefined;
activeGroupId: GroupId | undefined;
}
export type ContextMenuArgs = [Widget, TerminalManagerValidId];
export const toContextMenuArgs = (widget: Widget, node: TerminalManagerTreeNode): ContextMenuArgs => [widget, node.id as TerminalManagerValidId];
export const PAGE_NODE_MENU: MenuPath = ['terminal-manager-page-node'];
export const GROUP_NODE_MENU: MenuPath = ['terminal-manager-group-node'];
export const TERMINAL_NODE_MENU: MenuPath = ['terminal-manager-terminal-node'];
}
export type ReactInteraction<T = Element, U = MouseEvent> = React.MouseEvent<T, U> | React.KeyboardEvent<T>;

View File

@@ -0,0 +1,732 @@
// *****************************************************************************
// Copyright (C) 2023 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable, interfaces, postConstruct } from '@theia/core/shared/inversify';
import {
ApplicationShell,
BaseWidget,
codicon,
CompositeTreeNode,
Message,
Panel,
PanelLayout,
SplitLayout,
SplitPanel,
SplitPositionHandler,
StatefulWidget,
StorageService,
ViewContainerLayout,
Widget,
WidgetManager,
} from '@theia/core/lib/browser';
import { Emitter, nls } from '@theia/core';
import { UUID } from '@theia/core/shared/@lumino/coreutils';
import { TerminalWidget, TerminalWidgetOptions } from '@theia/terminal/lib/browser/base/terminal-widget';
import { TerminalWidgetImpl } from '@theia/terminal/lib/browser/terminal-widget-impl';
import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state';
import { TerminalFrontendContribution } from '@theia/terminal/lib/browser/terminal-frontend-contribution';
import { TerminalManagerPreferences } from './terminal-manager-preferences';
import { TerminalManagerTreeTypes } from './terminal-manager-types';
import { TerminalManagerTreeWidget } from './terminal-manager-tree-widget';
import { ConfirmDialog } from '@theia/core/lib/browser/dialogs';
export namespace TerminalManagerWidgetState {
export interface BaseLayoutData<ID> {
id: ID,
}
export interface TerminalWidgetLayoutData {
widget: TerminalWidget | undefined;
}
export interface TerminalGroupLayoutData extends BaseLayoutData<TerminalManagerTreeTypes.GroupId> {
childLayouts: TerminalWidgetLayoutData[];
widgetRelativeHeights: number[] | undefined;
}
export interface PageLayoutData extends BaseLayoutData<TerminalManagerTreeTypes.PageId> {
childLayouts: TerminalGroupLayoutData[];
groupRelativeWidths: number[] | undefined;
}
export interface TerminalManagerLayoutData extends BaseLayoutData<'ParentPanel'> {
childLayouts: PageLayoutData[];
}
export const isLayoutData = (obj: unknown): obj is LayoutData => typeof obj === 'object' && !!obj && 'type' in obj && obj.type === 'terminal-manager';
export interface PanelRelativeSizes {
terminal: number;
tree: number;
}
export interface LayoutData {
items?: TerminalManagerLayoutData;
widget: TerminalManagerTreeWidget;
terminalAndTreeRelativeSizes: PanelRelativeSizes | undefined;
}
}
@injectable()
export class TerminalManagerWidget extends BaseWidget implements StatefulWidget, ApplicationShell.TrackableWidgetProvider {
static ID = 'terminal-manager-widget';
static LABEL = nls.localize('theia/terminal-manager/label', 'Terminals');
protected panel: SplitPanel;
protected pageAndTreeLayout: SplitLayout | undefined;
protected stateIsSet = false;
pagePanels = new Map<TerminalManagerTreeTypes.PageId, TerminalManagerTreeTypes.PageSplitPanel>();
groupPanels = new Map<TerminalManagerTreeTypes.GroupId, TerminalManagerTreeTypes.GroupSplitPanel>();
/** By node ID: safer for state restoration. */
terminalWidgets = new Map<TerminalManagerTreeTypes.TerminalKey, TerminalWidget>();
/** By terminal ID to work from widget to internal metadata. */
terminalWidgetIdsToNodeIds = new Map<string, TerminalManagerTreeTypes.TerminalKey>();
protected readonly onDidChangeTrackableWidgetsEmitter = new Emitter<Widget[]>();
readonly onDidChangeTrackableWidgets = this.onDidChangeTrackableWidgetsEmitter.event;
// serves as an empty container so that different view containers can be swapped out
protected terminalPanelWrapper = new Panel({
layout: new PanelLayout(),
});
protected interceptCloseRequest = true;
@inject(TerminalFrontendContribution) protected terminalFrontendContribution: TerminalFrontendContribution;
@inject(TerminalManagerTreeWidget) readonly treeWidget: TerminalManagerTreeWidget;
@inject(SplitPositionHandler) protected readonly splitPositionHandler: SplitPositionHandler;
@inject(ApplicationShell) protected readonly shell: ApplicationShell;
@inject(TerminalManagerPreferences) protected readonly terminalManagerPreferences: TerminalManagerPreferences;
@inject(FrontendApplicationStateService) protected readonly applicationStateService: FrontendApplicationStateService;
@inject(WidgetManager) protected readonly widgetManager: WidgetManager;
@inject(StorageService) protected readonly storageService: StorageService;
protected readonly terminalsDeletingFromClose = new Set<TerminalManagerTreeTypes.TerminalKey>();
static createRestoreError = (
nodeId: string,
): Error => new Error(`Terminal manager widget state could not be restored, mismatch in restored data for ${nodeId}`);
static createContainer(parent: interfaces.Container): interfaces.Container {
const child = parent.createChild();
child.bind(TerminalManagerWidget).toSelf().inSingletonScope();
return child;
}
static createWidget(parent: interfaces.Container): Promise<TerminalManagerWidget> {
return TerminalManagerWidget.createContainer(parent).getAsync(TerminalManagerWidget);
}
@postConstruct()
protected init(): void {
this.title.iconClass = codicon('terminal-tmux');
this.id = TerminalManagerWidget.ID;
this.title.closable = true;
this.title.label = TerminalManagerWidget.LABEL;
this.title.caption = TerminalManagerWidget.LABEL;
this.node.tabIndex = 0;
this.registerListeners();
this.createPageAndTreeLayout();
}
/** Yields all terminal widgets owned by this widget and then closes this widget. */
*drainWidgets(): IterableIterator<TerminalWidget> {
for (const [key, widget] of this.terminalWidgets) {
this.removeTerminalReferenceByNodeId(key);
yield widget;
}
this.close();
}
async populateLayout(force?: boolean): Promise<void> {
if ((!this.stateIsSet && this.terminalWidgets.size === 0) || force) {
const terminalWidget = await this.createTerminalWidget();
this.addTerminalPage(terminalWidget);
this.onDidChangeTrackableWidgetsEmitter.fire(this.getTrackableWidgets());
this.stateIsSet = true;
}
}
async createTerminalWidget(options: TerminalWidgetOptions = {}): Promise<TerminalWidget> {
const terminalWidget = await this.terminalFrontendContribution.newTerminal({
// passing 'created' here as a millisecond value rather than the default `new Date().toString()` that Theia uses in
// its factory (resolves to something like 'Tue Aug 09 2022 13:21:26 GMT-0500 (Central Daylight Time)').
// The state restoration system relies on identifying terminals by their unique options, using an ms value ensures we don't
// get a duplication since the original date method is only accurate to within 1s.
created: new Date().getTime().toString(),
...options,
} as TerminalWidgetOptions);
terminalWidget.start();
return terminalWidget;
}
protected registerListeners(): void {
this.toDispose.push(this.treeWidget);
this.toDispose.push(this.treeWidget.model.onDidChangeTreeSelection(changeEvent => this.handleSelectionChange(changeEvent)));
this.toDispose.push(this.treeWidget.model.onDidAddPage(({ pageId }) => this.handlePageAdded(pageId)));
this.toDispose.push(this.treeWidget.model.onDidDeletePage(pageId => this.handlePageDeleted(pageId)));
this.toDispose.push(this.treeWidget.model.onDidAddTerminalGroup(({
groupId, pageId,
}) => this.handleTerminalGroupAdded(groupId, pageId)));
this.toDispose.push(this.treeWidget.model.onDidDeleteTerminalGroup(groupId => this.handleTerminalGroupDeleted(groupId)));
this.toDispose.push(this.treeWidget.model.onDidAddTerminalToGroup(({
terminalId, groupId,
}) => this.handleWidgetAddedToTerminalGroup(terminalId, groupId)));
this.toDispose.push(this.treeWidget.model.onDidDeleteTerminalFromGroup(({
terminalId,
}) => this.handleTerminalDeleted(terminalId)));
this.toDispose.push(this.treeWidget.model.onDidRenameNode(() => this.handlePageRenamed()));
this.toDispose.push(this.shell.onDidChangeActiveWidget(({ newValue }) => this.handleOnDidChangeActiveWidget(newValue)));
this.toDispose.push(this.terminalManagerPreferences.onPreferenceChanged(() => this.resolveMainLayout()));
}
protected handlePageRenamed(): void {
this.update();
}
setPanelSizes({ terminal, tree } = { terminal: .6, tree: .2 } as TerminalManagerWidgetState.PanelRelativeSizes): void {
const treeViewLocation = this.terminalManagerPreferences.get('terminal.grouping.treeViewLocation');
const panelSizes = treeViewLocation === 'left' ? [tree, terminal] : [terminal, tree];
requestAnimationFrame(() => this.pageAndTreeLayout?.setRelativeSizes(panelSizes));
}
getTrackableWidgets(): Widget[] {
return [this.treeWidget, ...this.terminalWidgets.values()];
}
toggleTreeVisibility(): void {
if (this.treeWidget.isHidden) {
this.treeWidget.show();
this.setPanelSizes();
} else {
this.treeWidget.hide();
}
}
protected async createPageAndTreeLayout(relativeSizes?: TerminalManagerWidgetState.PanelRelativeSizes): Promise<void> {
const layout = this.layout = new PanelLayout();
this.pageAndTreeLayout = new SplitLayout({
renderer: SplitPanel.defaultRenderer,
orientation: 'horizontal',
spacing: 2,
});
this.panel ??= new SplitPanel({
layout: this.pageAndTreeLayout,
});
layout.addWidget(this.panel);
await this.resolveMainLayout(relativeSizes);
this.update();
}
protected async resolveMainLayout(relativeSizes?: TerminalManagerWidgetState.PanelRelativeSizes): Promise<void> {
if (!this.pageAndTreeLayout) {
return;
}
await this.terminalManagerPreferences.ready;
const treeViewLocation = this.terminalManagerPreferences.get('terminal.grouping.treeViewLocation');
const widgetsInDesiredOrder = treeViewLocation === 'left' ? [this.treeWidget, this.terminalPanelWrapper] : [this.terminalPanelWrapper, this.treeWidget];
widgetsInDesiredOrder.forEach((widget, index) => {
this.pageAndTreeLayout?.insertWidget(index, widget);
});
this.setPanelSizes(relativeSizes);
}
protected override onAfterAttach(msg: Message): void {
super.onAfterAttach(msg);
this.populateLayout();
}
protected override onCloseRequest(msg: Message): void {
if (this.interceptCloseRequest && this.terminalWidgets.size > 0) {
this.interceptCloseRequest = false;
this.confirmClose()
.then(confirmed => {
if (confirmed) {
super.onCloseRequest(msg);
}
})
.finally(() => {
this.interceptCloseRequest = true;
});
return;
}
super.onCloseRequest(msg);
}
protected async confirmClose(): Promise<boolean> {
const CLOSE = nls.localizeByDefault('Close');
const dialog = new ConfirmDialog({
title: nls.localize('theia/terminal-manager/closeDialog/title', 'Do you want to close the terminal manager?'),
msg: nls.localize(
'theia/terminal-manager/closeDialog/message',
'Once the Terminal Manager is closed, its layout cannot be restored. Are you sure you want to close the Terminal Manager?'
),
ok: CLOSE,
cancel: nls.localizeByDefault('Cancel'),
});
const confirmed = await dialog.open();
return confirmed === true;
}
addTerminalPage(widget: Widget): void {
this.doAddTerminalPage(widget);
}
protected doAddTerminalPage(widget: Widget): TerminalManagerTreeTypes.PageSplitPanel | undefined {
if (widget instanceof TerminalWidgetImpl) {
const terminalKey = TerminalManagerTreeTypes.generateTerminalKey(widget);
this.addTerminalReference(widget, terminalKey);
this.onDidChangeTrackableWidgetsEmitter.fire(this.getTrackableWidgets());
const groupPanel = this.createTerminalGroupPanel();
groupPanel.addWidget(widget);
const pagePanel = this.createPagePanel();
pagePanel.addWidget(groupPanel);
this.treeWidget.model.addTerminalPage(terminalKey, groupPanel.id, pagePanel.id);
return pagePanel;
}
}
protected addTerminalReference(widget: TerminalWidget, nodeId: TerminalManagerTreeTypes.TerminalKey): void {
this.terminalWidgets.set(nodeId, widget);
this.terminalWidgetIdsToNodeIds.set(widget.id, nodeId);
}
protected removeTerminalReferenceByWidgetId(widgetId: string): boolean {
const nodeId = this.terminalWidgetIdsToNodeIds.get(widgetId);
if (nodeId === undefined) {return false; }
return this.terminalWidgets.delete(nodeId);
}
protected removeTerminalReferenceByNodeId(nodeId: TerminalManagerTreeTypes.TerminalKey): boolean {
const widget = this.terminalWidgets.get(nodeId);
if (!widget) {return false; }
this.terminalWidgets.delete(nodeId);
this.terminalWidgetIdsToNodeIds.delete(widget.id);
return true;
}
protected createPagePanel(pageId?: TerminalManagerTreeTypes.PageId): TerminalManagerTreeTypes.PageSplitPanel {
const newPageLayout = new ViewContainerLayout({
renderer: SplitPanel.defaultRenderer,
orientation: 'horizontal',
spacing: 2,
headerSize: 0,
animationDuration: 200,
}, this.splitPositionHandler);
const pagePanel = new SplitPanel({
layout: newPageLayout,
}) as TerminalManagerTreeTypes.PageSplitPanel;
const idPrefix = 'page-';
const uuid = this.generateUUIDAvoidDuplicatesFromStorage(idPrefix);
pagePanel.node.tabIndex = -1;
pagePanel.id = pageId ?? `${idPrefix}${uuid}`;
this.pagePanels.set(pagePanel.id, pagePanel);
return pagePanel;
}
protected generateUUIDAvoidDuplicatesFromStorage(idPrefix: 'group-' | 'page-'): string {
// highly unlikely there would ever be a duplicate, but just to be safe :)
let didNotGenerateValidId = true;
let uuid = '';
while (didNotGenerateValidId) {
uuid = UUID.uuid4();
if (idPrefix === 'group-') {
didNotGenerateValidId = this.groupPanels.has(`group-${uuid}`);
} else if (idPrefix === 'page-') {
didNotGenerateValidId = this.pagePanels.has(`page-${uuid}`);
}
}
return uuid;
}
protected handlePageAdded(pageId: TerminalManagerTreeTypes.PageId): void {
const pagePanel = this.pagePanels.get(pageId);
if (pagePanel) {
this.terminalPanelWrapper.addWidget(pagePanel);
this.update();
}
}
protected handlePageDeleted(pagePanelId: TerminalManagerTreeTypes.PageId): void {
const panel = this.pagePanels.get(pagePanelId);
if (!panel) {
return;
}
const isLastPanel = this.pagePanels.size === 1;
if (isLastPanel) {
this.interceptCloseRequest = false;
this.close();
return;
}
this.clearGroupReferences(panel);
panel.dispose();
this.pagePanels.delete(pagePanelId);
}
protected clearGroupReferences(panel: TerminalManagerTreeTypes.PageSplitPanel): void {
for (const group of panel.widgets) {
this.clearTerminalReferences(group);
this.groupPanels.delete(group.id);
}
}
addTerminalGroupToPage(widget: Widget, pageId: TerminalManagerTreeTypes.PageId): void {
if (!this.treeWidget) {
return;
}
if (widget instanceof TerminalWidgetImpl) {
const terminalId = TerminalManagerTreeTypes.generateTerminalKey(widget);
this.addTerminalReference(widget, terminalId);
this.onDidChangeTrackableWidgetsEmitter.fire(this.getTrackableWidgets());
const groupPanel = this.createTerminalGroupPanel();
groupPanel.addWidget(widget);
this.treeWidget.model.addTerminalGroup(terminalId, groupPanel.id, pageId);
}
}
protected createTerminalGroupPanel(groupId?: TerminalManagerTreeTypes.GroupId): TerminalManagerTreeTypes.GroupSplitPanel {
const terminalColumnLayout = new ViewContainerLayout({
renderer: SplitPanel.defaultRenderer,
orientation: 'vertical',
spacing: 0,
headerSize: 0,
animationDuration: 200,
alignment: 'end',
}, this.splitPositionHandler);
const groupPanel = new SplitPanel({
layout: terminalColumnLayout,
}) as TerminalManagerTreeTypes.GroupSplitPanel;
const idPrefix = 'group-';
const uuid = this.generateUUIDAvoidDuplicatesFromStorage(idPrefix);
groupPanel.node.tabIndex = -1;
groupPanel.id = groupId ?? `${idPrefix}${uuid}`;
this.groupPanels.set(groupPanel.id, groupPanel);
return groupPanel;
}
protected handleTerminalGroupAdded(
groupId: TerminalManagerTreeTypes.GroupId,
pageId: TerminalManagerTreeTypes.PageId,
): void {
if (!this.treeWidget) {
return;
}
const groupPanel = this.groupPanels.get(groupId);
if (!groupPanel) {
return;
}
const activePage = this.pagePanels.get(pageId);
if (activePage) {
activePage.addWidget(groupPanel);
this.update();
}
}
protected async activateTerminalWidget(terminalKey: TerminalManagerTreeTypes.TerminalKey): Promise<Widget | undefined> {
const terminalWidgetToActivate = this.terminalWidgets.get(terminalKey)?.id;
if (terminalWidgetToActivate) {
const activeWidgetFound = await this.shell.activateWidget(terminalWidgetToActivate);
return activeWidgetFound;
}
return undefined;
}
activateWidget(id: string): Widget | undefined {
const widget = Array.from(this.terminalWidgets.values()).find(terminalWidget => terminalWidget.id === id);
widget?.activate();
return widget;
}
protected handleTerminalGroupDeleted(groupPanelId: TerminalManagerTreeTypes.GroupId): void {
const panel = this.groupPanels.get(groupPanelId);
this.groupPanels.delete(groupPanelId);
if (!panel) { return; }
this.clearTerminalReferences(panel);
panel.dispose();
}
protected clearTerminalReferences(panel: TerminalManagerTreeTypes.GroupSplitPanel): void {
for (const terminal of panel.widgets) {
this.removeTerminalReferenceByWidgetId(terminal.id);
}
}
addWidgetToTerminalGroup(widget: Widget, groupId: TerminalManagerTreeTypes.GroupId): void {
if (widget instanceof TerminalWidgetImpl) {
const newTerminalId = TerminalManagerTreeTypes.generateTerminalKey(widget);
this.addTerminalReference(widget, newTerminalId);
this.onDidChangeTrackableWidgetsEmitter.fire(this.getTrackableWidgets());
this.treeWidget.model.addTerminal(newTerminalId, groupId);
}
}
protected override onActivateRequest(msg: Message): void {
super.onActivateRequest(msg);
const activeTerminalId = this.treeWidget.model.activeTerminalNode?.id;
if (activeTerminalId) {
const activeTerminalWidget = this.terminalWidgets.get(activeTerminalId);
if (activeTerminalWidget) {
activeTerminalWidget.activate();
return;
}
}
this.node.focus();
}
protected handleWidgetAddedToTerminalGroup(terminalKey: TerminalManagerTreeTypes.TerminalKey, groupId: TerminalManagerTreeTypes.GroupId): void {
const terminalWidget = this.terminalWidgets.get(terminalKey);
const group = this.groupPanels.get(groupId);
if (terminalWidget && group) {
const groupPanel = this.groupPanels.get(groupId);
groupPanel?.addWidget(terminalWidget);
this.update();
}
}
protected handleTerminalDeleted(terminalId: TerminalManagerTreeTypes.TerminalKey): void {
const terminalWidget = this.terminalWidgets.get(terminalId);
if (!terminalWidget?.isDisposed) {
terminalWidget?.dispose();
}
this.removeTerminalReferenceByNodeId(terminalId);
}
protected handleOnDidChangeActiveWidget(widget: Widget | null): void {
if (!(widget instanceof TerminalWidgetImpl)) {
return;
}
const terminalKey = TerminalManagerTreeTypes.generateTerminalKey(widget);
this.treeWidget.model.selectTerminalNode(terminalKey);
}
protected handleSelectionChange(changeEvent: TerminalManagerTreeTypes.SelectionChangedEvent): void {
const { activePageId } = changeEvent;
if (activePageId && activePageId) {
const pageNode = this.treeWidget.model.getNode(activePageId);
if (!TerminalManagerTreeTypes.isPageNode(pageNode)) {
return;
}
this.updateViewPage(activePageId);
}
this.update();
}
protected updateViewPage(activePageId: TerminalManagerTreeTypes.PageId): void {
const activePagePanel = this.pagePanels.get(activePageId);
if (activePagePanel) {
this.terminalPanelWrapper.widgets
.forEach(widget => widget !== activePagePanel && widget.hide());
activePagePanel.show();
this.update();
}
}
deleteTerminal(terminalId: TerminalManagerTreeTypes.TerminalKey): void {
this.treeWidget.model.deleteTerminalNode(terminalId);
}
deleteGroup(groupId: TerminalManagerTreeTypes.GroupId): void {
this.treeWidget.model.deleteTerminalGroup(groupId);
}
deletePage(pageNode: TerminalManagerTreeTypes.PageId): void {
this.treeWidget.model.deleteTerminalPage(pageNode);
}
toggleRenameTerminal(entityId: TerminalManagerTreeTypes.TerminalManagerValidId): void {
this.treeWidget.model.toggleRenameTerminal(entityId);
}
storeState(): TerminalManagerWidgetState.LayoutData {
return this.getLayoutData();
}
restoreState(oldState: TerminalManagerWidgetState.LayoutData): void {
const { items, widget, terminalAndTreeRelativeSizes } = oldState;
if (widget && terminalAndTreeRelativeSizes && items) {
this.setPanelSizes(terminalAndTreeRelativeSizes);
try {
this.restoreLayoutData(items, widget);
} catch (e) {
console.error(e);
this.resetLayout();
this.populateLayout(true);
} finally {
this.stateIsSet = true;
const { activeTerminalNode } = this.treeWidget.model;
setTimeout(() => {
this.treeWidget.model.selectTerminalNode(activeTerminalNode?.id ?? Array.from(this.terminalWidgets.keys())[0]);
});
}
}
}
protected resetLayout(): void {
this.pagePanels = new Map();
this.groupPanels = new Map();
this.terminalWidgets = new Map();
}
async resetView(): Promise<void> {
const terminalWidget = await this.createTerminalWidget();
const page = this.doAddTerminalPage(terminalWidget);
for (const id of this.pagePanels.keys()) {
if (id !== page?.id) {
this.deletePage(id);
}
}
}
protected iterateAndRestoreLayoutTree(pageLayouts: TerminalManagerWidgetState.PageLayoutData[], treeWidget: TerminalManagerTreeWidget): void {
for (const pageLayout of pageLayouts) {
const pageId = pageLayout.id;
const pagePanel = this.createPagePanel(pageId);
const pageNode = treeWidget.model.getNode(pageId);
if (!TerminalManagerTreeTypes.isPageNode(pageNode)) {
throw TerminalManagerWidget.createRestoreError(pageId);
}
this.pagePanels.set(pageId, pagePanel);
this.terminalPanelWrapper.addWidget(pagePanel);
const { childLayouts: groupLayouts } = pageLayout;
for (const groupLayout of groupLayouts) {
const groupId = groupLayout.id;
const groupPanel = this.createTerminalGroupPanel(groupId);
const groupNode = treeWidget.model.getNode(groupId);
if (!TerminalManagerTreeTypes.isGroupNode(groupNode)) {
throw TerminalManagerWidget.createRestoreError(groupId);
}
this.groupPanels.set(groupId, groupPanel);
pagePanel.insertWidget(0, groupPanel);
const { childLayouts: widgetLayouts } = groupLayout;
for (const widgetLayout of widgetLayouts) {
const { widget } = widgetLayout;
if (widget instanceof TerminalWidgetImpl) {
const widgetId = TerminalManagerTreeTypes.generateTerminalKey(widget);
const widgetNode = treeWidget.model.getNode(widgetId);
if (!TerminalManagerTreeTypes.isTerminalNode(widgetNode)) {
throw TerminalManagerWidget.createRestoreError(widgetId);
}
this.addTerminalReference(widget, widgetId);
this.onDidChangeTrackableWidgetsEmitter.fire(this.getTrackableWidgets());
groupPanel.addWidget(widget);
}
}
const { widgetRelativeHeights } = groupLayout;
if (widgetRelativeHeights) {
requestAnimationFrame(() => groupPanel.setRelativeSizes(widgetRelativeHeights));
}
}
const { groupRelativeWidths } = pageLayout;
if (groupRelativeWidths) {
requestAnimationFrame(() => pagePanel.setRelativeSizes(groupRelativeWidths));
}
}
}
restoreLayoutData(items: TerminalManagerWidgetState.TerminalManagerLayoutData, treeWidget: TerminalManagerTreeWidget): void {
const { childLayouts: pageLayouts } = items;
Array.from(this.pagePanels.keys()).forEach(pageId => this.deletePage(pageId));
this.iterateAndRestoreLayoutTree(pageLayouts, treeWidget);
this.onDidChangeTrackableWidgetsEmitter.fire(this.getTrackableWidgets());
this.update();
}
getLayoutData(): TerminalManagerWidgetState.LayoutData {
const pageItems: TerminalManagerWidgetState.TerminalManagerLayoutData = { childLayouts: [], id: 'ParentPanel' };
const treeViewLocation = this.terminalManagerPreferences.get('terminal.grouping.treeViewLocation');
let terminalAndTreeRelativeSizes: TerminalManagerWidgetState.PanelRelativeSizes | undefined = undefined;
const sizeArray = this.pageAndTreeLayout?.relativeSizes();
if (sizeArray && treeViewLocation === 'right') {
terminalAndTreeRelativeSizes = { tree: sizeArray[1], terminal: sizeArray[0] };
} else if (sizeArray && treeViewLocation === 'left') {
terminalAndTreeRelativeSizes = { tree: sizeArray[0], terminal: sizeArray[1] };
}
const fullLayoutData: TerminalManagerWidgetState.LayoutData = {
widget: this.treeWidget,
items: pageItems,
terminalAndTreeRelativeSizes,
};
const treeRoot = this.treeWidget.model.root;
if (treeRoot && CompositeTreeNode.is(treeRoot)) {
const pageNodes = treeRoot.children;
for (const pageNode of pageNodes) {
if (TerminalManagerTreeTypes.isPageNode(pageNode)) {
const groupNodes = pageNode.children;
const pagePanel = this.pagePanels.get(pageNode.id);
const pageLayoutData: TerminalManagerWidgetState.PageLayoutData = {
childLayouts: [],
id: pageNode.id,
groupRelativeWidths: pagePanel?.relativeSizes(),
};
for (const groupNode of groupNodes) {
const groupPanel = this.groupPanels.get(groupNode.id);
if (TerminalManagerTreeTypes.isGroupNode(groupNode)) {
const groupLayoutData: TerminalManagerWidgetState.TerminalGroupLayoutData = {
id: groupNode.id,
childLayouts: [],
widgetRelativeHeights: groupPanel?.relativeSizes(),
};
const widgetNodes = groupNode.children;
for (const widgetNode of widgetNodes) {
if (TerminalManagerTreeTypes.isTerminalNode(widgetNode)) {
const widget = this.terminalWidgets.get(widgetNode.id);
const terminalLayoutData: TerminalManagerWidgetState.TerminalWidgetLayoutData = {
widget,
};
groupLayoutData.childLayouts.push(terminalLayoutData);
}
}
pageLayoutData.childLayouts.unshift(groupLayoutData);
}
}
pageItems.childLayouts.push(pageLayoutData);
}
}
}
return fullLayoutData;
}
protected activateNextAvailableTerminal(excludeTerminalKey: TerminalManagerTreeTypes.TerminalKey): void {
const remainingTerminals = Array.from(this.terminalWidgets.entries()).filter(([key]) => key !== excludeTerminalKey);
if (remainingTerminals.length > 0) {
const activeTerminalId = this.treeWidget.model.activeTerminalNode?.id;
let targetTerminal: TerminalWidget | undefined;
if (activeTerminalId && activeTerminalId !== excludeTerminalKey && this.terminalWidgets.has(activeTerminalId)) {
targetTerminal = this.terminalWidgets.get(activeTerminalId);
} else {
targetTerminal = remainingTerminals[0][1];
}
if (targetTerminal) {
this.shell.activateWidget(targetTerminal.id);
}
} else {
this.shell.activateWidget(this.id);
}
}
override dispose(): void {
this.toDispose.dispose();
super.dispose();
this.terminalWidgets.clear();
}
}

View File

@@ -0,0 +1,51 @@
/********************************************************************************
* Copyright (C) 2022-2023 Ericsson and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/
#terminal-manager-widget .lm-Widget.lm-Panel.lm-SplitPanel {
height: 100%;
}
#terminal-manager-widget .lm-SplitPanel[data-orientation='horizontal']>.lm-SplitPanel-handle {
width: 1px !important;
background-color: var(--theia-tab-unfocusedInactiveForeground);
}
#terminal-manager-widget .lm-SplitPanel[data-orientation='vertical']>.lm-SplitPanel-handle {
height: 1px !important;
background-color: var(--theia-tab-unfocusedInactiveForeground);
}
.terminal-manager-tree-widget .rename-node-input {
width: 100%;
}
.terminal-manager-tree-widget .theia-CompositeTreeNode .terminal-manager-inline-actions-container {
visibility: hidden;
}
.terminal-manager-tree-widget .theia-CompositeTreeNode:hover .terminal-manager-inline-actions-container {
visibility: unset;
}
.terminal-manager-tree-widget .codicon {
margin-right: 3px;
}
.terminal-manager-inline-actions {
display: flex;
align-items: center;
cursor: pointer;
}

View File

@@ -0,0 +1,27 @@
// *****************************************************************************
// Copyright (C) 2017 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
/* note: this bogus test file is required so that
we are able to run mocha unit tests on this
package, without having any actual unit tests in it.
This way a coverage report will be generated,
showing 0% coverage, instead of no report.
This file can be removed once we have real unit
tests in place. */
describe('terminal package', () => {
it('support code coverage statistics', () => true);
});

View File

@@ -0,0 +1,22 @@
{
"extends": "../../configs/base.tsconfig",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib",
"composite": true
},
"include": [
"src"
],
"references": [
{
"path": "../core"
},
{
"path": "../preferences"
},
{
"path": "../terminal"
}
]
}