@theia/core
Version:
Theia is a cloud & desktop IDE framework implemented in TypeScript.
1,194 lines (1,087 loc) • 88 kB
text/typescript
// *****************************************************************************
// 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)) {