From be6e41f20b4af980b325d9cdefea6f0bc1f3b41f Mon Sep 17 00:00:00 2001 From: Camille Letavernier Date: Tue, 7 Jul 2026 09:35:08 +0200 Subject: [PATCH 1/9] feat: add a perspective service + AI First perspective --- .../ai-first-perspective-contribution.ts | 39 +++ .../ai-ide/src/browser/frontend-module.ts | 5 + .../browser/frontend-application-module.ts | 6 + packages/core/src/browser/index.ts | 1 + .../src/browser/perspective-service.spec.ts | 258 ++++++++++++++++++ .../core/src/browser/perspective-service.ts | 171 ++++++++++++ .../src/browser/shell/view-contribution.ts | 6 + 7 files changed, 486 insertions(+) create mode 100644 packages/ai-ide/src/browser/ai-first-perspective-contribution.ts create mode 100644 packages/core/src/browser/perspective-service.spec.ts create mode 100644 packages/core/src/browser/perspective-service.ts diff --git a/packages/ai-ide/src/browser/ai-first-perspective-contribution.ts b/packages/ai-ide/src/browser/ai-first-perspective-contribution.ts new file mode 100644 index 0000000000000..e96e4cd4b556e --- /dev/null +++ b/packages/ai-ide/src/browser/ai-first-perspective-contribution.ts @@ -0,0 +1,39 @@ +// ***************************************************************************** +// 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 { injectable } from '@theia/core/shared/inversify'; +import { PerspectiveContribution, PerspectiveService } from '@theia/core/lib/browser/perspective-service'; +import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell'; + +const CHAT_VIEW_WIDGET_ID = 'chat-view-widget'; +const EXPLORER_VIEW_CONTAINER_ID = 'explorer-view-container'; +const SCM_VIEW_CONTAINER_ID = 'scm-view-container'; + +@injectable() +export class AIFirstPerspectiveContribution implements PerspectiveContribution { + + registerPerspectives(service: PerspectiveService): void { + service.registerPerspective({ + id: 'ai-first', + label: 'AI First', + viewPlacements: new Map([ + [CHAT_VIEW_WIDGET_ID, 'main'], + [EXPLORER_VIEW_CONTAINER_ID, 'right'], + [SCM_VIEW_CONTAINER_ID, 'right'] + ]) + }); + } +} diff --git a/packages/ai-ide/src/browser/frontend-module.ts b/packages/ai-ide/src/browser/frontend-module.ts index 275fbdd97ac78..8c10680caf98b 100644 --- a/packages/ai-ide/src/browser/frontend-module.ts +++ b/packages/ai-ide/src/browser/frontend-module.ts @@ -125,6 +125,8 @@ import { CodeReviewerAgent } from './code-reviewer-agent'; import { CodeReviewCapabilityContribution } from './code-review-capability-contribution'; import { PRReviewAgent } from './review/pr-review-agent'; import { PRReviewCapabilityContribution } from './review/pr-review-capability-contribution'; +import { PerspectiveContribution } from '@theia/core/lib/browser/perspective-service'; +import { AIFirstPerspectiveContribution } from './ai-first-perspective-contribution'; export default new ContainerModule((bind, _unbind, _isBound, rebind) => { bind(PreferenceContribution).toConstantValue({ schema: aiIdePreferenceSchema }); @@ -350,4 +352,7 @@ export default new ContainerModule((bind, _unbind, _isBound, rebind) => { bind(FrontendApplicationContribution).to(CodeReviewCapabilityContribution); bind(FrontendApplicationContribution).to(PRReviewCapabilityContribution); + + bind(AIFirstPerspectiveContribution).toSelf().inSingletonScope(); + bind(PerspectiveContribution).toService(AIFirstPerspectiveContribution); }); diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index 3211cfa0d17ad..cdf87a4d18add 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -146,6 +146,7 @@ import { WidgetStatusBarContribution, WidgetStatusBarService } from './widget-st import { SymbolIconColorContribution } from './symbol-icon-color-contribution'; import { CorePreferences, bindCorePreferences } from '../common/core-preferences'; import { bindBadgeDecoration } from './badges'; +import { PerspectiveContribution, PerspectiveService } from './perspective-service'; export { bindResourceProvider, bindMessageService, bindPreferenceService }; @@ -483,6 +484,11 @@ export const frontendApplicationModule = new ContainerModule((bind, _unbind, _is bind(DomInputUndoRedoHandler).toSelf().inSingletonScope(); bind(UndoRedoHandler).toService(DomInputUndoRedoHandler); + bind(PerspectiveService).toSelf().inSingletonScope(); + bind(FrontendApplicationContribution).toService(PerspectiveService); + bind(CommandContribution).toService(PerspectiveService); + bindRootContributionProvider(bind, PerspectiveContribution); + bind(WidgetStatusBarService).toSelf().inSingletonScope(); bind(FrontendApplicationContribution).toService(WidgetStatusBarService); bindRootContributionProvider(bind, WidgetStatusBarContribution); diff --git a/packages/core/src/browser/index.ts b/packages/core/src/browser/index.ts index c17cdcf10988c..b0f91dbbf7176 100644 --- a/packages/core/src/browser/index.ts +++ b/packages/core/src/browser/index.ts @@ -55,3 +55,4 @@ export * from './markdown-rendering/markdown-renderer'; export * from './markdown-rendering/markdown'; export * from './markdown-rendering/markdown-link-handler'; export * from './components'; +export * from './perspective-service'; diff --git a/packages/core/src/browser/perspective-service.spec.ts b/packages/core/src/browser/perspective-service.spec.ts new file mode 100644 index 0000000000000..b02bfbafb437e --- /dev/null +++ b/packages/core/src/browser/perspective-service.spec.ts @@ -0,0 +1,258 @@ +// ***************************************************************************** +// 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 { enableJSDOM } from './test/jsdom'; +const disableJSDOM = enableJSDOM(); + +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { PerspectiveService, PerspectiveDescriptor } from './perspective-service'; +import { ApplicationShell } from './shell/application-shell'; +import { Widget } from '@lumino/widgets'; + +disableJSDOM(); + +describe('PerspectiveService', () => { + let service: PerspectiveService; + let addWidgetStub: sinon.SinonStub; + let activateWidgetStub: sinon.SinonStub; + let getTabBarForStub: sinon.SinonStub; + let getAreaForStub: sinon.SinonStub; + let getOrCreateWidgetStub: sinon.SinonStub; + let testWidget: Widget; + let toTearDown: () => void; + + beforeEach(() => { + toTearDown = enableJSDOM(); + service = new PerspectiveService(); + testWidget = new Widget(); + testWidget.id = 'test-widget'; + + addWidgetStub = sinon.stub().resolves(); + activateWidgetStub = sinon.stub().resolves(undefined); + getTabBarForStub = sinon.stub().returns(undefined); + getAreaForStub = sinon.stub().returns(undefined); + getOrCreateWidgetStub = sinon.stub().resolves(testWidget); + + const mockShell = { + addWidget: addWidgetStub, + activateWidget: activateWidgetStub, + getTabBarFor: getTabBarForStub, + getAreaFor: getAreaForStub + }; + + const mockWidgetManager = { + getOrCreateWidget: getOrCreateWidgetStub + }; + + // Assign mocks via property access since we can't use DI in tests + (service as unknown as Record)['shell'] = mockShell; + (service as unknown as Record)['widgetManager'] = mockWidgetManager; + }); + + afterEach(() => { + sinon.restore(); + toTearDown(); + }); + + it('should register a perspective', () => { + const descriptor: PerspectiveDescriptor = { + id: 'test', + label: 'Test', + viewPlacements: new Map([['widget-a', 'main' as ApplicationShell.Area]]) + }; + + service.registerPerspective(descriptor); + + const perspectives = service.getRegisteredPerspectives(); + expect(perspectives).to.have.lengthOf(1); + expect(perspectives[0].id).to.equal('test'); + expect(perspectives[0].label).to.equal('Test'); + }); + + it('should register multiple perspectives', () => { + service.registerPerspective({ + id: 'perspective-1', + label: 'Perspective 1', + viewPlacements: new Map() + }); + service.registerPerspective({ + id: 'perspective-2', + label: 'Perspective 2', + viewPlacements: new Map() + }); + + expect(service.getRegisteredPerspectives()).to.have.lengthOf(2); + }); + + it('should return undefined for active perspective when none is set', () => { + expect(service.getActivePerspective()).to.be.undefined; + }); + + it('should return undefined from getAreaForView when no perspective is active', () => { + service.registerPerspective({ + id: 'test', + label: 'Test', + viewPlacements: new Map([['widget-a', 'main' as ApplicationShell.Area]]) + }); + + expect(service.getAreaForView('widget-a')).to.be.undefined; + }); + + it('should return the override area when a perspective is active', async () => { + service.registerPerspective({ + id: 'test', + label: 'Test', + viewPlacements: new Map([['widget-a', 'main' as ApplicationShell.Area]]) + }); + + await service.switchPerspective('test'); + + expect(service.getAreaForView('widget-a')).to.equal('main'); + }); + + it('should return undefined for views not in the perspective placement map', async () => { + service.registerPerspective({ + id: 'test', + label: 'Test', + viewPlacements: new Map([['widget-a', 'main' as ApplicationShell.Area]]) + }); + + await service.switchPerspective('test'); + + expect(service.getAreaForView('widget-b')).to.be.undefined; + }); + + it('should switch perspectives and update the active perspective', async () => { + service.registerPerspective({ + id: 'first', + label: 'First', + viewPlacements: new Map() + }); + service.registerPerspective({ + id: 'second', + label: 'Second', + viewPlacements: new Map() + }); + + await service.switchPerspective('first'); + expect(service.getActivePerspective()?.id).to.equal('first'); + + await service.switchPerspective('second'); + expect(service.getActivePerspective()?.id).to.equal('second'); + }); + + it('should fire onDidChangePerspective event when switching', async () => { + service.registerPerspective({ + id: 'test', + label: 'Test', + viewPlacements: new Map() + }); + + const spy = sinon.spy(); + service.onDidChangePerspective(spy); + + await service.switchPerspective('test'); + + expect(spy.calledOnce).to.be.true; + expect(spy.calledWith('test')).to.be.true; + }); + + it('should not switch to a non-existent perspective', async () => { + const spy = sinon.spy(); + service.onDidChangePerspective(spy); + + await service.switchPerspective('nonexistent'); + + expect(spy.called).to.be.false; + expect(service.getActivePerspective()).to.be.undefined; + }); + + it('should call onDeactivate on old perspective and onActivate on new perspective', async () => { + const onDeactivate = sinon.spy(); + const onActivate = sinon.spy(); + + service.registerPerspective({ + id: 'old', + label: 'Old', + viewPlacements: new Map(), + onDeactivate + }); + service.registerPerspective({ + id: 'new', + label: 'New', + viewPlacements: new Map(), + onActivate + }); + + await service.switchPerspective('old'); + await service.switchPerspective('new'); + + expect(onDeactivate.calledOnce).to.be.true; + expect(onActivate.calledOnce).to.be.true; + expect(onDeactivate.calledBefore(onActivate)).to.be.true; + }); + + it('should add widgets to the target area during switch', async () => { + service.registerPerspective({ + id: 'test', + label: 'Test', + viewPlacements: new Map([['test-widget', 'main' as ApplicationShell.Area]]) + }); + + await service.switchPerspective('test'); + + expect(getOrCreateWidgetStub.calledWith('test-widget')).to.be.true; + expect(addWidgetStub.calledOnce).to.be.true; + expect(addWidgetStub.calledWith(testWidget, sinon.match({ area: 'main' }))).to.be.true; + }); + + it('should skip adding widget if already in the correct area', async () => { + getTabBarForStub.returns({}); + getAreaForStub.returns('main'); + + service.registerPerspective({ + id: 'test', + label: 'Test', + viewPlacements: new Map([['test-widget', 'main' as ApplicationShell.Area]]) + }); + + await service.switchPerspective('test'); + + expect(addWidgetStub.called).to.be.false; + }); + + it('should call initialize and register contributions', () => { + const mockContribution = { + registerPerspectives: sinon.spy() + }; + + (service as unknown as Record)['contributions'] = { + getContributions: () => [mockContribution] + }; + + service.initialize(); + + expect(mockContribution.registerPerspectives.calledOnce).to.be.true; + expect(mockContribution.registerPerspectives.calledWith(service)).to.be.true; + }); + + it('should handle initialize with no contributions', () => { + (service as unknown as Record)['contributions'] = undefined; + + expect(() => service.initialize()).to.not.throw(); + }); +}); diff --git a/packages/core/src/browser/perspective-service.ts b/packages/core/src/browser/perspective-service.ts new file mode 100644 index 0000000000000..ca438e9cdf0a8 --- /dev/null +++ b/packages/core/src/browser/perspective-service.ts @@ -0,0 +1,171 @@ +// ***************************************************************************** +// 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 { ApplicationShell } from './shell/application-shell'; +import { FrontendApplicationContribution } from './frontend-application-contribution'; +import { WidgetManager } from './widget-manager'; +import { ContributionProvider } from '../common/contribution-provider'; +import { CommandContribution, CommandRegistry } from '../common/command'; +import { Emitter, Event } from '../common/event'; +import { QuickInputService, QuickPickItem } from '../common/quick-pick-service'; +import { nls } from '../common/nls'; + +export interface PerspectiveDescriptor { + id: string; + label: string; + /** Widget/view-container ID → target shell area */ + viewPlacements: Map; + /** Called when perspective is activated */ + onActivate?(shell: ApplicationShell): void; + /** Called when switching away */ + onDeactivate?(shell: ApplicationShell): void; +} + +export const PerspectiveContribution = Symbol('PerspectiveContribution'); +export interface PerspectiveContribution { + registerPerspectives(service: PerspectiveService): void; +} + +@injectable() +export class PerspectiveService implements FrontendApplicationContribution, CommandContribution { + + static readonly SWITCH_PERSPECTIVE_COMMAND = { + id: 'perspective.switch', + category: nls.localizeByDefault('View'), + label: 'Switch Perspective' + }; + + @inject(ApplicationShell) + protected readonly shell: ApplicationShell; + + @inject(WidgetManager) + protected readonly widgetManager: WidgetManager; + + @inject(ContributionProvider) @named(PerspectiveContribution) @optional() + protected readonly contributions: ContributionProvider | undefined; + + @inject(QuickInputService) @optional() + protected readonly quickInputService: QuickInputService | undefined; + + protected readonly perspectives = new Map(); + protected activePerspectiveId: string | undefined; + + protected readonly onDidChangePerspectiveEmitter = new Emitter(); + readonly onDidChangePerspective: Event = this.onDidChangePerspectiveEmitter.event; + + initialize(): void { + if (this.contributions) { + for (const contribution of this.contributions.getContributions()) { + contribution.registerPerspectives(this); + } + } + } + + registerPerspective(descriptor: PerspectiveDescriptor): void { + this.perspectives.set(descriptor.id, descriptor); + } + + async switchPerspective(id: string): Promise { + const descriptor = this.perspectives.get(id); + if (!descriptor) { + return; + } + + const oldPerspective = this.getActivePerspective(); + if (oldPerspective?.onDeactivate) { + oldPerspective.onDeactivate(this.shell); + } + + this.activePerspectiveId = id; + + 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 { + // Widget factory may not be registered — skip silently + } + } + + for (const [viewId] of descriptor.viewPlacements) { + try { + await this.shell.activateWidget(viewId); + } catch { + // Ignore activation errors + } + } + + if (descriptor.onActivate) { + descriptor.onActivate(this.shell); + } + + this.onDidChangePerspectiveEmitter.fire(id); + } + + 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()); + } + + registerCommands(commands: CommandRegistry): void { + commands.registerCommand(PerspectiveService.SWITCH_PERSPECTIVE_COMMAND, { + execute: () => this.showPerspectivePicker(), + isEnabled: () => this.perspectives.size > 0 + }); + } + + protected async showPerspectivePicker(): Promise { + 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: 'Select a perspective' + }); + + if (selected?.id) { + await this.switchPerspective(selected.id); + } + } +} diff --git a/packages/core/src/browser/shell/view-contribution.ts b/packages/core/src/browser/shell/view-contribution.ts index 6229664dacbea..9e0ad468fa915 100644 --- a/packages/core/src/browser/shell/view-contribution.ts +++ b/packages/core/src/browser/shell/view-contribution.ts @@ -25,6 +25,7 @@ import { WidgetManager } from '../widget-manager'; import { CommonMenus } from '../common-menus'; import { ApplicationShell } from './application-shell'; import { QuickViewService } from '../quick-input'; +import { PerspectiveService } from '../perspective-service'; export interface OpenViewArguments extends ApplicationShell.WidgetOptions { toggle?: boolean @@ -61,6 +62,9 @@ export abstract class AbstractViewContribution implements Comm @inject(QuickViewService) @optional() protected readonly quickView: QuickViewService; + @inject(PerspectiveService) @optional() + protected readonly perspectiveService: PerspectiveService; + readonly toggleCommand?: Command; constructor( @@ -102,8 +106,10 @@ export abstract class AbstractViewContribution implements Comm const area = shell.getAreaFor(widget); if (!tabBar) { // The widget is not attached yet, so add it to the shell + const perspectiveArea = this.perspectiveService?.getAreaForView(this.options.viewContainerId || this.viewId); const widgetArgs: OpenViewArguments = { ...this.defaultViewOptions, + ...perspectiveArea ? { area: perspectiveArea } : {}, ...args }; await shell.addWidget(widget, widgetArgs); From 3a97354f7b5cd724854912b8bb2e49e60f286983 Mon Sep 17 00:00:00 2001 From: Camille Letavernier Date: Tue, 7 Jul 2026 10:12:39 +0200 Subject: [PATCH 2/9] feat: add default perspective; restore perspective state on switch --- .../ai-first-perspective-contribution.ts | 3 +- .../src/browser/perspective-service.spec.ts | 221 +++++++++++++++++- .../core/src/browser/perspective-service.ts | 61 +++-- 3 files changed, 264 insertions(+), 21 deletions(-) diff --git a/packages/ai-ide/src/browser/ai-first-perspective-contribution.ts b/packages/ai-ide/src/browser/ai-first-perspective-contribution.ts index e96e4cd4b556e..4439cc75957c5 100644 --- a/packages/ai-ide/src/browser/ai-first-perspective-contribution.ts +++ b/packages/ai-ide/src/browser/ai-first-perspective-contribution.ts @@ -15,6 +15,7 @@ // ***************************************************************************** import { injectable } from '@theia/core/shared/inversify'; +import { nls } from '@theia/core'; import { PerspectiveContribution, PerspectiveService } from '@theia/core/lib/browser/perspective-service'; import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell'; @@ -28,7 +29,7 @@ export class AIFirstPerspectiveContribution implements PerspectiveContribution { registerPerspectives(service: PerspectiveService): void { service.registerPerspective({ id: 'ai-first', - label: 'AI First', + label: nls.localize('theia/ai-ide/perspective/aiFirst', 'AI First'), viewPlacements: new Map([ [CHAT_VIEW_WIDGET_ID, 'main'], [EXPLORER_VIEW_CONTAINER_ID, 'right'], diff --git a/packages/core/src/browser/perspective-service.spec.ts b/packages/core/src/browser/perspective-service.spec.ts index b02bfbafb437e..f203c16f81116 100644 --- a/packages/core/src/browser/perspective-service.spec.ts +++ b/packages/core/src/browser/perspective-service.spec.ts @@ -32,6 +32,8 @@ describe('PerspectiveService', () => { let getTabBarForStub: sinon.SinonStub; let getAreaForStub: sinon.SinonStub; let getOrCreateWidgetStub: sinon.SinonStub; + let getLayoutDataStub: sinon.SinonStub; + let setLayoutDataStub: sinon.SinonStub; let testWidget: Widget; let toTearDown: () => void; @@ -46,12 +48,16 @@ describe('PerspectiveService', () => { getTabBarForStub = sinon.stub().returns(undefined); getAreaForStub = sinon.stub().returns(undefined); getOrCreateWidgetStub = sinon.stub().resolves(testWidget); + getLayoutDataStub = sinon.stub().returns({ mainPanel: {}, bottomPanel: {} }); + setLayoutDataStub = sinon.stub().resolves(); const mockShell = { addWidget: addWidgetStub, activateWidget: activateWidgetStub, getTabBarFor: getTabBarForStub, - getAreaFor: getAreaForStub + getAreaFor: getAreaForStub, + getLayoutData: getLayoutDataStub, + setLayoutData: setLayoutDataStub }; const mockWidgetManager = { @@ -255,4 +261,217 @@ describe('PerspectiveService', () => { expect(() => service.initialize()).to.not.throw(); }); + + // --- New tests for no-op guard, layout save/restore, and default perspective --- + + it('should register the default "Theia IDE" perspective on initialize', () => { + service.initialize(); + + const perspectives = service.getRegisteredPerspectives(); + const defaultPerspective = perspectives.find(p => p.id === PerspectiveService.DEFAULT_PERSPECTIVE_ID); + expect(defaultPerspective).to.not.be.undefined; + expect(defaultPerspective!.label).to.equal('Theia IDE'); + expect(defaultPerspective!.viewPlacements.size).to.equal(0); + }); + + it('should set the default perspective as the initial active perspective', () => { + service.initialize(); + + const active = service.getActivePerspective(); + expect(active).to.not.be.undefined; + expect(active!.id).to.equal(PerspectiveService.DEFAULT_PERSPECTIVE_ID); + }); + + it('should not re-apply when switching to the already-active perspective', async () => { + service.registerPerspective({ + id: 'test', + label: 'Test', + viewPlacements: new Map([['test-widget', 'main' as ApplicationShell.Area]]), + onActivate: sinon.spy(), + onDeactivate: sinon.spy() + }); + + await service.switchPerspective('test'); + + // Reset all stubs + addWidgetStub.resetHistory(); + setLayoutDataStub.resetHistory(); + getOrCreateWidgetStub.resetHistory(); + getLayoutDataStub.resetHistory(); + const eventSpy = sinon.spy(); + service.onDidChangePerspective(eventSpy); + + const descriptor = service.getActivePerspective()!; + const onActivateSpy = descriptor.onActivate as sinon.SinonSpy; + const onDeactivateSpy = descriptor.onDeactivate as sinon.SinonSpy; + onActivateSpy.resetHistory(); + onDeactivateSpy.resetHistory(); + + // Switch to the same perspective again + await service.switchPerspective('test'); + + expect(addWidgetStub.called).to.be.false; + expect(setLayoutDataStub.called).to.be.false; + expect(getOrCreateWidgetStub.called).to.be.false; + expect(getLayoutDataStub.called).to.be.false; + expect(eventSpy.called).to.be.false; + expect(onActivateSpy.called).to.be.false; + expect(onDeactivateSpy.called).to.be.false; + }); + + it('should save layout when switching away from a perspective', async () => { + service.registerPerspective({ + id: 'perspA', + label: 'A', + viewPlacements: new Map() + }); + service.registerPerspective({ + id: 'perspB', + label: 'B', + viewPlacements: new Map() + }); + + await service.switchPerspective('perspA'); + + const layoutA = { mainPanel: { widgets: ['editor1'] }, bottomPanel: {} }; + getLayoutDataStub.returns(layoutA); + + // Switch to B — should save A's layout + await service.switchPerspective('perspB'); + + expect(getLayoutDataStub.called).to.be.true; + + const layoutB = { mainPanel: { widgets: ['something-else'] }, bottomPanel: {} }; + getLayoutDataStub.returns(layoutB); + setLayoutDataStub.resetHistory(); + + // Switch back to A — should restore A's saved layout + await service.switchPerspective('perspA'); + + expect(setLayoutDataStub.calledOnce).to.be.true; + expect(setLayoutDataStub.calledWith(layoutA)).to.be.true; + }); + + it('should restore saved layout when switching back to a perspective', async () => { + service.registerPerspective({ + id: 'perspA', + label: 'A', + viewPlacements: new Map([['test-widget', 'left' as ApplicationShell.Area]]) + }); + service.registerPerspective({ + id: 'perspB', + label: 'B', + viewPlacements: new Map() + }); + + // Activate A (first time — applies viewPlacements) + await service.switchPerspective('perspA'); + + const layoutA = { mainPanel: { widgets: ['editor-customized'] }, bottomPanel: {} }; + getLayoutDataStub.returns(layoutA); + + // Switch to B (saves A's layout) + await service.switchPerspective('perspB'); + + const layoutB = { mainPanel: { widgets: ['something-else'] }, bottomPanel: {} }; + getLayoutDataStub.returns(layoutB); + + setLayoutDataStub.resetHistory(); + getOrCreateWidgetStub.resetHistory(); + addWidgetStub.resetHistory(); + + // Switch back to A — should restore saved layout, NOT apply viewPlacements + await service.switchPerspective('perspA'); + + expect(setLayoutDataStub.calledOnce).to.be.true; + expect(setLayoutDataStub.calledWith(layoutA)).to.be.true; + expect(getOrCreateWidgetStub.called).to.be.false; + expect(addWidgetStub.called).to.be.false; + }); + + it('should apply viewPlacements on first activation (no saved layout)', async () => { + service.registerPerspective({ + id: 'fresh', + label: 'Fresh', + viewPlacements: new Map([['test-widget', 'main' as ApplicationShell.Area]]) + }); + + await service.switchPerspective('fresh'); + + expect(getOrCreateWidgetStub.calledWith('test-widget')).to.be.true; + expect(addWidgetStub.calledOnce).to.be.true; + expect(addWidgetStub.calledWith(testWidget, sinon.match({ area: 'main' }))).to.be.true; + expect(setLayoutDataStub.called).to.be.false; + }); + + it('should not apply viewPlacements when a saved layout exists', async () => { + service.registerPerspective({ + id: 'perspA', + label: 'A', + viewPlacements: new Map([['test-widget', 'left' as ApplicationShell.Area]]) + }); + service.registerPerspective({ + id: 'perspB', + label: 'B', + viewPlacements: new Map() + }); + + // First activation of A — viewPlacements applied + await service.switchPerspective('perspA'); + expect(getOrCreateWidgetStub.called).to.be.true; + + // Switch to B (saves A's layout) + await service.switchPerspective('perspB'); + + getOrCreateWidgetStub.resetHistory(); + addWidgetStub.resetHistory(); + + // Switch back to A — should restore layout, NOT use viewPlacements + await service.switchPerspective('perspA'); + + expect(getOrCreateWidgetStub.called).to.be.false; + expect(addWidgetStub.called).to.be.false; + expect(setLayoutDataStub.called).to.be.true; + }); + + it('should allow full round-trip: default → custom → default', async () => { + service.initialize(); + + service.registerPerspective({ + id: 'ai-first', + label: 'AI First', + viewPlacements: new Map([['test-widget', 'left' as ApplicationShell.Area]]) + }); + + // Start in theia-ide (set by initialize) + expect(service.getActivePerspective()?.id).to.equal(PerspectiveService.DEFAULT_PERSPECTIVE_ID); + + const defaultLayout = { mainPanel: { widgets: ['default-editor'] }, bottomPanel: {} }; + getLayoutDataStub.returns(defaultLayout); + + // Switch to ai-first (saves default layout) + await service.switchPerspective('ai-first'); + expect(service.getActivePerspective()?.id).to.equal('ai-first'); + expect(getLayoutDataStub.called).to.be.true; + expect(getOrCreateWidgetStub.calledWith('test-widget')).to.be.true; + + const aiLayout = { mainPanel: { widgets: ['ai-stuff'] }, bottomPanel: {} }; + getLayoutDataStub.returns(aiLayout); + + setLayoutDataStub.resetHistory(); + + // Switch back to default (saves ai-first layout, restores default layout) + await service.switchPerspective(PerspectiveService.DEFAULT_PERSPECTIVE_ID); + expect(service.getActivePerspective()?.id).to.equal(PerspectiveService.DEFAULT_PERSPECTIVE_ID); + expect(setLayoutDataStub.calledOnce).to.be.true; + expect(setLayoutDataStub.calledWith(defaultLayout)).to.be.true; + + setLayoutDataStub.resetHistory(); + + // Switch back to ai-first (saves default layout again, restores ai-first layout) + await service.switchPerspective('ai-first'); + expect(service.getActivePerspective()?.id).to.equal('ai-first'); + expect(setLayoutDataStub.calledOnce).to.be.true; + expect(setLayoutDataStub.calledWith(aiLayout)).to.be.true; + }); }); diff --git a/packages/core/src/browser/perspective-service.ts b/packages/core/src/browser/perspective-service.ts index ca438e9cdf0a8..4f6701da22496 100644 --- a/packages/core/src/browser/perspective-service.ts +++ b/packages/core/src/browser/perspective-service.ts @@ -46,7 +46,7 @@ export class PerspectiveService implements FrontendApplicationContribution, Comm static readonly SWITCH_PERSPECTIVE_COMMAND = { id: 'perspective.switch', category: nls.localizeByDefault('View'), - label: 'Switch Perspective' + label: nls.localize('theia/core/perspective/switchPerspective', 'Switch Perspective') }; @inject(ApplicationShell) @@ -61,13 +61,23 @@ export class PerspectiveService implements FrontendApplicationContribution, Comm @inject(QuickInputService) @optional() protected readonly quickInputService: QuickInputService | undefined; + static readonly DEFAULT_PERSPECTIVE_ID = 'theia-ide'; + protected readonly perspectives = new Map(); protected activePerspectiveId: string | undefined; + protected readonly savedLayouts = new Map(); protected readonly onDidChangePerspectiveEmitter = new Emitter(); readonly onDidChangePerspective: Event = this.onDidChangePerspectiveEmitter.event; initialize(): void { + this.registerPerspective({ + id: PerspectiveService.DEFAULT_PERSPECTIVE_ID, + label: nls.localize('theia/core/perspective/theiaIde', 'Theia IDE'), + viewPlacements: new Map() + }); + this.activePerspectiveId = PerspectiveService.DEFAULT_PERSPECTIVE_ID; + if (this.contributions) { for (const contribution of this.contributions.getContributions()) { contribution.registerPerspectives(this); @@ -80,6 +90,10 @@ export class PerspectiveService implements FrontendApplicationContribution, Comm } async switchPerspective(id: string): Promise { + if (id === this.activePerspectiveId) { + return; + } + const descriptor = this.perspectives.get(id); if (!descriptor) { return; @@ -90,29 +104,38 @@ export class PerspectiveService implements FrontendApplicationContribution, Comm oldPerspective.onDeactivate(this.shell); } + if (this.activePerspectiveId) { + this.savedLayouts.set(this.activePerspectiveId, this.shell.getLayoutData()); + } + this.activePerspectiveId = id; - 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; + const savedLayout = this.savedLayouts.get(id); + if (savedLayout) { + await this.shell.setLayoutData(savedLayout); + } else { + 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 { + // Widget factory may not be registered — skip silently } - await this.shell.addWidget(widget, { area }); - } catch { - // Widget factory may not be registered — skip silently } - } - for (const [viewId] of descriptor.viewPlacements) { - try { - await this.shell.activateWidget(viewId); - } catch { - // Ignore activation errors + for (const [viewId] of descriptor.viewPlacements) { + try { + await this.shell.activateWidget(viewId); + } catch { + // Ignore activation errors + } } } @@ -161,7 +184,7 @@ export class PerspectiveService implements FrontendApplicationContribution, Comm })); const selected = await this.quickInputService.showQuickPick(items, { - placeholder: 'Select a perspective' + placeholder: nls.localize('theia/core/perspective/selectPerspective', 'Select a perspective') }); if (selected?.id) { From 98b71c87932c56bd95f06720e380a2f128f34b7e Mon Sep 17 00:00:00 2001 From: Camille Letavernier Date: Tue, 7 Jul 2026 10:38:26 +0200 Subject: [PATCH 3/9] feat: add chrome-visibility options on perspectives --- .../ai-first-perspective-contribution.ts | 10 +- .../src/browser/perspective-service.spec.ts | 231 +++++++++++++++++- .../core/src/browser/perspective-service.ts | 55 +++++ 3 files changed, 293 insertions(+), 3 deletions(-) diff --git a/packages/ai-ide/src/browser/ai-first-perspective-contribution.ts b/packages/ai-ide/src/browser/ai-first-perspective-contribution.ts index 4439cc75957c5..4c63203e37d56 100644 --- a/packages/ai-ide/src/browser/ai-first-perspective-contribution.ts +++ b/packages/ai-ide/src/browser/ai-first-perspective-contribution.ts @@ -16,7 +16,7 @@ import { injectable } from '@theia/core/shared/inversify'; import { nls } from '@theia/core'; -import { PerspectiveContribution, PerspectiveService } from '@theia/core/lib/browser/perspective-service'; +import { PerspectiveContribution, PerspectiveChromeOptions, PerspectiveService } from '@theia/core/lib/browser/perspective-service'; import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell'; const CHAT_VIEW_WIDGET_ID = 'chat-view-widget'; @@ -27,6 +27,11 @@ const SCM_VIEW_CONTAINER_ID = 'scm-view-container'; export class AIFirstPerspectiveContribution implements PerspectiveContribution { registerPerspectives(service: PerspectiveService): void { + const chromeOptions: PerspectiveChromeOptions = { + hideMenuBar: true, + hideStatusBar: true, + collapseAreas: ['left', 'bottom'] + }; service.registerPerspective({ id: 'ai-first', label: nls.localize('theia/ai-ide/perspective/aiFirst', 'AI First'), @@ -34,7 +39,8 @@ export class AIFirstPerspectiveContribution implements PerspectiveContribution { [CHAT_VIEW_WIDGET_ID, 'main'], [EXPLORER_VIEW_CONTAINER_ID, 'right'], [SCM_VIEW_CONTAINER_ID, 'right'] - ]) + ]), + chromeOptions }); } } diff --git a/packages/core/src/browser/perspective-service.spec.ts b/packages/core/src/browser/perspective-service.spec.ts index f203c16f81116..15b203fcc7ebd 100644 --- a/packages/core/src/browser/perspective-service.spec.ts +++ b/packages/core/src/browser/perspective-service.spec.ts @@ -22,6 +22,7 @@ import * as sinon from 'sinon'; import { PerspectiveService, PerspectiveDescriptor } from './perspective-service'; import { ApplicationShell } from './shell/application-shell'; import { Widget } from '@lumino/widgets'; +import { Emitter } from '../common/event'; disableJSDOM(); @@ -34,8 +35,13 @@ describe('PerspectiveService', () => { let getOrCreateWidgetStub: sinon.SinonStub; let getLayoutDataStub: sinon.SinonStub; let setLayoutDataStub: sinon.SinonStub; + let collapsePanelStub: sinon.SinonStub; + let topPanelSetHiddenStub: sinon.SinonStub; + let statusBarSetHiddenStub: sinon.SinonStub; let testWidget: Widget; let toTearDown: () => void; + let mockCorePreferences: Record & { onPreferenceChanged: sinon.SinonStub }; + let mockPreferenceService: { get: sinon.SinonStub }; beforeEach(() => { toTearDown = enableJSDOM(); @@ -50,6 +56,9 @@ describe('PerspectiveService', () => { getOrCreateWidgetStub = sinon.stub().resolves(testWidget); getLayoutDataStub = sinon.stub().returns({ mainPanel: {}, bottomPanel: {} }); setLayoutDataStub = sinon.stub().resolves(); + collapsePanelStub = sinon.stub().resolves(); + topPanelSetHiddenStub = sinon.stub(); + statusBarSetHiddenStub = sinon.stub(); const mockShell = { addWidget: addWidgetStub, @@ -57,16 +66,34 @@ describe('PerspectiveService', () => { getTabBarFor: getTabBarForStub, getAreaFor: getAreaForStub, getLayoutData: getLayoutDataStub, - setLayoutData: setLayoutDataStub + setLayoutData: setLayoutDataStub, + collapsePanel: collapsePanelStub, + topPanel: { setHidden: topPanelSetHiddenStub } + }; + + const mockStatusBar = { + setHidden: statusBarSetHiddenStub }; const mockWidgetManager = { getOrCreateWidget: getOrCreateWidgetStub }; + mockCorePreferences = { + 'window.menuBarVisibility': 'classic', + onPreferenceChanged: sinon.stub().returns({ dispose: () => { } }) + }; + + mockPreferenceService = { + get: sinon.stub().returns(true) + }; + // Assign mocks via property access since we can't use DI in tests (service as unknown as Record)['shell'] = mockShell; (service as unknown as Record)['widgetManager'] = mockWidgetManager; + (service as unknown as Record)['corePreferences'] = mockCorePreferences; + (service as unknown as Record)['preferenceService'] = mockPreferenceService; + (service as unknown as Record)['statusBar'] = mockStatusBar; }); afterEach(() => { @@ -474,4 +501,206 @@ describe('PerspectiveService', () => { expect(setLayoutDataStub.calledOnce).to.be.true; expect(setLayoutDataStub.calledWith(aiLayout)).to.be.true; }); + + // --- Chrome control options tests --- + + it('should hide menu bar when perspective has hideMenuBar', async () => { + service.registerPerspective({ + id: 'chrome-test', + label: 'Chrome Test', + viewPlacements: new Map(), + chromeOptions: { hideMenuBar: true } + }); + + await service.switchPerspective('chrome-test'); + + expect(topPanelSetHiddenStub.calledWith(true)).to.be.true; + }); + + it('should show menu bar when perspective does not hide it and preference allows', async () => { + mockCorePreferences['window.menuBarVisibility'] = 'classic'; + + service.registerPerspective({ + id: 'no-chrome', + label: 'No Chrome', + viewPlacements: new Map() + }); + + await service.switchPerspective('no-chrome'); + + expect(topPanelSetHiddenStub.calledWith(false)).to.be.true; + }); + + it('should keep menu bar hidden when preference hides it even if perspective does not', async () => { + mockCorePreferences['window.menuBarVisibility'] = 'hidden'; + + service.registerPerspective({ + id: 'no-chrome', + label: 'No Chrome', + viewPlacements: new Map() + }); + + await service.switchPerspective('no-chrome'); + + expect(topPanelSetHiddenStub.calledWith(true)).to.be.true; + }); + + it('should hide status bar when perspective has hideStatusBar', async () => { + service.registerPerspective({ + id: 'chrome-test', + label: 'Chrome Test', + viewPlacements: new Map(), + chromeOptions: { hideStatusBar: true } + }); + + await service.switchPerspective('chrome-test'); + + expect(statusBarSetHiddenStub.calledWith(true)).to.be.true; + }); + + it('should restore status bar based on preference when perspective does not hide it', async () => { + mockPreferenceService.get.returns(true); + + service.registerPerspective({ + id: 'no-chrome', + label: 'No Chrome', + viewPlacements: new Map() + }); + + await service.switchPerspective('no-chrome'); + + expect(statusBarSetHiddenStub.calledWith(false)).to.be.true; + }); + + it('should collapse areas on first activation only', async () => { + service.registerPerspective({ + id: 'collapse-test', + label: 'Collapse Test', + viewPlacements: new Map(), + chromeOptions: { collapseAreas: ['left'] } + }); + service.registerPerspective({ + id: 'other', + label: 'Other', + viewPlacements: new Map() + }); + + // First activation — should collapse + await service.switchPerspective('collapse-test'); + expect(collapsePanelStub.calledOnce).to.be.true; + expect(collapsePanelStub.calledWith('left')).to.be.true; + + collapsePanelStub.resetHistory(); + + // Switch away (saves layout) + await service.switchPerspective('other'); + + collapsePanelStub.resetHistory(); + + // Switch back — should restore saved layout, NOT collapse again + await service.switchPerspective('collapse-test'); + expect(collapsePanelStub.called).to.be.false; + }); + + it('should apply chrome options even when restoring saved layout', async () => { + service.registerPerspective({ + id: 'chrome-test', + label: 'Chrome Test', + viewPlacements: new Map(), + chromeOptions: { hideMenuBar: true, hideStatusBar: true } + }); + service.registerPerspective({ + id: 'other', + label: 'Other', + viewPlacements: new Map() + }); + + // First activation + await service.switchPerspective('chrome-test'); + expect(topPanelSetHiddenStub.calledWith(true)).to.be.true; + expect(statusBarSetHiddenStub.calledWith(true)).to.be.true; + + topPanelSetHiddenStub.resetHistory(); + statusBarSetHiddenStub.resetHistory(); + + // Switch away (saves layout) + await service.switchPerspective('other'); + + topPanelSetHiddenStub.resetHistory(); + statusBarSetHiddenStub.resetHistory(); + + // Switch back (restores saved layout) — chrome should still be applied + await service.switchPerspective('chrome-test'); + expect(topPanelSetHiddenStub.calledWith(true)).to.be.true; + expect(statusBarSetHiddenStub.calledWith(true)).to.be.true; + }); + + it('should re-evaluate chrome when preference changes', () => { + const prefEmitter = new Emitter<{ preferenceName: string }>(); + mockCorePreferences.onPreferenceChanged = sinon.stub().callsFake( + (listener: (e: { preferenceName: string }) => void) => prefEmitter.event(listener) + ); + + service.registerPerspective({ + id: 'chrome-test', + label: 'Chrome Test', + viewPlacements: new Map(), + chromeOptions: { hideMenuBar: true } + }); + + service.initialize(); + + // Manually set active perspective + (service as unknown as Record)['activePerspectiveId'] = 'chrome-test'; + + topPanelSetHiddenStub.resetHistory(); + statusBarSetHiddenStub.resetHistory(); + + // Fire a preference change + prefEmitter.fire({ preferenceName: 'window.menuBarVisibility' }); + + expect(topPanelSetHiddenStub.called).to.be.true; + expect(topPanelSetHiddenStub.calledWith(true)).to.be.true; + }); + + it('should keep status bar hidden when preference hides it even if perspective does not', async () => { + mockPreferenceService.get.returns(false); + + service.registerPerspective({ + id: 'no-chrome', + label: 'No Chrome', + viewPlacements: new Map() + }); + + await service.switchPerspective('no-chrome'); + + expect(statusBarSetHiddenStub.calledWith(true)).to.be.true; + }); + + it('should collapse multiple areas on first activation', async () => { + service.registerPerspective({ + id: 'multi-collapse', + label: 'Multi Collapse', + viewPlacements: new Map(), + chromeOptions: { collapseAreas: ['left', 'bottom'] } + }); + + await service.switchPerspective('multi-collapse'); + + expect(collapsePanelStub.calledTwice).to.be.true; + expect(collapsePanelStub.calledWith('left')).to.be.true; + expect(collapsePanelStub.calledWith('bottom')).to.be.true; + }); + + it('should not collapse areas when perspective has no collapseAreas', async () => { + service.registerPerspective({ + id: 'no-collapse', + label: 'No Collapse', + viewPlacements: new Map() + }); + + await service.switchPerspective('no-collapse'); + + expect(collapsePanelStub.called).to.be.false; + }); }); diff --git a/packages/core/src/browser/perspective-service.ts b/packages/core/src/browser/perspective-service.ts index 4f6701da22496..579a5bbc208a3 100644 --- a/packages/core/src/browser/perspective-service.ts +++ b/packages/core/src/browser/perspective-service.ts @@ -23,12 +23,26 @@ import { CommandContribution, CommandRegistry } from '../common/command'; import { Emitter, Event } from '../common/event'; import { QuickInputService, QuickPickItem } from '../common/quick-pick-service'; import { nls } from '../common/nls'; +import { CorePreferences } from '../common/core-preferences'; +import { PreferenceService } from '../common/preferences'; +import { StatusBarImpl } from './status-bar/status-bar'; + +export interface PerspectiveChromeOptions { + /** Hide the top menu bar. Default: false. */ + hideMenuBar?: boolean; + /** Hide the status bar. Default: false. */ + hideStatusBar?: boolean; + /** 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; + /** Chrome control options for this perspective */ + chromeOptions?: PerspectiveChromeOptions; /** Called when perspective is activated */ onActivate?(shell: ApplicationShell): void; /** Called when switching away */ @@ -61,6 +75,15 @@ export class PerspectiveService implements FrontendApplicationContribution, Comm @inject(QuickInputService) @optional() protected readonly quickInputService: QuickInputService | undefined; + @inject(CorePreferences) + protected readonly corePreferences: CorePreferences; + + @inject(PreferenceService) + protected readonly preferenceService: PreferenceService; + + @inject(StatusBarImpl) + protected readonly statusBar: StatusBarImpl; + static readonly DEFAULT_PERSPECTIVE_ID = 'theia-ide'; protected readonly perspectives = new Map(); @@ -83,6 +106,16 @@ export class PerspectiveService implements FrontendApplicationContribution, Comm contribution.registerPerspectives(this); } } + + this.corePreferences.onPreferenceChanged(pref => { + if (pref.preferenceName === 'window.menuBarVisibility' + || pref.preferenceName === 'workbench.statusBar.visible') { + const active = this.getActivePerspective(); + if (active) { + this.applyChrome(active); + } + } + }); } registerPerspective(descriptor: PerspectiveDescriptor): void { @@ -137,15 +170,37 @@ export class PerspectiveService implements FrontendApplicationContribution, Comm // Ignore activation errors } } + + if (descriptor.chromeOptions?.collapseAreas) { + for (const area of descriptor.chromeOptions.collapseAreas) { + await this.shell.collapsePanel(area); + } + } } if (descriptor.onActivate) { descriptor.onActivate(this.shell); } + this.applyChrome(descriptor); + this.onDidChangePerspectiveEmitter.fire(id); } + protected applyChrome(descriptor: PerspectiveDescriptor): void { + const perspectiveHidesMenu = descriptor.chromeOptions?.hideMenuBar ?? false; + const prefHidesMenu = ['compact', 'hidden'].includes( + this.corePreferences['window.menuBarVisibility'] + ); + this.shell.topPanel.setHidden(perspectiveHidesMenu || prefHidesMenu); + + const perspectiveHidesStatus = descriptor.chromeOptions?.hideStatusBar ?? false; + const prefHidesStatus = !this.preferenceService.get( + 'workbench.statusBar.visible', true + ); + this.statusBar.setHidden(perspectiveHidesStatus || prefHidesStatus); + } + getActivePerspective(): PerspectiveDescriptor | undefined { if (this.activePerspectiveId) { return this.perspectives.get(this.activePerspectiveId); From deb6dd430a374e65f75ded872d5a52d64cca3614 Mon Sep 17 00:00:00 2001 From: Camille Letavernier Date: Tue, 7 Jul 2026 12:04:00 +0200 Subject: [PATCH 4/9] feat: rename Theia IDE perspective to 'Default' --- packages/core/src/browser/perspective-service.spec.ts | 6 +++--- packages/core/src/browser/perspective-service.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/browser/perspective-service.spec.ts b/packages/core/src/browser/perspective-service.spec.ts index 15b203fcc7ebd..41fbf19edf2a1 100644 --- a/packages/core/src/browser/perspective-service.spec.ts +++ b/packages/core/src/browser/perspective-service.spec.ts @@ -291,13 +291,13 @@ describe('PerspectiveService', () => { // --- New tests for no-op guard, layout save/restore, and default perspective --- - it('should register the default "Theia IDE" perspective on initialize', () => { + it('should register the default perspective on initialize', () => { service.initialize(); const perspectives = service.getRegisteredPerspectives(); const defaultPerspective = perspectives.find(p => p.id === PerspectiveService.DEFAULT_PERSPECTIVE_ID); expect(defaultPerspective).to.not.be.undefined; - expect(defaultPerspective!.label).to.equal('Theia IDE'); + expect(defaultPerspective!.label).to.equal('Default'); expect(defaultPerspective!.viewPlacements.size).to.equal(0); }); @@ -470,7 +470,7 @@ describe('PerspectiveService', () => { viewPlacements: new Map([['test-widget', 'left' as ApplicationShell.Area]]) }); - // Start in theia-ide (set by initialize) + // Start in default (set by initialize) expect(service.getActivePerspective()?.id).to.equal(PerspectiveService.DEFAULT_PERSPECTIVE_ID); const defaultLayout = { mainPanel: { widgets: ['default-editor'] }, bottomPanel: {} }; diff --git a/packages/core/src/browser/perspective-service.ts b/packages/core/src/browser/perspective-service.ts index 579a5bbc208a3..3c6573f68fb97 100644 --- a/packages/core/src/browser/perspective-service.ts +++ b/packages/core/src/browser/perspective-service.ts @@ -84,7 +84,7 @@ export class PerspectiveService implements FrontendApplicationContribution, Comm @inject(StatusBarImpl) protected readonly statusBar: StatusBarImpl; - static readonly DEFAULT_PERSPECTIVE_ID = 'theia-ide'; + static readonly DEFAULT_PERSPECTIVE_ID = 'default'; protected readonly perspectives = new Map(); protected activePerspectiveId: string | undefined; @@ -96,7 +96,7 @@ export class PerspectiveService implements FrontendApplicationContribution, Comm initialize(): void { this.registerPerspective({ id: PerspectiveService.DEFAULT_PERSPECTIVE_ID, - label: nls.localize('theia/core/perspective/theiaIde', 'Theia IDE'), + label: nls.localizeByDefault('Default'), viewPlacements: new Map() }); this.activePerspectiveId = PerspectiveService.DEFAULT_PERSPECTIVE_ID; From 637f209b29ca0c7d05e2d70ad027c5e43e008068 Mon Sep 17 00:00:00 2001 From: Camille Letavernier Date: Tue, 7 Jul 2026 12:21:29 +0200 Subject: [PATCH 5/9] fix: address review comments --- .../ai-first-perspective-contribution.ts | 4 +- .../src/browser/perspective-service.spec.ts | 119 +++--------------- .../core/src/browser/perspective-service.ts | 49 ++------ .../src/browser/shell/application-shell.ts | 13 +- .../src/browser/status-bar/status-bar.tsx | 17 ++- 5 files changed, 60 insertions(+), 142 deletions(-) diff --git a/packages/ai-ide/src/browser/ai-first-perspective-contribution.ts b/packages/ai-ide/src/browser/ai-first-perspective-contribution.ts index 4c63203e37d56..32cb0eda6464f 100644 --- a/packages/ai-ide/src/browser/ai-first-perspective-contribution.ts +++ b/packages/ai-ide/src/browser/ai-first-perspective-contribution.ts @@ -18,10 +18,10 @@ import { injectable } from '@theia/core/shared/inversify'; import { nls } from '@theia/core'; import { PerspectiveContribution, PerspectiveChromeOptions, PerspectiveService } from '@theia/core/lib/browser/perspective-service'; import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell'; +import { EXPLORER_VIEW_CONTAINER_ID } from '@theia/navigator/lib/browser'; +import { SCM_VIEW_CONTAINER_ID } from '@theia/scm/lib/browser/scm-contribution'; const CHAT_VIEW_WIDGET_ID = 'chat-view-widget'; -const EXPLORER_VIEW_CONTAINER_ID = 'explorer-view-container'; -const SCM_VIEW_CONTAINER_ID = 'scm-view-container'; @injectable() export class AIFirstPerspectiveContribution implements PerspectiveContribution { diff --git a/packages/core/src/browser/perspective-service.spec.ts b/packages/core/src/browser/perspective-service.spec.ts index 41fbf19edf2a1..e2992407d7517 100644 --- a/packages/core/src/browser/perspective-service.spec.ts +++ b/packages/core/src/browser/perspective-service.spec.ts @@ -22,8 +22,6 @@ import * as sinon from 'sinon'; import { PerspectiveService, PerspectiveDescriptor } from './perspective-service'; import { ApplicationShell } from './shell/application-shell'; import { Widget } from '@lumino/widgets'; -import { Emitter } from '../common/event'; - disableJSDOM(); describe('PerspectiveService', () => { @@ -36,12 +34,10 @@ describe('PerspectiveService', () => { let getLayoutDataStub: sinon.SinonStub; let setLayoutDataStub: sinon.SinonStub; let collapsePanelStub: sinon.SinonStub; - let topPanelSetHiddenStub: sinon.SinonStub; - let statusBarSetHiddenStub: sinon.SinonStub; + let setMenuBarHiddenByPerspectiveStub: sinon.SinonStub; + let setStatusBarHiddenByPerspectiveStub: sinon.SinonStub; let testWidget: Widget; let toTearDown: () => void; - let mockCorePreferences: Record & { onPreferenceChanged: sinon.SinonStub }; - let mockPreferenceService: { get: sinon.SinonStub }; beforeEach(() => { toTearDown = enableJSDOM(); @@ -57,8 +53,8 @@ describe('PerspectiveService', () => { getLayoutDataStub = sinon.stub().returns({ mainPanel: {}, bottomPanel: {} }); setLayoutDataStub = sinon.stub().resolves(); collapsePanelStub = sinon.stub().resolves(); - topPanelSetHiddenStub = sinon.stub(); - statusBarSetHiddenStub = sinon.stub(); + setMenuBarHiddenByPerspectiveStub = sinon.stub(); + setStatusBarHiddenByPerspectiveStub = sinon.stub(); const mockShell = { addWidget: addWidgetStub, @@ -68,32 +64,17 @@ describe('PerspectiveService', () => { getLayoutData: getLayoutDataStub, setLayoutData: setLayoutDataStub, collapsePanel: collapsePanelStub, - topPanel: { setHidden: topPanelSetHiddenStub } - }; - - const mockStatusBar = { - setHidden: statusBarSetHiddenStub + setMenuBarHiddenByPerspective: setMenuBarHiddenByPerspectiveStub, + setStatusBarHiddenByPerspective: setStatusBarHiddenByPerspectiveStub }; const mockWidgetManager = { getOrCreateWidget: getOrCreateWidgetStub }; - mockCorePreferences = { - 'window.menuBarVisibility': 'classic', - onPreferenceChanged: sinon.stub().returns({ dispose: () => { } }) - }; - - mockPreferenceService = { - get: sinon.stub().returns(true) - }; - // Assign mocks via property access since we can't use DI in tests (service as unknown as Record)['shell'] = mockShell; (service as unknown as Record)['widgetManager'] = mockWidgetManager; - (service as unknown as Record)['corePreferences'] = mockCorePreferences; - (service as unknown as Record)['preferenceService'] = mockPreferenceService; - (service as unknown as Record)['statusBar'] = mockStatusBar; }); afterEach(() => { @@ -514,12 +495,10 @@ describe('PerspectiveService', () => { await service.switchPerspective('chrome-test'); - expect(topPanelSetHiddenStub.calledWith(true)).to.be.true; + expect(setMenuBarHiddenByPerspectiveStub.calledWith(true)).to.be.true; }); - it('should show menu bar when perspective does not hide it and preference allows', async () => { - mockCorePreferences['window.menuBarVisibility'] = 'classic'; - + it('should show menu bar when perspective does not hide it', async () => { service.registerPerspective({ id: 'no-chrome', label: 'No Chrome', @@ -528,21 +507,7 @@ describe('PerspectiveService', () => { await service.switchPerspective('no-chrome'); - expect(topPanelSetHiddenStub.calledWith(false)).to.be.true; - }); - - it('should keep menu bar hidden when preference hides it even if perspective does not', async () => { - mockCorePreferences['window.menuBarVisibility'] = 'hidden'; - - service.registerPerspective({ - id: 'no-chrome', - label: 'No Chrome', - viewPlacements: new Map() - }); - - await service.switchPerspective('no-chrome'); - - expect(topPanelSetHiddenStub.calledWith(true)).to.be.true; + expect(setMenuBarHiddenByPerspectiveStub.calledWith(false)).to.be.true; }); it('should hide status bar when perspective has hideStatusBar', async () => { @@ -555,12 +520,10 @@ describe('PerspectiveService', () => { await service.switchPerspective('chrome-test'); - expect(statusBarSetHiddenStub.calledWith(true)).to.be.true; + expect(setStatusBarHiddenByPerspectiveStub.calledWith(true)).to.be.true; }); - it('should restore status bar based on preference when perspective does not hide it', async () => { - mockPreferenceService.get.returns(true); - + it('should not hide status bar when perspective does not hide it', async () => { service.registerPerspective({ id: 'no-chrome', label: 'No Chrome', @@ -569,7 +532,7 @@ describe('PerspectiveService', () => { await service.switchPerspective('no-chrome'); - expect(statusBarSetHiddenStub.calledWith(false)).to.be.true; + expect(setStatusBarHiddenByPerspectiveStub.calledWith(false)).to.be.true; }); it('should collapse areas on first activation only', async () => { @@ -617,64 +580,22 @@ describe('PerspectiveService', () => { // First activation await service.switchPerspective('chrome-test'); - expect(topPanelSetHiddenStub.calledWith(true)).to.be.true; - expect(statusBarSetHiddenStub.calledWith(true)).to.be.true; + expect(setMenuBarHiddenByPerspectiveStub.calledWith(true)).to.be.true; + expect(setStatusBarHiddenByPerspectiveStub.calledWith(true)).to.be.true; - topPanelSetHiddenStub.resetHistory(); - statusBarSetHiddenStub.resetHistory(); + setMenuBarHiddenByPerspectiveStub.resetHistory(); + setStatusBarHiddenByPerspectiveStub.resetHistory(); // Switch away (saves layout) await service.switchPerspective('other'); - topPanelSetHiddenStub.resetHistory(); - statusBarSetHiddenStub.resetHistory(); + setMenuBarHiddenByPerspectiveStub.resetHistory(); + setStatusBarHiddenByPerspectiveStub.resetHistory(); // Switch back (restores saved layout) — chrome should still be applied await service.switchPerspective('chrome-test'); - expect(topPanelSetHiddenStub.calledWith(true)).to.be.true; - expect(statusBarSetHiddenStub.calledWith(true)).to.be.true; - }); - - it('should re-evaluate chrome when preference changes', () => { - const prefEmitter = new Emitter<{ preferenceName: string }>(); - mockCorePreferences.onPreferenceChanged = sinon.stub().callsFake( - (listener: (e: { preferenceName: string }) => void) => prefEmitter.event(listener) - ); - - service.registerPerspective({ - id: 'chrome-test', - label: 'Chrome Test', - viewPlacements: new Map(), - chromeOptions: { hideMenuBar: true } - }); - - service.initialize(); - - // Manually set active perspective - (service as unknown as Record)['activePerspectiveId'] = 'chrome-test'; - - topPanelSetHiddenStub.resetHistory(); - statusBarSetHiddenStub.resetHistory(); - - // Fire a preference change - prefEmitter.fire({ preferenceName: 'window.menuBarVisibility' }); - - expect(topPanelSetHiddenStub.called).to.be.true; - expect(topPanelSetHiddenStub.calledWith(true)).to.be.true; - }); - - it('should keep status bar hidden when preference hides it even if perspective does not', async () => { - mockPreferenceService.get.returns(false); - - service.registerPerspective({ - id: 'no-chrome', - label: 'No Chrome', - viewPlacements: new Map() - }); - - await service.switchPerspective('no-chrome'); - - expect(statusBarSetHiddenStub.calledWith(true)).to.be.true; + expect(setMenuBarHiddenByPerspectiveStub.calledWith(true)).to.be.true; + expect(setStatusBarHiddenByPerspectiveStub.calledWith(true)).to.be.true; }); it('should collapse multiple areas on first activation', async () => { diff --git a/packages/core/src/browser/perspective-service.ts b/packages/core/src/browser/perspective-service.ts index 3c6573f68fb97..b65b078e23683 100644 --- a/packages/core/src/browser/perspective-service.ts +++ b/packages/core/src/browser/perspective-service.ts @@ -19,13 +19,12 @@ import { ApplicationShell } from './shell/application-shell'; import { FrontendApplicationContribution } from './frontend-application-contribution'; import { WidgetManager } from './widget-manager'; import { ContributionProvider } from '../common/contribution-provider'; -import { CommandContribution, CommandRegistry } from '../common/command'; +import { Command, CommandContribution, CommandRegistry } from '../common/command'; import { Emitter, Event } from '../common/event'; import { QuickInputService, QuickPickItem } from '../common/quick-pick-service'; import { nls } from '../common/nls'; -import { CorePreferences } from '../common/core-preferences'; -import { PreferenceService } from '../common/preferences'; -import { StatusBarImpl } from './status-bar/status-bar'; +import { DisposableCollection } from '../common/disposable'; +import { CommonCommands } from './common-commands'; export interface PerspectiveChromeOptions { /** Hide the top menu bar. Default: false. */ @@ -57,11 +56,11 @@ export interface PerspectiveContribution { @injectable() export class PerspectiveService implements FrontendApplicationContribution, CommandContribution { - static readonly SWITCH_PERSPECTIVE_COMMAND = { + static readonly SWITCH_PERSPECTIVE_COMMAND = Command.toLocalizedCommand({ id: 'perspective.switch', - category: nls.localizeByDefault('View'), - label: nls.localize('theia/core/perspective/switchPerspective', 'Switch Perspective') - }; + category: 'View', + label: 'Switch Perspective' + }, 'theia/core/perspective/switchPerspective', CommonCommands.VIEW_CATEGORY_KEY); @inject(ApplicationShell) protected readonly shell: ApplicationShell; @@ -75,15 +74,6 @@ export class PerspectiveService implements FrontendApplicationContribution, Comm @inject(QuickInputService) @optional() protected readonly quickInputService: QuickInputService | undefined; - @inject(CorePreferences) - protected readonly corePreferences: CorePreferences; - - @inject(PreferenceService) - protected readonly preferenceService: PreferenceService; - - @inject(StatusBarImpl) - protected readonly statusBar: StatusBarImpl; - static readonly DEFAULT_PERSPECTIVE_ID = 'default'; protected readonly perspectives = new Map(); @@ -93,6 +83,8 @@ export class PerspectiveService implements FrontendApplicationContribution, Comm protected readonly onDidChangePerspectiveEmitter = new Emitter(); readonly onDidChangePerspective: Event = this.onDidChangePerspectiveEmitter.event; + protected readonly toDispose = new DisposableCollection(); + initialize(): void { this.registerPerspective({ id: PerspectiveService.DEFAULT_PERSPECTIVE_ID, @@ -107,15 +99,7 @@ export class PerspectiveService implements FrontendApplicationContribution, Comm } } - this.corePreferences.onPreferenceChanged(pref => { - if (pref.preferenceName === 'window.menuBarVisibility' - || pref.preferenceName === 'workbench.statusBar.visible') { - const active = this.getActivePerspective(); - if (active) { - this.applyChrome(active); - } - } - }); + this.toDispose.push(this.onDidChangePerspectiveEmitter); } registerPerspective(descriptor: PerspectiveDescriptor): void { @@ -188,17 +172,8 @@ export class PerspectiveService implements FrontendApplicationContribution, Comm } protected applyChrome(descriptor: PerspectiveDescriptor): void { - const perspectiveHidesMenu = descriptor.chromeOptions?.hideMenuBar ?? false; - const prefHidesMenu = ['compact', 'hidden'].includes( - this.corePreferences['window.menuBarVisibility'] - ); - this.shell.topPanel.setHidden(perspectiveHidesMenu || prefHidesMenu); - - const perspectiveHidesStatus = descriptor.chromeOptions?.hideStatusBar ?? false; - const prefHidesStatus = !this.preferenceService.get( - 'workbench.statusBar.visible', true - ); - this.statusBar.setHidden(perspectiveHidesStatus || prefHidesStatus); + this.shell.setMenuBarHiddenByPerspective(descriptor.chromeOptions?.hideMenuBar ?? false); + this.shell.setStatusBarHiddenByPerspective(descriptor.chromeOptions?.hideStatusBar ?? false); } getActivePerspective(): PerspectiveDescriptor | undefined { diff --git a/packages/core/src/browser/shell/application-shell.ts b/packages/core/src/browser/shell/application-shell.ts index 31a7b90d27821..0eed54546fabe 100644 --- a/packages/core/src/browser/shell/application-shell.ts +++ b/packages/core/src/browser/shell/application-shell.ts @@ -414,9 +414,20 @@ export class ApplicationShell extends Widget { this.onDidChangeActiveWidget(updateFocusContextKeys); } + protected perspectiveHidesTopPanel = false; + + setMenuBarHiddenByPerspective(hidden: boolean): void { + this.perspectiveHidesTopPanel = hidden; + this.setTopPanelVisibility(this.corePreferences['window.menuBarVisibility']); + } + + setStatusBarHiddenByPerspective(hidden: boolean): void { + this.statusBar.setHiddenByPerspective(hidden); + } + protected setTopPanelVisibility(preference: string): void { const hiddenPreferences = ['compact', 'hidden']; - this.topPanel.setHidden(hiddenPreferences.includes(preference)); + this.topPanel.setHidden(this.perspectiveHidesTopPanel || hiddenPreferences.includes(preference)); } protected override onBeforeAttach(msg: Message): void { diff --git a/packages/core/src/browser/status-bar/status-bar.tsx b/packages/core/src/browser/status-bar/status-bar.tsx index 1d7a0fe8b5ab7..21282a7f6748a 100644 --- a/packages/core/src/browser/status-bar/status-bar.tsx +++ b/packages/core/src/browser/status-bar/status-bar.tsx @@ -35,6 +35,8 @@ export class StatusBarImpl extends ReactWidget implements StatusBar { protected backgroundColor: string | undefined; protected color: string | undefined; + protected perspectiveHidesStatusBar = false; + constructor( @inject(CommandService) protected readonly commands: CommandService, @inject(LabelParser) protected readonly entryService: LabelParser, @@ -50,19 +52,28 @@ export class StatusBarImpl extends ReactWidget implements StatusBar { // Hide the status bar until the `workbench.statusBar.visible` preference returns with a `true` value. this.hide(); this.preferences.ready.then(() => { - const preferenceValue = this.preferences.get('workbench.statusBar.visible', true); - this.setHidden(!preferenceValue); + this.updateVisibility(); }); this.toDispose.push( this.preferences.onPreferenceChanged(preference => { if (preference.preferenceName === 'workbench.statusBar.visible') { - this.setHidden(!this.preferences.get('workbench.statusBar.visible', true)); + this.updateVisibility(); } }) ); this.toDispose.push(this.viewModel.onDidChange(() => this.debouncedUpdate())); } + setHiddenByPerspective(hidden: boolean): void { + this.perspectiveHidesStatusBar = hidden; + this.updateVisibility(); + } + + protected updateVisibility(): void { + const prefHides = !this.preferences.get('workbench.statusBar.visible', true); + this.setHidden(this.perspectiveHidesStatusBar || prefHides); + } + protected debouncedUpdate = debounce(() => this.update(), 50); protected get ready(): Promise { From ae98b0c749064f3a3bd0c5013efe8438feddd05f Mon Sep 17 00:00:00 2001 From: Camille Letavernier Date: Tue, 7 Jul 2026 13:41:32 +0200 Subject: [PATCH 6/9] feat: do not rely on view-contribution for perspective management --- .../src/browser/perspective-service.spec.ts | 52 ++++++++++++++++++- .../core/src/browser/perspective-service.ts | 8 +++ .../src/browser/shell/application-shell.ts | 26 +++++++++- .../src/browser/shell/view-contribution.ts | 6 --- 4 files changed, 84 insertions(+), 8 deletions(-) diff --git a/packages/core/src/browser/perspective-service.spec.ts b/packages/core/src/browser/perspective-service.spec.ts index e2992407d7517..65e7a738f9fef 100644 --- a/packages/core/src/browser/perspective-service.spec.ts +++ b/packages/core/src/browser/perspective-service.spec.ts @@ -36,6 +36,7 @@ describe('PerspectiveService', () => { let collapsePanelStub: sinon.SinonStub; let setMenuBarHiddenByPerspectiveStub: sinon.SinonStub; let setStatusBarHiddenByPerspectiveStub: sinon.SinonStub; + let setWidgetAreaResolverStub: sinon.SinonStub; let testWidget: Widget; let toTearDown: () => void; @@ -55,6 +56,7 @@ describe('PerspectiveService', () => { collapsePanelStub = sinon.stub().resolves(); setMenuBarHiddenByPerspectiveStub = sinon.stub(); setStatusBarHiddenByPerspectiveStub = sinon.stub(); + setWidgetAreaResolverStub = sinon.stub(); const mockShell = { addWidget: addWidgetStub, @@ -65,7 +67,8 @@ describe('PerspectiveService', () => { setLayoutData: setLayoutDataStub, collapsePanel: collapsePanelStub, setMenuBarHiddenByPerspective: setMenuBarHiddenByPerspectiveStub, - setStatusBarHiddenByPerspective: setStatusBarHiddenByPerspectiveStub + setStatusBarHiddenByPerspective: setStatusBarHiddenByPerspectiveStub, + setWidgetAreaResolver: setWidgetAreaResolverStub }; const mockWidgetManager = { @@ -624,4 +627,51 @@ describe('PerspectiveService', () => { expect(collapsePanelStub.called).to.be.false; }); + + // --- WidgetAreaResolver registration tests --- + + it('should register a widget area resolver on the shell during initialize', () => { + service.initialize(); + + expect(setWidgetAreaResolverStub.calledOnce).to.be.true; + expect(typeof setWidgetAreaResolverStub.firstCall.args[0]).to.equal('function'); + }); + + it('should resolve widget area from active perspective via the registered resolver', async () => { + service.initialize(); + + service.registerPerspective({ + id: 'resolver-test', + label: 'Resolver Test', + viewPlacements: new Map([['my-widget', 'right' as ApplicationShell.Area]]) + }); + + await service.switchPerspective('resolver-test'); + + const resolver = setWidgetAreaResolverStub.firstCall.args[0] as (widgetId: string, requestedArea: ApplicationShell.Area) => ApplicationShell.Area | undefined; + expect(resolver('my-widget', 'left')).to.equal('right'); + }); + + it('should return undefined from the resolver for unmapped widgets', async () => { + service.initialize(); + + service.registerPerspective({ + id: 'resolver-test', + label: 'Resolver Test', + viewPlacements: new Map([['my-widget', 'right' as ApplicationShell.Area]]) + }); + + await service.switchPerspective('resolver-test'); + + const resolver = setWidgetAreaResolverStub.firstCall.args[0] as (widgetId: string, requestedArea: ApplicationShell.Area) => ApplicationShell.Area | undefined; + expect(resolver('unknown-widget', 'main')).to.be.undefined; + }); + + it('should return undefined from the resolver when default perspective is active', () => { + service.initialize(); + + const resolver = setWidgetAreaResolverStub.firstCall.args[0] as (widgetId: string, requestedArea: ApplicationShell.Area) => ApplicationShell.Area | undefined; + // Default perspective has empty viewPlacements + expect(resolver('any-widget', 'main')).to.be.undefined; + }); }); diff --git a/packages/core/src/browser/perspective-service.ts b/packages/core/src/browser/perspective-service.ts index b65b078e23683..1144451c655f5 100644 --- a/packages/core/src/browser/perspective-service.ts +++ b/packages/core/src/browser/perspective-service.ts @@ -93,6 +93,14 @@ export class PerspectiveService implements FrontendApplicationContribution, Comm }); this.activePerspectiveId = PerspectiveService.DEFAULT_PERSPECTIVE_ID; + this.shell.setWidgetAreaResolver((widgetId, requestedArea) => { + const active = this.getActivePerspective(); + if (active) { + return active.viewPlacements.get(widgetId); + } + return undefined; + }); + if (this.contributions) { for (const contribution of this.contributions.getContributions()) { contribution.registerPerspectives(this); diff --git a/packages/core/src/browser/shell/application-shell.ts b/packages/core/src/browser/shell/application-shell.ts index 0eed54546fabe..8c7cf8f944ffb 100644 --- a/packages/core/src/browser/shell/application-shell.ts +++ b/packages/core/src/browser/shell/application-shell.ts @@ -988,12 +988,30 @@ export class ApplicationShell extends Widget { * * Widgets added to the top area are not tracked regarding the _current_ and _active_ states. */ + protected widgetAreaResolver?: WidgetAreaResolver; + + setWidgetAreaResolver(resolver: WidgetAreaResolver | undefined): void { + this.widgetAreaResolver = resolver; + } + + protected resolveWidgetArea(widget: Widget, options?: Readonly): Readonly | undefined { + if (this.widgetAreaResolver && !options?.ref) { + const requestedArea = options?.area || 'main'; + const resolvedArea = this.widgetAreaResolver(widget.id, requestedArea); + if (resolvedArea && resolvedArea !== requestedArea) { + return { ...options, area: resolvedArea }; + } + } + return options; + } + async addWidget(widget: Widget, options?: Readonly): Promise { if (!widget.id) { this.logger.error('Widgets added to the application shell must have a unique id property.'); return; } - const { area, addOptions } = this.getInsertionOptions(options); + const resolvedOptions = this.resolveWidgetArea(widget, options); + const { area, addOptions } = this.getInsertionOptions(resolvedOptions); const sidePanelOptions: SidePanel.WidgetOptions = { rank: options?.rank }; switch (area) { case 'main': @@ -2237,6 +2255,12 @@ export class ApplicationShell extends Widget { /** * The namespace for `ApplicationShell` class statics. */ +/** + * A function that can override the shell area for a widget. + * Returns a new area to use, or `undefined` to keep the original. + */ +export type WidgetAreaResolver = (widgetId: string, requestedArea: ApplicationShell.Area) => ApplicationShell.Area | undefined; + export namespace ApplicationShell { /** * The areas of the application shell where widgets can reside. diff --git a/packages/core/src/browser/shell/view-contribution.ts b/packages/core/src/browser/shell/view-contribution.ts index 9e0ad468fa915..6229664dacbea 100644 --- a/packages/core/src/browser/shell/view-contribution.ts +++ b/packages/core/src/browser/shell/view-contribution.ts @@ -25,7 +25,6 @@ import { WidgetManager } from '../widget-manager'; import { CommonMenus } from '../common-menus'; import { ApplicationShell } from './application-shell'; import { QuickViewService } from '../quick-input'; -import { PerspectiveService } from '../perspective-service'; export interface OpenViewArguments extends ApplicationShell.WidgetOptions { toggle?: boolean @@ -62,9 +61,6 @@ export abstract class AbstractViewContribution implements Comm @inject(QuickViewService) @optional() protected readonly quickView: QuickViewService; - @inject(PerspectiveService) @optional() - protected readonly perspectiveService: PerspectiveService; - readonly toggleCommand?: Command; constructor( @@ -106,10 +102,8 @@ export abstract class AbstractViewContribution implements Comm const area = shell.getAreaFor(widget); if (!tabBar) { // The widget is not attached yet, so add it to the shell - const perspectiveArea = this.perspectiveService?.getAreaForView(this.options.viewContainerId || this.viewId); const widgetArgs: OpenViewArguments = { ...this.defaultViewOptions, - ...perspectiveArea ? { area: perspectiveArea } : {}, ...args }; await shell.addWidget(widget, widgetArgs); From 2183211262a8c5b8ade321017bff1bb9034f1eea Mon Sep 17 00:00:00 2001 From: Camille Letavernier Date: Tue, 7 Jul 2026 15:31:04 +0200 Subject: [PATCH 7/9] fix: address review comments --- .../ai-first-perspective-contribution.ts | 5 +- .../src/browser/perspective-service.spec.ts | 89 +++++++++++++++++++ .../core/src/browser/perspective-service.ts | 35 +++++--- .../src/browser/shell/application-shell.ts | 6 +- 4 files changed, 118 insertions(+), 17 deletions(-) diff --git a/packages/ai-ide/src/browser/ai-first-perspective-contribution.ts b/packages/ai-ide/src/browser/ai-first-perspective-contribution.ts index 32cb0eda6464f..25a670ec002e4 100644 --- a/packages/ai-ide/src/browser/ai-first-perspective-contribution.ts +++ b/packages/ai-ide/src/browser/ai-first-perspective-contribution.ts @@ -20,8 +20,7 @@ import { PerspectiveContribution, PerspectiveChromeOptions, PerspectiveService } import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell'; import { EXPLORER_VIEW_CONTAINER_ID } from '@theia/navigator/lib/browser'; import { SCM_VIEW_CONTAINER_ID } from '@theia/scm/lib/browser/scm-contribution'; - -const CHAT_VIEW_WIDGET_ID = 'chat-view-widget'; +import { ChatViewWidget } from '@theia/ai-chat-ui/lib/browser/chat-view-widget'; @injectable() export class AIFirstPerspectiveContribution implements PerspectiveContribution { @@ -36,7 +35,7 @@ export class AIFirstPerspectiveContribution implements PerspectiveContribution { id: 'ai-first', label: nls.localize('theia/ai-ide/perspective/aiFirst', 'AI First'), viewPlacements: new Map([ - [CHAT_VIEW_WIDGET_ID, 'main'], + [ChatViewWidget.ID, 'main'], [EXPLORER_VIEW_CONTAINER_ID, 'right'], [SCM_VIEW_CONTAINER_ID, 'right'] ]), diff --git a/packages/core/src/browser/perspective-service.spec.ts b/packages/core/src/browser/perspective-service.spec.ts index 65e7a738f9fef..3f113013ff001 100644 --- a/packages/core/src/browser/perspective-service.spec.ts +++ b/packages/core/src/browser/perspective-service.spec.ts @@ -37,6 +37,7 @@ describe('PerspectiveService', () => { let setMenuBarHiddenByPerspectiveStub: sinon.SinonStub; let setStatusBarHiddenByPerspectiveStub: sinon.SinonStub; let setWidgetAreaResolverStub: sinon.SinonStub; + let mockLogger: { debug: sinon.SinonStub }; let testWidget: Widget; let toTearDown: () => void; @@ -57,6 +58,7 @@ describe('PerspectiveService', () => { setMenuBarHiddenByPerspectiveStub = sinon.stub(); setStatusBarHiddenByPerspectiveStub = sinon.stub(); setWidgetAreaResolverStub = sinon.stub(); + mockLogger = { debug: sinon.stub() }; const mockShell = { addWidget: addWidgetStub, @@ -78,6 +80,7 @@ describe('PerspectiveService', () => { // Assign mocks via property access since we can't use DI in tests (service as unknown as Record)['shell'] = mockShell; (service as unknown as Record)['widgetManager'] = mockWidgetManager; + (service as unknown as Record)['logger'] = mockLogger; }); afterEach(() => { @@ -674,4 +677,90 @@ describe('PerspectiveService', () => { // Default perspective has empty viewPlacements expect(resolver('any-widget', 'main')).to.be.undefined; }); + + // --- Logger tests --- + + it('should log debug when widget creation fails during switchPerspective', async () => { + const widgetError = new Error('No factory registered'); + getOrCreateWidgetStub.rejects(widgetError); + + service.registerPerspective({ + id: 'fail-test', + label: 'Fail Test', + viewPlacements: new Map([['missing-widget', 'main' as ApplicationShell.Area]]) + }); + + await service.switchPerspective('fail-test'); + + expect(mockLogger.debug.calledOnce).to.be.true; + expect(mockLogger.debug.firstCall.args[0]).to.equal('Failed to create or place widget for perspective'); + expect(mockLogger.debug.firstCall.args[1]).to.equal(widgetError); + }); + + it('should log debug when widget activation fails during switchPerspective', async () => { + const activationError = new Error('Activation failed'); + activateWidgetStub.rejects(activationError); + + service.registerPerspective({ + id: 'activate-fail', + label: 'Activate Fail', + viewPlacements: new Map([['test-widget', 'main' as ApplicationShell.Area]]) + }); + + await service.switchPerspective('activate-fail'); + + expect(mockLogger.debug.called).to.be.true; + const activateCall = mockLogger.debug.getCalls().find( + (c: sinon.SinonSpyCall) => c.args[0] === 'Failed to activate widget for perspective' + ); + expect(activateCall).to.not.be.undefined; + expect(activateCall!.args[1]).to.equal(activationError); + }); + + // --- Reentrancy guard tests --- + + it('should handle two rapid switchPerspective calls without interleaving', async () => { + service.registerPerspective({ + id: 'first', + label: 'First', + viewPlacements: new Map() + }); + service.registerPerspective({ + id: 'second', + label: 'Second', + viewPlacements: new Map() + }); + + const promise1 = service.switchPerspective('first'); + const promise2 = service.switchPerspective('second'); + + await Promise.all([promise1, promise2]); + + expect(service.getActivePerspective()?.id).to.equal('second'); + }); + + it('should serialize perspective switches with reentrancy guard', async () => { + const callOrder: string[] = []; + + service.registerPerspective({ + id: 'perspA', + label: 'A', + viewPlacements: new Map(), + onActivate: () => callOrder.push('activate-A') + }); + service.registerPerspective({ + id: 'perspB', + label: 'B', + viewPlacements: new Map(), + onActivate: () => callOrder.push('activate-B') + }); + + const p1 = service.switchPerspective('perspA'); + const p2 = service.switchPerspective('perspB'); + + await Promise.all([p1, p2]); + + expect(callOrder).to.deep.equal(['activate-A', 'activate-B']); + expect(service.getActivePerspective()?.id).to.equal('perspB'); + }); }); diff --git a/packages/core/src/browser/perspective-service.ts b/packages/core/src/browser/perspective-service.ts index 1144451c655f5..bc99c11767aab 100644 --- a/packages/core/src/browser/perspective-service.ts +++ b/packages/core/src/browser/perspective-service.ts @@ -21,6 +21,7 @@ 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 { DisposableCollection } from '../common/disposable'; @@ -74,6 +75,9 @@ export class PerspectiveService implements FrontendApplicationContribution, Comm @inject(QuickInputService) @optional() protected readonly quickInputService: QuickInputService | undefined; + @inject(ILogger) @named('core:PerspectiveService') + protected readonly logger: ILogger; + static readonly DEFAULT_PERSPECTIVE_ID = 'default'; protected readonly perspectives = new Map(); @@ -84,6 +88,7 @@ export class PerspectiveService implements FrontendApplicationContribution, Comm readonly onDidChangePerspective: Event = this.onDidChangePerspectiveEmitter.event; protected readonly toDispose = new DisposableCollection(); + protected switchInProgress: Promise | undefined; initialize(): void { this.registerPerspective({ @@ -93,13 +98,9 @@ export class PerspectiveService implements FrontendApplicationContribution, Comm }); this.activePerspectiveId = PerspectiveService.DEFAULT_PERSPECTIVE_ID; - this.shell.setWidgetAreaResolver((widgetId, requestedArea) => { - const active = this.getActivePerspective(); - if (active) { - return active.viewPlacements.get(widgetId); - } - return undefined; - }); + this.shell.setWidgetAreaResolver((widgetId, _requestedArea) => + this.getAreaForView(widgetId) + ); if (this.contributions) { for (const contribution of this.contributions.getContributions()) { @@ -115,6 +116,18 @@ export class PerspectiveService implements FrontendApplicationContribution, Comm } async switchPerspective(id: string): Promise { + if (this.switchInProgress) { + await this.switchInProgress; + } + this.switchInProgress = this.doSwitchPerspective(id); + try { + await this.switchInProgress; + } finally { + this.switchInProgress = undefined; + } + } + + protected async doSwitchPerspective(id: string): Promise { if (id === this.activePerspectiveId) { return; } @@ -150,16 +163,16 @@ export class PerspectiveService implements FrontendApplicationContribution, Comm } } await this.shell.addWidget(widget, { area }); - } catch { - // Widget factory may not be registered — skip silently + } catch (error) { + this.logger.debug('Failed to create or place widget for perspective', error); } } for (const [viewId] of descriptor.viewPlacements) { try { await this.shell.activateWidget(viewId); - } catch { - // Ignore activation errors + } catch (error) { + this.logger.debug('Failed to activate widget for perspective', error); } } diff --git a/packages/core/src/browser/shell/application-shell.ts b/packages/core/src/browser/shell/application-shell.ts index 8c7cf8f944ffb..54620caf92117 100644 --- a/packages/core/src/browser/shell/application-shell.ts +++ b/packages/core/src/browser/shell/application-shell.ts @@ -2252,15 +2252,15 @@ export class ApplicationShell extends Widget { } } -/** - * The namespace for `ApplicationShell` class statics. - */ /** * A function that can override the shell area for a widget. * Returns a new area to use, or `undefined` to keep the original. */ export type WidgetAreaResolver = (widgetId: string, requestedArea: ApplicationShell.Area) => ApplicationShell.Area | undefined; +/** + * The namespace for `ApplicationShell` class statics. + */ export namespace ApplicationShell { /** * The areas of the application shell where widgets can reside. From 3a656493046061f697518143cf6f3c023acafe80 Mon Sep 17 00:00:00 2001 From: Camille Letavernier Date: Tue, 7 Jul 2026 16:38:43 +0200 Subject: [PATCH 8/9] feat: remove menu bar filtering --- .../ai-first-perspective-contribution.ts | 1 - .../src/browser/perspective-service.spec.ts | 34 +------------------ .../core/src/browser/perspective-service.ts | 3 -- .../src/browser/shell/application-shell.ts | 9 +---- 4 files changed, 2 insertions(+), 45 deletions(-) diff --git a/packages/ai-ide/src/browser/ai-first-perspective-contribution.ts b/packages/ai-ide/src/browser/ai-first-perspective-contribution.ts index 25a670ec002e4..9db2f11a4c6c9 100644 --- a/packages/ai-ide/src/browser/ai-first-perspective-contribution.ts +++ b/packages/ai-ide/src/browser/ai-first-perspective-contribution.ts @@ -27,7 +27,6 @@ export class AIFirstPerspectiveContribution implements PerspectiveContribution { registerPerspectives(service: PerspectiveService): void { const chromeOptions: PerspectiveChromeOptions = { - hideMenuBar: true, hideStatusBar: true, collapseAreas: ['left', 'bottom'] }; diff --git a/packages/core/src/browser/perspective-service.spec.ts b/packages/core/src/browser/perspective-service.spec.ts index 3f113013ff001..928b856ea12be 100644 --- a/packages/core/src/browser/perspective-service.spec.ts +++ b/packages/core/src/browser/perspective-service.spec.ts @@ -34,7 +34,6 @@ describe('PerspectiveService', () => { let getLayoutDataStub: sinon.SinonStub; let setLayoutDataStub: sinon.SinonStub; let collapsePanelStub: sinon.SinonStub; - let setMenuBarHiddenByPerspectiveStub: sinon.SinonStub; let setStatusBarHiddenByPerspectiveStub: sinon.SinonStub; let setWidgetAreaResolverStub: sinon.SinonStub; let mockLogger: { debug: sinon.SinonStub }; @@ -55,7 +54,6 @@ describe('PerspectiveService', () => { getLayoutDataStub = sinon.stub().returns({ mainPanel: {}, bottomPanel: {} }); setLayoutDataStub = sinon.stub().resolves(); collapsePanelStub = sinon.stub().resolves(); - setMenuBarHiddenByPerspectiveStub = sinon.stub(); setStatusBarHiddenByPerspectiveStub = sinon.stub(); setWidgetAreaResolverStub = sinon.stub(); mockLogger = { debug: sinon.stub() }; @@ -68,7 +66,6 @@ describe('PerspectiveService', () => { getLayoutData: getLayoutDataStub, setLayoutData: setLayoutDataStub, collapsePanel: collapsePanelStub, - setMenuBarHiddenByPerspective: setMenuBarHiddenByPerspectiveStub, setStatusBarHiddenByPerspective: setStatusBarHiddenByPerspectiveStub, setWidgetAreaResolver: setWidgetAreaResolverStub }; @@ -491,31 +488,6 @@ describe('PerspectiveService', () => { // --- Chrome control options tests --- - it('should hide menu bar when perspective has hideMenuBar', async () => { - service.registerPerspective({ - id: 'chrome-test', - label: 'Chrome Test', - viewPlacements: new Map(), - chromeOptions: { hideMenuBar: true } - }); - - await service.switchPerspective('chrome-test'); - - expect(setMenuBarHiddenByPerspectiveStub.calledWith(true)).to.be.true; - }); - - it('should show menu bar when perspective does not hide it', async () => { - service.registerPerspective({ - id: 'no-chrome', - label: 'No Chrome', - viewPlacements: new Map() - }); - - await service.switchPerspective('no-chrome'); - - expect(setMenuBarHiddenByPerspectiveStub.calledWith(false)).to.be.true; - }); - it('should hide status bar when perspective has hideStatusBar', async () => { service.registerPerspective({ id: 'chrome-test', @@ -576,7 +548,7 @@ describe('PerspectiveService', () => { id: 'chrome-test', label: 'Chrome Test', viewPlacements: new Map(), - chromeOptions: { hideMenuBar: true, hideStatusBar: true } + chromeOptions: { hideStatusBar: true } }); service.registerPerspective({ id: 'other', @@ -586,21 +558,17 @@ describe('PerspectiveService', () => { // First activation await service.switchPerspective('chrome-test'); - expect(setMenuBarHiddenByPerspectiveStub.calledWith(true)).to.be.true; expect(setStatusBarHiddenByPerspectiveStub.calledWith(true)).to.be.true; - setMenuBarHiddenByPerspectiveStub.resetHistory(); setStatusBarHiddenByPerspectiveStub.resetHistory(); // Switch away (saves layout) await service.switchPerspective('other'); - setMenuBarHiddenByPerspectiveStub.resetHistory(); setStatusBarHiddenByPerspectiveStub.resetHistory(); // Switch back (restores saved layout) — chrome should still be applied await service.switchPerspective('chrome-test'); - expect(setMenuBarHiddenByPerspectiveStub.calledWith(true)).to.be.true; expect(setStatusBarHiddenByPerspectiveStub.calledWith(true)).to.be.true; }); diff --git a/packages/core/src/browser/perspective-service.ts b/packages/core/src/browser/perspective-service.ts index bc99c11767aab..e084ff3614fec 100644 --- a/packages/core/src/browser/perspective-service.ts +++ b/packages/core/src/browser/perspective-service.ts @@ -28,8 +28,6 @@ import { DisposableCollection } from '../common/disposable'; import { CommonCommands } from './common-commands'; export interface PerspectiveChromeOptions { - /** Hide the top menu bar. Default: false. */ - hideMenuBar?: boolean; /** Hide the status bar. Default: false. */ hideStatusBar?: boolean; /** Areas to collapse on first activation. User can re-expand freely. */ @@ -193,7 +191,6 @@ export class PerspectiveService implements FrontendApplicationContribution, Comm } protected applyChrome(descriptor: PerspectiveDescriptor): void { - this.shell.setMenuBarHiddenByPerspective(descriptor.chromeOptions?.hideMenuBar ?? false); this.shell.setStatusBarHiddenByPerspective(descriptor.chromeOptions?.hideStatusBar ?? false); } diff --git a/packages/core/src/browser/shell/application-shell.ts b/packages/core/src/browser/shell/application-shell.ts index 54620caf92117..3e676f0559565 100644 --- a/packages/core/src/browser/shell/application-shell.ts +++ b/packages/core/src/browser/shell/application-shell.ts @@ -414,20 +414,13 @@ export class ApplicationShell extends Widget { this.onDidChangeActiveWidget(updateFocusContextKeys); } - protected perspectiveHidesTopPanel = false; - - setMenuBarHiddenByPerspective(hidden: boolean): void { - this.perspectiveHidesTopPanel = hidden; - this.setTopPanelVisibility(this.corePreferences['window.menuBarVisibility']); - } - setStatusBarHiddenByPerspective(hidden: boolean): void { this.statusBar.setHiddenByPerspective(hidden); } protected setTopPanelVisibility(preference: string): void { const hiddenPreferences = ['compact', 'hidden']; - this.topPanel.setHidden(this.perspectiveHidesTopPanel || hiddenPreferences.includes(preference)); + this.topPanel.setHidden(hiddenPreferences.includes(preference)); } protected override onBeforeAttach(msg: Message): void { From 91bc4b0c4ad178c10c70824facb8292fac50ad2f Mon Sep 17 00:00:00 2001 From: Camille Letavernier Date: Tue, 7 Jul 2026 17:54:11 +0200 Subject: [PATCH 9/9] fix: properly chain perspective switching promises for consistency --- .../src/browser/perspective-service.spec.ts | 32 +++++++++++++++++++ .../core/src/browser/perspective-service.ts | 13 ++++---- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/packages/core/src/browser/perspective-service.spec.ts b/packages/core/src/browser/perspective-service.spec.ts index 928b856ea12be..f309a7488e32e 100644 --- a/packages/core/src/browser/perspective-service.spec.ts +++ b/packages/core/src/browser/perspective-service.spec.ts @@ -731,4 +731,36 @@ describe('PerspectiveService', () => { expect(callOrder).to.deep.equal(['activate-A', 'activate-B']); expect(service.getActivePerspective()?.id).to.equal('perspB'); }); + + it('should serialize three or more concurrent perspective switches', async () => { + const callOrder: string[] = []; + + service.registerPerspective({ + id: 'perspA', + label: 'A', + viewPlacements: new Map(), + onActivate: () => callOrder.push('activate-A') + }); + service.registerPerspective({ + id: 'perspB', + label: 'B', + viewPlacements: new Map(), + onActivate: () => callOrder.push('activate-B') + }); + service.registerPerspective({ + id: 'perspC', + label: 'C', + viewPlacements: new Map(), + onActivate: () => callOrder.push('activate-C') + }); + + const p1 = service.switchPerspective('perspA'); + const p2 = service.switchPerspective('perspB'); + const p3 = service.switchPerspective('perspC'); + + await Promise.all([p1, p2, p3]); + + expect(callOrder).to.deep.equal(['activate-A', 'activate-B', 'activate-C']); + expect(service.getActivePerspective()?.id).to.equal('perspC'); + }); }); diff --git a/packages/core/src/browser/perspective-service.ts b/packages/core/src/browser/perspective-service.ts index e084ff3614fec..e7c44444f967f 100644 --- a/packages/core/src/browser/perspective-service.ts +++ b/packages/core/src/browser/perspective-service.ts @@ -114,14 +114,15 @@ export class PerspectiveService implements FrontendApplicationContribution, Comm } async switchPerspective(id: string): Promise { - if (this.switchInProgress) { - await this.switchInProgress; - } - this.switchInProgress = this.doSwitchPerspective(id); + const pending = (this.switchInProgress ?? Promise.resolve()) + .then(() => this.doSwitchPerspective(id)); + this.switchInProgress = pending; try { - await this.switchInProgress; + await pending; } finally { - this.switchInProgress = undefined; + if (this.switchInProgress === pending) { + this.switchInProgress = undefined; + } } }