// ***************************************************************************** // 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, named } from '@theia/core/shared/inversify'; import { ApplicationShell, BaseWidget, codicon, CompositeTreeNode, ExtractableWidget, Message, Panel, PanelLayout, SplitLayout, SplitPanel, SplitPositionHandler, StatefulWidget, StorageService, ViewContainerLayout, Widget, WidgetManager, } from '@theia/core/lib/browser'; import { Disposable, DisposableCollection, Emitter, nls, ILogger } from '@theia/core'; import { UUID } from '@theia/core/shared/@lumino/coreutils'; import { TerminalWidget, TerminalWidgetOptions } from '@theia/terminal/lib/browser/base/terminal-widget'; import { TerminalWidgetImpl, nextTerminalCreationToken } 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, } export interface TerminalWidgetLayoutData { widget: TerminalWidget | undefined; } export interface TerminalGroupLayoutData extends BaseLayoutData { childLayouts: TerminalWidgetLayoutData[]; widgetRelativeHeights: number[] | undefined; } export interface PageLayoutData extends BaseLayoutData { 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, ExtractableWidget { static ID = 'terminal-manager-widget'; static LABEL = nls.localize('theia/terminal-manager/label', 'Terminals'); isExtractable: boolean = true; secondaryWindow: Window | undefined; protected panel: SplitPanel; protected pageAndTreeLayout: SplitLayout | undefined; protected stateIsSet = false; pagePanels = new Map(); groupPanels = new Map(); /** By node ID: safer for state restoration. */ terminalWidgets = new Map(); /** By terminal ID to work from widget to internal metadata. */ terminalWidgetIdsToNodeIds = new Map(); /** Track disposables per terminal to prevent memory leaks. */ protected terminalDisposables = new Map(); protected readonly onDidChangeTrackableWidgetsEmitter = new Emitter(); 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; @inject(ILogger) @named('terminal-manager:TerminalManagerWidget') protected readonly logger: ILogger; protected readonly terminalsDeletingFromClose = new Set(); 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 { 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 { for (const [key, widget] of this.terminalWidgets) { this.removeTerminalReferenceByNodeId(key); yield widget; } this.close(); } async populateLayout(force?: boolean): Promise { 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 { const terminalWidget = await this.terminalFrontendContribution.newTerminal({ created: nextTerminalCreationToken(), ...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(node => this.handleNodeRenamed(node))); this.toDispose.push(this.shell.onDidChangeActiveWidget(({ newValue }) => this.handleOnDidChangeActiveWidget(newValue))); this.toDispose.push(this.terminalManagerPreferences.onPreferenceChanged(() => this.resolveMainLayout())); } protected handleNodeRenamed(node: TerminalManagerTreeTypes.TerminalManagerTreeNode): void { if (TerminalManagerTreeTypes.isTerminalNode(node)) { const widget = this.terminalWidgets.get(node.id); if (widget) { widget.setTitle(node.label); } } 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 { 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 { 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 { const CLOSE = nls.localizeByDefault('Close'); // When the widget lives in a secondary window, open the dialog there so it is visible to the user. const dialogOptions = this.secondaryWindow ? { node: this.secondaryWindow.document.createElement('div') } : undefined; 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'), }, dialogOptions); const confirmed = await dialog.open(); return confirmed === true; } /** * Add a terminal to a page. If no `pageId` is given, a new auto-numbered * page is created. If a `pageId` is given, the existing page is reused * or a new one is created (with special-page config applied if available). */ addTerminalPage(widget: Widget, pageId?: TerminalManagerTreeTypes.PageId): void { if (widget instanceof TerminalWidgetImpl) { const resolvedPageId = pageId ?? this.createPagePanel().id; const terminalKey = TerminalManagerTreeTypes.generateTerminalKey(widget); this.addTerminalReference(widget, terminalKey); this.onDidChangeTrackableWidgetsEmitter.fire(this.getTrackableWidgets()); const groupPanel = this.createTerminalGroupPanel(); groupPanel.addWidget(widget); this.treeWidget.model.addTerminalPage(terminalKey, groupPanel.id, resolvedPageId, widget.title.label); } } protected addTerminalReference(widget: TerminalWidget, nodeId: TerminalManagerTreeTypes.TerminalKey): void { this.terminalWidgets.set(nodeId, widget); this.terminalWidgetIdsToNodeIds.set(widget.id, nodeId); // Create disposable collection for this terminal const disposables = new DisposableCollection(); // Track title label changes with proper disposal let currentLabel = widget.title.label; const titleChangeHandler = () => { if (widget.title.label !== currentLabel) { currentLabel = widget.title.label; this.treeWidget.model.updateTerminalLabel(nodeId, currentLabel); } }; widget.title.changed.connect(titleChangeHandler); disposables.push(Disposable.create(() => widget.title.changed.disconnect(titleChangeHandler))); // When the terminal widget is disposed externally (e.g. debug process // exits), remove its tree node so the manager doesn't show an empty // entry. const onWidgetDisposed = () => { if (this.terminalWidgets.has(nodeId)) { this.deleteTerminal(nodeId); } }; widget.disposed.connect(onWidgetDisposed); disposables.push(Disposable.create(() => widget.disposed.disconnect(onWidgetDisposed))); this.terminalDisposables.set(nodeId, disposables); } protected removeTerminalReferenceByWidgetId(widgetId: string): boolean { const nodeId = this.terminalWidgetIdsToNodeIds.get(widgetId); if (nodeId === undefined) { return false; } this.removeTerminalReferenceByNodeId(nodeId); return true; } 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); // Dispose signal connections const disposables = this.terminalDisposables.get(nodeId); if (disposables) { disposables.dispose(); this.terminalDisposables.delete(nodeId); } 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 { let pagePanel = this.pagePanels.get(pageId); if (!pagePanel) { pagePanel = this.createPagePanel(pageId); } 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, widget.title.label); } } 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 { 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, widget.title.label); } } 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); // Remove the reference before disposing so the disposed-signal // handler does not re-enter deleteTerminal. this.removeTerminalReferenceByNodeId(terminalId); if (!terminalWidget?.isDisposed) { terminalWidget?.dispose(); } } 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 && items) { if (terminalAndTreeRelativeSizes) { this.setPanelSizes(terminalAndTreeRelativeSizes); } try { this.restoreLayoutData(items, widget); } catch (e) { this.logger.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 { const terminalWidget = await this.createTerminalWidget(); const pagePanel = this.createPagePanel(); this.addTerminalPage(terminalWidget, pagePanel.id); for (const id of this.pagePanels.keys()) { if (id !== pagePanel.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 { // Dispose all remaining signal connections this.terminalDisposables.forEach(disposables => disposables.dispose()); this.terminalDisposables.clear(); this.toDispose.dispose(); super.dispose(); this.terminalWidgets.clear(); } }