UNPKG

@theia/core

Version:

Theia is a cloud & desktop IDE framework implemented in TypeScript.

1,194 lines (1,087 loc) • 88 kB
// ***************************************************************************** // Copyright (C) 2018 TypeFox 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, optional, postConstruct } from 'inversify'; import { ArrayExt, find, toArray, each } from '@phosphor/algorithm'; import { BoxLayout, BoxPanel, DockLayout, DockPanel, FocusTracker, Layout, Panel, SplitLayout, SplitPanel, TabBar, Widget, Title } from '@phosphor/widgets'; import { Message } from '@phosphor/messaging'; import { IDragEvent } from '@phosphor/dragdrop'; import { RecursivePartial, Event as CommonEvent, DisposableCollection, Disposable, environment, isObject } from '../../common'; import { animationFrame } from '../browser'; import { Saveable, SaveableWidget, SaveOptions, SaveableSource } from '../saveable'; import { StatusBarImpl, StatusBarEntry, StatusBarAlignment } from '../status-bar/status-bar'; import { TheiaDockPanel, BOTTOM_AREA_ID, MAIN_AREA_ID } from './theia-dock-panel'; import { SidePanelHandler, SidePanel, SidePanelHandlerFactory } from './side-panel-handler'; import { TabBarRendererFactory, SHELL_TABBAR_CONTEXT_MENU, ScrollableTabBar, ToolbarAwareTabBar } from './tab-bars'; import { SplitPositionHandler, SplitPositionOptions } from './split-panels'; import { FrontendApplicationStateService } from '../frontend-application-state'; import { TabBarToolbarRegistry, TabBarToolbarFactory } from './tab-bar-toolbar'; import { ContextKeyService } from '../context-key-service'; import { Emitter } from '../../common/event'; import { waitForRevealed, waitForClosed, PINNED_CLASS } from '../widgets'; import { CorePreferences } from '../core-preferences'; import { BreadcrumbsRendererFactory } from '../breadcrumbs/breadcrumbs-renderer'; import { Deferred } from '../../common/promise-util'; import { SaveResourceService } from '../save-resource-service'; import { nls } from '../../common/nls'; import { SecondaryWindowHandler } from '../secondary-window-handler'; import URI from '../../common/uri'; import { OpenerService } from '../opener-service'; import { PreviewableWidget } from '../widgets/previewable-widget'; /** The class name added to ApplicationShell instances. */ const APPLICATION_SHELL_CLASS = 'theia-ApplicationShell'; /** The class name added to the main and bottom area panels. */ const MAIN_BOTTOM_AREA_CLASS = 'theia-app-centers'; /** Status bar entry identifier for the bottom panel toggle button. */ const BOTTOM_PANEL_TOGGLE_ID = 'bottom-panel-toggle'; /** The class name added to the main area panel. */ const MAIN_AREA_CLASS = 'theia-app-main'; /** The class name added to the bottom area panel. */ const BOTTOM_AREA_CLASS = 'theia-app-bottom'; export type ApplicationShellLayoutVersion = /** layout versioning is introduced, unversioned layout are not compatible */ 2.0 | /** view containers are introduced, backward compatible to 2.0 */ 3.0 | /** git history view is replaced by a more generic scm history view, backward compatible to 3.0 */ 4.0 | /** Replace custom/font-awesome icons with codicons */ 5.0 | /** added the ability to drag and drop view parts between view containers */ 6.0; /** * When a version is increased, make sure to introduce a migration (ApplicationShellLayoutMigration) to this version. */ export const applicationShellLayoutVersion: ApplicationShellLayoutVersion = 5.0; export const ApplicationShellOptions = Symbol('ApplicationShellOptions'); export const DockPanelRendererFactory = Symbol('DockPanelRendererFactory'); export interface DockPanelRendererFactory { (): DockPanelRenderer } /** * A renderer for dock panels that supports context menus on tabs. */ @injectable() export class DockPanelRenderer implements DockLayout.IRenderer { @inject(TheiaDockPanel.Factory) protected readonly dockPanelFactory: TheiaDockPanel.Factory; readonly tabBarClasses: string[] = []; private readonly onDidCreateTabBarEmitter = new Emitter<TabBar<Widget>>(); constructor( @inject(TabBarRendererFactory) protected readonly tabBarRendererFactory: TabBarRendererFactory, @inject(TabBarToolbarRegistry) protected readonly tabBarToolbarRegistry: TabBarToolbarRegistry, @inject(TabBarToolbarFactory) protected readonly tabBarToolbarFactory: TabBarToolbarFactory, @inject(BreadcrumbsRendererFactory) protected readonly breadcrumbsRendererFactory: BreadcrumbsRendererFactory, @inject(CorePreferences) protected readonly corePreferences: CorePreferences ) { } get onDidCreateTabBar(): CommonEvent<TabBar<Widget>> { return this.onDidCreateTabBarEmitter.event; } createTabBar(): TabBar<Widget> { const getDynamicTabOptions: () => ScrollableTabBar.Options | undefined = () => { if (this.corePreferences.get('workbench.tab.shrinkToFit.enabled')) { return { minimumTabSize: this.corePreferences.get('workbench.tab.shrinkToFit.minimumSize'), defaultTabSize: this.corePreferences.get('workbench.tab.shrinkToFit.defaultSize') }; } else { return undefined; } }; const renderer = this.tabBarRendererFactory(); const tabBar = new ToolbarAwareTabBar( this.tabBarToolbarRegistry, this.tabBarToolbarFactory, this.breadcrumbsRendererFactory, { renderer, // Scroll bar options handlers: ['drag-thumb', 'keyboard', 'wheel', 'touch'], useBothWheelAxes: true, scrollXMarginOffset: 4, suppressScrollY: true }, getDynamicTabOptions()); this.tabBarClasses.forEach(c => tabBar.addClass(c)); renderer.tabBar = tabBar; tabBar.disposed.connect(() => renderer.dispose()); renderer.contextMenuPath = SHELL_TABBAR_CONTEXT_MENU; tabBar.currentChanged.connect(this.onCurrentTabChanged, this); this.corePreferences.onPreferenceChanged(change => { if (change.preferenceName === 'workbench.tab.shrinkToFit.enabled' || change.preferenceName === 'workbench.tab.shrinkToFit.minimumSize' || change.preferenceName === 'workbench.tab.shrinkToFit.defaultSize') { tabBar.dynamicTabOptions = getDynamicTabOptions(); } }); this.onDidCreateTabBarEmitter.fire(tabBar); return tabBar; } createHandle(): HTMLDivElement { return DockPanel.defaultRenderer.createHandle(); } protected onCurrentTabChanged(sender: ToolbarAwareTabBar, { currentIndex }: TabBar.ICurrentChangedArgs<Widget>): void { if (currentIndex >= 0) { sender.revealTab(currentIndex); } } } /** * Data stored while dragging widgets in the shell. */ interface WidgetDragState { startTime: number; leftExpanded: boolean; rightExpanded: boolean; bottomExpanded: boolean; lastDragOver?: IDragEvent; leaveTimeout?: number; } /** * The application shell manages the top-level widgets of the application. Use this class to * add, remove, or activate a widget. */ @injectable() export class ApplicationShell extends Widget { /** * The dock panel in the main shell area. This is where editors usually go to. */ mainPanel: TheiaDockPanel; /** * The dock panel in the bottom shell area. In contrast to the main panel, the bottom panel * can be collapsed and expanded. */ bottomPanel: TheiaDockPanel; /** * Handler for the left side panel. The primary application views go here, such as the * file explorer and the git view. */ leftPanelHandler: SidePanelHandler; /** * Handler for the right side panel. The secondary application views go here, such as the * outline view. */ rightPanelHandler: SidePanelHandler; /** * General options for the application shell. */ protected options: ApplicationShell.Options; /** * The fixed-size panel shown on top. This one usually holds the main menu. */ topPanel: Panel; /** * The current state of the bottom panel. */ protected readonly bottomPanelState: SidePanel.State = { empty: true, expansion: SidePanel.ExpansionState.collapsed, pendingUpdate: Promise.resolve() }; private readonly tracker = new FocusTracker<Widget>(); private dragState?: WidgetDragState; additionalDraggedUris: URI[] | undefined; @inject(ContextKeyService) protected readonly contextKeyService: ContextKeyService; @inject(OpenerService) protected readonly openerService: OpenerService; protected readonly onDidAddWidgetEmitter = new Emitter<Widget>(); readonly onDidAddWidget = this.onDidAddWidgetEmitter.event; protected fireDidAddWidget(widget: Widget): void { this.onDidAddWidgetEmitter.fire(widget); } protected readonly onDidRemoveWidgetEmitter = new Emitter<Widget>(); readonly onDidRemoveWidget = this.onDidRemoveWidgetEmitter.event; protected fireDidRemoveWidget(widget: Widget): void { this.onDidRemoveWidgetEmitter.fire(widget); } protected readonly onDidChangeActiveWidgetEmitter = new Emitter<FocusTracker.IChangedArgs<Widget>>(); readonly onDidChangeActiveWidget = this.onDidChangeActiveWidgetEmitter.event; protected readonly onDidChangeCurrentWidgetEmitter = new Emitter<FocusTracker.IChangedArgs<Widget>>(); readonly onDidChangeCurrentWidget = this.onDidChangeCurrentWidgetEmitter.event; protected readonly onDidDoubleClickMainAreaEmitter = new Emitter<void>(); readonly onDidDoubleClickMainArea = this.onDidDoubleClickMainAreaEmitter.event; @inject(TheiaDockPanel.Factory) protected readonly dockPanelFactory: TheiaDockPanel.Factory; private _mainPanelRenderer: DockPanelRenderer; get mainPanelRenderer(): DockPanelRenderer { return this._mainPanelRenderer; } /** * Construct a new application shell. */ constructor( @inject(DockPanelRendererFactory) protected dockPanelRendererFactory: () => DockPanelRenderer, @inject(StatusBarImpl) protected readonly statusBar: StatusBarImpl, @inject(SidePanelHandlerFactory) protected readonly sidePanelHandlerFactory: () => SidePanelHandler, @inject(SplitPositionHandler) protected splitPositionHandler: SplitPositionHandler, @inject(FrontendApplicationStateService) protected readonly applicationStateService: FrontendApplicationStateService, @inject(ApplicationShellOptions) @optional() options: RecursivePartial<ApplicationShell.Options> = {}, @inject(CorePreferences) protected readonly corePreferences: CorePreferences, @inject(SaveResourceService) protected readonly saveResourceService: SaveResourceService, @inject(SecondaryWindowHandler) protected readonly secondaryWindowHandler: SecondaryWindowHandler, ) { super(options as Widget.IOptions); // Merge the user-defined application options with the default options this.options = { bottomPanel: { ...ApplicationShell.DEFAULT_OPTIONS.bottomPanel, ...options?.bottomPanel || {} }, leftPanel: { ...ApplicationShell.DEFAULT_OPTIONS.leftPanel, ...options?.leftPanel || {} }, rightPanel: { ...ApplicationShell.DEFAULT_OPTIONS.rightPanel, ...options?.rightPanel || {} } }; } @postConstruct() protected init(): void { this.initializeShell(); this.initSidebarVisibleKeyContext(); this.initFocusKeyContexts(); if (!environment.electron.is()) { this.corePreferences.ready.then(() => { this.setTopPanelVisibility(this.corePreferences['window.menuBarVisibility']); }); this.corePreferences.onPreferenceChanged(preference => { if (preference.preferenceName === 'window.menuBarVisibility') { this.setTopPanelVisibility(preference.newValue); } }); } this.corePreferences.onPreferenceChanged(preference => { if (preference.preferenceName === 'window.tabbar.enhancedPreview') { this.allTabBars.forEach(tabBar => { tabBar.update(); }); } }); } protected initializeShell(): void { this.addClass(APPLICATION_SHELL_CLASS); this.id = 'theia-app-shell'; this.mainPanel = this.createMainPanel(); this.topPanel = this.createTopPanel(); this.bottomPanel = this.createBottomPanel(); this.leftPanelHandler = this.sidePanelHandlerFactory(); this.leftPanelHandler.create('left', this.options.leftPanel); this.leftPanelHandler.dockPanel.widgetAdded.connect((_, widget) => this.fireDidAddWidget(widget)); this.leftPanelHandler.dockPanel.widgetRemoved.connect((_, widget) => this.fireDidRemoveWidget(widget)); this.rightPanelHandler = this.sidePanelHandlerFactory(); this.rightPanelHandler.create('right', this.options.rightPanel); this.rightPanelHandler.dockPanel.widgetAdded.connect((_, widget) => this.fireDidAddWidget(widget)); this.rightPanelHandler.dockPanel.widgetRemoved.connect((_, widget) => this.fireDidRemoveWidget(widget)); this.secondaryWindowHandler.init(this); this.secondaryWindowHandler.onDidAddWidget(widget => this.fireDidAddWidget(widget)); this.secondaryWindowHandler.onDidRemoveWidget(widget => this.fireDidRemoveWidget(widget)); this.layout = this.createLayout(); this.tracker.currentChanged.connect(this.onCurrentChanged, this); this.tracker.activeChanged.connect(this.onActiveChanged, this); } protected initSidebarVisibleKeyContext(): void { const leftSideBarPanel = this.leftPanelHandler.dockPanel; const sidebarVisibleKey = this.contextKeyService.createKey('sidebarVisible', leftSideBarPanel.isVisible); const onAfterShow = leftSideBarPanel['onAfterShow'].bind(leftSideBarPanel); leftSideBarPanel['onAfterShow'] = (msg: Message) => { onAfterShow(msg); sidebarVisibleKey.set(true); }; const onAfterHide = leftSideBarPanel['onAfterHide'].bind(leftSideBarPanel); leftSideBarPanel['onAfterHide'] = (msg: Message) => { onAfterHide(msg); sidebarVisibleKey.set(false); }; } protected initFocusKeyContexts(): void { const sideBarFocus = this.contextKeyService.createKey<boolean>('sideBarFocus', false); const panelFocus = this.contextKeyService.createKey<boolean>('panelFocus', false); const updateFocusContextKeys = () => { const area = this.activeWidget && this.getAreaFor(this.activeWidget); sideBarFocus.set(area === 'left'); panelFocus.set(area === 'main'); }; updateFocusContextKeys(); this.onDidChangeActiveWidget(updateFocusContextKeys); } protected setTopPanelVisibility(preference: string): void { const hiddenPreferences = ['compact', 'hidden']; this.topPanel.setHidden(hiddenPreferences.includes(preference)); } protected override onBeforeAttach(msg: Message): void { document.addEventListener('p-dragenter', this, true); document.addEventListener('p-dragover', this, true); document.addEventListener('p-dragleave', this, true); document.addEventListener('p-drop', this, true); } protected override onAfterDetach(msg: Message): void { document.removeEventListener('p-dragenter', this, true); document.removeEventListener('p-dragover', this, true); document.removeEventListener('p-dragleave', this, true); document.removeEventListener('p-drop', this, true); } handleEvent(event: Event): void { switch (event.type) { case 'p-dragenter': this.onDragEnter(event as IDragEvent); break; case 'p-dragover': this.onDragOver(event as IDragEvent); break; case 'p-drop': this.onDrop(event as IDragEvent); break; case 'p-dragleave': this.onDragLeave(event as IDragEvent); break; } } protected onDragEnter({ mimeData }: IDragEvent): void { if (!this.dragState) { if (mimeData && mimeData.hasData('application/vnd.phosphor.widget-factory')) { // The drag contains a widget, so we'll track it and expand side panels as needed this.dragState = { startTime: performance.now(), leftExpanded: false, rightExpanded: false, bottomExpanded: false }; } } } protected onDragOver(event: IDragEvent): void { const state = this.dragState; if (state) { state.lastDragOver = event; if (state.leaveTimeout) { window.clearTimeout(state.leaveTimeout); state.leaveTimeout = undefined; } const { clientX, clientY } = event; const { offsetLeft, offsetTop, clientWidth, clientHeight } = this.node; // Don't expand any side panels right after the drag has started const allowExpansion = performance.now() - state.startTime >= 500; const expLeft = allowExpansion && clientX >= offsetLeft && clientX <= offsetLeft + this.options.leftPanel.expandThreshold; const expRight = allowExpansion && clientX <= offsetLeft + clientWidth && clientX >= offsetLeft + clientWidth - this.options.rightPanel.expandThreshold; const expBottom = allowExpansion && !expLeft && !expRight && clientY <= offsetTop + clientHeight && clientY >= offsetTop + clientHeight - this.options.bottomPanel.expandThreshold; // eslint-disable-next-line no-null/no-null if (expLeft && !state.leftExpanded && this.leftPanelHandler.tabBar.currentTitle === null) { // The mouse cursor is moved close to the left border this.leftPanelHandler.expand(); this.leftPanelHandler.state.pendingUpdate.then(() => this.dispatchMouseMove()); state.leftExpanded = true; } else if (!expLeft && state.leftExpanded) { // The mouse cursor is moved away from the left border this.leftPanelHandler.collapse(); state.leftExpanded = false; } // eslint-disable-next-line no-null/no-null if (expRight && !state.rightExpanded && this.rightPanelHandler.tabBar.currentTitle === null) { // The mouse cursor is moved close to the right border this.rightPanelHandler.expand(); this.rightPanelHandler.state.pendingUpdate.then(() => this.dispatchMouseMove()); state.rightExpanded = true; } else if (!expRight && state.rightExpanded) { // The mouse cursor is moved away from the right border this.rightPanelHandler.collapse(); state.rightExpanded = false; } if (expBottom && !state.bottomExpanded && this.bottomPanel.isHidden) { // The mouse cursor is moved close to the bottom border this.expandBottomPanel(); this.bottomPanelState.pendingUpdate.then(() => this.dispatchMouseMove()); state.bottomExpanded = true; } else if (!expBottom && state.bottomExpanded) { // The mouse cursor is moved away from the bottom border this.collapseBottomPanel(); state.bottomExpanded = false; } } } /** * This method is called after a side panel has been expanded while dragging a widget. It fires * a `mousemove` event so that the drag overlay markers are updated correctly in all dock panels. */ private dispatchMouseMove(): void { if (this.dragState && this.dragState.lastDragOver) { const { clientX, clientY } = this.dragState.lastDragOver; const event = document.createEvent('MouseEvent'); event.initMouseEvent('mousemove', true, true, window, 0, 0, 0, // eslint-disable-next-line no-null/no-null clientX, clientY, false, false, false, false, 0, null); document.dispatchEvent(event); } } protected onDrop(event: IDragEvent): void { const state = this.dragState; if (state) { if (state.leaveTimeout) { window.clearTimeout(state.leaveTimeout); } this.dragState = undefined; window.requestAnimationFrame(() => { // Clean up the side panel state in the next frame if (this.leftPanelHandler.dockPanel.isEmpty) { this.leftPanelHandler.collapse(); } if (this.rightPanelHandler.dockPanel.isEmpty) { this.rightPanelHandler.collapse(); } if (this.bottomPanel.isEmpty) { this.collapseBottomPanel(); } }); } } protected onDragLeave(event: IDragEvent): void { const state = this.dragState; if (state) { state.lastDragOver = undefined; if (state.leaveTimeout) { window.clearTimeout(state.leaveTimeout); } state.leaveTimeout = window.setTimeout(() => { this.dragState = undefined; if (state.leftExpanded || this.leftPanelHandler.dockPanel.isEmpty) { this.leftPanelHandler.collapse(); } if (state.rightExpanded || this.rightPanelHandler.dockPanel.isEmpty) { this.rightPanelHandler.collapse(); } if (state.bottomExpanded || this.bottomPanel.isEmpty) { this.collapseBottomPanel(); } }, 100); } } /** * Create the dock panel in the main shell area. */ protected createMainPanel(): TheiaDockPanel { const renderer = this.dockPanelRendererFactory(); renderer.tabBarClasses.push(MAIN_BOTTOM_AREA_CLASS); renderer.tabBarClasses.push(MAIN_AREA_CLASS); this._mainPanelRenderer = renderer; const dockPanel = this.dockPanelFactory({ mode: 'multiple-document', renderer, spacing: 0 }); dockPanel.id = MAIN_AREA_ID; dockPanel.widgetAdded.connect((_, widget) => this.fireDidAddWidget(widget)); dockPanel.widgetRemoved.connect((_, widget) => this.fireDidRemoveWidget(widget)); const openUri = async (fileUri: URI) => { try { const opener = await this.openerService.getOpener(fileUri); opener.open(fileUri); } catch (e) { console.info(`no opener found for '${fileUri}'`); } }; dockPanel.node.addEventListener('drop', event => { if (event.dataTransfer) { const uris = this.additionalDraggedUris || ApplicationShell.getDraggedEditorUris(event.dataTransfer); if (uris.length > 0) { uris.forEach(openUri); } else if (event.dataTransfer.files?.length > 0) { // the files were dragged from the outside the workspace Array.from(event.dataTransfer.files).forEach(file => { if (file.path) { const fileUri = URI.fromComponents({ scheme: 'file', path: file.path, authority: '', query: '', fragment: '' }); openUri(fileUri); } }); } } }); dockPanel.node.addEventListener('dblclick', event => { const el = event.target as Element; if (el.id === MAIN_AREA_ID || el.classList.contains('p-TabBar-content')) { this.onDidDoubleClickMainAreaEmitter.fire(); } }); const handler = (e: DragEvent) => { if (e.dataTransfer) { e.dataTransfer.dropEffect = 'link'; e.preventDefault(); e.stopPropagation(); } }; dockPanel.node.addEventListener('dragover', handler); dockPanel.node.addEventListener('dragenter', handler); return dockPanel; } addAdditionalDraggedEditorUris(uris: URI[]): void { this.additionalDraggedUris = uris; } clearAdditionalDraggedEditorUris(): void { this.additionalDraggedUris = undefined; } protected static getDraggedEditorUris(dataTransfer: DataTransfer): URI[] { const data = dataTransfer.getData('theia-editor-dnd'); return data ? data.split('\n').map(entry => new URI(entry)) : []; } static setDraggedEditorUris(dataTransfer: DataTransfer, uris: URI[]): void { dataTransfer.setData('theia-editor-dnd', uris.map(uri => uri.toString()).join('\n')); } /** * Create the dock panel in the bottom shell area. */ protected createBottomPanel(): TheiaDockPanel { const renderer = this.dockPanelRendererFactory(); renderer.tabBarClasses.push(MAIN_BOTTOM_AREA_CLASS); renderer.tabBarClasses.push(BOTTOM_AREA_CLASS); const dockPanel = this.dockPanelFactory({ mode: 'multiple-document', renderer, spacing: 0 }); dockPanel.id = BOTTOM_AREA_ID; dockPanel.widgetAdded.connect((sender, widget) => { this.refreshBottomPanelToggleButton(); }); dockPanel.widgetRemoved.connect((sender, widget) => { if (sender.isEmpty) { this.collapseBottomPanel(); } this.refreshBottomPanelToggleButton(); }, this); dockPanel.node.addEventListener('p-dragenter', event => { // Make sure that the main panel hides its overlay when the bottom panel is expanded this.mainPanel.overlay.hide(0); }); dockPanel.hide(); dockPanel.widgetAdded.connect((_, widget) => this.fireDidAddWidget(widget)); dockPanel.widgetRemoved.connect((_, widget) => this.fireDidRemoveWidget(widget)); return dockPanel; } /** * Create the top panel, which is used to hold the main menu. */ protected createTopPanel(): Panel { const topPanel = new Panel(); topPanel.id = 'theia-top-panel'; topPanel.hide(); return topPanel; } /** * Create a box layout to assemble the application shell layout. */ protected createBoxLayout(widgets: Widget[], stretch?: number[], options?: BoxPanel.IOptions): BoxLayout { const boxLayout = new BoxLayout(options); for (let i = 0; i < widgets.length; i++) { if (stretch !== undefined && i < stretch.length) { BoxPanel.setStretch(widgets[i], stretch[i]); } boxLayout.addWidget(widgets[i]); } return boxLayout; } /** * Create a split layout to assemble the application shell layout. */ protected createSplitLayout(widgets: Widget[], stretch?: number[], options?: Partial<SplitLayout.IOptions>): SplitLayout { let optParam: SplitLayout.IOptions = { renderer: SplitPanel.defaultRenderer, }; if (options) { optParam = { ...optParam, ...options }; } const splitLayout = new SplitLayout(optParam); for (let i = 0; i < widgets.length; i++) { if (stretch !== undefined && i < stretch.length) { SplitPanel.setStretch(widgets[i], stretch[i]); } splitLayout.addWidget(widgets[i]); } return splitLayout; } /** * Assemble the application shell layout. Override this method in order to change the arrangement * of the main area and the side panels. */ protected createLayout(): Layout { const bottomSplitLayout = this.createSplitLayout( [this.mainPanel, this.bottomPanel], [1, 0], { orientation: 'vertical', spacing: 0 } ); const panelForBottomArea = new SplitPanel({ layout: bottomSplitLayout }); panelForBottomArea.id = 'theia-bottom-split-panel'; const leftRightSplitLayout = this.createSplitLayout( [this.leftPanelHandler.container, panelForBottomArea, this.rightPanelHandler.container], [0, 1, 0], { orientation: 'horizontal', spacing: 0 } ); const panelForSideAreas = new SplitPanel({ layout: leftRightSplitLayout }); panelForSideAreas.id = 'theia-left-right-split-panel'; return this.createBoxLayout( [this.topPanel, panelForSideAreas, this.statusBar], [0, 1, 0], { direction: 'top-to-bottom', spacing: 0 } ); } /** * Create an object that describes the current shell layout. This object may contain references * to widgets; these need to be transformed before the layout can be serialized. */ getLayoutData(): ApplicationShell.LayoutData { return { version: applicationShellLayoutVersion, mainPanel: this.mainPanel.saveLayout(), mainPanelPinned: this.getPinnedMainWidgets(), bottomPanel: { config: this.bottomPanel.saveLayout(), pinned: this.getPinnedBottomWidgets(), size: this.bottomPanel.isVisible ? this.getBottomPanelSize() : this.bottomPanelState.lastPanelSize, expanded: this.isExpanded('bottom') }, leftPanel: this.leftPanelHandler.getLayoutData(), rightPanel: this.rightPanelHandler.getLayoutData(), activeWidgetId: this.activeWidget ? this.activeWidget.id : undefined }; } // Get an array corresponding to main panel widgets' pinned state. getPinnedMainWidgets(): boolean[] { const pinned: boolean[] = []; toArray(this.mainPanel.widgets()).forEach((a, i) => { pinned[i] = a.title.className.includes(PINNED_CLASS); }); return pinned; } // Get an array corresponding to bottom panel widgets' pinned state. getPinnedBottomWidgets(): boolean[] { const pinned: boolean[] = []; toArray(this.bottomPanel.widgets()).forEach((a, i) => { pinned[i] = a.title.className.includes(PINNED_CLASS); }); return pinned; } /** * Compute the current height of the bottom panel. This implementation assumes that the container * of the bottom panel is a `SplitPanel`. */ protected getBottomPanelSize(): number | undefined { const parent = this.bottomPanel.parent; if (parent instanceof SplitPanel && parent.isVisible) { const index = parent.widgets.indexOf(this.bottomPanel) - 1; if (index >= 0) { const handle = parent.handles[index]; if (!handle.classList.contains('p-mod-hidden')) { const parentHeight = parent.node.clientHeight; return parentHeight - handle.offsetTop; } } } } /** * Determine the default size to apply when the bottom panel is expanded for the first time. */ protected getDefaultBottomPanelSize(): number | undefined { const parent = this.bottomPanel.parent; if (parent && parent.isVisible) { return parent.node.clientHeight * this.options.bottomPanel.initialSizeRatio; } } /** * Apply a shell layout that has been previously created with `getLayoutData`. */ async setLayoutData(layoutData: ApplicationShell.LayoutData): Promise<void> { const { mainPanel, mainPanelPinned, bottomPanel, leftPanel, rightPanel, activeWidgetId } = layoutData; if (leftPanel) { this.leftPanelHandler.setLayoutData(leftPanel); this.registerWithFocusTracker(leftPanel); } if (rightPanel) { this.rightPanelHandler.setLayoutData(rightPanel); this.registerWithFocusTracker(rightPanel); } // Proceed with the bottom panel once the side panels are set up await Promise.all([this.leftPanelHandler.state.pendingUpdate, this.rightPanelHandler.state.pendingUpdate]); if (bottomPanel) { if (bottomPanel.config) { this.bottomPanel.restoreLayout(bottomPanel.config); this.registerWithFocusTracker(bottomPanel.config.main); } if (bottomPanel.size) { this.bottomPanelState.lastPanelSize = bottomPanel.size; } if (bottomPanel.expanded) { this.expandBottomPanel(); } else { this.collapseBottomPanel(); } const widgets = toArray(this.bottomPanel.widgets()); this.bottomPanel.markActiveTabBar(widgets[0]?.title); if (bottomPanel.pinned && bottomPanel.pinned.length === widgets.length) { widgets.forEach((a, i) => { if (bottomPanel.pinned![i]) { a.title.className += ` ${PINNED_CLASS}`; a.title.closable = false; } }); } this.refreshBottomPanelToggleButton(); } // Proceed with the main panel once all others are set up await this.bottomPanelState.pendingUpdate; if (mainPanel) { this.mainPanel.restoreLayout(mainPanel); this.registerWithFocusTracker(mainPanel.main); const widgets = toArray(this.mainPanel.widgets()); // We don't store information about the last active tabbar // So we simply mark the first as being active this.mainPanel.markActiveTabBar(widgets[0]?.title); if (mainPanelPinned && mainPanelPinned.length === widgets.length) { widgets.forEach((a, i) => { if (mainPanelPinned[i]) { a.title.className += ` ${PINNED_CLASS}`; a.title.closable = false; } }); } } if (activeWidgetId) { this.activateWidget(activeWidgetId); } } /** * Modify the height of the bottom panel. This implementation assumes that the container of the * bottom panel is a `SplitPanel`. */ protected setBottomPanelSize(size: number): Promise<void> { const enableAnimation = this.applicationStateService.state === 'ready'; const options: SplitPositionOptions = { side: 'bottom', duration: enableAnimation ? this.options.bottomPanel.expandDuration : 0, referenceWidget: this.bottomPanel }; const promise = this.splitPositionHandler.setSidePanelSize(this.bottomPanel, size, options); const result = new Promise<void>(resolve => { // Resolve the resulting promise in any case, regardless of whether resizing was successful promise.then(() => resolve(), () => resolve()); }); this.bottomPanelState.pendingUpdate = this.bottomPanelState.pendingUpdate.then(() => result); return result; } /** * A promise that is resolved when all currently pending updates are done. */ get pendingUpdates(): Promise<void> { return Promise.all([ this.bottomPanelState.pendingUpdate, this.leftPanelHandler.state.pendingUpdate, this.rightPanelHandler.state.pendingUpdate // eslint-disable-next-line @typescript-eslint/no-explicit-any ]) as Promise<any>; } /** * Track all widgets that are referenced by the given layout data. */ protected registerWithFocusTracker(data: DockLayout.ITabAreaConfig | DockLayout.ISplitAreaConfig | SidePanel.LayoutData | null): void { if (data) { if (data.type === 'tab-area') { for (const widget of data.widgets) { if (widget) { this.track(widget); } } } else if (data.type === 'split-area') { for (const child of data.children) { this.registerWithFocusTracker(child); } } else if (data.type === 'sidepanel' && data.items) { for (const item of data.items) { if (item.widget) { this.track(item.widget); } } } } } /** * Add a widget to the application shell. The given widget must have a unique `id` property, * which will be used as the DOM id. * * Widgets are removed from the shell by calling their `close` or `dispose` methods. * * Widgets added to the top area are not tracked regarding the _current_ and _active_ states. */ async addWidget(widget: Widget, options?: Readonly<ApplicationShell.WidgetOptions>): Promise<void> { if (!widget.id) { console.error('Widgets added to the application shell must have a unique id property.'); return; } const { area, addOptions } = this.getInsertionOptions(options); const sidePanelOptions: SidePanel.WidgetOptions = { rank: options?.rank }; switch (area) { case 'main': this.mainPanel.addWidget(widget, addOptions); break; case 'top': this.topPanel.addWidget(widget); break; case 'bottom': this.bottomPanel.addWidget(widget, addOptions); break; case 'left': this.leftPanelHandler.addWidget(widget, sidePanelOptions); break; case 'right': this.rightPanelHandler.addWidget(widget, sidePanelOptions); break; case 'secondaryWindow': /** At the moment, widgets are only moved to this area (i.e. a secondary window) by moving them from one of the other areas. */ throw new Error('Widgets cannot be added directly to a secondary window'); default: throw new Error('Unexpected area: ' + options?.area); } if (area !== 'top') { this.track(widget); } } getInsertionOptions(options?: Readonly<ApplicationShell.WidgetOptions>): { area: string; addOptions: DockLayout.IAddOptions; } { let ref: Widget | undefined = options?.ref; let area: ApplicationShell.Area = options?.area || 'main'; if (!ref && (area === 'main' || area === 'bottom')) { const tabBar = this.getTabBarFor(area); ref = tabBar && tabBar.currentTitle && tabBar.currentTitle.owner || undefined; } // make sure that ref belongs to area area = ref && this.getAreaFor(ref) || area; const addOptions: DockPanel.IAddOptions = {}; if (ApplicationShell.isOpenToSideMode(options?.mode)) { const areaPanel = area === 'main' ? this.mainPanel : area === 'bottom' ? this.bottomPanel : undefined; const sideRef = areaPanel && ref && (options?.mode === 'open-to-left' ? areaPanel.previousTabBarWidget(ref) : areaPanel.nextTabBarWidget(ref)); if (sideRef) { addOptions.ref = sideRef; } else { addOptions.ref = ref; addOptions.mode = options?.mode === 'open-to-left' ? 'split-left' : 'split-right'; } } else { addOptions.ref = ref; addOptions.mode = options?.mode; } return { area, addOptions }; } /** * The widgets contained in the given shell area. */ getWidgets(area: ApplicationShell.Area): Widget[] { switch (area) { case 'main': return toArray(this.mainPanel.widgets()); case 'top': return toArray(this.topPanel.widgets); case 'bottom': return toArray(this.bottomPanel.widgets()); case 'left': return toArray(this.leftPanelHandler.dockPanel.widgets()); case 'right': return toArray(this.rightPanelHandler.dockPanel.widgets()); case 'secondaryWindow': return toArray(this.secondaryWindowHandler.widgets); default: throw new Error('Illegal argument: ' + area); } } /** * Find the widget that contains the given HTML element. The returned widget may be one * that is managed by the application shell, or one that is embedded in another widget and * not directly managed by the shell, or a tab bar. */ findWidgetForElement(element: HTMLElement): Widget | undefined { let widgetNode: HTMLElement | null = element; while (widgetNode && !widgetNode.classList.contains('p-Widget')) { widgetNode = widgetNode.parentElement; } if (widgetNode) { return this.findWidgetForNode(widgetNode, this); } return undefined; } private findWidgetForNode(widgetNode: HTMLElement, widget: Widget): Widget | undefined { if (widget.node === widgetNode) { return widget; } let result: Widget | undefined; each(widget.children(), child => { result = this.findWidgetForNode(widgetNode, child); return !result; }); return result; } /** * Finds the title widget from the tab-bar. * @param tabBar used for providing an array of titles. * @returns the selected title widget, else returns the currentTitle or undefined. */ findTitle(tabBar: TabBar<Widget>, event?: Event): Title<Widget> | undefined { if (event?.target instanceof HTMLElement) { const tabNode = event.target; const titleIndex = Array.from(tabBar.contentNode.getElementsByClassName('p-TabBar-tab')) .findIndex(node => node.contains(tabNode)); if (titleIndex !== -1) { return tabBar.titles[titleIndex]; } } return tabBar.currentTitle || undefined; } /** * Finds the tab-bar widget. * @returns the selected tab-bar, else returns the currentTabBar. */ findTabBar(event?: Event): TabBar<Widget> | undefined { if (event?.target instanceof HTMLElement) { const tabBar = this.findWidgetForElement(event.target); if (tabBar instanceof TabBar) { return tabBar; } } return this.currentTabBar; } /** * @returns the widget whose title has been targeted by a DOM event on a tabbar, or undefined if none can be found. */ findTargetedWidget(event?: Event): Widget | undefined { if (event) { const tab = this.findTabBar(event); const title = tab && this.findTitle(tab, event); return title && title.owner; } } /** * The current widget in the application shell. The current widget is the last widget that * was active and not yet closed. See the remarks to `activeWidget` on what _active_ means. */ get currentWidget(): Widget | undefined { return this.tracker.currentWidget || undefined; } /** * The active widget in the application shell. The active widget is the one that has focus * (either the widget itself or any of its contents). * * _Note:_ Focus is taken by a widget through the `onActivateRequest` method. It is up to the * widget implementation which DOM element will get the focus. The default implementation * does not take any focus; in that case the widget is never returned by this property. */ get activeWidget(): Widget | undefined { return this.tracker.activeWidget || undefined; } /** * Returns the last active widget in the given shell area. */ getCurrentWidget(area: ApplicationShell.Area): Widget | undefined { let title: Title<Widget> | null | undefined; switch (area) { case 'main': title = this.mainPanel.currentTitle; break; case 'bottom': title = this.bottomPanel.currentTitle; break; case 'left': title = this.leftPanelHandler.tabBar.currentTitle; break; case 'right': title = this.rightPanelHandler.tabBar.currentTitle; break; case 'secondaryWindow': // The current widget in a secondary window is not tracked. return undefined; default: throw new Error('Illegal argument: ' + area); } return title ? title.owner : undefined; } /** * Handle a change to the current widget. */ private onCurrentChanged(sender: FocusTracker<Widget>, args: FocusTracker.IChangedArgs<Widget>): void { this.onDidChangeCurrentWidgetEmitter.fire(args); } protected readonly toDisposeOnActiveChanged = new DisposableCollection(); /** * Handle a change to the active widget. */ private onActiveChanged(sender: FocusTracker<Widget>, args: FocusTracker.IChangedArgs<Widget>): void { this.toDisposeOnActiveChanged.dispose(); const { newValue, oldValue } = args; if (oldValue) { let w: Widget | null = oldValue; while (w) { // Remove the mark of the previously active widget w.title.className = w.title.className.replace(' theia-mod-active', ''); w = w.parent; } // Reset the z-index to the default // eslint-disable-next-line no-null/no-null this.setZIndex(oldValue.node, null); } if (newValue) { let w: Widget | null = newValue; while (w) { // Mark the tab of the active widget w.title.className += ' theia-mod-active'; w = w.parent; } // Reveal the title of the active widget in its tab bar const tabBar = this.getTabBarFor(newValue); if (tabBar instanceof ScrollableTabBar) { const index = tabBar.titles.indexOf(newValue.title); if (index >= 0) { tabBar.revealTab(index); } } const widget = this.toTrackedStack(newValue.id).pop(); const panel = this.findPanel(widget); if (panel) { // if widget was undefined, we wouldn't have gotten a panel back before panel.markAsCurrent(widget!.title); } // Add checks to ensure that the 'sash' for left panel is displayed correctly if (newValue.node.className === 'p-Widget theia-view-container p-DockPanel-widget') { // Set the z-index so elements with `position: fixed` contained in the active widget are displayed correctly this.setZIndex(newValue.node, '1'); } // activate another widget if an active widget will be closed const onCloseRequest = newValue['onCloseRequest']; newValue['onCloseRequest'] = msg => { const currentTabBar = this.currentTabBar; if (currentTabBar) { const recentlyUsedInTabBar = currentTabBar['_previousTitle'] as TabBar<Widget>['currentTitle']; if (recentlyUsedInTabBar && recentlyUsedInTabBar.owner !== newValue) { currentTabBar.currentIndex = ArrayExt.firstIndexOf(currentTabBar.titles, recentlyUsedInTabBar); if (currentTabBar.currentTitle) { this.activateWidget(currentTabBar.currentTitle.owner.id); } } else if (!this.activateNextTabInTabBar(currentTabBar)) { if (!this.activatePreviousTabBar(currentTabBar)) {