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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -0
  3. package/THIRD_PARTY_LICENSES.md +53 -0
  4. package/css/base.css +3439 -0
  5. package/css/flexdesk.css +5797 -0
  6. package/css/overrides.css +2349 -0
  7. package/css/reset.css +46 -0
  8. package/css/tokens.css +137 -0
  9. package/dist/charts.js +780 -0
  10. package/dist/charts.js.map +7 -0
  11. package/dist/chunk-3PHCPZHT.js +222 -0
  12. package/dist/chunk-3PHCPZHT.js.map +7 -0
  13. package/dist/chunk-CT4YXXLP.js +1770 -0
  14. package/dist/chunk-CT4YXXLP.js.map +7 -0
  15. package/dist/chunk-DRYCDMEG.js +2217 -0
  16. package/dist/chunk-DRYCDMEG.js.map +7 -0
  17. package/dist/chunk-DVU44T77.js +855 -0
  18. package/dist/chunk-DVU44T77.js.map +7 -0
  19. package/dist/chunk-FL5KFNQH.js +640 -0
  20. package/dist/chunk-FL5KFNQH.js.map +7 -0
  21. package/dist/chunk-FOOS3T5L.js +117 -0
  22. package/dist/chunk-FOOS3T5L.js.map +7 -0
  23. package/dist/chunk-JYWURG5T.js +32 -0
  24. package/dist/chunk-JYWURG5T.js.map +7 -0
  25. package/dist/chunk-TLZUUFOE.js +276 -0
  26. package/dist/chunk-TLZUUFOE.js.map +7 -0
  27. package/dist/chunk-UCJ2WD4D.js +625 -0
  28. package/dist/chunk-UCJ2WD4D.js.map +7 -0
  29. package/dist/chunk-WVFGV5FT.js +205 -0
  30. package/dist/chunk-WVFGV5FT.js.map +7 -0
  31. package/dist/core.js +1098 -0
  32. package/dist/core.js.map +7 -0
  33. package/dist/editor.js +1661 -0
  34. package/dist/editor.js.map +7 -0
  35. package/dist/flexdesk.css +5797 -0
  36. package/dist/host.js +132 -0
  37. package/dist/host.js.map +7 -0
  38. package/dist/reset.css +46 -0
  39. package/dist/tile_registry-6WZPTUZV.js +28 -0
  40. package/dist/tile_registry-6WZPTUZV.js.map +7 -0
  41. package/dist/tiles.js +2068 -0
  42. package/dist/tiles.js.map +7 -0
  43. package/dist/tokens.css +137 -0
  44. package/dist/widgets.js +5438 -0
  45. package/dist/widgets.js.map +7 -0
  46. package/dist/wm.js +4830 -0
  47. package/dist/wm.js.map +7 -0
  48. package/package.json +80 -0
  49. package/src/charts/chart_types.js +699 -0
  50. package/src/charts/data_series_plot_window.js +761 -0
  51. package/src/charts/downsample.js +119 -0
  52. package/src/charts/plot_config.js +176 -0
  53. package/src/charts/plot_popout_window.js +1560 -0
  54. package/src/charts/plotly_wrapper.js +432 -0
  55. package/src/core/event_bus.js +230 -0
  56. package/src/core/logging.js +300 -0
  57. package/src/core/settings.js +813 -0
  58. package/src/core/state_guard.js +382 -0
  59. package/src/core/state_guard_config.js +309 -0
  60. package/src/core/state_machine.js +258 -0
  61. package/src/editor/editor_pane.js +264 -0
  62. package/src/editor/monaco_editor_factory.js +449 -0
  63. package/src/editor/monaco_loader.js +74 -0
  64. package/src/editor/notebook_search.js +460 -0
  65. package/src/editor/notebook_tab_bar.js +456 -0
  66. package/src/editor/notebook_undo_manager.js +108 -0
  67. package/src/editor/split_pane_container.js +197 -0
  68. package/src/help/help_modal.js +409 -0
  69. package/src/help/help_registry.js +65 -0
  70. package/src/host/host.js +69 -0
  71. package/src/host/pywebview_host.js +133 -0
  72. package/src/tiles/config_schema.js +256 -0
  73. package/src/tiles/layout_persistence.js +662 -0
  74. package/src/tiles/tile_base.js +785 -0
  75. package/src/tiles/tile_grid.js +905 -0
  76. package/src/tiles/tile_registry.js +136 -0
  77. package/src/tiling/command_palette.js +328 -0
  78. package/src/tiling/content_registry.js +110 -0
  79. package/src/tiling/desktops.js +133 -0
  80. package/src/tiling/entity_sources.js +179 -0
  81. package/src/tiling/keymap.js +156 -0
  82. package/src/tiling/kind_taxonomy.js +172 -0
  83. package/src/tiling/landing_table.js +717 -0
  84. package/src/tiling/loading_overlay.js +33 -0
  85. package/src/tiling/nav_panel.js +142 -0
  86. package/src/tiling/page_factory.js +215 -0
  87. package/src/tiling/panel_keys.js +145 -0
  88. package/src/tiling/shell.js +437 -0
  89. package/src/tiling/tile_breadcrumb.js +222 -0
  90. package/src/tiling/tile_renderer.js +459 -0
  91. package/src/tiling/tile_tab_menu.js +340 -0
  92. package/src/tiling/tile_tree.js +880 -0
  93. package/src/tiling/wm.js +1352 -0
  94. package/src/ui/base/component_base.js +30 -0
  95. package/src/ui/base/controller_base.js +48 -0
  96. package/src/ui/base/page_base.js +102 -0
  97. package/src/ui/components/about_dialog.js +224 -0
  98. package/src/ui/components/action_dropdown.js +389 -0
  99. package/src/ui/components/attribute_list_editor.js +652 -0
  100. package/src/ui/components/autocomplete_field.js +732 -0
  101. package/src/ui/components/computing_status_window.js +81 -0
  102. package/src/ui/components/confirm_dialog.js +307 -0
  103. package/src/ui/components/context_menu.js +87 -0
  104. package/src/ui/components/data_table.js +2228 -0
  105. package/src/ui/components/detail_header.js +177 -0
  106. package/src/ui/components/drag_reorder.js +0 -0
  107. package/src/ui/components/gallery_picker.js +147 -0
  108. package/src/ui/components/inline_renamer.js +129 -0
  109. package/src/ui/components/managed_window.js +804 -0
  110. package/src/ui/components/modal.js +512 -0
  111. package/src/ui/components/notification_history.js +264 -0
  112. package/src/ui/components/slide_out_panel.js +185 -0
  113. package/src/ui/components/slider_field.js +122 -0
  114. package/src/ui/components/sortable_list.js +309 -0
  115. package/src/ui/components/table_state_store.js +81 -0
  116. package/src/ui/components/toast.js +59 -0
  117. package/src/ui/components/tree_view.js +460 -0
  118. package/src/ui/components/window_taskbar.js +143 -0
  119. package/src/ui/controllers/panel_state_machine.js +634 -0
  120. package/src/ui/controllers/window_chrome_controller.js +659 -0
  121. package/src/ui/notification_center.js +576 -0
  122. package/src/ui/utils/overlay_scrollbar.js +492 -0
  123. package/src/ui/utils/raf_resize_observer.js +31 -0
  124. package/src/ui/utils/tooltip_service.js +406 -0
@@ -0,0 +1,456 @@
1
+ /**
2
+ * NotebookTabBar — tab strip showing open files, with dirty indicators,
3
+ * close buttons, drag-and-drop reordering, double-click rename, and
4
+ * right-click context menu.
5
+ *
6
+ * Uses the same `.tab` / `.tab-title` / `.tab-dirty` / `.tab-close` DOM structure
7
+ * as the flow-page tab bar, so all editor tab bars share a unified appearance.
8
+ *
9
+ * Calls back into parent via:
10
+ * onActivate(filePath)
11
+ * onClose(filePath)
12
+ * onCloseOthers(filePath)
13
+ * onCloseToRight(filePath)
14
+ * onCloseAll()
15
+ * onCloseSaved()
16
+ * onRename(oldPath, newPath)
17
+ * onDuplicate(filePath)
18
+ * onReorder(orderedPaths)
19
+ * onRevealInExplorer(filePath)
20
+ * onSplitRight(filePath)
21
+ * onMoveToOtherPane(filePath)
22
+ * onDropFromOtherPane(filePath) — cross-pane drag-and-drop
23
+ */
24
+
25
+ import { DragReorder } from '../ui/components/drag_reorder.js';
26
+
27
+ /** Custom MIME type so tab bars recognise cross-pane drags. */
28
+ const TAB_MIME = 'application/x-ecosim-tab';
29
+
30
+ export class NotebookTabBar {
31
+ /** @type {HTMLElement} */
32
+ #container = null;
33
+
34
+ /** @type {object[]} [{filePath, label, fileType, isDirty}] */
35
+ #tabs = [];
36
+
37
+ /** @type {string|null} */
38
+ #activeTab = null;
39
+
40
+ /** @type {object} callbacks */
41
+ #callbacks = {};
42
+
43
+ // ─── Context menu ────────────────────────────────────────────────────────
44
+
45
+ /** @type {HTMLElement|null} */
46
+ #contextMenuEl = null;
47
+
48
+ /** @type {Function|null} */
49
+ #boundHideContextMenu = null;
50
+
51
+ // ─── Drag and drop ───────────────────────────────────────────────────────
52
+
53
+ /** Same-pane reorder. Cross-pane drops stay in this class's own
54
+ * container handlers (#onStripDrag*).
55
+ * @type {DragReorder|null} */
56
+ #dragReorder = null;
57
+
58
+ mount(container, callbacks = {}) {
59
+ this.#container = container;
60
+ this.#callbacks = callbacks;
61
+ this.#container.className = 'tabs notebook-tabs';
62
+ this.#boundHideContextMenu = () => this.#hideContextMenu();
63
+ this.#installStripDnDHandlers();
64
+ this.#dragReorder = new DragReorder({
65
+ container: this.#container,
66
+ itemSelector: '.tab',
67
+ keyAttr: 'path',
68
+ axis: 'x',
69
+ draggableGuard: (e) => {
70
+ if (e.target.closest('.twm-tab-close')) return false;
71
+ const title = e.target.closest('.tab')?.querySelector('.twm-tab-title');
72
+ return title?.getAttribute('contenteditable') !== 'true';
73
+ },
74
+ onDragStart: (e, key) => {
75
+ // Carry the cross-pane MIME so other panes accept this tab.
76
+ try { e.dataTransfer.setData(TAB_MIME, key); } catch (_) {}
77
+ },
78
+ onReorder: (order) => this.#callbacks.onReorder?.(order),
79
+ });
80
+ this.#render();
81
+ }
82
+
83
+ /**
84
+ * Sync tabs from ProjectModel state.
85
+ * @param {Array<{ filePath: string, fileType: string, isDirty: boolean }>} tabs
86
+ * @param {string|null} activeFilePath
87
+ */
88
+ update(tabs, activeFilePath) {
89
+ this.#tabs = tabs;
90
+ this.#activeTab = activeFilePath;
91
+ this.#render();
92
+ }
93
+
94
+ markDirty(filePath, isDirty) {
95
+ const tab = this.#tabs.find(t => t.filePath === filePath);
96
+ if (tab) {
97
+ tab.isDirty = isDirty;
98
+ this.#render();
99
+ }
100
+ }
101
+
102
+ dispose() {
103
+ this.#hideContextMenu();
104
+ this.#removeStripDnDHandlers();
105
+ this.#dragReorder?.destroy();
106
+ this.#dragReorder = null;
107
+ this.#container = null;
108
+ }
109
+
110
+ // Material symbol per file kind, merged over the generic set. The map used to
111
+ // hardcode EcoAgent's kinds — `scenario`, `dashboard`, `model` — so a generic
112
+ // tab bar knew one application's file taxonomy. Pass `fileTypeIcons` to add
113
+ // your own.
114
+ static #FILE_TYPE_ICONS = {
115
+ module: 'extension',
116
+ test: 'fact_check',
117
+ diff: 'difference',
118
+ unknown: 'description',
119
+ };
120
+
121
+ static #EXTRA_ICONS = {};
122
+
123
+ /**
124
+ * Add your application's file kinds to the tab-bar icon map.
125
+ *
126
+ * The map used to hardcode `scenario: 'science'`, `dashboard: 'dashboard'` and
127
+ * `model: 'schema'` — EcoAgent's file taxonomy, baked into a generic tab bar.
128
+ *
129
+ * @param {Object.<string, string>} icons fileType -> material symbol name
130
+ */
131
+ static setFileTypeIcons(icons = {}) {
132
+ NotebookTabBar.#EXTRA_ICONS = { ...icons };
133
+ }
134
+
135
+ static #iconFor(fileType) {
136
+ return NotebookTabBar.#EXTRA_ICONS[fileType]
137
+ ?? NotebookTabBar.#FILE_TYPE_ICONS[fileType]
138
+ ?? NotebookTabBar.#FILE_TYPE_ICONS.unknown;
139
+ }
140
+
141
+ // ═════════════════════════════════════════════════════════════════════════
142
+ // Rendering
143
+ // ═════════════════════════════════════════════════════════════════════════
144
+
145
+ #render() {
146
+ if (!this.#container) return;
147
+
148
+ this.#container.innerHTML = '';
149
+
150
+ for (const tab of this.#tabs) {
151
+ const isActive = tab.filePath === this.#activeTab;
152
+ const rawPath = tab.filePath.replace(/^(diff|builtin):\/\//, '');
153
+ const label = tab.label
154
+ ?? (tab.fileType === 'diff'
155
+ ? `↔ ${rawPath.split('/').pop()}`
156
+ : rawPath.split('/').pop().replace(/\.[^.]+$/, ''));
157
+ const icon = NotebookTabBar.#iconFor(tab.fileType);
158
+
159
+ const tabEl = document.createElement('div');
160
+ tabEl.className = `tab${isActive ? ' active' : ''}${tab.isPreview ? ' tab--preview' : ''}`;
161
+ tabEl.dataset.path = tab.filePath;
162
+ tabEl.title = tab.filePath;
163
+
164
+ tabEl.innerHTML = `
165
+ <span class="material-symbols-outlined tab-icon">${icon}</span>
166
+ <span class="twm-tab-title">${this.#escHtml(label)}</span>
167
+ ${tab.isDirty ? '<span class="tab-dirty">\u25cf</span>' : ''}
168
+ ${tab.readonly ? '' : '<button class="twm-tab-close" title="Close tab" aria-label="Close tab">\u00d7</button>'}
169
+ `;
170
+
171
+ // Click → activate
172
+ tabEl.addEventListener('click', (e) => {
173
+ if (e.target.closest('.twm-tab-close')) return;
174
+ if (tabEl.querySelector('.twm-tab-title')?.getAttribute('contenteditable') === 'true') return;
175
+ this.#callbacks.onActivate?.(tab.filePath);
176
+ });
177
+
178
+ // Close button
179
+ tabEl.querySelector('.twm-tab-close')?.addEventListener('click', (e) => {
180
+ e.stopPropagation();
181
+ this.#callbacks.onClose?.(tab.filePath);
182
+ });
183
+
184
+ // Double-click → inline rename
185
+ tabEl.addEventListener('dblclick', (e) => {
186
+ if (e.target.closest('.twm-tab-close')) return;
187
+ this.#beginInlineRename(tab.filePath);
188
+ });
189
+
190
+ // F2 → inline rename
191
+ tabEl.addEventListener('keydown', (e) => {
192
+ if (e.key === 'F2') {
193
+ e.preventDefault();
194
+ this.#beginInlineRename(tab.filePath);
195
+ }
196
+ });
197
+
198
+ // Context menu
199
+ tabEl.addEventListener('contextmenu', (e) => {
200
+ e.preventDefault();
201
+ e.stopPropagation();
202
+ this.#showContextMenu(e.clientX, e.clientY, tab.filePath);
203
+ });
204
+
205
+ this.#container.appendChild(tabEl);
206
+ }
207
+ // Make the freshly-rendered tabs draggable-to-reorder.
208
+ this.#dragReorder?.attach();
209
+ }
210
+
211
+ // ═════════════════════════════════════════════════════════════════════════
212
+ // Drag and drop reordering (same-pane + cross-pane)
213
+ // ═════════════════════════════════════════════════════════════════════════
214
+
215
+ #installStripDnDHandlers() {
216
+ if (!this.#container) return;
217
+ this.#container.addEventListener('dragover', this.#onStripDragOver);
218
+ this.#container.addEventListener('drop', this.#onStripDrop);
219
+ this.#container.addEventListener('dragleave', this.#onStripDragLeave);
220
+ }
221
+
222
+ #removeStripDnDHandlers() {
223
+ if (!this.#container) return;
224
+ this.#container.removeEventListener('dragover', this.#onStripDragOver);
225
+ this.#container.removeEventListener('drop', this.#onStripDrop);
226
+ this.#container.removeEventListener('dragleave', this.#onStripDragLeave);
227
+ }
228
+
229
+ /** Check whether a drag event carries a cross-pane tab (not from this bar). */
230
+ #isExternalTabDrag(e) {
231
+ if (this.#dragReorder?.isActive()) return false; // same-pane drag is active
232
+ try { return e.dataTransfer.types.includes(TAB_MIME); } catch { return false; }
233
+ }
234
+
235
+ // Same-pane reorder is handled by the shared DragReorder module (set
236
+ // up in mount()). These container handlers cover ONLY cross-pane tab
237
+ // drops — gated on `#isExternalTabDrag`, which defers while our own
238
+ // reorder drag is active.
239
+
240
+ #onStripDragOver = (e) => {
241
+ if (this.#isExternalTabDrag(e)) {
242
+ e.preventDefault();
243
+ try { e.dataTransfer.dropEffect = 'move'; } catch (_) {}
244
+ this.#container.classList.add('notebook-tabs--drop-target');
245
+ }
246
+ };
247
+
248
+ #onStripDrop = (e) => {
249
+ if (this.#isExternalTabDrag(e)) {
250
+ e.preventDefault();
251
+ this.#container.classList.remove('notebook-tabs--drop-target');
252
+ const filePath = e.dataTransfer.getData(TAB_MIME) || e.dataTransfer.getData('text/plain');
253
+ if (filePath) {
254
+ this.#callbacks.onDropFromOtherPane?.(filePath);
255
+ }
256
+ }
257
+ };
258
+
259
+ #onStripDragLeave = (e) => {
260
+ if (!this.#container.contains(e.relatedTarget)) {
261
+ this.#container.classList.remove('notebook-tabs--drop-target');
262
+ }
263
+ };
264
+
265
+ // ═════════════════════════════════════════════════════════════════════════
266
+ // Context menu
267
+ // ═════════════════════════════════════════════════════════════════════════
268
+
269
+ #showContextMenu(x, y, filePath) {
270
+ this.#hideContextMenu();
271
+
272
+ const idx = this.#tabs.findIndex(t => t.filePath === filePath);
273
+ const total = this.#tabs.length;
274
+
275
+ const allItems = [
276
+ { label: 'Rename', icon: 'edit', action: 'rename', shortcut: 'F2' },
277
+ { label: 'Duplicate', icon: 'content_copy', action: 'duplicate' },
278
+ { separator: true },
279
+ { label: 'Split Right', icon: 'vertical_split', action: 'split-right', shortcut: 'Ctrl+\\' },
280
+ { label: 'Move to Other Pane',icon: 'swap_horiz', action: 'move-to-other' },
281
+ { separator: true },
282
+ { label: 'Close', icon: 'close', action: 'close', danger: true },
283
+ { label: 'Close Others', icon: 'close_fullscreen', action: 'close-others', danger: true, disabled: total <= 1 },
284
+ { label: 'Close to the Right',icon: 'tab_close', action: 'close-right', danger: true, disabled: idx === -1 || idx >= total - 1 },
285
+ { label: 'Close All', icon: 'tab_close_inactive', action: 'close-all', danger: true },
286
+ { label: 'Close Saved', icon: 'check_circle', action: 'close-saved', danger: true },
287
+ { separator: true },
288
+ { label: 'Reveal in Explorer', icon: 'folder_open', action: 'reveal' },
289
+ ];
290
+
291
+ const menuFilter = this.#callbacks.menuFilter;
292
+ const items = menuFilter
293
+ ? allItems.filter(item => item.separator || menuFilter(item.action, filePath) !== false)
294
+ : allItems;
295
+
296
+ // Collapse consecutive / leading / trailing separators
297
+ const cleaned = [];
298
+ for (const item of items) {
299
+ if (item.separator) {
300
+ if (cleaned.length > 0 && !cleaned[cleaned.length - 1].separator) cleaned.push(item);
301
+ } else {
302
+ cleaned.push(item);
303
+ }
304
+ }
305
+ while (cleaned.length > 0 && cleaned[cleaned.length - 1].separator) cleaned.pop();
306
+
307
+ const menu = document.createElement('div');
308
+ menu.className = 'nb-context-menu';
309
+
310
+ for (const item of cleaned) {
311
+ if (item.separator) {
312
+ const sep = document.createElement('div');
313
+ sep.className = 'nb-context-menu__separator';
314
+ menu.appendChild(sep);
315
+ continue;
316
+ }
317
+
318
+ const btn = document.createElement('button');
319
+ btn.className = 'nb-context-menu__item';
320
+ if (item.danger) btn.classList.add('nb-context-menu__item--danger');
321
+ if (item.disabled) btn.classList.add('nb-context-menu__item--disabled');
322
+
323
+ btn.innerHTML = `<span class="material-symbols-outlined">${item.icon}</span><span>${this.#escHtml(item.label)}</span>${
324
+ item.shortcut ? `<span class="nb-context-menu__shortcut">${item.shortcut}</span>` : ''
325
+ }`;
326
+
327
+ if (!item.disabled) {
328
+ btn.addEventListener('click', (e) => {
329
+ e.stopPropagation();
330
+ this.#hideContextMenu();
331
+ this.#handleContextMenuAction(item.action, filePath);
332
+ });
333
+ }
334
+ menu.appendChild(btn);
335
+ }
336
+
337
+ document.body.appendChild(menu);
338
+ this.#contextMenuEl = menu;
339
+
340
+ // Position with viewport clamping
341
+ const rect = menu.getBoundingClientRect();
342
+ const left = Math.min(x, window.innerWidth - rect.width - 8);
343
+ const top = Math.min(y, window.innerHeight - rect.height - 8);
344
+ menu.style.left = `${Math.max(0, left)}px`;
345
+ menu.style.top = `${Math.max(0, top)}px`;
346
+
347
+ // Animate in
348
+ requestAnimationFrame(() => menu.classList.add('visible'));
349
+
350
+ // Dismiss on outside click or Escape
351
+ setTimeout(() => {
352
+ document.addEventListener('click', this.#boundHideContextMenu, true);
353
+ document.addEventListener('contextmenu', this.#boundHideContextMenu, true);
354
+ }, 0);
355
+ document.addEventListener('keydown', this.#onKeyDown);
356
+ }
357
+
358
+ #hideContextMenu() {
359
+ if (!this.#contextMenuEl) return;
360
+ this.#contextMenuEl.remove();
361
+ this.#contextMenuEl = null;
362
+ document.removeEventListener('click', this.#boundHideContextMenu, true);
363
+ document.removeEventListener('contextmenu', this.#boundHideContextMenu, true);
364
+ document.removeEventListener('keydown', this.#onKeyDown);
365
+ }
366
+
367
+ #onKeyDown = (e) => {
368
+ if (e.key === 'Escape') this.#hideContextMenu();
369
+ };
370
+
371
+ #handleContextMenuAction(action, filePath) {
372
+ switch (action) {
373
+ case 'rename': this.#beginInlineRename(filePath); break;
374
+ case 'duplicate': this.#callbacks.onDuplicate?.(filePath); break;
375
+ case 'close': this.#callbacks.onClose?.(filePath); break;
376
+ case 'close-others': this.#callbacks.onCloseOthers?.(filePath); break;
377
+ case 'close-right': this.#callbacks.onCloseToRight?.(filePath); break;
378
+ case 'close-all': this.#callbacks.onCloseAll?.(); break;
379
+ case 'close-saved': this.#callbacks.onCloseSaved?.(); break;
380
+ case 'split-right': this.#callbacks.onSplitRight?.(filePath); break;
381
+ case 'move-to-other':this.#callbacks.onMoveToOtherPane?.(filePath); break;
382
+ case 'reveal': this.#callbacks.onRevealInExplorer?.(filePath); break;
383
+ }
384
+ }
385
+
386
+ // ═════════════════════════════════════════════════════════════════════════
387
+ // Inline rename (contenteditable on tab title)
388
+ // ═════════════════════════════════════════════════════════════════════════
389
+
390
+ #beginInlineRename(filePath) {
391
+ const tabEl = this.#container?.querySelector(`.tab[data-path="${CSS.escape(filePath)}"]`);
392
+ if (!tabEl) return;
393
+
394
+ const title = tabEl.querySelector('.twm-tab-title');
395
+ if (!title || title.getAttribute('contenteditable') === 'true') return;
396
+
397
+ const tab = this.#tabs.find(t => t.filePath === filePath);
398
+ if (!tab) return;
399
+
400
+ // model.notebook, builtin, and readonly files cannot be renamed
401
+ if (tab.fileType === 'model') return;
402
+ if (filePath.startsWith('builtin://')) return;
403
+ if (tab.readonly) return;
404
+
405
+ const original = title.textContent || '';
406
+ title.setAttribute('contenteditable', 'true');
407
+ title.focus();
408
+
409
+ try {
410
+ const range = document.createRange();
411
+ range.selectNodeContents(title);
412
+ const sel = window.getSelection();
413
+ sel.removeAllRanges();
414
+ sel.addRange(range);
415
+ } catch (_) { /* ignore */ }
416
+
417
+ let committed = false;
418
+
419
+ const finish = (commit) => {
420
+ if (committed) return;
421
+ committed = true;
422
+
423
+ const newName = (title.textContent || '').trim();
424
+ title.removeAttribute('contenteditable');
425
+ title.removeEventListener('keydown', onKeyDown);
426
+ title.removeEventListener('blur', onBlur, true);
427
+
428
+ try { window.getSelection()?.removeAllRanges?.(); } catch (_) { /* ignore */ }
429
+
430
+ if (commit && newName && newName !== original && /^[\w][\w.-]*$/.test(newName)) {
431
+ title.textContent = newName;
432
+ const dir = filePath.split('/').slice(0, -1).join('/');
433
+ const ext = filePath.split('.').pop();
434
+ const newPath = `${dir}/${newName}.${ext}`;
435
+ this.#callbacks.onRename?.(filePath, newPath);
436
+ } else {
437
+ title.textContent = original;
438
+ }
439
+ };
440
+
441
+ const onKeyDown = (e) => {
442
+ if (e.key === 'Enter') { e.preventDefault(); finish(true); }
443
+ else if (e.key === 'Escape') { e.preventDefault(); finish(false); }
444
+ e.stopPropagation();
445
+ };
446
+
447
+ const onBlur = () => finish(true);
448
+
449
+ title.addEventListener('keydown', onKeyDown);
450
+ title.addEventListener('blur', onBlur, true);
451
+ }
452
+
453
+ #escHtml(s) {
454
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
455
+ }
456
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * NotebookUndoManager
3
+ *
4
+ * Command-pattern undo/redo for notebook cell operations.
5
+ *
6
+ * Each command: { description: string, undo: () => (void | Promise), redo: () => (void | Promise) }
7
+ *
8
+ * Operations tracked by NotebookEditor:
9
+ * - Cell add
10
+ * - Cell delete
11
+ * - Cell move (up/down, drag-reorder)
12
+ * - Cell config change (debounced per-cell snapshot)
13
+ */
14
+ export class NotebookUndoManager {
15
+
16
+ /** @type {Array<{ description: string, undo: Function, redo: Function }>} */
17
+ #undoStack = [];
18
+
19
+ /** @type {Array<{ description: string, undo: Function, redo: Function }>} */
20
+ #redoStack = [];
21
+
22
+ /** @type {number} */
23
+ #maxSize;
24
+
25
+ /** @type {Function|null} Called with (canUndo: boolean, canRedo: boolean) after each change */
26
+ #onStateChange;
27
+
28
+ /** @type {boolean} Prevents re-entrant undo/redo */
29
+ #applying = false;
30
+
31
+ /**
32
+ * @param {{ maxSize?: number, onStateChange?: Function }} opts
33
+ */
34
+ constructor({ maxSize = 100, onStateChange = null } = {}) {
35
+ this.#maxSize = maxSize;
36
+ this.#onStateChange = onStateChange;
37
+ }
38
+
39
+ // ─── Public API ───────────────────────────────────────────────────────────
40
+
41
+ get canUndo() { return this.#undoStack.length > 0; }
42
+ get canRedo() { return this.#redoStack.length > 0; }
43
+
44
+ /**
45
+ * Record a new command. Clears the redo stack.
46
+ * Should NOT be called from inside undo/redo callbacks.
47
+ *
48
+ * @param {{ description: string, undo: Function, redo: Function }} command
49
+ */
50
+ push(command) {
51
+ if (this.#applying) return; // ignore changes triggered by undo/redo
52
+ this.#undoStack.push(command);
53
+ if (this.#undoStack.length > this.#maxSize) {
54
+ this.#undoStack.shift();
55
+ }
56
+ this.#redoStack = [];
57
+ this.#notify();
58
+ }
59
+
60
+ /**
61
+ * Undo the last command.
62
+ * @returns {Promise<boolean>} true if something was undone
63
+ */
64
+ async undo() {
65
+ const cmd = this.#undoStack.pop();
66
+ if (!cmd) return false;
67
+ this.#applying = true;
68
+ try {
69
+ await cmd.undo();
70
+ } finally {
71
+ this.#applying = false;
72
+ }
73
+ this.#redoStack.push(cmd);
74
+ this.#notify();
75
+ return true;
76
+ }
77
+
78
+ /**
79
+ * Redo the last undone command.
80
+ * @returns {Promise<boolean>} true if something was redone
81
+ */
82
+ async redo() {
83
+ const cmd = this.#redoStack.pop();
84
+ if (!cmd) return false;
85
+ this.#applying = true;
86
+ try {
87
+ await cmd.redo();
88
+ } finally {
89
+ this.#applying = false;
90
+ }
91
+ this.#undoStack.push(cmd);
92
+ this.#notify();
93
+ return true;
94
+ }
95
+
96
+ /** Reset both stacks (e.g., after file load). */
97
+ clear() {
98
+ this.#undoStack = [];
99
+ this.#redoStack = [];
100
+ this.#notify();
101
+ }
102
+
103
+ // ─── Private ──────────────────────────────────────────────────────────────
104
+
105
+ #notify() {
106
+ this.#onStateChange?.(this.canUndo, this.canRedo);
107
+ }
108
+ }