flexdesk 0.16.1 → 0.17.1

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 (262) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/LICENSE +0 -0
  3. package/README.md +2435 -42
  4. package/THIRD_PARTY_LICENSES.md +0 -0
  5. package/canvas.js +18 -0
  6. package/charts.js +21 -0
  7. package/core.js +14 -0
  8. package/css/base.css +1834 -75
  9. package/css/flexdesk.css +3165 -159
  10. package/css/overrides.css +16 -0
  11. package/css/reset.css +0 -0
  12. package/css/tokens.css +0 -0
  13. package/dist/canvas.js +6 -239
  14. package/dist/canvas.js.map +3 -3
  15. package/dist/charts.js +707 -18
  16. package/dist/charts.js.map +4 -4
  17. package/dist/chunk-3PHCPZHT.js +0 -0
  18. package/dist/chunk-3PHCPZHT.js.map +0 -0
  19. package/dist/chunk-4A7SJC3T.js +503 -0
  20. package/dist/chunk-4A7SJC3T.js.map +7 -0
  21. package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
  22. package/dist/chunk-CVMCZZX2.js.map +7 -0
  23. package/dist/chunk-D3COLZZ2.js +1311 -0
  24. package/dist/chunk-D3COLZZ2.js.map +7 -0
  25. package/dist/chunk-FOOS3T5L.js +0 -0
  26. package/dist/chunk-FOOS3T5L.js.map +0 -0
  27. package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
  28. package/dist/chunk-GOQLR5WR.js.map +7 -0
  29. package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
  30. package/dist/chunk-HNU6MM2B.js.map +7 -0
  31. package/dist/chunk-HYJYYEPK.js +375 -0
  32. package/dist/chunk-HYJYYEPK.js.map +7 -0
  33. package/dist/chunk-JKZLNKG6.js +2041 -0
  34. package/dist/chunk-JKZLNKG6.js.map +7 -0
  35. package/dist/chunk-JYWURG5T.js +0 -0
  36. package/dist/chunk-JYWURG5T.js.map +0 -0
  37. package/dist/{chunk-FBGP5KN5.js → chunk-LWV23YVE.js} +5073 -532
  38. package/dist/chunk-LWV23YVE.js.map +7 -0
  39. package/dist/chunk-N7HC3ADV.js +30 -0
  40. package/dist/chunk-N7HC3ADV.js.map +7 -0
  41. package/dist/{chunk-RQM5Z2MU.js → chunk-RMTRWWEO.js} +633 -311
  42. package/dist/chunk-RMTRWWEO.js.map +7 -0
  43. package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
  44. package/dist/chunk-UURZ37SD.js.map +7 -0
  45. package/dist/{chunk-B5Q53LAJ.js → chunk-UWO43BZU.js} +2 -2
  46. package/dist/chunk-UWO43BZU.js.map +7 -0
  47. package/dist/chunk-WVFGV5FT.js +0 -0
  48. package/dist/chunk-WVFGV5FT.js.map +0 -0
  49. package/dist/core.js +170 -118
  50. package/dist/core.js.map +2 -2
  51. package/dist/editor.js +156 -70
  52. package/dist/editor.js.map +2 -2
  53. package/dist/flexdesk.css +3165 -159
  54. package/dist/flow.js +2789 -306
  55. package/dist/flow.js.map +4 -4
  56. package/dist/host.js +109 -3
  57. package/dist/host.js.map +3 -3
  58. package/dist/reset.css +0 -0
  59. package/dist/tile_registry-6WZPTUZV.js +0 -0
  60. package/dist/tile_registry-6WZPTUZV.js.map +0 -0
  61. package/dist/tiles.js +657 -234
  62. package/dist/tiles.js.map +4 -4
  63. package/dist/tokens.css +0 -0
  64. package/dist/widgets.js +3290 -2124
  65. package/dist/widgets.js.map +4 -4
  66. package/dist/wm.js +1227 -347
  67. package/dist/wm.js.map +4 -4
  68. package/editor.js +20 -0
  69. package/flow.js +21 -0
  70. package/host.js +14 -0
  71. package/package.json +12 -1
  72. package/src/canvas/canvas_adapter.js +5 -0
  73. package/src/canvas/edge_router.js +0 -0
  74. package/src/canvas/graph/connector_router.js +0 -0
  75. package/src/canvas/graph/history_service.js +0 -0
  76. package/src/canvas/graph/node_platform.js +0 -0
  77. package/src/canvas/index.js +1 -0
  78. package/src/canvas/layered_layout.js +244 -0
  79. package/src/charts/chart_types.js +0 -0
  80. package/src/charts/data_series_plot_window.js +0 -0
  81. package/src/charts/downsample.js +2 -2
  82. package/src/charts/plot_config.js +53 -29
  83. package/src/charts/plot_config_editor.js +627 -0
  84. package/src/charts/plot_grid.js +449 -0
  85. package/src/charts/plot_popout_window.js +103 -317
  86. package/src/charts/plotly_wrapper.js +22 -2
  87. package/src/charts/series_transforms.js +206 -0
  88. package/src/core/event_bus.js +0 -0
  89. package/src/core/logging.js +1 -1
  90. package/src/core/settings.js +105 -19
  91. package/src/core/state_guard.js +93 -51
  92. package/src/core/state_guard_config.js +85 -74
  93. package/src/core/state_machine.js +0 -0
  94. package/src/editor/editor_pane.js +3 -3
  95. package/src/editor/monaco_editor_factory.js +124 -60
  96. package/src/editor/monaco_loader.js +103 -28
  97. package/src/editor/notebook_search.js +0 -0
  98. package/src/editor/notebook_tab_bar.js +38 -52
  99. package/src/editor/notebook_undo_manager.js +0 -0
  100. package/src/editor/split_pane_container.js +6 -3
  101. package/src/flow/canvas_editor/connections.js +90 -0
  102. package/src/flow/canvas_editor/editor.js +2351 -153
  103. package/src/flow/canvas_editor/index.js +2 -1
  104. package/src/flow/canvas_editor/layout.js +119 -12
  105. package/src/flow/canvas_editor/strings.js +80 -0
  106. package/src/flow/kit/always_before.js +0 -0
  107. package/src/flow/kit/catalogue.js +97 -3
  108. package/src/flow/kit/chip_input.js +0 -0
  109. package/src/flow/kit/dom.js +0 -0
  110. package/src/flow/kit/findings.js +0 -0
  111. package/src/flow/kit/history.js +0 -0
  112. package/src/flow/kit/index.js +3 -3
  113. package/src/flow/kit/keys.js +7 -1
  114. package/src/flow/kit/logic_graph.js +97 -15
  115. package/src/flow/kit/references.js +0 -0
  116. package/src/flow/kit/settings_panel.js +0 -0
  117. package/src/flow/kit/settings_schema.js +2 -1
  118. package/src/flow/kit/step_picker.js +0 -0
  119. package/src/flow/kit/strings.js +3 -1
  120. package/src/flow/kit/value_picker.js +0 -0
  121. package/src/flow/kit/widgets.js +86 -109
  122. package/src/flow/lanes/editor.js +136 -14
  123. package/src/flow/lanes/index.js +3 -1
  124. package/src/flow/lanes/layout.js +0 -0
  125. package/src/flow/lanes/pipeline.js +44 -2
  126. package/src/flow/lanes/preview.js +0 -0
  127. package/src/flow/lanes/strings.js +1 -0
  128. package/src/flow/outline/editor.js +0 -0
  129. package/src/flow/outline/index.js +0 -0
  130. package/src/flow/outline/mapping.js +0 -0
  131. package/src/flow/outline/menu.js +0 -0
  132. package/src/flow/outline/operations.js +0 -0
  133. package/src/flow/outline/recognise.js +0 -0
  134. package/src/flow/outline/strings.js +0 -0
  135. package/src/help/help_modal.js +168 -42
  136. package/src/help/help_registry.js +12 -1
  137. package/src/host/external_links.js +0 -0
  138. package/src/host/file_picker.js +117 -0
  139. package/src/host/host.js +10 -2
  140. package/src/host/photino_host.js +9 -0
  141. package/src/host/photino_host.md +21 -3
  142. package/src/host/pywebview_host.js +10 -0
  143. package/src/tiles/chart_tiles.js +53 -245
  144. package/src/tiles/config_schema.js +8 -0
  145. package/src/tiles/figure_tile.js +322 -0
  146. package/src/tiles/layout_persistence.js +3 -3
  147. package/src/tiles/list_tiles.js +7 -7
  148. package/src/tiles/scope_picker.js +0 -0
  149. package/src/tiles/tile_base.js +1 -1
  150. package/src/tiles/tile_config_form.js +442 -0
  151. package/src/tiles/tile_grid.js +2 -0
  152. package/src/tiles/tile_registry.js +0 -0
  153. package/src/tiling/bottom_panel_tabs.js +67 -99
  154. package/src/tiling/command_palette.js +64 -19
  155. package/src/tiling/content_registry.js +4 -1
  156. package/src/tiling/desktops.js +11 -1
  157. package/src/tiling/entity_sources.js +0 -0
  158. package/src/tiling/keymap.js +403 -128
  159. package/src/tiling/kind_taxonomy.js +78 -0
  160. package/src/tiling/landing_table.js +0 -0
  161. package/src/tiling/loading_overlay.js +0 -0
  162. package/src/tiling/nav_panel.js +59 -10
  163. package/src/tiling/page_factory.js +5 -5
  164. package/src/tiling/panel_availability.js +49 -0
  165. package/src/tiling/panel_keys.js +0 -0
  166. package/src/tiling/shell.js +114 -12
  167. package/src/tiling/tab_strip.js +0 -0
  168. package/src/tiling/tile_breadcrumb.js +0 -0
  169. package/src/tiling/tile_renderer.js +105 -9
  170. package/src/tiling/tile_tab_menu.js +0 -0
  171. package/src/tiling/tile_tree.js +0 -0
  172. package/src/tiling/wm.js +562 -30
  173. package/src/tiling/zoom.js +0 -0
  174. package/src/ui/base/component_base.js +0 -0
  175. package/src/ui/base/controller_base.js +0 -0
  176. package/src/ui/base/page_base.js +0 -0
  177. package/src/ui/components/about_dialog.js +0 -0
  178. package/src/ui/components/action_dropdown.js +30 -2
  179. package/src/ui/components/alert_log.js +0 -0
  180. package/src/ui/components/alerts_pane.js +0 -0
  181. package/src/ui/components/attribute_list_editor.js +0 -0
  182. package/src/ui/components/autocomplete_field.js +0 -0
  183. package/src/ui/components/cell_editor_host.js +293 -0
  184. package/src/ui/components/cell_editors.js +480 -0
  185. package/src/ui/components/cell_grid.js +761 -0
  186. package/src/ui/components/cell_paste.js +243 -0
  187. package/src/ui/components/cell_selection.js +220 -0
  188. package/src/ui/components/choice_controls.js +351 -0
  189. package/src/ui/components/color_field.js +211 -0
  190. package/src/ui/components/color_list_field.js +210 -0
  191. package/src/ui/components/column_fit.js +0 -0
  192. package/src/ui/components/column_groups.js +314 -0
  193. package/src/ui/components/column_order.js +142 -0
  194. package/src/ui/components/computing_status_window.js +98 -25
  195. package/src/ui/components/confirm_dialog.js +81 -38
  196. package/src/ui/components/context_menu.js +2 -2
  197. package/src/ui/components/conversation_view.js +440 -0
  198. package/src/ui/components/crosstab.js +289 -0
  199. package/src/ui/components/data_table.js +696 -71
  200. package/src/ui/components/detail_header.js +22 -11
  201. package/src/ui/components/document_outline.js +397 -0
  202. package/src/ui/components/drag_reorder.js +0 -0
  203. package/src/ui/components/fact_table.js +0 -0
  204. package/src/ui/components/fill_handle.js +322 -0
  205. package/src/ui/components/gallery_picker.js +19 -8
  206. package/src/ui/components/header_actions.js +55 -0
  207. package/src/ui/components/inline_renamer.js +0 -0
  208. package/src/ui/components/job_progress.js +51 -0
  209. package/src/ui/components/managed_window.js +27 -4
  210. package/src/ui/components/menu_bar.js +745 -0
  211. package/src/ui/components/modal.js +449 -75
  212. package/src/ui/components/notification_history.js +3 -3
  213. package/src/ui/components/progress.js +483 -0
  214. package/src/ui/components/record_list.js +0 -0
  215. package/src/ui/components/row_activation.js +0 -0
  216. package/src/ui/components/section_stack.js +298 -0
  217. package/src/ui/components/series.js +173 -0
  218. package/src/ui/components/settings_view.js +634 -0
  219. package/src/ui/components/slide_out_panel.js +43 -6
  220. package/src/ui/components/slider_field.js +275 -18
  221. package/src/ui/components/sortable_list.js +0 -0
  222. package/src/ui/components/staged_edits.js +361 -0
  223. package/src/ui/components/staged_history.js +172 -0
  224. package/src/ui/components/table_filter_chips.js +0 -0
  225. package/src/ui/components/table_state_store.js +0 -0
  226. package/src/ui/components/table_structure.js +182 -0
  227. package/src/ui/components/table_values.js +0 -0
  228. package/src/ui/components/tabs.js +497 -0
  229. package/src/ui/components/tag_input.js +215 -0
  230. package/src/ui/components/toast.js +25 -8
  231. package/src/ui/components/toggle_field.js +175 -0
  232. package/src/ui/components/tree_view.js +55 -34
  233. package/src/ui/components/tsv.js +74 -0
  234. package/src/ui/components/window_taskbar.js +0 -0
  235. package/src/ui/components/wizard.js +563 -0
  236. package/src/ui/controllers/window_chrome_controller.js +2 -2
  237. package/src/ui/notification_center.js +375 -119
  238. package/src/ui/utils/auto_scrollbars.js +0 -0
  239. package/src/ui/utils/markdown.js +709 -0
  240. package/src/ui/utils/native_context_menu.js +0 -0
  241. package/src/ui/utils/overlay_scrollbar.js +16 -12
  242. package/src/ui/utils/raf_resize_observer.js +0 -0
  243. package/src/ui/utils/scroll_lock.js +0 -0
  244. package/src/ui/utils/strip_scroll.js +55 -0
  245. package/src/ui/utils/tooltip_service.js +0 -8
  246. package/tiles.js +27 -0
  247. package/widgets.js +69 -0
  248. package/wm.js +28 -0
  249. package/dist/chunk-6JSOVNID.js.map +0 -7
  250. package/dist/chunk-B5Q53LAJ.js.map +0 -7
  251. package/dist/chunk-FBGP5KN5.js.map +0 -7
  252. package/dist/chunk-FL5KFNQH.js.map +0 -7
  253. package/dist/chunk-NLTOY57M.js.map +0 -7
  254. package/dist/chunk-NNFTETK3.js +0 -633
  255. package/dist/chunk-NNFTETK3.js.map +0 -7
  256. package/dist/chunk-O5OHMWBB.js +0 -284
  257. package/dist/chunk-O5OHMWBB.js.map +0 -7
  258. package/dist/chunk-RQM5Z2MU.js.map +0 -7
  259. package/dist/chunk-SN4LSOAR.js.map +0 -7
  260. package/dist/chunk-W6FBJLPW.js +0 -367
  261. package/dist/chunk-W6FBJLPW.js.map +0 -7
  262. package/src/ui/controllers/panel_state_machine.js +0 -634
@@ -35,9 +35,18 @@
35
35
  *
36
36
  * The kit's (`bindFlowKeys`, on the editor's root): ← → ↑ ↓ move between cards
37
37
  * (and between the step tabs), Enter opens the step's settings, Delete removes
38
- * it, F2 renames it, Ctrl/⌘+Z and Ctrl/⌘+Y undo and redo, Escape goes back
39
- * from the settings to the card. The context-menu key (or Shift+F10) opens a
40
- * card's menu. Backspace is never the editor's: a host may climb a level on it.
38
+ * it, Ctrl/⌘+D duplicates it, F2 renames it, Ctrl/⌘+Z and Ctrl/⌘+Y undo and
39
+ * redo, Escape goes back from the settings to the card. The context-menu key
40
+ * (or Shift+F10) opens a card's menu. Backspace is never the editor's: a host
41
+ * may climb a level on it.
42
+ *
43
+ * ══ THE CARD'S MENU ════════════════════════════════════════════════════
44
+ *
45
+ * *Open its settings*, *Add a step after it*, *Duplicate*, *Remove step*, then
46
+ * the host's own items (`menuItems(step)`). An item that cannot be chosen is
47
+ * greyed WITH ITS REASON in its own line, never only greyed. A host item edits
48
+ * a DRAFT of the flow, and what it changed is taken as one undoable edit
49
+ * (`flow:step:menu`): it goes through the history like every other edit.
41
50
  */
42
51
 
43
52
  import { createStrings, say } from '../kit/strings.js';
@@ -53,8 +62,9 @@ import { bindFlowKeys } from '../kit/keys.js';
53
62
  import { DataTable } from '../../ui/components/data_table.js';
54
63
  import { layoutLanes, placeLanes, stepPorts } from './layout.js';
55
64
  import {
56
- LANE_ACTIONS, PARAMETER_NAME, addSource, addStepAfter, emptyPipeline, inputCandidates, inputOf, laneFeedOf,
57
- normalisePipeline, portsOf, removeParameter, removeStep, serialisePipeline, setInput, setParameter, upstreamOf,
65
+ LANE_ACTIONS, PARAMETER_NAME, addSource, addStepAfter, duplicateStep, emptyPipeline, inputCandidates, inputOf,
66
+ laneFeedOf, normalisePipeline, portsOf, removeParameter, removeStep, serialisePipeline, setInput, setParameter,
67
+ upstreamOf,
58
68
  } from './pipeline.js';
59
69
  import { createPreviewRunner } from './preview.js';
60
70
  import { LANE_STRINGS } from './strings.js';
@@ -124,6 +134,7 @@ export function createLaneEditor(host, options = {}) {
124
134
  let travelling = null; // 'undo' | 'redo' while a history step is applied
125
135
  let shownColumns = ''; // the selected step's input columns the panel was drawn with
126
136
  let panelReadOnly = false; // what the panel was last told
137
+ let panelPainting = false; // inside paintPanel: a field it removes losing the focus is not the reader leaving
127
138
 
128
139
  const node = (id) => pipeline.nodes.find((n) => n.id === id) ?? null;
129
140
  const typeOf = (n) => catalogue.get(n?.type) ?? null;
@@ -164,6 +175,7 @@ export function createLaneEditor(host, options = {}) {
164
175
  let undoBtn = null;
165
176
  let redoBtn = null;
166
177
  let statusEl = null;
178
+ let noticeEl = null; // why a key did nothing (a refused Ctrl+D)
167
179
  let showPreviewBtn = null;
168
180
  let showRunBtn = null;
169
181
  const actionButtons = new Map();
@@ -203,6 +215,10 @@ export function createLaneEditor(host, options = {}) {
203
215
  statusEl = el('span', 'twm-flow-lanes__status');
204
216
  statusEl.setAttribute('aria-live', 'polite');
205
217
  toolbar.appendChild(statusEl);
218
+ noticeEl = el('span', 'twm-flow-lanes__notice');
219
+ noticeEl.setAttribute('role', 'status');
220
+ noticeEl.hidden = true;
221
+ toolbar.appendChild(noticeEl);
206
222
  toolbar.appendChild(el('span', 'twm-flow-lanes__spacer'));
207
223
  const showId = uid('lanes-show');
208
224
  const showLabel = el('span', 'twm-flow-lanes__show-label', say(S, 'stepsShow'));
@@ -361,6 +377,7 @@ export function createLaneEditor(host, options = {}) {
361
377
  function commit(action, key = null, { preview = true } = {}) {
362
378
  const moved = history.commit(snapshot(), action, key);
363
379
  if (!moved) return false;
380
+ notice('');
364
381
  report(action, key);
365
382
  if (preview) runner.schedule();
366
383
  return true;
@@ -776,6 +793,16 @@ export function createLaneEditor(host, options = {}) {
776
793
  }
777
794
  const n = selected && selected !== 'flow' ? node(selected) : null;
778
795
  settingsHint.hidden = true;
796
+ panelPainting = true;
797
+ try {
798
+ paintPanelBody(n);
799
+ } finally {
800
+ panelPainting = false;
801
+ }
802
+ panel.setFindings(findings);
803
+ }
804
+
805
+ function paintPanelBody(n) {
779
806
  if (n) {
780
807
  panel.show(stepPanelSpec(n));
781
808
  shownColumns = JSON.stringify(inputColumns(n.id));
@@ -790,16 +817,15 @@ export function createLaneEditor(host, options = {}) {
790
817
  settingsHint.hidden = false;
791
818
  shownColumns = '';
792
819
  }
793
- panel.setFindings(findings);
794
820
  }
795
821
 
796
822
  /** A describe or preview answer changed the selected step's input columns: redraw its fields, unless
797
823
  * the reader is in them — a repaint the reader did not ask for rebuilds no field they hold. */
798
- function reshowForColumns() {
824
+ function reshowForColumns({ leaving = false } = {}) {
799
825
  if (!panel || !selected || selected === 'flow' || !node(selected)) return;
800
826
  const now = JSON.stringify(inputColumns(selected));
801
827
  if (now === shownColumns) return;
802
- if (panel.el.contains(root.ownerDocument.activeElement)) return;
828
+ if (!leaving && panel.el.contains(root.ownerDocument.activeElement)) return;
803
829
  paintPanel();
804
830
  }
805
831
 
@@ -1063,6 +1089,59 @@ export function createLaneEditor(host, options = {}) {
1063
1089
  return true;
1064
1090
  }
1065
1091
 
1092
+ /** Why a step cannot be copied now, or null. */
1093
+ function duplicateRefusal(id) {
1094
+ if (isReadOnly()) return readOnlyReason()[0] ?? say(S, 'readOnlyTitle');
1095
+ const r = duplicateStep(normalisePipeline(pipeline), catalogue, id, { strings: options.strings });
1096
+ return r.ok ? null : r.reason;
1097
+ }
1098
+
1099
+ /** Copy a step (`duplicateStep`), choose the copy and put the focus on it. */
1100
+ function duplicate(id) {
1101
+ if (isReadOnly() || !node(id)) return false;
1102
+ const r = duplicateStep(pipeline, catalogue, id, { strings: options.strings });
1103
+ if (!r.ok) {
1104
+ notice(r.reason);
1105
+ return false;
1106
+ }
1107
+ selected = r.node.id;
1108
+ commit('flow:step:duplicate');
1109
+ afterShapeChange(r.node.id);
1110
+ options.onSelect?.({ kind: 'step', id: r.node.id });
1111
+ return true;
1112
+ }
1113
+
1114
+ /** Words for a refused key, beside the status; an edit clears them. */
1115
+ function notice(text) {
1116
+ if (!noticeEl) return;
1117
+ noticeEl.textContent = String(text ?? '');
1118
+ noticeEl.hidden = !noticeEl.textContent;
1119
+ }
1120
+
1121
+ /**
1122
+ * Run a host's menu item on a DRAFT of the flow and take what it changed as
1123
+ * ONE undoable edit. An item may answer later (a promise); a draft that
1124
+ * answers after another edit, or once the flow is read only, is dropped —
1125
+ * it was made from a flow that is no longer there.
1126
+ */
1127
+ async function runHostItem(item, id) {
1128
+ if (isReadOnly() || !node(id)) return false;
1129
+ const before = serialisePipeline(pipeline);
1130
+ const draft = normalisePipeline(pipeline);
1131
+ const step = clone(node(id));
1132
+ const answer = await item.run?.({ step, pipeline: draft, editor: api });
1133
+ if (answer === false || destroyed || isReadOnly()) return false;
1134
+ if (serialisePipeline(pipeline) !== before) return false;
1135
+ if (serialisePipeline(draft) === before) return false;
1136
+ pipeline = normalisePipeline(draft);
1137
+ relayout();
1138
+ if (selected && selected !== 'flow' && !node(selected)) selected = firstStep();
1139
+ commit('flow:step:menu');
1140
+ paintAll();
1141
+ focusCard(selected);
1142
+ return true;
1143
+ }
1144
+
1066
1145
  function changeSetting(stepId, key, value) {
1067
1146
  if (isReadOnly()) return;
1068
1147
  if (stepId === null) {
@@ -1217,28 +1296,52 @@ export function createLaneEditor(host, options = {}) {
1217
1296
  const menu = el('div', 'twm-flow-lanes__menu');
1218
1297
  menu.setAttribute('role', 'menu');
1219
1298
  let handle = null;
1220
- const item = (label, glyph, run, { danger = false, disabled = false } = {}) => {
1299
+ const item = (label, glyph, run, { danger = false, refusal = null, id: itemId = null } = {}) => {
1221
1300
  const b = el('button', `twm-flow-lanes__menu-item${danger ? ' twm-flow-lanes__menu-item--danger' : ''}`);
1301
+ b.classList.toggle('twm-flow-lanes__menu-item--refused', Boolean(refusal));
1222
1302
  b.type = 'button';
1223
1303
  b.setAttribute('role', 'menuitem');
1224
- b.disabled = disabled;
1225
- b.append(icon(glyph), el('span', '', label));
1304
+ if (itemId) b.dataset.item = itemId;
1305
+ const words = el('span', 'twm-flow-lanes__menu-words');
1306
+ words.appendChild(el('span', 'twm-flow-lanes__menu-label', label));
1307
+ if (refusal) {
1308
+ // Greyed WITH its reason, in its own line: a greyed line nobody can read the reason of is dead.
1309
+ b.disabled = true;
1310
+ b.setAttribute('aria-disabled', 'true');
1311
+ words.appendChild(el('span', 'twm-flow-lanes__menu-reason', refusal));
1312
+ }
1313
+ b.append(icon(glyph || ''), words);
1226
1314
  b.addEventListener('click', () => {
1315
+ if (b.disabled) return;
1227
1316
  handle?.close('chosen');
1228
1317
  run();
1229
1318
  });
1230
1319
  menu.appendChild(b);
1231
1320
  return b;
1232
1321
  };
1233
- const first = item(say(S, 'openSettings'), 'tune', () => openSettingsOf(id));
1322
+ const first = item(say(S, 'openSettings'), 'tune', () => openSettingsOf(id), { id: 'settings' });
1234
1323
  if (canAddAfter(id)) {
1235
1324
  item(say(S, 'addStepAfter'), 'add', () => {
1236
1325
  const add = [...surface.querySelectorAll('.twm-flow-lanes__node')].find((w) => w.dataset.step === id)
1237
1326
  ?.querySelector('.twm-flow-lanes__add');
1238
1327
  if (add) pickAfter(id, add);
1239
- });
1328
+ }, { id: 'add' });
1329
+ }
1330
+ item(say(S, 'duplicate'), 'content_copy', () => duplicate(id), { refusal: duplicateRefusal(id), id: 'duplicate' });
1331
+ if (!isReadOnly()) item(say(S, 'removeStep'), 'delete', () => remove(id), { danger: true, id: 'remove' });
1332
+ const extra = typeof options.menuItems === 'function' ? options.menuItems(clone(node(id))) : null;
1333
+ const hosts = (Array.isArray(extra) ? extra : []).filter((x) => isObject(x) && x.label !== undefined);
1334
+ if (hosts.length) {
1335
+ const sep = el('div', 'twm-flow-lanes__menu-sep');
1336
+ sep.setAttribute('role', 'separator');
1337
+ menu.appendChild(sep);
1338
+ }
1339
+ const roWhy = isReadOnly() ? (readOnlyReason()[0] ?? say(S, 'readOnlyTitle')) : null;
1340
+ for (const h of hosts) {
1341
+ const refusal = roWhy ?? (h.disabled ? String(h.reason || say(S, 'unavailable')) : null);
1342
+ item(String(h.label), h.icon || '', () => { runHostItem(h, id); },
1343
+ { danger: Boolean(h.danger), refusal, id: h.id !== undefined ? String(h.id) : null });
1240
1344
  }
1241
- if (!isReadOnly()) item(say(S, 'removeStep'), 'delete', () => remove(id), { danger: true });
1242
1345
  menu.addEventListener('keydown', (ev) => {
1243
1346
  if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return;
1244
1347
  ev.preventDefault();
@@ -1277,6 +1380,14 @@ export function createLaneEditor(host, options = {}) {
1277
1380
  remove(id);
1278
1381
  return true;
1279
1382
  },
1383
+ duplicate: (ev) => {
1384
+ if (!(inSurface(ev) || inTabs(ev))) return false;
1385
+ const id = stepAt(ev) ?? selected;
1386
+ if (!id || id === 'flow' || !node(id)) return false;
1387
+ if (isReadOnly()) notice(readOnlyReason()[0] ?? say(S, 'readOnlyTitle'));
1388
+ else duplicate(id);
1389
+ return true;
1390
+ },
1280
1391
  rename: () => {
1281
1392
  if (!selected || selected === 'flow' || !panel) return false;
1282
1393
  const t = panel.el.querySelector('[data-twm-flow-title]');
@@ -1311,6 +1422,16 @@ export function createLaneEditor(host, options = {}) {
1311
1422
  };
1312
1423
  settingsCol?.addEventListener('keydown', onPanelKey);
1313
1424
 
1425
+ // Columns that arrived while the reader was in the settings are drawn when
1426
+ // the focus LEAVES them (for anything but a popover the panel opened): a
1427
+ // column typed by name before the columns were known becomes a select then.
1428
+ const onPanelLeave = (ev) => {
1429
+ const to = ev.relatedTarget;
1430
+ if (panelPainting || (to && (panel.el.contains(to) || to.closest?.('.twm-flow-popover')))) return;
1431
+ reshowForColumns({ leaving: true });
1432
+ };
1433
+ settingsCol?.addEventListener('focusout', onPanelLeave);
1434
+
1314
1435
  function arrow(ev, dir) {
1315
1436
  if (inTabs(ev)) {
1316
1437
  if (dir !== 'left' && dir !== 'right') return false;
@@ -1449,6 +1570,7 @@ export function createLaneEditor(host, options = {}) {
1449
1570
  lastRunAsk?.abort();
1450
1571
  unbindKeys();
1451
1572
  settingsCol?.removeEventListener('keydown', onPanelKey);
1573
+ settingsCol?.removeEventListener('focusout', onPanelLeave);
1452
1574
  closeFlowPopovers();
1453
1575
  table?.dispose?.();
1454
1576
  table = null;
@@ -20,7 +20,9 @@ export {
20
20
  LANE_GEOMETRY, LANE_PAD, LANE_GAP, CW as LANE_CW, CH as LANE_CH, GAP as LANE_COLUMN_GAP,
21
21
  LAYOUT_STRINGS as LANE_LAYOUT_STRINGS, layoutLanes, placeLanes, stepPorts as laneStepPorts,
22
22
  } from './layout.js';
23
- export { LANE_ACTIONS, emptyPipeline, normalisePipeline, serialisePipeline } from './pipeline.js';
23
+ export {
24
+ LANE_ACTIONS, PIPELINE_STRINGS as LANE_PIPELINE_STRINGS, emptyPipeline, normalisePipeline, serialisePipeline,
25
+ } from './pipeline.js';
24
26
  export * as lanePipeline from './pipeline.js';
25
27
  export { createPreviewRunner as createLanePreviewRunner } from './preview.js';
26
28
  export { LANE_STRINGS } from './strings.js';
File without changes
@@ -29,10 +29,18 @@ import { stepPorts } from './layout.js';
29
29
 
30
30
  /** The edits the lane editor records — anything else is refused by its history. */
31
31
  export const LANE_ACTIONS = Object.freeze([
32
- 'flow:step:add', 'flow:step:remove', 'flow:step:label', 'flow:step:config', 'flow:join:set',
33
- 'flow:source:add', 'flow:parameter:add', 'flow:parameter:change', 'flow:parameter:remove', 'flow:settings',
32
+ 'flow:step:add', 'flow:step:remove', 'flow:step:label', 'flow:step:config', 'flow:step:duplicate',
33
+ 'flow:step:menu', 'flow:join:set', 'flow:source:add', 'flow:parameter:add', 'flow:parameter:change',
34
+ 'flow:parameter:remove', 'flow:settings',
34
35
  ]);
35
36
 
37
+ /** The words the pure edits say: a copy's name, and why a step cannot be copied. */
38
+ export const PIPELINE_STRINGS = Object.freeze({
39
+ copyLabel: (title, n) => (n > 1 ? `${title} (copy ${n})` : `${title} (copy)`),
40
+ duplicateNoStep: (id) => `There is no step ‘${id}’ to copy.`,
41
+ duplicateUnavailable: (title, why) => `‘${title}’ cannot be copied: ${why}`,
42
+ });
43
+
36
44
  /** A parameter's name: what a reference to it can spell. */
37
45
  export const PARAMETER_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
38
46
 
@@ -250,6 +258,40 @@ export function removeStep(pipeline, catalogue, id) {
250
258
  return true;
251
259
  }
252
260
 
261
+ /**
262
+ * A copy of step `id`, put right after it in `nodes`: a fresh id (the
263
+ * catalogue's stem, as for a new step), a label derived from the step's title
264
+ * (`Title (copy)`, then `(copy 2)`, … — never one another step already has), a
265
+ * DEEP copy of its config and every other key it carries (bar `position`, which
266
+ * belongs to the original's place), and THE SAME LINES IN: each connection into
267
+ * the step is repeated into the copy at the same port, under the lowest free
268
+ * ids. Nothing leads out of the copy, so it is a fork of what fed the step (a
269
+ * copied source starts a lane of its own).
270
+ *
271
+ * Returns `{ok: true, node}`, or `{ok: false, reason}` — and changes nothing —
272
+ * when there is no such step, or its type is one the catalogue says is not
273
+ * available here (`unavailable`, which is the reason given).
274
+ */
275
+ export function duplicateStep(pipeline, catalogue, id, { strings = null } = {}) {
276
+ const S = { ...PIPELINE_STRINGS, ...(isObject(strings) ? strings : {}) };
277
+ const original = findNode(pipeline, id);
278
+ if (!original) return { ok: false, reason: S.duplicateNoStep(String(id)) };
279
+ const type = catalogue?.get?.(original.type) ?? null;
280
+ const title = original.label || type?.label || String(original.type);
281
+ if (type?.unavailable) return { ok: false, reason: S.duplicateUnavailable(title, String(type.unavailable)) };
282
+ const titles = new Set(pipeline.nodes.map((n) => n.label || catalogue?.get?.(n.type)?.label || String(n.type)));
283
+ let label = S.copyLabel(title, 1);
284
+ for (let i = 2; titles.has(label); i += 1) label = S.copyLabel(title, i);
285
+ const copy = { id: nextStepId(pipeline, original.type, catalogue), type: original.type, label,
286
+ config: isObject(original.config) ? clone(original.config) : {} };
287
+ for (const [k, v] of Object.entries(original)) if (!NODE_KEYS.includes(k)) copy[k] = clone(v);
288
+ pipeline.nodes.splice(pipeline.nodes.indexOf(original) + 1, 0, copy);
289
+ for (const c of pipeline.connections.filter((x) => x.targetId === id)) {
290
+ pipeline.connections.push({ ...clone(c), id: nextConnectionId(pipeline), targetId: copy.id });
291
+ }
292
+ return { ok: true, node: copy };
293
+ }
294
+
253
295
  /** May `sourceId` feed `stepId` without making a cycle? */
254
296
  export function mayFeed(pipeline, stepId, sourceId) {
255
297
  if (!sourceId || sourceId === stepId) return false;
File without changes
@@ -24,6 +24,7 @@ export const LANE_STRINGS = Object.freeze({
24
24
  stepMenu: (title) => `${title}: what to do`,
25
25
  openSettings: 'Open its settings',
26
26
  addStepAfter: 'Add a step after it',
27
+ unavailable: 'Not available here.',
27
28
 
28
29
  // ── the toolbar ────────────────────────────────────────────────────
29
30
  toolbar: 'Flow',
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes