@olenbetong/appframe-ds 1.3.4 → 1.3.6

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 (178) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +3 -3
  3. package/scripts/copyAssets.mjs +27 -27
  4. package/src/AfLookup/Lookup.css +129 -129
  5. package/src/AfLookup/Lookup.tsx +186 -186
  6. package/src/AfLookup/LookupCombobox.css +78 -78
  7. package/src/AfLookup/LookupCombobox.tsx +237 -237
  8. package/src/AfLookup/LookupEdit.tsx +130 -130
  9. package/src/AfLookup/LookupGrid.tsx +124 -124
  10. package/src/AfLookup/index.ts +5 -5
  11. package/src/autocomplete/Autocomplete.css +256 -256
  12. package/src/autocomplete/Autocomplete.tsx +320 -320
  13. package/src/autocomplete/AutocompleteFrame.tsx +168 -168
  14. package/src/autocomplete/Combobox.tsx +286 -286
  15. package/src/autocomplete/index.ts +4 -4
  16. package/src/autocomplete/types.ts +204 -204
  17. package/src/autocomplete/utils.ts +183 -183
  18. package/src/binding/BoundNumericTextField.tsx +102 -102
  19. package/src/binding/BoundTextField.tsx +49 -49
  20. package/src/binding/CancelButton.tsx +30 -30
  21. package/src/binding/CancelIconButton.tsx +30 -30
  22. package/src/binding/DataEditToolbar.css +23 -23
  23. package/src/binding/DataEditToolbar.tsx +84 -84
  24. package/src/binding/DeleteButton.tsx +35 -35
  25. package/src/binding/DeleteIconButton.tsx +35 -35
  26. package/src/binding/RefreshButton.tsx +31 -31
  27. package/src/binding/RefreshIconButton.tsx +33 -33
  28. package/src/binding/RefreshRowIconButton.tsx +33 -33
  29. package/src/binding/SaveButton.tsx +37 -37
  30. package/src/binding/SaveIconButton.tsx +38 -38
  31. package/src/container/Container.tsx +49 -49
  32. package/src/container/index.ts +1 -1
  33. package/src/cutoff/CutOffPicker.css +12 -12
  34. package/src/cutoff/CutOffPicker.tsx +165 -165
  35. package/src/cutoff/context.tsx +194 -194
  36. package/src/cutoff/dsCutOffDates.ts +21 -21
  37. package/src/cutoff/index.ts +4 -4
  38. package/src/cutoff/utils.test.ts +59 -59
  39. package/src/cutoff/utils.ts +276 -276
  40. package/src/filter/ChipListInput.tsx +79 -79
  41. package/src/filter/FieldFilterPanel.css +150 -150
  42. package/src/filter/FieldFilterPanel.tsx +309 -309
  43. package/src/filter/FilterBuilder.css +152 -152
  44. package/src/filter/FilterBuilder.test.tsx +153 -153
  45. package/src/filter/FilterBuilder.tsx +435 -435
  46. package/src/filter/FilterEditor.css +231 -231
  47. package/src/filter/FilterEditor.test.tsx +285 -285
  48. package/src/filter/FilterEditor.tsx +75 -75
  49. package/src/filter/FilterGroupEditor.tsx +160 -160
  50. package/src/filter/FilterNameDialog.css +22 -22
  51. package/src/filter/FilterNameDialog.tsx +81 -81
  52. package/src/filter/FilterRow.tsx +117 -117
  53. package/src/filter/FilterShareDialog.css +28 -28
  54. package/src/filter/FilterShareDialog.tsx +201 -201
  55. package/src/filter/FilterStringField.tsx +78 -78
  56. package/src/filter/FilterValueEditor.tsx +239 -239
  57. package/src/filter/SavedFilterTree.css +107 -107
  58. package/src/filter/SavedFilterTree.test.tsx +94 -94
  59. package/src/filter/SavedFilterTree.tsx +218 -218
  60. package/src/filter/fieldFilter.test.ts +158 -158
  61. package/src/filter/fieldFilter.ts +139 -139
  62. package/src/filter/index.ts +27 -27
  63. package/src/filter/types.ts +38 -38
  64. package/src/filter/useDistinctValues.test.ts +102 -102
  65. package/src/filter/useDistinctValues.ts +230 -230
  66. package/src/global.d.ts +11 -11
  67. package/src/grid/AfGridColumnsPanel.tsx +118 -118
  68. package/src/grid/AfGridContext.tsx +51 -51
  69. package/src/grid/AfGridError.tsx +46 -46
  70. package/src/grid/AfHeaderFilterCell.tsx +147 -147
  71. package/src/grid/AfHeaderFilterPanel.tsx +111 -111
  72. package/src/grid/GridEditLookup.css +12 -12
  73. package/src/grid/GridEditLookup.tsx +121 -121
  74. package/src/grid/Toolbar.tsx +233 -233
  75. package/src/grid/editing.ts +100 -100
  76. package/src/grid/filter.ts +78 -78
  77. package/src/grid/formatters.ts +48 -48
  78. package/src/grid/index.css +245 -245
  79. package/src/grid/index.tsx +503 -503
  80. package/src/grid/license.ts +48 -48
  81. package/src/grid/localization.ts +369 -369
  82. package/src/grid/slots/index.tsx +334 -334
  83. package/src/grid/slots/slots.css +64 -64
  84. package/src/grid/slots/slots.test.tsx +39 -39
  85. package/src/grid/theme.tsx +138 -138
  86. package/src/grid/useAfColumns.tsx +371 -371
  87. package/src/grid/useAfCurrentIndex.ts +63 -63
  88. package/src/grid/useAfData.ts +20 -20
  89. package/src/grid/useAfFilter.ts +160 -160
  90. package/src/grid/useAfFilterFields.ts +55 -55
  91. package/src/grid/useAfGridApi.ts +71 -71
  92. package/src/grid/useAfKeyBindings.ts +36 -36
  93. package/src/grid/useAfNewItemRow.ts +258 -258
  94. package/src/grid/useAfPagination.ts +53 -53
  95. package/src/grid/useAfPersistedState.ts +230 -230
  96. package/src/grid/useAfRowEditModel.ts +145 -145
  97. package/src/grid/useAfRowGrouping.ts +47 -47
  98. package/src/grid/useAfServerAggregation.test.ts +68 -68
  99. package/src/grid/useAfServerAggregation.ts +158 -158
  100. package/src/grid/useAfSortModel.ts +84 -84
  101. package/src/i18n.ts +13 -13
  102. package/src/index.ts +14 -14
  103. package/src/input/InputAdornments.css +84 -84
  104. package/src/input/InputAdornments.tsx +102 -102
  105. package/src/input/index.ts +1 -1
  106. package/src/layout/AppMenu.tsx +74 -74
  107. package/src/layout/AppMenuDrawer.css +70 -70
  108. package/src/layout/AppMenuDrawer.tsx +122 -122
  109. package/src/layout/ErrorBoundary.tsx +162 -162
  110. package/src/layout/PageContainer.css +48 -48
  111. package/src/layout/PageContainer.tsx +41 -41
  112. package/src/layout/ResponsiveDialog.css +86 -86
  113. package/src/layout/ResponsiveDialog.tsx +55 -55
  114. package/src/layout/index.tsx +69 -69
  115. package/src/link/Link.tsx +23 -23
  116. package/src/link/index.ts +1 -1
  117. package/src/mdi/Mdi.css +138 -138
  118. package/src/mdi/Mdi.test.tsx +91 -91
  119. package/src/mdi/Mdi.tsx +141 -141
  120. package/src/mdi/MdiConfirmCloseDialog.tsx +47 -47
  121. package/src/mdi/MdiContext.tsx +59 -59
  122. package/src/mdi/MdiDocumentContext.tsx +60 -60
  123. package/src/mdi/MdiPanel.tsx +61 -61
  124. package/src/mdi/MdiRestore.test.tsx +86 -86
  125. package/src/mdi/MdiTab.tsx +30 -30
  126. package/src/mdi/controller.test.ts +382 -382
  127. package/src/mdi/controller.ts +656 -656
  128. package/src/mdi/dockviewHost.ts +132 -132
  129. package/src/mdi/fakeHost.ts +200 -200
  130. package/src/mdi/host.ts +44 -44
  131. package/src/mdi/ids.test.ts +20 -20
  132. package/src/mdi/ids.ts +18 -18
  133. package/src/mdi/index.ts +31 -31
  134. package/src/mdi/layoutRestore.test.ts +156 -156
  135. package/src/mdi/layoutRestore.ts +311 -311
  136. package/src/mdi/layoutSpec.test.ts +143 -143
  137. package/src/mdi/layoutSpec.ts +214 -214
  138. package/src/mdi/layoutStore.test.ts +93 -93
  139. package/src/mdi/layoutStore.ts +99 -99
  140. package/src/mdi/memorySettingsStore.ts +21 -21
  141. package/src/mdi/registry.ts +34 -34
  142. package/src/mdi/testTypes.ts +37 -37
  143. package/src/mdi/theme.ts +22 -22
  144. package/src/mdi/types.ts +214 -214
  145. package/src/page/Page.css +53 -53
  146. package/src/page/PageBlock.css +64 -64
  147. package/src/page/PageBlock.tsx +92 -92
  148. package/src/page/PageHeader.css +88 -88
  149. package/src/page/PageHeader.tsx +115 -115
  150. package/src/page/PageTitle.css +5 -5
  151. package/src/page/PageTitle.tsx +21 -21
  152. package/src/page/index.ts +4 -4
  153. package/src/paper/Paper.tsx +52 -52
  154. package/src/progress/LinearProgress.css +93 -93
  155. package/src/progress/LinearProgress.tsx +104 -104
  156. package/src/progress/index.ts +1 -1
  157. package/src/report-downloader/ReportDownloader.css +67 -67
  158. package/src/report-downloader/components/StatusItem.tsx +87 -87
  159. package/src/report-downloader/components/StatusList.tsx +28 -28
  160. package/src/report-downloader/index.ts +41 -41
  161. package/src/report-downloader/status.tsx +92 -92
  162. package/src/sortable/DropIndicator.css +51 -51
  163. package/src/sortable/DropIndicator.tsx +27 -27
  164. package/src/sortable/SortableItemList.css +21 -21
  165. package/src/sortable/SortableItemList.tsx +264 -264
  166. package/src/sortable/index.ts +8 -8
  167. package/src/sortable/reorderSortOrder.ts +126 -126
  168. package/src/sortable/sortOrder.ts +101 -101
  169. package/src/sortable/types.ts +9 -9
  170. package/src/sortable/useSortableContainer.ts +53 -53
  171. package/src/sortable/useSortableItem.ts +95 -95
  172. package/src/sortable/useSortableList.ts +117 -117
  173. package/src/test/happy-dom-document-class.ts +24 -24
  174. package/src/test/setup.ts +12 -12
  175. package/src/theme/useDsColorScheme.ts +53 -53
  176. package/tsconfig.build.json +9 -9
  177. package/tsconfig.json +8 -8
  178. 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
+ }