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
package/dist/editor.js ADDED
@@ -0,0 +1,1661 @@
1
+ import {
2
+ DragReorder
3
+ } from "./chunk-WVFGV5FT.js";
4
+ import "./chunk-JYWURG5T.js";
5
+
6
+ // src/editor/notebook_tab_bar.js
7
+ var TAB_MIME = "application/x-ecosim-tab";
8
+ var NotebookTabBar = class _NotebookTabBar {
9
+ /** @type {HTMLElement} */
10
+ #container = null;
11
+ /** @type {object[]} [{filePath, label, fileType, isDirty}] */
12
+ #tabs = [];
13
+ /** @type {string|null} */
14
+ #activeTab = null;
15
+ /** @type {object} callbacks */
16
+ #callbacks = {};
17
+ // ─── Context menu ────────────────────────────────────────────────────────
18
+ /** @type {HTMLElement|null} */
19
+ #contextMenuEl = null;
20
+ /** @type {Function|null} */
21
+ #boundHideContextMenu = null;
22
+ // ─── Drag and drop ───────────────────────────────────────────────────────
23
+ /** Same-pane reorder. Cross-pane drops stay in this class's own
24
+ * container handlers (#onStripDrag*).
25
+ * @type {DragReorder|null} */
26
+ #dragReorder = null;
27
+ mount(container, callbacks = {}) {
28
+ this.#container = container;
29
+ this.#callbacks = callbacks;
30
+ this.#container.className = "tabs notebook-tabs";
31
+ this.#boundHideContextMenu = () => this.#hideContextMenu();
32
+ this.#installStripDnDHandlers();
33
+ this.#dragReorder = new DragReorder({
34
+ container: this.#container,
35
+ itemSelector: ".tab",
36
+ keyAttr: "path",
37
+ axis: "x",
38
+ draggableGuard: (e) => {
39
+ if (e.target.closest(".twm-tab-close")) return false;
40
+ const title = e.target.closest(".tab")?.querySelector(".twm-tab-title");
41
+ return title?.getAttribute("contenteditable") !== "true";
42
+ },
43
+ onDragStart: (e, key) => {
44
+ try {
45
+ e.dataTransfer.setData(TAB_MIME, key);
46
+ } catch (_) {
47
+ }
48
+ },
49
+ onReorder: (order) => this.#callbacks.onReorder?.(order)
50
+ });
51
+ this.#render();
52
+ }
53
+ /**
54
+ * Sync tabs from ProjectModel state.
55
+ * @param {Array<{ filePath: string, fileType: string, isDirty: boolean }>} tabs
56
+ * @param {string|null} activeFilePath
57
+ */
58
+ update(tabs, activeFilePath) {
59
+ this.#tabs = tabs;
60
+ this.#activeTab = activeFilePath;
61
+ this.#render();
62
+ }
63
+ markDirty(filePath, isDirty) {
64
+ const tab = this.#tabs.find((t) => t.filePath === filePath);
65
+ if (tab) {
66
+ tab.isDirty = isDirty;
67
+ this.#render();
68
+ }
69
+ }
70
+ dispose() {
71
+ this.#hideContextMenu();
72
+ this.#removeStripDnDHandlers();
73
+ this.#dragReorder?.destroy();
74
+ this.#dragReorder = null;
75
+ this.#container = null;
76
+ }
77
+ // Material symbol per file kind, merged over the generic set. The map used to
78
+ // hardcode EcoAgent's kinds — `scenario`, `dashboard`, `model` — so a generic
79
+ // tab bar knew one application's file taxonomy. Pass `fileTypeIcons` to add
80
+ // your own.
81
+ static #FILE_TYPE_ICONS = {
82
+ module: "extension",
83
+ test: "fact_check",
84
+ diff: "difference",
85
+ unknown: "description"
86
+ };
87
+ static #EXTRA_ICONS = {};
88
+ /**
89
+ * Add your application's file kinds to the tab-bar icon map.
90
+ *
91
+ * The map used to hardcode `scenario: 'science'`, `dashboard: 'dashboard'` and
92
+ * `model: 'schema'` — EcoAgent's file taxonomy, baked into a generic tab bar.
93
+ *
94
+ * @param {Object.<string, string>} icons fileType -> material symbol name
95
+ */
96
+ static setFileTypeIcons(icons = {}) {
97
+ _NotebookTabBar.#EXTRA_ICONS = { ...icons };
98
+ }
99
+ static #iconFor(fileType) {
100
+ return _NotebookTabBar.#EXTRA_ICONS[fileType] ?? _NotebookTabBar.#FILE_TYPE_ICONS[fileType] ?? _NotebookTabBar.#FILE_TYPE_ICONS.unknown;
101
+ }
102
+ // ═════════════════════════════════════════════════════════════════════════
103
+ // Rendering
104
+ // ═════════════════════════════════════════════════════════════════════════
105
+ #render() {
106
+ if (!this.#container) return;
107
+ this.#container.innerHTML = "";
108
+ for (const tab of this.#tabs) {
109
+ const isActive = tab.filePath === this.#activeTab;
110
+ const rawPath = tab.filePath.replace(/^(diff|builtin):\/\//, "");
111
+ const label = tab.label ?? (tab.fileType === "diff" ? `\u2194 ${rawPath.split("/").pop()}` : rawPath.split("/").pop().replace(/\.[^.]+$/, ""));
112
+ const icon = _NotebookTabBar.#iconFor(tab.fileType);
113
+ const tabEl = document.createElement("div");
114
+ tabEl.className = `tab${isActive ? " active" : ""}${tab.isPreview ? " tab--preview" : ""}`;
115
+ tabEl.dataset.path = tab.filePath;
116
+ tabEl.title = tab.filePath;
117
+ tabEl.innerHTML = `
118
+ <span class="material-symbols-outlined tab-icon">${icon}</span>
119
+ <span class="twm-tab-title">${this.#escHtml(label)}</span>
120
+ ${tab.isDirty ? '<span class="tab-dirty">\u25CF</span>' : ""}
121
+ ${tab.readonly ? "" : '<button class="twm-tab-close" title="Close tab" aria-label="Close tab">\xD7</button>'}
122
+ `;
123
+ tabEl.addEventListener("click", (e) => {
124
+ if (e.target.closest(".twm-tab-close")) return;
125
+ if (tabEl.querySelector(".twm-tab-title")?.getAttribute("contenteditable") === "true") return;
126
+ this.#callbacks.onActivate?.(tab.filePath);
127
+ });
128
+ tabEl.querySelector(".twm-tab-close")?.addEventListener("click", (e) => {
129
+ e.stopPropagation();
130
+ this.#callbacks.onClose?.(tab.filePath);
131
+ });
132
+ tabEl.addEventListener("dblclick", (e) => {
133
+ if (e.target.closest(".twm-tab-close")) return;
134
+ this.#beginInlineRename(tab.filePath);
135
+ });
136
+ tabEl.addEventListener("keydown", (e) => {
137
+ if (e.key === "F2") {
138
+ e.preventDefault();
139
+ this.#beginInlineRename(tab.filePath);
140
+ }
141
+ });
142
+ tabEl.addEventListener("contextmenu", (e) => {
143
+ e.preventDefault();
144
+ e.stopPropagation();
145
+ this.#showContextMenu(e.clientX, e.clientY, tab.filePath);
146
+ });
147
+ this.#container.appendChild(tabEl);
148
+ }
149
+ this.#dragReorder?.attach();
150
+ }
151
+ // ═════════════════════════════════════════════════════════════════════════
152
+ // Drag and drop reordering (same-pane + cross-pane)
153
+ // ═════════════════════════════════════════════════════════════════════════
154
+ #installStripDnDHandlers() {
155
+ if (!this.#container) return;
156
+ this.#container.addEventListener("dragover", this.#onStripDragOver);
157
+ this.#container.addEventListener("drop", this.#onStripDrop);
158
+ this.#container.addEventListener("dragleave", this.#onStripDragLeave);
159
+ }
160
+ #removeStripDnDHandlers() {
161
+ if (!this.#container) return;
162
+ this.#container.removeEventListener("dragover", this.#onStripDragOver);
163
+ this.#container.removeEventListener("drop", this.#onStripDrop);
164
+ this.#container.removeEventListener("dragleave", this.#onStripDragLeave);
165
+ }
166
+ /** Check whether a drag event carries a cross-pane tab (not from this bar). */
167
+ #isExternalTabDrag(e) {
168
+ if (this.#dragReorder?.isActive()) return false;
169
+ try {
170
+ return e.dataTransfer.types.includes(TAB_MIME);
171
+ } catch {
172
+ return false;
173
+ }
174
+ }
175
+ // Same-pane reorder is handled by the shared DragReorder module (set
176
+ // up in mount()). These container handlers cover ONLY cross-pane tab
177
+ // drops — gated on `#isExternalTabDrag`, which defers while our own
178
+ // reorder drag is active.
179
+ #onStripDragOver = (e) => {
180
+ if (this.#isExternalTabDrag(e)) {
181
+ e.preventDefault();
182
+ try {
183
+ e.dataTransfer.dropEffect = "move";
184
+ } catch (_) {
185
+ }
186
+ this.#container.classList.add("notebook-tabs--drop-target");
187
+ }
188
+ };
189
+ #onStripDrop = (e) => {
190
+ if (this.#isExternalTabDrag(e)) {
191
+ e.preventDefault();
192
+ this.#container.classList.remove("notebook-tabs--drop-target");
193
+ const filePath = e.dataTransfer.getData(TAB_MIME) || e.dataTransfer.getData("text/plain");
194
+ if (filePath) {
195
+ this.#callbacks.onDropFromOtherPane?.(filePath);
196
+ }
197
+ }
198
+ };
199
+ #onStripDragLeave = (e) => {
200
+ if (!this.#container.contains(e.relatedTarget)) {
201
+ this.#container.classList.remove("notebook-tabs--drop-target");
202
+ }
203
+ };
204
+ // ═════════════════════════════════════════════════════════════════════════
205
+ // Context menu
206
+ // ═════════════════════════════════════════════════════════════════════════
207
+ #showContextMenu(x, y, filePath) {
208
+ this.#hideContextMenu();
209
+ const idx = this.#tabs.findIndex((t) => t.filePath === filePath);
210
+ const total = this.#tabs.length;
211
+ const allItems = [
212
+ { label: "Rename", icon: "edit", action: "rename", shortcut: "F2" },
213
+ { label: "Duplicate", icon: "content_copy", action: "duplicate" },
214
+ { separator: true },
215
+ { label: "Split Right", icon: "vertical_split", action: "split-right", shortcut: "Ctrl+\\" },
216
+ { label: "Move to Other Pane", icon: "swap_horiz", action: "move-to-other" },
217
+ { separator: true },
218
+ { label: "Close", icon: "close", action: "close", danger: true },
219
+ { label: "Close Others", icon: "close_fullscreen", action: "close-others", danger: true, disabled: total <= 1 },
220
+ { label: "Close to the Right", icon: "tab_close", action: "close-right", danger: true, disabled: idx === -1 || idx >= total - 1 },
221
+ { label: "Close All", icon: "tab_close_inactive", action: "close-all", danger: true },
222
+ { label: "Close Saved", icon: "check_circle", action: "close-saved", danger: true },
223
+ { separator: true },
224
+ { label: "Reveal in Explorer", icon: "folder_open", action: "reveal" }
225
+ ];
226
+ const menuFilter = this.#callbacks.menuFilter;
227
+ const items = menuFilter ? allItems.filter((item) => item.separator || menuFilter(item.action, filePath) !== false) : allItems;
228
+ const cleaned = [];
229
+ for (const item of items) {
230
+ if (item.separator) {
231
+ if (cleaned.length > 0 && !cleaned[cleaned.length - 1].separator) cleaned.push(item);
232
+ } else {
233
+ cleaned.push(item);
234
+ }
235
+ }
236
+ while (cleaned.length > 0 && cleaned[cleaned.length - 1].separator) cleaned.pop();
237
+ const menu = document.createElement("div");
238
+ menu.className = "nb-context-menu";
239
+ for (const item of cleaned) {
240
+ if (item.separator) {
241
+ const sep = document.createElement("div");
242
+ sep.className = "nb-context-menu__separator";
243
+ menu.appendChild(sep);
244
+ continue;
245
+ }
246
+ const btn = document.createElement("button");
247
+ btn.className = "nb-context-menu__item";
248
+ if (item.danger) btn.classList.add("nb-context-menu__item--danger");
249
+ if (item.disabled) btn.classList.add("nb-context-menu__item--disabled");
250
+ btn.innerHTML = `<span class="material-symbols-outlined">${item.icon}</span><span>${this.#escHtml(item.label)}</span>${item.shortcut ? `<span class="nb-context-menu__shortcut">${item.shortcut}</span>` : ""}`;
251
+ if (!item.disabled) {
252
+ btn.addEventListener("click", (e) => {
253
+ e.stopPropagation();
254
+ this.#hideContextMenu();
255
+ this.#handleContextMenuAction(item.action, filePath);
256
+ });
257
+ }
258
+ menu.appendChild(btn);
259
+ }
260
+ document.body.appendChild(menu);
261
+ this.#contextMenuEl = menu;
262
+ const rect = menu.getBoundingClientRect();
263
+ const left = Math.min(x, window.innerWidth - rect.width - 8);
264
+ const top = Math.min(y, window.innerHeight - rect.height - 8);
265
+ menu.style.left = `${Math.max(0, left)}px`;
266
+ menu.style.top = `${Math.max(0, top)}px`;
267
+ requestAnimationFrame(() => menu.classList.add("visible"));
268
+ setTimeout(() => {
269
+ document.addEventListener("click", this.#boundHideContextMenu, true);
270
+ document.addEventListener("contextmenu", this.#boundHideContextMenu, true);
271
+ }, 0);
272
+ document.addEventListener("keydown", this.#onKeyDown);
273
+ }
274
+ #hideContextMenu() {
275
+ if (!this.#contextMenuEl) return;
276
+ this.#contextMenuEl.remove();
277
+ this.#contextMenuEl = null;
278
+ document.removeEventListener("click", this.#boundHideContextMenu, true);
279
+ document.removeEventListener("contextmenu", this.#boundHideContextMenu, true);
280
+ document.removeEventListener("keydown", this.#onKeyDown);
281
+ }
282
+ #onKeyDown = (e) => {
283
+ if (e.key === "Escape") this.#hideContextMenu();
284
+ };
285
+ #handleContextMenuAction(action, filePath) {
286
+ switch (action) {
287
+ case "rename":
288
+ this.#beginInlineRename(filePath);
289
+ break;
290
+ case "duplicate":
291
+ this.#callbacks.onDuplicate?.(filePath);
292
+ break;
293
+ case "close":
294
+ this.#callbacks.onClose?.(filePath);
295
+ break;
296
+ case "close-others":
297
+ this.#callbacks.onCloseOthers?.(filePath);
298
+ break;
299
+ case "close-right":
300
+ this.#callbacks.onCloseToRight?.(filePath);
301
+ break;
302
+ case "close-all":
303
+ this.#callbacks.onCloseAll?.();
304
+ break;
305
+ case "close-saved":
306
+ this.#callbacks.onCloseSaved?.();
307
+ break;
308
+ case "split-right":
309
+ this.#callbacks.onSplitRight?.(filePath);
310
+ break;
311
+ case "move-to-other":
312
+ this.#callbacks.onMoveToOtherPane?.(filePath);
313
+ break;
314
+ case "reveal":
315
+ this.#callbacks.onRevealInExplorer?.(filePath);
316
+ break;
317
+ }
318
+ }
319
+ // ═════════════════════════════════════════════════════════════════════════
320
+ // Inline rename (contenteditable on tab title)
321
+ // ═════════════════════════════════════════════════════════════════════════
322
+ #beginInlineRename(filePath) {
323
+ const tabEl = this.#container?.querySelector(`.tab[data-path="${CSS.escape(filePath)}"]`);
324
+ if (!tabEl) return;
325
+ const title = tabEl.querySelector(".twm-tab-title");
326
+ if (!title || title.getAttribute("contenteditable") === "true") return;
327
+ const tab = this.#tabs.find((t) => t.filePath === filePath);
328
+ if (!tab) return;
329
+ if (tab.fileType === "model") return;
330
+ if (filePath.startsWith("builtin://")) return;
331
+ if (tab.readonly) return;
332
+ const original = title.textContent || "";
333
+ title.setAttribute("contenteditable", "true");
334
+ title.focus();
335
+ try {
336
+ const range = document.createRange();
337
+ range.selectNodeContents(title);
338
+ const sel = window.getSelection();
339
+ sel.removeAllRanges();
340
+ sel.addRange(range);
341
+ } catch (_) {
342
+ }
343
+ let committed = false;
344
+ const finish = (commit) => {
345
+ if (committed) return;
346
+ committed = true;
347
+ const newName = (title.textContent || "").trim();
348
+ title.removeAttribute("contenteditable");
349
+ title.removeEventListener("keydown", onKeyDown);
350
+ title.removeEventListener("blur", onBlur, true);
351
+ try {
352
+ window.getSelection()?.removeAllRanges?.();
353
+ } catch (_) {
354
+ }
355
+ if (commit && newName && newName !== original && /^[\w][\w.-]*$/.test(newName)) {
356
+ title.textContent = newName;
357
+ const dir = filePath.split("/").slice(0, -1).join("/");
358
+ const ext = filePath.split(".").pop();
359
+ const newPath = `${dir}/${newName}.${ext}`;
360
+ this.#callbacks.onRename?.(filePath, newPath);
361
+ } else {
362
+ title.textContent = original;
363
+ }
364
+ };
365
+ const onKeyDown = (e) => {
366
+ if (e.key === "Enter") {
367
+ e.preventDefault();
368
+ finish(true);
369
+ } else if (e.key === "Escape") {
370
+ e.preventDefault();
371
+ finish(false);
372
+ }
373
+ e.stopPropagation();
374
+ };
375
+ const onBlur = () => finish(true);
376
+ title.addEventListener("keydown", onKeyDown);
377
+ title.addEventListener("blur", onBlur, true);
378
+ }
379
+ #escHtml(s) {
380
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
381
+ }
382
+ };
383
+
384
+ // src/editor/editor_pane.js
385
+ var EditorPane = class {
386
+ /** @type {string} */
387
+ #id;
388
+ /** @type {HTMLElement} root element */
389
+ #el = null;
390
+ /** @type {NotebookTabBar} */
391
+ #tabBar = null;
392
+ /** @type {HTMLElement} */
393
+ #editorContainer = null;
394
+ /** @type {string[]} file paths in tab order */
395
+ #filePaths = [];
396
+ /** @type {string|null} */
397
+ #activeFilePath = null;
398
+ /** @type {string|null} file path of the preview (transient) tab, if any */
399
+ #previewFilePath = null;
400
+ /** @type {object} callbacks from parent */
401
+ #callbacks = {};
402
+ /** @type {boolean} whether this pane is the focused/active pane */
403
+ #focused = false;
404
+ /**
405
+ * @param {string} id — unique pane identifier ('left' or 'right')
406
+ * @param {object} callbacks
407
+ * @param {Function} callbacks.onActivate — (paneId, filePath) => void
408
+ * @param {Function} callbacks.onClose — (paneId, filePath) => void
409
+ * @param {Function} callbacks.onCloseOthers — (paneId, filePath) => void
410
+ * @param {Function} callbacks.onCloseToRight — (paneId, filePath) => void
411
+ * @param {Function} callbacks.onCloseAll — (paneId) => void
412
+ * @param {Function} callbacks.onCloseSaved — (paneId) => void
413
+ * @param {Function} callbacks.onRename — (oldPath, newPath) => void
414
+ * @param {Function} callbacks.onDuplicate — (filePath) => void
415
+ * @param {Function} callbacks.onReorder — (paneId, orderedPaths) => void
416
+ * @param {Function} callbacks.onRevealInExplorer — (filePath) => void
417
+ * @param {Function} callbacks.onFocus — (paneId) => void
418
+ * @param {Function} callbacks.onSplitRight — (paneId, filePath) => void
419
+ * @param {Function} callbacks.onMoveToOtherPane — (paneId, filePath) => void
420
+ */
421
+ constructor(id, callbacks = {}) {
422
+ this.#id = id;
423
+ this.#callbacks = callbacks;
424
+ }
425
+ get id() {
426
+ return this.#id;
427
+ }
428
+ get activeFilePath() {
429
+ return this.#activeFilePath;
430
+ }
431
+ get filePaths() {
432
+ return [...this.#filePaths];
433
+ }
434
+ get editorContainer() {
435
+ return this.#editorContainer;
436
+ }
437
+ get element() {
438
+ return this.#el;
439
+ }
440
+ get isEmpty() {
441
+ return this.#filePaths.length === 0;
442
+ }
443
+ /**
444
+ * Build DOM and mount the tab bar.
445
+ * @returns {HTMLElement} the root element (to be placed in SplitPaneContainer)
446
+ */
447
+ mount() {
448
+ this.#el = document.createElement("div");
449
+ this.#el.className = "editor-pane";
450
+ this.#el.dataset.paneId = this.#id;
451
+ this.#el.addEventListener("mousedown", () => {
452
+ this.#callbacks.onFocus?.(this.#id);
453
+ }, true);
454
+ const tabBarEl = document.createElement("div");
455
+ this.#el.appendChild(tabBarEl);
456
+ this.#tabBar = new NotebookTabBar();
457
+ this.#tabBar.mount(tabBarEl, {
458
+ onActivate: (path) => this.#callbacks.onActivate?.(this.#id, path),
459
+ onClose: (path) => this.#callbacks.onClose?.(this.#id, path),
460
+ onCloseOthers: (path) => this.#callbacks.onCloseOthers?.(this.#id, path),
461
+ onCloseToRight: (path) => this.#callbacks.onCloseToRight?.(this.#id, path),
462
+ onCloseAll: () => this.#callbacks.onCloseAll?.(this.#id),
463
+ onCloseSaved: () => this.#callbacks.onCloseSaved?.(this.#id),
464
+ onRename: (o, n) => this.#callbacks.onRename?.(o, n),
465
+ onDuplicate: (path) => this.#callbacks.onDuplicate?.(path),
466
+ onReorder: (paths) => this.#callbacks.onReorder?.(this.#id, paths),
467
+ onRevealInExplorer: (path) => this.#callbacks.onRevealInExplorer?.(path),
468
+ onSplitRight: (path) => this.#callbacks.onSplitRight?.(this.#id, path),
469
+ onMoveToOtherPane: (path) => this.#callbacks.onMoveToOtherPane?.(this.#id, path),
470
+ onDropFromOtherPane: (path) => this.#callbacks.onDropFromOtherPane?.(this.#id, path)
471
+ });
472
+ const body = document.createElement("div");
473
+ body.className = "editor-pane__body";
474
+ this.#el.appendChild(body);
475
+ this.#editorContainer = document.createElement("div");
476
+ this.#editorContainer.className = "notebook-editor-container";
477
+ body.appendChild(this.#editorContainer);
478
+ this.#editorContainer.addEventListener("dragover", (e) => {
479
+ if (this.#isExternalTabDrag(e)) {
480
+ e.preventDefault();
481
+ try {
482
+ e.dataTransfer.dropEffect = "move";
483
+ } catch {
484
+ }
485
+ this.#editorContainer.classList.add("notebook-editor-container--drop-target");
486
+ }
487
+ });
488
+ this.#editorContainer.addEventListener("drop", (e) => {
489
+ if (this.#isExternalTabDrag(e)) {
490
+ e.preventDefault();
491
+ this.#editorContainer.classList.remove("notebook-editor-container--drop-target");
492
+ const filePath = e.dataTransfer.getData("application/x-ecosim-tab") || e.dataTransfer.getData("text/plain");
493
+ if (filePath) {
494
+ this.#callbacks.onDropFromOtherPane?.(this.#id, filePath);
495
+ }
496
+ }
497
+ });
498
+ this.#editorContainer.addEventListener("dragleave", (e) => {
499
+ if (!this.#editorContainer.contains(e.relatedTarget)) {
500
+ this.#editorContainer.classList.remove("notebook-editor-container--drop-target");
501
+ }
502
+ });
503
+ return this.#el;
504
+ }
505
+ /**
506
+ * Update the file list and active tab.
507
+ * @param {Array<{filePath: string, fileType: string, isDirty: boolean}>} tabs
508
+ * @param {string|null} activeFilePath
509
+ */
510
+ syncTabBar(tabs, activeFilePath) {
511
+ this.#tabBar?.update(tabs, activeFilePath);
512
+ }
513
+ /**
514
+ * Set the files assigned to this pane.
515
+ * @param {string[]} filePaths
516
+ */
517
+ setFilePaths(filePaths) {
518
+ this.#filePaths = [...filePaths];
519
+ }
520
+ /**
521
+ * Add a file to this pane's tabs (at end).
522
+ * @param {string} filePath
523
+ */
524
+ addFile(filePath) {
525
+ if (!this.#filePaths.includes(filePath)) {
526
+ this.#filePaths.push(filePath);
527
+ }
528
+ }
529
+ /**
530
+ * Remove a file from this pane's tabs.
531
+ * @param {string} filePath
532
+ */
533
+ removeFile(filePath) {
534
+ this.#filePaths = this.#filePaths.filter((p) => p !== filePath);
535
+ if (this.#previewFilePath === filePath) this.#previewFilePath = null;
536
+ if (this.#activeFilePath === filePath) {
537
+ this.#activeFilePath = this.#filePaths.length > 0 ? this.#filePaths[this.#filePaths.length - 1] : null;
538
+ }
539
+ }
540
+ hasFile(filePath) {
541
+ return this.#filePaths.includes(filePath);
542
+ }
543
+ setActiveFile(filePath) {
544
+ this.#activeFilePath = filePath;
545
+ }
546
+ setFocused(focused) {
547
+ this.#focused = focused;
548
+ this.#el?.classList.toggle("editor-pane--focused", focused);
549
+ }
550
+ markDirty(filePath, isDirty) {
551
+ this.#tabBar?.markDirty(filePath, isDirty);
552
+ }
553
+ reorderFiles(orderedPaths) {
554
+ const valid = orderedPaths.filter((p) => this.#filePaths.includes(p));
555
+ for (const p of this.#filePaths) {
556
+ if (!valid.includes(p)) valid.push(p);
557
+ }
558
+ this.#filePaths = valid;
559
+ }
560
+ renameFile(oldPath, newPath) {
561
+ const idx = this.#filePaths.indexOf(oldPath);
562
+ if (idx !== -1) this.#filePaths[idx] = newPath;
563
+ if (this.#activeFilePath === oldPath) this.#activeFilePath = newPath;
564
+ if (this.#previewFilePath === oldPath) this.#previewFilePath = newPath;
565
+ }
566
+ // ─── Preview (transient) tab ──────────────────────────────────────────────
567
+ /** @returns {string|null} the preview file path, or null if no preview tab */
568
+ get previewFilePath() {
569
+ return this.#previewFilePath;
570
+ }
571
+ /** Mark a file as the preview tab. Only one preview tab per pane. */
572
+ setPreview(filePath) {
573
+ this.#previewFilePath = filePath;
574
+ }
575
+ /** Whether a file is the preview (transient) tab. */
576
+ isPreview(filePath) {
577
+ return this.#previewFilePath === filePath;
578
+ }
579
+ /** Pin the preview tab (promote to permanent). */
580
+ pinPreview() {
581
+ this.#previewFilePath = null;
582
+ }
583
+ /** Check if a drag event is carrying a tab from another pane. */
584
+ #isExternalTabDrag(e) {
585
+ try {
586
+ return e.dataTransfer.types.includes("application/x-ecosim-tab");
587
+ } catch {
588
+ return false;
589
+ }
590
+ }
591
+ dispose() {
592
+ this.#tabBar?.dispose();
593
+ this.#tabBar = null;
594
+ this.#el?.remove();
595
+ this.#el = null;
596
+ this.#editorContainer = null;
597
+ this.#filePaths = [];
598
+ this.#activeFilePath = null;
599
+ this.#previewFilePath = null;
600
+ }
601
+ };
602
+
603
+ // src/editor/monaco_loader.js
604
+ var _resolveMonaco;
605
+ var _monacoReady = false;
606
+ var monacoReady = new Promise((resolve) => {
607
+ _resolveMonaco = resolve;
608
+ });
609
+ function injectLoaderScript(src) {
610
+ return new Promise((resolve, reject) => {
611
+ const s = document.createElement("script");
612
+ s.src = src;
613
+ s.onload = resolve;
614
+ s.onerror = () => reject(new Error(`Failed to load Monaco loader: ${src}`));
615
+ document.head.appendChild(s);
616
+ });
617
+ }
618
+ function injectMonacoCss(basePath) {
619
+ const id = "monaco-editor-css";
620
+ if (document.getElementById(id)) return;
621
+ const link = document.createElement("link");
622
+ link.id = id;
623
+ link.rel = "stylesheet";
624
+ link.href = `${basePath}/vs/editor/editor.main.css`;
625
+ document.head.appendChild(link);
626
+ }
627
+ async function initMonaco(basePath) {
628
+ if (!basePath) {
629
+ throw new Error(
630
+ "[twm/editor] initMonaco() needs the path to your copy of Monaco, e.g. initMonaco('vendor/monaco'). Monaco is a peer dependency; the library does not guess where you put it."
631
+ );
632
+ }
633
+ if (_monacoReady) return monacoReady;
634
+ _monacoReady = true;
635
+ injectMonacoCss(basePath);
636
+ if (!window.require) {
637
+ await injectLoaderScript(`${basePath}/vs/loader.js`);
638
+ }
639
+ window.require.config({
640
+ paths: { vs: `${basePath}/vs` },
641
+ "vs/nls": { availableLanguages: {} }
642
+ });
643
+ window.require(["vs/editor/editor.main"], () => {
644
+ _resolveMonaco(window.monaco);
645
+ });
646
+ return monacoReady;
647
+ }
648
+
649
+ // src/editor/monaco_editor_factory.js
650
+ var _defaultLanguage = "plaintext";
651
+ function setDefaultLanguage(id) {
652
+ if (typeof id === "string" && id) _defaultLanguage = id;
653
+ }
654
+ function safeDispose(editor) {
655
+ try {
656
+ editor.dispose();
657
+ } catch (e) {
658
+ if (e?.message !== "Canceled") throw e;
659
+ }
660
+ }
661
+ var ECOSIM_DARK_THEME = {
662
+ base: "vs-dark",
663
+ inherit: true,
664
+ rules: [
665
+ { token: "comment", foreground: "6a9955", fontStyle: "italic" },
666
+ { token: "keyword", foreground: "569cd6", fontStyle: "bold" },
667
+ { token: "keyword.directive", foreground: "c586c0", fontStyle: "bold" },
668
+ { token: "keyword.operator", foreground: "569cd6" },
669
+ { token: "constant", foreground: "c586c0" },
670
+ { token: "number", foreground: "b5cea8" },
671
+ { token: "string", foreground: "ce9178" },
672
+ { token: "type.identifier", foreground: "4ec9b0", fontStyle: "bold" },
673
+ { token: "support.function", foreground: "a6e22e" },
674
+ { token: "identifier", foreground: "4fc1ff" },
675
+ { token: "operator", foreground: "d4d4d4" },
676
+ { token: "delimiter", foreground: "808080" }
677
+ ],
678
+ colors: {
679
+ // Layer-2 input well — almost black, matches the EcoAgent
680
+ // three-layer contrast pattern (chrome #1a1a1a, content
681
+ // #212121, input/code #0a0a0a). Makes editable surfaces
682
+ // visually recess from the surrounding cards.
683
+ "editor.background": "#0a0a0a",
684
+ "editor.foreground": "#e0e0e0",
685
+ "editor.lineHighlightBackground": "#161616",
686
+ "editor.selectionBackground": "#264f78",
687
+ "editor.inactiveSelectionBackground": "#1f3350",
688
+ "editorLineNumber.foreground": "#5a5a5a",
689
+ "editorLineNumber.activeForeground": "#c6c6c6",
690
+ "editorCursor.foreground": "#39ff14",
691
+ "editor.findMatchBackground": "#9e6a03",
692
+ "editor.findMatchHighlightBackground": "#f2cc6030",
693
+ "editorIndentGuide.background1": "#2e2e2e",
694
+ "editorIndentGuide.activeBackground1": "#5a5a5a",
695
+ "editorGutter.background": "#0a0a0a",
696
+ // Pop-ups stay slightly lighter so they read as floating
697
+ // surfaces above the editor body, not part of it.
698
+ "editorWidget.background": "#1a1a1a",
699
+ "editorSuggestWidget.background": "#1a1a1a",
700
+ "editorSuggestWidget.border": "#383838",
701
+ "editorSuggestWidget.selectedBackground": "#094771",
702
+ "scrollbarSlider.background": "#4a4a4d40",
703
+ "scrollbarSlider.hoverBackground": "#4a4a4d70"
704
+ }
705
+ };
706
+ var ECOSIM_LIGHT_THEME = {
707
+ base: "vs",
708
+ inherit: true,
709
+ rules: [
710
+ { token: "comment", foreground: "6a737d", fontStyle: "italic" },
711
+ { token: "keyword", foreground: "d73a49", fontStyle: "bold" },
712
+ { token: "keyword.directive", foreground: "6f42c1", fontStyle: "bold" },
713
+ { token: "keyword.operator", foreground: "d73a49" },
714
+ { token: "constant", foreground: "e36209" },
715
+ { token: "number", foreground: "005cc5" },
716
+ { token: "string", foreground: "22863a" },
717
+ { token: "type.identifier", foreground: "005cc5", fontStyle: "bold" },
718
+ { token: "support.function", foreground: "6f42c1" },
719
+ { token: "identifier", foreground: "24292e" },
720
+ { token: "operator", foreground: "d73a49" },
721
+ { token: "delimiter", foreground: "586069" }
722
+ ],
723
+ colors: {
724
+ "editor.background": "#ffffff",
725
+ "editor.foreground": "#24292e",
726
+ "editor.lineHighlightBackground": "#f6f8fa",
727
+ "editor.selectionBackground": "#c8d3e6",
728
+ "editorLineNumber.foreground": "#babbbd",
729
+ "editorLineNumber.activeForeground": "#586069",
730
+ "editorCursor.foreground": "#044289",
731
+ "editorGutter.background": "#ffffff",
732
+ "editorWidget.background": "#f6f8fa",
733
+ "editorSuggestWidget.background": "#f6f8fa",
734
+ "editorSuggestWidget.border": "#e1e4e8",
735
+ "scrollbarSlider.background": "#babbbd40",
736
+ "scrollbarSlider.hoverBackground": "#babbbd70"
737
+ }
738
+ };
739
+ var _themesRegistered = false;
740
+ function ensureThemes(monaco) {
741
+ if (_themesRegistered) return;
742
+ _themesRegistered = true;
743
+ monaco.editor.defineTheme("ecosim-dark", ECOSIM_DARK_THEME);
744
+ monaco.editor.defineTheme("ecosim-light", ECOSIM_LIGHT_THEME);
745
+ }
746
+ function baseOptions(overrides = {}) {
747
+ return {
748
+ language: _defaultLanguage,
749
+ theme: "ecosim-dark",
750
+ fontFamily: 'Consolas, "Courier New", monospace',
751
+ fontSize: 13,
752
+ lineHeight: 19.5,
753
+ tabSize: 4,
754
+ insertSpaces: true,
755
+ automaticLayout: true,
756
+ minimap: { enabled: false },
757
+ scrollBeyondLastLine: false,
758
+ renderLineHighlight: "gutter",
759
+ matchBrackets: "always",
760
+ padding: { top: 8, bottom: 8 },
761
+ overviewRulerLanes: 0,
762
+ hideCursorInOverviewRuler: true,
763
+ overviewRulerBorder: false,
764
+ scrollbar: { handleMouseWheel: false },
765
+ folding: true,
766
+ lineDecorationsWidth: 0,
767
+ // Reserve a couple extra gutter columns so the right-aligned line
768
+ // numbers get some breathing room on the left instead of sitting flush
769
+ // against the editor edge (digits stay right-aligned; the slack lands
770
+ // on the left). Covers up to 5-digit files too.
771
+ lineNumbersMinChars: 5,
772
+ suggest: {
773
+ showKeywords: true,
774
+ showFunctions: true,
775
+ showVariables: true,
776
+ showConstants: true,
777
+ showModules: true,
778
+ snippetsPreventQuickSuggestions: false
779
+ },
780
+ quickSuggestions: { other: true, comments: false, strings: false },
781
+ parameterHints: { enabled: true },
782
+ wordWrap: "off",
783
+ fixedOverflowWidgets: true,
784
+ ...overrides
785
+ };
786
+ }
787
+ function singleLineOptions(overrides = {}) {
788
+ return baseOptions({
789
+ lineNumbers: "off",
790
+ lineDecorationsWidth: 0,
791
+ lineNumbersMinChars: 0,
792
+ glyphMargin: false,
793
+ folding: false,
794
+ renderLineHighlight: "none",
795
+ scrollbar: { vertical: "hidden", horizontal: "hidden", handleMouseWheel: false },
796
+ wordWrap: "off",
797
+ overviewRulerLanes: 0,
798
+ padding: { top: 3, bottom: 3 },
799
+ ...overrides
800
+ });
801
+ }
802
+ var MonacoEditorFactory = class {
803
+ #monaco = null;
804
+ #currentTheme = "ecosim-dark";
805
+ constructor() {
806
+ }
807
+ async init() {
808
+ this.#monaco = await monacoReady;
809
+ ensureThemes(this.#monaco);
810
+ return this;
811
+ }
812
+ get monaco() {
813
+ return this.#monaco;
814
+ }
815
+ /**
816
+ * Set global theme for all editors.
817
+ * @param {'ecosim-dark'|'ecosim-light'} theme
818
+ */
819
+ setTheme(theme) {
820
+ this.#currentTheme = theme;
821
+ this.#monaco?.editor.setTheme(theme);
822
+ }
823
+ /**
824
+ * Create a full multi-line editor (for CodeCell).
825
+ *
826
+ * @param {HTMLElement} container
827
+ * @param {string} initialValue
828
+ * @param {{ language?: string, readOnly?: boolean, lineNumbers?: string } & object} [overrides]
829
+ * @returns {EditorHandle}
830
+ */
831
+ createEditor(container, initialValue = "", overrides = {}) {
832
+ if (!this.#monaco) throw new Error("MonacoEditorFactory not initialized");
833
+ const { noAutoHeight, ...editorOverrides } = overrides;
834
+ const editor = this.#monaco.editor.create(container, {
835
+ ...baseOptions({ theme: this.#currentTheme }),
836
+ value: initialValue,
837
+ ...editorOverrides
838
+ });
839
+ let minHeight = 80;
840
+ let heightDisposable = null;
841
+ let updateHeight = null;
842
+ if (noAutoHeight) {
843
+ editor.updateOptions({ scrollbar: { handleMouseWheel: true } });
844
+ } else {
845
+ updateHeight = () => {
846
+ const maxHeightEnabled = container.closest(".notebook-editor-container")?.dataset.codeMaxHeight === "true";
847
+ const maxH = maxHeightEnabled ? 400 : Infinity;
848
+ const rawContent = editor.getContentHeight();
849
+ const contentHeight = Math.min(maxH, Math.max(minHeight, rawContent));
850
+ container.style.height = `${contentHeight}px`;
851
+ editor.layout();
852
+ const needsInternalScroll = maxHeightEnabled && rawContent > maxH;
853
+ editor.updateOptions({ scrollbar: { handleMouseWheel: needsInternalScroll } });
854
+ };
855
+ heightDisposable = editor.onDidContentSizeChange(updateHeight);
856
+ updateHeight();
857
+ }
858
+ const editorContainer = container.closest(".notebook-editor-container");
859
+ let maxHeightObserver = null;
860
+ if (updateHeight && editorContainer) {
861
+ maxHeightObserver = new MutationObserver(() => updateHeight());
862
+ maxHeightObserver.observe(editorContainer, { attributes: true, attributeFilter: ["data-code-max-height"] });
863
+ }
864
+ let findWidgetObserver = null;
865
+ const observeFindWidget = () => {
866
+ const overflowGuard = container.querySelector(".overflow-guard");
867
+ if (!overflowGuard) return;
868
+ findWidgetObserver = new MutationObserver(() => {
869
+ const fw = container.querySelector(".find-widget.visible");
870
+ if (fw) fw.scrollIntoView({ block: "nearest", behavior: "smooth" });
871
+ });
872
+ findWidgetObserver.observe(overflowGuard, {
873
+ subtree: true,
874
+ attributes: true,
875
+ attributeFilter: ["class"]
876
+ });
877
+ };
878
+ requestAnimationFrame(observeFindWidget);
879
+ return {
880
+ editor,
881
+ getValue: () => editor.getValue(),
882
+ setValue: (v) => {
883
+ if (editor.getValue() !== v) editor.setValue(v);
884
+ },
885
+ onDidChange: (cb) => editor.onDidChangeModelContent(cb),
886
+ focus: () => editor.focus(),
887
+ layout: () => editor.layout(),
888
+ setMinHeight: (h) => {
889
+ minHeight = h;
890
+ },
891
+ updateHeight,
892
+ dispose: () => {
893
+ findWidgetObserver?.disconnect();
894
+ maxHeightObserver?.disconnect();
895
+ heightDisposable?.dispose();
896
+ safeDispose(editor);
897
+ }
898
+ };
899
+ }
900
+ /**
901
+ * Create a single-line inline editor (for ParameterCell value, expression fields).
902
+ *
903
+ * @param {HTMLElement} container
904
+ * @param {string} initialValue
905
+ * @param {{ language?: string, placeholder?: string } & object} [overrides]
906
+ * @returns {EditorHandle}
907
+ */
908
+ createInlineEditor(container, initialValue = "", overrides = {}) {
909
+ if (!this.#monaco) throw new Error("MonacoEditorFactory not initialized");
910
+ container.style.height = "26px";
911
+ const editor = this.#monaco.editor.create(container, {
912
+ ...singleLineOptions({ theme: this.#currentTheme }),
913
+ value: initialValue,
914
+ ...overrides
915
+ });
916
+ editor.onKeyDown((e) => {
917
+ if (e.keyCode === this.#monaco.KeyCode.Enter) {
918
+ e.preventDefault();
919
+ e.stopPropagation();
920
+ }
921
+ });
922
+ return {
923
+ editor,
924
+ getValue: () => editor.getValue(),
925
+ setValue: (v) => {
926
+ if (editor.getValue() !== v) editor.setValue(v);
927
+ },
928
+ onDidChange: (cb) => editor.onDidChangeModelContent(cb),
929
+ focus: () => editor.focus(),
930
+ layout: () => editor.layout(),
931
+ dispose: () => safeDispose(editor)
932
+ };
933
+ }
934
+ /**
935
+ * Create a plain text editor (for documentation cells with markdown).
936
+ * Uses VS Code's built-in markdown language, no EcoLang features.
937
+ *
938
+ * @param {HTMLElement} container
939
+ * @param {string} initialValue
940
+ * @returns {EditorHandle}
941
+ */
942
+ createMarkdownEditor(container, initialValue = "") {
943
+ return this.createEditor(container, initialValue, {
944
+ language: "markdown",
945
+ wordWrap: "on",
946
+ lineNumbers: "off",
947
+ folding: false,
948
+ quickSuggestions: false
949
+ });
950
+ }
951
+ /**
952
+ * Create a read-only DSL viewer (shown in the "DSL" tab of a Godley/parameter cell).
953
+ *
954
+ * @param {HTMLElement} container
955
+ * @param {string} dslContent
956
+ * @returns {EditorHandle}
957
+ */
958
+ createDslViewer(container, dslContent = "") {
959
+ return this.createEditor(container, dslContent, {
960
+ readOnly: true,
961
+ lineNumbers: "off",
962
+ glyphMargin: false,
963
+ folding: false
964
+ });
965
+ }
966
+ /**
967
+ * Create a side-by-side diff editor for comparing two versions of a file.
968
+ *
969
+ * @param {HTMLElement} container
970
+ * @param {string} originalContent - Left side (e.g. HEAD version)
971
+ * @param {string} modifiedContent - Right side (e.g. working copy)
972
+ * @param {{ language?: string, readOnly?: boolean }} [options]
973
+ * @returns {{ editor: object, dispose: Function, setModels: Function }}
974
+ */
975
+ createDiffEditor(container, originalContent = "", modifiedContent = "", options = {}) {
976
+ const monaco = this.#monaco;
977
+ const language = options.language || "plaintext";
978
+ const originalModel = monaco.editor.createModel(originalContent, language);
979
+ const modifiedModel = monaco.editor.createModel(modifiedContent, language);
980
+ const diffEditor = monaco.editor.createDiffEditor(container, {
981
+ theme: this.#currentTheme,
982
+ readOnly: options.readOnly !== false,
983
+ renderSideBySide: true,
984
+ automaticLayout: true,
985
+ minimap: { enabled: false },
986
+ scrollBeyondLastLine: false,
987
+ lineNumbers: "on",
988
+ glyphMargin: false,
989
+ folding: true,
990
+ renderOverviewRuler: false,
991
+ enableSplitViewResizing: true
992
+ });
993
+ diffEditor.setModel({ original: originalModel, modified: modifiedModel });
994
+ return {
995
+ editor: diffEditor,
996
+ setModels(original, modified, lang) {
997
+ originalModel.dispose();
998
+ modifiedModel.dispose();
999
+ const newOrig = monaco.editor.createModel(original, lang || language);
1000
+ const newMod = monaco.editor.createModel(modified, lang || language);
1001
+ diffEditor.setModel({ original: newOrig, modified: newMod });
1002
+ },
1003
+ dispose() {
1004
+ safeDispose(diffEditor);
1005
+ safeDispose(originalModel);
1006
+ safeDispose(modifiedModel);
1007
+ }
1008
+ };
1009
+ }
1010
+ };
1011
+ var _factoryInstance = null;
1012
+ var _monacoBasePath = null;
1013
+ function setMonacoBasePath(basePath) {
1014
+ if (typeof basePath === "string" && basePath) _monacoBasePath = basePath;
1015
+ }
1016
+ async function getEditorFactory() {
1017
+ if (!_factoryInstance) {
1018
+ initMonaco(_monacoBasePath);
1019
+ _factoryInstance = await new MonacoEditorFactory().init();
1020
+ }
1021
+ return _factoryInstance;
1022
+ }
1023
+
1024
+ // src/editor/notebook_search.js
1025
+ var NotebookSearchBar = class {
1026
+ /** @type {HTMLElement} parent container (.notebook-editor-container) */
1027
+ #parent = null;
1028
+ /** @type {HTMLElement} the search bar root element */
1029
+ #el = null;
1030
+ /** @type {HTMLInputElement} */
1031
+ #searchInput = null;
1032
+ /** @type {HTMLInputElement} */
1033
+ #replaceInput = null;
1034
+ /** @type {HTMLElement} match info label */
1035
+ #matchInfo = null;
1036
+ // State
1037
+ #isOpen = false;
1038
+ #showReplace = false;
1039
+ #caseSensitive = false;
1040
+ #wholeWord = false;
1041
+ #useRegex = false;
1042
+ // Match tracking
1043
+ /** @type {SearchMatch[]} */
1044
+ #matches = [];
1045
+ #currentIndex = -1;
1046
+ // Callbacks into NotebookEditor
1047
+ #getCells = null;
1048
+ // () => [{ id, type }]
1049
+ #getCellRenderer = null;
1050
+ // (id) => CellBase|null
1051
+ #getCellElement = null;
1052
+ // (id) => HTMLElement|null
1053
+ #scrollToCell = null;
1054
+ // (id) => void
1055
+ /**
1056
+ * @param {HTMLElement} parent — .notebook-editor-container
1057
+ * @param {{ getCells, getCellRenderer, getCellElement, scrollToCell }} callbacks
1058
+ */
1059
+ constructor(parent, { getCells, getCellRenderer, getCellElement, scrollToCell }) {
1060
+ this.#parent = parent;
1061
+ this.#getCells = getCells;
1062
+ this.#getCellRenderer = getCellRenderer;
1063
+ this.#getCellElement = getCellElement;
1064
+ this.#scrollToCell = scrollToCell;
1065
+ this.#render();
1066
+ }
1067
+ get isOpen() {
1068
+ return this.#isOpen;
1069
+ }
1070
+ // ─── Public API ──────────────────────────────────────────────────────────
1071
+ open(showReplace = false) {
1072
+ this.#isOpen = true;
1073
+ this.#showReplace = showReplace;
1074
+ this.#el.classList.add("nb-search-bar--open");
1075
+ this.#el.querySelector(".nb-search-bar__replace-row").style.display = showReplace ? "" : "none";
1076
+ this.#updateToggleIcon();
1077
+ const sel = window.getSelection()?.toString()?.trim();
1078
+ if (sel && sel.length < 200 && !sel.includes("\n")) {
1079
+ this.#searchInput.value = sel;
1080
+ }
1081
+ this.#searchInput.focus();
1082
+ this.#searchInput.select();
1083
+ if (this.#searchInput.value) this.#runSearch();
1084
+ }
1085
+ close() {
1086
+ this.#isOpen = false;
1087
+ this.#el.classList.remove("nb-search-bar--open");
1088
+ this.#clearAllHighlights();
1089
+ this.#matches = [];
1090
+ this.#currentIndex = -1;
1091
+ this.#updateMatchInfo();
1092
+ }
1093
+ dispose() {
1094
+ this.#clearAllHighlights();
1095
+ this.#el?.remove();
1096
+ this.#el = null;
1097
+ }
1098
+ // ─── Rendering ───────────────────────────────────────────────────────────
1099
+ #render() {
1100
+ const el = document.createElement("div");
1101
+ el.className = "nb-search-bar";
1102
+ el.innerHTML = `
1103
+ <div class="nb-search-bar__find-row">
1104
+ <button class="nb-search-bar__btn nb-search-bar__toggle-replace" title="Toggle Replace">
1105
+ <span class="material-symbols-outlined">chevron_right</span>
1106
+ </button>
1107
+ <div class="nb-search-bar__input-wrap">
1108
+ <input class="nb-search-bar__input nb-search-bar__find-input"
1109
+ type="text" placeholder="Find" spellcheck="false" />
1110
+ </div>
1111
+ <span class="nb-search-bar__match-info">No results</span>
1112
+ <button class="nb-search-bar__btn" data-action="prev" title="Previous Match (Shift+Enter)">
1113
+ <span class="material-symbols-outlined">arrow_upward</span>
1114
+ </button>
1115
+ <button class="nb-search-bar__btn" data-action="next" title="Next Match (Enter)">
1116
+ <span class="material-symbols-outlined">arrow_downward</span>
1117
+ </button>
1118
+ <div class="nb-search-bar__separator"></div>
1119
+ <button class="nb-search-bar__btn nb-search-bar__btn--toggle" data-action="case"
1120
+ title="Match Case (Alt+C)">Aa</button>
1121
+ <button class="nb-search-bar__btn nb-search-bar__btn--toggle" data-action="word"
1122
+ title="Match Whole Word (Alt+W)">
1123
+ <span class="nb-search-bar__icon-word">ab</span>
1124
+ </button>
1125
+ <button class="nb-search-bar__btn nb-search-bar__btn--toggle" data-action="regex"
1126
+ title="Use Regular Expression (Alt+R)">.*</button>
1127
+ <div class="nb-search-bar__separator"></div>
1128
+ <button class="nb-search-bar__btn" data-action="close" title="Close (Escape)">
1129
+ <span class="material-symbols-outlined">close</span>
1130
+ </button>
1131
+ </div>
1132
+ <div class="nb-search-bar__replace-row" style="display:none">
1133
+ <div class="nb-search-bar__spacer"></div>
1134
+ <div class="nb-search-bar__input-wrap">
1135
+ <input class="nb-search-bar__input nb-search-bar__replace-input"
1136
+ type="text" placeholder="Replace" spellcheck="false" />
1137
+ </div>
1138
+ <button class="nb-search-bar__btn" data-action="replace" title="Replace (Ctrl+Shift+1)">
1139
+ <span class="material-symbols-outlined">find_replace</span>
1140
+ </button>
1141
+ <button class="nb-search-bar__btn" data-action="replace-all" title="Replace All (Ctrl+Alt+Enter)">
1142
+ <span class="material-symbols-outlined">swap_horiz</span>
1143
+ </button>
1144
+ </div>
1145
+ `;
1146
+ this.#el = el;
1147
+ this.#searchInput = el.querySelector(".nb-search-bar__find-input");
1148
+ this.#replaceInput = el.querySelector(".nb-search-bar__replace-input");
1149
+ this.#matchInfo = el.querySelector(".nb-search-bar__match-info");
1150
+ this.#bindEvents();
1151
+ this.#parent.prepend(el);
1152
+ }
1153
+ #bindEvents() {
1154
+ this.#searchInput.addEventListener("input", () => this.#runSearch());
1155
+ this.#searchInput.addEventListener("keydown", (e) => {
1156
+ if (e.key === "Enter" && !e.shiftKey) {
1157
+ e.preventDefault();
1158
+ this.#navigateNext();
1159
+ } else if (e.key === "Enter" && e.shiftKey) {
1160
+ e.preventDefault();
1161
+ this.#navigatePrev();
1162
+ } else if (e.key === "Escape") {
1163
+ e.preventDefault();
1164
+ this.close();
1165
+ }
1166
+ });
1167
+ this.#replaceInput.addEventListener("keydown", (e) => {
1168
+ if (e.key === "Enter" && !e.shiftKey) {
1169
+ e.preventDefault();
1170
+ this.#doReplace();
1171
+ } else if (e.key === "Escape") {
1172
+ e.preventDefault();
1173
+ this.close();
1174
+ }
1175
+ });
1176
+ this.#el.addEventListener("click", (e) => {
1177
+ const btn = e.target.closest("[data-action]");
1178
+ if (!btn) return;
1179
+ switch (btn.dataset.action) {
1180
+ case "prev":
1181
+ this.#navigatePrev();
1182
+ break;
1183
+ case "next":
1184
+ this.#navigateNext();
1185
+ break;
1186
+ case "close":
1187
+ this.close();
1188
+ break;
1189
+ case "replace":
1190
+ this.#doReplace();
1191
+ break;
1192
+ case "replace-all":
1193
+ this.#doReplaceAll();
1194
+ break;
1195
+ case "case":
1196
+ this.#caseSensitive = !this.#caseSensitive;
1197
+ btn.classList.toggle("nb-search-bar__btn--active", this.#caseSensitive);
1198
+ this.#runSearch();
1199
+ break;
1200
+ case "word":
1201
+ this.#wholeWord = !this.#wholeWord;
1202
+ btn.classList.toggle("nb-search-bar__btn--active", this.#wholeWord);
1203
+ this.#runSearch();
1204
+ break;
1205
+ case "regex":
1206
+ this.#useRegex = !this.#useRegex;
1207
+ btn.classList.toggle("nb-search-bar__btn--active", this.#useRegex);
1208
+ this.#runSearch();
1209
+ break;
1210
+ }
1211
+ });
1212
+ this.#el.querySelector(".nb-search-bar__toggle-replace").addEventListener("click", () => {
1213
+ this.#showReplace = !this.#showReplace;
1214
+ this.#el.querySelector(".nb-search-bar__replace-row").style.display = this.#showReplace ? "" : "none";
1215
+ this.#updateToggleIcon();
1216
+ });
1217
+ this.#el.addEventListener("keydown", (e) => {
1218
+ if (e.altKey && e.key === "c") {
1219
+ e.preventDefault();
1220
+ this.#caseSensitive = !this.#caseSensitive;
1221
+ this.#el.querySelector('[data-action="case"]').classList.toggle("nb-search-bar__btn--active", this.#caseSensitive);
1222
+ this.#runSearch();
1223
+ } else if (e.altKey && e.key === "w") {
1224
+ e.preventDefault();
1225
+ this.#wholeWord = !this.#wholeWord;
1226
+ this.#el.querySelector('[data-action="word"]').classList.toggle("nb-search-bar__btn--active", this.#wholeWord);
1227
+ this.#runSearch();
1228
+ } else if (e.altKey && e.key === "r") {
1229
+ e.preventDefault();
1230
+ this.#useRegex = !this.#useRegex;
1231
+ this.#el.querySelector('[data-action="regex"]').classList.toggle("nb-search-bar__btn--active", this.#useRegex);
1232
+ this.#runSearch();
1233
+ }
1234
+ });
1235
+ this.#el.addEventListener("keydown", (e) => {
1236
+ e.stopPropagation();
1237
+ if (e.ctrlKey && (e.key === "f" || e.key === "h")) {
1238
+ e.preventDefault();
1239
+ if (e.key === "h" && !this.#showReplace) {
1240
+ this.#showReplace = true;
1241
+ this.#el.querySelector(".nb-search-bar__replace-row").style.display = "";
1242
+ this.#updateToggleIcon();
1243
+ }
1244
+ }
1245
+ });
1246
+ }
1247
+ #updateToggleIcon() {
1248
+ const icon = this.#el.querySelector(".nb-search-bar__toggle-replace .material-symbols-outlined");
1249
+ if (icon) icon.textContent = this.#showReplace ? "expand_more" : "chevron_right";
1250
+ }
1251
+ #updateMatchInfo() {
1252
+ if (!this.#matchInfo) return;
1253
+ if (this.#matches.length === 0) {
1254
+ const hasQuery = this.#searchInput?.value?.length > 0;
1255
+ this.#matchInfo.textContent = hasQuery ? "No results" : "";
1256
+ this.#matchInfo.classList.toggle("nb-search-bar__match-info--no-results", hasQuery);
1257
+ } else {
1258
+ this.#matchInfo.textContent = `${this.#currentIndex + 1} of ${this.#matches.length}`;
1259
+ this.#matchInfo.classList.remove("nb-search-bar__match-info--no-results");
1260
+ }
1261
+ }
1262
+ // ─── Search logic ────────────────────────────────────────────────────────
1263
+ #buildQuery() {
1264
+ const raw = this.#searchInput.value;
1265
+ if (!raw) return null;
1266
+ let pattern;
1267
+ if (this.#useRegex) {
1268
+ try {
1269
+ pattern = raw;
1270
+ new RegExp(pattern);
1271
+ } catch {
1272
+ return null;
1273
+ }
1274
+ } else {
1275
+ pattern = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1276
+ }
1277
+ if (this.#wholeWord) pattern = `\\b${pattern}\\b`;
1278
+ const flags = "g" + (this.#caseSensitive ? "" : "i");
1279
+ try {
1280
+ return new RegExp(pattern, flags);
1281
+ } catch {
1282
+ return null;
1283
+ }
1284
+ }
1285
+ #runSearch() {
1286
+ this.#clearAllHighlights();
1287
+ this.#matches = [];
1288
+ this.#currentIndex = -1;
1289
+ const query = this.#buildQuery();
1290
+ if (!query) {
1291
+ this.#updateMatchInfo();
1292
+ return;
1293
+ }
1294
+ const cells = this.#getCells();
1295
+ for (const { id } of cells) {
1296
+ const renderer = this.#getCellRenderer(id);
1297
+ if (!renderer) continue;
1298
+ const text = renderer.getSearchableText();
1299
+ if (!text) continue;
1300
+ query.lastIndex = 0;
1301
+ let m;
1302
+ let indexInCell = 0;
1303
+ while ((m = query.exec(text)) !== null) {
1304
+ this.#matches.push({ cellId: id, indexInCell, charOffset: m.index });
1305
+ indexInCell++;
1306
+ if (!query.global) break;
1307
+ }
1308
+ }
1309
+ if (this.#matches.length > 0) {
1310
+ this.#currentIndex = 0;
1311
+ this.#applyHighlights();
1312
+ }
1313
+ this.#updateMatchInfo();
1314
+ }
1315
+ // ─── Navigation ──────────────────────────────────────────────────────────
1316
+ #navigateNext() {
1317
+ if (this.#matches.length === 0) return;
1318
+ this.#currentIndex = (this.#currentIndex + 1) % this.#matches.length;
1319
+ this.#applyHighlights();
1320
+ this.#scrollToCurrentMatch();
1321
+ this.#updateMatchInfo();
1322
+ }
1323
+ #navigatePrev() {
1324
+ if (this.#matches.length === 0) return;
1325
+ this.#currentIndex = (this.#currentIndex - 1 + this.#matches.length) % this.#matches.length;
1326
+ this.#applyHighlights();
1327
+ this.#scrollToCurrentMatch();
1328
+ this.#updateMatchInfo();
1329
+ }
1330
+ #scrollToCurrentMatch() {
1331
+ const match = this.#matches[this.#currentIndex];
1332
+ if (!match) return;
1333
+ this.#scrollToCell(match.cellId);
1334
+ requestAnimationFrame(() => {
1335
+ const cellEl = this.#getCellElement(match.cellId);
1336
+ if (!cellEl) return;
1337
+ const active = cellEl.querySelector(".nb-search-match--active");
1338
+ if (active) active.scrollIntoView({ block: "nearest", behavior: "smooth" });
1339
+ });
1340
+ }
1341
+ // ─── Highlighting ────────────────────────────────────────────────────────
1342
+ #applyHighlights() {
1343
+ const query = this.#buildQuery();
1344
+ if (!query) return;
1345
+ const matchesByCell = /* @__PURE__ */ new Map();
1346
+ for (const m of this.#matches) {
1347
+ if (!matchesByCell.has(m.cellId)) matchesByCell.set(m.cellId, []);
1348
+ matchesByCell.get(m.cellId).push(m);
1349
+ }
1350
+ const currentMatch = this.#matches[this.#currentIndex];
1351
+ const cells = this.#getCells();
1352
+ for (const { id } of cells) {
1353
+ const renderer = this.#getCellRenderer(id);
1354
+ if (!renderer) continue;
1355
+ const cellEl = this.#getCellElement(id);
1356
+ if (matchesByCell.has(id)) {
1357
+ const activeOffset = currentMatch?.cellId === id ? currentMatch.charOffset : -1;
1358
+ renderer.applySearchHighlights(query, activeOffset);
1359
+ cellEl?.classList.add("notebook-cell--search-match");
1360
+ cellEl?.classList.toggle("notebook-cell--search-active", currentMatch?.cellId === id);
1361
+ } else {
1362
+ renderer.clearSearchHighlights();
1363
+ cellEl?.classList.remove("notebook-cell--search-match", "notebook-cell--search-active");
1364
+ }
1365
+ }
1366
+ }
1367
+ #clearAllHighlights() {
1368
+ const cells = this.#getCells();
1369
+ for (const { id } of cells) {
1370
+ const renderer = this.#getCellRenderer(id);
1371
+ renderer?.clearSearchHighlights();
1372
+ const cellEl = this.#getCellElement(id);
1373
+ cellEl?.classList.remove("notebook-cell--search-match", "notebook-cell--search-active");
1374
+ }
1375
+ }
1376
+ // ─── Replace ─────────────────────────────────────────────────────────────
1377
+ #doReplace() {
1378
+ if (this.#matches.length === 0 || this.#currentIndex < 0) return;
1379
+ const match = this.#matches[this.#currentIndex];
1380
+ const renderer = this.#getCellRenderer(match.cellId);
1381
+ if (!renderer) return;
1382
+ const query = this.#buildQuery();
1383
+ if (!query) return;
1384
+ const replacement = this.#replaceInput.value;
1385
+ const success = renderer.replaceMatch(query, match.indexInCell, replacement);
1386
+ if (success) {
1387
+ const wasIndex = this.#currentIndex;
1388
+ this.#runSearch();
1389
+ if (this.#matches.length > 0) {
1390
+ this.#currentIndex = Math.min(wasIndex, this.#matches.length - 1);
1391
+ this.#applyHighlights();
1392
+ this.#scrollToCurrentMatch();
1393
+ this.#updateMatchInfo();
1394
+ }
1395
+ }
1396
+ }
1397
+ #doReplaceAll() {
1398
+ const query = this.#buildQuery();
1399
+ if (!query || this.#matches.length === 0) return;
1400
+ const replacement = this.#replaceInput.value;
1401
+ const cellIds = [...new Set(this.#matches.map((m) => m.cellId))];
1402
+ let totalReplaced = 0;
1403
+ for (const cellId of cellIds) {
1404
+ const renderer = this.#getCellRenderer(cellId);
1405
+ if (renderer) totalReplaced += renderer.replaceAll(query, replacement);
1406
+ }
1407
+ if (totalReplaced > 0) this.#runSearch();
1408
+ }
1409
+ };
1410
+
1411
+ // src/editor/notebook_undo_manager.js
1412
+ var NotebookUndoManager = class {
1413
+ /** @type {Array<{ description: string, undo: Function, redo: Function }>} */
1414
+ #undoStack = [];
1415
+ /** @type {Array<{ description: string, undo: Function, redo: Function }>} */
1416
+ #redoStack = [];
1417
+ /** @type {number} */
1418
+ #maxSize;
1419
+ /** @type {Function|null} Called with (canUndo: boolean, canRedo: boolean) after each change */
1420
+ #onStateChange;
1421
+ /** @type {boolean} Prevents re-entrant undo/redo */
1422
+ #applying = false;
1423
+ /**
1424
+ * @param {{ maxSize?: number, onStateChange?: Function }} opts
1425
+ */
1426
+ constructor({ maxSize = 100, onStateChange = null } = {}) {
1427
+ this.#maxSize = maxSize;
1428
+ this.#onStateChange = onStateChange;
1429
+ }
1430
+ // ─── Public API ───────────────────────────────────────────────────────────
1431
+ get canUndo() {
1432
+ return this.#undoStack.length > 0;
1433
+ }
1434
+ get canRedo() {
1435
+ return this.#redoStack.length > 0;
1436
+ }
1437
+ /**
1438
+ * Record a new command. Clears the redo stack.
1439
+ * Should NOT be called from inside undo/redo callbacks.
1440
+ *
1441
+ * @param {{ description: string, undo: Function, redo: Function }} command
1442
+ */
1443
+ push(command) {
1444
+ if (this.#applying) return;
1445
+ this.#undoStack.push(command);
1446
+ if (this.#undoStack.length > this.#maxSize) {
1447
+ this.#undoStack.shift();
1448
+ }
1449
+ this.#redoStack = [];
1450
+ this.#notify();
1451
+ }
1452
+ /**
1453
+ * Undo the last command.
1454
+ * @returns {Promise<boolean>} true if something was undone
1455
+ */
1456
+ async undo() {
1457
+ const cmd = this.#undoStack.pop();
1458
+ if (!cmd) return false;
1459
+ this.#applying = true;
1460
+ try {
1461
+ await cmd.undo();
1462
+ } finally {
1463
+ this.#applying = false;
1464
+ }
1465
+ this.#redoStack.push(cmd);
1466
+ this.#notify();
1467
+ return true;
1468
+ }
1469
+ /**
1470
+ * Redo the last undone command.
1471
+ * @returns {Promise<boolean>} true if something was redone
1472
+ */
1473
+ async redo() {
1474
+ const cmd = this.#redoStack.pop();
1475
+ if (!cmd) return false;
1476
+ this.#applying = true;
1477
+ try {
1478
+ await cmd.redo();
1479
+ } finally {
1480
+ this.#applying = false;
1481
+ }
1482
+ this.#undoStack.push(cmd);
1483
+ this.#notify();
1484
+ return true;
1485
+ }
1486
+ /** Reset both stacks (e.g., after file load). */
1487
+ clear() {
1488
+ this.#undoStack = [];
1489
+ this.#redoStack = [];
1490
+ this.#notify();
1491
+ }
1492
+ // ─── Private ──────────────────────────────────────────────────────────────
1493
+ #notify() {
1494
+ this.#onStateChange?.(this.canUndo, this.canRedo);
1495
+ }
1496
+ };
1497
+
1498
+ // src/editor/split_pane_container.js
1499
+ var SplitPaneContainer = class {
1500
+ /** @type {HTMLElement} */
1501
+ #container = null;
1502
+ /** @type {HTMLElement} */
1503
+ #pane1 = null;
1504
+ /** @type {HTMLElement} */
1505
+ #pane2 = null;
1506
+ /** @type {HTMLElement} */
1507
+ #divider = null;
1508
+ /** @type {number} 0..1, fraction of width for pane 1 */
1509
+ #ratio = 0.5;
1510
+ /** @type {boolean} */
1511
+ #isSplit = false;
1512
+ /** @type {Function|null} */
1513
+ #onRatioChanged = null;
1514
+ // Drag state
1515
+ #dragging = false;
1516
+ #boundOnMouseMove = null;
1517
+ #boundOnMouseUp = null;
1518
+ /**
1519
+ * @param {{ onRatioChanged?: (ratio: number) => void }} opts
1520
+ */
1521
+ constructor(opts = {}) {
1522
+ this.#onRatioChanged = opts.onRatioChanged ?? null;
1523
+ }
1524
+ get isSplit() {
1525
+ return this.#isSplit;
1526
+ }
1527
+ get ratio() {
1528
+ return this.#ratio;
1529
+ }
1530
+ mount(container) {
1531
+ this.#container = container;
1532
+ this.#container.classList.add("split-pane-container");
1533
+ this.#container.innerHTML = "";
1534
+ this.#divider = document.createElement("div");
1535
+ this.#divider.className = "split-pane-divider";
1536
+ this.#divider.addEventListener("mousedown", (e) => this.#onDividerMouseDown(e));
1537
+ this.#applyLayout();
1538
+ }
1539
+ /**
1540
+ * Set pane elements. Pass one element for single view, two for split.
1541
+ * @param {HTMLElement} pane1
1542
+ * @param {HTMLElement} [pane2]
1543
+ */
1544
+ setPanes(pane1, pane2) {
1545
+ this.#pane1 = pane1;
1546
+ this.#pane2 = pane2 ?? null;
1547
+ this.#isSplit = !!pane2;
1548
+ this.#applyLayout();
1549
+ }
1550
+ /**
1551
+ * Set the split ratio (fraction for left pane).
1552
+ * @param {number} ratio — 0.2..0.8
1553
+ */
1554
+ setRatio(ratio) {
1555
+ this.#ratio = Math.max(0.2, Math.min(0.8, ratio));
1556
+ if (this.#isSplit) this.#applySizes();
1557
+ }
1558
+ /**
1559
+ * Collapse to single pane (left). Returns the removed right pane element.
1560
+ * @returns {HTMLElement|null}
1561
+ */
1562
+ unsplit() {
1563
+ if (!this.#isSplit) return null;
1564
+ const removed = this.#pane2;
1565
+ this.#pane2 = null;
1566
+ this.#isSplit = false;
1567
+ this.#applyLayout();
1568
+ return removed;
1569
+ }
1570
+ dispose() {
1571
+ this.#stopDrag();
1572
+ this.#container = null;
1573
+ this.#pane1 = null;
1574
+ this.#pane2 = null;
1575
+ this.#divider = null;
1576
+ }
1577
+ // ─── Layout ──────────────────────────────────────────────────────────────
1578
+ #applyLayout() {
1579
+ if (!this.#container) return;
1580
+ this.#container.innerHTML = "";
1581
+ if (!this.#pane1) return;
1582
+ if (!this.#isSplit) {
1583
+ this.#pane1.className = "split-pane";
1584
+ this.#pane1.style.flex = "1 1 0";
1585
+ this.#pane1.style.minWidth = "0";
1586
+ this.#container.appendChild(this.#pane1);
1587
+ this.#container.classList.remove("split-pane-container--split");
1588
+ } else {
1589
+ this.#pane1.className = "split-pane";
1590
+ this.#pane2.className = "split-pane";
1591
+ this.#pane1.style.minWidth = "0";
1592
+ this.#pane2.style.minWidth = "0";
1593
+ this.#container.appendChild(this.#pane1);
1594
+ this.#container.appendChild(this.#divider);
1595
+ this.#container.appendChild(this.#pane2);
1596
+ this.#container.classList.add("split-pane-container--split");
1597
+ this.#applySizes();
1598
+ }
1599
+ }
1600
+ #applySizes() {
1601
+ if (!this.#pane1 || !this.#pane2) return;
1602
+ const r = this.#ratio;
1603
+ this.#pane1.style.flex = `${r} 1 0`;
1604
+ this.#pane2.style.flex = `${1 - r} 1 0`;
1605
+ }
1606
+ // ─── Divider drag ────────────────────────────────────────────────────────
1607
+ #onDividerMouseDown(e) {
1608
+ if (e.button !== 0) return;
1609
+ e.preventDefault();
1610
+ this.#dragging = true;
1611
+ this.#divider.classList.add("split-pane-divider--active");
1612
+ document.body.style.cursor = "col-resize";
1613
+ document.body.style.userSelect = "none";
1614
+ this.#container.querySelectorAll("iframe").forEach((f) => f.style.pointerEvents = "none");
1615
+ this.#boundOnMouseMove = (e2) => this.#onMouseMove(e2);
1616
+ this.#boundOnMouseUp = () => this.#onMouseUp();
1617
+ document.addEventListener("mousemove", this.#boundOnMouseMove);
1618
+ document.addEventListener("mouseup", this.#boundOnMouseUp);
1619
+ }
1620
+ #onMouseMove(e) {
1621
+ if (!this.#dragging || !this.#container) return;
1622
+ const rect = this.#container.getBoundingClientRect();
1623
+ const dividerWidth = this.#divider.offsetWidth;
1624
+ const usable = rect.width - dividerWidth;
1625
+ if (usable <= 0) return;
1626
+ const x = e.clientX - rect.left - dividerWidth / 2;
1627
+ const ratio = Math.max(0.2, Math.min(0.8, x / usable));
1628
+ this.#ratio = ratio;
1629
+ this.#applySizes();
1630
+ }
1631
+ #onMouseUp() {
1632
+ this.#stopDrag();
1633
+ this.#onRatioChanged?.(this.#ratio);
1634
+ }
1635
+ #stopDrag() {
1636
+ if (!this.#dragging) return;
1637
+ this.#dragging = false;
1638
+ this.#divider?.classList.remove("split-pane-divider--active");
1639
+ document.body.style.cursor = "";
1640
+ document.body.style.userSelect = "";
1641
+ this.#container?.querySelectorAll("iframe").forEach((f) => f.style.pointerEvents = "");
1642
+ document.removeEventListener("mousemove", this.#boundOnMouseMove);
1643
+ document.removeEventListener("mouseup", this.#boundOnMouseUp);
1644
+ this.#boundOnMouseMove = null;
1645
+ this.#boundOnMouseUp = null;
1646
+ }
1647
+ };
1648
+ export {
1649
+ EditorPane,
1650
+ MonacoEditorFactory,
1651
+ NotebookSearchBar,
1652
+ NotebookTabBar,
1653
+ NotebookUndoManager,
1654
+ SplitPaneContainer,
1655
+ getEditorFactory,
1656
+ initMonaco,
1657
+ monacoReady,
1658
+ setDefaultLanguage,
1659
+ setMonacoBasePath
1660
+ };
1661
+ //# sourceMappingURL=editor.js.map