@olenbetong/appframe-ds 1.3.6 → 1.3.8

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 (179) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +6 -6
  3. package/scripts/copyAssets.mjs +27 -27
  4. package/src/AfLookup/Lookup.css +130 -129
  5. package/src/AfLookup/Lookup.tsx +186 -186
  6. package/src/AfLookup/LookupCombobox.css +72 -78
  7. package/src/AfLookup/LookupCombobox.tsx +237 -237
  8. package/src/AfLookup/LookupEdit.tsx +130 -130
  9. package/src/AfLookup/LookupGrid.css +7 -12
  10. package/src/AfLookup/LookupGrid.tsx +124 -124
  11. package/src/AfLookup/index.ts +5 -5
  12. package/src/autocomplete/Autocomplete.css +256 -256
  13. package/src/autocomplete/Autocomplete.tsx +320 -320
  14. package/src/autocomplete/AutocompleteFrame.tsx +168 -168
  15. package/src/autocomplete/Combobox.tsx +286 -286
  16. package/src/autocomplete/index.ts +4 -4
  17. package/src/autocomplete/types.ts +204 -204
  18. package/src/autocomplete/utils.ts +183 -183
  19. package/src/binding/BoundNumericTextField.tsx +102 -102
  20. package/src/binding/BoundTextField.tsx +49 -49
  21. package/src/binding/CancelButton.tsx +30 -30
  22. package/src/binding/CancelIconButton.tsx +30 -30
  23. package/src/binding/DataEditToolbar.css +23 -23
  24. package/src/binding/DataEditToolbar.tsx +84 -84
  25. package/src/binding/DeleteButton.tsx +35 -35
  26. package/src/binding/DeleteIconButton.tsx +35 -35
  27. package/src/binding/RefreshButton.tsx +31 -31
  28. package/src/binding/RefreshIconButton.tsx +33 -33
  29. package/src/binding/RefreshRowIconButton.tsx +33 -33
  30. package/src/binding/SaveButton.tsx +37 -37
  31. package/src/binding/SaveIconButton.tsx +38 -38
  32. package/src/container/Container.tsx +50 -49
  33. package/src/container/index.ts +1 -1
  34. package/src/cutoff/CutOffPicker.css +12 -12
  35. package/src/cutoff/CutOffPicker.tsx +165 -165
  36. package/src/cutoff/context.tsx +194 -194
  37. package/src/cutoff/dsCutOffDates.ts +21 -21
  38. package/src/cutoff/index.ts +4 -4
  39. package/src/cutoff/utils.test.ts +59 -59
  40. package/src/cutoff/utils.ts +276 -276
  41. package/src/filter/ChipListInput.tsx +79 -79
  42. package/src/filter/FieldFilterPanel.css +150 -150
  43. package/src/filter/FieldFilterPanel.tsx +309 -309
  44. package/src/filter/FilterBuilder.css +152 -152
  45. package/src/filter/FilterBuilder.test.tsx +153 -153
  46. package/src/filter/FilterBuilder.tsx +435 -435
  47. package/src/filter/FilterEditor.css +231 -231
  48. package/src/filter/FilterEditor.test.tsx +285 -285
  49. package/src/filter/FilterEditor.tsx +75 -75
  50. package/src/filter/FilterGroupEditor.tsx +160 -160
  51. package/src/filter/FilterNameDialog.css +22 -22
  52. package/src/filter/FilterNameDialog.tsx +81 -81
  53. package/src/filter/FilterRow.tsx +117 -117
  54. package/src/filter/FilterShareDialog.css +28 -28
  55. package/src/filter/FilterShareDialog.tsx +201 -201
  56. package/src/filter/FilterStringField.tsx +78 -78
  57. package/src/filter/FilterValueEditor.tsx +239 -239
  58. package/src/filter/SavedFilterTree.css +107 -107
  59. package/src/filter/SavedFilterTree.test.tsx +94 -94
  60. package/src/filter/SavedFilterTree.tsx +218 -218
  61. package/src/filter/fieldFilter.test.ts +158 -158
  62. package/src/filter/fieldFilter.ts +139 -139
  63. package/src/filter/index.ts +27 -27
  64. package/src/filter/types.ts +38 -38
  65. package/src/filter/useDistinctValues.test.ts +102 -102
  66. package/src/filter/useDistinctValues.ts +230 -230
  67. package/src/global.d.ts +11 -11
  68. package/src/grid/AfGridColumnsPanel.tsx +118 -118
  69. package/src/grid/AfGridContext.tsx +51 -51
  70. package/src/grid/AfGridError.tsx +46 -46
  71. package/src/grid/AfHeaderFilterCell.tsx +147 -147
  72. package/src/grid/AfHeaderFilterPanel.tsx +111 -111
  73. package/src/grid/GridEditLookup.css +12 -12
  74. package/src/grid/GridEditLookup.tsx +121 -121
  75. package/src/grid/Toolbar.tsx +233 -233
  76. package/src/grid/editing.ts +100 -100
  77. package/src/grid/filter.ts +78 -78
  78. package/src/grid/formatters.ts +48 -48
  79. package/src/grid/index.css +245 -245
  80. package/src/grid/index.tsx +503 -503
  81. package/src/grid/license.ts +48 -48
  82. package/src/grid/localization.ts +369 -369
  83. package/src/grid/slots/index.tsx +334 -334
  84. package/src/grid/slots/slots.css +64 -64
  85. package/src/grid/slots/slots.test.tsx +39 -39
  86. package/src/grid/theme.tsx +138 -138
  87. package/src/grid/useAfColumns.tsx +371 -371
  88. package/src/grid/useAfCurrentIndex.ts +63 -63
  89. package/src/grid/useAfData.ts +20 -20
  90. package/src/grid/useAfFilter.ts +160 -160
  91. package/src/grid/useAfFilterFields.ts +55 -55
  92. package/src/grid/useAfGridApi.ts +71 -71
  93. package/src/grid/useAfKeyBindings.ts +36 -36
  94. package/src/grid/useAfNewItemRow.ts +258 -258
  95. package/src/grid/useAfPagination.ts +53 -53
  96. package/src/grid/useAfPersistedState.ts +230 -230
  97. package/src/grid/useAfRowEditModel.ts +145 -145
  98. package/src/grid/useAfRowGrouping.ts +47 -47
  99. package/src/grid/useAfServerAggregation.test.ts +68 -68
  100. package/src/grid/useAfServerAggregation.ts +158 -158
  101. package/src/grid/useAfSortModel.ts +84 -84
  102. package/src/i18n.ts +13 -13
  103. package/src/index.ts +14 -14
  104. package/src/input/InputAdornments.css +84 -84
  105. package/src/input/InputAdornments.tsx +102 -102
  106. package/src/input/index.ts +1 -1
  107. package/src/layout/AppMenu.tsx +74 -74
  108. package/src/layout/AppMenuDrawer.css +70 -70
  109. package/src/layout/AppMenuDrawer.tsx +122 -122
  110. package/src/layout/ErrorBoundary.tsx +162 -162
  111. package/src/layout/PageContainer.css +48 -48
  112. package/src/layout/PageContainer.tsx +41 -41
  113. package/src/layout/ResponsiveDialog.css +86 -86
  114. package/src/layout/ResponsiveDialog.tsx +55 -55
  115. package/src/layout/index.tsx +69 -69
  116. package/src/link/Link.tsx +23 -23
  117. package/src/link/index.ts +1 -1
  118. package/src/mdi/Mdi.css +138 -138
  119. package/src/mdi/Mdi.test.tsx +91 -91
  120. package/src/mdi/Mdi.tsx +141 -141
  121. package/src/mdi/MdiConfirmCloseDialog.tsx +47 -47
  122. package/src/mdi/MdiContext.tsx +59 -59
  123. package/src/mdi/MdiDocumentContext.tsx +60 -60
  124. package/src/mdi/MdiPanel.tsx +61 -61
  125. package/src/mdi/MdiRestore.test.tsx +86 -86
  126. package/src/mdi/MdiTab.tsx +30 -30
  127. package/src/mdi/controller.test.ts +382 -382
  128. package/src/mdi/controller.ts +656 -656
  129. package/src/mdi/dockviewHost.ts +132 -132
  130. package/src/mdi/fakeHost.ts +200 -200
  131. package/src/mdi/host.ts +44 -44
  132. package/src/mdi/ids.test.ts +20 -20
  133. package/src/mdi/ids.ts +18 -18
  134. package/src/mdi/index.ts +31 -31
  135. package/src/mdi/layoutRestore.test.ts +156 -156
  136. package/src/mdi/layoutRestore.ts +311 -311
  137. package/src/mdi/layoutSpec.test.ts +143 -143
  138. package/src/mdi/layoutSpec.ts +214 -214
  139. package/src/mdi/layoutStore.test.ts +93 -93
  140. package/src/mdi/layoutStore.ts +99 -99
  141. package/src/mdi/memorySettingsStore.ts +21 -21
  142. package/src/mdi/registry.ts +34 -34
  143. package/src/mdi/testTypes.ts +37 -37
  144. package/src/mdi/theme.ts +22 -22
  145. package/src/mdi/types.ts +214 -214
  146. package/src/page/Page.css +53 -53
  147. package/src/page/PageBlock.css +64 -64
  148. package/src/page/PageBlock.tsx +93 -92
  149. package/src/page/PageHeader.css +88 -88
  150. package/src/page/PageHeader.tsx +115 -115
  151. package/src/page/PageTitle.css +5 -5
  152. package/src/page/PageTitle.tsx +21 -21
  153. package/src/page/index.ts +4 -4
  154. package/src/paper/Paper.tsx +52 -52
  155. package/src/progress/LinearProgress.css +93 -93
  156. package/src/progress/LinearProgress.tsx +104 -104
  157. package/src/progress/index.ts +1 -1
  158. package/src/report-downloader/ReportDownloader.css +67 -67
  159. package/src/report-downloader/components/StatusItem.tsx +87 -87
  160. package/src/report-downloader/components/StatusList.tsx +28 -28
  161. package/src/report-downloader/index.ts +41 -41
  162. package/src/report-downloader/status.tsx +92 -92
  163. package/src/sortable/DropIndicator.css +51 -51
  164. package/src/sortable/DropIndicator.tsx +27 -27
  165. package/src/sortable/SortableItemList.css +21 -21
  166. package/src/sortable/SortableItemList.tsx +264 -264
  167. package/src/sortable/index.ts +8 -8
  168. package/src/sortable/reorderSortOrder.ts +126 -126
  169. package/src/sortable/sortOrder.ts +101 -101
  170. package/src/sortable/types.ts +9 -9
  171. package/src/sortable/useSortableContainer.ts +53 -53
  172. package/src/sortable/useSortableItem.ts +95 -95
  173. package/src/sortable/useSortableList.ts +117 -117
  174. package/src/test/happy-dom-document-class.ts +24 -24
  175. package/src/test/setup.ts +12 -12
  176. package/src/theme/useDsColorScheme.ts +53 -53
  177. package/tsconfig.build.json +9 -9
  178. package/tsconfig.json +8 -8
  179. package/vitest.config.ts +10 -10
@@ -1,656 +1,656 @@
1
- import mitt, { type Emitter } from "mitt";
2
-
3
- import type { MdiHost } from "./host.js";
4
- import { panelId } from "./ids.js";
5
- import {
6
- dockPopoutGroups,
7
- isLayoutEmpty,
8
- listGridGroups,
9
- missingRequiredDocuments,
10
- placementFor,
11
- pruneLayout,
12
- } from "./layoutRestore.js";
13
- import { layoutSpecToLayout, resolveLocked } from "./layoutSpec.js";
14
- import { layoutStore as defaultLayoutStore, type MdiLayoutStore } from "./layoutStore.js";
15
- import { resolveClosable, resolveTitle, resolveType } from "./registry.js";
16
- import type {
17
- MdiApi,
18
- MdiCloseOptions,
19
- MdiConfirmCloseResult,
20
- MdiDocumentInfo,
21
- MdiDocumentOptions,
22
- MdiDocumentState,
23
- MdiDocumentTypes,
24
- MdiEvents,
25
- MdiLayout,
26
- MdiLayoutSpec,
27
- MdiOpenOptions,
28
- MdiParams,
29
- } from "./types.js";
30
-
31
- export type MdiControllerOptions = {
32
- /** Storage key within the article. Without it nothing is restored or saved. */
33
- id?: string;
34
- layoutVersion: string | number;
35
- defaultLayout: MdiLayoutSpec;
36
- /** Save every layout change automatically. Off: only `saveLayout()` writes, but a saved layout is still restored on load. */
37
- persist: boolean;
38
- popoutUrl: string;
39
- confirmClose: (document: MdiDocumentInfo) => Promise<MdiConfirmCloseResult>;
40
- };
41
-
42
- type InternalDocument = MdiDocumentState & { dirty: boolean };
43
-
44
- type StoreEvents = MdiEvents & { change: undefined };
45
-
46
- /**
47
- * Owns the documents of one `Mdi`: the registry lookup, the open/close/dirty
48
- * lifecycle, restore and persistence. Talks to dockview through the narrow
49
- * `MdiHost` interface so it can be tested without a DOM. The React layer is a thin
50
- * shell around it; `MdiProvider` creates one and `useMdi()` hands it out.
51
- */
52
- export class MdiController implements MdiApi {
53
- #types: MdiDocumentTypes;
54
- #store: MdiLayoutStore;
55
- #documents = new Map<string, InternalDocument>();
56
- #guards = new Map<string, MdiDocumentOptions>();
57
- #emitter: Emitter<StoreEvents> = mitt<StoreEvents>();
58
- #host: MdiHost | undefined;
59
- #hostDisposables: (() => void)[] = [];
60
- #options: MdiControllerOptions | undefined;
61
- #activeId: string | undefined;
62
- #infoCache = new Map<string, MdiDocumentInfo>();
63
- #listCache: MdiDocumentInfo[] | undefined;
64
- #emptyList: MdiDocumentInfo[] = [];
65
- #initialized = false;
66
- #applying = false;
67
- #generation = 0;
68
-
69
- constructor(types: MdiDocumentTypes, store: MdiLayoutStore = defaultLayoutStore) {
70
- this.#types = types;
71
- this.#store = store;
72
- }
73
-
74
- getTypes(): MdiDocumentTypes {
75
- return this.#types;
76
- }
77
-
78
- setTypes(types: MdiDocumentTypes) {
79
- this.#types = types;
80
- }
81
-
82
- setOptions(options: MdiControllerOptions) {
83
- this.#options = options;
84
- }
85
-
86
- get isAttached(): boolean {
87
- return this.#host !== undefined;
88
- }
89
-
90
- /* ---------------------------------------------------------------- host */
91
-
92
- attach(host: MdiHost) {
93
- this.detach();
94
- this.#host = host;
95
- this.#hostDisposables = [
96
- host.onDidActivePanelChange((id) => this.#setActive(id)),
97
- host.onDidRemovePanel((id) => this.#handleRemoved(id)),
98
- host.onDidLayoutChange(() => {
99
- if (this.#applying) return;
100
- this.#emitter.emit("layoutChange", undefined);
101
- this.#scheduleSave();
102
- }),
103
- ];
104
- }
105
-
106
- detach() {
107
- for (let dispose of this.#hostDisposables) dispose();
108
- this.#hostDisposables = [];
109
- this.#host = undefined;
110
- this.#initialized = false;
111
- this.#generation += 1;
112
- }
113
-
114
- /**
115
- * Restores the persisted layout (dropping documents the app no longer wants) or
116
- * applies the default one, then makes sure every fixed pane is present.
117
- */
118
- async initialize(): Promise<void> {
119
- let options = this.#requireOptions();
120
- let generation = ++this.#generation;
121
- let key = this.#storageKey();
122
- let layout: MdiLayout | undefined;
123
-
124
- if (key) {
125
- await this.#store.loaded;
126
- if (generation !== this.#generation) return;
127
-
128
- let persisted = this.#store.get(key, options.layoutVersion);
129
-
130
- if (persisted) {
131
- layout = await this.#resolvePersisted(persisted);
132
- if (generation !== this.#generation) return;
133
- }
134
- }
135
-
136
- this.#applyRestoredOrDefault(layout);
137
- }
138
-
139
- /* ---------------------------------------------------------- documents */
140
-
141
- openDocument<P extends MdiParams = MdiParams>(options: MdiOpenOptions<P>): string {
142
- let host = this.#requireHost();
143
- let definition = resolveType(this.#types, options.type);
144
- let key = definition.singleton ? undefined : options.key;
145
- let id = panelId(options.type, key);
146
- let existing = this.#documents.get(id);
147
-
148
- if (existing) {
149
- let changed = false;
150
-
151
- if (options.params) {
152
- existing.params = { ...options.params };
153
- changed = true;
154
- }
155
-
156
- if (options.title !== undefined && options.title !== existing.title) {
157
- existing.title = options.title;
158
- host.getPanel(id)?.setTitle(options.title);
159
- this.#emitter.emit("titleChange", { id, title: options.title });
160
- changed = true;
161
- }
162
-
163
- if (changed) {
164
- this.#invalidate(id);
165
- this.#scheduleSave();
166
- }
167
-
168
- if (!options.inactive) {
169
- this.activate(id);
170
- }
171
-
172
- return id;
173
- }
174
-
175
- let params: MdiParams = { ...options.params };
176
- let title = options.title ?? resolveTitle(definition, options.type, params, key);
177
-
178
- this.#documents.set(id, {
179
- type: options.type,
180
- key,
181
- params,
182
- title,
183
- closable: resolveClosable(definition, options.closable),
184
- dirty: false,
185
- });
186
-
187
- host.addPanel({ id, title, position: options.position, inactive: options.inactive });
188
- this.#invalidate(id);
189
- this.#emitter.emit("open", this.getDocument(id)!);
190
- this.#scheduleSave();
191
-
192
- return id;
193
- }
194
-
195
- activate(id: string) {
196
- this.#host?.getPanel(id)?.setActive();
197
- }
198
-
199
- async close(id: string, options: MdiCloseOptions = {}): Promise<boolean> {
200
- let document = this.#documents.get(id);
201
-
202
- if (!document) {
203
- return false;
204
- }
205
-
206
- if (!options.force) {
207
- if (!document.closable) {
208
- return false;
209
- }
210
-
211
- let guards = this.#guards.get(id);
212
-
213
- if (guards?.onClosing && !(await guards.onClosing())) {
214
- return false;
215
- }
216
-
217
- if (document.dirty) {
218
- let result = await this.#requireOptions().confirmClose(this.getDocument(id)!);
219
-
220
- if (result === "cancel") {
221
- return false;
222
- }
223
-
224
- if (result === "save") {
225
- let saved = guards?.onSave ? await guards.onSave() : true;
226
-
227
- if (!saved) {
228
- return false;
229
- }
230
- }
231
- }
232
- }
233
-
234
- // The document may have gone away while a guard was pending.
235
- if (!this.#documents.has(id)) {
236
- return true;
237
- }
238
-
239
- let panel = this.#host?.getPanel(id);
240
-
241
- if (panel) {
242
- panel.close();
243
- } else {
244
- this.#handleRemoved(id, true);
245
- }
246
-
247
- return true;
248
- }
249
-
250
- async closeByKey(key: string, options: MdiCloseOptions = { force: true }): Promise<void> {
251
- let ids = [...this.#documents.entries()].filter(([, document]) => document.key === key).map(([id]) => id);
252
-
253
- for (let id of ids) {
254
- await this.close(id, options);
255
- }
256
- }
257
-
258
- async closeAll(type?: string): Promise<boolean> {
259
- let ids = [...this.#documents.entries()]
260
- .filter(([, document]) => document.closable && (type === undefined || document.type === type))
261
- .map(([id]) => id);
262
-
263
- for (let id of ids) {
264
- if (this.#documents.has(id) && !(await this.close(id))) {
265
- return false;
266
- }
267
- }
268
-
269
- return true;
270
- }
271
-
272
- setTitle(id: string, title: string) {
273
- let document = this.#documents.get(id);
274
-
275
- if (!document || document.title === title) {
276
- return;
277
- }
278
-
279
- document.title = title;
280
- this.#host?.getPanel(id)?.setTitle(title);
281
- this.#invalidate(id);
282
- this.#emitter.emit("titleChange", { id, title });
283
- this.#scheduleSave();
284
- }
285
-
286
- setDirty(id: string, dirty: boolean) {
287
- let document = this.#documents.get(id);
288
-
289
- if (!document || document.dirty === dirty) {
290
- return;
291
- }
292
-
293
- document.dirty = dirty;
294
- this.#invalidate(id);
295
- this.#emitter.emit("dirtyChange", { id, dirty });
296
- }
297
-
298
- getActive = (): MdiDocumentInfo | undefined => {
299
- return this.#activeId ? this.getDocument(this.#activeId) : undefined;
300
- };
301
-
302
- getDocument = (id: string): MdiDocumentInfo | undefined => {
303
- let cached = this.#infoCache.get(id);
304
-
305
- if (cached) {
306
- return cached;
307
- }
308
-
309
- let document = this.#documents.get(id);
310
-
311
- if (!document) {
312
- return undefined;
313
- }
314
-
315
- let info: MdiDocumentInfo = { ...document, id, isActive: id === this.#activeId };
316
- this.#infoCache.set(id, info);
317
-
318
- return info;
319
- };
320
-
321
- /** Every open document, in insertion order. Referentially stable until something changes. */
322
- getDocumentsSnapshot = (): MdiDocumentInfo[] => {
323
- if (!this.#listCache) {
324
- let list = [...this.#documents.keys()].map((id) => this.getDocument(id)!);
325
- this.#listCache = list.length === 0 ? this.#emptyList : list;
326
- }
327
-
328
- return this.#listCache;
329
- };
330
-
331
- getDocuments(type?: string): MdiDocumentInfo[] {
332
- let all = this.getDocumentsSnapshot();
333
- return type === undefined ? [...all] : all.filter((document) => document.type === type);
334
- }
335
-
336
- hasDirty(): boolean {
337
- for (let document of this.#documents.values()) {
338
- if (document.dirty) return true;
339
- }
340
-
341
- return false;
342
- }
343
-
344
- async popout(id: string): Promise<boolean> {
345
- let host = this.#requireHost();
346
- return host.addPopoutGroup(id, this.#requireOptions().popoutUrl);
347
- }
348
-
349
- getWindow(id: string): Window {
350
- return this.#host?.getPanel(id)?.getWindow() ?? globalThis.window;
351
- }
352
-
353
- panelId(type: string, key?: string): string {
354
- let definition = this.#types[type];
355
- return panelId(type, definition?.singleton ? undefined : key);
356
- }
357
-
358
- /* -------------------------------------------------------------- guards */
359
-
360
- /** Registers the close guards of a mounted document component. Returns the unregister function. */
361
- registerGuards(id: string, guards: MdiDocumentOptions): () => void {
362
- this.#guards.set(id, guards);
363
-
364
- return () => {
365
- // Another instance may already have registered for the same id (React remounts).
366
- if (this.#guards.get(id) === guards) {
367
- this.#guards.delete(id);
368
- }
369
- };
370
- }
371
-
372
- /* -------------------------------------------------------------- events */
373
-
374
- subscribe<K extends keyof MdiEvents>(event: K, handler: (event: MdiEvents[K]) => void): () => void {
375
- this.#emitter.on(event, handler);
376
- return () => this.#emitter.off(event, handler);
377
- }
378
-
379
- /** Fires on every change of the document list, a document, or the active document. For `useSyncExternalStore`. */
380
- subscribeStore = (handler: () => void): (() => void) => {
381
- this.#emitter.on("change", handler);
382
- return () => this.#emitter.off("change", handler);
383
- };
384
-
385
- /* -------------------------------------------------------------- layout */
386
-
387
- getLayout(): MdiLayout | undefined {
388
- if (!this.#host || !this.#options) {
389
- return undefined;
390
- }
391
-
392
- let documents: Record<string, MdiDocumentState> = {};
393
-
394
- for (let [id, document] of this.#documents) {
395
- let { dirty: _dirty, ...state } = document;
396
- documents[id] = state;
397
- }
398
-
399
- return {
400
- format: 1,
401
- layoutVersion: this.#options.layoutVersion,
402
- documents,
403
- dockview: this.#host.toJSON(),
404
- };
405
- }
406
-
407
- async saveLayout(): Promise<MdiLayout | undefined> {
408
- let key = this.#storageKey();
409
- let layout = this.getLayout();
410
-
411
- if (key && layout) {
412
- await this.#store.setNow(key, layout);
413
- }
414
-
415
- return layout;
416
- }
417
-
418
- async loadLayout(layout?: MdiLayout): Promise<void> {
419
- let options = this.#requireOptions();
420
- let key = this.#storageKey();
421
- let source = layout ?? (key ? this.#store.get(key, options.layoutVersion) : undefined);
422
- let resolved = source ? await this.#resolvePersisted(source) : undefined;
423
-
424
- this.#applyRestoredOrDefault(resolved);
425
- }
426
-
427
- async resetLayout(): Promise<void> {
428
- let key = this.#storageKey();
429
-
430
- if (key) {
431
- await this.#store.remove(key);
432
- }
433
-
434
- this.#applyRestoredOrDefault(undefined);
435
- }
436
-
437
- /* ------------------------------------------------------------ internal */
438
-
439
- #requireHost(): MdiHost {
440
- if (!this.#host) {
441
- throw new Error("Mdi: no dock is mounted yet. Open documents after <Mdi> has rendered.");
442
- }
443
-
444
- return this.#host;
445
- }
446
-
447
- #requireOptions(): MdiControllerOptions {
448
- if (!this.#options) {
449
- throw new Error("Mdi: the controller has no options. Render <Mdi> inside the provider first.");
450
- }
451
-
452
- return this.#options;
453
- }
454
-
455
- #storageKey(): string | undefined {
456
- let id = this.#options?.id;
457
- return id ? this.#store.key(id) : undefined;
458
- }
459
-
460
- #defaultLayout(): MdiLayout {
461
- let options = this.#requireOptions();
462
- return layoutSpecToLayout(options.defaultLayout, this.#types, options.layoutVersion);
463
- }
464
-
465
- #applyRestoredOrDefault(layout: MdiLayout | undefined) {
466
- let options = this.#requireOptions();
467
-
468
- if (layout && !isLayoutEmpty(layout)) {
469
- try {
470
- this.#applyLayout(layout);
471
- } catch (error) {
472
- console.warn("Mdi: could not apply the saved layout, falling back to the default.", error);
473
- layout = undefined;
474
- }
475
- } else {
476
- layout = undefined;
477
- }
478
-
479
- if (!layout) {
480
- layout = this.#defaultLayout();
481
- this.#applyLayout(layout);
482
- }
483
-
484
- this.#initialized = true;
485
- this.#addMissingRequired(options.defaultLayout, layout);
486
- }
487
-
488
- /** Re-docks popouts, runs the `restore` resolvers and drops what they reject. */
489
- async #resolvePersisted(persisted: MdiLayout): Promise<MdiLayout> {
490
- let layout: MdiLayout = { ...persisted, dockview: dockPopoutGroups(persisted.dockview) };
491
- let keep = new Set<string>();
492
- let documents: Record<string, MdiDocumentState> = {};
493
-
494
- await Promise.all(
495
- Object.entries(layout.documents).map(async ([id, document]) => {
496
- let definition = this.#types[document.type];
497
-
498
- if (!definition) {
499
- console.warn(`Mdi: dropping document "${id}", its type is no longer registered.`);
500
- return;
501
- }
502
-
503
- try {
504
- let result = definition.restore
505
- ? await definition.restore({ key: document.key, params: document.params })
506
- : true;
507
-
508
- if (result === false) {
509
- return;
510
- }
511
-
512
- let state: MdiDocumentState = { ...document, params: { ...document.params } };
513
-
514
- if (typeof result === "object") {
515
- if (result.params) state.params = { ...result.params };
516
- if (result.title !== undefined) state.title = result.title;
517
- }
518
-
519
- keep.add(id);
520
- documents[id] = state;
521
- } catch (error) {
522
- console.warn(`Mdi: dropping document "${id}", restore failed.`, error);
523
- }
524
- }),
525
- );
526
-
527
- let pruned = pruneLayout({ ...layout, documents }, keep);
528
-
529
- for (let [id, document] of Object.entries(pruned.documents)) {
530
- let panel = pruned.dockview.panels[id];
531
- if (panel) panel.title = document.title;
532
- }
533
-
534
- return pruned;
535
- }
536
-
537
- #applyLayout(layout: MdiLayout) {
538
- let host = this.#requireHost();
539
-
540
- this.#applying = true;
541
-
542
- try {
543
- this.#documents.clear();
544
- this.#guards.clear();
545
-
546
- for (let [id, document] of Object.entries(layout.documents)) {
547
- this.#documents.set(id, { ...document, params: { ...document.params }, dirty: false });
548
- }
549
-
550
- host.fromJSON(structuredClone(layout.dockview));
551
-
552
- // dockview's fromJSON reduces `locked` to a boolean, which still allows drops beside
553
- // the group; put the stricter value back.
554
- for (let group of listGridGroups(layout.dockview)) {
555
- if (group.locked === "no-drop-target") host.getGroup(group.id)?.setLocked("no-drop-target");
556
- }
557
-
558
- // Drop anything dockview did not accept, so the two sides agree.
559
- for (let id of this.#documents.keys()) {
560
- if (!host.getPanel(id)) this.#documents.delete(id);
561
- }
562
- } finally {
563
- this.#applying = false;
564
- }
565
-
566
- this.#activeId = host.getActivePanelId();
567
- this.#invalidate();
568
- this.#emitter.emit("activeChange", this.getActive());
569
- this.#emitter.emit("layoutChange", undefined);
570
- }
571
-
572
- /** Fixed panes added to the default layout after a layout was saved are missing from it; put them back. */
573
- #addMissingRequired(spec: MdiLayoutSpec, layout: MdiLayout) {
574
- let host = this.#requireHost();
575
-
576
- for (let document of missingRequiredDocuments(spec, this.#types, layout)) {
577
- let existing = new Set(this.#documents.keys());
578
- let { position, groupSpec } = placementFor(spec, document.id, this.#types, existing);
579
- let id = this.openDocument({
580
- type: document.type,
581
- key: document.key,
582
- params: document.params,
583
- title: document.title,
584
- closable: false,
585
- position,
586
- inactive: true,
587
- });
588
- let panel = host.getPanel(id);
589
-
590
- if (panel && groupSpec && position.direction !== "within" && panel.group.panelCount === 1) {
591
- let locked = resolveLocked(groupSpec);
592
- if (groupSpec.hideHeader) panel.group.setHeaderHidden(true);
593
- if (locked) panel.group.setLocked(locked);
594
- }
595
- }
596
- }
597
-
598
- #handleRemoved(id: string, force = false) {
599
- if (this.#applying) return;
600
-
601
- // dockview reports removals while moving panels between groups too; only a panel
602
- // that is really gone counts as closed.
603
- if (!force && this.#host?.getPanel(id)) return;
604
-
605
- let info = this.getDocument(id);
606
-
607
- if (!info) return;
608
-
609
- this.#documents.delete(id);
610
- this.#guards.delete(id);
611
-
612
- if (this.#activeId === id) {
613
- this.#activeId = undefined;
614
- }
615
-
616
- this.#invalidate();
617
- this.#emitter.emit("close", info);
618
- this.#scheduleSave();
619
- }
620
-
621
- #setActive(id: string | undefined) {
622
- if (id === this.#activeId) return;
623
-
624
- let previous = this.#activeId;
625
- this.#activeId = id;
626
-
627
- if (previous) this.#infoCache.delete(previous);
628
- if (id) this.#infoCache.delete(id);
629
- this.#listCache = undefined;
630
-
631
- this.#emitter.emit("change", undefined);
632
- this.#emitter.emit("activeChange", this.getActive());
633
- }
634
-
635
- #invalidate(id?: string) {
636
- if (id === undefined) {
637
- this.#infoCache.clear();
638
- } else {
639
- this.#infoCache.delete(id);
640
- }
641
-
642
- this.#listCache = undefined;
643
- this.#emitter.emit("change", undefined);
644
- }
645
-
646
- #scheduleSave() {
647
- if (!this.#initialized || this.#applying || !this.#options?.persist) return;
648
-
649
- let key = this.#storageKey();
650
- let layout = this.getLayout();
651
-
652
- if (key && layout) {
653
- this.#store.set(key, layout);
654
- }
655
- }
656
- }
1
+ import mitt, { type Emitter } from "mitt";
2
+
3
+ import type { MdiHost } from "./host.js";
4
+ import { panelId } from "./ids.js";
5
+ import {
6
+ dockPopoutGroups,
7
+ isLayoutEmpty,
8
+ listGridGroups,
9
+ missingRequiredDocuments,
10
+ placementFor,
11
+ pruneLayout,
12
+ } from "./layoutRestore.js";
13
+ import { layoutSpecToLayout, resolveLocked } from "./layoutSpec.js";
14
+ import { layoutStore as defaultLayoutStore, type MdiLayoutStore } from "./layoutStore.js";
15
+ import { resolveClosable, resolveTitle, resolveType } from "./registry.js";
16
+ import type {
17
+ MdiApi,
18
+ MdiCloseOptions,
19
+ MdiConfirmCloseResult,
20
+ MdiDocumentInfo,
21
+ MdiDocumentOptions,
22
+ MdiDocumentState,
23
+ MdiDocumentTypes,
24
+ MdiEvents,
25
+ MdiLayout,
26
+ MdiLayoutSpec,
27
+ MdiOpenOptions,
28
+ MdiParams,
29
+ } from "./types.js";
30
+
31
+ export type MdiControllerOptions = {
32
+ /** Storage key within the article. Without it nothing is restored or saved. */
33
+ id?: string;
34
+ layoutVersion: string | number;
35
+ defaultLayout: MdiLayoutSpec;
36
+ /** Save every layout change automatically. Off: only `saveLayout()` writes, but a saved layout is still restored on load. */
37
+ persist: boolean;
38
+ popoutUrl: string;
39
+ confirmClose: (document: MdiDocumentInfo) => Promise<MdiConfirmCloseResult>;
40
+ };
41
+
42
+ type InternalDocument = MdiDocumentState & { dirty: boolean };
43
+
44
+ type StoreEvents = MdiEvents & { change: undefined };
45
+
46
+ /**
47
+ * Owns the documents of one `Mdi`: the registry lookup, the open/close/dirty
48
+ * lifecycle, restore and persistence. Talks to dockview through the narrow
49
+ * `MdiHost` interface so it can be tested without a DOM. The React layer is a thin
50
+ * shell around it; `MdiProvider` creates one and `useMdi()` hands it out.
51
+ */
52
+ export class MdiController implements MdiApi {
53
+ #types: MdiDocumentTypes;
54
+ #store: MdiLayoutStore;
55
+ #documents = new Map<string, InternalDocument>();
56
+ #guards = new Map<string, MdiDocumentOptions>();
57
+ #emitter: Emitter<StoreEvents> = mitt<StoreEvents>();
58
+ #host: MdiHost | undefined;
59
+ #hostDisposables: (() => void)[] = [];
60
+ #options: MdiControllerOptions | undefined;
61
+ #activeId: string | undefined;
62
+ #infoCache = new Map<string, MdiDocumentInfo>();
63
+ #listCache: MdiDocumentInfo[] | undefined;
64
+ #emptyList: MdiDocumentInfo[] = [];
65
+ #initialized = false;
66
+ #applying = false;
67
+ #generation = 0;
68
+
69
+ constructor(types: MdiDocumentTypes, store: MdiLayoutStore = defaultLayoutStore) {
70
+ this.#types = types;
71
+ this.#store = store;
72
+ }
73
+
74
+ getTypes(): MdiDocumentTypes {
75
+ return this.#types;
76
+ }
77
+
78
+ setTypes(types: MdiDocumentTypes) {
79
+ this.#types = types;
80
+ }
81
+
82
+ setOptions(options: MdiControllerOptions) {
83
+ this.#options = options;
84
+ }
85
+
86
+ get isAttached(): boolean {
87
+ return this.#host !== undefined;
88
+ }
89
+
90
+ /* ---------------------------------------------------------------- host */
91
+
92
+ attach(host: MdiHost) {
93
+ this.detach();
94
+ this.#host = host;
95
+ this.#hostDisposables = [
96
+ host.onDidActivePanelChange((id) => this.#setActive(id)),
97
+ host.onDidRemovePanel((id) => this.#handleRemoved(id)),
98
+ host.onDidLayoutChange(() => {
99
+ if (this.#applying) return;
100
+ this.#emitter.emit("layoutChange", undefined);
101
+ this.#scheduleSave();
102
+ }),
103
+ ];
104
+ }
105
+
106
+ detach() {
107
+ for (let dispose of this.#hostDisposables) dispose();
108
+ this.#hostDisposables = [];
109
+ this.#host = undefined;
110
+ this.#initialized = false;
111
+ this.#generation += 1;
112
+ }
113
+
114
+ /**
115
+ * Restores the persisted layout (dropping documents the app no longer wants) or
116
+ * applies the default one, then makes sure every fixed pane is present.
117
+ */
118
+ async initialize(): Promise<void> {
119
+ let options = this.#requireOptions();
120
+ let generation = ++this.#generation;
121
+ let key = this.#storageKey();
122
+ let layout: MdiLayout | undefined;
123
+
124
+ if (key) {
125
+ await this.#store.loaded;
126
+ if (generation !== this.#generation) return;
127
+
128
+ let persisted = this.#store.get(key, options.layoutVersion);
129
+
130
+ if (persisted) {
131
+ layout = await this.#resolvePersisted(persisted);
132
+ if (generation !== this.#generation) return;
133
+ }
134
+ }
135
+
136
+ this.#applyRestoredOrDefault(layout);
137
+ }
138
+
139
+ /* ---------------------------------------------------------- documents */
140
+
141
+ openDocument<P extends MdiParams = MdiParams>(options: MdiOpenOptions<P>): string {
142
+ let host = this.#requireHost();
143
+ let definition = resolveType(this.#types, options.type);
144
+ let key = definition.singleton ? undefined : options.key;
145
+ let id = panelId(options.type, key);
146
+ let existing = this.#documents.get(id);
147
+
148
+ if (existing) {
149
+ let changed = false;
150
+
151
+ if (options.params) {
152
+ existing.params = { ...options.params };
153
+ changed = true;
154
+ }
155
+
156
+ if (options.title !== undefined && options.title !== existing.title) {
157
+ existing.title = options.title;
158
+ host.getPanel(id)?.setTitle(options.title);
159
+ this.#emitter.emit("titleChange", { id, title: options.title });
160
+ changed = true;
161
+ }
162
+
163
+ if (changed) {
164
+ this.#invalidate(id);
165
+ this.#scheduleSave();
166
+ }
167
+
168
+ if (!options.inactive) {
169
+ this.activate(id);
170
+ }
171
+
172
+ return id;
173
+ }
174
+
175
+ let params: MdiParams = { ...options.params };
176
+ let title = options.title ?? resolveTitle(definition, options.type, params, key);
177
+
178
+ this.#documents.set(id, {
179
+ type: options.type,
180
+ key,
181
+ params,
182
+ title,
183
+ closable: resolveClosable(definition, options.closable),
184
+ dirty: false,
185
+ });
186
+
187
+ host.addPanel({ id, title, position: options.position, inactive: options.inactive });
188
+ this.#invalidate(id);
189
+ this.#emitter.emit("open", this.getDocument(id)!);
190
+ this.#scheduleSave();
191
+
192
+ return id;
193
+ }
194
+
195
+ activate(id: string) {
196
+ this.#host?.getPanel(id)?.setActive();
197
+ }
198
+
199
+ async close(id: string, options: MdiCloseOptions = {}): Promise<boolean> {
200
+ let document = this.#documents.get(id);
201
+
202
+ if (!document) {
203
+ return false;
204
+ }
205
+
206
+ if (!options.force) {
207
+ if (!document.closable) {
208
+ return false;
209
+ }
210
+
211
+ let guards = this.#guards.get(id);
212
+
213
+ if (guards?.onClosing && !(await guards.onClosing())) {
214
+ return false;
215
+ }
216
+
217
+ if (document.dirty) {
218
+ let result = await this.#requireOptions().confirmClose(this.getDocument(id)!);
219
+
220
+ if (result === "cancel") {
221
+ return false;
222
+ }
223
+
224
+ if (result === "save") {
225
+ let saved = guards?.onSave ? await guards.onSave() : true;
226
+
227
+ if (!saved) {
228
+ return false;
229
+ }
230
+ }
231
+ }
232
+ }
233
+
234
+ // The document may have gone away while a guard was pending.
235
+ if (!this.#documents.has(id)) {
236
+ return true;
237
+ }
238
+
239
+ let panel = this.#host?.getPanel(id);
240
+
241
+ if (panel) {
242
+ panel.close();
243
+ } else {
244
+ this.#handleRemoved(id, true);
245
+ }
246
+
247
+ return true;
248
+ }
249
+
250
+ async closeByKey(key: string, options: MdiCloseOptions = { force: true }): Promise<void> {
251
+ let ids = [...this.#documents.entries()].filter(([, document]) => document.key === key).map(([id]) => id);
252
+
253
+ for (let id of ids) {
254
+ await this.close(id, options);
255
+ }
256
+ }
257
+
258
+ async closeAll(type?: string): Promise<boolean> {
259
+ let ids = [...this.#documents.entries()]
260
+ .filter(([, document]) => document.closable && (type === undefined || document.type === type))
261
+ .map(([id]) => id);
262
+
263
+ for (let id of ids) {
264
+ if (this.#documents.has(id) && !(await this.close(id))) {
265
+ return false;
266
+ }
267
+ }
268
+
269
+ return true;
270
+ }
271
+
272
+ setTitle(id: string, title: string) {
273
+ let document = this.#documents.get(id);
274
+
275
+ if (!document || document.title === title) {
276
+ return;
277
+ }
278
+
279
+ document.title = title;
280
+ this.#host?.getPanel(id)?.setTitle(title);
281
+ this.#invalidate(id);
282
+ this.#emitter.emit("titleChange", { id, title });
283
+ this.#scheduleSave();
284
+ }
285
+
286
+ setDirty(id: string, dirty: boolean) {
287
+ let document = this.#documents.get(id);
288
+
289
+ if (!document || document.dirty === dirty) {
290
+ return;
291
+ }
292
+
293
+ document.dirty = dirty;
294
+ this.#invalidate(id);
295
+ this.#emitter.emit("dirtyChange", { id, dirty });
296
+ }
297
+
298
+ getActive = (): MdiDocumentInfo | undefined => {
299
+ return this.#activeId ? this.getDocument(this.#activeId) : undefined;
300
+ };
301
+
302
+ getDocument = (id: string): MdiDocumentInfo | undefined => {
303
+ let cached = this.#infoCache.get(id);
304
+
305
+ if (cached) {
306
+ return cached;
307
+ }
308
+
309
+ let document = this.#documents.get(id);
310
+
311
+ if (!document) {
312
+ return undefined;
313
+ }
314
+
315
+ let info: MdiDocumentInfo = { ...document, id, isActive: id === this.#activeId };
316
+ this.#infoCache.set(id, info);
317
+
318
+ return info;
319
+ };
320
+
321
+ /** Every open document, in insertion order. Referentially stable until something changes. */
322
+ getDocumentsSnapshot = (): MdiDocumentInfo[] => {
323
+ if (!this.#listCache) {
324
+ let list = [...this.#documents.keys()].map((id) => this.getDocument(id)!);
325
+ this.#listCache = list.length === 0 ? this.#emptyList : list;
326
+ }
327
+
328
+ return this.#listCache;
329
+ };
330
+
331
+ getDocuments(type?: string): MdiDocumentInfo[] {
332
+ let all = this.getDocumentsSnapshot();
333
+ return type === undefined ? [...all] : all.filter((document) => document.type === type);
334
+ }
335
+
336
+ hasDirty(): boolean {
337
+ for (let document of this.#documents.values()) {
338
+ if (document.dirty) return true;
339
+ }
340
+
341
+ return false;
342
+ }
343
+
344
+ async popout(id: string): Promise<boolean> {
345
+ let host = this.#requireHost();
346
+ return host.addPopoutGroup(id, this.#requireOptions().popoutUrl);
347
+ }
348
+
349
+ getWindow(id: string): Window {
350
+ return this.#host?.getPanel(id)?.getWindow() ?? globalThis.window;
351
+ }
352
+
353
+ panelId(type: string, key?: string): string {
354
+ let definition = this.#types[type];
355
+ return panelId(type, definition?.singleton ? undefined : key);
356
+ }
357
+
358
+ /* -------------------------------------------------------------- guards */
359
+
360
+ /** Registers the close guards of a mounted document component. Returns the unregister function. */
361
+ registerGuards(id: string, guards: MdiDocumentOptions): () => void {
362
+ this.#guards.set(id, guards);
363
+
364
+ return () => {
365
+ // Another instance may already have registered for the same id (React remounts).
366
+ if (this.#guards.get(id) === guards) {
367
+ this.#guards.delete(id);
368
+ }
369
+ };
370
+ }
371
+
372
+ /* -------------------------------------------------------------- events */
373
+
374
+ subscribe<K extends keyof MdiEvents>(event: K, handler: (event: MdiEvents[K]) => void): () => void {
375
+ this.#emitter.on(event, handler);
376
+ return () => this.#emitter.off(event, handler);
377
+ }
378
+
379
+ /** Fires on every change of the document list, a document, or the active document. For `useSyncExternalStore`. */
380
+ subscribeStore = (handler: () => void): (() => void) => {
381
+ this.#emitter.on("change", handler);
382
+ return () => this.#emitter.off("change", handler);
383
+ };
384
+
385
+ /* -------------------------------------------------------------- layout */
386
+
387
+ getLayout(): MdiLayout | undefined {
388
+ if (!this.#host || !this.#options) {
389
+ return undefined;
390
+ }
391
+
392
+ let documents: Record<string, MdiDocumentState> = {};
393
+
394
+ for (let [id, document] of this.#documents) {
395
+ let { dirty: _dirty, ...state } = document;
396
+ documents[id] = state;
397
+ }
398
+
399
+ return {
400
+ format: 1,
401
+ layoutVersion: this.#options.layoutVersion,
402
+ documents,
403
+ dockview: this.#host.toJSON(),
404
+ };
405
+ }
406
+
407
+ async saveLayout(): Promise<MdiLayout | undefined> {
408
+ let key = this.#storageKey();
409
+ let layout = this.getLayout();
410
+
411
+ if (key && layout) {
412
+ await this.#store.setNow(key, layout);
413
+ }
414
+
415
+ return layout;
416
+ }
417
+
418
+ async loadLayout(layout?: MdiLayout): Promise<void> {
419
+ let options = this.#requireOptions();
420
+ let key = this.#storageKey();
421
+ let source = layout ?? (key ? this.#store.get(key, options.layoutVersion) : undefined);
422
+ let resolved = source ? await this.#resolvePersisted(source) : undefined;
423
+
424
+ this.#applyRestoredOrDefault(resolved);
425
+ }
426
+
427
+ async resetLayout(): Promise<void> {
428
+ let key = this.#storageKey();
429
+
430
+ if (key) {
431
+ await this.#store.remove(key);
432
+ }
433
+
434
+ this.#applyRestoredOrDefault(undefined);
435
+ }
436
+
437
+ /* ------------------------------------------------------------ internal */
438
+
439
+ #requireHost(): MdiHost {
440
+ if (!this.#host) {
441
+ throw new Error("Mdi: no dock is mounted yet. Open documents after <Mdi> has rendered.");
442
+ }
443
+
444
+ return this.#host;
445
+ }
446
+
447
+ #requireOptions(): MdiControllerOptions {
448
+ if (!this.#options) {
449
+ throw new Error("Mdi: the controller has no options. Render <Mdi> inside the provider first.");
450
+ }
451
+
452
+ return this.#options;
453
+ }
454
+
455
+ #storageKey(): string | undefined {
456
+ let id = this.#options?.id;
457
+ return id ? this.#store.key(id) : undefined;
458
+ }
459
+
460
+ #defaultLayout(): MdiLayout {
461
+ let options = this.#requireOptions();
462
+ return layoutSpecToLayout(options.defaultLayout, this.#types, options.layoutVersion);
463
+ }
464
+
465
+ #applyRestoredOrDefault(layout: MdiLayout | undefined) {
466
+ let options = this.#requireOptions();
467
+
468
+ if (layout && !isLayoutEmpty(layout)) {
469
+ try {
470
+ this.#applyLayout(layout);
471
+ } catch (error) {
472
+ console.warn("Mdi: could not apply the saved layout, falling back to the default.", error);
473
+ layout = undefined;
474
+ }
475
+ } else {
476
+ layout = undefined;
477
+ }
478
+
479
+ if (!layout) {
480
+ layout = this.#defaultLayout();
481
+ this.#applyLayout(layout);
482
+ }
483
+
484
+ this.#initialized = true;
485
+ this.#addMissingRequired(options.defaultLayout, layout);
486
+ }
487
+
488
+ /** Re-docks popouts, runs the `restore` resolvers and drops what they reject. */
489
+ async #resolvePersisted(persisted: MdiLayout): Promise<MdiLayout> {
490
+ let layout: MdiLayout = { ...persisted, dockview: dockPopoutGroups(persisted.dockview) };
491
+ let keep = new Set<string>();
492
+ let documents: Record<string, MdiDocumentState> = {};
493
+
494
+ await Promise.all(
495
+ Object.entries(layout.documents).map(async ([id, document]) => {
496
+ let definition = this.#types[document.type];
497
+
498
+ if (!definition) {
499
+ console.warn(`Mdi: dropping document "${id}", its type is no longer registered.`);
500
+ return;
501
+ }
502
+
503
+ try {
504
+ let result = definition.restore
505
+ ? await definition.restore({ key: document.key, params: document.params })
506
+ : true;
507
+
508
+ if (result === false) {
509
+ return;
510
+ }
511
+
512
+ let state: MdiDocumentState = { ...document, params: { ...document.params } };
513
+
514
+ if (typeof result === "object") {
515
+ if (result.params) state.params = { ...result.params };
516
+ if (result.title !== undefined) state.title = result.title;
517
+ }
518
+
519
+ keep.add(id);
520
+ documents[id] = state;
521
+ } catch (error) {
522
+ console.warn(`Mdi: dropping document "${id}", restore failed.`, error);
523
+ }
524
+ }),
525
+ );
526
+
527
+ let pruned = pruneLayout({ ...layout, documents }, keep);
528
+
529
+ for (let [id, document] of Object.entries(pruned.documents)) {
530
+ let panel = pruned.dockview.panels[id];
531
+ if (panel) panel.title = document.title;
532
+ }
533
+
534
+ return pruned;
535
+ }
536
+
537
+ #applyLayout(layout: MdiLayout) {
538
+ let host = this.#requireHost();
539
+
540
+ this.#applying = true;
541
+
542
+ try {
543
+ this.#documents.clear();
544
+ this.#guards.clear();
545
+
546
+ for (let [id, document] of Object.entries(layout.documents)) {
547
+ this.#documents.set(id, { ...document, params: { ...document.params }, dirty: false });
548
+ }
549
+
550
+ host.fromJSON(structuredClone(layout.dockview));
551
+
552
+ // dockview's fromJSON reduces `locked` to a boolean, which still allows drops beside
553
+ // the group; put the stricter value back.
554
+ for (let group of listGridGroups(layout.dockview)) {
555
+ if (group.locked === "no-drop-target") host.getGroup(group.id)?.setLocked("no-drop-target");
556
+ }
557
+
558
+ // Drop anything dockview did not accept, so the two sides agree.
559
+ for (let id of this.#documents.keys()) {
560
+ if (!host.getPanel(id)) this.#documents.delete(id);
561
+ }
562
+ } finally {
563
+ this.#applying = false;
564
+ }
565
+
566
+ this.#activeId = host.getActivePanelId();
567
+ this.#invalidate();
568
+ this.#emitter.emit("activeChange", this.getActive());
569
+ this.#emitter.emit("layoutChange", undefined);
570
+ }
571
+
572
+ /** Fixed panes added to the default layout after a layout was saved are missing from it; put them back. */
573
+ #addMissingRequired(spec: MdiLayoutSpec, layout: MdiLayout) {
574
+ let host = this.#requireHost();
575
+
576
+ for (let document of missingRequiredDocuments(spec, this.#types, layout)) {
577
+ let existing = new Set(this.#documents.keys());
578
+ let { position, groupSpec } = placementFor(spec, document.id, this.#types, existing);
579
+ let id = this.openDocument({
580
+ type: document.type,
581
+ key: document.key,
582
+ params: document.params,
583
+ title: document.title,
584
+ closable: false,
585
+ position,
586
+ inactive: true,
587
+ });
588
+ let panel = host.getPanel(id);
589
+
590
+ if (panel && groupSpec && position.direction !== "within" && panel.group.panelCount === 1) {
591
+ let locked = resolveLocked(groupSpec);
592
+ if (groupSpec.hideHeader) panel.group.setHeaderHidden(true);
593
+ if (locked) panel.group.setLocked(locked);
594
+ }
595
+ }
596
+ }
597
+
598
+ #handleRemoved(id: string, force = false) {
599
+ if (this.#applying) return;
600
+
601
+ // dockview reports removals while moving panels between groups too; only a panel
602
+ // that is really gone counts as closed.
603
+ if (!force && this.#host?.getPanel(id)) return;
604
+
605
+ let info = this.getDocument(id);
606
+
607
+ if (!info) return;
608
+
609
+ this.#documents.delete(id);
610
+ this.#guards.delete(id);
611
+
612
+ if (this.#activeId === id) {
613
+ this.#activeId = undefined;
614
+ }
615
+
616
+ this.#invalidate();
617
+ this.#emitter.emit("close", info);
618
+ this.#scheduleSave();
619
+ }
620
+
621
+ #setActive(id: string | undefined) {
622
+ if (id === this.#activeId) return;
623
+
624
+ let previous = this.#activeId;
625
+ this.#activeId = id;
626
+
627
+ if (previous) this.#infoCache.delete(previous);
628
+ if (id) this.#infoCache.delete(id);
629
+ this.#listCache = undefined;
630
+
631
+ this.#emitter.emit("change", undefined);
632
+ this.#emitter.emit("activeChange", this.getActive());
633
+ }
634
+
635
+ #invalidate(id?: string) {
636
+ if (id === undefined) {
637
+ this.#infoCache.clear();
638
+ } else {
639
+ this.#infoCache.delete(id);
640
+ }
641
+
642
+ this.#listCache = undefined;
643
+ this.#emitter.emit("change", undefined);
644
+ }
645
+
646
+ #scheduleSave() {
647
+ if (!this.#initialized || this.#applying || !this.#options?.persist) return;
648
+
649
+ let key = this.#storageKey();
650
+ let layout = this.getLayout();
651
+
652
+ if (key && layout) {
653
+ this.#store.set(key, layout);
654
+ }
655
+ }
656
+ }