@workbench-kit/shell-react 0.0.2-prototype.0.2.10

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 (105) hide show
  1. package/README.md +98 -0
  2. package/package.json +51 -0
  3. package/src/chat/ai-mock-proposals.ts +39 -0
  4. package/src/chat/command-input.ts +52 -0
  5. package/src/chat/command-policy.ts +54 -0
  6. package/src/chat/command-surface.tsx +155 -0
  7. package/src/chat/use-chat-command-proposals.ts +194 -0
  8. package/src/chat/view-data.ts +20 -0
  9. package/src/chat/view.tsx +220 -0
  10. package/src/commands/inspector-surface.tsx +46 -0
  11. package/src/commands/use-command-descriptors.ts +41 -0
  12. package/src/commands/use-context-key-revision.ts +18 -0
  13. package/src/commands/view-data.ts +17 -0
  14. package/src/commands/view.tsx +43 -0
  15. package/src/devtools/WorkbenchDevtoolsPanel.tsx +124 -0
  16. package/src/devtools/WorkbenchDevtoolsShell.tsx +17 -0
  17. package/src/devtools/index.ts +11 -0
  18. package/src/devtools/use-workbench-devtools-snapshot.ts +84 -0
  19. package/src/devtools/workbench-devtools-snapshot.ts +140 -0
  20. package/src/devtools/workbench-devtools.css +82 -0
  21. package/src/editor/area-dnd.ts +69 -0
  22. package/src/editor/area-model.ts +71 -0
  23. package/src/editor/area.css +454 -0
  24. package/src/editor/area.tsx +676 -0
  25. package/src/editor/host-surface.tsx +691 -0
  26. package/src/editor/markdown-document-view-data.ts +16 -0
  27. package/src/editor/pane-visibility.ts +130 -0
  28. package/src/editor/resource.ts +36 -0
  29. package/src/editor/state-storage.ts +182 -0
  30. package/src/editor/tab-context-menu.ts +118 -0
  31. package/src/editor/use-editor.ts +138 -0
  32. package/src/editor/view-providers.tsx +504 -0
  33. package/src/editor/workspace-file-availability.ts +15 -0
  34. package/src/editor/workspace-reconcile.tsx +31 -0
  35. package/src/explorer/context-menu.ts +114 -0
  36. package/src/explorer/create-command-workspace-explorer-port.ts +86 -0
  37. package/src/explorer/reveal.ts +112 -0
  38. package/src/explorer/search-view-data.ts +15 -0
  39. package/src/explorer/search-view.tsx +65 -0
  40. package/src/explorer/use-active-workspace-path.ts +15 -0
  41. package/src/explorer/view-data.ts +58 -0
  42. package/src/explorer/view.tsx +227 -0
  43. package/src/extensions/context-menu.ts +52 -0
  44. package/src/extensions/management-model.ts +313 -0
  45. package/src/extensions/use-extension-management.ts +241 -0
  46. package/src/extensions/view-data.ts +19 -0
  47. package/src/extensions/view.tsx +63 -0
  48. package/src/field-remap/chrome-labels.ts +74 -0
  49. package/src/field-remap/convert-note-editor.tsx +206 -0
  50. package/src/field-remap/convert-palette.tsx +126 -0
  51. package/src/field-remap/demo.tsx +105 -0
  52. package/src/field-remap/detail-panel.tsx +659 -0
  53. package/src/field-remap/editor-surface.tsx +36 -0
  54. package/src/field-remap/flow-adapter.ts +838 -0
  55. package/src/field-remap/flow-ops.ts +442 -0
  56. package/src/field-remap/flow.tsx +948 -0
  57. package/src/field-remap/graph.tsx +559 -0
  58. package/src/field-remap/index.ts +88 -0
  59. package/src/field-remap/io-class-browse.tsx +145 -0
  60. package/src/field-remap/jsonata-transform.ts +166 -0
  61. package/src/field-remap/panel.tsx +466 -0
  62. package/src/field-remap/samples.ts +507 -0
  63. package/src/field-remap/shape-io-editor.tsx +170 -0
  64. package/src/field-remap/table-mapping-adapter.ts +59 -0
  65. package/src/field-remap/transform-options-editor.tsx +312 -0
  66. package/src/field-remap/tree.tsx +404 -0
  67. package/src/field-remap/view-data.ts +16 -0
  68. package/src/field-remap/view.css +1367 -0
  69. package/src/field-remap/view.tsx +86 -0
  70. package/src/index.ts +294 -0
  71. package/src/jdw/document-view-data.ts +31 -0
  72. package/src/jdw/json-form-source-patch.ts +323 -0
  73. package/src/jdw/lab-view-data.ts +25 -0
  74. package/src/jdw/lab-view.css +21 -0
  75. package/src/jdw/lab-view.tsx +79 -0
  76. package/src/jdw/widget-form-view.tsx +68 -0
  77. package/src/jdw/widget-preview-view.tsx +54 -0
  78. package/src/management/keybinding-overrides-storage.ts +49 -0
  79. package/src/management/keybinding-settings.tsx +16 -0
  80. package/src/management/palette-commands.ts +38 -0
  81. package/src/management/preference-settings-storage.ts +50 -0
  82. package/src/management/settings-ids.ts +12 -0
  83. package/src/management/settings.tsx +96 -0
  84. package/src/management/use-command-management.ts +191 -0
  85. package/src/management/use-keybinding-management.ts +130 -0
  86. package/src/shell/model.tsx +149 -0
  87. package/src/shell/provider.tsx +606 -0
  88. package/src/shell/secondary-actions.tsx +59 -0
  89. package/src/shell/settings-constants.ts +9 -0
  90. package/src/shell/settings.tsx +599 -0
  91. package/src/shell/shell.tsx +680 -0
  92. package/src/shell/titlebar-layout-controls.tsx +4 -0
  93. package/src/shell/view-host.tsx +212 -0
  94. package/src/workbench/appearance-storage.ts +107 -0
  95. package/src/workbench/command-host.tsx +243 -0
  96. package/src/workbench/command-palette.ts +208 -0
  97. package/src/workbench/keybinding-bridge.ts +54 -0
  98. package/src/workbench/layout-storage.ts +134 -0
  99. package/src/workbench/profile-modal.tsx +118 -0
  100. package/src/workbench/shell-command-registration.ts +58 -0
  101. package/src/workbench/startup-gate.tsx +136 -0
  102. package/src/workbench/status-sections.ts +43 -0
  103. package/src/workbench/use-persisted-appearance.ts +33 -0
  104. package/src/workbench/user-commands.ts +46 -0
  105. package/src/workbench/workspace-view-state.ts +35 -0
@@ -0,0 +1,948 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useImperativeHandle,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type JSX,
9
+ type KeyboardEvent,
10
+ type MouseEvent,
11
+ type Ref,
12
+ } from 'react';
13
+ import {
14
+ Background,
15
+ Controls,
16
+ Handle,
17
+ MiniMap,
18
+ Position,
19
+ ReactFlow,
20
+ ReactFlowProvider,
21
+ useEdgesState,
22
+ useNodesState,
23
+ useReactFlow,
24
+ type Connection,
25
+ type Edge,
26
+ type Node,
27
+ type NodeProps,
28
+ } from '@xyflow/react';
29
+ import '@xyflow/react/dist/style.css';
30
+ import { Badge, Button } from '@workbench-kit/react/primitives';
31
+ import {
32
+ MAX_TRANSFORM_CHAIN,
33
+ type MappingEdge,
34
+ type MappingOperator,
35
+ type SourceField,
36
+ type TargetSlot,
37
+ type ValueTransformRegistry,
38
+ } from '@workbench-kit/field-remap';
39
+
40
+ import { FieldRemapConvertPalette } from './convert-palette.js';
41
+ import {
42
+ resolveFieldRemapChromeLabels,
43
+ type FieldRemapChromeLabels,
44
+ type FieldRemapTranslate,
45
+ } from './chrome-labels.js';
46
+ import { FieldRemapDetailPanel } from './detail-panel.js';
47
+ import {
48
+ applyFieldRemapFlowConnection,
49
+ isValidFieldRemapFlowConnection,
50
+ mappingToFlowGraph,
51
+ parseDraftTransformNodeId,
52
+ parseOperatorNodeId,
53
+ type FieldRemapCombineOperatorNodeData,
54
+ type FieldRemapDraftTransformNodeData,
55
+ type FieldRemapFlowEdgeData,
56
+ type FieldRemapFlowNodeData,
57
+ type FieldRemapSourceObjectNodeData,
58
+ type FieldRemapSplitOperatorNodeData,
59
+ type FieldRemapTargetObjectNodeData,
60
+ type FieldRemapTransformNodeData,
61
+ } from './flow-adapter.js';
62
+ import {
63
+ addTransformStepToEdge,
64
+ bindDraftSource,
65
+ bindDraftTarget,
66
+ bindOperatorInput,
67
+ bindOperatorOutput,
68
+ canEditListContext,
69
+ createCombineOperator,
70
+ createDraftTransform,
71
+ createSplitOperator,
72
+ edgePortTypes,
73
+ enableListContextOnEdge,
74
+ finalizeDraftTransform,
75
+ listCompatibleTransforms,
76
+ removeMappingOperator,
77
+ removeTransformStepFromEdge,
78
+ updateMappingOperator,
79
+ type FieldRemapDraftTransform,
80
+ type FieldRemapSelection,
81
+ } from './flow-ops.js';
82
+ import './view.css';
83
+
84
+ function TypeBadge({ dataType }: { readonly dataType?: string }): JSX.Element | null {
85
+ if (dataType !== 'object' && dataType !== 'array') {
86
+ return null;
87
+ }
88
+ return <Badge variant="muted">{dataType}</Badge>;
89
+ }
90
+
91
+ function SourceObjectNode({ data }: NodeProps<Node<FieldRemapSourceObjectNodeData>>): JSX.Element {
92
+ return (
93
+ <div
94
+ className="workbench-field-remap-flow-node workbench-field-remap-flow-node--object workbench-field-remap-flow-node--source"
95
+ data-testid="field-remap-source-schema"
96
+ >
97
+ <div className="workbench-field-remap-flow-node__header">
98
+ <strong>{data.title}</strong>
99
+ <span className="workbench-field-remap-flow-node__hint">
100
+ {data.schemaRole ?? 'Source schema'}
101
+ </span>
102
+ </div>
103
+ <ul className="workbench-field-remap-flow-node__ports">
104
+ {data.ports.map((port) => (
105
+ <li key={port.fieldId} className="workbench-field-remap-flow-node__port">
106
+ <span className="workbench-field-remap-flow-node__port-label">
107
+ <span>{port.label}</span>
108
+ <TypeBadge dataType={port.dataType} />
109
+ </span>
110
+ <Handle
111
+ type="source"
112
+ position={Position.Right}
113
+ id={port.fieldId}
114
+ className="workbench-field-remap-flow-node__handle"
115
+ />
116
+ </li>
117
+ ))}
118
+ </ul>
119
+ </div>
120
+ );
121
+ }
122
+
123
+ function TargetObjectNode({ data }: NodeProps<Node<FieldRemapTargetObjectNodeData>>): JSX.Element {
124
+ return (
125
+ <div
126
+ className="workbench-field-remap-flow-node workbench-field-remap-flow-node--object workbench-field-remap-flow-node--target"
127
+ data-testid="field-remap-target-schema"
128
+ >
129
+ <div className="workbench-field-remap-flow-node__header">
130
+ <strong>{data.title}</strong>
131
+ <span className="workbench-field-remap-flow-node__hint">
132
+ {data.schemaRole ?? 'Target schema'}
133
+ </span>
134
+ </div>
135
+ <ul className="workbench-field-remap-flow-node__ports">
136
+ {data.ports.map((port) => (
137
+ <li key={port.fieldId} className="workbench-field-remap-flow-node__port">
138
+ <Handle
139
+ type="target"
140
+ position={Position.Left}
141
+ id={port.fieldId}
142
+ className="workbench-field-remap-flow-node__handle"
143
+ />
144
+ <span className="workbench-field-remap-flow-node__port-label">
145
+ <span>{port.label}</span>
146
+ <TypeBadge dataType={port.dataType} />
147
+ </span>
148
+ </li>
149
+ ))}
150
+ </ul>
151
+ </div>
152
+ );
153
+ }
154
+
155
+ function TransformNode({ data }: NodeProps<Node<FieldRemapTransformNodeData>>): JSX.Element {
156
+ return (
157
+ <div
158
+ className={
159
+ data.selected
160
+ ? 'workbench-field-remap-flow-node workbench-field-remap-flow-node--transform is-selected'
161
+ : 'workbench-field-remap-flow-node workbench-field-remap-flow-node--transform'
162
+ }
163
+ >
164
+ <Handle type="target" position={Position.Left} id="in" />
165
+ <div className="workbench-field-remap-flow-node__title">
166
+ <strong>{data.label}</strong>
167
+ </div>
168
+ <code className="workbench-field-remap-flow-node__id">{data.transformId}</code>
169
+ <Handle type="source" position={Position.Right} id="out" />
170
+ </div>
171
+ );
172
+ }
173
+
174
+ function DraftTransformNode({
175
+ data,
176
+ }: NodeProps<Node<FieldRemapDraftTransformNodeData>>): JSX.Element {
177
+ return (
178
+ <div
179
+ className="workbench-field-remap-flow-node workbench-field-remap-flow-node--transform workbench-field-remap-flow-node--draft"
180
+ data-testid={`field-remap-draft-${data.localId}`}
181
+ >
182
+ <Handle type="target" position={Position.Left} id="in" />
183
+ <div className="workbench-field-remap-flow-node__title">
184
+ <strong>{data.label}</strong>
185
+ <Badge variant="muted">draft</Badge>
186
+ </div>
187
+ <code className="workbench-field-remap-flow-node__id">{data.transformId}</code>
188
+ <Handle type="source" position={Position.Right} id="out" />
189
+ </div>
190
+ );
191
+ }
192
+
193
+ function CombineOperatorNode({
194
+ data,
195
+ }: NodeProps<Node<FieldRemapCombineOperatorNodeData>>): JSX.Element {
196
+ return (
197
+ <div
198
+ className="workbench-field-remap-flow-node workbench-field-remap-flow-node--operator workbench-field-remap-flow-node--combine"
199
+ data-testid={`field-remap-op-${data.operatorId}`}
200
+ >
201
+ <div className="workbench-field-remap-flow-node__title">
202
+ <strong>{data.label}</strong>
203
+ <Badge variant="muted">n→1</Badge>
204
+ </div>
205
+ <ul className="workbench-field-remap-flow-node__ports">
206
+ {data.inputFieldIds.map((fieldId) => (
207
+ <li key={fieldId} className="workbench-field-remap-flow-node__port">
208
+ <Handle
209
+ type="target"
210
+ position={Position.Left}
211
+ id={fieldId}
212
+ className="workbench-field-remap-flow-node__handle"
213
+ />
214
+ <span className="workbench-field-remap-flow-node__port-label">
215
+ <code>{fieldId}</code>
216
+ </span>
217
+ </li>
218
+ ))}
219
+ </ul>
220
+ <div className="workbench-field-remap-flow-node__port workbench-field-remap-flow-node__port--out">
221
+ <span className="workbench-field-remap-flow-node__port-label">
222
+ <code>{data.outputSlotId}</code>
223
+ </span>
224
+ <Handle
225
+ type="source"
226
+ position={Position.Right}
227
+ id="out"
228
+ className="workbench-field-remap-flow-node__handle"
229
+ />
230
+ </div>
231
+ </div>
232
+ );
233
+ }
234
+
235
+ function SplitOperatorNode({
236
+ data,
237
+ }: NodeProps<Node<FieldRemapSplitOperatorNodeData>>): JSX.Element {
238
+ return (
239
+ <div
240
+ className="workbench-field-remap-flow-node workbench-field-remap-flow-node--operator workbench-field-remap-flow-node--split"
241
+ data-testid={`field-remap-op-${data.operatorId}`}
242
+ >
243
+ <div className="workbench-field-remap-flow-node__title">
244
+ <strong>{data.label}</strong>
245
+ <Badge variant="muted">1→n</Badge>
246
+ </div>
247
+ <div className="workbench-field-remap-flow-node__port workbench-field-remap-flow-node__port--in">
248
+ <Handle
249
+ type="target"
250
+ position={Position.Left}
251
+ id="in"
252
+ className="workbench-field-remap-flow-node__handle"
253
+ />
254
+ <span className="workbench-field-remap-flow-node__port-label">
255
+ <code>{data.inputFieldId}</code>
256
+ </span>
257
+ </div>
258
+ <ul className="workbench-field-remap-flow-node__ports">
259
+ {data.outputSlotIds.map((slotId) => (
260
+ <li key={slotId} className="workbench-field-remap-flow-node__port">
261
+ <span className="workbench-field-remap-flow-node__port-label">
262
+ <code>{slotId}</code>
263
+ </span>
264
+ <Handle
265
+ type="source"
266
+ position={Position.Right}
267
+ id={slotId}
268
+ className="workbench-field-remap-flow-node__handle"
269
+ />
270
+ </li>
271
+ ))}
272
+ </ul>
273
+ </div>
274
+ );
275
+ }
276
+
277
+ const nodeTypes = {
278
+ fieldRemapSourceObject: SourceObjectNode,
279
+ fieldRemapTargetObject: TargetObjectNode,
280
+ fieldRemapTransform: TransformNode,
281
+ fieldRemapDraftTransform: DraftTransformNode,
282
+ fieldRemapCombineOperator: CombineOperatorNode,
283
+ fieldRemapSplitOperator: SplitOperatorNode,
284
+ };
285
+
286
+ /** Imperative Flow chrome actions for integrating hosts (fit-view without Controls DOM). */
287
+ export interface FieldRemapFlowActions {
288
+ fitView: (options?: { padding?: number; maxZoom?: number }) => void;
289
+ }
290
+
291
+ const DEFAULT_FIT_VIEW_OPTIONS = { padding: 0.12, maxZoom: 1.15 } as const;
292
+
293
+ /**
294
+ * Bridges imperative fit-view to hosts without subscribing the whole canvas
295
+ * to the React Flow store (avoids update loops with controlled nodes).
296
+ */
297
+ function FieldRemapFlowActionsBridge({
298
+ flowActionsRef,
299
+ }: {
300
+ readonly flowActionsRef?: Ref<FieldRemapFlowActions | null> | undefined;
301
+ }): null {
302
+ const { fitView } = useReactFlow();
303
+ useImperativeHandle(
304
+ flowActionsRef,
305
+ () => ({
306
+ fitView: (options) => {
307
+ void fitView({
308
+ padding: options?.padding ?? DEFAULT_FIT_VIEW_OPTIONS.padding,
309
+ maxZoom: options?.maxZoom ?? DEFAULT_FIT_VIEW_OPTIONS.maxZoom,
310
+ });
311
+ },
312
+ }),
313
+ [fitView],
314
+ );
315
+ return null;
316
+ }
317
+
318
+ export interface FieldRemapFlowMapperProps {
319
+ readonly sources: readonly SourceField[];
320
+ readonly targets: readonly TargetSlot[];
321
+ readonly edges: readonly MappingEdge[];
322
+ readonly transforms: ValueTransformRegistry;
323
+ readonly onEdgesChange: (edges: readonly MappingEdge[]) => void;
324
+ /** Document v2 n→m operators (display + authoring when onOperatorsChange is set). */
325
+ readonly operators?: readonly MappingOperator[] | undefined;
326
+ readonly onOperatorsChange?: ((operators: readonly MappingOperator[]) => void) | undefined;
327
+ readonly sourceTitle?: string | undefined;
328
+ readonly targetTitle?: string | undefined;
329
+ readonly selection?: FieldRemapSelection | undefined;
330
+ readonly onSelectionChange?: ((next: FieldRemapSelection) => void) | undefined;
331
+ /**
332
+ * When false, MiniMap is not mounted (not CSS-hidden). Defaults to true for
333
+ * backward compatibility with existing samples.
334
+ */
335
+ readonly showMinimap?: boolean | undefined;
336
+ /** Pane context menu; host owns menu UI. Receives current Field Remap selection. */
337
+ readonly onPaneContextMenu?:
338
+ | ((event: MouseEvent | globalThis.MouseEvent, ctx: { selection: FieldRemapSelection }) => void)
339
+ | undefined;
340
+ /** Node context menu; host owns menu UI. */
341
+ readonly onNodeContextMenu?:
342
+ | ((
343
+ event: MouseEvent | globalThis.MouseEvent,
344
+ ctx: { nodeId: string; selection: FieldRemapSelection },
345
+ ) => void)
346
+ | undefined;
347
+ /** Edge context menu; host owns menu UI. */
348
+ readonly onEdgeContextMenu?:
349
+ | ((
350
+ event: MouseEvent | globalThis.MouseEvent,
351
+ ctx: { edgeId: string; selection: FieldRemapSelection },
352
+ ) => void)
353
+ | undefined;
354
+ /** Imperative fit-view (same defaults as Controls fit-view). */
355
+ readonly flowActionsRef?: Ref<FieldRemapFlowActions | null> | undefined;
356
+ /**
357
+ * Override high-visibility chrome strings (edge-list heading, Convert palette).
358
+ * Prefer this over CSS text hacks when the host product noun is not “Bindings”.
359
+ */
360
+ readonly labels?: Partial<FieldRemapChromeLabels> | undefined;
361
+ /** Optional `t(key, fallback)` injection; `labels` wins when both are set. */
362
+ readonly t?: FieldRemapTranslate | undefined;
363
+ }
364
+
365
+ function FieldRemapFlowCanvas({
366
+ sources,
367
+ targets,
368
+ edges,
369
+ transforms,
370
+ onEdgesChange,
371
+ operators = [],
372
+ onOperatorsChange,
373
+ sourceTitle,
374
+ targetTitle,
375
+ selection: selectionProp,
376
+ onSelectionChange: onSelectionChangeProp,
377
+ showMinimap = true,
378
+ onPaneContextMenu,
379
+ onNodeContextMenu,
380
+ onEdgeContextMenu,
381
+ flowActionsRef,
382
+ labels: labelOverrides,
383
+ t,
384
+ }: FieldRemapFlowMapperProps): JSX.Element {
385
+ const chromeLabels = useMemo(
386
+ () => resolveFieldRemapChromeLabels(labelOverrides, t),
387
+ [labelOverrides, t],
388
+ );
389
+ const [internalSelection, setInternalSelection] = useState<FieldRemapSelection>(null);
390
+ const selection = selectionProp !== undefined ? selectionProp : internalSelection;
391
+ const setSelection = onSelectionChangeProp ?? setInternalSelection;
392
+ const [drafts, setDrafts] = useState<readonly FieldRemapDraftTransform[]>([]);
393
+ const [placeTransformId, setPlaceTransformId] = useState(() => {
394
+ const first = transforms.list().find((definition) => definition.id !== 'identity');
395
+ return first?.id ?? '';
396
+ });
397
+
398
+ const graph = useMemo(
399
+ () =>
400
+ mappingToFlowGraph({
401
+ sources,
402
+ targets,
403
+ edges,
404
+ transforms,
405
+ operators,
406
+ sourceTitle,
407
+ targetTitle,
408
+ drafts,
409
+ }),
410
+ [sources, targets, edges, transforms, operators, sourceTitle, targetTitle, drafts],
411
+ );
412
+
413
+ const nodesWithSelection = useMemo(
414
+ () =>
415
+ graph.nodes.map((node) => {
416
+ if (node.data.kind === 'transform') {
417
+ const selected =
418
+ selection?.kind === 'transformStep' &&
419
+ selection.edgeId === node.data.mappingEdgeId &&
420
+ selection.stepIndex === node.data.stepIndex;
421
+ return {
422
+ ...node,
423
+ data: { ...node.data, selected },
424
+ selected,
425
+ };
426
+ }
427
+ if (node.data.kind === 'draft-transform') {
428
+ const selected = selection?.kind === 'draft' && selection.localId === node.data.localId;
429
+ return { ...node, selected };
430
+ }
431
+ if (node.data.kind === 'combine-operator' || node.data.kind === 'split-operator') {
432
+ const selected =
433
+ selection?.kind === 'operator' && selection.operatorId === node.data.operatorId;
434
+ return { ...node, selected };
435
+ }
436
+ return node;
437
+ }),
438
+ [graph.nodes, selection],
439
+ );
440
+
441
+ const [nodes, setNodes, onNodesChange] = useNodesState(nodesWithSelection);
442
+ const [flowEdges, setFlowEdges, onFlowEdgesChange] = useEdgesState(graph.edges);
443
+
444
+ // Sync controlled graph by topology/selection signature only. Depending on
445
+ // `nodesWithSelection` (new array each paint) re-enters xyflow StoreUpdater and
446
+ // can hit "Maximum update depth exceeded" in embed hosts with tight flex layouts.
447
+ const graphSyncKey = useMemo(
448
+ () =>
449
+ [
450
+ ...graph.nodes.map((node) => node.id),
451
+ ...graph.edges.map(
452
+ (edge) =>
453
+ `${edge.id}:${edge.source}:${edge.target}:${edge.sourceHandle ?? ''}:${edge.targetHandle ?? ''}`,
454
+ ),
455
+ selection ? JSON.stringify(selection) : '',
456
+ ...drafts.map((draft) => draft.localId),
457
+ ].join('|'),
458
+ [drafts, graph.edges, graph.nodes, selection],
459
+ );
460
+ const nodesWithSelectionRef = useRef(nodesWithSelection);
461
+ const graphEdgesRef = useRef(graph.edges);
462
+ nodesWithSelectionRef.current = nodesWithSelection;
463
+ graphEdgesRef.current = graph.edges;
464
+
465
+ useEffect(() => {
466
+ setNodes(nodesWithSelectionRef.current);
467
+ setFlowEdges(graphEdgesRef.current);
468
+ }, [graphSyncKey, setFlowEdges, setNodes]);
469
+
470
+ const connectionContext = useMemo(
471
+ () => ({ sources, targets, edges, transforms, drafts, operators }),
472
+ [sources, targets, edges, transforms, drafts, operators],
473
+ );
474
+
475
+ const isValidConnection = useCallback(
476
+ (connection: Connection | Edge) =>
477
+ isValidFieldRemapFlowConnection(connection, connectionContext),
478
+ [connectionContext],
479
+ );
480
+
481
+ const onConnect = useCallback(
482
+ (connection: Connection) => {
483
+ if (!connection.source || !connection.target) {
484
+ return;
485
+ }
486
+ if (!isValidFieldRemapFlowConnection(connection, connectionContext)) {
487
+ return;
488
+ }
489
+
490
+ const draftAsTarget = parseDraftTransformNodeId(connection.target);
491
+ if (draftAsTarget && connection.sourceHandle) {
492
+ const draft = drafts.find((item) => item.localId === draftAsTarget);
493
+ if (!draft) {
494
+ return;
495
+ }
496
+ const bound = bindDraftSource(draft, connection.sourceHandle);
497
+ const finalized = finalizeDraftTransform(bound, {
498
+ registry: transforms,
499
+ sources,
500
+ targets,
501
+ existing: edges,
502
+ });
503
+ if (finalized) {
504
+ const withoutTarget = edges.filter(
505
+ (edge) => edge.targetSlotId !== finalized.targetSlotId,
506
+ );
507
+ onEdgesChange([...withoutTarget, finalized]);
508
+ setDrafts(drafts.filter((item) => item.localId !== draft.localId));
509
+ setSelection({
510
+ kind: 'transformStep',
511
+ edgeId: finalized.id,
512
+ stepIndex: 0,
513
+ });
514
+ return;
515
+ }
516
+ setDrafts(drafts.map((item) => (item.localId === draft.localId ? bound : item)));
517
+ setSelection({ kind: 'draft', localId: draft.localId });
518
+ return;
519
+ }
520
+
521
+ const draftAsSource = parseDraftTransformNodeId(connection.source);
522
+ if (draftAsSource && connection.targetHandle) {
523
+ const draft = drafts.find((item) => item.localId === draftAsSource);
524
+ if (!draft) {
525
+ return;
526
+ }
527
+ const bound = bindDraftTarget(draft, connection.targetHandle);
528
+ const finalized = finalizeDraftTransform(bound, {
529
+ registry: transforms,
530
+ sources,
531
+ targets,
532
+ existing: edges,
533
+ });
534
+ if (finalized) {
535
+ const withoutTarget = edges.filter(
536
+ (edge) => edge.targetSlotId !== finalized.targetSlotId,
537
+ );
538
+ onEdgesChange([...withoutTarget, finalized]);
539
+ setDrafts(drafts.filter((item) => item.localId !== draft.localId));
540
+ setSelection({
541
+ kind: 'transformStep',
542
+ edgeId: finalized.id,
543
+ stepIndex: 0,
544
+ });
545
+ return;
546
+ }
547
+ setDrafts(drafts.map((item) => (item.localId === draft.localId ? bound : item)));
548
+ setSelection({ kind: 'draft', localId: draft.localId });
549
+ return;
550
+ }
551
+
552
+ const operatorAsTarget = parseOperatorNodeId(connection.target);
553
+ if (operatorAsTarget && connection.sourceHandle && onOperatorsChange) {
554
+ onOperatorsChange(
555
+ updateMappingOperator(operators, operatorAsTarget, (operator) =>
556
+ bindOperatorInput(operator, connection.sourceHandle!),
557
+ ),
558
+ );
559
+ setSelection({ kind: 'operator', operatorId: operatorAsTarget });
560
+ return;
561
+ }
562
+
563
+ const operatorAsSource = parseOperatorNodeId(connection.source);
564
+ if (operatorAsSource && connection.targetHandle && onOperatorsChange) {
565
+ onOperatorsChange(
566
+ updateMappingOperator(operators, operatorAsSource, (operator) =>
567
+ bindOperatorOutput(operator, connection.targetHandle!),
568
+ ),
569
+ );
570
+ setSelection({ kind: 'operator', operatorId: operatorAsSource });
571
+ return;
572
+ }
573
+
574
+ const result = applyFieldRemapFlowConnection({
575
+ sourceNodeId: connection.source,
576
+ targetNodeId: connection.target,
577
+ sourceHandle: connection.sourceHandle,
578
+ targetHandle: connection.targetHandle,
579
+ existing: edges,
580
+ });
581
+ if (!result) {
582
+ return;
583
+ }
584
+ const removeIds = new Set(result.removeEdgeIds ?? []);
585
+ const withoutTarget = edges.filter(
586
+ (edge) =>
587
+ edge.targetSlotId !== result.edge.targetSlotId &&
588
+ edge.id !== result.edge.id &&
589
+ !removeIds.has(edge.id),
590
+ );
591
+ onEdgesChange([...withoutTarget, result.edge]);
592
+ setSelection({ kind: 'edge', edgeId: result.edge.id });
593
+ },
594
+ [connectionContext, drafts, edges, onEdgesChange, setSelection, sources, targets, transforms],
595
+ );
596
+
597
+ const onEdgesDelete = useCallback(
598
+ (deleted: Edge[]) => {
599
+ const mappingIds = new Set(
600
+ deleted
601
+ .map((edge) => {
602
+ const data = edge.data as FieldRemapFlowEdgeData | undefined;
603
+ return data?.mappingEdgeId;
604
+ })
605
+ .filter((id): id is string => typeof id === 'string'),
606
+ );
607
+ if (mappingIds.size === 0) {
608
+ return;
609
+ }
610
+ onEdgesChange(edges.filter((edge) => !mappingIds.has(edge.id)));
611
+ if (
612
+ selection &&
613
+ (selection.kind === 'edge' || selection.kind === 'transformStep') &&
614
+ mappingIds.has(selection.edgeId)
615
+ ) {
616
+ setSelection(null);
617
+ }
618
+ },
619
+ [edges, onEdgesChange, selection, setSelection],
620
+ );
621
+
622
+ const placeDraft = useCallback(
623
+ (transformId: string) => {
624
+ const draft = createDraftTransform(transformId);
625
+ setDrafts((current) => [...current, draft]);
626
+ setSelection({ kind: 'draft', localId: draft.localId });
627
+ setPlaceTransformId(transformId);
628
+ },
629
+ [setSelection],
630
+ );
631
+
632
+ const onNodeClick = useCallback(
633
+ (event: MouseEvent, node: Node) => {
634
+ const data = node.data as FieldRemapFlowNodeData;
635
+ if (data.kind === 'draft-transform') {
636
+ setSelection({ kind: 'draft', localId: data.localId });
637
+ return;
638
+ }
639
+ if (data.kind === 'combine-operator' || data.kind === 'split-operator') {
640
+ if (event.altKey && onOperatorsChange) {
641
+ onOperatorsChange(removeMappingOperator(operators, data.operatorId));
642
+ if (selection?.kind === 'operator' && selection.operatorId === data.operatorId) {
643
+ setSelection(null);
644
+ }
645
+ return;
646
+ }
647
+ setSelection({ kind: 'operator', operatorId: data.operatorId });
648
+ return;
649
+ }
650
+ if (data.kind !== 'transform') {
651
+ return;
652
+ }
653
+ if (event.altKey) {
654
+ const edge = edges.find((item) => item.id === data.mappingEdgeId);
655
+ if (!edge) {
656
+ return;
657
+ }
658
+ const next = removeTransformStepFromEdge(edge, data.stepIndex);
659
+ onEdgesChange(edges.map((item) => (item.id === edge.id ? next : item)));
660
+ setSelection(
661
+ (next.transformIds?.length ?? 0) > 0
662
+ ? {
663
+ kind: 'transformStep',
664
+ edgeId: edge.id,
665
+ stepIndex: Math.min(data.stepIndex, (next.transformIds?.length ?? 1) - 1),
666
+ }
667
+ : { kind: 'edge', edgeId: edge.id },
668
+ );
669
+ return;
670
+ }
671
+ setSelection({
672
+ kind: 'transformStep',
673
+ edgeId: data.mappingEdgeId,
674
+ stepIndex: data.stepIndex,
675
+ });
676
+ },
677
+ [edges, onEdgesChange, setSelection],
678
+ );
679
+
680
+ const onKeyDown = useCallback(
681
+ (event: KeyboardEvent<HTMLDivElement>) => {
682
+ if (event.key === 'Escape') {
683
+ setSelection(null);
684
+ setDrafts([]);
685
+ }
686
+ },
687
+ [setSelection],
688
+ );
689
+
690
+ const handlePaneContextMenu = useCallback(
691
+ (event: MouseEvent | globalThis.MouseEvent) => {
692
+ onPaneContextMenu?.(event, { selection });
693
+ },
694
+ [onPaneContextMenu, selection],
695
+ );
696
+
697
+ const handleNodeContextMenu = useCallback(
698
+ (event: MouseEvent, node: Node) => {
699
+ onNodeContextMenu?.(event, { nodeId: node.id, selection });
700
+ },
701
+ [onNodeContextMenu, selection],
702
+ );
703
+
704
+ const handleEdgeContextMenu = useCallback(
705
+ (event: MouseEvent, edge: Edge) => {
706
+ onEdgeContextMenu?.(event, { edgeId: edge.id, selection });
707
+ },
708
+ [onEdgeContextMenu, selection],
709
+ );
710
+
711
+ return (
712
+ <div
713
+ className="workbench-field-remap-flow"
714
+ data-testid="field-remap-mapper"
715
+ data-minimap={showMinimap ? 'on' : 'off'}
716
+ onKeyDown={onKeyDown}
717
+ >
718
+ <p className="workbench-field-remap-mapper__hint" data-testid="field-remap-hint">
719
+ Convert-first: pick a convert in the palette, place it, then wire source → draft → target.
720
+ Select a convert note for the Convert editor; select a binding for lighter mapping detail.
721
+ Use n→m actions to author combine/split. Alt-click removes a convert step or operator.
722
+ Escape clears selection and unfinished drafts.
723
+ </p>
724
+
725
+ <div
726
+ className={
727
+ selection?.kind === 'transformStep'
728
+ ? 'workbench-field-remap-flow__workspace workbench-field-remap-flow__workspace--convert'
729
+ : 'workbench-field-remap-flow__workspace'
730
+ }
731
+ data-surface={
732
+ selection?.kind === 'transformStep'
733
+ ? 'convert-note'
734
+ : selection?.kind === 'draft'
735
+ ? 'draft-convert'
736
+ : selection?.kind === 'operator'
737
+ ? 'operator'
738
+ : 'binding'
739
+ }
740
+ >
741
+ <FieldRemapConvertPalette
742
+ transforms={transforms}
743
+ selectedTransformId={placeTransformId}
744
+ onSelectedTransformIdChange={setPlaceTransformId}
745
+ onPlaceDraft={placeDraft}
746
+ chromeLabels={chromeLabels}
747
+ onAddCombine={
748
+ onOperatorsChange
749
+ ? () => {
750
+ const next = createCombineOperator();
751
+ onOperatorsChange([...operators, next]);
752
+ setSelection({ kind: 'operator', operatorId: next.id });
753
+ }
754
+ : undefined
755
+ }
756
+ onAddSplit={
757
+ onOperatorsChange
758
+ ? () => {
759
+ const next = createSplitOperator();
760
+ onOperatorsChange([...operators, next]);
761
+ setSelection({ kind: 'operator', operatorId: next.id });
762
+ }
763
+ : undefined
764
+ }
765
+ />
766
+
767
+ <div className="workbench-field-remap-flow__canvas" data-testid="field-remap-flow">
768
+ <ReactFlow
769
+ nodes={nodes}
770
+ edges={flowEdges}
771
+ nodeTypes={nodeTypes}
772
+ onNodesChange={onNodesChange}
773
+ onEdgesChange={onFlowEdgesChange}
774
+ onConnect={onConnect}
775
+ onEdgesDelete={onEdgesDelete}
776
+ onNodeClick={onNodeClick}
777
+ onPaneContextMenu={onPaneContextMenu ? handlePaneContextMenu : undefined}
778
+ onNodeContextMenu={onNodeContextMenu ? handleNodeContextMenu : undefined}
779
+ onEdgeContextMenu={onEdgeContextMenu ? handleEdgeContextMenu : undefined}
780
+ isValidConnection={isValidConnection}
781
+ fitView
782
+ fitViewOptions={DEFAULT_FIT_VIEW_OPTIONS}
783
+ proOptions={{ hideAttribution: true }}
784
+ >
785
+ <FieldRemapFlowActionsBridge flowActionsRef={flowActionsRef} />
786
+ <Background gap={16} color="var(--xy-background-pattern-color)" />
787
+ <Controls showInteractive={false} fitViewOptions={DEFAULT_FIT_VIEW_OPTIONS} />
788
+ {showMinimap ? (
789
+ <MiniMap
790
+ pannable
791
+ zoomable
792
+ bgColor="var(--xy-minimap-background-color)"
793
+ maskColor="var(--xy-minimap-mask-background-color)"
794
+ nodeColor={(node) => {
795
+ const kind = (node.data as FieldRemapFlowNodeData | undefined)?.kind;
796
+ if (kind === 'source-object') {
797
+ return 'var(--vscode-charts-blue, #3794ff)';
798
+ }
799
+ if (kind === 'target-object') {
800
+ return 'var(--vscode-charts-green, #89d185)';
801
+ }
802
+ return 'var(--vscode-focusBorder, var(--color-accent, #3794ff))';
803
+ }}
804
+ nodeStrokeColor="var(--xy-minimap-node-stroke-color)"
805
+ />
806
+ ) : null}
807
+ </ReactFlow>
808
+ </div>
809
+
810
+ <FieldRemapDetailPanel
811
+ selection={selection}
812
+ edges={edges}
813
+ sources={sources}
814
+ targets={targets}
815
+ transforms={transforms}
816
+ onEdgesChange={onEdgesChange}
817
+ onSelectionChange={setSelection}
818
+ drafts={drafts}
819
+ onDiscardDraft={(localId) => {
820
+ setDrafts((current) => current.filter((item) => item.localId !== localId));
821
+ }}
822
+ operators={operators}
823
+ onOperatorsChange={onOperatorsChange}
824
+ />
825
+ </div>
826
+
827
+ <div className="workbench-field-remap-flow__bindings" data-testid="field-remap-edges">
828
+ <h4>{chromeLabels.bindingsTitle}</h4>
829
+ <ul>
830
+ {edges.map((edge) => {
831
+ const portTypes = edgePortTypes(edge, sources, targets);
832
+ const appendCatalog = listCompatibleTransforms({
833
+ registry: transforms,
834
+ edge,
835
+ stepIndex: edge.transformIds?.length ?? 0,
836
+ sourceType: portTypes.sourceType,
837
+ targetType: portTypes.targetType,
838
+ mode: 'append',
839
+ });
840
+ const defaultAddId = appendCatalog[0]?.id;
841
+ const listContext = canEditListContext(edge, sources, targets);
842
+ const selected =
843
+ (selection?.kind === 'edge' || selection?.kind === 'transformStep') &&
844
+ selection.edgeId === edge.id;
845
+
846
+ return (
847
+ <li
848
+ key={edge.id}
849
+ className={selected ? 'is-selected' : undefined}
850
+ data-testid={`field-remap-lane-${edge.id}`}
851
+ >
852
+ <button
853
+ type="button"
854
+ className="workbench-field-remap-flow__binding-select"
855
+ data-testid={`field-remap-select-edge-${edge.id}`}
856
+ onClick={() => setSelection({ kind: 'edge', edgeId: edge.id })}
857
+ >
858
+ <code>
859
+ {edge.sourceFieldId} →{' '}
860
+ {(edge.transformIds ?? []).length > 0
861
+ ? `${(edge.transformIds ?? []).join(' → ')} → `
862
+ : ''}
863
+ {edge.targetSlotId}
864
+ {edge.itemEdges ? ` · ${edge.itemEdges.length} item fields` : ''}
865
+ </code>
866
+ </button>
867
+ <span className="workbench-field-remap-mapper__edge-actions">
868
+ {(edge.transformIds?.length ?? 0) < MAX_TRANSFORM_CHAIN && defaultAddId ? (
869
+ <Button
870
+ compact
871
+ type="button"
872
+ data-testid={`field-remap-add-node-${edge.id}`}
873
+ onClick={() => {
874
+ const next = addTransformStepToEdge(edge, defaultAddId, {
875
+ registry: transforms,
876
+ sourceType: portTypes.sourceType,
877
+ targetType: portTypes.targetType,
878
+ });
879
+ if (!next) {
880
+ return;
881
+ }
882
+ onEdgesChange(edges.map((item) => (item.id === edge.id ? next : item)));
883
+ setSelection({
884
+ kind: 'transformStep',
885
+ edgeId: edge.id,
886
+ stepIndex: (next.transformIds?.length ?? 1) - 1,
887
+ });
888
+ }}
889
+ >
890
+ + node
891
+ </Button>
892
+ ) : null}
893
+ {listContext ? (
894
+ <Button
895
+ compact
896
+ type="button"
897
+ data-testid={`field-remap-edit-items-${edge.id}`}
898
+ onClick={() => {
899
+ if (!edge.itemEdges) {
900
+ onEdgesChange(
901
+ edges.map((item) =>
902
+ item.id === edge.id ? enableListContextOnEdge(item) : item,
903
+ ),
904
+ );
905
+ }
906
+ setSelection({ kind: 'edge', edgeId: edge.id });
907
+ }}
908
+ >
909
+ Edit items
910
+ </Button>
911
+ ) : null}
912
+ <Button
913
+ compact
914
+ type="button"
915
+ onClick={() => {
916
+ onEdgesChange(edges.filter((item) => item.id !== edge.id));
917
+ if (
918
+ selection &&
919
+ (selection.kind === 'edge' || selection.kind === 'transformStep') &&
920
+ selection.edgeId === edge.id
921
+ ) {
922
+ setSelection(null);
923
+ }
924
+ }}
925
+ >
926
+ Remove
927
+ </Button>
928
+ </span>
929
+ </li>
930
+ );
931
+ })}
932
+ </ul>
933
+ </div>
934
+ </div>
935
+ );
936
+ }
937
+
938
+ /**
939
+ * React Flow mapper: multi-port source object → convert notes → multi-port target object.
940
+ * Selection gates the side rail: binding detail vs dedicated Convert note editor.
941
+ */
942
+ export function FieldRemapFlowMapper(props: FieldRemapFlowMapperProps): JSX.Element {
943
+ return (
944
+ <ReactFlowProvider>
945
+ <FieldRemapFlowCanvas {...props} />
946
+ </ReactFlowProvider>
947
+ );
948
+ }