@olenbetong/appframe-ds 0.9.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/package.json +21 -16
  3. package/scripts/copyAssets.mjs +3 -0
  4. package/src/autocomplete/Autocomplete.css +13 -0
  5. package/src/autocomplete/Autocomplete.tsx +3 -1
  6. package/src/autocomplete/Combobox.tsx +16 -3
  7. package/src/binding/BoundTextField.tsx +9 -1
  8. package/src/filter/FieldFilterPanel.css +25 -2
  9. package/src/grid/AfGridColumnsPanel.tsx +1 -1
  10. package/src/grid/AfGridContext.tsx +1 -1
  11. package/src/grid/AfGridError.tsx +1 -1
  12. package/src/grid/AfHeaderFilterCell.tsx +1 -1
  13. package/src/grid/Toolbar.tsx +1 -1
  14. package/src/grid/editing.ts +17 -7
  15. package/src/grid/filter.ts +1 -1
  16. package/src/grid/index.css +59 -1
  17. package/src/grid/index.tsx +126 -18
  18. package/src/grid/localization.ts +1 -1
  19. package/src/grid/slots/index.tsx +27 -2
  20. package/src/grid/theme.tsx +3 -50
  21. package/src/grid/useAfColumns.tsx +103 -45
  22. package/src/grid/useAfCurrentIndex.ts +13 -5
  23. package/src/grid/useAfGridApi.ts +6 -1
  24. package/src/grid/useAfKeyBindings.ts +1 -1
  25. package/src/grid/useAfNewItemRow.ts +258 -0
  26. package/src/grid/useAfPagination.ts +2 -2
  27. package/src/grid/useAfPersistedState.ts +9 -1
  28. package/src/grid/useAfRowEditModel.ts +20 -12
  29. package/src/grid/useAfRowGrouping.ts +47 -0
  30. package/src/grid/useAfServerAggregation.ts +145 -0
  31. package/src/grid/useAfSortModel.ts +15 -3
  32. package/src/layout/AppMenuDrawer.css +70 -0
  33. package/src/layout/AppMenuDrawer.tsx +122 -0
  34. package/src/layout/index.tsx +1 -0
  35. package/src/mdi/Mdi.css +138 -0
  36. package/src/mdi/Mdi.test.tsx +87 -0
  37. package/src/mdi/Mdi.tsx +141 -0
  38. package/src/mdi/MdiConfirmCloseDialog.tsx +47 -0
  39. package/src/mdi/MdiContext.tsx +59 -0
  40. package/src/mdi/MdiDocumentContext.tsx +60 -0
  41. package/src/mdi/MdiPanel.tsx +61 -0
  42. package/src/mdi/MdiRestore.test.tsx +82 -0
  43. package/src/mdi/MdiTab.tsx +30 -0
  44. package/src/mdi/controller.test.ts +382 -0
  45. package/src/mdi/controller.ts +656 -0
  46. package/src/mdi/dockviewHost.ts +132 -0
  47. package/src/mdi/fakeHost.ts +200 -0
  48. package/src/mdi/host.ts +44 -0
  49. package/src/mdi/ids.test.ts +20 -0
  50. package/src/mdi/ids.ts +18 -0
  51. package/src/mdi/index.ts +31 -0
  52. package/src/mdi/layoutRestore.test.ts +156 -0
  53. package/src/mdi/layoutRestore.ts +311 -0
  54. package/src/mdi/layoutSpec.test.ts +143 -0
  55. package/src/mdi/layoutSpec.ts +214 -0
  56. package/src/mdi/layoutStore.test.ts +93 -0
  57. package/src/mdi/layoutStore.ts +99 -0
  58. package/src/mdi/memorySettingsStore.ts +21 -0
  59. package/src/mdi/registry.ts +34 -0
  60. package/src/mdi/testTypes.ts +37 -0
  61. package/src/mdi/theme.ts +22 -0
  62. package/src/mdi/types.ts +214 -0
  63. package/src/page/Page.css +10 -2
  64. package/src/page/PageBlock.css +5 -0
  65. package/src/page/PageBlock.tsx +14 -0
  66. package/src/page/PageHeader.css +0 -60
  67. package/src/page/PageHeader.tsx +6 -74
  68. package/src/test/happy-dom-document-class.ts +24 -0
  69. package/src/test/setup.ts +4 -0
  70. package/src/theme/useDsColorScheme.ts +53 -0
@@ -0,0 +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
+ }