@theia/core 1.74.0-next.9 → 1.74.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -10
- package/i18n/nls.cs.json +143 -16
- package/i18n/nls.de.json +143 -16
- package/i18n/nls.es.json +143 -16
- package/i18n/nls.fr.json +143 -16
- package/i18n/nls.hu.json +143 -16
- package/i18n/nls.it.json +143 -16
- package/i18n/nls.ja.json +143 -16
- package/i18n/nls.json +143 -16
- package/i18n/nls.ko.json +143 -16
- package/i18n/nls.pl.json +143 -16
- package/i18n/nls.pt-br.json +143 -16
- package/i18n/nls.ru.json +143 -16
- package/i18n/nls.tr.json +143 -16
- package/i18n/nls.zh-cn.json +143 -16
- package/i18n/nls.zh-tw.json +143 -16
- package/lib/browser/browser-clipboard-service.d.ts +3 -0
- package/lib/browser/browser-clipboard-service.d.ts.map +1 -1
- package/lib/browser/browser-clipboard-service.js +8 -1
- package/lib/browser/browser-clipboard-service.js.map +1 -1
- package/lib/browser/catalog.json +505 -46
- package/lib/browser/clipboard-service.d.ts +11 -0
- package/lib/browser/clipboard-service.d.ts.map +1 -1
- package/lib/browser/clipboard-service.js.map +1 -1
- package/lib/browser/common-frontend-contribution.d.ts +3 -0
- package/lib/browser/common-frontend-contribution.d.ts.map +1 -1
- package/lib/browser/common-frontend-contribution.js +22 -10
- package/lib/browser/common-frontend-contribution.js.map +1 -1
- package/lib/browser/common-frontend-contribution.spec.d.ts +2 -0
- package/lib/browser/common-frontend-contribution.spec.d.ts.map +1 -0
- package/lib/browser/common-frontend-contribution.spec.js +71 -0
- package/lib/browser/common-frontend-contribution.spec.js.map +1 -0
- package/lib/browser/frontend-application-contribution.d.ts +1 -1
- package/lib/browser/frontend-application-module.d.ts +2 -2
- package/lib/browser/frontend-application-module.d.ts.map +1 -1
- package/lib/browser/frontend-application-module.js +13 -2
- package/lib/browser/frontend-application-module.js.map +1 -1
- package/lib/browser/frontend-application.d.ts.map +1 -1
- package/lib/browser/frontend-application.js +12 -2
- package/lib/browser/frontend-application.js.map +1 -1
- package/lib/browser/index.d.ts +1 -0
- package/lib/browser/index.d.ts.map +1 -1
- package/lib/browser/index.js +1 -0
- package/lib/browser/index.js.map +1 -1
- package/lib/browser/keybinding.d.ts.map +1 -1
- package/lib/browser/keybinding.js +12 -7
- package/lib/browser/keybinding.js.map +1 -1
- package/lib/browser/perspective-service.d.ts +173 -0
- package/lib/browser/perspective-service.d.ts.map +1 -0
- package/lib/browser/perspective-service.js +484 -0
- package/lib/browser/perspective-service.js.map +1 -0
- package/lib/browser/perspective-service.spec.d.ts +2 -0
- package/lib/browser/perspective-service.spec.d.ts.map +1 -0
- package/lib/browser/perspective-service.spec.js +1806 -0
- package/lib/browser/perspective-service.spec.js.map +1 -0
- package/lib/browser/preferences/preference-contribution.d.ts.map +1 -1
- package/lib/browser/preferences/preference-contribution.js +9 -1
- package/lib/browser/preferences/preference-contribution.js.map +1 -1
- package/lib/browser/preferences/preference-service.spec.js +63 -0
- package/lib/browser/preferences/preference-service.spec.js.map +1 -1
- package/lib/browser/shell/application-shell.d.ts +7 -0
- package/lib/browser/shell/application-shell.d.ts.map +1 -1
- package/lib/browser/shell/application-shell.js +38 -28
- package/lib/browser/shell/application-shell.js.map +1 -1
- package/lib/browser/shell/shell-layout-restorer.d.ts +9 -0
- package/lib/browser/shell/shell-layout-restorer.d.ts.map +1 -1
- package/lib/browser/shell/shell-layout-restorer.js +124 -14
- package/lib/browser/shell/shell-layout-restorer.js.map +1 -1
- package/lib/browser/shell/shell-layout-restorer.spec.d.ts +2 -0
- package/lib/browser/shell/shell-layout-restorer.spec.d.ts.map +1 -0
- package/lib/browser/shell/shell-layout-restorer.spec.js +539 -0
- package/lib/browser/shell/shell-layout-restorer.spec.js.map +1 -0
- package/lib/browser/shell/view-contribution.d.ts +2 -0
- package/lib/browser/shell/view-contribution.d.ts.map +1 -1
- package/lib/browser/shell/view-contribution.js +5 -1
- package/lib/browser/shell/view-contribution.js.map +1 -1
- package/lib/browser/status-bar/status-bar.d.ts +2 -0
- package/lib/browser/status-bar/status-bar.d.ts.map +1 -1
- package/lib/browser/status-bar/status-bar.js +11 -3
- package/lib/browser/status-bar/status-bar.js.map +1 -1
- package/lib/browser/tree/tree-consistency.spec.js +103 -0
- package/lib/browser/tree/tree-consistency.spec.js.map +1 -1
- package/lib/browser/tree/tree-widget.d.ts +7 -33
- package/lib/browser/tree/tree-widget.d.ts.map +1 -1
- package/lib/browser/tree/tree-widget.js +7 -54
- package/lib/browser/tree/tree-widget.js.map +1 -1
- package/lib/browser/tree/tree.d.ts.map +1 -1
- package/lib/browser/tree/tree.js +6 -0
- package/lib/browser/tree/tree.js.map +1 -1
- package/lib/browser/widgets/index.d.ts +1 -0
- package/lib/browser/widgets/index.d.ts.map +1 -1
- package/lib/browser/widgets/index.js +1 -0
- package/lib/browser/widgets/index.js.map +1 -1
- package/lib/browser/widgets/select-component.d.ts +15 -2
- package/lib/browser/widgets/select-component.d.ts.map +1 -1
- package/lib/browser/widgets/select-component.js +86 -24
- package/lib/browser/widgets/select-component.js.map +1 -1
- package/lib/browser/widgets/tab-bar-tracker.d.ts +23 -0
- package/lib/browser/widgets/tab-bar-tracker.d.ts.map +1 -0
- package/lib/browser/widgets/tab-bar-tracker.js +65 -0
- package/lib/browser/widgets/tab-bar-tracker.js.map +1 -0
- package/lib/browser/widgets/tab-bar-tracker.spec.d.ts +2 -0
- package/lib/browser/widgets/tab-bar-tracker.spec.d.ts.map +1 -0
- package/lib/browser/widgets/tab-bar-tracker.spec.js +109 -0
- package/lib/browser/widgets/tab-bar-tracker.spec.js.map +1 -0
- package/lib/browser/window/default-secondary-window-service.d.ts +2 -0
- package/lib/browser/window/default-secondary-window-service.d.ts.map +1 -1
- package/lib/browser/window/default-secondary-window-service.js +8 -2
- package/lib/browser/window/default-secondary-window-service.js.map +1 -1
- package/lib/browser/window/default-window-service.d.ts +19 -0
- package/lib/browser/window/default-window-service.d.ts.map +1 -1
- package/lib/browser/window/default-window-service.js +34 -5
- package/lib/browser/window/default-window-service.js.map +1 -1
- package/lib/browser/window/default-window-service.spec.js +22 -0
- package/lib/browser/window/default-window-service.spec.js.map +1 -1
- package/lib/browser/window/secondary-window-service.d.ts +7 -0
- package/lib/browser/window/secondary-window-service.d.ts.map +1 -1
- package/lib/browser/window/window-service.d.ts +1 -1
- package/lib/browser-only/messaging/frontend-only-service-connection-provider.d.ts +13 -2
- package/lib/browser-only/messaging/frontend-only-service-connection-provider.d.ts.map +1 -1
- package/lib/browser-only/messaging/frontend-only-service-connection-provider.js +31 -3
- package/lib/browser-only/messaging/frontend-only-service-connection-provider.js.map +1 -1
- package/lib/common/preferences/index.d.ts +1 -0
- package/lib/common/preferences/index.d.ts.map +1 -1
- package/lib/common/preferences/index.js +1 -0
- package/lib/common/preferences/index.js.map +1 -1
- package/lib/common/preferences/preference-provider.d.ts +15 -0
- package/lib/common/preferences/preference-provider.d.ts.map +1 -1
- package/lib/common/preferences/preference-provider.js +21 -1
- package/lib/common/preferences/preference-provider.js.map +1 -1
- package/lib/common/preferences/preference-provider.spec.js +17 -0
- package/lib/common/preferences/preference-provider.spec.js.map +1 -1
- package/lib/common/preferences/preference-scope.d.ts +6 -1
- package/lib/common/preferences/preference-scope.d.ts.map +1 -1
- package/lib/common/preferences/preference-scope.js +5 -0
- package/lib/common/preferences/preference-scope.js.map +1 -1
- package/lib/common/preferences/preference-scope.spec.js +5 -3
- package/lib/common/preferences/preference-scope.spec.js.map +1 -1
- package/lib/common/preferences/preference-service.d.ts +19 -0
- package/lib/common/preferences/preference-service.d.ts.map +1 -1
- package/lib/common/preferences/preference-service.js +34 -5
- package/lib/common/preferences/preference-service.js.map +1 -1
- package/lib/common/preferences/session-preference-provider.d.ts +17 -0
- package/lib/common/preferences/session-preference-provider.d.ts.map +1 -0
- package/lib/common/preferences/session-preference-provider.js +77 -0
- package/lib/common/preferences/session-preference-provider.js.map +1 -0
- package/lib/common/preferences/session-preference-provider.spec.d.ts +2 -0
- package/lib/common/preferences/session-preference-provider.spec.d.ts.map +1 -0
- package/lib/common/preferences/session-preference-provider.spec.js +71 -0
- package/lib/common/preferences/session-preference-provider.spec.js.map +1 -0
- package/lib/electron-browser/electron-clipboard-service.d.ts +3 -0
- package/lib/electron-browser/electron-clipboard-service.d.ts.map +1 -1
- package/lib/electron-browser/electron-clipboard-service.js +6 -0
- package/lib/electron-browser/electron-clipboard-service.js.map +1 -1
- package/lib/electron-browser/menu/electron-menu-contribution.js +1 -1
- package/lib/electron-browser/menu/electron-menu-contribution.js.map +1 -1
- package/lib/electron-browser/preload.d.ts.map +1 -1
- package/lib/electron-browser/preload.js +2 -1
- package/lib/electron-browser/preload.js.map +1 -1
- package/lib/electron-browser/window/electron-secondary-window-service.js +1 -1
- package/lib/electron-browser/window/electron-secondary-window-service.js.map +1 -1
- package/lib/electron-browser/window/electron-window-service.js +3 -3
- package/lib/electron-browser/window/electron-window-service.js.map +1 -1
- package/lib/electron-common/electron-api.d.ts +2 -0
- package/lib/electron-common/electron-api.d.ts.map +1 -1
- package/lib/electron-common/electron-api.js +2 -1
- package/lib/electron-common/electron-api.js.map +1 -1
- package/lib/electron-main/electron-api-main.d.ts.map +1 -1
- package/lib/electron-main/electron-api-main.js +43 -0
- package/lib/electron-main/electron-api-main.js.map +1 -1
- package/lib/electron-main/electron-main-application.d.ts.map +1 -1
- package/lib/electron-main/electron-main-application.js +3 -0
- package/lib/electron-main/electron-main-application.js.map +1 -1
- package/lib/electron-node/hosting/electron-backend-hosting-module.d.ts.map +1 -1
- package/lib/electron-node/hosting/electron-backend-hosting-module.js +7 -0
- package/lib/electron-node/hosting/electron-backend-hosting-module.js.map +1 -1
- package/lib/electron-node/hosting/electron-backend-hosting-module.spec.d.ts +2 -0
- package/lib/electron-node/hosting/electron-backend-hosting-module.spec.d.ts.map +1 -0
- package/lib/electron-node/hosting/electron-backend-hosting-module.spec.js +39 -0
- package/lib/electron-node/hosting/electron-backend-hosting-module.spec.js.map +1 -0
- package/lib/node/hosting/backend-hosting-module.d.ts.map +1 -1
- package/lib/node/hosting/backend-hosting-module.js +4 -1
- package/lib/node/hosting/backend-hosting-module.js.map +1 -1
- package/lib/node/hosting/browser-connection-token.d.ts +29 -1
- package/lib/node/hosting/browser-connection-token.d.ts.map +1 -1
- package/lib/node/hosting/browser-connection-token.js +25 -1
- package/lib/node/hosting/browser-connection-token.js.map +1 -1
- package/lib/node/hosting/browser-connection-token.spec.js +70 -0
- package/lib/node/hosting/browser-connection-token.spec.js.map +1 -1
- package/lib/node/index.d.ts +1 -0
- package/lib/node/index.d.ts.map +1 -1
- package/lib/node/index.js +3 -1
- package/lib/node/index.js.map +1 -1
- package/package.json +11 -11
- package/src/browser/browser-clipboard-service.ts +7 -1
- package/src/browser/clipboard-service.ts +11 -0
- package/src/browser/common-frontend-contribution.spec.ts +84 -0
- package/src/browser/common-frontend-contribution.ts +22 -10
- package/src/browser/frontend-application-contribution.ts +1 -1
- package/src/browser/frontend-application-module.ts +15 -3
- package/src/browser/frontend-application.ts +12 -4
- package/src/browser/index.ts +1 -0
- package/src/browser/keybinding.ts +12 -7
- package/src/browser/perspective-service.spec.ts +2270 -0
- package/src/browser/perspective-service.ts +625 -0
- package/src/browser/preferences/preference-contribution.ts +9 -1
- package/src/browser/preferences/preference-service.spec.ts +72 -0
- package/src/browser/shell/application-shell.ts +41 -26
- package/src/browser/shell/shell-layout-restorer.spec.ts +746 -0
- package/src/browser/shell/shell-layout-restorer.ts +134 -13
- package/src/browser/shell/view-contribution.ts +6 -1
- package/src/browser/status-bar/status-bar.tsx +13 -3
- package/src/browser/style/select-component.css +16 -0
- package/src/browser/style/sidepanel.css +5 -0
- package/src/browser/style/status-bar.css +1 -1
- package/src/browser/tree/tree-consistency.spec.ts +119 -0
- package/src/browser/tree/tree-widget.tsx +12 -78
- package/src/browser/tree/tree.ts +6 -0
- package/src/browser/widgets/index.ts +1 -0
- package/src/browser/widgets/select-component.tsx +90 -24
- package/src/browser/widgets/tab-bar-tracker.spec.ts +121 -0
- package/src/browser/widgets/tab-bar-tracker.ts +68 -0
- package/src/browser/window/default-secondary-window-service.ts +8 -2
- package/src/browser/window/default-window-service.spec.ts +22 -0
- package/src/browser/window/default-window-service.ts +37 -5
- package/src/browser/window/secondary-window-service.ts +7 -0
- package/src/browser/window/window-service.ts +1 -1
- package/src/browser-only/messaging/frontend-only-service-connection-provider.ts +32 -3
- package/src/common/i18n/nls.metadata.json +4149 -888
- package/src/common/preferences/index.ts +1 -0
- package/src/common/preferences/preference-provider.spec.ts +17 -0
- package/src/common/preferences/preference-provider.ts +22 -1
- package/src/common/preferences/preference-scope.spec.ts +5 -3
- package/src/common/preferences/preference-scope.ts +6 -1
- package/src/common/preferences/preference-service.ts +39 -5
- package/src/common/preferences/session-preference-provider.spec.ts +78 -0
- package/src/common/preferences/session-preference-provider.ts +69 -0
- package/src/electron-browser/electron-clipboard-service.ts +5 -0
- package/src/electron-browser/menu/electron-menu-contribution.ts +1 -1
- package/src/electron-browser/menu/electron-menu-style.css +3 -1
- package/src/electron-browser/preload.ts +5 -2
- package/src/electron-browser/window/electron-secondary-window-service.ts +1 -1
- package/src/electron-browser/window/electron-window-service.ts +3 -3
- package/src/electron-common/electron-api.ts +4 -0
- package/src/electron-main/electron-api-main.ts +56 -3
- package/src/electron-main/electron-main-application.ts +3 -0
- package/src/electron-node/hosting/electron-backend-hosting-module.spec.ts +42 -0
- package/src/electron-node/hosting/electron-backend-hosting-module.ts +8 -0
- package/src/node/hosting/backend-hosting-module.ts +5 -2
- package/src/node/hosting/browser-connection-token.spec.ts +92 -0
- package/src/node/hosting/browser-connection-token.ts +45 -1
- package/src/node/index.ts +1 -0
|
@@ -0,0 +1,2270 @@
|
|
|
1
|
+
// *****************************************************************************
|
|
2
|
+
// Copyright (C) 2026 EclipseSource GmbH.
|
|
3
|
+
//
|
|
4
|
+
// This program and the accompanying materials are made available under the
|
|
5
|
+
// terms of the Eclipse Public License v. 2.0 which is available at
|
|
6
|
+
// http://www.eclipse.org/legal/epl-2.0.
|
|
7
|
+
//
|
|
8
|
+
// This Source Code may also be made available under the following Secondary
|
|
9
|
+
// Licenses when the conditions for such availability set forth in the Eclipse
|
|
10
|
+
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
|
11
|
+
// with the GNU Classpath Exception which is available at
|
|
12
|
+
// https://www.gnu.org/software/classpath/license.html.
|
|
13
|
+
//
|
|
14
|
+
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
|
15
|
+
// *****************************************************************************
|
|
16
|
+
|
|
17
|
+
import { enableJSDOM } from './test/jsdom';
|
|
18
|
+
const disableJSDOM = enableJSDOM();
|
|
19
|
+
|
|
20
|
+
import { expect } from 'chai';
|
|
21
|
+
import * as sinon from 'sinon';
|
|
22
|
+
import { PerspectiveServiceImpl, PerspectiveDescriptor, WidgetAreaResolverImpl } from './perspective-service';
|
|
23
|
+
import { ApplicationShell } from './shell/application-shell';
|
|
24
|
+
import { AbstractViewContribution, ViewContributionOptions } from './shell/view-contribution';
|
|
25
|
+
import { FrontendApplicationContribution } from './frontend-application-contribution';
|
|
26
|
+
import { DockLayout, Panel, Widget } from '@lumino/widgets';
|
|
27
|
+
import { SidePanel } from './shell/side-panel-handler';
|
|
28
|
+
disableJSDOM();
|
|
29
|
+
|
|
30
|
+
describe('PerspectiveService', () => {
|
|
31
|
+
let service: PerspectiveServiceImpl;
|
|
32
|
+
let addWidgetStub: sinon.SinonStub;
|
|
33
|
+
let activateWidgetStub: sinon.SinonStub;
|
|
34
|
+
let getTabBarForStub: sinon.SinonStub;
|
|
35
|
+
let getAreaForStub: sinon.SinonStub;
|
|
36
|
+
let getOrCreateWidgetStub: sinon.SinonStub;
|
|
37
|
+
let getLayoutDataStub: sinon.SinonStub;
|
|
38
|
+
let setLayoutDataStub: sinon.SinonStub;
|
|
39
|
+
let collapsePanelStub: sinon.SinonStub;
|
|
40
|
+
let widgetAreaResolver: WidgetAreaResolverImpl;
|
|
41
|
+
let tryGetWidgetStub: sinon.SinonStub;
|
|
42
|
+
let getWidgetsStub: sinon.SinonStub;
|
|
43
|
+
let mockLogger: { debug: sinon.SinonStub; warn: sinon.SinonStub };
|
|
44
|
+
let testWidget: Widget;
|
|
45
|
+
let toTearDown: () => void;
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
toTearDown = enableJSDOM();
|
|
49
|
+
service = new PerspectiveServiceImpl();
|
|
50
|
+
testWidget = new Widget();
|
|
51
|
+
testWidget.id = 'test-widget';
|
|
52
|
+
|
|
53
|
+
addWidgetStub = sinon.stub().resolves();
|
|
54
|
+
activateWidgetStub = sinon.stub().resolves(undefined);
|
|
55
|
+
getTabBarForStub = sinon.stub().returns(undefined);
|
|
56
|
+
getAreaForStub = sinon.stub().returns(undefined);
|
|
57
|
+
getOrCreateWidgetStub = sinon.stub().resolves(testWidget);
|
|
58
|
+
getLayoutDataStub = sinon.stub().returns({ mainPanel: {}, bottomPanel: {} });
|
|
59
|
+
setLayoutDataStub = sinon.stub().resolves();
|
|
60
|
+
collapsePanelStub = sinon.stub().resolves();
|
|
61
|
+
widgetAreaResolver = new WidgetAreaResolverImpl();
|
|
62
|
+
getWidgetsStub = sinon.stub().returns([]);
|
|
63
|
+
mockLogger = { debug: sinon.stub(), warn: sinon.stub() };
|
|
64
|
+
|
|
65
|
+
const mockShell = {
|
|
66
|
+
addWidget: addWidgetStub,
|
|
67
|
+
activateWidget: activateWidgetStub,
|
|
68
|
+
getTabBarFor: getTabBarForStub,
|
|
69
|
+
getAreaFor: getAreaForStub,
|
|
70
|
+
getLayoutData: getLayoutDataStub,
|
|
71
|
+
setLayoutData: setLayoutDataStub,
|
|
72
|
+
collapsePanel: collapsePanelStub,
|
|
73
|
+
getWidgets: getWidgetsStub
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
tryGetWidgetStub = sinon.stub().returns(undefined);
|
|
77
|
+
|
|
78
|
+
const mockWidgetManager = {
|
|
79
|
+
getOrCreateWidget: getOrCreateWidgetStub,
|
|
80
|
+
tryGetWidget: tryGetWidgetStub
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Assign mocks via property access since we can't use DI in tests
|
|
84
|
+
(service as unknown as Record<string, unknown>)['shell'] = mockShell;
|
|
85
|
+
(service as unknown as Record<string, unknown>)['widgetManager'] = mockWidgetManager;
|
|
86
|
+
(service as unknown as Record<string, unknown>)['logger'] = mockLogger;
|
|
87
|
+
(service as unknown as Record<string, unknown>)['widgetAreaResolver'] = widgetAreaResolver;
|
|
88
|
+
(service as unknown as Record<string, unknown>)['appContributions'] = {
|
|
89
|
+
getContributions: () => [] as FrontendApplicationContribution[]
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
afterEach(() => {
|
|
94
|
+
sinon.restore();
|
|
95
|
+
toTearDown();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should register a perspective', () => {
|
|
99
|
+
const descriptor: PerspectiveDescriptor = {
|
|
100
|
+
id: 'test',
|
|
101
|
+
label: 'Test',
|
|
102
|
+
viewPlacements: new Map([['widget-a', 'main' as ApplicationShell.Area]])
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
service.registerPerspective(descriptor);
|
|
106
|
+
|
|
107
|
+
const perspectives = service.getRegisteredPerspectives();
|
|
108
|
+
expect(perspectives).to.have.lengthOf(1);
|
|
109
|
+
expect(perspectives[0].id).to.equal('test');
|
|
110
|
+
expect(perspectives[0].label).to.equal('Test');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should register multiple perspectives', () => {
|
|
114
|
+
service.registerPerspective({
|
|
115
|
+
id: 'perspective-1',
|
|
116
|
+
label: 'Perspective 1',
|
|
117
|
+
viewPlacements: new Map()
|
|
118
|
+
});
|
|
119
|
+
service.registerPerspective({
|
|
120
|
+
id: 'perspective-2',
|
|
121
|
+
label: 'Perspective 2',
|
|
122
|
+
viewPlacements: new Map()
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(service.getRegisteredPerspectives()).to.have.lengthOf(2);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should return undefined for active perspective when none is set', () => {
|
|
129
|
+
expect(service.getActivePerspective()).to.be.undefined;
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should return undefined from getAreaForView when no perspective is active', () => {
|
|
133
|
+
service.registerPerspective({
|
|
134
|
+
id: 'test',
|
|
135
|
+
label: 'Test',
|
|
136
|
+
viewPlacements: new Map([['widget-a', 'main' as ApplicationShell.Area]])
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(service.getAreaForView('widget-a')).to.be.undefined;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should return the override area when a perspective is active', async () => {
|
|
143
|
+
service.registerPerspective({
|
|
144
|
+
id: 'test',
|
|
145
|
+
label: 'Test',
|
|
146
|
+
viewPlacements: new Map([['widget-a', 'main' as ApplicationShell.Area]])
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await service.switchPerspective('test');
|
|
150
|
+
|
|
151
|
+
expect(service.getAreaForView('widget-a')).to.equal('main');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should return undefined for views not in the perspective placement map', async () => {
|
|
155
|
+
service.registerPerspective({
|
|
156
|
+
id: 'test',
|
|
157
|
+
label: 'Test',
|
|
158
|
+
viewPlacements: new Map([['widget-a', 'main' as ApplicationShell.Area]])
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await service.switchPerspective('test');
|
|
162
|
+
|
|
163
|
+
expect(service.getAreaForView('widget-b')).to.be.undefined;
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should switch perspectives and update the active perspective', async () => {
|
|
167
|
+
service.registerPerspective({
|
|
168
|
+
id: 'first',
|
|
169
|
+
label: 'First',
|
|
170
|
+
viewPlacements: new Map()
|
|
171
|
+
});
|
|
172
|
+
service.registerPerspective({
|
|
173
|
+
id: 'second',
|
|
174
|
+
label: 'Second',
|
|
175
|
+
viewPlacements: new Map()
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await service.switchPerspective('first');
|
|
179
|
+
expect(service.getActivePerspective()?.id).to.equal('first');
|
|
180
|
+
|
|
181
|
+
await service.switchPerspective('second');
|
|
182
|
+
expect(service.getActivePerspective()?.id).to.equal('second');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should fire onDidChangePerspective event when switching', async () => {
|
|
186
|
+
service.registerPerspective({
|
|
187
|
+
id: 'test',
|
|
188
|
+
label: 'Test',
|
|
189
|
+
viewPlacements: new Map()
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const spy = sinon.spy();
|
|
193
|
+
service.onDidChangePerspective(spy);
|
|
194
|
+
|
|
195
|
+
await service.switchPerspective('test');
|
|
196
|
+
|
|
197
|
+
expect(spy.calledOnce).to.be.true;
|
|
198
|
+
expect(spy.calledWith('test')).to.be.true;
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should not switch to a non-existent perspective', async () => {
|
|
202
|
+
const spy = sinon.spy();
|
|
203
|
+
service.onDidChangePerspective(spy);
|
|
204
|
+
|
|
205
|
+
await service.switchPerspective('nonexistent');
|
|
206
|
+
|
|
207
|
+
expect(spy.called).to.be.false;
|
|
208
|
+
expect(service.getActivePerspective()).to.be.undefined;
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should call onDeactivate on old perspective and onActivate on new perspective', async () => {
|
|
212
|
+
const onDeactivate = sinon.spy();
|
|
213
|
+
const onActivate = sinon.spy();
|
|
214
|
+
|
|
215
|
+
service.registerPerspective({
|
|
216
|
+
id: 'old',
|
|
217
|
+
label: 'Old',
|
|
218
|
+
viewPlacements: new Map(),
|
|
219
|
+
onDeactivate
|
|
220
|
+
});
|
|
221
|
+
service.registerPerspective({
|
|
222
|
+
id: 'new',
|
|
223
|
+
label: 'New',
|
|
224
|
+
viewPlacements: new Map(),
|
|
225
|
+
onActivate
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await service.switchPerspective('old');
|
|
229
|
+
await service.switchPerspective('new');
|
|
230
|
+
|
|
231
|
+
expect(onDeactivate.calledOnce).to.be.true;
|
|
232
|
+
expect(onActivate.calledOnce).to.be.true;
|
|
233
|
+
expect(onDeactivate.calledBefore(onActivate)).to.be.true;
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should add widgets to the target area during switch', async () => {
|
|
237
|
+
service.registerPerspective({
|
|
238
|
+
id: 'test',
|
|
239
|
+
label: 'Test',
|
|
240
|
+
viewPlacements: new Map([['test-widget', 'main' as ApplicationShell.Area]])
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
await service.switchPerspective('test');
|
|
244
|
+
|
|
245
|
+
expect(getOrCreateWidgetStub.calledWith('test-widget')).to.be.true;
|
|
246
|
+
expect(addWidgetStub.calledOnce).to.be.true;
|
|
247
|
+
expect(addWidgetStub.calledWith(testWidget, sinon.match({ area: 'main' }))).to.be.true;
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should skip adding widget if already in the correct area', async () => {
|
|
251
|
+
getTabBarForStub.returns({});
|
|
252
|
+
getAreaForStub.returns('main');
|
|
253
|
+
|
|
254
|
+
service.registerPerspective({
|
|
255
|
+
id: 'test',
|
|
256
|
+
label: 'Test',
|
|
257
|
+
viewPlacements: new Map([['test-widget', 'main' as ApplicationShell.Area]])
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
await service.switchPerspective('test');
|
|
261
|
+
|
|
262
|
+
expect(addWidgetStub.called).to.be.false;
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('should call initialize and register contributions', () => {
|
|
266
|
+
const mockContribution = {
|
|
267
|
+
registerPerspectives: sinon.spy()
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
(service as unknown as Record<string, unknown>)['contributions'] = {
|
|
271
|
+
getContributions: () => [mockContribution]
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
service.initialize();
|
|
275
|
+
|
|
276
|
+
expect(mockContribution.registerPerspectives.calledOnce).to.be.true;
|
|
277
|
+
expect(mockContribution.registerPerspectives.calledWith(service)).to.be.true;
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('should handle initialize with no contributions', () => {
|
|
281
|
+
(service as unknown as Record<string, unknown>)['contributions'] = undefined;
|
|
282
|
+
|
|
283
|
+
expect(() => service.initialize()).to.not.throw();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// --- New tests for no-op guard, layout save/restore, and default perspective ---
|
|
287
|
+
|
|
288
|
+
it('should register the default perspective on initialize', () => {
|
|
289
|
+
service.initialize();
|
|
290
|
+
|
|
291
|
+
const perspectives = service.getRegisteredPerspectives();
|
|
292
|
+
const defaultPerspective = perspectives.find(p => p.id === PerspectiveServiceImpl.DEFAULT_PERSPECTIVE_ID);
|
|
293
|
+
expect(defaultPerspective).to.not.be.undefined;
|
|
294
|
+
expect(defaultPerspective!.label).to.equal('Default');
|
|
295
|
+
expect(defaultPerspective!.viewPlacements.size).to.equal(0);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('should set the default perspective as the initial active perspective', () => {
|
|
299
|
+
service.initialize();
|
|
300
|
+
|
|
301
|
+
const active = service.getActivePerspective();
|
|
302
|
+
expect(active).to.not.be.undefined;
|
|
303
|
+
expect(active!.id).to.equal(PerspectiveServiceImpl.DEFAULT_PERSPECTIVE_ID);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('should not re-apply when switching to the already-active perspective', async () => {
|
|
307
|
+
service.registerPerspective({
|
|
308
|
+
id: 'test',
|
|
309
|
+
label: 'Test',
|
|
310
|
+
viewPlacements: new Map([['test-widget', 'main' as ApplicationShell.Area]]),
|
|
311
|
+
onActivate: sinon.spy(),
|
|
312
|
+
onDeactivate: sinon.spy()
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
await service.switchPerspective('test');
|
|
316
|
+
|
|
317
|
+
// Reset all stubs
|
|
318
|
+
addWidgetStub.resetHistory();
|
|
319
|
+
setLayoutDataStub.resetHistory();
|
|
320
|
+
getOrCreateWidgetStub.resetHistory();
|
|
321
|
+
getLayoutDataStub.resetHistory();
|
|
322
|
+
const eventSpy = sinon.spy();
|
|
323
|
+
service.onDidChangePerspective(eventSpy);
|
|
324
|
+
|
|
325
|
+
const descriptor = service.getActivePerspective()!;
|
|
326
|
+
const onActivateSpy = descriptor.onActivate as sinon.SinonSpy;
|
|
327
|
+
const onDeactivateSpy = descriptor.onDeactivate as sinon.SinonSpy;
|
|
328
|
+
onActivateSpy.resetHistory();
|
|
329
|
+
onDeactivateSpy.resetHistory();
|
|
330
|
+
|
|
331
|
+
// Switch to the same perspective again
|
|
332
|
+
await service.switchPerspective('test');
|
|
333
|
+
|
|
334
|
+
expect(addWidgetStub.called).to.be.false;
|
|
335
|
+
expect(setLayoutDataStub.called).to.be.false;
|
|
336
|
+
expect(getOrCreateWidgetStub.called).to.be.false;
|
|
337
|
+
expect(getLayoutDataStub.called).to.be.false;
|
|
338
|
+
expect(eventSpy.called).to.be.false;
|
|
339
|
+
expect(onActivateSpy.called).to.be.false;
|
|
340
|
+
expect(onDeactivateSpy.called).to.be.false;
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('should save layout when switching away from a perspective', async () => {
|
|
344
|
+
service.registerPerspective({
|
|
345
|
+
id: 'perspA',
|
|
346
|
+
label: 'A',
|
|
347
|
+
viewPlacements: new Map()
|
|
348
|
+
});
|
|
349
|
+
service.registerPerspective({
|
|
350
|
+
id: 'perspB',
|
|
351
|
+
label: 'B',
|
|
352
|
+
viewPlacements: new Map()
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
await service.switchPerspective('perspA');
|
|
356
|
+
|
|
357
|
+
const layoutA = { mainPanel: { widgets: ['editor1'] }, bottomPanel: {} };
|
|
358
|
+
getLayoutDataStub.returns(layoutA);
|
|
359
|
+
|
|
360
|
+
// Switch to B — should save A's layout
|
|
361
|
+
await service.switchPerspective('perspB');
|
|
362
|
+
|
|
363
|
+
expect(getLayoutDataStub.called).to.be.true;
|
|
364
|
+
|
|
365
|
+
const layoutB = { mainPanel: { widgets: ['something-else'] }, bottomPanel: {} };
|
|
366
|
+
getLayoutDataStub.returns(layoutB);
|
|
367
|
+
setLayoutDataStub.resetHistory();
|
|
368
|
+
|
|
369
|
+
// Switch back to A — should restore A's saved layout
|
|
370
|
+
await service.switchPerspective('perspA');
|
|
371
|
+
|
|
372
|
+
expect(setLayoutDataStub.calledOnce).to.be.true;
|
|
373
|
+
expect(setLayoutDataStub.calledWith(layoutA)).to.be.true;
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('should restore saved layout when switching back to a perspective', async () => {
|
|
377
|
+
service.registerPerspective({
|
|
378
|
+
id: 'perspA',
|
|
379
|
+
label: 'A',
|
|
380
|
+
viewPlacements: new Map([['test-widget', 'left' as ApplicationShell.Area]])
|
|
381
|
+
});
|
|
382
|
+
service.registerPerspective({
|
|
383
|
+
id: 'perspB',
|
|
384
|
+
label: 'B',
|
|
385
|
+
viewPlacements: new Map()
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// Activate A (first time — applies viewPlacements)
|
|
389
|
+
await service.switchPerspective('perspA');
|
|
390
|
+
|
|
391
|
+
const layoutA = { mainPanel: { widgets: ['editor-customized'] }, bottomPanel: {} };
|
|
392
|
+
getLayoutDataStub.returns(layoutA);
|
|
393
|
+
|
|
394
|
+
// Switch to B (saves A's layout)
|
|
395
|
+
await service.switchPerspective('perspB');
|
|
396
|
+
|
|
397
|
+
const layoutB = { mainPanel: { widgets: ['something-else'] }, bottomPanel: {} };
|
|
398
|
+
getLayoutDataStub.returns(layoutB);
|
|
399
|
+
|
|
400
|
+
setLayoutDataStub.resetHistory();
|
|
401
|
+
getOrCreateWidgetStub.resetHistory();
|
|
402
|
+
addWidgetStub.resetHistory();
|
|
403
|
+
|
|
404
|
+
// Switch back to A — should restore saved layout, NOT apply viewPlacements
|
|
405
|
+
await service.switchPerspective('perspA');
|
|
406
|
+
|
|
407
|
+
expect(setLayoutDataStub.calledOnce).to.be.true;
|
|
408
|
+
expect(setLayoutDataStub.calledWith(layoutA)).to.be.true;
|
|
409
|
+
expect(getOrCreateWidgetStub.called).to.be.false;
|
|
410
|
+
expect(addWidgetStub.called).to.be.false;
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('should apply viewPlacements on first activation (no saved layout)', async () => {
|
|
414
|
+
service.registerPerspective({
|
|
415
|
+
id: 'fresh',
|
|
416
|
+
label: 'Fresh',
|
|
417
|
+
viewPlacements: new Map([['test-widget', 'main' as ApplicationShell.Area]])
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
await service.switchPerspective('fresh');
|
|
421
|
+
|
|
422
|
+
expect(getOrCreateWidgetStub.calledWith('test-widget')).to.be.true;
|
|
423
|
+
expect(addWidgetStub.calledOnce).to.be.true;
|
|
424
|
+
expect(addWidgetStub.calledWith(testWidget, sinon.match({ area: 'main' }))).to.be.true;
|
|
425
|
+
expect(setLayoutDataStub.called).to.be.false;
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('should not apply viewPlacements when a saved layout exists', async () => {
|
|
429
|
+
service.registerPerspective({
|
|
430
|
+
id: 'perspA',
|
|
431
|
+
label: 'A',
|
|
432
|
+
viewPlacements: new Map([['test-widget', 'left' as ApplicationShell.Area]])
|
|
433
|
+
});
|
|
434
|
+
service.registerPerspective({
|
|
435
|
+
id: 'perspB',
|
|
436
|
+
label: 'B',
|
|
437
|
+
viewPlacements: new Map()
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// First activation of A — viewPlacements applied
|
|
441
|
+
await service.switchPerspective('perspA');
|
|
442
|
+
expect(getOrCreateWidgetStub.called).to.be.true;
|
|
443
|
+
|
|
444
|
+
// Switch to B (saves A's layout)
|
|
445
|
+
await service.switchPerspective('perspB');
|
|
446
|
+
|
|
447
|
+
getOrCreateWidgetStub.resetHistory();
|
|
448
|
+
addWidgetStub.resetHistory();
|
|
449
|
+
|
|
450
|
+
// Switch back to A — should restore layout, NOT use viewPlacements
|
|
451
|
+
await service.switchPerspective('perspA');
|
|
452
|
+
|
|
453
|
+
expect(getOrCreateWidgetStub.called).to.be.false;
|
|
454
|
+
expect(addWidgetStub.called).to.be.false;
|
|
455
|
+
expect(setLayoutDataStub.called).to.be.true;
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('should allow full round-trip: default → custom → default', async () => {
|
|
459
|
+
service.initialize();
|
|
460
|
+
|
|
461
|
+
service.registerPerspective({
|
|
462
|
+
id: 'ai-first',
|
|
463
|
+
label: 'AI First',
|
|
464
|
+
viewPlacements: new Map([['test-widget', 'left' as ApplicationShell.Area]])
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// Start in default (set by initialize)
|
|
468
|
+
expect(service.getActivePerspective()?.id).to.equal(PerspectiveServiceImpl.DEFAULT_PERSPECTIVE_ID);
|
|
469
|
+
|
|
470
|
+
const defaultLayout = { mainPanel: { widgets: ['default-editor'] }, bottomPanel: {} };
|
|
471
|
+
getLayoutDataStub.returns(defaultLayout);
|
|
472
|
+
|
|
473
|
+
// Switch to ai-first (saves default layout)
|
|
474
|
+
await service.switchPerspective('ai-first');
|
|
475
|
+
expect(service.getActivePerspective()?.id).to.equal('ai-first');
|
|
476
|
+
expect(getLayoutDataStub.called).to.be.true;
|
|
477
|
+
expect(getOrCreateWidgetStub.calledWith('test-widget')).to.be.true;
|
|
478
|
+
|
|
479
|
+
const aiLayout = { mainPanel: { widgets: ['ai-stuff'] }, bottomPanel: {} };
|
|
480
|
+
getLayoutDataStub.returns(aiLayout);
|
|
481
|
+
|
|
482
|
+
setLayoutDataStub.resetHistory();
|
|
483
|
+
|
|
484
|
+
// Switch back to default (saves ai-first layout, restores default layout)
|
|
485
|
+
await service.switchPerspective(PerspectiveServiceImpl.DEFAULT_PERSPECTIVE_ID);
|
|
486
|
+
expect(service.getActivePerspective()?.id).to.equal(PerspectiveServiceImpl.DEFAULT_PERSPECTIVE_ID);
|
|
487
|
+
expect(setLayoutDataStub.calledOnce).to.be.true;
|
|
488
|
+
expect(setLayoutDataStub.calledWith(defaultLayout)).to.be.true;
|
|
489
|
+
|
|
490
|
+
setLayoutDataStub.resetHistory();
|
|
491
|
+
|
|
492
|
+
// Switch back to ai-first (saves default layout again, restores ai-first layout)
|
|
493
|
+
await service.switchPerspective('ai-first');
|
|
494
|
+
expect(service.getActivePerspective()?.id).to.equal('ai-first');
|
|
495
|
+
expect(setLayoutDataStub.calledOnce).to.be.true;
|
|
496
|
+
expect(setLayoutDataStub.calledWith(aiLayout)).to.be.true;
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// --- Chrome control options tests ---
|
|
500
|
+
|
|
501
|
+
it('should collapse areas on first activation only', async () => {
|
|
502
|
+
service.registerPerspective({
|
|
503
|
+
id: 'collapse-test',
|
|
504
|
+
label: 'Collapse Test',
|
|
505
|
+
viewPlacements: new Map(),
|
|
506
|
+
chromeOptions: { collapseAreas: ['left'] }
|
|
507
|
+
});
|
|
508
|
+
service.registerPerspective({
|
|
509
|
+
id: 'other',
|
|
510
|
+
label: 'Other',
|
|
511
|
+
viewPlacements: new Map()
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// First activation — should collapse
|
|
515
|
+
await service.switchPerspective('collapse-test');
|
|
516
|
+
expect(collapsePanelStub.calledOnce).to.be.true;
|
|
517
|
+
expect(collapsePanelStub.calledWith('left')).to.be.true;
|
|
518
|
+
|
|
519
|
+
collapsePanelStub.resetHistory();
|
|
520
|
+
|
|
521
|
+
// Switch away (saves layout)
|
|
522
|
+
await service.switchPerspective('other');
|
|
523
|
+
|
|
524
|
+
collapsePanelStub.resetHistory();
|
|
525
|
+
|
|
526
|
+
// Switch back — should restore saved layout, NOT collapse again
|
|
527
|
+
await service.switchPerspective('collapse-test');
|
|
528
|
+
expect(collapsePanelStub.called).to.be.false;
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('should collapse multiple areas on first activation', async () => {
|
|
532
|
+
service.registerPerspective({
|
|
533
|
+
id: 'multi-collapse',
|
|
534
|
+
label: 'Multi Collapse',
|
|
535
|
+
viewPlacements: new Map(),
|
|
536
|
+
chromeOptions: { collapseAreas: ['left', 'bottom'] }
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
await service.switchPerspective('multi-collapse');
|
|
540
|
+
|
|
541
|
+
expect(collapsePanelStub.calledTwice).to.be.true;
|
|
542
|
+
expect(collapsePanelStub.calledWith('left')).to.be.true;
|
|
543
|
+
expect(collapsePanelStub.calledWith('bottom')).to.be.true;
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it('should not collapse areas when perspective has no collapseAreas', async () => {
|
|
547
|
+
service.registerPerspective({
|
|
548
|
+
id: 'no-collapse',
|
|
549
|
+
label: 'No Collapse',
|
|
550
|
+
viewPlacements: new Map()
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
await service.switchPerspective('no-collapse');
|
|
554
|
+
|
|
555
|
+
expect(collapsePanelStub.called).to.be.false;
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// --- WidgetAreaResolver tests ---
|
|
559
|
+
|
|
560
|
+
it('should resolve widget area from active perspective via the WidgetAreaResolver', async () => {
|
|
561
|
+
service.initialize();
|
|
562
|
+
|
|
563
|
+
service.registerPerspective({
|
|
564
|
+
id: 'resolver-test',
|
|
565
|
+
label: 'Resolver Test',
|
|
566
|
+
viewPlacements: new Map([['my-widget', 'right' as ApplicationShell.Area]])
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
await service.switchPerspective('resolver-test');
|
|
570
|
+
|
|
571
|
+
expect(widgetAreaResolver.resolveArea('my-widget', 'left')).to.equal('right');
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it('should return undefined from the resolver for unmapped widgets', async () => {
|
|
575
|
+
service.initialize();
|
|
576
|
+
|
|
577
|
+
service.registerPerspective({
|
|
578
|
+
id: 'resolver-test',
|
|
579
|
+
label: 'Resolver Test',
|
|
580
|
+
viewPlacements: new Map([['my-widget', 'right' as ApplicationShell.Area]])
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
await service.switchPerspective('resolver-test');
|
|
584
|
+
|
|
585
|
+
expect(widgetAreaResolver.resolveArea('unknown-widget', 'main')).to.be.undefined;
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it('should return undefined from the resolver when default perspective is active', () => {
|
|
589
|
+
service.initialize();
|
|
590
|
+
|
|
591
|
+
// Default perspective has empty viewPlacements
|
|
592
|
+
expect(widgetAreaResolver.resolveArea('any-widget', 'main')).to.be.undefined;
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
// --- Logger tests ---
|
|
596
|
+
|
|
597
|
+
it('should log warning when widget creation fails during switchPerspective', async () => {
|
|
598
|
+
const widgetError = new Error('No factory registered');
|
|
599
|
+
getOrCreateWidgetStub.rejects(widgetError);
|
|
600
|
+
|
|
601
|
+
service.registerPerspective({
|
|
602
|
+
id: 'fail-test',
|
|
603
|
+
label: 'Fail Test',
|
|
604
|
+
viewPlacements: new Map([['missing-widget', 'main' as ApplicationShell.Area]])
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
await service.switchPerspective('fail-test');
|
|
608
|
+
|
|
609
|
+
expect(mockLogger.warn.calledOnce).to.be.true;
|
|
610
|
+
expect(mockLogger.warn.firstCall.args[0]).to.equal('Failed to create or place widget for perspective');
|
|
611
|
+
expect(mockLogger.warn.firstCall.args[1]).to.equal(widgetError);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it('should log warning when widget activation fails during switchPerspective', async () => {
|
|
615
|
+
const activationError = new Error('Activation failed');
|
|
616
|
+
activateWidgetStub.rejects(activationError);
|
|
617
|
+
|
|
618
|
+
service.registerPerspective({
|
|
619
|
+
id: 'activate-fail',
|
|
620
|
+
label: 'Activate Fail',
|
|
621
|
+
viewPlacements: new Map([['test-widget', 'main' as ApplicationShell.Area]])
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
await service.switchPerspective('activate-fail');
|
|
625
|
+
|
|
626
|
+
expect(mockLogger.warn.called).to.be.true;
|
|
627
|
+
const activateCall = mockLogger.warn.getCalls().find(
|
|
628
|
+
(c: sinon.SinonSpyCall) => c.args[0] === 'Failed to activate widget for perspective'
|
|
629
|
+
);
|
|
630
|
+
expect(activateCall).to.not.be.undefined;
|
|
631
|
+
expect(activateCall!.args[1]).to.equal(activationError);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
// --- Reentrancy guard tests ---
|
|
635
|
+
|
|
636
|
+
it('should handle two rapid switchPerspective calls without interleaving', async () => {
|
|
637
|
+
service.registerPerspective({
|
|
638
|
+
id: 'first',
|
|
639
|
+
label: 'First',
|
|
640
|
+
viewPlacements: new Map()
|
|
641
|
+
});
|
|
642
|
+
service.registerPerspective({
|
|
643
|
+
id: 'second',
|
|
644
|
+
label: 'Second',
|
|
645
|
+
viewPlacements: new Map()
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
const promise1 = service.switchPerspective('first');
|
|
649
|
+
const promise2 = service.switchPerspective('second');
|
|
650
|
+
|
|
651
|
+
await Promise.all([promise1, promise2]);
|
|
652
|
+
|
|
653
|
+
expect(service.getActivePerspective()?.id).to.equal('second');
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it('should serialize perspective switches with reentrancy guard', async () => {
|
|
657
|
+
const callOrder: string[] = [];
|
|
658
|
+
|
|
659
|
+
service.registerPerspective({
|
|
660
|
+
id: 'perspA',
|
|
661
|
+
label: 'A',
|
|
662
|
+
viewPlacements: new Map(),
|
|
663
|
+
onActivate: () => callOrder.push('activate-A')
|
|
664
|
+
});
|
|
665
|
+
service.registerPerspective({
|
|
666
|
+
id: 'perspB',
|
|
667
|
+
label: 'B',
|
|
668
|
+
viewPlacements: new Map(),
|
|
669
|
+
onActivate: () => callOrder.push('activate-B')
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
const p1 = service.switchPerspective('perspA');
|
|
673
|
+
const p2 = service.switchPerspective('perspB');
|
|
674
|
+
|
|
675
|
+
await Promise.all([p1, p2]);
|
|
676
|
+
|
|
677
|
+
expect(callOrder).to.deep.equal(['activate-A', 'activate-B']);
|
|
678
|
+
expect(service.getActivePerspective()?.id).to.equal('perspB');
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
it('should serialize three or more concurrent perspective switches', async () => {
|
|
682
|
+
const callOrder: string[] = [];
|
|
683
|
+
|
|
684
|
+
service.registerPerspective({
|
|
685
|
+
id: 'perspA',
|
|
686
|
+
label: 'A',
|
|
687
|
+
viewPlacements: new Map(),
|
|
688
|
+
onActivate: () => callOrder.push('activate-A')
|
|
689
|
+
});
|
|
690
|
+
service.registerPerspective({
|
|
691
|
+
id: 'perspB',
|
|
692
|
+
label: 'B',
|
|
693
|
+
viewPlacements: new Map(),
|
|
694
|
+
onActivate: () => callOrder.push('activate-B')
|
|
695
|
+
});
|
|
696
|
+
service.registerPerspective({
|
|
697
|
+
id: 'perspC',
|
|
698
|
+
label: 'C',
|
|
699
|
+
viewPlacements: new Map(),
|
|
700
|
+
onActivate: () => callOrder.push('activate-C')
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
const p1 = service.switchPerspective('perspA');
|
|
704
|
+
const p2 = service.switchPerspective('perspB');
|
|
705
|
+
const p3 = service.switchPerspective('perspC');
|
|
706
|
+
|
|
707
|
+
await Promise.all([p1, p2, p3]);
|
|
708
|
+
|
|
709
|
+
expect(callOrder).to.deep.equal(['activate-A', 'activate-B', 'activate-C']);
|
|
710
|
+
expect(service.getActivePerspective()?.id).to.equal('perspC');
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
// --- onLayoutRestored() tests ---
|
|
714
|
+
|
|
715
|
+
it('should not throw when onLayoutRestored is called with an unregistered perspective', () => {
|
|
716
|
+
service.initialize();
|
|
717
|
+
|
|
718
|
+
expect(() => service.onLayoutRestored('non-existent')).to.not.throw();
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
// --- Rejection resilience tests ---
|
|
722
|
+
|
|
723
|
+
it('should warn and continue when setLayoutData rejects during saved layout restore', async () => {
|
|
724
|
+
service.registerPerspective({
|
|
725
|
+
id: 'perspA',
|
|
726
|
+
label: 'A',
|
|
727
|
+
viewPlacements: new Map()
|
|
728
|
+
});
|
|
729
|
+
service.registerPerspective({
|
|
730
|
+
id: 'perspB',
|
|
731
|
+
label: 'B',
|
|
732
|
+
viewPlacements: new Map()
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
// Switch to A, then to B (saves A's layout)
|
|
736
|
+
await service.switchPerspective('perspA');
|
|
737
|
+
await service.switchPerspective('perspB');
|
|
738
|
+
|
|
739
|
+
// Make setLayoutData reject
|
|
740
|
+
setLayoutDataStub.rejects(new Error('layout error'));
|
|
741
|
+
|
|
742
|
+
const eventSpy = sinon.spy();
|
|
743
|
+
service.onDidChangePerspective(eventSpy);
|
|
744
|
+
|
|
745
|
+
// Switch back to A — should not throw
|
|
746
|
+
await service.switchPerspective('perspA');
|
|
747
|
+
|
|
748
|
+
expect(service.getActivePerspective()?.id).to.equal('perspA');
|
|
749
|
+
expect(eventSpy.calledOnce).to.be.true;
|
|
750
|
+
expect(eventSpy.calledWith('perspA')).to.be.true;
|
|
751
|
+
expect(mockLogger.warn.calledOnce).to.be.true;
|
|
752
|
+
expect(mockLogger.warn.firstCall.args[0]).to.equal('Failed to apply layout for perspective');
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
it('should warn and continue when collapsePanel rejects on first activation', async () => {
|
|
756
|
+
service.registerPerspective({
|
|
757
|
+
id: 'collapse-fail',
|
|
758
|
+
label: 'Collapse Fail',
|
|
759
|
+
viewPlacements: new Map(),
|
|
760
|
+
chromeOptions: { collapseAreas: ['left'] }
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
collapsePanelStub.rejects(new Error('collapse error'));
|
|
764
|
+
|
|
765
|
+
const eventSpy = sinon.spy();
|
|
766
|
+
service.onDidChangePerspective(eventSpy);
|
|
767
|
+
|
|
768
|
+
// Switch — should not throw
|
|
769
|
+
await service.switchPerspective('collapse-fail');
|
|
770
|
+
|
|
771
|
+
expect(service.getActivePerspective()?.id).to.equal('collapse-fail');
|
|
772
|
+
expect(eventSpy.calledOnce).to.be.true;
|
|
773
|
+
expect(mockLogger.warn.calledOnce).to.be.true;
|
|
774
|
+
expect(mockLogger.warn.firstCall.args[0]).to.equal('Failed to apply layout for perspective');
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
it('should not drop queued switches when layout application fails', async () => {
|
|
778
|
+
service.registerPerspective({
|
|
779
|
+
id: 'perspA',
|
|
780
|
+
label: 'A',
|
|
781
|
+
viewPlacements: new Map()
|
|
782
|
+
});
|
|
783
|
+
service.registerPerspective({
|
|
784
|
+
id: 'perspB',
|
|
785
|
+
label: 'B',
|
|
786
|
+
viewPlacements: new Map()
|
|
787
|
+
});
|
|
788
|
+
service.registerPerspective({
|
|
789
|
+
id: 'perspC',
|
|
790
|
+
label: 'C',
|
|
791
|
+
viewPlacements: new Map()
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
// Switch to A, then to B (saves A's layout)
|
|
795
|
+
await service.switchPerspective('perspA');
|
|
796
|
+
await service.switchPerspective('perspB');
|
|
797
|
+
|
|
798
|
+
// Make setLayoutData reject only on first call, then resolve
|
|
799
|
+
setLayoutDataStub.resetBehavior();
|
|
800
|
+
setLayoutDataStub.onFirstCall().rejects(new Error('layout error'));
|
|
801
|
+
setLayoutDataStub.onSecondCall().resolves();
|
|
802
|
+
|
|
803
|
+
// Queue: switch to A (will fail layout restore), then switch to C
|
|
804
|
+
const p1 = service.switchPerspective('perspA');
|
|
805
|
+
const p2 = service.switchPerspective('perspC');
|
|
806
|
+
|
|
807
|
+
await Promise.all([p1, p2]);
|
|
808
|
+
|
|
809
|
+
expect(service.getActivePerspective()?.id).to.equal('perspC');
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
// --- resetCurrentPerspective tests ---
|
|
813
|
+
|
|
814
|
+
describe('resetCurrentPerspective', () => {
|
|
815
|
+
it('should clear saved layout and re-apply viewPlacements on reset for non-default perspective', async () => {
|
|
816
|
+
service.registerPerspective({
|
|
817
|
+
id: 'perspA',
|
|
818
|
+
label: 'A',
|
|
819
|
+
viewPlacements: new Map([['test-widget', 'left' as ApplicationShell.Area]])
|
|
820
|
+
});
|
|
821
|
+
service.registerPerspective({
|
|
822
|
+
id: 'perspB',
|
|
823
|
+
label: 'B',
|
|
824
|
+
viewPlacements: new Map()
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
await service.switchPerspective('perspA');
|
|
828
|
+
|
|
829
|
+
const layoutA = { mainPanel: { widgets: ['customized'] }, bottomPanel: {} };
|
|
830
|
+
getLayoutDataStub.returns(layoutA);
|
|
831
|
+
|
|
832
|
+
// Switch to B (saves A's layout)
|
|
833
|
+
await service.switchPerspective('perspB');
|
|
834
|
+
|
|
835
|
+
// Switch back to A (restores saved layout)
|
|
836
|
+
await service.switchPerspective('perspA');
|
|
837
|
+
|
|
838
|
+
// Verify saved layout exists
|
|
839
|
+
expect(service.getSavedLayout('perspA')).to.not.be.undefined;
|
|
840
|
+
|
|
841
|
+
// Reset stubs to track reset behavior
|
|
842
|
+
setLayoutDataStub.resetHistory();
|
|
843
|
+
getOrCreateWidgetStub.resetHistory();
|
|
844
|
+
addWidgetStub.resetHistory();
|
|
845
|
+
activateWidgetStub.resetHistory();
|
|
846
|
+
|
|
847
|
+
await service.resetCurrentPerspective();
|
|
848
|
+
|
|
849
|
+
// Saved layout should be cleared
|
|
850
|
+
expect(service.getSavedLayout('perspA')).to.be.undefined;
|
|
851
|
+
|
|
852
|
+
// Should re-apply viewPlacements
|
|
853
|
+
expect(getOrCreateWidgetStub.calledWith('test-widget')).to.be.true;
|
|
854
|
+
expect(addWidgetStub.called).to.be.true;
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
it('should call onActivate callback', async () => {
|
|
858
|
+
const onActivate = sinon.spy();
|
|
859
|
+
service.registerPerspective({
|
|
860
|
+
id: 'activate-reset',
|
|
861
|
+
label: 'Activate Reset',
|
|
862
|
+
viewPlacements: new Map(),
|
|
863
|
+
onActivate
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
await service.switchPerspective('activate-reset');
|
|
867
|
+
onActivate.resetHistory();
|
|
868
|
+
|
|
869
|
+
await service.resetCurrentPerspective();
|
|
870
|
+
|
|
871
|
+
expect(onActivate.calledOnce).to.be.true;
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
it('should fire onDidChangePerspective event', async () => {
|
|
875
|
+
service.registerPerspective({
|
|
876
|
+
id: 'event-reset',
|
|
877
|
+
label: 'Event Reset',
|
|
878
|
+
viewPlacements: new Map()
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
await service.switchPerspective('event-reset');
|
|
882
|
+
|
|
883
|
+
const spy = sinon.spy();
|
|
884
|
+
service.onDidChangePerspective(spy);
|
|
885
|
+
|
|
886
|
+
await service.resetCurrentPerspective();
|
|
887
|
+
|
|
888
|
+
expect(spy.calledOnce).to.be.true;
|
|
889
|
+
expect(spy.calledWith('event-reset')).to.be.true;
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
it('should not affect other perspectives\' saved layouts', async () => {
|
|
893
|
+
service.registerPerspective({
|
|
894
|
+
id: 'perspA',
|
|
895
|
+
label: 'A',
|
|
896
|
+
viewPlacements: new Map()
|
|
897
|
+
});
|
|
898
|
+
service.registerPerspective({
|
|
899
|
+
id: 'perspB',
|
|
900
|
+
label: 'B',
|
|
901
|
+
viewPlacements: new Map()
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
// Activate A, then B, to create saved layouts for both
|
|
905
|
+
await service.switchPerspective('perspA');
|
|
906
|
+
|
|
907
|
+
const layoutA = { mainPanel: { widgets: ['a-stuff'] }, bottomPanel: {} };
|
|
908
|
+
getLayoutDataStub.returns(layoutA);
|
|
909
|
+
|
|
910
|
+
await service.switchPerspective('perspB');
|
|
911
|
+
|
|
912
|
+
const layoutB = { mainPanel: { widgets: ['b-stuff'] }, bottomPanel: {} };
|
|
913
|
+
getLayoutDataStub.returns(layoutB);
|
|
914
|
+
|
|
915
|
+
// Switch back to A (saves B's layout)
|
|
916
|
+
await service.switchPerspective('perspA');
|
|
917
|
+
|
|
918
|
+
// Reset A
|
|
919
|
+
await service.resetCurrentPerspective();
|
|
920
|
+
|
|
921
|
+
// A's saved layout should be cleared
|
|
922
|
+
expect(service.getSavedLayout('perspA')).to.be.undefined;
|
|
923
|
+
// B's saved layout should be preserved
|
|
924
|
+
expect(service.getSavedLayout('perspB')).to.equal(layoutB);
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
it('should serialize with other switchPerspective calls', async () => {
|
|
928
|
+
const callOrder: string[] = [];
|
|
929
|
+
|
|
930
|
+
service.registerPerspective({
|
|
931
|
+
id: 'perspA',
|
|
932
|
+
label: 'A',
|
|
933
|
+
viewPlacements: new Map(),
|
|
934
|
+
onActivate: () => callOrder.push('activate-A')
|
|
935
|
+
});
|
|
936
|
+
service.registerPerspective({
|
|
937
|
+
id: 'perspB',
|
|
938
|
+
label: 'B',
|
|
939
|
+
viewPlacements: new Map(),
|
|
940
|
+
onActivate: () => callOrder.push('activate-B')
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
await service.switchPerspective('perspA');
|
|
944
|
+
callOrder.length = 0;
|
|
945
|
+
|
|
946
|
+
// Queue a reset and a switch concurrently
|
|
947
|
+
const p1 = service.resetCurrentPerspective();
|
|
948
|
+
const p2 = service.switchPerspective('perspB');
|
|
949
|
+
|
|
950
|
+
await Promise.all([p1, p2]);
|
|
951
|
+
|
|
952
|
+
expect(callOrder).to.deep.equal(['activate-A', 'activate-B']);
|
|
953
|
+
expect(service.getActivePerspective()?.id).to.equal('perspB');
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
it('should do nothing when no active perspective is set', async () => {
|
|
957
|
+
// No perspective active (activePerspectiveId is undefined)
|
|
958
|
+
const spy = sinon.spy();
|
|
959
|
+
service.onDidChangePerspective(spy);
|
|
960
|
+
|
|
961
|
+
await service.resetCurrentPerspective();
|
|
962
|
+
|
|
963
|
+
expect(spy.called).to.be.false;
|
|
964
|
+
expect(getOrCreateWidgetStub.called).to.be.false;
|
|
965
|
+
});
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
describe('resetDefaultPerspective', () => {
|
|
969
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
970
|
+
function createMockViewContribution(
|
|
971
|
+
options: ViewContributionOptions
|
|
972
|
+
): AbstractViewContribution<Widget> & FrontendApplicationContribution {
|
|
973
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
974
|
+
const contribution = Object.create(AbstractViewContribution.prototype) as any;
|
|
975
|
+
contribution.options = options;
|
|
976
|
+
contribution.widgetManager = {
|
|
977
|
+
getOrCreateWidget: getOrCreateWidgetStub,
|
|
978
|
+
tryGetWidget: tryGetWidgetStub
|
|
979
|
+
};
|
|
980
|
+
contribution.shell = (service as unknown as Record<string, unknown>)['shell'];
|
|
981
|
+
contribution.openView = sinon.stub().resolves(testWidget);
|
|
982
|
+
return contribution;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function createMockNonViewContribution(): FrontendApplicationContribution {
|
|
986
|
+
const contrib: FrontendApplicationContribution = {};
|
|
987
|
+
return contrib;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
it('should relocate open view from wrong area to defaultViewOptions.area', async () => {
|
|
991
|
+
service.initialize();
|
|
992
|
+
|
|
993
|
+
const explorerWidget = new Widget();
|
|
994
|
+
explorerWidget.id = 'explorer';
|
|
995
|
+
const explorerPanel = new Panel();
|
|
996
|
+
Widget.attach(explorerPanel, document.body);
|
|
997
|
+
explorerPanel.addWidget(explorerWidget);
|
|
998
|
+
|
|
999
|
+
tryGetWidgetStub.withArgs('explorer').returns(explorerWidget);
|
|
1000
|
+
getAreaForStub.withArgs(explorerWidget).returns('right');
|
|
1001
|
+
|
|
1002
|
+
const contribution = createMockViewContribution({
|
|
1003
|
+
widgetId: 'explorer',
|
|
1004
|
+
widgetName: 'Explorer',
|
|
1005
|
+
defaultWidgetOptions: { area: 'left' }
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
(service as unknown as Record<string, unknown>)['appContributions'] = {
|
|
1009
|
+
getContributions: () => [contribution]
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
await service.resetCurrentPerspective();
|
|
1013
|
+
|
|
1014
|
+
expect(addWidgetStub.calledWith(explorerWidget, sinon.match({ area: 'left' }))).to.be.true;
|
|
1015
|
+
|
|
1016
|
+
explorerPanel.dispose();
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
it('should skip views already in correct area', async () => {
|
|
1020
|
+
service.initialize();
|
|
1021
|
+
|
|
1022
|
+
const explorerWidget = new Widget();
|
|
1023
|
+
explorerWidget.id = 'explorer';
|
|
1024
|
+
const explorerPanel = new Panel();
|
|
1025
|
+
Widget.attach(explorerPanel, document.body);
|
|
1026
|
+
explorerPanel.addWidget(explorerWidget);
|
|
1027
|
+
|
|
1028
|
+
tryGetWidgetStub.withArgs('explorer').returns(explorerWidget);
|
|
1029
|
+
getAreaForStub.withArgs(explorerWidget).returns('left');
|
|
1030
|
+
|
|
1031
|
+
const contribution = createMockViewContribution({
|
|
1032
|
+
widgetId: 'explorer',
|
|
1033
|
+
widgetName: 'Explorer',
|
|
1034
|
+
defaultWidgetOptions: { area: 'left' }
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
(service as unknown as Record<string, unknown>)['appContributions'] = {
|
|
1038
|
+
getContributions: () => [contribution]
|
|
1039
|
+
};
|
|
1040
|
+
|
|
1041
|
+
addWidgetStub.resetHistory();
|
|
1042
|
+
|
|
1043
|
+
await service.resetCurrentPerspective();
|
|
1044
|
+
|
|
1045
|
+
expect(addWidgetStub.called).to.be.false;
|
|
1046
|
+
|
|
1047
|
+
explorerPanel.dispose();
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
it('should skip views not attached', async () => {
|
|
1051
|
+
service.initialize();
|
|
1052
|
+
|
|
1053
|
+
const explorerWidget = new Widget();
|
|
1054
|
+
explorerWidget.id = 'explorer';
|
|
1055
|
+
// Not attached to any parent
|
|
1056
|
+
|
|
1057
|
+
tryGetWidgetStub.withArgs('explorer').returns(explorerWidget);
|
|
1058
|
+
|
|
1059
|
+
const contribution = createMockViewContribution({
|
|
1060
|
+
widgetId: 'explorer',
|
|
1061
|
+
widgetName: 'Explorer',
|
|
1062
|
+
defaultWidgetOptions: { area: 'left' }
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
(service as unknown as Record<string, unknown>)['appContributions'] = {
|
|
1066
|
+
getContributions: () => [contribution]
|
|
1067
|
+
};
|
|
1068
|
+
|
|
1069
|
+
addWidgetStub.resetHistory();
|
|
1070
|
+
|
|
1071
|
+
await service.resetCurrentPerspective();
|
|
1072
|
+
|
|
1073
|
+
// Phase 1 should not try to relocate (not attached)
|
|
1074
|
+
// Phase 2 will call initializeLayout
|
|
1075
|
+
expect(addWidgetStub.called).to.be.false;
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
it('should call openView on AbstractViewContribution instances', async () => {
|
|
1079
|
+
service.initialize();
|
|
1080
|
+
|
|
1081
|
+
const contribution = createMockViewContribution({
|
|
1082
|
+
widgetId: 'explorer',
|
|
1083
|
+
widgetName: 'Explorer',
|
|
1084
|
+
defaultWidgetOptions: { area: 'left' }
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
(service as unknown as Record<string, unknown>)['appContributions'] = {
|
|
1088
|
+
getContributions: () => [contribution]
|
|
1089
|
+
};
|
|
1090
|
+
|
|
1091
|
+
await service.resetCurrentPerspective();
|
|
1092
|
+
|
|
1093
|
+
expect((contribution.openView as sinon.SinonStub).calledOnce).to.be.true;
|
|
1094
|
+
expect((contribution.openView as sinon.SinonStub).calledWith({ activate: false })).to.be.true;
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
it('should NOT call openView on non-AbstractViewContribution contributions', async () => {
|
|
1098
|
+
service.initialize();
|
|
1099
|
+
|
|
1100
|
+
const nonViewContrib = createMockNonViewContribution();
|
|
1101
|
+
|
|
1102
|
+
(service as unknown as Record<string, unknown>)['appContributions'] = {
|
|
1103
|
+
getContributions: () => [nonViewContrib]
|
|
1104
|
+
};
|
|
1105
|
+
|
|
1106
|
+
// Should not throw
|
|
1107
|
+
await service.resetCurrentPerspective();
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
it('should handle combined scenario: open view in wrong area + closed view', async () => {
|
|
1111
|
+
service.initialize();
|
|
1112
|
+
|
|
1113
|
+
// Explorer is open but in the wrong area
|
|
1114
|
+
const explorerWidget = new Widget();
|
|
1115
|
+
explorerWidget.id = 'explorer';
|
|
1116
|
+
const panel = new Panel();
|
|
1117
|
+
Widget.attach(panel, document.body);
|
|
1118
|
+
panel.addWidget(explorerWidget);
|
|
1119
|
+
|
|
1120
|
+
tryGetWidgetStub.withArgs('explorer').returns(explorerWidget);
|
|
1121
|
+
getAreaForStub.withArgs(explorerWidget).returns('right');
|
|
1122
|
+
|
|
1123
|
+
const explorerContrib = createMockViewContribution({
|
|
1124
|
+
widgetId: 'explorer',
|
|
1125
|
+
widgetName: 'Explorer',
|
|
1126
|
+
defaultWidgetOptions: { area: 'left' }
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
// SCM is closed (tryGetWidget returns undefined)
|
|
1130
|
+
tryGetWidgetStub.withArgs('scm').returns(undefined);
|
|
1131
|
+
|
|
1132
|
+
const scmContrib = createMockViewContribution({
|
|
1133
|
+
widgetId: 'scm',
|
|
1134
|
+
widgetName: 'SCM',
|
|
1135
|
+
defaultWidgetOptions: { area: 'left' }
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
(service as unknown as Record<string, unknown>)['appContributions'] = {
|
|
1139
|
+
getContributions: () => [explorerContrib, scmContrib]
|
|
1140
|
+
};
|
|
1141
|
+
|
|
1142
|
+
addWidgetStub.resetHistory();
|
|
1143
|
+
|
|
1144
|
+
await service.resetCurrentPerspective();
|
|
1145
|
+
|
|
1146
|
+
// Phase 1: Explorer should be relocated
|
|
1147
|
+
expect(addWidgetStub.calledWith(explorerWidget, sinon.match({ area: 'left' }))).to.be.true;
|
|
1148
|
+
// Phase 2: Both should have openView called
|
|
1149
|
+
expect((explorerContrib.openView as sinon.SinonStub).calledOnce).to.be.true;
|
|
1150
|
+
expect((scmContrib.openView as sinon.SinonStub).calledOnce).to.be.true;
|
|
1151
|
+
|
|
1152
|
+
panel.dispose();
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
it('should use viewContainerId as effectiveWidgetId when set', async () => {
|
|
1156
|
+
service.initialize();
|
|
1157
|
+
|
|
1158
|
+
const containerWidget = new Widget();
|
|
1159
|
+
containerWidget.id = 'explorer-container';
|
|
1160
|
+
const panel = new Panel();
|
|
1161
|
+
Widget.attach(panel, document.body);
|
|
1162
|
+
panel.addWidget(containerWidget);
|
|
1163
|
+
|
|
1164
|
+
tryGetWidgetStub.withArgs('explorer-container').returns(containerWidget);
|
|
1165
|
+
getAreaForStub.withArgs(containerWidget).returns('right');
|
|
1166
|
+
|
|
1167
|
+
const contribution = createMockViewContribution({
|
|
1168
|
+
widgetId: 'explorer',
|
|
1169
|
+
viewContainerId: 'explorer-container',
|
|
1170
|
+
widgetName: 'Explorer',
|
|
1171
|
+
defaultWidgetOptions: { area: 'left' }
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
(service as unknown as Record<string, unknown>)['appContributions'] = {
|
|
1175
|
+
getContributions: () => [contribution]
|
|
1176
|
+
};
|
|
1177
|
+
|
|
1178
|
+
addWidgetStub.resetHistory();
|
|
1179
|
+
|
|
1180
|
+
await service.resetCurrentPerspective();
|
|
1181
|
+
|
|
1182
|
+
expect(tryGetWidgetStub.calledWith('explorer-container')).to.be.true;
|
|
1183
|
+
expect(addWidgetStub.calledWith(containerWidget, sinon.match({ area: 'left' }))).to.be.true;
|
|
1184
|
+
|
|
1185
|
+
panel.dispose();
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
it('should handle openView errors gracefully', async () => {
|
|
1189
|
+
service.initialize();
|
|
1190
|
+
|
|
1191
|
+
const contribution = createMockViewContribution({
|
|
1192
|
+
widgetId: 'broken',
|
|
1193
|
+
widgetName: 'Broken',
|
|
1194
|
+
defaultWidgetOptions: { area: 'left' }
|
|
1195
|
+
});
|
|
1196
|
+
(contribution.openView as sinon.SinonStub).rejects(new Error('openView failed'));
|
|
1197
|
+
|
|
1198
|
+
(service as unknown as Record<string, unknown>)['appContributions'] = {
|
|
1199
|
+
getContributions: () => [contribution]
|
|
1200
|
+
};
|
|
1201
|
+
|
|
1202
|
+
// Should not throw
|
|
1203
|
+
await service.resetCurrentPerspective();
|
|
1204
|
+
|
|
1205
|
+
expect(mockLogger.warn.called).to.be.true;
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
it('should handle widget relocation errors gracefully', async () => {
|
|
1209
|
+
service.initialize();
|
|
1210
|
+
|
|
1211
|
+
const explorerWidget = new Widget();
|
|
1212
|
+
explorerWidget.id = 'explorer';
|
|
1213
|
+
const panel = new Panel();
|
|
1214
|
+
Widget.attach(panel, document.body);
|
|
1215
|
+
panel.addWidget(explorerWidget);
|
|
1216
|
+
|
|
1217
|
+
tryGetWidgetStub.withArgs('explorer').returns(explorerWidget);
|
|
1218
|
+
getAreaForStub.withArgs(explorerWidget).returns('right');
|
|
1219
|
+
addWidgetStub.rejects(new Error('relocation failed'));
|
|
1220
|
+
|
|
1221
|
+
const contribution = createMockViewContribution({
|
|
1222
|
+
widgetId: 'explorer',
|
|
1223
|
+
widgetName: 'Explorer',
|
|
1224
|
+
defaultWidgetOptions: { area: 'left' }
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
(service as unknown as Record<string, unknown>)['appContributions'] = {
|
|
1228
|
+
getContributions: () => [contribution]
|
|
1229
|
+
};
|
|
1230
|
+
|
|
1231
|
+
// Should not throw
|
|
1232
|
+
await service.resetCurrentPerspective();
|
|
1233
|
+
|
|
1234
|
+
expect(mockLogger.warn.called).to.be.true;
|
|
1235
|
+
|
|
1236
|
+
panel.dispose();
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
it('should fire onDidChangePerspective event on default perspective reset', async () => {
|
|
1240
|
+
service.initialize();
|
|
1241
|
+
|
|
1242
|
+
(service as unknown as Record<string, unknown>)['appContributions'] = {
|
|
1243
|
+
getContributions: () => []
|
|
1244
|
+
};
|
|
1245
|
+
|
|
1246
|
+
const spy = sinon.spy();
|
|
1247
|
+
service.onDidChangePerspective(spy);
|
|
1248
|
+
|
|
1249
|
+
await service.resetCurrentPerspective();
|
|
1250
|
+
|
|
1251
|
+
expect(spy.calledOnce).to.be.true;
|
|
1252
|
+
expect(spy.calledWith(PerspectiveServiceImpl.DEFAULT_PERSPECTIVE_ID)).to.be.true;
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
it('should skip contributions without defaultViewOptions.area in Phase 1', async () => {
|
|
1256
|
+
service.initialize();
|
|
1257
|
+
|
|
1258
|
+
const contribution = createMockViewContribution({
|
|
1259
|
+
widgetId: 'no-area',
|
|
1260
|
+
widgetName: 'No Area',
|
|
1261
|
+
defaultWidgetOptions: {}
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
(service as unknown as Record<string, unknown>)['appContributions'] = {
|
|
1265
|
+
getContributions: () => [contribution]
|
|
1266
|
+
};
|
|
1267
|
+
|
|
1268
|
+
addWidgetStub.resetHistory();
|
|
1269
|
+
|
|
1270
|
+
await service.resetCurrentPerspective();
|
|
1271
|
+
|
|
1272
|
+
// Phase 1 should skip (no target area)
|
|
1273
|
+
// Phase 2 should still call openView
|
|
1274
|
+
expect((contribution.openView as sinon.SinonStub).calledOnce).to.be.true;
|
|
1275
|
+
});
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
describe('layout healing on perspective switch', () => {
|
|
1279
|
+
it('should replace disposed side panel widget when switching back to a perspective', async () => {
|
|
1280
|
+
service.registerPerspective({
|
|
1281
|
+
id: 'perspA',
|
|
1282
|
+
label: 'A',
|
|
1283
|
+
viewPlacements: new Map()
|
|
1284
|
+
});
|
|
1285
|
+
service.registerPerspective({
|
|
1286
|
+
id: 'perspB',
|
|
1287
|
+
label: 'B',
|
|
1288
|
+
viewPlacements: new Map()
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
// Create a widget and then dispose it to simulate cross-perspective closure
|
|
1292
|
+
const disposedWidget = new Widget();
|
|
1293
|
+
disposedWidget.id = 'explorer-widget';
|
|
1294
|
+
disposedWidget.dispose();
|
|
1295
|
+
expect(disposedWidget.isDisposed).to.be.true;
|
|
1296
|
+
|
|
1297
|
+
const freshWidget = new Widget();
|
|
1298
|
+
freshWidget.id = 'explorer-widget';
|
|
1299
|
+
getOrCreateWidgetStub.withArgs('explorer-widget').resolves(freshWidget);
|
|
1300
|
+
|
|
1301
|
+
const savedLayout: ApplicationShell.LayoutData = {
|
|
1302
|
+
leftPanel: {
|
|
1303
|
+
type: 'sidepanel',
|
|
1304
|
+
items: [{ widget: disposedWidget, rank: 0, expanded: true }]
|
|
1305
|
+
} as SidePanel.LayoutData,
|
|
1306
|
+
mainPanel: undefined,
|
|
1307
|
+
bottomPanel: undefined
|
|
1308
|
+
};
|
|
1309
|
+
|
|
1310
|
+
// Switch to A, then to B
|
|
1311
|
+
await service.switchPerspective('perspA');
|
|
1312
|
+
await service.switchPerspective('perspB');
|
|
1313
|
+
|
|
1314
|
+
// Manually set the saved layout for A with the disposed widget
|
|
1315
|
+
service.setSavedLayout('perspA', savedLayout);
|
|
1316
|
+
|
|
1317
|
+
setLayoutDataStub.resetHistory();
|
|
1318
|
+
getOrCreateWidgetStub.resetHistory();
|
|
1319
|
+
|
|
1320
|
+
// Switch back to A
|
|
1321
|
+
await service.switchPerspective('perspA');
|
|
1322
|
+
|
|
1323
|
+
expect(getOrCreateWidgetStub.calledWith('explorer-widget')).to.be.true;
|
|
1324
|
+
expect(setLayoutDataStub.calledOnce).to.be.true;
|
|
1325
|
+
// The layout should now contain the fresh widget
|
|
1326
|
+
const restoredLayout = setLayoutDataStub.firstCall.args[0] as ApplicationShell.LayoutData;
|
|
1327
|
+
expect(restoredLayout.leftPanel!.items![0].widget).to.equal(freshWidget);
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
it('should replace disposed dock panel widget (main area)', async () => {
|
|
1331
|
+
service.registerPerspective({
|
|
1332
|
+
id: 'perspA',
|
|
1333
|
+
label: 'A',
|
|
1334
|
+
viewPlacements: new Map()
|
|
1335
|
+
});
|
|
1336
|
+
service.registerPerspective({
|
|
1337
|
+
id: 'perspB',
|
|
1338
|
+
label: 'B',
|
|
1339
|
+
viewPlacements: new Map()
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
const disposedWidget = new Widget();
|
|
1343
|
+
disposedWidget.id = 'editor-widget';
|
|
1344
|
+
disposedWidget.dispose();
|
|
1345
|
+
|
|
1346
|
+
const freshWidget = new Widget();
|
|
1347
|
+
freshWidget.id = 'editor-widget';
|
|
1348
|
+
getOrCreateWidgetStub.withArgs('editor-widget').resolves(freshWidget);
|
|
1349
|
+
|
|
1350
|
+
const savedLayout: ApplicationShell.LayoutData = {
|
|
1351
|
+
mainPanel: {
|
|
1352
|
+
main: {
|
|
1353
|
+
type: 'tab-area',
|
|
1354
|
+
widgets: [disposedWidget],
|
|
1355
|
+
currentIndex: 0
|
|
1356
|
+
} as DockLayout.ITabAreaConfig
|
|
1357
|
+
},
|
|
1358
|
+
bottomPanel: undefined
|
|
1359
|
+
};
|
|
1360
|
+
|
|
1361
|
+
await service.switchPerspective('perspA');
|
|
1362
|
+
await service.switchPerspective('perspB');
|
|
1363
|
+
service.setSavedLayout('perspA', savedLayout);
|
|
1364
|
+
|
|
1365
|
+
setLayoutDataStub.resetHistory();
|
|
1366
|
+
getOrCreateWidgetStub.resetHistory();
|
|
1367
|
+
|
|
1368
|
+
await service.switchPerspective('perspA');
|
|
1369
|
+
|
|
1370
|
+
expect(getOrCreateWidgetStub.calledWith('editor-widget')).to.be.true;
|
|
1371
|
+
const restoredLayout = setLayoutDataStub.firstCall.args[0] as ApplicationShell.LayoutData;
|
|
1372
|
+
expect((restoredLayout.mainPanel!.main as DockLayout.ITabAreaConfig).widgets[0]).to.equal(freshWidget);
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
it('should handle recreation failure gracefully', async () => {
|
|
1376
|
+
service.registerPerspective({
|
|
1377
|
+
id: 'perspA',
|
|
1378
|
+
label: 'A',
|
|
1379
|
+
viewPlacements: new Map()
|
|
1380
|
+
});
|
|
1381
|
+
service.registerPerspective({
|
|
1382
|
+
id: 'perspB',
|
|
1383
|
+
label: 'B',
|
|
1384
|
+
viewPlacements: new Map()
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
const disposedWidget = new Widget();
|
|
1388
|
+
disposedWidget.id = 'broken-widget';
|
|
1389
|
+
disposedWidget.dispose();
|
|
1390
|
+
|
|
1391
|
+
getOrCreateWidgetStub.withArgs('broken-widget').rejects(new Error('No factory'));
|
|
1392
|
+
|
|
1393
|
+
const savedLayout: ApplicationShell.LayoutData = {
|
|
1394
|
+
leftPanel: {
|
|
1395
|
+
type: 'sidepanel',
|
|
1396
|
+
items: [{ widget: disposedWidget, rank: 0, expanded: true }]
|
|
1397
|
+
} as SidePanel.LayoutData,
|
|
1398
|
+
mainPanel: undefined,
|
|
1399
|
+
bottomPanel: undefined
|
|
1400
|
+
};
|
|
1401
|
+
|
|
1402
|
+
await service.switchPerspective('perspA');
|
|
1403
|
+
await service.switchPerspective('perspB');
|
|
1404
|
+
service.setSavedLayout('perspA', savedLayout);
|
|
1405
|
+
|
|
1406
|
+
mockLogger.warn.resetHistory();
|
|
1407
|
+
setLayoutDataStub.resetHistory();
|
|
1408
|
+
|
|
1409
|
+
await service.switchPerspective('perspA');
|
|
1410
|
+
|
|
1411
|
+
// Widget should be set to undefined in the layout
|
|
1412
|
+
const restoredLayout = setLayoutDataStub.firstCall.args[0] as ApplicationShell.LayoutData;
|
|
1413
|
+
expect(restoredLayout.leftPanel!.items![0].widget).to.be.undefined;
|
|
1414
|
+
// Warn log should have been called
|
|
1415
|
+
expect(mockLogger.warn.called).to.be.true;
|
|
1416
|
+
const healCall = mockLogger.warn.getCalls().find(
|
|
1417
|
+
(c: sinon.SinonSpyCall) => c.args[0] === 'Failed to recreate disposed widget for perspective layout'
|
|
1418
|
+
);
|
|
1419
|
+
expect(healCall).to.not.be.undefined;
|
|
1420
|
+
// setLayoutData should still be called
|
|
1421
|
+
expect(setLayoutDataStub.calledOnce).to.be.true;
|
|
1422
|
+
});
|
|
1423
|
+
|
|
1424
|
+
it('should handle recreation failure in dock panel by splicing widget', async () => {
|
|
1425
|
+
service.registerPerspective({
|
|
1426
|
+
id: 'perspA',
|
|
1427
|
+
label: 'A',
|
|
1428
|
+
viewPlacements: new Map()
|
|
1429
|
+
});
|
|
1430
|
+
service.registerPerspective({
|
|
1431
|
+
id: 'perspB',
|
|
1432
|
+
label: 'B',
|
|
1433
|
+
viewPlacements: new Map()
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
const disposedWidget = new Widget();
|
|
1437
|
+
disposedWidget.id = 'broken-widget';
|
|
1438
|
+
disposedWidget.dispose();
|
|
1439
|
+
|
|
1440
|
+
const goodWidget = new Widget();
|
|
1441
|
+
goodWidget.id = 'good-widget';
|
|
1442
|
+
|
|
1443
|
+
getOrCreateWidgetStub.withArgs('broken-widget').rejects(new Error('No factory'));
|
|
1444
|
+
|
|
1445
|
+
const savedLayout: ApplicationShell.LayoutData = {
|
|
1446
|
+
mainPanel: {
|
|
1447
|
+
main: {
|
|
1448
|
+
type: 'tab-area',
|
|
1449
|
+
widgets: [goodWidget, disposedWidget],
|
|
1450
|
+
currentIndex: 0
|
|
1451
|
+
} as DockLayout.ITabAreaConfig
|
|
1452
|
+
},
|
|
1453
|
+
bottomPanel: undefined
|
|
1454
|
+
};
|
|
1455
|
+
|
|
1456
|
+
await service.switchPerspective('perspA');
|
|
1457
|
+
await service.switchPerspective('perspB');
|
|
1458
|
+
service.setSavedLayout('perspA', savedLayout);
|
|
1459
|
+
|
|
1460
|
+
setLayoutDataStub.resetHistory();
|
|
1461
|
+
|
|
1462
|
+
await service.switchPerspective('perspA');
|
|
1463
|
+
|
|
1464
|
+
const restoredLayout = setLayoutDataStub.firstCall.args[0] as ApplicationShell.LayoutData;
|
|
1465
|
+
const tabArea = restoredLayout.mainPanel!.main as DockLayout.ITabAreaConfig;
|
|
1466
|
+
// The broken widget should have been spliced out
|
|
1467
|
+
expect(tabArea.widgets).to.have.lengthOf(1);
|
|
1468
|
+
expect(tabArea.widgets[0]).to.equal(goodWidget);
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
it('should not touch non-disposed widgets', async () => {
|
|
1472
|
+
service.registerPerspective({
|
|
1473
|
+
id: 'perspA',
|
|
1474
|
+
label: 'A',
|
|
1475
|
+
viewPlacements: new Map()
|
|
1476
|
+
});
|
|
1477
|
+
service.registerPerspective({
|
|
1478
|
+
id: 'perspB',
|
|
1479
|
+
label: 'B',
|
|
1480
|
+
viewPlacements: new Map()
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
const liveWidget = new Widget();
|
|
1484
|
+
liveWidget.id = 'live-widget';
|
|
1485
|
+
|
|
1486
|
+
const disposedWidget = new Widget();
|
|
1487
|
+
disposedWidget.id = 'dead-widget';
|
|
1488
|
+
disposedWidget.dispose();
|
|
1489
|
+
|
|
1490
|
+
const freshWidget = new Widget();
|
|
1491
|
+
freshWidget.id = 'dead-widget';
|
|
1492
|
+
getOrCreateWidgetStub.withArgs('dead-widget').resolves(freshWidget);
|
|
1493
|
+
|
|
1494
|
+
const savedLayout: ApplicationShell.LayoutData = {
|
|
1495
|
+
leftPanel: {
|
|
1496
|
+
type: 'sidepanel',
|
|
1497
|
+
items: [
|
|
1498
|
+
{ widget: liveWidget, rank: 0, expanded: true },
|
|
1499
|
+
{ widget: disposedWidget, rank: 1, expanded: false }
|
|
1500
|
+
]
|
|
1501
|
+
} as SidePanel.LayoutData,
|
|
1502
|
+
mainPanel: undefined,
|
|
1503
|
+
bottomPanel: undefined
|
|
1504
|
+
};
|
|
1505
|
+
|
|
1506
|
+
await service.switchPerspective('perspA');
|
|
1507
|
+
await service.switchPerspective('perspB');
|
|
1508
|
+
service.setSavedLayout('perspA', savedLayout);
|
|
1509
|
+
|
|
1510
|
+
getOrCreateWidgetStub.resetHistory();
|
|
1511
|
+
setLayoutDataStub.resetHistory();
|
|
1512
|
+
|
|
1513
|
+
await service.switchPerspective('perspA');
|
|
1514
|
+
|
|
1515
|
+
// Only the disposed widget should trigger getOrCreateWidget
|
|
1516
|
+
expect(getOrCreateWidgetStub.calledOnce).to.be.true;
|
|
1517
|
+
expect(getOrCreateWidgetStub.calledWith('dead-widget')).to.be.true;
|
|
1518
|
+
// The live widget should remain unchanged
|
|
1519
|
+
const restoredLayout = setLayoutDataStub.firstCall.args[0] as ApplicationShell.LayoutData;
|
|
1520
|
+
expect(restoredLayout.leftPanel!.items![0].widget).to.equal(liveWidget);
|
|
1521
|
+
expect(restoredLayout.leftPanel!.items![1].widget).to.equal(freshWidget);
|
|
1522
|
+
});
|
|
1523
|
+
|
|
1524
|
+
it('should handle nested split-area configs', async () => {
|
|
1525
|
+
service.registerPerspective({
|
|
1526
|
+
id: 'perspA',
|
|
1527
|
+
label: 'A',
|
|
1528
|
+
viewPlacements: new Map()
|
|
1529
|
+
});
|
|
1530
|
+
service.registerPerspective({
|
|
1531
|
+
id: 'perspB',
|
|
1532
|
+
label: 'B',
|
|
1533
|
+
viewPlacements: new Map()
|
|
1534
|
+
});
|
|
1535
|
+
|
|
1536
|
+
const disposedWidget = new Widget();
|
|
1537
|
+
disposedWidget.id = 'nested-widget';
|
|
1538
|
+
disposedWidget.dispose();
|
|
1539
|
+
|
|
1540
|
+
const freshWidget = new Widget();
|
|
1541
|
+
freshWidget.id = 'nested-widget';
|
|
1542
|
+
getOrCreateWidgetStub.withArgs('nested-widget').resolves(freshWidget);
|
|
1543
|
+
|
|
1544
|
+
const liveWidget = new Widget();
|
|
1545
|
+
liveWidget.id = 'other-widget';
|
|
1546
|
+
|
|
1547
|
+
const savedLayout: ApplicationShell.LayoutData = {
|
|
1548
|
+
mainPanel: {
|
|
1549
|
+
main: {
|
|
1550
|
+
type: 'split-area',
|
|
1551
|
+
orientation: 'horizontal',
|
|
1552
|
+
sizes: [0.5, 0.5],
|
|
1553
|
+
children: [
|
|
1554
|
+
{
|
|
1555
|
+
type: 'tab-area',
|
|
1556
|
+
widgets: [liveWidget],
|
|
1557
|
+
currentIndex: 0
|
|
1558
|
+
} as DockLayout.ITabAreaConfig,
|
|
1559
|
+
{
|
|
1560
|
+
type: 'tab-area',
|
|
1561
|
+
widgets: [disposedWidget],
|
|
1562
|
+
currentIndex: 0
|
|
1563
|
+
} as DockLayout.ITabAreaConfig
|
|
1564
|
+
]
|
|
1565
|
+
} as DockLayout.ISplitAreaConfig
|
|
1566
|
+
},
|
|
1567
|
+
bottomPanel: undefined
|
|
1568
|
+
};
|
|
1569
|
+
|
|
1570
|
+
await service.switchPerspective('perspA');
|
|
1571
|
+
await service.switchPerspective('perspB');
|
|
1572
|
+
service.setSavedLayout('perspA', savedLayout);
|
|
1573
|
+
|
|
1574
|
+
getOrCreateWidgetStub.resetHistory();
|
|
1575
|
+
setLayoutDataStub.resetHistory();
|
|
1576
|
+
|
|
1577
|
+
await service.switchPerspective('perspA');
|
|
1578
|
+
|
|
1579
|
+
expect(getOrCreateWidgetStub.calledWith('nested-widget')).to.be.true;
|
|
1580
|
+
const restoredLayout = setLayoutDataStub.firstCall.args[0] as ApplicationShell.LayoutData;
|
|
1581
|
+
const splitArea = restoredLayout.mainPanel!.main as DockLayout.ISplitAreaConfig;
|
|
1582
|
+
expect((splitArea.children[0] as DockLayout.ITabAreaConfig).widgets[0]).to.equal(liveWidget);
|
|
1583
|
+
expect((splitArea.children[1] as DockLayout.ITabAreaConfig).widgets[0]).to.equal(freshWidget);
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1586
|
+
it('should handle empty/missing panel sections', async () => {
|
|
1587
|
+
service.registerPerspective({
|
|
1588
|
+
id: 'perspA',
|
|
1589
|
+
label: 'A',
|
|
1590
|
+
viewPlacements: new Map()
|
|
1591
|
+
});
|
|
1592
|
+
service.registerPerspective({
|
|
1593
|
+
id: 'perspB',
|
|
1594
|
+
label: 'B',
|
|
1595
|
+
viewPlacements: new Map()
|
|
1596
|
+
});
|
|
1597
|
+
|
|
1598
|
+
const savedLayout: ApplicationShell.LayoutData = {
|
|
1599
|
+
mainPanel: undefined,
|
|
1600
|
+
bottomPanel: undefined,
|
|
1601
|
+
leftPanel: undefined,
|
|
1602
|
+
rightPanel: undefined
|
|
1603
|
+
};
|
|
1604
|
+
|
|
1605
|
+
await service.switchPerspective('perspA');
|
|
1606
|
+
await service.switchPerspective('perspB');
|
|
1607
|
+
service.setSavedLayout('perspA', savedLayout);
|
|
1608
|
+
|
|
1609
|
+
setLayoutDataStub.resetHistory();
|
|
1610
|
+
|
|
1611
|
+
// Should not throw
|
|
1612
|
+
await service.switchPerspective('perspA');
|
|
1613
|
+
|
|
1614
|
+
expect(setLayoutDataStub.calledOnce).to.be.true;
|
|
1615
|
+
});
|
|
1616
|
+
|
|
1617
|
+
it('should heal bottom panel disposed widgets', async () => {
|
|
1618
|
+
service.registerPerspective({
|
|
1619
|
+
id: 'perspA',
|
|
1620
|
+
label: 'A',
|
|
1621
|
+
viewPlacements: new Map()
|
|
1622
|
+
});
|
|
1623
|
+
service.registerPerspective({
|
|
1624
|
+
id: 'perspB',
|
|
1625
|
+
label: 'B',
|
|
1626
|
+
viewPlacements: new Map()
|
|
1627
|
+
});
|
|
1628
|
+
|
|
1629
|
+
const disposedWidget = new Widget();
|
|
1630
|
+
disposedWidget.id = 'bottom-widget';
|
|
1631
|
+
disposedWidget.dispose();
|
|
1632
|
+
|
|
1633
|
+
const freshWidget = new Widget();
|
|
1634
|
+
freshWidget.id = 'bottom-widget';
|
|
1635
|
+
getOrCreateWidgetStub.withArgs('bottom-widget').resolves(freshWidget);
|
|
1636
|
+
|
|
1637
|
+
const savedLayout: ApplicationShell.LayoutData = {
|
|
1638
|
+
mainPanel: undefined,
|
|
1639
|
+
bottomPanel: {
|
|
1640
|
+
config: {
|
|
1641
|
+
main: {
|
|
1642
|
+
type: 'tab-area',
|
|
1643
|
+
widgets: [disposedWidget],
|
|
1644
|
+
currentIndex: 0
|
|
1645
|
+
} as DockLayout.ITabAreaConfig
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
};
|
|
1649
|
+
|
|
1650
|
+
await service.switchPerspective('perspA');
|
|
1651
|
+
await service.switchPerspective('perspB');
|
|
1652
|
+
service.setSavedLayout('perspA', savedLayout);
|
|
1653
|
+
|
|
1654
|
+
getOrCreateWidgetStub.resetHistory();
|
|
1655
|
+
setLayoutDataStub.resetHistory();
|
|
1656
|
+
|
|
1657
|
+
await service.switchPerspective('perspA');
|
|
1658
|
+
|
|
1659
|
+
expect(getOrCreateWidgetStub.calledWith('bottom-widget')).to.be.true;
|
|
1660
|
+
const restoredLayout = setLayoutDataStub.firstCall.args[0] as ApplicationShell.LayoutData;
|
|
1661
|
+
expect((restoredLayout.bottomPanel!.config!.main as DockLayout.ITabAreaConfig).widgets[0]).to.equal(freshWidget);
|
|
1662
|
+
});
|
|
1663
|
+
|
|
1664
|
+
it('should heal right panel disposed widgets', async () => {
|
|
1665
|
+
service.registerPerspective({
|
|
1666
|
+
id: 'perspA',
|
|
1667
|
+
label: 'A',
|
|
1668
|
+
viewPlacements: new Map()
|
|
1669
|
+
});
|
|
1670
|
+
service.registerPerspective({
|
|
1671
|
+
id: 'perspB',
|
|
1672
|
+
label: 'B',
|
|
1673
|
+
viewPlacements: new Map()
|
|
1674
|
+
});
|
|
1675
|
+
|
|
1676
|
+
const disposedWidget = new Widget();
|
|
1677
|
+
disposedWidget.id = 'right-widget';
|
|
1678
|
+
disposedWidget.dispose();
|
|
1679
|
+
|
|
1680
|
+
const freshWidget = new Widget();
|
|
1681
|
+
freshWidget.id = 'right-widget';
|
|
1682
|
+
getOrCreateWidgetStub.withArgs('right-widget').resolves(freshWidget);
|
|
1683
|
+
|
|
1684
|
+
const savedLayout: ApplicationShell.LayoutData = {
|
|
1685
|
+
mainPanel: undefined,
|
|
1686
|
+
bottomPanel: undefined,
|
|
1687
|
+
rightPanel: {
|
|
1688
|
+
type: 'sidepanel',
|
|
1689
|
+
items: [{ widget: disposedWidget, rank: 0, expanded: true }]
|
|
1690
|
+
} as SidePanel.LayoutData
|
|
1691
|
+
};
|
|
1692
|
+
|
|
1693
|
+
await service.switchPerspective('perspA');
|
|
1694
|
+
await service.switchPerspective('perspB');
|
|
1695
|
+
service.setSavedLayout('perspA', savedLayout);
|
|
1696
|
+
|
|
1697
|
+
getOrCreateWidgetStub.resetHistory();
|
|
1698
|
+
setLayoutDataStub.resetHistory();
|
|
1699
|
+
|
|
1700
|
+
await service.switchPerspective('perspA');
|
|
1701
|
+
|
|
1702
|
+
expect(getOrCreateWidgetStub.calledWith('right-widget')).to.be.true;
|
|
1703
|
+
const restoredLayout = setLayoutDataStub.firstCall.args[0] as ApplicationShell.LayoutData;
|
|
1704
|
+
expect(restoredLayout.rightPanel!.items![0].widget).to.equal(freshWidget);
|
|
1705
|
+
});
|
|
1706
|
+
});
|
|
1707
|
+
|
|
1708
|
+
describe('Plumbing API (layout persistence)', () => {
|
|
1709
|
+
it('should expose defaultPerspectiveId matching the static constant', () => {
|
|
1710
|
+
expect(service.defaultPerspectiveId).to.equal(PerspectiveServiceImpl.DEFAULT_PERSPECTIVE_ID);
|
|
1711
|
+
});
|
|
1712
|
+
|
|
1713
|
+
it('should return default perspective ID when none is set', () => {
|
|
1714
|
+
expect(service.getActivePerspectiveId()).to.equal(PerspectiveServiceImpl.DEFAULT_PERSPECTIVE_ID);
|
|
1715
|
+
});
|
|
1716
|
+
|
|
1717
|
+
it('should return active perspective ID after switching', async () => {
|
|
1718
|
+
service.registerPerspective({
|
|
1719
|
+
id: 'test-persp',
|
|
1720
|
+
label: 'Test',
|
|
1721
|
+
viewPlacements: new Map()
|
|
1722
|
+
});
|
|
1723
|
+
await service.switchPerspective('test-persp');
|
|
1724
|
+
expect(service.getActivePerspectiveId()).to.equal('test-persp');
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
it('should round-trip getSavedLayout/setSavedLayout', () => {
|
|
1728
|
+
const layout = { mainPanel: { widgets: ['w1'] }, bottomPanel: {} } as unknown as ApplicationShell.LayoutData;
|
|
1729
|
+
service.setSavedLayout('persp-x', layout);
|
|
1730
|
+
|
|
1731
|
+
expect(service.getSavedLayout('persp-x')).to.equal(layout);
|
|
1732
|
+
});
|
|
1733
|
+
|
|
1734
|
+
it('should return undefined for non-existent saved layout', () => {
|
|
1735
|
+
expect(service.getSavedLayout('no-such-persp')).to.be.undefined;
|
|
1736
|
+
});
|
|
1737
|
+
|
|
1738
|
+
it('should return correct saved perspective IDs', () => {
|
|
1739
|
+
const layout1 = { mainPanel: {} } as unknown as ApplicationShell.LayoutData;
|
|
1740
|
+
const layout2 = { mainPanel: {} } as unknown as ApplicationShell.LayoutData;
|
|
1741
|
+
service.setSavedLayout('a', layout1);
|
|
1742
|
+
service.setSavedLayout('b', layout2);
|
|
1743
|
+
|
|
1744
|
+
const ids = service.getSavedPerspectiveIds();
|
|
1745
|
+
expect(ids).to.include('a');
|
|
1746
|
+
expect(ids).to.include('b');
|
|
1747
|
+
expect(ids).to.have.lengthOf(2);
|
|
1748
|
+
});
|
|
1749
|
+
|
|
1750
|
+
it('should set active perspective ID only for registered perspectives', () => {
|
|
1751
|
+
service.registerPerspective({
|
|
1752
|
+
id: 'registered',
|
|
1753
|
+
label: 'Registered',
|
|
1754
|
+
viewPlacements: new Map()
|
|
1755
|
+
});
|
|
1756
|
+
|
|
1757
|
+
expect(service.setActivePerspectiveId('registered')).to.be.true;
|
|
1758
|
+
expect(service.getActivePerspectiveId()).to.equal('registered');
|
|
1759
|
+
|
|
1760
|
+
expect(service.setActivePerspectiveId('unregistered')).to.be.false;
|
|
1761
|
+
expect(service.getActivePerspectiveId()).to.equal('registered');
|
|
1762
|
+
});
|
|
1763
|
+
|
|
1764
|
+
it('should clear all saved layouts', () => {
|
|
1765
|
+
const layout = { mainPanel: {} } as unknown as ApplicationShell.LayoutData;
|
|
1766
|
+
service.setSavedLayout('a', layout);
|
|
1767
|
+
service.setSavedLayout('b', layout);
|
|
1768
|
+
|
|
1769
|
+
service.clearSavedLayouts();
|
|
1770
|
+
|
|
1771
|
+
expect(service.getSavedPerspectiveIds()).to.have.lengthOf(0);
|
|
1772
|
+
expect(service.getSavedLayout('a')).to.be.undefined;
|
|
1773
|
+
expect(service.getSavedLayout('b')).to.be.undefined;
|
|
1774
|
+
});
|
|
1775
|
+
});
|
|
1776
|
+
|
|
1777
|
+
describe('collectWidgetIds', () => {
|
|
1778
|
+
it('should collect IDs from side panel items', () => {
|
|
1779
|
+
const widgetA = new Widget();
|
|
1780
|
+
widgetA.id = 'widget-a';
|
|
1781
|
+
const widgetB = new Widget();
|
|
1782
|
+
widgetB.id = 'widget-b';
|
|
1783
|
+
|
|
1784
|
+
const layout: ApplicationShell.LayoutData = {
|
|
1785
|
+
leftPanel: {
|
|
1786
|
+
type: 'sidepanel',
|
|
1787
|
+
items: [
|
|
1788
|
+
{ widget: widgetA, rank: 0, expanded: true },
|
|
1789
|
+
{ widget: widgetB, rank: 1, expanded: false }
|
|
1790
|
+
]
|
|
1791
|
+
} as SidePanel.LayoutData,
|
|
1792
|
+
mainPanel: undefined,
|
|
1793
|
+
bottomPanel: undefined
|
|
1794
|
+
};
|
|
1795
|
+
|
|
1796
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1797
|
+
const ids = (service as any).collectWidgetIds(layout);
|
|
1798
|
+
expect(ids.has('widget-a')).to.be.true;
|
|
1799
|
+
expect(ids.has('widget-b')).to.be.true;
|
|
1800
|
+
expect(ids.size).to.equal(2);
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
it('should collect IDs from dock panel tab-areas', () => {
|
|
1804
|
+
const widgetA = new Widget();
|
|
1805
|
+
widgetA.id = 'tab-widget-a';
|
|
1806
|
+
const widgetB = new Widget();
|
|
1807
|
+
widgetB.id = 'tab-widget-b';
|
|
1808
|
+
|
|
1809
|
+
const layout: ApplicationShell.LayoutData = {
|
|
1810
|
+
mainPanel: {
|
|
1811
|
+
main: {
|
|
1812
|
+
type: 'tab-area',
|
|
1813
|
+
widgets: [widgetA, widgetB],
|
|
1814
|
+
currentIndex: 0
|
|
1815
|
+
} as DockLayout.ITabAreaConfig
|
|
1816
|
+
},
|
|
1817
|
+
bottomPanel: undefined
|
|
1818
|
+
};
|
|
1819
|
+
|
|
1820
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1821
|
+
const ids = (service as any).collectWidgetIds(layout);
|
|
1822
|
+
expect(ids.has('tab-widget-a')).to.be.true;
|
|
1823
|
+
expect(ids.has('tab-widget-b')).to.be.true;
|
|
1824
|
+
expect(ids.size).to.equal(2);
|
|
1825
|
+
});
|
|
1826
|
+
|
|
1827
|
+
it('should collect IDs from nested split-areas', () => {
|
|
1828
|
+
const widgetA = new Widget();
|
|
1829
|
+
widgetA.id = 'split-a';
|
|
1830
|
+
const widgetB = new Widget();
|
|
1831
|
+
widgetB.id = 'split-b';
|
|
1832
|
+
|
|
1833
|
+
const layout: ApplicationShell.LayoutData = {
|
|
1834
|
+
mainPanel: {
|
|
1835
|
+
main: {
|
|
1836
|
+
type: 'split-area',
|
|
1837
|
+
orientation: 'horizontal',
|
|
1838
|
+
sizes: [0.5, 0.5],
|
|
1839
|
+
children: [
|
|
1840
|
+
{
|
|
1841
|
+
type: 'tab-area',
|
|
1842
|
+
widgets: [widgetA],
|
|
1843
|
+
currentIndex: 0
|
|
1844
|
+
} as DockLayout.ITabAreaConfig,
|
|
1845
|
+
{
|
|
1846
|
+
type: 'tab-area',
|
|
1847
|
+
widgets: [widgetB],
|
|
1848
|
+
currentIndex: 0
|
|
1849
|
+
} as DockLayout.ITabAreaConfig
|
|
1850
|
+
]
|
|
1851
|
+
} as DockLayout.ISplitAreaConfig
|
|
1852
|
+
},
|
|
1853
|
+
bottomPanel: undefined
|
|
1854
|
+
};
|
|
1855
|
+
|
|
1856
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1857
|
+
const ids = (service as any).collectWidgetIds(layout);
|
|
1858
|
+
expect(ids.has('split-a')).to.be.true;
|
|
1859
|
+
expect(ids.has('split-b')).to.be.true;
|
|
1860
|
+
expect(ids.size).to.equal(2);
|
|
1861
|
+
});
|
|
1862
|
+
|
|
1863
|
+
it('should return empty set for empty/missing layout sections', () => {
|
|
1864
|
+
const layout: ApplicationShell.LayoutData = {
|
|
1865
|
+
mainPanel: undefined,
|
|
1866
|
+
bottomPanel: undefined,
|
|
1867
|
+
leftPanel: undefined,
|
|
1868
|
+
rightPanel: undefined
|
|
1869
|
+
};
|
|
1870
|
+
|
|
1871
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1872
|
+
const ids = (service as any).collectWidgetIds(layout);
|
|
1873
|
+
expect(ids.size).to.equal(0);
|
|
1874
|
+
});
|
|
1875
|
+
|
|
1876
|
+
it('should skip undefined widget entries in tab-areas', () => {
|
|
1877
|
+
const widgetA = new Widget();
|
|
1878
|
+
widgetA.id = 'valid-widget';
|
|
1879
|
+
|
|
1880
|
+
const layout: ApplicationShell.LayoutData = {
|
|
1881
|
+
mainPanel: {
|
|
1882
|
+
main: {
|
|
1883
|
+
type: 'tab-area',
|
|
1884
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1885
|
+
widgets: [widgetA, undefined as any],
|
|
1886
|
+
currentIndex: 0
|
|
1887
|
+
} as DockLayout.ITabAreaConfig
|
|
1888
|
+
},
|
|
1889
|
+
bottomPanel: undefined
|
|
1890
|
+
};
|
|
1891
|
+
|
|
1892
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1893
|
+
const ids = (service as any).collectWidgetIds(layout);
|
|
1894
|
+
expect(ids.has('valid-widget')).to.be.true;
|
|
1895
|
+
expect(ids.size).to.equal(1);
|
|
1896
|
+
});
|
|
1897
|
+
|
|
1898
|
+
it('should skip undefined widget entries in side panel items', () => {
|
|
1899
|
+
const widgetA = new Widget();
|
|
1900
|
+
widgetA.id = 'valid-side-widget';
|
|
1901
|
+
|
|
1902
|
+
const layout: ApplicationShell.LayoutData = {
|
|
1903
|
+
leftPanel: {
|
|
1904
|
+
type: 'sidepanel',
|
|
1905
|
+
items: [
|
|
1906
|
+
{ widget: widgetA, rank: 0, expanded: true },
|
|
1907
|
+
{ widget: undefined, rank: 1, expanded: false }
|
|
1908
|
+
]
|
|
1909
|
+
} as SidePanel.LayoutData,
|
|
1910
|
+
mainPanel: undefined,
|
|
1911
|
+
bottomPanel: undefined
|
|
1912
|
+
};
|
|
1913
|
+
|
|
1914
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1915
|
+
const ids = (service as any).collectWidgetIds(layout);
|
|
1916
|
+
expect(ids.has('valid-side-widget')).to.be.true;
|
|
1917
|
+
expect(ids.size).to.equal(1);
|
|
1918
|
+
});
|
|
1919
|
+
|
|
1920
|
+
it('should collect IDs from bottom panel', () => {
|
|
1921
|
+
const widgetA = new Widget();
|
|
1922
|
+
widgetA.id = 'bottom-widget';
|
|
1923
|
+
|
|
1924
|
+
const layout: ApplicationShell.LayoutData = {
|
|
1925
|
+
mainPanel: undefined,
|
|
1926
|
+
bottomPanel: {
|
|
1927
|
+
config: {
|
|
1928
|
+
main: {
|
|
1929
|
+
type: 'tab-area',
|
|
1930
|
+
widgets: [widgetA],
|
|
1931
|
+
currentIndex: 0
|
|
1932
|
+
} as DockLayout.ITabAreaConfig
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
};
|
|
1936
|
+
|
|
1937
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1938
|
+
const ids = (service as any).collectWidgetIds(layout);
|
|
1939
|
+
expect(ids.has('bottom-widget')).to.be.true;
|
|
1940
|
+
expect(ids.size).to.equal(1);
|
|
1941
|
+
});
|
|
1942
|
+
|
|
1943
|
+
it('should collect IDs from all panels', () => {
|
|
1944
|
+
const leftWidget = new Widget();
|
|
1945
|
+
leftWidget.id = 'left-w';
|
|
1946
|
+
const rightWidget = new Widget();
|
|
1947
|
+
rightWidget.id = 'right-w';
|
|
1948
|
+
const mainWidget = new Widget();
|
|
1949
|
+
mainWidget.id = 'main-w';
|
|
1950
|
+
const bottomWidget = new Widget();
|
|
1951
|
+
bottomWidget.id = 'bottom-w';
|
|
1952
|
+
|
|
1953
|
+
const layout: ApplicationShell.LayoutData = {
|
|
1954
|
+
leftPanel: {
|
|
1955
|
+
type: 'sidepanel',
|
|
1956
|
+
items: [{ widget: leftWidget, rank: 0, expanded: true }]
|
|
1957
|
+
} as SidePanel.LayoutData,
|
|
1958
|
+
rightPanel: {
|
|
1959
|
+
type: 'sidepanel',
|
|
1960
|
+
items: [{ widget: rightWidget, rank: 0, expanded: true }]
|
|
1961
|
+
} as SidePanel.LayoutData,
|
|
1962
|
+
mainPanel: {
|
|
1963
|
+
main: {
|
|
1964
|
+
type: 'tab-area',
|
|
1965
|
+
widgets: [mainWidget],
|
|
1966
|
+
currentIndex: 0
|
|
1967
|
+
} as DockLayout.ITabAreaConfig
|
|
1968
|
+
},
|
|
1969
|
+
bottomPanel: {
|
|
1970
|
+
config: {
|
|
1971
|
+
main: {
|
|
1972
|
+
type: 'tab-area',
|
|
1973
|
+
widgets: [bottomWidget],
|
|
1974
|
+
currentIndex: 0
|
|
1975
|
+
} as DockLayout.ITabAreaConfig
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
};
|
|
1979
|
+
|
|
1980
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1981
|
+
const ids = (service as any).collectWidgetIds(layout);
|
|
1982
|
+
expect(ids.has('left-w')).to.be.true;
|
|
1983
|
+
expect(ids.has('right-w')).to.be.true;
|
|
1984
|
+
expect(ids.has('main-w')).to.be.true;
|
|
1985
|
+
expect(ids.has('bottom-w')).to.be.true;
|
|
1986
|
+
expect(ids.size).to.equal(4);
|
|
1987
|
+
});
|
|
1988
|
+
});
|
|
1989
|
+
|
|
1990
|
+
describe('detachStrayWidgets', () => {
|
|
1991
|
+
it('should detach a left-panel widget whose ID is not in the layout ID set', () => {
|
|
1992
|
+
const strayWidget = new Widget();
|
|
1993
|
+
strayWidget.id = 'stray-left';
|
|
1994
|
+
const panel = new Panel();
|
|
1995
|
+
Widget.attach(panel, document.body);
|
|
1996
|
+
panel.addWidget(strayWidget);
|
|
1997
|
+
expect(strayWidget.isAttached).to.be.true;
|
|
1998
|
+
|
|
1999
|
+
getWidgetsStub.withArgs('left').returns([strayWidget]);
|
|
2000
|
+
getWidgetsStub.withArgs('right').returns([]);
|
|
2001
|
+
|
|
2002
|
+
const widgetIds = new Set<string>();
|
|
2003
|
+
|
|
2004
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2005
|
+
(service as any).detachStrayWidgets(widgetIds);
|
|
2006
|
+
|
|
2007
|
+
expect(strayWidget.isAttached).to.be.false;
|
|
2008
|
+
|
|
2009
|
+
panel.dispose();
|
|
2010
|
+
});
|
|
2011
|
+
|
|
2012
|
+
it('should detach a right-panel widget whose ID is not in the layout ID set', () => {
|
|
2013
|
+
const strayWidget = new Widget();
|
|
2014
|
+
strayWidget.id = 'stray-right';
|
|
2015
|
+
const panel = new Panel();
|
|
2016
|
+
Widget.attach(panel, document.body);
|
|
2017
|
+
panel.addWidget(strayWidget);
|
|
2018
|
+
expect(strayWidget.isAttached).to.be.true;
|
|
2019
|
+
|
|
2020
|
+
getWidgetsStub.withArgs('left').returns([]);
|
|
2021
|
+
getWidgetsStub.withArgs('right').returns([strayWidget]);
|
|
2022
|
+
|
|
2023
|
+
const widgetIds = new Set<string>();
|
|
2024
|
+
|
|
2025
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2026
|
+
(service as any).detachStrayWidgets(widgetIds);
|
|
2027
|
+
|
|
2028
|
+
expect(strayWidget.isAttached).to.be.false;
|
|
2029
|
+
|
|
2030
|
+
panel.dispose();
|
|
2031
|
+
});
|
|
2032
|
+
|
|
2033
|
+
it('should NOT detach a widget whose ID IS in the layout ID set', () => {
|
|
2034
|
+
const keptWidget = new Widget();
|
|
2035
|
+
keptWidget.id = 'kept-widget';
|
|
2036
|
+
const panel = new Panel();
|
|
2037
|
+
Widget.attach(panel, document.body);
|
|
2038
|
+
panel.addWidget(keptWidget);
|
|
2039
|
+
expect(keptWidget.isAttached).to.be.true;
|
|
2040
|
+
|
|
2041
|
+
getWidgetsStub.withArgs('left').returns([keptWidget]);
|
|
2042
|
+
getWidgetsStub.withArgs('right').returns([]);
|
|
2043
|
+
|
|
2044
|
+
const widgetIds = new Set<string>(['kept-widget']);
|
|
2045
|
+
|
|
2046
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2047
|
+
(service as any).detachStrayWidgets(widgetIds);
|
|
2048
|
+
|
|
2049
|
+
expect(keptWidget.isAttached).to.be.true;
|
|
2050
|
+
|
|
2051
|
+
panel.dispose();
|
|
2052
|
+
});
|
|
2053
|
+
|
|
2054
|
+
it('should handle empty side panels gracefully', () => {
|
|
2055
|
+
getWidgetsStub.withArgs('left').returns([]);
|
|
2056
|
+
getWidgetsStub.withArgs('right').returns([]);
|
|
2057
|
+
|
|
2058
|
+
const widgetIds = new Set<string>();
|
|
2059
|
+
|
|
2060
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2061
|
+
expect(() => (service as any).detachStrayWidgets(widgetIds)).to.not.throw();
|
|
2062
|
+
});
|
|
2063
|
+
|
|
2064
|
+
it('should detach non-viewPlacements widget that leaked from another perspective', () => {
|
|
2065
|
+
// Simulate: Outline widget is in the left panel but not in the target layout
|
|
2066
|
+
const outlineWidget = new Widget();
|
|
2067
|
+
outlineWidget.id = 'outline-view';
|
|
2068
|
+
const panel = new Panel();
|
|
2069
|
+
Widget.attach(panel, document.body);
|
|
2070
|
+
panel.addWidget(outlineWidget);
|
|
2071
|
+
|
|
2072
|
+
getWidgetsStub.withArgs('left').returns([outlineWidget]);
|
|
2073
|
+
getWidgetsStub.withArgs('right').returns([]);
|
|
2074
|
+
|
|
2075
|
+
// Target layout has no widgets — outline should be detached
|
|
2076
|
+
const widgetIds = new Set<string>();
|
|
2077
|
+
|
|
2078
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2079
|
+
(service as any).detachStrayWidgets(widgetIds);
|
|
2080
|
+
|
|
2081
|
+
expect(outlineWidget.isAttached).to.be.false;
|
|
2082
|
+
|
|
2083
|
+
panel.dispose();
|
|
2084
|
+
});
|
|
2085
|
+
|
|
2086
|
+
it('should detach multiple stray widgets from both panels', () => {
|
|
2087
|
+
const leftStray = new Widget();
|
|
2088
|
+
leftStray.id = 'left-stray';
|
|
2089
|
+
const rightStray = new Widget();
|
|
2090
|
+
rightStray.id = 'right-stray';
|
|
2091
|
+
const leftPanel = new Panel();
|
|
2092
|
+
const rightPanel = new Panel();
|
|
2093
|
+
Widget.attach(leftPanel, document.body);
|
|
2094
|
+
Widget.attach(rightPanel, document.body);
|
|
2095
|
+
leftPanel.addWidget(leftStray);
|
|
2096
|
+
rightPanel.addWidget(rightStray);
|
|
2097
|
+
|
|
2098
|
+
getWidgetsStub.withArgs('left').returns([leftStray]);
|
|
2099
|
+
getWidgetsStub.withArgs('right').returns([rightStray]);
|
|
2100
|
+
|
|
2101
|
+
const widgetIds = new Set<string>();
|
|
2102
|
+
|
|
2103
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2104
|
+
(service as any).detachStrayWidgets(widgetIds);
|
|
2105
|
+
|
|
2106
|
+
expect(leftStray.isAttached).to.be.false;
|
|
2107
|
+
expect(rightStray.isAttached).to.be.false;
|
|
2108
|
+
|
|
2109
|
+
leftPanel.dispose();
|
|
2110
|
+
rightPanel.dispose();
|
|
2111
|
+
});
|
|
2112
|
+
|
|
2113
|
+
describe('stray widget cleanup during perspective switch', () => {
|
|
2114
|
+
let parentPanel: Panel;
|
|
2115
|
+
|
|
2116
|
+
beforeEach(() => {
|
|
2117
|
+
parentPanel = new Panel();
|
|
2118
|
+
Widget.attach(parentPanel, document.body);
|
|
2119
|
+
});
|
|
2120
|
+
|
|
2121
|
+
afterEach(() => {
|
|
2122
|
+
parentPanel.dispose();
|
|
2123
|
+
});
|
|
2124
|
+
|
|
2125
|
+
it('should detach stray widgets when restoring a saved layout', async () => {
|
|
2126
|
+
const chatWidget = new Widget();
|
|
2127
|
+
chatWidget.id = 'chat-view';
|
|
2128
|
+
parentPanel.addWidget(chatWidget);
|
|
2129
|
+
|
|
2130
|
+
getOrCreateWidgetStub.withArgs('chat-view').resolves(chatWidget);
|
|
2131
|
+
|
|
2132
|
+
service.registerPerspective({
|
|
2133
|
+
id: 'perspA',
|
|
2134
|
+
label: 'A',
|
|
2135
|
+
viewPlacements: new Map([['chat-view', 'main' as ApplicationShell.Area]])
|
|
2136
|
+
});
|
|
2137
|
+
service.registerPerspective({
|
|
2138
|
+
id: 'perspB',
|
|
2139
|
+
label: 'B',
|
|
2140
|
+
viewPlacements: new Map()
|
|
2141
|
+
});
|
|
2142
|
+
|
|
2143
|
+
// Activate A (first time — viewPlacements applied)
|
|
2144
|
+
await service.switchPerspective('perspA');
|
|
2145
|
+
|
|
2146
|
+
// Simulate saving A's layout WITHOUT chat-view (user closed it)
|
|
2147
|
+
const layoutAWithoutChat: ApplicationShell.LayoutData = {
|
|
2148
|
+
mainPanel: {
|
|
2149
|
+
main: {
|
|
2150
|
+
type: 'tab-area',
|
|
2151
|
+
widgets: [],
|
|
2152
|
+
currentIndex: -1
|
|
2153
|
+
} as DockLayout.ITabAreaConfig
|
|
2154
|
+
},
|
|
2155
|
+
bottomPanel: undefined
|
|
2156
|
+
};
|
|
2157
|
+
getLayoutDataStub.returns(layoutAWithoutChat);
|
|
2158
|
+
|
|
2159
|
+
// Switch to B (saves A's layout without chat)
|
|
2160
|
+
await service.switchPerspective('perspB');
|
|
2161
|
+
|
|
2162
|
+
// chat-view is still attached (simulating the stray scenario)
|
|
2163
|
+
expect(chatWidget.isAttached).to.be.true;
|
|
2164
|
+
|
|
2165
|
+
// When switching back to A, shell.getWidgets returns the stray
|
|
2166
|
+
getWidgetsStub.withArgs('left').returns([chatWidget]);
|
|
2167
|
+
getWidgetsStub.withArgs('right').returns([]);
|
|
2168
|
+
|
|
2169
|
+
// Switch back to A — should restore layout and detach stray chat-view
|
|
2170
|
+
await service.switchPerspective('perspA');
|
|
2171
|
+
|
|
2172
|
+
expect(chatWidget.isAttached).to.be.false;
|
|
2173
|
+
});
|
|
2174
|
+
|
|
2175
|
+
it('should NOT detach a widget that IS in the saved layout (user customization preserved)', async () => {
|
|
2176
|
+
const chatWidget = new Widget();
|
|
2177
|
+
chatWidget.id = 'chat-view';
|
|
2178
|
+
parentPanel.addWidget(chatWidget);
|
|
2179
|
+
|
|
2180
|
+
getOrCreateWidgetStub.withArgs('chat-view').resolves(chatWidget);
|
|
2181
|
+
|
|
2182
|
+
service.registerPerspective({
|
|
2183
|
+
id: 'perspA',
|
|
2184
|
+
label: 'A',
|
|
2185
|
+
viewPlacements: new Map([['chat-view', 'main' as ApplicationShell.Area]])
|
|
2186
|
+
});
|
|
2187
|
+
service.registerPerspective({
|
|
2188
|
+
id: 'perspB',
|
|
2189
|
+
label: 'B',
|
|
2190
|
+
viewPlacements: new Map()
|
|
2191
|
+
});
|
|
2192
|
+
|
|
2193
|
+
// Activate A
|
|
2194
|
+
await service.switchPerspective('perspA');
|
|
2195
|
+
|
|
2196
|
+
// Simulate saving A's layout WITH chat-view in right panel
|
|
2197
|
+
const layoutAWithChat: ApplicationShell.LayoutData = {
|
|
2198
|
+
rightPanel: {
|
|
2199
|
+
type: 'sidepanel',
|
|
2200
|
+
items: [{ widget: chatWidget, rank: 0, expanded: true }]
|
|
2201
|
+
} as SidePanel.LayoutData,
|
|
2202
|
+
mainPanel: undefined,
|
|
2203
|
+
bottomPanel: undefined
|
|
2204
|
+
};
|
|
2205
|
+
getLayoutDataStub.returns(layoutAWithChat);
|
|
2206
|
+
|
|
2207
|
+
// Switch to B (saves A's layout with chat)
|
|
2208
|
+
await service.switchPerspective('perspB');
|
|
2209
|
+
|
|
2210
|
+
// When switching back, chat is in the right panel
|
|
2211
|
+
getWidgetsStub.withArgs('left').returns([]);
|
|
2212
|
+
getWidgetsStub.withArgs('right').returns([chatWidget]);
|
|
2213
|
+
|
|
2214
|
+
// Switch back to A — chat IS in layout, should NOT be detached
|
|
2215
|
+
await service.switchPerspective('perspA');
|
|
2216
|
+
|
|
2217
|
+
expect(chatWidget.isAttached).to.be.true;
|
|
2218
|
+
});
|
|
2219
|
+
|
|
2220
|
+
it('should detach non-viewPlacements widget leaking from another perspective', async () => {
|
|
2221
|
+
// Outline is not in AI First's viewPlacements
|
|
2222
|
+
const outlineWidget = new Widget();
|
|
2223
|
+
outlineWidget.id = 'outline-view';
|
|
2224
|
+
parentPanel.addWidget(outlineWidget);
|
|
2225
|
+
|
|
2226
|
+
service.registerPerspective({
|
|
2227
|
+
id: 'ai-first',
|
|
2228
|
+
label: 'AI First',
|
|
2229
|
+
viewPlacements: new Map([['chat-view', 'main' as ApplicationShell.Area]])
|
|
2230
|
+
});
|
|
2231
|
+
service.registerPerspective({
|
|
2232
|
+
id: 'default-persp',
|
|
2233
|
+
label: 'Default',
|
|
2234
|
+
viewPlacements: new Map()
|
|
2235
|
+
});
|
|
2236
|
+
|
|
2237
|
+
// Activate AI First
|
|
2238
|
+
await service.switchPerspective('ai-first');
|
|
2239
|
+
|
|
2240
|
+
// Save AI First layout without outline
|
|
2241
|
+
const aiLayout: ApplicationShell.LayoutData = {
|
|
2242
|
+
mainPanel: {
|
|
2243
|
+
main: {
|
|
2244
|
+
type: 'tab-area',
|
|
2245
|
+
widgets: [],
|
|
2246
|
+
currentIndex: -1
|
|
2247
|
+
} as DockLayout.ITabAreaConfig
|
|
2248
|
+
},
|
|
2249
|
+
bottomPanel: undefined
|
|
2250
|
+
};
|
|
2251
|
+
getLayoutDataStub.returns(aiLayout);
|
|
2252
|
+
|
|
2253
|
+
// Switch to default (saves AI First layout)
|
|
2254
|
+
await service.switchPerspective('default-persp');
|
|
2255
|
+
|
|
2256
|
+
// Outline is open in default's left panel
|
|
2257
|
+
expect(outlineWidget.isAttached).to.be.true;
|
|
2258
|
+
|
|
2259
|
+
// Switch back to AI First — outline is a stray
|
|
2260
|
+
getWidgetsStub.withArgs('left').returns([outlineWidget]);
|
|
2261
|
+
getWidgetsStub.withArgs('right').returns([]);
|
|
2262
|
+
|
|
2263
|
+
await service.switchPerspective('ai-first');
|
|
2264
|
+
|
|
2265
|
+
// Outline should be detached (not in AI First's saved layout)
|
|
2266
|
+
expect(outlineWidget.isAttached).to.be.false;
|
|
2267
|
+
});
|
|
2268
|
+
});
|
|
2269
|
+
});
|
|
2270
|
+
});
|