flexdesk 0.16.0 → 0.17.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 (251) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE +0 -0
  3. package/README.md +2261 -42
  4. package/THIRD_PARTY_LICENSES.md +0 -0
  5. package/css/base.css +1834 -75
  6. package/css/flexdesk.css +3373 -238
  7. package/css/overrides.css +16 -0
  8. package/css/reset.css +0 -0
  9. package/css/tokens.css +0 -0
  10. package/dist/canvas.js +6 -239
  11. package/dist/canvas.js.map +3 -3
  12. package/dist/charts.js +709 -18
  13. package/dist/charts.js.map +4 -4
  14. package/dist/chunk-3PHCPZHT.js +0 -0
  15. package/dist/chunk-3PHCPZHT.js.map +0 -0
  16. package/dist/chunk-4A7SJC3T.js +503 -0
  17. package/dist/chunk-4A7SJC3T.js.map +7 -0
  18. package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
  19. package/dist/chunk-CVMCZZX2.js.map +7 -0
  20. package/dist/chunk-D3COLZZ2.js +1311 -0
  21. package/dist/chunk-D3COLZZ2.js.map +7 -0
  22. package/dist/chunk-FOOS3T5L.js +0 -0
  23. package/dist/chunk-FOOS3T5L.js.map +0 -0
  24. package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
  25. package/dist/chunk-GOQLR5WR.js.map +7 -0
  26. package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
  27. package/dist/chunk-HNU6MM2B.js.map +7 -0
  28. package/dist/chunk-HYJYYEPK.js +375 -0
  29. package/dist/chunk-HYJYYEPK.js.map +7 -0
  30. package/dist/chunk-JKZLNKG6.js +2041 -0
  31. package/dist/chunk-JKZLNKG6.js.map +7 -0
  32. package/dist/chunk-JYWURG5T.js +0 -0
  33. package/dist/chunk-JYWURG5T.js.map +0 -0
  34. package/dist/{chunk-RQM5Z2MU.js → chunk-MCIXFNVF.js} +633 -311
  35. package/dist/chunk-MCIXFNVF.js.map +7 -0
  36. package/dist/chunk-N7HC3ADV.js +30 -0
  37. package/dist/chunk-N7HC3ADV.js.map +7 -0
  38. package/dist/{chunk-FBGP5KN5.js → chunk-Q36EPT2R.js} +3268 -203
  39. package/dist/chunk-Q36EPT2R.js.map +7 -0
  40. package/dist/chunk-QWQJUDPD.js +610 -0
  41. package/dist/chunk-QWQJUDPD.js.map +7 -0
  42. package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
  43. package/dist/chunk-UURZ37SD.js.map +7 -0
  44. package/dist/chunk-UWO43BZU.js +111 -0
  45. package/dist/chunk-UWO43BZU.js.map +7 -0
  46. package/dist/chunk-WVFGV5FT.js +0 -0
  47. package/dist/chunk-WVFGV5FT.js.map +0 -0
  48. package/dist/core.js +170 -118
  49. package/dist/core.js.map +2 -2
  50. package/dist/editor.js +156 -70
  51. package/dist/editor.js.map +2 -2
  52. package/dist/flexdesk.css +3373 -238
  53. package/dist/flow.js +2789 -306
  54. package/dist/flow.js.map +4 -4
  55. package/dist/host.js +109 -3
  56. package/dist/host.js.map +3 -3
  57. package/dist/reset.css +0 -0
  58. package/dist/tile_registry-6WZPTUZV.js +0 -0
  59. package/dist/tile_registry-6WZPTUZV.js.map +0 -0
  60. package/dist/tiles.js +1131 -253
  61. package/dist/tiles.js.map +4 -4
  62. package/dist/tokens.css +0 -0
  63. package/dist/widgets.js +3254 -2112
  64. package/dist/widgets.js.map +4 -4
  65. package/dist/wm.js +1227 -345
  66. package/dist/wm.js.map +4 -4
  67. package/package.json +3 -1
  68. package/src/canvas/canvas_adapter.js +5 -0
  69. package/src/canvas/edge_router.js +0 -0
  70. package/src/canvas/graph/connector_router.js +0 -0
  71. package/src/canvas/graph/history_service.js +0 -0
  72. package/src/canvas/graph/node_platform.js +0 -0
  73. package/src/canvas/index.js +1 -0
  74. package/src/canvas/layered_layout.js +244 -0
  75. package/src/charts/chart_types.js +0 -0
  76. package/src/charts/data_series_plot_window.js +0 -0
  77. package/src/charts/downsample.js +2 -2
  78. package/src/charts/plot_config.js +53 -29
  79. package/src/charts/plot_config_editor.js +627 -0
  80. package/src/charts/plot_grid.js +449 -0
  81. package/src/charts/plot_popout_window.js +103 -317
  82. package/src/charts/plotly_wrapper.js +22 -2
  83. package/src/charts/series_transforms.js +206 -0
  84. package/src/core/event_bus.js +0 -0
  85. package/src/core/logging.js +1 -1
  86. package/src/core/settings.js +105 -19
  87. package/src/core/state_guard.js +93 -51
  88. package/src/core/state_guard_config.js +85 -74
  89. package/src/core/state_machine.js +0 -0
  90. package/src/editor/editor_pane.js +3 -3
  91. package/src/editor/monaco_editor_factory.js +124 -60
  92. package/src/editor/monaco_loader.js +103 -28
  93. package/src/editor/notebook_search.js +0 -0
  94. package/src/editor/notebook_tab_bar.js +38 -52
  95. package/src/editor/notebook_undo_manager.js +0 -0
  96. package/src/editor/split_pane_container.js +6 -3
  97. package/src/flow/canvas_editor/connections.js +90 -0
  98. package/src/flow/canvas_editor/editor.js +2351 -153
  99. package/src/flow/canvas_editor/index.js +2 -1
  100. package/src/flow/canvas_editor/layout.js +119 -12
  101. package/src/flow/canvas_editor/strings.js +80 -0
  102. package/src/flow/kit/always_before.js +0 -0
  103. package/src/flow/kit/catalogue.js +97 -3
  104. package/src/flow/kit/chip_input.js +0 -0
  105. package/src/flow/kit/dom.js +0 -0
  106. package/src/flow/kit/findings.js +0 -0
  107. package/src/flow/kit/history.js +0 -0
  108. package/src/flow/kit/index.js +3 -3
  109. package/src/flow/kit/keys.js +7 -1
  110. package/src/flow/kit/logic_graph.js +97 -15
  111. package/src/flow/kit/references.js +0 -0
  112. package/src/flow/kit/settings_panel.js +0 -0
  113. package/src/flow/kit/settings_schema.js +2 -1
  114. package/src/flow/kit/step_picker.js +0 -0
  115. package/src/flow/kit/strings.js +3 -1
  116. package/src/flow/kit/value_picker.js +0 -0
  117. package/src/flow/kit/widgets.js +86 -109
  118. package/src/flow/lanes/editor.js +136 -14
  119. package/src/flow/lanes/index.js +3 -1
  120. package/src/flow/lanes/layout.js +0 -0
  121. package/src/flow/lanes/pipeline.js +44 -2
  122. package/src/flow/lanes/preview.js +0 -0
  123. package/src/flow/lanes/strings.js +1 -0
  124. package/src/flow/outline/editor.js +0 -0
  125. package/src/flow/outline/index.js +0 -0
  126. package/src/flow/outline/mapping.js +0 -0
  127. package/src/flow/outline/menu.js +0 -0
  128. package/src/flow/outline/operations.js +0 -0
  129. package/src/flow/outline/recognise.js +0 -0
  130. package/src/flow/outline/strings.js +0 -0
  131. package/src/help/help_modal.js +168 -42
  132. package/src/help/help_registry.js +12 -1
  133. package/src/host/external_links.js +0 -0
  134. package/src/host/file_picker.js +117 -0
  135. package/src/host/host.js +10 -2
  136. package/src/host/photino_host.js +9 -0
  137. package/src/host/photino_host.md +21 -3
  138. package/src/host/pywebview_host.js +10 -0
  139. package/src/tiles/chart_tiles.js +53 -245
  140. package/src/tiles/config_schema.js +8 -0
  141. package/src/tiles/figure_tile.js +322 -0
  142. package/src/tiles/layout_persistence.js +3 -3
  143. package/src/tiles/list_tiles.js +422 -0
  144. package/src/tiles/scope_picker.js +96 -0
  145. package/src/tiles/tile_base.js +12 -1
  146. package/src/tiles/tile_config_form.js +442 -0
  147. package/src/tiles/tile_grid.js +38 -1
  148. package/src/tiles/tile_registry.js +0 -0
  149. package/src/tiling/bottom_panel_tabs.js +67 -99
  150. package/src/tiling/command_palette.js +64 -19
  151. package/src/tiling/content_registry.js +4 -1
  152. package/src/tiling/desktops.js +11 -1
  153. package/src/tiling/entity_sources.js +0 -0
  154. package/src/tiling/keymap.js +403 -128
  155. package/src/tiling/kind_taxonomy.js +78 -0
  156. package/src/tiling/landing_table.js +0 -0
  157. package/src/tiling/loading_overlay.js +0 -0
  158. package/src/tiling/nav_panel.js +59 -10
  159. package/src/tiling/page_factory.js +5 -5
  160. package/src/tiling/panel_availability.js +49 -0
  161. package/src/tiling/panel_keys.js +0 -0
  162. package/src/tiling/shell.js +114 -12
  163. package/src/tiling/tab_strip.js +0 -0
  164. package/src/tiling/tile_breadcrumb.js +0 -0
  165. package/src/tiling/tile_renderer.js +105 -9
  166. package/src/tiling/tile_tab_menu.js +0 -0
  167. package/src/tiling/tile_tree.js +0 -0
  168. package/src/tiling/wm.js +562 -30
  169. package/src/tiling/zoom.js +0 -0
  170. package/src/ui/base/component_base.js +0 -0
  171. package/src/ui/base/controller_base.js +0 -0
  172. package/src/ui/base/page_base.js +0 -0
  173. package/src/ui/components/about_dialog.js +0 -0
  174. package/src/ui/components/action_dropdown.js +30 -2
  175. package/src/ui/components/alert_log.js +0 -0
  176. package/src/ui/components/alerts_pane.js +0 -0
  177. package/src/ui/components/attribute_list_editor.js +0 -0
  178. package/src/ui/components/autocomplete_field.js +0 -0
  179. package/src/ui/components/cell_editor_host.js +211 -0
  180. package/src/ui/components/cell_grid.js +648 -0
  181. package/src/ui/components/cell_selection.js +220 -0
  182. package/src/ui/components/choice_controls.js +351 -0
  183. package/src/ui/components/color_field.js +211 -0
  184. package/src/ui/components/color_list_field.js +210 -0
  185. package/src/ui/components/column_fit.js +0 -0
  186. package/src/ui/components/column_groups.js +314 -0
  187. package/src/ui/components/column_order.js +142 -0
  188. package/src/ui/components/computing_status_window.js +98 -25
  189. package/src/ui/components/confirm_dialog.js +81 -38
  190. package/src/ui/components/context_menu.js +2 -2
  191. package/src/ui/components/conversation_view.js +440 -0
  192. package/src/ui/components/crosstab.js +289 -0
  193. package/src/ui/components/data_table.js +601 -62
  194. package/src/ui/components/detail_header.js +22 -11
  195. package/src/ui/components/document_outline.js +397 -0
  196. package/src/ui/components/drag_reorder.js +0 -0
  197. package/src/ui/components/fact_table.js +0 -0
  198. package/src/ui/components/fill_handle.js +322 -0
  199. package/src/ui/components/gallery_picker.js +19 -8
  200. package/src/ui/components/header_actions.js +55 -0
  201. package/src/ui/components/inline_renamer.js +0 -0
  202. package/src/ui/components/job_progress.js +51 -0
  203. package/src/ui/components/managed_window.js +27 -4
  204. package/src/ui/components/menu_bar.js +745 -0
  205. package/src/ui/components/modal.js +449 -75
  206. package/src/ui/components/notification_history.js +3 -3
  207. package/src/ui/components/progress.js +483 -0
  208. package/src/ui/components/record_list.js +0 -0
  209. package/src/ui/components/row_activation.js +0 -0
  210. package/src/ui/components/section_stack.js +298 -0
  211. package/src/ui/components/series.js +173 -0
  212. package/src/ui/components/settings_view.js +634 -0
  213. package/src/ui/components/slide_out_panel.js +43 -6
  214. package/src/ui/components/slider_field.js +275 -18
  215. package/src/ui/components/sortable_list.js +26 -4
  216. package/src/ui/components/staged_edits.js +343 -0
  217. package/src/ui/components/staged_history.js +172 -0
  218. package/src/ui/components/table_filter_chips.js +0 -0
  219. package/src/ui/components/table_state_store.js +0 -0
  220. package/src/ui/components/table_structure.js +182 -0
  221. package/src/ui/components/table_values.js +0 -0
  222. package/src/ui/components/tabs.js +497 -0
  223. package/src/ui/components/tag_input.js +215 -0
  224. package/src/ui/components/toast.js +25 -8
  225. package/src/ui/components/toggle_field.js +175 -0
  226. package/src/ui/components/tree_view.js +55 -34
  227. package/src/ui/components/window_taskbar.js +0 -0
  228. package/src/ui/components/wizard.js +563 -0
  229. package/src/ui/controllers/window_chrome_controller.js +2 -2
  230. package/src/ui/notification_center.js +375 -119
  231. package/src/ui/utils/auto_scrollbars.js +0 -0
  232. package/src/ui/utils/markdown.js +709 -0
  233. package/src/ui/utils/native_context_menu.js +0 -0
  234. package/src/ui/utils/overlay_scrollbar.js +16 -12
  235. package/src/ui/utils/raf_resize_observer.js +0 -0
  236. package/src/ui/utils/scroll_lock.js +0 -0
  237. package/src/ui/utils/strip_scroll.js +55 -0
  238. package/src/ui/utils/tooltip_service.js +0 -8
  239. package/dist/chunk-6JSOVNID.js.map +0 -7
  240. package/dist/chunk-CWPMLX67.js +0 -475
  241. package/dist/chunk-CWPMLX67.js.map +0 -7
  242. package/dist/chunk-FBGP5KN5.js.map +0 -7
  243. package/dist/chunk-FL5KFNQH.js.map +0 -7
  244. package/dist/chunk-NLTOY57M.js.map +0 -7
  245. package/dist/chunk-NNFTETK3.js +0 -633
  246. package/dist/chunk-NNFTETK3.js.map +0 -7
  247. package/dist/chunk-O5OHMWBB.js +0 -284
  248. package/dist/chunk-O5OHMWBB.js.map +0 -7
  249. package/dist/chunk-RQM5Z2MU.js.map +0 -7
  250. package/dist/chunk-SN4LSOAR.js.map +0 -7
  251. package/src/ui/controllers/panel_state_machine.js +0 -634
@@ -5,7 +5,8 @@
5
5
  * Handles:
6
6
  * - Creating full editors (multi-line, for CodeCells)
7
7
  * - Creating single-line inline editors (for ParameterCell values, expression fields)
8
- * - Theme registration (ecosim-dark, ecosim-light)
8
+ * - Theme registration (built-in dark and light themes, names set by
9
+ * setEditorThemeNames(); default flexdesk-dark, flexdesk-light)
9
10
  * - Shared editor options
10
11
  * - Instance lifecycle (dispose on cell removal)
11
12
  */
@@ -28,7 +29,7 @@ function safeDispose(editor) {
28
29
 
29
30
  // ─── Theme definitions ─────────────────────────────────────────────────────────
30
31
 
31
- const ECOSIM_DARK_THEME = {
32
+ const DARK_THEME = {
32
33
  base: 'vs-dark',
33
34
  inherit: true,
34
35
  rules: [
@@ -74,7 +75,7 @@ const ECOSIM_DARK_THEME = {
74
75
  },
75
76
  };
76
77
 
77
- const ECOSIM_LIGHT_THEME = {
78
+ const LIGHT_THEME = {
78
79
  base: 'vs',
79
80
  inherit: true,
80
81
  rules: [
@@ -108,13 +109,47 @@ const ECOSIM_LIGHT_THEME = {
108
109
  },
109
110
  };
110
111
 
111
- let _themesRegistered = false;
112
+ /** The names the built-in themes are registered under when the embedder
113
+ * names none. */
114
+ export const DEFAULT_EDITOR_THEME_NAMES = Object.freeze({
115
+ dark: 'flexdesk-dark',
116
+ light: 'flexdesk-light',
117
+ });
118
+
119
+ let _themeNames = DEFAULT_EDITOR_THEME_NAMES;
120
+
121
+ /**
122
+ * Name the built-in dark and light Monaco themes. Call before the first
123
+ * factory is created: a factory starts on the dark name, and init() registers
124
+ * the built-in themes under the names current at that moment. An embedder with
125
+ * its own theme defines it with `monaco.editor.defineTheme(name, …)` and passes
126
+ * that name to `factory.setTheme(name)` instead.
127
+ *
128
+ * @param {{ dark?: string, light?: string }} [names]
129
+ */
130
+ export function setEditorThemeNames({ dark, light } = {}) {
131
+ _themeNames = Object.freeze({
132
+ dark: (typeof dark === 'string' && dark) ? dark : DEFAULT_EDITOR_THEME_NAMES.dark,
133
+ light: (typeof light === 'string' && light) ? light : DEFAULT_EDITOR_THEME_NAMES.light,
134
+ });
135
+ }
136
+
137
+ /** @returns {{ dark: string, light: string }} The built-in themes' names. */
138
+ export function getEditorThemeNames() {
139
+ return _themeNames;
140
+ }
141
+
142
+ /** Monaco instance -> names already defined on it, so each name is defined once. */
143
+ const _definedThemes = new WeakMap();
112
144
 
113
145
  function ensureThemes(monaco) {
114
- if (_themesRegistered) return;
115
- _themesRegistered = true;
116
- monaco.editor.defineTheme('ecosim-dark', ECOSIM_DARK_THEME);
117
- monaco.editor.defineTheme('ecosim-light', ECOSIM_LIGHT_THEME);
146
+ let defined = _definedThemes.get(monaco);
147
+ if (!defined) { defined = new Set(); _definedThemes.set(monaco, defined); }
148
+ for (const [name, theme] of [[_themeNames.dark, DARK_THEME], [_themeNames.light, LIGHT_THEME]]) {
149
+ if (defined.has(name)) continue;
150
+ defined.add(name);
151
+ monaco.editor.defineTheme(name, theme);
152
+ }
118
153
  }
119
154
 
120
155
  // ─── Shared editor options ─────────────────────────────────────────────────────
@@ -122,7 +157,7 @@ function ensureThemes(monaco) {
122
157
  function baseOptions(overrides = {}) {
123
158
  return {
124
159
  language: _defaultLanguage,
125
- theme: 'ecosim-dark',
160
+ theme: _themeNames.dark,
126
161
  fontFamily: 'Consolas, "Courier New", monospace',
127
162
  fontSize: 13,
128
163
  lineHeight: 19.5,
@@ -177,15 +212,23 @@ function singleLineOptions(overrides = {}) {
177
212
  });
178
213
  }
179
214
 
215
+ /** A cap is a positive number of px, or `Infinity` for none. */
216
+ function checkMaxHeight(px) {
217
+ if (typeof px !== 'number' || Number.isNaN(px) || px <= 0) {
218
+ throw new RangeError(`maxHeight must be a positive number of px or Infinity, got ${String(px)}`);
219
+ }
220
+ return px;
221
+ }
222
+
180
223
  // ─── Factory ───────────────────────────────────────────────────────────────────
181
224
 
182
225
  /**
183
- * @typedef {{ editor: object, dispose: () => void, getValue: () => string, setValue: (v: string) => void, onDidChange: (cb: () => void) => object }} EditorHandle
226
+ * @typedef {{ editor: object, dispose: () => void, getValue: () => string, setValue: (v: string) => void, onDidChange: (cb: () => void) => object, setMaxHeight?: (px: number) => void }} EditorHandle
184
227
  */
185
228
 
186
229
  export class MonacoEditorFactory {
187
230
  #monaco = null;
188
- #currentTheme = 'ecosim-dark';
231
+ #currentTheme = _themeNames.dark;
189
232
 
190
233
  constructor() {}
191
234
 
@@ -199,7 +242,8 @@ export class MonacoEditorFactory {
199
242
 
200
243
  /**
201
244
  * Set global theme for all editors.
202
- * @param {'ecosim-dark'|'ecosim-light'} theme
245
+ * @param {string} theme A theme name Monaco knows: one of
246
+ * getEditorThemeNames(), or one the embedder defined itself.
203
247
  */
204
248
  setTheme(theme) {
205
249
  this.#currentTheme = theme;
@@ -207,17 +251,26 @@ export class MonacoEditorFactory {
207
251
  }
208
252
 
209
253
  /**
210
- * Create a full multi-line editor (for CodeCell).
254
+ * Create a full multi-line editor.
255
+ *
256
+ * Unless `noAutoHeight` is set, the editor sizes `container` to its content,
257
+ * never below `setMinHeight()` (80px by default) and never above `maxHeight`.
258
+ * Past the cap, Monaco scrolls internally and handles the mouse wheel; below
259
+ * it, Monaco leaves the wheel alone, so it reaches whatever scrolls around
260
+ * the editor.
211
261
  *
212
262
  * @param {HTMLElement} container
213
263
  * @param {string} initialValue
214
- * @param {{ language?: string, readOnly?: boolean, lineNumbers?: string } & object} [overrides]
264
+ * @param {{ language?: string, readOnly?: boolean, lineNumbers?: string, maxHeight?: number, noAutoHeight?: boolean } & object} [overrides]
265
+ * `maxHeight`: the auto-height cap in px, `Infinity` (the default) for none.
266
+ * `noAutoHeight`: leave the container's size to its layout; the wheel always scrolls the editor.
215
267
  * @returns {EditorHandle}
216
268
  */
217
269
  createEditor(container, initialValue = '', overrides = {}) {
218
270
  if (!this.#monaco) throw new Error('MonacoEditorFactory not initialized');
219
271
 
220
- const { noAutoHeight, ...editorOverrides } = overrides;
272
+ const { noAutoHeight, maxHeight: maxHeightOption = Infinity, ...editorOverrides } = overrides;
273
+ let maxHeight = checkMaxHeight(maxHeightOption);
221
274
 
222
275
  const editor = this.#monaco.editor.create(container, {
223
276
  ...baseOptions({ theme: this.#currentTheme }),
@@ -225,17 +278,9 @@ export class MonacoEditorFactory {
225
278
  ...editorOverrides,
226
279
  });
227
280
 
228
- // Auto-resize to content height.
229
- // When the "limit code cell height" setting is active (data-code-max-height="true"
230
- // on the editor container), cap at 400px so Monaco handles internal scrolling.
231
- // Otherwise, grow to fit all content with no upper bound.
232
- //
233
- // Monaco's handleMouseWheel is toggled dynamically: disabled when the editor
234
- // auto-sizes to fit content (so wheel events propagate to the notebook scroller),
235
- // enabled only when content is capped and there is actual internal overflow.
236
- //
237
- // When noAutoHeight is true, skip auto-sizing entirely and let the container
238
- // control dimensions (e.g. flex layout). Mouse wheel scrolling stays enabled.
281
+ // Monaco's handleMouseWheel is toggled with the height: off while the
282
+ // editor fits its content (the wheel scrolls the page around it), on only
283
+ // when the content overflows the cap and the editor has something to scroll.
239
284
  let minHeight = 80;
240
285
  let heightDisposable = null;
241
286
  let updateHeight = null;
@@ -243,29 +288,16 @@ export class MonacoEditorFactory {
243
288
  editor.updateOptions({ scrollbar: { handleMouseWheel: true } });
244
289
  } else {
245
290
  updateHeight = () => {
246
- const maxHeightEnabled = container.closest('.notebook-editor-container')?.dataset.codeMaxHeight === 'true';
247
- const maxH = maxHeightEnabled ? 400 : Infinity;
248
291
  const rawContent = editor.getContentHeight();
249
- const contentHeight = Math.min(maxH, Math.max(minHeight, rawContent));
292
+ const contentHeight = Math.min(maxHeight, Math.max(minHeight, rawContent));
250
293
  container.style.height = `${contentHeight}px`;
251
294
  editor.layout();
252
-
253
- // Enable Monaco wheel handling only when content overflows the capped height
254
- const needsInternalScroll = maxHeightEnabled && rawContent > maxH;
255
- editor.updateOptions({ scrollbar: { handleMouseWheel: needsInternalScroll } });
295
+ editor.updateOptions({ scrollbar: { handleMouseWheel: rawContent > maxHeight } });
256
296
  };
257
297
  heightDisposable = editor.onDidContentSizeChange(updateHeight);
258
298
  updateHeight();
259
299
  }
260
300
 
261
- // Re-evaluate height when the code-max-height setting changes on the ancestor
262
- const editorContainer = container.closest('.notebook-editor-container');
263
- let maxHeightObserver = null;
264
- if (updateHeight && editorContainer) {
265
- maxHeightObserver = new MutationObserver(() => updateHeight());
266
- maxHeightObserver.observe(editorContainer, { attributes: true, attributeFilter: ['data-code-max-height'] });
267
- }
268
-
269
301
  // Scroll the find widget into view when it becomes visible.
270
302
  // The editor auto-sizes to content (no internal scroll), so the notebook
271
303
  // container scrolls instead. The find widget sits at top:0 of the editor,
@@ -296,10 +328,14 @@ export class MonacoEditorFactory {
296
328
  focus: () => editor.focus(),
297
329
  layout: () => editor.layout(),
298
330
  setMinHeight: (h) => { minHeight = h; },
331
+ /** The auto-height cap in px, `Infinity` for none; applies at once. No effect with `noAutoHeight`. */
332
+ setMaxHeight: (px) => {
333
+ maxHeight = checkMaxHeight(px);
334
+ updateHeight?.();
335
+ },
299
336
  updateHeight,
300
337
  dispose: () => {
301
338
  findWidgetObserver?.disconnect();
302
- maxHeightObserver?.disconnect();
303
339
  heightDisposable?.dispose();
304
340
  safeDispose(editor);
305
341
  },
@@ -391,8 +427,12 @@ export class MonacoEditorFactory {
391
427
  const monaco = this.#monaco;
392
428
  const language = options.language || 'plaintext';
393
429
 
394
- const originalModel = monaco.editor.createModel(originalContent, language);
395
- const modifiedModel = monaco.editor.createModel(modifiedContent, language);
430
+ // The pair currently attached to the diff editor. setModels() replaces it, and
431
+ // dispose() frees whichever pair is attached at that moment — not the first one.
432
+ let models = {
433
+ original: monaco.editor.createModel(originalContent, language),
434
+ modified: monaco.editor.createModel(modifiedContent, language),
435
+ };
396
436
 
397
437
  const diffEditor = monaco.editor.createDiffEditor(container, {
398
438
  theme: this.#currentTheme,
@@ -408,28 +448,37 @@ export class MonacoEditorFactory {
408
448
  enableSplitViewResizing: true,
409
449
  });
410
450
 
411
- diffEditor.setModel({ original: originalModel, modified: modifiedModel });
451
+ diffEditor.setModel(models);
412
452
 
413
453
  return {
414
454
  editor: diffEditor,
415
455
  setModels(original, modified, lang) {
416
- originalModel.dispose();
417
- modifiedModel.dispose();
418
- const newOrig = monaco.editor.createModel(original, lang || language);
419
- const newMod = monaco.editor.createModel(modified, lang || language);
420
- diffEditor.setModel({ original: newOrig, modified: newMod });
456
+ const previous = models;
457
+ models = {
458
+ original: monaco.editor.createModel(original, lang || language),
459
+ modified: monaco.editor.createModel(modified, lang || language),
460
+ };
461
+ // Attach the new pair first: a model must not be disposed while the
462
+ // diff editor still holds it.
463
+ diffEditor.setModel(models);
464
+ safeDispose(previous.original);
465
+ safeDispose(previous.modified);
421
466
  },
422
467
  dispose() {
423
468
  safeDispose(diffEditor);
424
- safeDispose(originalModel);
425
- safeDispose(modifiedModel);
469
+ safeDispose(models.original);
470
+ safeDispose(models.modified);
426
471
  },
427
472
  };
428
473
  }
429
474
  }
430
475
 
431
- /** Singleton instance, initialized lazily. */
432
- let _factoryInstance = null;
476
+ /**
477
+ * The singleton, as a promise: concurrent callers share one load, and a FAILED
478
+ * load clears it so the next call can retry.
479
+ * @type {Promise<MonacoEditorFactory> | null}
480
+ */
481
+ let _factoryPromise = null;
433
482
 
434
483
  // Where the app keeps Monaco. Set once, at boot.
435
484
  let _monacoBasePath = null;
@@ -439,11 +488,26 @@ export function setMonacoBasePath(basePath) {
439
488
  if (typeof basePath === 'string' && basePath) _monacoBasePath = basePath;
440
489
  }
441
490
 
442
- export async function getEditorFactory() {
443
- if (!_factoryInstance) {
444
- // Ensure Monaco is initialized — initMonaco is idempotent, safe to call from any entry point
445
- initMonaco(_monacoBasePath);
446
- _factoryInstance = await new MonacoEditorFactory().init();
491
+ /**
492
+ * The shared editor factory, once Monaco has loaded.
493
+ *
494
+ * Uses a load already started by initMonaco(path), so a consumer that calls
495
+ * initMonaco() itself needs no setMonacoBasePath(). Otherwise it starts one
496
+ * from setMonacoBasePath()'s path.
497
+ *
498
+ * Rejects — rather than pending forever (BUG-0023) — with initMonaco()'s error
499
+ * when no path was set and no load was started, or when Monaco cannot be
500
+ * loaded. A later call asks initMonaco() again: after a loader.js failure that
501
+ * is a fresh attempt (fix the path first); after an AMD-level failure it is
502
+ * the same rejection, because that one is final for the page.
503
+ *
504
+ * @returns {Promise<MonacoEditorFactory>}
505
+ */
506
+ export function getEditorFactory() {
507
+ if (!_factoryPromise) {
508
+ _factoryPromise = initMonaco(_monacoBasePath)
509
+ .then(() => new MonacoEditorFactory().init())
510
+ .catch((err) => { _factoryPromise = null; throw err; });
447
511
  }
448
- return _factoryInstance;
512
+ return _factoryPromise;
449
513
  }
@@ -8,9 +8,34 @@
8
8
  */
9
9
 
10
10
  let _resolveMonaco;
11
- let _monacoReady = false;
12
11
 
13
- /** @type {Promise<typeof monaco>} */
12
+ /**
13
+ * The attempt in flight, the one that succeeded, or one that failed for good.
14
+ *
15
+ * Whether a failure is retryable depends on how far the attempt got:
16
+ *
17
+ * - vs/loader.js did not load (a 404, a wrong path): nothing is left behind,
18
+ * so the attempt is cleared and the next initMonaco() starts over. The flag
19
+ * this replaced was set BEFORE loader.js had loaded, so one 404 blocked
20
+ * every retry for the life of the page (BUG-0023).
21
+ * - Monaco's AMD loader reported an error (vs/editor/editor.main or one of
22
+ * its dependencies failed): FINAL for the page. window.require is now
23
+ * Monaco's loader, which records the failed module as complete-with-error
24
+ * and hands every later require() of it the same error — it has no undef.
25
+ * Retrying cannot help, so the rejected attempt is kept and every caller
26
+ * gets that one error. Recovering takes a page reload.
27
+ * @type {Promise<typeof monaco> | null}
28
+ */
29
+ let _attempt = null;
30
+
31
+ /**
32
+ * Resolves with the `monaco` namespace once any initMonaco() call has loaded it.
33
+ *
34
+ * It never rejects, because a failed attempt is not final — a later
35
+ * initMonaco() may still succeed. To learn that an attempt FAILED, await
36
+ * initMonaco() (or getEditorFactory()), which rejects.
37
+ * @type {Promise<typeof monaco>}
38
+ */
14
39
  export const monacoReady = new Promise(resolve => { _resolveMonaco = resolve; });
15
40
 
16
41
  function injectLoaderScript(src) {
@@ -18,21 +43,67 @@ function injectLoaderScript(src) {
18
43
  const s = document.createElement('script');
19
44
  s.src = src;
20
45
  s.onload = resolve;
21
- s.onerror = () => reject(new Error(`Failed to load Monaco loader: ${src}`));
46
+ s.onerror = () => {
47
+ // Take the dead tag out, so a retry is a fresh request and not a
48
+ // second tag beside the first.
49
+ s.remove?.();
50
+ reject(new Error(`Failed to load Monaco loader: ${src}`));
51
+ };
22
52
  document.head.appendChild(s);
23
53
  });
24
54
  }
25
55
 
26
56
  function injectMonacoCss(basePath) {
27
57
  const id = 'monaco-editor-css';
28
- if (document.getElementById(id)) return;
58
+ const href = `${basePath}/vs/editor/editor.main.css`;
59
+ const existing = document.getElementById(id);
60
+ if (existing) {
61
+ // A retry with a corrected path must not keep the stylesheet of the bad one.
62
+ if (existing.getAttribute?.('href') !== href) existing.setAttribute?.('href', href);
63
+ return;
64
+ }
29
65
  const link = document.createElement('link');
30
66
  link.id = id;
31
67
  link.rel = 'stylesheet';
32
- link.href = `${basePath}/vs/editor/editor.main.css`;
68
+ link.href = href;
33
69
  document.head.appendChild(link);
34
70
  }
35
71
 
72
+ function requireEditorMain(basePath) {
73
+ return new Promise((resolve, reject) => {
74
+ window.require.config({
75
+ paths: { vs: `${basePath}/vs` },
76
+ 'vs/nls': { availableLanguages: {} },
77
+ });
78
+ window.require(['vs/editor/editor.main'], () => {
79
+ if (window.monaco) resolve(window.monaco);
80
+ else reject(new Error(
81
+ `[twm/editor] vs/editor/editor.main loaded from ${basePath}/vs but did not `
82
+ + 'set window.monaco.'));
83
+ }, (err) => {
84
+ reject(new Error(
85
+ `[twm/editor] Failed to load Monaco from ${basePath}/vs: ${err?.message ?? err}. `
86
+ + 'Monaco\'s AMD loader caches this failure, so it is final for this page: '
87
+ + 'fix the path and reload.'));
88
+ });
89
+ });
90
+ }
91
+
92
+ async function loadMonaco(basePath) {
93
+ // Inject CSS and loader.js dynamically — kept out of the HTML to avoid global bleed
94
+ injectMonacoCss(basePath);
95
+ if (!window.require) {
96
+ const src = `${basePath}/vs/loader.js`;
97
+ await injectLoaderScript(src);
98
+ if (!window.require) {
99
+ throw new Error(
100
+ `[twm/editor] ${src} loaded but did not define window.require; `
101
+ + 'is it Monaco\'s vs/loader.js?');
102
+ }
103
+ }
104
+ return requireEditorMain(basePath);
105
+ }
106
+
36
107
  /**
37
108
  * Call once when the notebook page initialises.
38
109
  * Where Monaco lives is the CONSUMER's business.
@@ -43,32 +114,36 @@ function injectMonacoCss(basePath) {
43
114
  *
44
115
  * Monaco is a PEER dependency — 14 MB this bundle deliberately does not contain.
45
116
  *
46
- * @param {string} basePath Path prefix for the vs/ directory. REQUIRED.
117
+ * Idempotent: while an attempt is in flight or has succeeded, every caller
118
+ * gets the same promise, whatever path it passes or whether it passes one —
119
+ * so getEditorFactory() piggybacks on a load the consumer started here.
120
+ *
121
+ * The returned promise REJECTS when no path is given and no attempt exists,
122
+ * when loader.js fails to load, or when Monaco's AMD loader reports an error.
123
+ * After a loader.js failure the next call tries again (with the path it is
124
+ * given). An AMD-level failure is final for the page: every later call gets
125
+ * the same rejection, and only a reload recovers (see `_attempt`).
126
+ *
127
+ * @param {string} [basePath] Path prefix for the vs/ directory. REQUIRED
128
+ * unless an attempt is already in flight or has succeeded.
129
+ * @returns {Promise<typeof monaco>}
47
130
  */
48
- export async function initMonaco(basePath) {
131
+ export function initMonaco(basePath) {
132
+ if (_attempt) return _attempt;
49
133
  if (!basePath) {
50
- throw new Error(
134
+ return Promise.reject(new Error(
51
135
  '[twm/editor] initMonaco() needs the path to your copy of Monaco, e.g. '
52
136
  + "initMonaco('vendor/monaco'). Monaco is a peer dependency; the library "
53
- + 'does not guess where you put it.');
137
+ + 'does not guess where you put it.'));
54
138
  }
55
- if (_monacoReady) return monacoReady;
56
- _monacoReady = true;
57
-
58
- // Inject CSS and loader.js dynamically — kept out of the HTML to avoid global bleed
59
- injectMonacoCss(basePath);
60
- if (!window.require) {
61
- await injectLoaderScript(`${basePath}/vs/loader.js`);
62
- }
63
-
64
- window.require.config({
65
- paths: { vs: `${basePath}/vs` },
66
- 'vs/nls': { availableLanguages: {} },
67
- });
68
-
69
- window.require(['vs/editor/editor.main'], () => {
70
- _resolveMonaco(window.monaco);
71
- });
72
-
73
- return monacoReady;
139
+ const attempt = loadMonaco(basePath).then(
140
+ (m) => { _resolveMonaco(m); return m; },
141
+ (err) => {
142
+ // Monaco's AMD loader is installed only once loader.js has run; a
143
+ // failure before that leaves nothing cached and can be retried.
144
+ if (!window.require && _attempt === attempt) _attempt = null;
145
+ throw err;
146
+ });
147
+ _attempt = attempt;
148
+ return attempt;
74
149
  }
File without changes
@@ -23,9 +23,29 @@
23
23
  */
24
24
 
25
25
  import { DragReorder } from '../ui/components/drag_reorder.js';
26
+ import { revealInStrip, wheelStripSideways } from '../ui/utils/strip_scroll.js';
26
27
 
27
- /** Custom MIME type so tab bars recognise cross-pane drags. */
28
- const TAB_MIME = 'application/x-ecosim-tab';
28
+ /** The MIME type a dragged editor tab carries when the embedder names none. */
29
+ export const DEFAULT_TAB_DRAG_MIME = 'application/x-flexdesk-tab';
30
+
31
+ /** Custom MIME type so tab bars and editor panes recognise cross-pane drags. */
32
+ let _tabMime = DEFAULT_TAB_DRAG_MIME;
33
+
34
+ /**
35
+ * Name the MIME type editor tabs carry while dragged. Every NotebookTabBar and
36
+ * EditorPane in the document reads it at drag time, so they always agree.
37
+ * Call at boot, before the first drag.
38
+ *
39
+ * @param {string} [mime=DEFAULT_TAB_DRAG_MIME]
40
+ */
41
+ export function setTabDragMime(mime = DEFAULT_TAB_DRAG_MIME) {
42
+ _tabMime = (typeof mime === 'string' && mime) ? mime : DEFAULT_TAB_DRAG_MIME;
43
+ }
44
+
45
+ /** @returns {string} The MIME type editor tabs carry while dragged. */
46
+ export function getTabDragMime() {
47
+ return _tabMime;
48
+ }
29
49
 
30
50
  export class NotebookTabBar {
31
51
  /** @type {HTMLElement} */
@@ -75,7 +95,7 @@ export class NotebookTabBar {
75
95
  },
76
96
  onDragStart: (e, key, item) => {
77
97
  // Carry the cross-pane MIME so other panes accept this tab.
78
- try { e.dataTransfer.setData(TAB_MIME, key); } catch (_) {}
98
+ try { e.dataTransfer.setData(_tabMime, key); } catch (_) {}
79
99
  // C33. …AND LET THE HOST ADD ITS OWN, which is the whole of
80
100
  // what a second consumer of this strip needs from it.
81
101
  //
@@ -87,7 +107,7 @@ export class NotebookTabBar {
87
107
  // tabs — a different question from "is this an editor tab",
88
108
  // asked of the same gesture.
89
109
  //
90
- // ADDITIVE BY CONSTRUCTION: `TAB_MIME` is set first and
110
+ // ADDITIVE BY CONSTRUCTION: the tab MIME is set first and
91
111
  // unchanged, so `EditorPane`'s existing cross-pane drop cannot
92
112
  // notice that anyone else is listening.
93
113
  try { this.#callbacks.onDragStart?.(e, key, item); }
@@ -259,59 +279,25 @@ export class NotebookTabBar {
259
279
  /** @type {number} a pending retry, for a strip not yet laid out */
260
280
  #revealFrame = 0;
261
281
 
262
- /** Bring the active tab fully into the strip — and ONLY the strip. Not
263
- * `scrollIntoView`, which scrolls every scrollable ancestor as well and
264
- * would drag the tile, the window or the page along with the tab. */
282
+ /** Bring the active tab fully into the strip (`revealInStrip`). */
265
283
  #revealActive(retry = false) {
266
284
  const el = this.#container;
267
285
  if (!el) return;
268
286
  const active = el.querySelector(':scope > .tab.active');
269
287
  if (!active) return;
270
- if (!el.isConnected || el.clientWidth === 0) {
271
- // Mounted but not laid out yet (the renderer updates a strip before
272
- // it attaches it). Once, on the next frame — not a loop.
273
- if (retry && !this.#revealFrame && typeof requestAnimationFrame === 'function') {
274
- this.#revealFrame = requestAnimationFrame(() => {
275
- this.#revealFrame = 0;
276
- this.#revealActive(false);
277
- });
278
- }
279
- return;
288
+ // Mounted but not laid out yet (the renderer updates a strip before it
289
+ // attaches it). Once, on the next frame — not a loop.
290
+ if (!revealInStrip(el, active) && retry && !this.#revealFrame
291
+ && typeof requestAnimationFrame === 'function') {
292
+ this.#revealFrame = requestAnimationFrame(() => {
293
+ this.#revealFrame = 0;
294
+ this.#revealActive(false);
295
+ });
280
296
  }
281
- const box = el.getBoundingClientRect();
282
- const tab = active.getBoundingClientRect();
283
- // Client pixels to the strip's own: 1 unless something above it zooms.
284
- const scale = (el.offsetWidth ? box.width / el.offsetWidth : 1) || 1;
285
- const left = (tab.left - box.left) / scale + el.scrollLeft;
286
- const right = left + tab.width / scale;
287
- if (left < el.scrollLeft) el.scrollLeft = left;
288
- else if (right > el.scrollLeft + el.clientWidth) el.scrollLeft = right - el.clientWidth;
289
297
  }
290
298
 
291
- /**
292
- * A VERTICAL WHEEL SCROLLS THE STRIP SIDEWAYS. Chromium and Firefox scroll
293
- * an element that overflows only horizontally with Shift+wheel or a
294
- * touchpad, and not with the plain wheel most people have — so with the
295
- * scrollbar hidden, every tab past the edge was unreachable with a mouse.
296
- *
297
- * The wheel is taken from the page only while the strip can actually move
298
- * that way: at either end it is left alone, so a wheel over a strip that
299
- * has nothing more to show still scrolls whatever is behind it. A gesture
300
- * that is already horizontal (a touchpad, Shift+wheel) and Ctrl+wheel (zoom)
301
- * are the browser's, untouched.
302
- */
303
- #onWheel = (e) => {
304
- const el = this.#container;
305
- if (!el || e.ctrlKey || e.shiftKey) return;
306
- if (Math.abs(e.deltaX) >= Math.abs(e.deltaY)) return;
307
- const room = el.scrollWidth - el.clientWidth;
308
- if (room <= 0) return;
309
- const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? el.clientWidth : 1;
310
- const next = Math.max(0, Math.min(room, el.scrollLeft + e.deltaY * unit));
311
- if (Math.abs(next - el.scrollLeft) < 0.5) return;
312
- e.preventDefault();
313
- el.scrollLeft = next;
314
- };
299
+ /** A vertical wheel scrolls the strip sideways (`wheelStripSideways`). */
300
+ #onWheel = (e) => wheelStripSideways(this.#container, e);
315
301
 
316
302
  // ═════════════════════════════════════════════════════════════════════════
317
303
  // Drag and drop reordering (same-pane + cross-pane)
@@ -335,7 +321,7 @@ export class NotebookTabBar {
335
321
  *
336
322
  * C33. A HOST MAY NAME A SECOND MIME, at mount, as
337
323
  * `callbacks.externalTabMimes` — the tile renderer's tabs are not editor
338
- * tabs and do not carry `application/x-ecosim-tab`, so without this the
324
+ * tabs and do not carry the editor tab MIME (getTabDragMime()), so without this the
339
325
  * strip in tile B would refuse a tab dragged out of tile A's strip. Empty
340
326
  * by default, so every existing consumer keeps exactly today's admission
341
327
  * test.
@@ -353,7 +339,7 @@ export class NotebookTabBar {
353
339
  ? this.#callbacks.externalTabMimes : [];
354
340
  try {
355
341
  const types = e.dataTransfer.types;
356
- return types.includes(TAB_MIME) || extra.some((m) => types.includes(m));
342
+ return types.includes(_tabMime) || extra.some((m) => types.includes(m));
357
343
  } catch { return false; }
358
344
  }
359
345
 
@@ -374,7 +360,7 @@ export class NotebookTabBar {
374
360
  if (this.#isExternalTabDrag(e)) {
375
361
  e.preventDefault();
376
362
  this.#container.classList.remove('notebook-tabs--drop-target');
377
- const filePath = e.dataTransfer.getData(TAB_MIME) || e.dataTransfer.getData('text/plain');
363
+ const filePath = e.dataTransfer.getData(_tabMime) || e.dataTransfer.getData('text/plain');
378
364
  if (filePath) {
379
365
  this.#callbacks.onDropFromOtherPane?.(filePath);
380
366
  }
File without changes
@@ -114,17 +114,20 @@ export class SplitPaneContainer {
114
114
 
115
115
  if (!this.#pane1) return;
116
116
 
117
+ // ADD the class, never assign it: a pane is the caller's element
118
+ // (an EditorPane root is `.editor-pane`), and its own classes are
119
+ // what its stylesheet reaches it by.
117
120
  if (!this.#isSplit) {
118
121
  // Single pane: fills container
119
- this.#pane1.className = 'split-pane';
122
+ this.#pane1.classList.add('split-pane');
120
123
  this.#pane1.style.flex = '1 1 0';
121
124
  this.#pane1.style.minWidth = '0';
122
125
  this.#container.appendChild(this.#pane1);
123
126
  this.#container.classList.remove('split-pane-container--split');
124
127
  } else {
125
128
  // Split view
126
- this.#pane1.className = 'split-pane';
127
- this.#pane2.className = 'split-pane';
129
+ this.#pane1.classList.add('split-pane');
130
+ this.#pane2.classList.add('split-pane');
128
131
  this.#pane1.style.minWidth = '0';
129
132
  this.#pane2.style.minWidth = '0';
130
133