@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.
Files changed (252) 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 +505 -46
  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 +3 -0
  26. package/lib/browser/common-frontend-contribution.d.ts.map +1 -1
  27. package/lib/browser/common-frontend-contribution.js +22 -10
  28. package/lib/browser/common-frontend-contribution.js.map +1 -1
  29. package/lib/browser/common-frontend-contribution.spec.d.ts +2 -0
  30. package/lib/browser/common-frontend-contribution.spec.d.ts.map +1 -0
  31. package/lib/browser/common-frontend-contribution.spec.js +71 -0
  32. package/lib/browser/common-frontend-contribution.spec.js.map +1 -0
  33. package/lib/browser/frontend-application-contribution.d.ts +1 -1
  34. package/lib/browser/frontend-application-module.d.ts +2 -2
  35. package/lib/browser/frontend-application-module.d.ts.map +1 -1
  36. package/lib/browser/frontend-application-module.js +13 -2
  37. package/lib/browser/frontend-application-module.js.map +1 -1
  38. package/lib/browser/frontend-application.d.ts.map +1 -1
  39. package/lib/browser/frontend-application.js +12 -2
  40. package/lib/browser/frontend-application.js.map +1 -1
  41. package/lib/browser/index.d.ts +1 -0
  42. package/lib/browser/index.d.ts.map +1 -1
  43. package/lib/browser/index.js +1 -0
  44. package/lib/browser/index.js.map +1 -1
  45. package/lib/browser/keybinding.d.ts.map +1 -1
  46. package/lib/browser/keybinding.js +12 -7
  47. package/lib/browser/keybinding.js.map +1 -1
  48. package/lib/browser/perspective-service.d.ts +173 -0
  49. package/lib/browser/perspective-service.d.ts.map +1 -0
  50. package/lib/browser/perspective-service.js +484 -0
  51. package/lib/browser/perspective-service.js.map +1 -0
  52. package/lib/browser/perspective-service.spec.d.ts +2 -0
  53. package/lib/browser/perspective-service.spec.d.ts.map +1 -0
  54. package/lib/browser/perspective-service.spec.js +1806 -0
  55. package/lib/browser/perspective-service.spec.js.map +1 -0
  56. package/lib/browser/preferences/preference-contribution.d.ts.map +1 -1
  57. package/lib/browser/preferences/preference-contribution.js +9 -1
  58. package/lib/browser/preferences/preference-contribution.js.map +1 -1
  59. package/lib/browser/preferences/preference-service.spec.js +63 -0
  60. package/lib/browser/preferences/preference-service.spec.js.map +1 -1
  61. package/lib/browser/shell/application-shell.d.ts +7 -0
  62. package/lib/browser/shell/application-shell.d.ts.map +1 -1
  63. package/lib/browser/shell/application-shell.js +38 -28
  64. package/lib/browser/shell/application-shell.js.map +1 -1
  65. package/lib/browser/shell/shell-layout-restorer.d.ts +9 -0
  66. package/lib/browser/shell/shell-layout-restorer.d.ts.map +1 -1
  67. package/lib/browser/shell/shell-layout-restorer.js +124 -14
  68. package/lib/browser/shell/shell-layout-restorer.js.map +1 -1
  69. package/lib/browser/shell/shell-layout-restorer.spec.d.ts +2 -0
  70. package/lib/browser/shell/shell-layout-restorer.spec.d.ts.map +1 -0
  71. package/lib/browser/shell/shell-layout-restorer.spec.js +539 -0
  72. package/lib/browser/shell/shell-layout-restorer.spec.js.map +1 -0
  73. package/lib/browser/shell/view-contribution.d.ts +2 -0
  74. package/lib/browser/shell/view-contribution.d.ts.map +1 -1
  75. package/lib/browser/shell/view-contribution.js +5 -1
  76. package/lib/browser/shell/view-contribution.js.map +1 -1
  77. package/lib/browser/status-bar/status-bar.d.ts +2 -0
  78. package/lib/browser/status-bar/status-bar.d.ts.map +1 -1
  79. package/lib/browser/status-bar/status-bar.js +11 -3
  80. package/lib/browser/status-bar/status-bar.js.map +1 -1
  81. package/lib/browser/tree/tree-consistency.spec.js +103 -0
  82. package/lib/browser/tree/tree-consistency.spec.js.map +1 -1
  83. package/lib/browser/tree/tree-widget.d.ts +7 -33
  84. package/lib/browser/tree/tree-widget.d.ts.map +1 -1
  85. package/lib/browser/tree/tree-widget.js +7 -54
  86. package/lib/browser/tree/tree-widget.js.map +1 -1
  87. package/lib/browser/tree/tree.d.ts.map +1 -1
  88. package/lib/browser/tree/tree.js +6 -0
  89. package/lib/browser/tree/tree.js.map +1 -1
  90. package/lib/browser/widgets/index.d.ts +1 -0
  91. package/lib/browser/widgets/index.d.ts.map +1 -1
  92. package/lib/browser/widgets/index.js +1 -0
  93. package/lib/browser/widgets/index.js.map +1 -1
  94. package/lib/browser/widgets/select-component.d.ts +15 -2
  95. package/lib/browser/widgets/select-component.d.ts.map +1 -1
  96. package/lib/browser/widgets/select-component.js +86 -24
  97. package/lib/browser/widgets/select-component.js.map +1 -1
  98. package/lib/browser/widgets/tab-bar-tracker.d.ts +23 -0
  99. package/lib/browser/widgets/tab-bar-tracker.d.ts.map +1 -0
  100. package/lib/browser/widgets/tab-bar-tracker.js +65 -0
  101. package/lib/browser/widgets/tab-bar-tracker.js.map +1 -0
  102. package/lib/browser/widgets/tab-bar-tracker.spec.d.ts +2 -0
  103. package/lib/browser/widgets/tab-bar-tracker.spec.d.ts.map +1 -0
  104. package/lib/browser/widgets/tab-bar-tracker.spec.js +109 -0
  105. package/lib/browser/widgets/tab-bar-tracker.spec.js.map +1 -0
  106. package/lib/browser/window/default-secondary-window-service.d.ts +2 -0
  107. package/lib/browser/window/default-secondary-window-service.d.ts.map +1 -1
  108. package/lib/browser/window/default-secondary-window-service.js +8 -2
  109. package/lib/browser/window/default-secondary-window-service.js.map +1 -1
  110. package/lib/browser/window/default-window-service.d.ts +19 -0
  111. package/lib/browser/window/default-window-service.d.ts.map +1 -1
  112. package/lib/browser/window/default-window-service.js +34 -5
  113. package/lib/browser/window/default-window-service.js.map +1 -1
  114. package/lib/browser/window/default-window-service.spec.js +22 -0
  115. package/lib/browser/window/default-window-service.spec.js.map +1 -1
  116. package/lib/browser/window/secondary-window-service.d.ts +7 -0
  117. package/lib/browser/window/secondary-window-service.d.ts.map +1 -1
  118. package/lib/browser/window/window-service.d.ts +1 -1
  119. package/lib/browser-only/messaging/frontend-only-service-connection-provider.d.ts +13 -2
  120. package/lib/browser-only/messaging/frontend-only-service-connection-provider.d.ts.map +1 -1
  121. package/lib/browser-only/messaging/frontend-only-service-connection-provider.js +31 -3
  122. package/lib/browser-only/messaging/frontend-only-service-connection-provider.js.map +1 -1
  123. package/lib/common/preferences/index.d.ts +1 -0
  124. package/lib/common/preferences/index.d.ts.map +1 -1
  125. package/lib/common/preferences/index.js +1 -0
  126. package/lib/common/preferences/index.js.map +1 -1
  127. package/lib/common/preferences/preference-provider.d.ts +15 -0
  128. package/lib/common/preferences/preference-provider.d.ts.map +1 -1
  129. package/lib/common/preferences/preference-provider.js +21 -1
  130. package/lib/common/preferences/preference-provider.js.map +1 -1
  131. package/lib/common/preferences/preference-provider.spec.js +17 -0
  132. package/lib/common/preferences/preference-provider.spec.js.map +1 -1
  133. package/lib/common/preferences/preference-scope.d.ts +6 -1
  134. package/lib/common/preferences/preference-scope.d.ts.map +1 -1
  135. package/lib/common/preferences/preference-scope.js +5 -0
  136. package/lib/common/preferences/preference-scope.js.map +1 -1
  137. package/lib/common/preferences/preference-scope.spec.js +5 -3
  138. package/lib/common/preferences/preference-scope.spec.js.map +1 -1
  139. package/lib/common/preferences/preference-service.d.ts +19 -0
  140. package/lib/common/preferences/preference-service.d.ts.map +1 -1
  141. package/lib/common/preferences/preference-service.js +34 -5
  142. package/lib/common/preferences/preference-service.js.map +1 -1
  143. package/lib/common/preferences/session-preference-provider.d.ts +17 -0
  144. package/lib/common/preferences/session-preference-provider.d.ts.map +1 -0
  145. package/lib/common/preferences/session-preference-provider.js +77 -0
  146. package/lib/common/preferences/session-preference-provider.js.map +1 -0
  147. package/lib/common/preferences/session-preference-provider.spec.d.ts +2 -0
  148. package/lib/common/preferences/session-preference-provider.spec.d.ts.map +1 -0
  149. package/lib/common/preferences/session-preference-provider.spec.js +71 -0
  150. package/lib/common/preferences/session-preference-provider.spec.js.map +1 -0
  151. package/lib/electron-browser/electron-clipboard-service.d.ts +3 -0
  152. package/lib/electron-browser/electron-clipboard-service.d.ts.map +1 -1
  153. package/lib/electron-browser/electron-clipboard-service.js +6 -0
  154. package/lib/electron-browser/electron-clipboard-service.js.map +1 -1
  155. package/lib/electron-browser/menu/electron-menu-contribution.js +1 -1
  156. package/lib/electron-browser/menu/electron-menu-contribution.js.map +1 -1
  157. package/lib/electron-browser/preload.d.ts.map +1 -1
  158. package/lib/electron-browser/preload.js +2 -1
  159. package/lib/electron-browser/preload.js.map +1 -1
  160. package/lib/electron-browser/window/electron-secondary-window-service.js +1 -1
  161. package/lib/electron-browser/window/electron-secondary-window-service.js.map +1 -1
  162. package/lib/electron-browser/window/electron-window-service.js +3 -3
  163. package/lib/electron-browser/window/electron-window-service.js.map +1 -1
  164. package/lib/electron-common/electron-api.d.ts +2 -0
  165. package/lib/electron-common/electron-api.d.ts.map +1 -1
  166. package/lib/electron-common/electron-api.js +2 -1
  167. package/lib/electron-common/electron-api.js.map +1 -1
  168. package/lib/electron-main/electron-api-main.d.ts.map +1 -1
  169. package/lib/electron-main/electron-api-main.js +43 -0
  170. package/lib/electron-main/electron-api-main.js.map +1 -1
  171. package/lib/electron-main/electron-main-application.d.ts.map +1 -1
  172. package/lib/electron-main/electron-main-application.js +3 -0
  173. package/lib/electron-main/electron-main-application.js.map +1 -1
  174. package/lib/electron-node/hosting/electron-backend-hosting-module.d.ts.map +1 -1
  175. package/lib/electron-node/hosting/electron-backend-hosting-module.js +7 -0
  176. package/lib/electron-node/hosting/electron-backend-hosting-module.js.map +1 -1
  177. package/lib/electron-node/hosting/electron-backend-hosting-module.spec.d.ts +2 -0
  178. package/lib/electron-node/hosting/electron-backend-hosting-module.spec.d.ts.map +1 -0
  179. package/lib/electron-node/hosting/electron-backend-hosting-module.spec.js +39 -0
  180. package/lib/electron-node/hosting/electron-backend-hosting-module.spec.js.map +1 -0
  181. package/lib/node/hosting/backend-hosting-module.d.ts.map +1 -1
  182. package/lib/node/hosting/backend-hosting-module.js +4 -1
  183. package/lib/node/hosting/backend-hosting-module.js.map +1 -1
  184. package/lib/node/hosting/browser-connection-token.d.ts +29 -1
  185. package/lib/node/hosting/browser-connection-token.d.ts.map +1 -1
  186. package/lib/node/hosting/browser-connection-token.js +25 -1
  187. package/lib/node/hosting/browser-connection-token.js.map +1 -1
  188. package/lib/node/hosting/browser-connection-token.spec.js +70 -0
  189. package/lib/node/hosting/browser-connection-token.spec.js.map +1 -1
  190. package/lib/node/index.d.ts +1 -0
  191. package/lib/node/index.d.ts.map +1 -1
  192. package/lib/node/index.js +3 -1
  193. package/lib/node/index.js.map +1 -1
  194. package/package.json +11 -11
  195. package/src/browser/browser-clipboard-service.ts +7 -1
  196. package/src/browser/clipboard-service.ts +11 -0
  197. package/src/browser/common-frontend-contribution.spec.ts +84 -0
  198. package/src/browser/common-frontend-contribution.ts +22 -10
  199. package/src/browser/frontend-application-contribution.ts +1 -1
  200. package/src/browser/frontend-application-module.ts +15 -3
  201. package/src/browser/frontend-application.ts +12 -4
  202. package/src/browser/index.ts +1 -0
  203. package/src/browser/keybinding.ts +12 -7
  204. package/src/browser/perspective-service.spec.ts +2270 -0
  205. package/src/browser/perspective-service.ts +625 -0
  206. package/src/browser/preferences/preference-contribution.ts +9 -1
  207. package/src/browser/preferences/preference-service.spec.ts +72 -0
  208. package/src/browser/shell/application-shell.ts +41 -26
  209. package/src/browser/shell/shell-layout-restorer.spec.ts +746 -0
  210. package/src/browser/shell/shell-layout-restorer.ts +134 -13
  211. package/src/browser/shell/view-contribution.ts +6 -1
  212. package/src/browser/status-bar/status-bar.tsx +13 -3
  213. package/src/browser/style/select-component.css +16 -0
  214. package/src/browser/style/sidepanel.css +5 -0
  215. package/src/browser/style/status-bar.css +1 -1
  216. package/src/browser/tree/tree-consistency.spec.ts +119 -0
  217. package/src/browser/tree/tree-widget.tsx +12 -78
  218. package/src/browser/tree/tree.ts +6 -0
  219. package/src/browser/widgets/index.ts +1 -0
  220. package/src/browser/widgets/select-component.tsx +90 -24
  221. package/src/browser/widgets/tab-bar-tracker.spec.ts +121 -0
  222. package/src/browser/widgets/tab-bar-tracker.ts +68 -0
  223. package/src/browser/window/default-secondary-window-service.ts +8 -2
  224. package/src/browser/window/default-window-service.spec.ts +22 -0
  225. package/src/browser/window/default-window-service.ts +37 -5
  226. package/src/browser/window/secondary-window-service.ts +7 -0
  227. package/src/browser/window/window-service.ts +1 -1
  228. package/src/browser-only/messaging/frontend-only-service-connection-provider.ts +32 -3
  229. package/src/common/i18n/nls.metadata.json +4149 -888
  230. package/src/common/preferences/index.ts +1 -0
  231. package/src/common/preferences/preference-provider.spec.ts +17 -0
  232. package/src/common/preferences/preference-provider.ts +22 -1
  233. package/src/common/preferences/preference-scope.spec.ts +5 -3
  234. package/src/common/preferences/preference-scope.ts +6 -1
  235. package/src/common/preferences/preference-service.ts +39 -5
  236. package/src/common/preferences/session-preference-provider.spec.ts +78 -0
  237. package/src/common/preferences/session-preference-provider.ts +69 -0
  238. package/src/electron-browser/electron-clipboard-service.ts +5 -0
  239. package/src/electron-browser/menu/electron-menu-contribution.ts +1 -1
  240. package/src/electron-browser/menu/electron-menu-style.css +3 -1
  241. package/src/electron-browser/preload.ts +5 -2
  242. package/src/electron-browser/window/electron-secondary-window-service.ts +1 -1
  243. package/src/electron-browser/window/electron-window-service.ts +3 -3
  244. package/src/electron-common/electron-api.ts +4 -0
  245. package/src/electron-main/electron-api-main.ts +56 -3
  246. package/src/electron-main/electron-main-application.ts +3 -0
  247. package/src/electron-node/hosting/electron-backend-hosting-module.spec.ts +42 -0
  248. package/src/electron-node/hosting/electron-backend-hosting-module.ts +8 -0
  249. package/src/node/hosting/backend-hosting-module.ts +5 -2
  250. package/src/node/hosting/browser-connection-token.spec.ts +92 -0
  251. package/src/node/hosting/browser-connection-token.ts +45 -1
  252. 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
+ });