@theia/core 1.74.0-next.8 → 1.74.0

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