@theia/core
Version:
Theia is a cloud & desktop IDE framework implemented in TypeScript.
626 lines (535 loc) • 24.4 kB
text/typescript
// *****************************************************************************
// Copyright (C) 2026 EclipseSource GmbH.
//
// 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, named, optional } from 'inversify';
import { DockLayout } from '@lumino/widgets';
import { ApplicationShell, WidgetAreaResolver } from './shell/application-shell';
import { SidePanel } from './shell/side-panel-handler';
import { FrontendApplicationContribution } from './frontend-application-contribution';
import { AbstractViewContribution } from './shell/view-contribution';
import { WidgetManager } from './widget-manager';
import { ContributionProvider } from '../common/contribution-provider';
import { Command, CommandContribution, CommandRegistry } from '../common/command';
import { Emitter, Event } from '../common/event';
import { ILogger } from '../common/logger';
import { QuickInputService, QuickPickItem } from '../common/quick-pick-service';
import { nls } from '../common/nls';
import { CommonCommands } from './common-commands';
import { ContextKeyService } from './context-key-service';
export const ACTIVE_PERSPECTIVE_CONTEXT_KEY = 'activePerspectiveId';
export interface PerspectiveChromeOptions {
/** Areas to collapse on first activation. User can re-expand freely. */
collapseAreas?: ('left' | 'right' | 'bottom')[];
}
export interface PerspectiveDescriptor {
id: string;
label: string;
/** Widget/view-container ID → target shell area */
viewPlacements: Map<string, ApplicationShell.Area>;
/** Chrome control options for this perspective */
chromeOptions?: PerspectiveChromeOptions;
/** Per-area primary view: the widget to activate last in each shell area, so it gets focus. */
primaryViews?: Partial<Record<ApplicationShell.Area, string>>;
/** Called when perspective is activated */
onActivate?(shell: ApplicationShell): void;
/** Called when switching away */
onDeactivate?(shell: ApplicationShell): void;
}
export const PerspectiveService = Symbol('PerspectiveService');
export interface PerspectiveService {
// --- High-level API (for general consumers) ---
/** Event fired whenever the active perspective changes. The payload is the new perspective ID. */
readonly onDidChangePerspective: Event<string>;
/**
* Registers a new perspective descriptor.
* Contributions should call this from their `PerspectiveContribution.registerPerspectives` callback.
*/
registerPerspective(descriptor: PerspectiveDescriptor): void;
/**
* Switches the workbench to the given perspective.
* Saves the current perspective's layout, then restores the target perspective's layout
* (or applies its `viewPlacements` on first activation). Concurrent calls are serialized.
*/
switchPerspective(id: string): Promise<void>;
/** Returns the descriptor of the currently active perspective, or `undefined` if none. */
getActivePerspective(): PerspectiveDescriptor | undefined;
/** Returns the target shell area for a widget in the active perspective, or `undefined` if unmapped. */
getAreaForView(viewId: string): ApplicationShell.Area | undefined;
/** Returns all registered perspective descriptors. */
getRegisteredPerspectives(): PerspectiveDescriptor[];
/** Returns the ID of the currently active perspective. A convenient alternative to `getActivePerspective()?.id` that always returns a string. */
getActivePerspectiveId(): string;
/**
* Resets the active perspective to its programmatic state (viewPlacements from its descriptor),
* discarding the saved layout. Does not affect other perspectives.
*/
resetCurrentPerspective(): Promise<void>;
}
export const PerspectiveServiceInternal = Symbol('PerspectiveServiceInternal');
/**
* Internal plumbing API for layout persistence infrastructure.
* General consumers should use {@link PerspectiveService} instead.
*/
export interface PerspectiveServiceInternal {
/**
* Returns the ID of the currently active perspective.
* @internal Plumbing API, used by `ShellLayoutRestorer` for layout persistence.
*/
getActivePerspectiveId(): string;
/**
* Returns the IDs of all perspectives that have a saved (in-memory) layout snapshot.
* @internal Plumbing API, used by `ShellLayoutRestorer` for layout persistence.
*/
getSavedPerspectiveIds(): string[];
/**
* Returns the saved in-memory layout for a perspective, or `undefined` if none exists.
* @internal Plumbing API, used by `ShellLayoutRestorer` for layout persistence.
*/
getSavedLayout(perspectiveId: string): ApplicationShell.LayoutData | undefined;
/**
* Stores an in-memory layout snapshot for a perspective.
* @internal Plumbing API, used by `ShellLayoutRestorer` for layout persistence.
*/
setSavedLayout(perspectiveId: string, layout: ApplicationShell.LayoutData): void;
/**
* Sets the active perspective ID (without triggering a switch).
* Returns `true` if the ID corresponds to a registered perspective, `false` otherwise.
* @internal Plumbing API, used by `ShellLayoutRestorer` during layout restore.
*/
setActivePerspectiveId(id: string): boolean;
/**
* The ID of the built-in default perspective.
* @internal Plumbing API, used by `ShellLayoutRestorer` for legacy migration.
*/
readonly defaultPerspectiveId: string;
/**
* Clears all saved in-memory layout snapshots.
* @internal Plumbing API, used by `ShellLayoutRestorer` during layout reset.
*/
clearSavedLayouts(): void;
/**
* Called by `ShellLayoutRestorer` after restoring a persisted layout to apply chrome options
* (e.g., status bar visibility) for the given perspective.
* @internal Plumbing API, used by `ShellLayoutRestorer` after layout restore.
*/
onLayoutRestored(activePerspectiveId: string): void;
}
export const PerspectiveContribution = Symbol('PerspectiveContribution');
export interface PerspectiveContribution {
registerPerspectives(service: PerspectiveService): void;
}
export class WidgetAreaResolverImpl implements WidgetAreaResolver {
protected activePlacementMap = new Map<string, ApplicationShell.Area>();
setActivePlacementMap(map: Map<string, ApplicationShell.Area>): void {
this.activePlacementMap = map;
}
resolveArea(widgetId: string, requestedArea: ApplicationShell.Area): ApplicationShell.Area | undefined {
return this.activePlacementMap.get(widgetId);
}
}
export class PerspectiveServiceImpl implements FrontendApplicationContribution, CommandContribution, PerspectiveService, PerspectiveServiceInternal {
static readonly SWITCH_PERSPECTIVE_COMMAND = Command.toLocalizedCommand({
id: 'perspective.switch',
category: CommonCommands.VIEW_CATEGORY,
label: 'Switch Perspective (Experimental)'
}, 'theia/core/perspective/switchPerspective', CommonCommands.VIEW_CATEGORY_KEY);
static readonly RESET_PERSPECTIVE_COMMAND = Command.toLocalizedCommand({
id: 'perspective.reset',
category: CommonCommands.VIEW_CATEGORY,
label: 'Reset Perspective (Experimental)'
}, 'theia/core/perspective/resetPerspective', CommonCommands.VIEW_CATEGORY_KEY);
protected readonly shell: ApplicationShell;
protected readonly widgetManager: WidgetManager;
protected readonly contributions: ContributionProvider<PerspectiveContribution> | undefined;
protected readonly appContributions: ContributionProvider<FrontendApplicationContribution>;
protected readonly quickInputService: QuickInputService | undefined;
protected readonly logger: ILogger;
protected readonly widgetAreaResolver: WidgetAreaResolver;
protected readonly contextKeyService: ContextKeyService | undefined;
static readonly DEFAULT_PERSPECTIVE_ID = 'default';
readonly defaultPerspectiveId = PerspectiveServiceImpl.DEFAULT_PERSPECTIVE_ID;
protected readonly perspectives = new Map<string, PerspectiveDescriptor>();
protected activePerspectiveId: string | undefined;
protected readonly savedLayouts = new Map<string, ApplicationShell.LayoutData>();
protected readonly onDidChangePerspectiveEmitter = new Emitter<string>();
readonly onDidChangePerspective: Event<string> = this.onDidChangePerspectiveEmitter.event;
protected switchInProgress: Promise<void> | undefined;
onLayoutRestored(activePerspectiveId: string): void {
this.updateActivePerspectiveContextKey(activePerspectiveId);
}
initialize(): void {
this.registerPerspective({
id: PerspectiveServiceImpl.DEFAULT_PERSPECTIVE_ID,
label: nls.localizeByDefault('Default'),
viewPlacements: new Map()
});
this.activePerspectiveId = PerspectiveServiceImpl.DEFAULT_PERSPECTIVE_ID;
this.updateActivePerspectiveContextKey(PerspectiveServiceImpl.DEFAULT_PERSPECTIVE_ID);
if (this.contributions) {
for (const contribution of this.contributions.getContributions()) {
contribution.registerPerspectives(this);
}
}
}
onStop(): void {
this.onDidChangePerspectiveEmitter.dispose();
}
protected updateActivePerspectiveContextKey(id: string): void {
this.contextKeyService?.createKey<string>(ACTIVE_PERSPECTIVE_CONTEXT_KEY, id).set(id);
}
registerPerspective(descriptor: PerspectiveDescriptor): void {
this.perspectives.set(descriptor.id, descriptor);
}
async switchPerspective(id: string): Promise<void> {
const pending = (this.switchInProgress ?? Promise.resolve())
.catch(() => { /* swallow previous rejection so next switch can proceed */ })
.then(() => this.doSwitchPerspective(id));
this.switchInProgress = pending;
try {
await pending;
} finally {
if (this.switchInProgress === pending) {
this.switchInProgress = undefined;
}
}
}
protected async doSwitchPerspective(id: string): Promise<void> {
if (id === this.activePerspectiveId) {
return;
}
const descriptor = this.perspectives.get(id);
if (!descriptor) {
return;
}
const oldPerspective = this.getActivePerspective();
if (oldPerspective?.onDeactivate) {
oldPerspective.onDeactivate(this.shell);
}
if (this.activePerspectiveId) {
this.savedLayouts.set(this.activePerspectiveId, this.shell.getLayoutData());
}
this.activePerspectiveId = id;
this.widgetAreaResolver.setActivePlacementMap(descriptor.viewPlacements);
try {
const savedLayout = this.savedLayouts.get(id);
if (savedLayout) {
const layoutWidgetIds = this.collectWidgetIds(savedLayout);
await this.healLayoutData(savedLayout);
await this.shell.setLayoutData(savedLayout);
this.detachStrayWidgets(layoutWidgetIds);
} else {
await this.applyViewPlacements(descriptor);
}
} catch (error) {
this.logger.warn('Failed to apply layout for perspective', error);
}
if (descriptor.onActivate) {
descriptor.onActivate(this.shell);
}
this.updateActivePerspectiveContextKey(id);
this.onDidChangePerspectiveEmitter.fire(id);
}
protected async applyViewPlacements(descriptor: PerspectiveDescriptor): Promise<void> {
for (const [viewId, area] of descriptor.viewPlacements) {
try {
const widget = await this.widgetManager.getOrCreateWidget(viewId);
const currentTabBar = this.shell.getTabBarFor(widget);
if (currentTabBar) {
const currentArea = this.shell.getAreaFor(widget);
if (currentArea === area) {
continue;
}
}
await this.shell.addWidget(widget, { area });
} catch (error) {
this.logger.warn('Failed to create or place widget for perspective', error);
}
}
for (const [viewId] of descriptor.viewPlacements) {
try {
await this.shell.activateWidget(viewId);
} catch (error) {
this.logger.warn('Failed to activate widget for perspective', error);
}
}
if (descriptor.primaryViews) {
for (const viewId of Object.values(descriptor.primaryViews)) {
if (viewId) {
try {
await this.shell.activateWidget(viewId);
} catch (error) {
this.logger.warn('Failed to activate primary view for perspective', error);
}
}
}
}
if (descriptor.chromeOptions?.collapseAreas) {
for (const area of descriptor.chromeOptions.collapseAreas) {
await this.shell.collapsePanel(area);
}
}
}
async resetCurrentPerspective(): Promise<void> {
const pending = (this.switchInProgress ?? Promise.resolve())
.catch(() => { /* swallow previous rejection so next reset can proceed */ })
.then(() => this.doResetCurrentPerspective());
this.switchInProgress = pending;
try {
await pending;
} finally {
if (this.switchInProgress === pending) {
this.switchInProgress = undefined;
}
}
}
protected async doResetCurrentPerspective(): Promise<void> {
const descriptor = this.getActivePerspective();
if (!descriptor) {
return;
}
this.savedLayouts.delete(descriptor.id);
try {
if (descriptor.id === PerspectiveServiceImpl.DEFAULT_PERSPECTIVE_ID) {
await this.resetDefaultPerspective();
} else {
await this.applyViewPlacements(descriptor);
}
} catch (error) {
this.logger.warn('Failed to apply layout for perspective', error);
}
if (descriptor.onActivate) {
descriptor.onActivate(this.shell);
}
this.onDidChangePerspectiveEmitter.fire(descriptor.id);
}
protected async resetDefaultPerspective(): Promise<void> {
// Phase 1: Relocate open views to their default areas
for (const contribution of this.appContributions.getContributions()) {
if (contribution instanceof AbstractViewContribution) {
const widgetId = contribution.effectiveWidgetId;
const targetArea = contribution.defaultViewOptions?.area;
if (!widgetId || !targetArea) {
continue;
}
const widget = this.widgetManager.tryGetWidget(widgetId);
if (widget && !widget.isDisposed && widget.isAttached) {
const currentArea = this.shell.getAreaFor(widget);
if (currentArea && currentArea !== targetArea) {
try {
await this.shell.addWidget(widget, { area: targetArea });
} catch (error) {
this.logger.warn('Failed to relocate widget during default perspective reset', widgetId, error);
}
}
}
}
}
// Phase 2: Re-open closed views via openView({ activate: false }).
// Non-view contributions (e.g., Terminal) are skipped to avoid side effects
// like creating new terminal instances.
for (const contribution of this.appContributions.getContributions()) {
if (contribution instanceof AbstractViewContribution) {
try {
await contribution.openView({ activate: false });
} catch (error) {
this.logger.warn('Failed to open view during default perspective reset', error);
}
}
}
}
/**
* Views may be shared between multiple perspective. If a view exists in two or more perspectives
* and is closed in one of them, the shared widget is disposed. When switching back to the other
* perspective, we need to restore this widget (i.e. reopen the view).
* @param layout
*/
protected async healLayoutData(layout: ApplicationShell.LayoutData): Promise<void> {
if (layout.leftPanel) {
await this.healSidePanelLayout(layout.leftPanel);
}
if (layout.rightPanel) {
await this.healSidePanelLayout(layout.rightPanel);
}
if (layout.mainPanel?.main) {
await this.healDockAreaConfig(layout.mainPanel.main);
}
if (layout.bottomPanel?.config?.main) {
await this.healDockAreaConfig(layout.bottomPanel.config.main);
}
}
protected async healSidePanelLayout(layout: SidePanel.LayoutData): Promise<void> {
if (!layout.items) {
return;
}
for (const item of layout.items) {
if (item.widget?.isDisposed) {
try {
item.widget = await this.widgetManager.getOrCreateWidget(item.widget.id);
} catch (error) {
this.logger.warn('Failed to recreate disposed widget for perspective layout', item.widget.id, error);
item.widget = undefined;
}
}
}
}
protected async healDockAreaConfig(config: DockLayout.ITabAreaConfig | DockLayout.ISplitAreaConfig): Promise<void> {
if (config.type === 'tab-area') {
for (let i = 0; i < config.widgets.length; i++) {
const widget = config.widgets[i];
if (widget.isDisposed) {
try {
config.widgets[i] = await this.widgetManager.getOrCreateWidget(widget.id);
} catch (error) {
this.logger.warn('Failed to recreate disposed widget for perspective layout', widget.id, error);
config.widgets.splice(i, 1);
i--;
}
}
}
} else if (config.type === 'split-area') {
for (const child of config.children) {
await this.healDockAreaConfig(child);
}
}
}
protected collectWidgetIds(layout: ApplicationShell.LayoutData): Set<string> {
const ids = new Set<string>();
if (layout.leftPanel) {
this.collectSidePanelWidgetIds(layout.leftPanel, ids);
}
if (layout.rightPanel) {
this.collectSidePanelWidgetIds(layout.rightPanel, ids);
}
if (layout.mainPanel?.main) {
this.collectDockWidgetIds(layout.mainPanel.main, ids);
}
if (layout.bottomPanel?.config?.main) {
this.collectDockWidgetIds(layout.bottomPanel.config.main, ids);
}
return ids;
}
protected collectSidePanelWidgetIds(layout: SidePanel.LayoutData, ids: Set<string>): void {
if (layout.items) {
for (const item of layout.items) {
if (item.widget) {
ids.add(item.widget.id);
}
}
}
}
protected collectDockWidgetIds(
config: DockLayout.ITabAreaConfig | DockLayout.ISplitAreaConfig,
ids: Set<string>
): void {
if (config.type === 'tab-area') {
for (const widget of config.widgets) {
if (widget) {
ids.add(widget.id);
}
}
} else if (config.type === 'split-area') {
for (const child of config.children) {
this.collectDockWidgetIds(child, ids);
}
}
}
protected detachStrayWidgets(layoutWidgetIds: Set<string>): void {
const sidePanelAreas: ApplicationShell.Area[] = ['left', 'right'];
for (const area of sidePanelAreas) {
for (const widget of this.shell.getWidgets(area)) {
if (!layoutWidgetIds.has(widget.id)) {
// Detach via parent = null rather than widget.close() to avoid
// disposing the widget — it may be needed by another perspective.
// eslint-disable-next-line no-null/no-null
widget.parent = null;
}
}
}
}
getActivePerspective(): PerspectiveDescriptor | undefined {
if (this.activePerspectiveId) {
return this.perspectives.get(this.activePerspectiveId);
}
return undefined;
}
getAreaForView(viewId: string): ApplicationShell.Area | undefined {
const active = this.getActivePerspective();
if (active) {
return active.viewPlacements.get(viewId);
}
return undefined;
}
getRegisteredPerspectives(): PerspectiveDescriptor[] {
return Array.from(this.perspectives.values());
}
getActivePerspectiveId(): string {
return this.activePerspectiveId ?? PerspectiveServiceImpl.DEFAULT_PERSPECTIVE_ID;
}
getSavedPerspectiveIds(): string[] {
return Array.from(this.savedLayouts.keys());
}
getSavedLayout(perspectiveId: string): ApplicationShell.LayoutData | undefined {
return this.savedLayouts.get(perspectiveId);
}
setSavedLayout(perspectiveId: string, layout: ApplicationShell.LayoutData): void {
this.savedLayouts.set(perspectiveId, layout);
}
setActivePerspectiveId(id: string): boolean {
if (this.perspectives.has(id)) {
this.activePerspectiveId = id;
return true;
}
return false;
}
clearSavedLayouts(): void {
this.savedLayouts.clear();
}
registerCommands(commands: CommandRegistry): void {
commands.registerCommand(PerspectiveServiceImpl.SWITCH_PERSPECTIVE_COMMAND, {
execute: () => this.showPerspectivePicker(),
isVisible: () => this.perspectives.size > 1
});
commands.registerCommand(PerspectiveServiceImpl.RESET_PERSPECTIVE_COMMAND, {
execute: () => this.resetCurrentPerspective(),
isEnabled: () => this.activePerspectiveId !== undefined
});
}
protected async showPerspectivePicker(): Promise<void> {
if (!this.quickInputService) {
return;
}
const items: QuickPickItem[] = this.getRegisteredPerspectives().map(p => ({
label: p.label,
id: p.id,
description: this.activePerspectiveId === p.id ? nls.localizeByDefault('Active') : undefined
}));
const selected = await this.quickInputService.showQuickPick(items, {
placeholder: nls.localize('theia/core/perspective/selectPerspective', 'Select a perspective')
});
if (selected?.id) {
await this.switchPerspective(selected.id);
}
}
}