flexdesk 0.1.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.
- package/LICENSE +21 -0
- package/README.md +195 -0
- package/THIRD_PARTY_LICENSES.md +53 -0
- package/css/base.css +3439 -0
- package/css/flexdesk.css +5797 -0
- package/css/overrides.css +2349 -0
- package/css/reset.css +46 -0
- package/css/tokens.css +137 -0
- package/dist/charts.js +780 -0
- package/dist/charts.js.map +7 -0
- package/dist/chunk-3PHCPZHT.js +222 -0
- package/dist/chunk-3PHCPZHT.js.map +7 -0
- package/dist/chunk-CT4YXXLP.js +1770 -0
- package/dist/chunk-CT4YXXLP.js.map +7 -0
- package/dist/chunk-DRYCDMEG.js +2217 -0
- package/dist/chunk-DRYCDMEG.js.map +7 -0
- package/dist/chunk-DVU44T77.js +855 -0
- package/dist/chunk-DVU44T77.js.map +7 -0
- package/dist/chunk-FL5KFNQH.js +640 -0
- package/dist/chunk-FL5KFNQH.js.map +7 -0
- package/dist/chunk-FOOS3T5L.js +117 -0
- package/dist/chunk-FOOS3T5L.js.map +7 -0
- package/dist/chunk-JYWURG5T.js +32 -0
- package/dist/chunk-JYWURG5T.js.map +7 -0
- package/dist/chunk-TLZUUFOE.js +276 -0
- package/dist/chunk-TLZUUFOE.js.map +7 -0
- package/dist/chunk-UCJ2WD4D.js +625 -0
- package/dist/chunk-UCJ2WD4D.js.map +7 -0
- package/dist/chunk-WVFGV5FT.js +205 -0
- package/dist/chunk-WVFGV5FT.js.map +7 -0
- package/dist/core.js +1098 -0
- package/dist/core.js.map +7 -0
- package/dist/editor.js +1661 -0
- package/dist/editor.js.map +7 -0
- package/dist/flexdesk.css +5797 -0
- package/dist/host.js +132 -0
- package/dist/host.js.map +7 -0
- package/dist/reset.css +46 -0
- package/dist/tile_registry-6WZPTUZV.js +28 -0
- package/dist/tile_registry-6WZPTUZV.js.map +7 -0
- package/dist/tiles.js +2068 -0
- package/dist/tiles.js.map +7 -0
- package/dist/tokens.css +137 -0
- package/dist/widgets.js +5438 -0
- package/dist/widgets.js.map +7 -0
- package/dist/wm.js +4830 -0
- package/dist/wm.js.map +7 -0
- package/package.json +80 -0
- package/src/charts/chart_types.js +699 -0
- package/src/charts/data_series_plot_window.js +761 -0
- package/src/charts/downsample.js +119 -0
- package/src/charts/plot_config.js +176 -0
- package/src/charts/plot_popout_window.js +1560 -0
- package/src/charts/plotly_wrapper.js +432 -0
- package/src/core/event_bus.js +230 -0
- package/src/core/logging.js +300 -0
- package/src/core/settings.js +813 -0
- package/src/core/state_guard.js +382 -0
- package/src/core/state_guard_config.js +309 -0
- package/src/core/state_machine.js +258 -0
- package/src/editor/editor_pane.js +264 -0
- package/src/editor/monaco_editor_factory.js +449 -0
- package/src/editor/monaco_loader.js +74 -0
- package/src/editor/notebook_search.js +460 -0
- package/src/editor/notebook_tab_bar.js +456 -0
- package/src/editor/notebook_undo_manager.js +108 -0
- package/src/editor/split_pane_container.js +197 -0
- package/src/help/help_modal.js +409 -0
- package/src/help/help_registry.js +65 -0
- package/src/host/host.js +69 -0
- package/src/host/pywebview_host.js +133 -0
- package/src/tiles/config_schema.js +256 -0
- package/src/tiles/layout_persistence.js +662 -0
- package/src/tiles/tile_base.js +785 -0
- package/src/tiles/tile_grid.js +905 -0
- package/src/tiles/tile_registry.js +136 -0
- package/src/tiling/command_palette.js +328 -0
- package/src/tiling/content_registry.js +110 -0
- package/src/tiling/desktops.js +133 -0
- package/src/tiling/entity_sources.js +179 -0
- package/src/tiling/keymap.js +156 -0
- package/src/tiling/kind_taxonomy.js +172 -0
- package/src/tiling/landing_table.js +717 -0
- package/src/tiling/loading_overlay.js +33 -0
- package/src/tiling/nav_panel.js +142 -0
- package/src/tiling/page_factory.js +215 -0
- package/src/tiling/panel_keys.js +145 -0
- package/src/tiling/shell.js +437 -0
- package/src/tiling/tile_breadcrumb.js +222 -0
- package/src/tiling/tile_renderer.js +459 -0
- package/src/tiling/tile_tab_menu.js +340 -0
- package/src/tiling/tile_tree.js +880 -0
- package/src/tiling/wm.js +1352 -0
- package/src/ui/base/component_base.js +30 -0
- package/src/ui/base/controller_base.js +48 -0
- package/src/ui/base/page_base.js +102 -0
- package/src/ui/components/about_dialog.js +224 -0
- package/src/ui/components/action_dropdown.js +389 -0
- package/src/ui/components/attribute_list_editor.js +652 -0
- package/src/ui/components/autocomplete_field.js +732 -0
- package/src/ui/components/computing_status_window.js +81 -0
- package/src/ui/components/confirm_dialog.js +307 -0
- package/src/ui/components/context_menu.js +87 -0
- package/src/ui/components/data_table.js +2228 -0
- package/src/ui/components/detail_header.js +177 -0
- package/src/ui/components/drag_reorder.js +0 -0
- package/src/ui/components/gallery_picker.js +147 -0
- package/src/ui/components/inline_renamer.js +129 -0
- package/src/ui/components/managed_window.js +804 -0
- package/src/ui/components/modal.js +512 -0
- package/src/ui/components/notification_history.js +264 -0
- package/src/ui/components/slide_out_panel.js +185 -0
- package/src/ui/components/slider_field.js +122 -0
- package/src/ui/components/sortable_list.js +309 -0
- package/src/ui/components/table_state_store.js +81 -0
- package/src/ui/components/toast.js +59 -0
- package/src/ui/components/tree_view.js +460 -0
- package/src/ui/components/window_taskbar.js +143 -0
- package/src/ui/controllers/panel_state_machine.js +634 -0
- package/src/ui/controllers/window_chrome_controller.js +659 -0
- package/src/ui/notification_center.js +576 -0
- package/src/ui/utils/overlay_scrollbar.js +492 -0
- package/src/ui/utils/raf_resize_observer.js +31 -0
- package/src/ui/utils/tooltip_service.js +406 -0
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* layout_persistence.js — saved tile layouts, keyed by whatever the app keys them by.
|
|
3
|
+
*
|
|
4
|
+
* Three things used to make this un-shippable, and all three are now injected:
|
|
5
|
+
*
|
|
6
|
+
* 1. It wrote to `localStorage` under 'ecosim.results.layouts' and
|
|
7
|
+
* 'ecosim.results.configs' — a LIBRARY scribbling on one application's storage
|
|
8
|
+
* keys. The keys are the consumer's now, and EcoAgent passes its existing ones,
|
|
9
|
+
* so not a single saved layout is orphaned.
|
|
10
|
+
*
|
|
11
|
+
* 2. It imported EcoAgent's TemplateRegistry to pick a starting layout. That is a
|
|
12
|
+
* `resolveTemplate` callback now: hand it your context, get a layout back, or
|
|
13
|
+
* return null and get an empty grid.
|
|
14
|
+
*
|
|
15
|
+
* 3. Everything was called `scenarioId`, 85 times. A tile layout is keyed by
|
|
16
|
+
* SOMETHING — a scenario, a document, a user, a run — and the library does not
|
|
17
|
+
* get an opinion about which. It is `contextId` now. This is a rename of
|
|
18
|
+
* IDENTIFIERS only: the old name was never a stored string, just a key into the
|
|
19
|
+
* stored object, so no persisted data changes shape.
|
|
20
|
+
*
|
|
21
|
+
* `storage` is any synchronous Storage-shaped object (getItem/setItem/removeItem).
|
|
22
|
+
* It defaults to localStorage because that is what a browser has; it is not the Host,
|
|
23
|
+
* because the Host is async and every caller of this class is not.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const LAYOUT_VERSION = 3;
|
|
27
|
+
|
|
28
|
+
const DEFAULT_LAYOUTS_KEY = 'twm.tiles.layouts';
|
|
29
|
+
const DEFAULT_CONFIGS_KEY = 'twm.tiles.configs';
|
|
30
|
+
|
|
31
|
+
export class LayoutPersistence {
|
|
32
|
+
constructor({
|
|
33
|
+
storage = (typeof localStorage !== 'undefined' ? localStorage : null),
|
|
34
|
+
layoutsKey = DEFAULT_LAYOUTS_KEY,
|
|
35
|
+
configsKey = DEFAULT_CONFIGS_KEY,
|
|
36
|
+
resolveTemplate = null,
|
|
37
|
+
logger = null,
|
|
38
|
+
} = {}) {
|
|
39
|
+
this._cache = null;
|
|
40
|
+
this._storage = storage;
|
|
41
|
+
this._logger = logger;
|
|
42
|
+
this._layoutsKey = layoutsKey;
|
|
43
|
+
this._configsKey = configsKey;
|
|
44
|
+
// (context) => layout | null. No resolver => no starting template.
|
|
45
|
+
this._resolveTemplate = typeof resolveTemplate === 'function' ? resolveTemplate : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get layout for a specific context.
|
|
50
|
+
* Falls back to template resolution when no saved layout exists.
|
|
51
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
52
|
+
* @param {Object} [context] - Passed opaquely to `resolveTemplate`.
|
|
53
|
+
* @returns {Object} Layout object with tiles array
|
|
54
|
+
*/
|
|
55
|
+
getLayout(contextId, context) {
|
|
56
|
+
const all = this._loadAll();
|
|
57
|
+
const layout = all[contextId];
|
|
58
|
+
|
|
59
|
+
if (layout && layout.version === LAYOUT_VERSION) {
|
|
60
|
+
return layout;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Resolve a template based on context metadata
|
|
64
|
+
const instantiated = this._resolveTemplate?.(context) ?? null;
|
|
65
|
+
if (instantiated) {
|
|
66
|
+
return {
|
|
67
|
+
tiles: instantiated.tiles,
|
|
68
|
+
version: LAYOUT_VERSION,
|
|
69
|
+
templateId: instantiated.templateId,
|
|
70
|
+
templateModified: false,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Final fallback: empty layout (should not happen — resolve always returns a default)
|
|
75
|
+
return { tiles: [], version: LAYOUT_VERSION };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Save layout for a specific context.
|
|
80
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
81
|
+
* @param {Array|Object} layout - Layout array or object with tiles property
|
|
82
|
+
*/
|
|
83
|
+
saveLayout(contextId, layout) {
|
|
84
|
+
const all = this._loadAll();
|
|
85
|
+
|
|
86
|
+
// Handle both array (from TileGrid.getLayout) and object (with tiles property) formats
|
|
87
|
+
const tilesArray = Array.isArray(layout) ? layout : (layout?.tiles || []);
|
|
88
|
+
|
|
89
|
+
all[contextId] = {
|
|
90
|
+
tiles: tilesArray,
|
|
91
|
+
version: LAYOUT_VERSION,
|
|
92
|
+
updatedAt: Date.now(),
|
|
93
|
+
templateId: layout?.templateId || all[contextId]?.templateId || null,
|
|
94
|
+
templateModified: layout?.templateModified ?? true,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
this._saveAll(all);
|
|
98
|
+
this._logger?.info?.(`[LayoutPersistence] Saved layout for context ${contextId}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Delete layout for a specific context.
|
|
103
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
104
|
+
*/
|
|
105
|
+
deleteLayout(contextId) {
|
|
106
|
+
const all = this._loadAll();
|
|
107
|
+
|
|
108
|
+
if (all[contextId]) {
|
|
109
|
+
delete all[contextId];
|
|
110
|
+
this._saveAll(all);
|
|
111
|
+
this._logger?.info?.(`[LayoutPersistence] Deleted layout for context ${contextId}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Reset a layout by re-resolving the best-matching template.
|
|
117
|
+
*
|
|
118
|
+
* With no `resolveTemplate` supplied, "reset" means "empty grid" — which is the
|
|
119
|
+
* honest answer for a library that has no templates. The previous code called
|
|
120
|
+
* `TemplateRegistry.instantiate()` unconditionally and read `.tiles` off the
|
|
121
|
+
* result, so a null resolver here would have been a TypeError.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
124
|
+
* @param {Object} [context] - Passed opaquely to `resolveTemplate`.
|
|
125
|
+
* @returns {Object} The resolved layout (empty if nothing resolves).
|
|
126
|
+
*/
|
|
127
|
+
resetLayout(contextId, context) {
|
|
128
|
+
const instantiated = this._resolveTemplate?.(context) ?? null;
|
|
129
|
+
const layout = {
|
|
130
|
+
tiles: instantiated?.tiles ?? [],
|
|
131
|
+
version: LAYOUT_VERSION,
|
|
132
|
+
templateId: instantiated?.templateId ?? null,
|
|
133
|
+
templateModified: false,
|
|
134
|
+
};
|
|
135
|
+
this.saveLayout(contextId, layout);
|
|
136
|
+
return layout;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Check if a context has a saved layout.
|
|
141
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
142
|
+
* @returns {boolean}
|
|
143
|
+
*/
|
|
144
|
+
hasLayout(contextId) {
|
|
145
|
+
const all = this._loadAll();
|
|
146
|
+
return !!all[contextId];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Get all context IDs with saved layouts.
|
|
151
|
+
* @returns {Array<string>}
|
|
152
|
+
*/
|
|
153
|
+
getAllContextIds() {
|
|
154
|
+
const all = this._loadAll();
|
|
155
|
+
return Object.keys(all);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Export all layouts as JSON string.
|
|
160
|
+
* @returns {string}
|
|
161
|
+
*/
|
|
162
|
+
exportAll() {
|
|
163
|
+
const all = this._loadAll();
|
|
164
|
+
return JSON.stringify(all, null, 2);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Import layouts from JSON string.
|
|
169
|
+
* @param {string} json - JSON string of layouts
|
|
170
|
+
* @param {boolean} [merge=false] - Whether to merge with existing or replace
|
|
171
|
+
*/
|
|
172
|
+
importAll(json, merge = false) {
|
|
173
|
+
try {
|
|
174
|
+
const imported = JSON.parse(json);
|
|
175
|
+
|
|
176
|
+
if (merge) {
|
|
177
|
+
const existing = this._loadAll();
|
|
178
|
+
this._saveAll({ ...existing, ...imported });
|
|
179
|
+
} else {
|
|
180
|
+
this._saveAll(imported);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
this._cache = null;
|
|
184
|
+
this._logger?.info?.('[LayoutPersistence] Imported layouts');
|
|
185
|
+
} catch (err) {
|
|
186
|
+
console.error('[LayoutPersistence] Failed to import layouts:', err);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Clear all saved layouts.
|
|
192
|
+
*/
|
|
193
|
+
clearAll() {
|
|
194
|
+
try {
|
|
195
|
+
this._storage?.removeItem(this._layoutsKey);
|
|
196
|
+
this._cache = null;
|
|
197
|
+
this._logger?.info?.('[LayoutPersistence] Cleared all layouts');
|
|
198
|
+
} catch (err) {
|
|
199
|
+
console.error('[LayoutPersistence] Failed to clear layouts:', err);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Load all layouts from storage.
|
|
205
|
+
* @returns {Object}
|
|
206
|
+
* @private
|
|
207
|
+
*/
|
|
208
|
+
_loadAll() {
|
|
209
|
+
if (this._cache) {
|
|
210
|
+
return this._cache;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const raw = this._storage?.getItem(this._layoutsKey);
|
|
215
|
+
if (raw) {
|
|
216
|
+
this._cache = JSON.parse(raw);
|
|
217
|
+
return this._cache;
|
|
218
|
+
}
|
|
219
|
+
} catch (err) {
|
|
220
|
+
console.error('[LayoutPersistence] Failed to load layouts:', err);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
this._cache = {};
|
|
224
|
+
return this._cache;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Save all layouts to storage.
|
|
229
|
+
* @param {Object} data
|
|
230
|
+
* @private
|
|
231
|
+
*/
|
|
232
|
+
_saveAll(data) {
|
|
233
|
+
try {
|
|
234
|
+
this._storage?.setItem(this._layoutsKey, JSON.stringify(data));
|
|
235
|
+
this._cache = data;
|
|
236
|
+
} catch (err) {
|
|
237
|
+
console.error('[LayoutPersistence] Failed to save layouts:', err);
|
|
238
|
+
|
|
239
|
+
// If quota exceeded, try to clear old entries
|
|
240
|
+
if (err.name === 'QuotaExceededError') {
|
|
241
|
+
this._pruneOldLayouts(data);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Remove oldest layouts to free up space.
|
|
248
|
+
* @param {Object} data
|
|
249
|
+
* @private
|
|
250
|
+
*/
|
|
251
|
+
_pruneOldLayouts(data) {
|
|
252
|
+
const entries = Object.entries(data);
|
|
253
|
+
|
|
254
|
+
// Sort by updatedAt, oldest first
|
|
255
|
+
entries.sort((a, b) => (a[1].updatedAt || 0) - (b[1].updatedAt || 0));
|
|
256
|
+
|
|
257
|
+
// Remove oldest 25%
|
|
258
|
+
const removeCount = Math.ceil(entries.length * 0.25);
|
|
259
|
+
for (let i = 0; i < removeCount; i++) {
|
|
260
|
+
delete data[entries[i][0]];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Try saving again
|
|
264
|
+
try {
|
|
265
|
+
this._storage?.setItem(this._layoutsKey, JSON.stringify(data));
|
|
266
|
+
this._cache = data;
|
|
267
|
+
this._logger?.warn?.('[LayoutPersistence] Pruned old layouts due to quota');
|
|
268
|
+
} catch (err) {
|
|
269
|
+
console.error('[LayoutPersistence] Still cannot save after pruning:', err);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Get the template ID associated with a saved layout.
|
|
275
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
276
|
+
* @returns {string|null} Template ID or null
|
|
277
|
+
*/
|
|
278
|
+
getTemplateId(contextId) {
|
|
279
|
+
const all = this._loadAll();
|
|
280
|
+
return all[contextId]?.templateId || null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Check if the user has modified the layout since the template was applied.
|
|
285
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
286
|
+
* @returns {boolean}
|
|
287
|
+
*/
|
|
288
|
+
isTemplateModified(contextId) {
|
|
289
|
+
const all = this._loadAll();
|
|
290
|
+
return all[contextId]?.templateModified ?? false;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// =========================================================================
|
|
294
|
+
// NAMED CONFIGURATION MANAGEMENT
|
|
295
|
+
// =========================================================================
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Get all saved configurations for a context.
|
|
299
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
300
|
+
* @returns {Object} { activeId: string, configs: { id: { name, tiles, createdAt } } }
|
|
301
|
+
*/
|
|
302
|
+
getConfigurations(contextId) {
|
|
303
|
+
const all = this._loadConfigs();
|
|
304
|
+
const contextConfigs = all[contextId];
|
|
305
|
+
|
|
306
|
+
if (contextConfigs && Object.keys(contextConfigs.configs || {}).length > 0) {
|
|
307
|
+
return contextConfigs;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Return empty structure
|
|
311
|
+
return {
|
|
312
|
+
activeId: null,
|
|
313
|
+
configs: {}
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Save the current layout as a named configuration.
|
|
319
|
+
* Names are enforced to be unique - duplicates get "(2)", "(3)", etc. suffix.
|
|
320
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
321
|
+
* @param {string} name - Configuration name
|
|
322
|
+
* @param {Array|Object} layout - Layout to save
|
|
323
|
+
* @param {Object} [options] - Additional options
|
|
324
|
+
* @param {string} [options.templateId] - Template ID that generated this layout
|
|
325
|
+
* @returns {string} The new configuration ID
|
|
326
|
+
*/
|
|
327
|
+
saveConfiguration(contextId, name, layout, options) {
|
|
328
|
+
const all = this._loadConfigs();
|
|
329
|
+
|
|
330
|
+
if (!all[contextId]) {
|
|
331
|
+
all[contextId] = { activeId: null, configs: {} };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Ensure unique name
|
|
335
|
+
const uniqueName = this._getUniqueName(all[contextId].configs, name);
|
|
336
|
+
|
|
337
|
+
const configId = `config-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
338
|
+
const tilesArray = Array.isArray(layout) ? layout : (layout?.tiles || []);
|
|
339
|
+
|
|
340
|
+
all[contextId].configs[configId] = {
|
|
341
|
+
name: uniqueName,
|
|
342
|
+
tiles: tilesArray,
|
|
343
|
+
version: LAYOUT_VERSION,
|
|
344
|
+
createdAt: Date.now(),
|
|
345
|
+
templateId: options?.templateId || null,
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// Set as active if it's the first one
|
|
349
|
+
if (!all[contextId].activeId) {
|
|
350
|
+
all[contextId].activeId = configId;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
this._saveConfigs(all);
|
|
354
|
+
this._logger?.info?.(`[LayoutPersistence] Saved configuration "${uniqueName}" for context ${contextId}`);
|
|
355
|
+
|
|
356
|
+
return configId;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Generate a unique name by appending (2), (3), etc. if name already exists.
|
|
361
|
+
* @param {Object} configs - Existing configurations object
|
|
362
|
+
* @param {string} name - Desired name
|
|
363
|
+
* @returns {string} Unique name
|
|
364
|
+
* @private
|
|
365
|
+
*/
|
|
366
|
+
_getUniqueName(configs, name) {
|
|
367
|
+
if (!configs) return name;
|
|
368
|
+
|
|
369
|
+
const existingNames = new Set(Object.values(configs).map(c => c.name));
|
|
370
|
+
|
|
371
|
+
if (!existingNames.has(name)) {
|
|
372
|
+
return name;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Find next available number
|
|
376
|
+
let counter = 2;
|
|
377
|
+
let candidate = `${name} (${counter})`;
|
|
378
|
+
while (existingNames.has(candidate)) {
|
|
379
|
+
counter++;
|
|
380
|
+
candidate = `${name} (${counter})`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return candidate;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Load a specific configuration.
|
|
388
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
389
|
+
* @param {string} configId - Configuration ID
|
|
390
|
+
* @returns {Object|null} Configuration layout or null if not found
|
|
391
|
+
*/
|
|
392
|
+
loadConfiguration(contextId, configId) {
|
|
393
|
+
const all = this._loadConfigs();
|
|
394
|
+
const config = all[contextId]?.configs?.[configId];
|
|
395
|
+
|
|
396
|
+
if (config) {
|
|
397
|
+
// Update active config
|
|
398
|
+
all[contextId].activeId = configId;
|
|
399
|
+
this._saveConfigs(all);
|
|
400
|
+
return config;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Delete a saved configuration.
|
|
408
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
409
|
+
* @param {string} configId - Configuration ID
|
|
410
|
+
* @returns {boolean} True if deleted, false if not found
|
|
411
|
+
*/
|
|
412
|
+
deleteConfiguration(contextId, configId) {
|
|
413
|
+
const all = this._loadConfigs();
|
|
414
|
+
|
|
415
|
+
if (!all[contextId]?.configs?.[configId]) {
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
delete all[contextId].configs[configId];
|
|
420
|
+
|
|
421
|
+
// Update active if deleted
|
|
422
|
+
if (all[contextId].activeId === configId) {
|
|
423
|
+
const remaining = Object.keys(all[contextId].configs);
|
|
424
|
+
all[contextId].activeId = remaining.length > 0 ? remaining[0] : null;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
this._saveConfigs(all);
|
|
428
|
+
this._logger?.info?.(`[LayoutPersistence] Deleted configuration ${configId} for context ${contextId}`);
|
|
429
|
+
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Update an existing configuration's layout (without creating a new ID).
|
|
435
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
436
|
+
* @param {string} configId - Configuration ID
|
|
437
|
+
* @param {Array|Object} layout - New layout to save
|
|
438
|
+
* @returns {boolean} True if updated, false if not found
|
|
439
|
+
*/
|
|
440
|
+
updateConfiguration(contextId, configId, layout) {
|
|
441
|
+
const all = this._loadConfigs();
|
|
442
|
+
|
|
443
|
+
if (!all[contextId]?.configs?.[configId]) {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const tilesArray = Array.isArray(layout) ? layout : (layout?.tiles || []);
|
|
448
|
+
|
|
449
|
+
all[contextId].configs[configId].tiles = tilesArray;
|
|
450
|
+
all[contextId].configs[configId].version = LAYOUT_VERSION;
|
|
451
|
+
all[contextId].configs[configId].updatedAt = Date.now();
|
|
452
|
+
|
|
453
|
+
this._saveConfigs(all);
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Update the templateId on an existing configuration.
|
|
459
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
460
|
+
* @param {string} configId - Configuration ID
|
|
461
|
+
* @param {string} templateId - New template ID
|
|
462
|
+
* @returns {boolean} True if updated, false if not found
|
|
463
|
+
*/
|
|
464
|
+
updateConfigurationTemplateId(contextId, configId, templateId) {
|
|
465
|
+
const all = this._loadConfigs();
|
|
466
|
+
|
|
467
|
+
if (!all[contextId]?.configs?.[configId]) {
|
|
468
|
+
return false;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
all[contextId].configs[configId].templateId = templateId;
|
|
472
|
+
this._saveConfigs(all);
|
|
473
|
+
return true;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Rename a configuration.
|
|
478
|
+
* Names are enforced to be unique - duplicates get "(2)", "(3)", etc. suffix.
|
|
479
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
480
|
+
* @param {string} configId - Configuration ID
|
|
481
|
+
* @param {string} newName - New name
|
|
482
|
+
* @returns {string|false} The actual name used (may differ if duplicate), or false if not found
|
|
483
|
+
*/
|
|
484
|
+
renameConfiguration(contextId, configId, newName) {
|
|
485
|
+
const all = this._loadConfigs();
|
|
486
|
+
|
|
487
|
+
if (!all[contextId]?.configs?.[configId]) {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Get configs excluding the one being renamed for uniqueness check
|
|
492
|
+
const otherConfigs = {};
|
|
493
|
+
Object.entries(all[contextId].configs).forEach(([id, config]) => {
|
|
494
|
+
if (id !== configId) {
|
|
495
|
+
otherConfigs[id] = config;
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
const uniqueName = this._getUniqueName(otherConfigs, newName);
|
|
500
|
+
all[contextId].configs[configId].name = uniqueName;
|
|
501
|
+
this._saveConfigs(all);
|
|
502
|
+
|
|
503
|
+
return uniqueName;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Get the active configuration for a context.
|
|
508
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
509
|
+
* @returns {Object|null} { id, name, tiles } or null
|
|
510
|
+
*/
|
|
511
|
+
getActiveConfiguration(contextId) {
|
|
512
|
+
const all = this._loadConfigs();
|
|
513
|
+
const contextConfigs = all[contextId];
|
|
514
|
+
|
|
515
|
+
if (!contextConfigs?.activeId) {
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const activeConfig = contextConfigs.configs[contextConfigs.activeId];
|
|
520
|
+
if (activeConfig) {
|
|
521
|
+
return {
|
|
522
|
+
id: contextConfigs.activeId,
|
|
523
|
+
...activeConfig
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Set the active configuration.
|
|
532
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
533
|
+
* @param {string} configId - Configuration ID
|
|
534
|
+
*/
|
|
535
|
+
setActiveConfiguration(contextId, configId) {
|
|
536
|
+
const all = this._loadConfigs();
|
|
537
|
+
|
|
538
|
+
if (all[contextId]?.configs?.[configId]) {
|
|
539
|
+
all[contextId].activeId = configId;
|
|
540
|
+
this._saveConfigs(all);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* List all configurations for a context.
|
|
546
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
547
|
+
* @returns {Array} Array of { id, name, createdAt, isActive }
|
|
548
|
+
*/
|
|
549
|
+
listConfigurations(contextId) {
|
|
550
|
+
const all = this._loadConfigs();
|
|
551
|
+
const contextConfigs = all[contextId];
|
|
552
|
+
|
|
553
|
+
if (!contextConfigs?.configs) {
|
|
554
|
+
return [];
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return Object.entries(contextConfigs.configs).map(([id, config]) => ({
|
|
558
|
+
id,
|
|
559
|
+
name: config.name,
|
|
560
|
+
createdAt: config.createdAt,
|
|
561
|
+
isActive: id === contextConfigs.activeId
|
|
562
|
+
})).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Get the template set fingerprint for a context.
|
|
567
|
+
* Used to detect when templates have changed and configs need regeneration.
|
|
568
|
+
* @param {string} contextId
|
|
569
|
+
* @returns {string|null}
|
|
570
|
+
*/
|
|
571
|
+
getTemplateSetId(contextId) {
|
|
572
|
+
const all = this._loadConfigs();
|
|
573
|
+
return all[contextId]?.templateSetId || null;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Store the template set fingerprint for a context.
|
|
578
|
+
* @param {string} contextId
|
|
579
|
+
* @param {string} templateSetId
|
|
580
|
+
*/
|
|
581
|
+
setTemplateSetId(contextId, templateSetId) {
|
|
582
|
+
const all = this._loadConfigs();
|
|
583
|
+
if (!all[contextId]) {
|
|
584
|
+
all[contextId] = { activeId: null, configs: {} };
|
|
585
|
+
}
|
|
586
|
+
all[contextId].templateSetId = templateSetId;
|
|
587
|
+
this._saveConfigs(all);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Delete ALL configurations for a context.
|
|
592
|
+
* @param {string} contextId
|
|
593
|
+
*/
|
|
594
|
+
clearConfigurations(contextId) {
|
|
595
|
+
const all = this._loadConfigs();
|
|
596
|
+
delete all[contextId];
|
|
597
|
+
this._saveConfigs(all);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Load all configurations from storage.
|
|
602
|
+
* @returns {Object}
|
|
603
|
+
* @private
|
|
604
|
+
*/
|
|
605
|
+
_loadConfigs() {
|
|
606
|
+
try {
|
|
607
|
+
const raw = this._storage?.getItem(this._configsKey);
|
|
608
|
+
if (raw) {
|
|
609
|
+
return JSON.parse(raw);
|
|
610
|
+
}
|
|
611
|
+
} catch (err) {
|
|
612
|
+
console.error('[LayoutPersistence] Failed to load configurations:', err);
|
|
613
|
+
}
|
|
614
|
+
return {};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Save all configurations to storage.
|
|
619
|
+
* @param {Object} data
|
|
620
|
+
* @private
|
|
621
|
+
*/
|
|
622
|
+
_saveConfigs(data) {
|
|
623
|
+
try {
|
|
624
|
+
this._storage?.setItem(this._configsKey, JSON.stringify(data));
|
|
625
|
+
} catch (err) {
|
|
626
|
+
console.error('[LayoutPersistence] Failed to save configurations:', err);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Singleton instance
|
|
632
|
+
let instance = null;
|
|
633
|
+
let _options = {};
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Configure the singleton BEFORE anything reads it. Call once, at boot.
|
|
637
|
+
*
|
|
638
|
+
* The first cut let `getLayoutPersistence(options)` configure it lazily — whoever
|
|
639
|
+
* called first won. That is a trap with teeth: if ANY code path had reached the
|
|
640
|
+
* store before the dashboard did, the singleton would have been built with the
|
|
641
|
+
* library's generic defaults, and the app would have read
|
|
642
|
+
* 'twm.tiles.layouts' instead of 'ecosim.results.layouts' — an empty store. Every
|
|
643
|
+
* saved dashboard a user had ever built would have silently vanished, with no
|
|
644
|
+
* error, and no captured view would show it because the pixel gate runs on a
|
|
645
|
+
* fresh template that has no saved layouts to lose.
|
|
646
|
+
*
|
|
647
|
+
* Configure once, at boot, where the ordering is not in question.
|
|
648
|
+
*/
|
|
649
|
+
export function configureLayoutPersistence(options = {}) {
|
|
650
|
+
_options = options;
|
|
651
|
+
instance = null; // rebuild on next get
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** @returns {LayoutPersistence} */
|
|
655
|
+
export function getLayoutPersistence() {
|
|
656
|
+
if (!instance) {
|
|
657
|
+
instance = new LayoutPersistence(_options);
|
|
658
|
+
}
|
|
659
|
+
return instance;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
export default LayoutPersistence;
|