@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,838 @@
1
+ import type { Edge, Node } from '@xyflow/react';
2
+ import {
3
+ MAX_TRANSFORM_CHAIN,
4
+ arePortsCompatible,
5
+ findSourceField,
6
+ findTargetSlot,
7
+ flattenSourceFields,
8
+ flattenTargetSlots,
9
+ resizeOptionSteps,
10
+ type MappingEdge,
11
+ type MappingOperator,
12
+ type SourceField,
13
+ type TargetSlot,
14
+ type ValueTransformRegistry,
15
+ } from '@workbench-kit/field-remap';
16
+
17
+ export type FieldRemapFlowNodeKind =
18
+ | 'source-object'
19
+ | 'target-object'
20
+ | 'transform'
21
+ | 'draft-transform'
22
+ | 'combine-operator'
23
+ | 'split-operator';
24
+
25
+ export type FieldRemapPort = {
26
+ fieldId: string;
27
+ label: string;
28
+ dataType?: string;
29
+ };
30
+
31
+ /** XYFlow requires node/edge `data` to extend `Record<string, unknown>`. */
32
+ export type FieldRemapSourceObjectNodeData = {
33
+ kind: 'source-object';
34
+ title: string;
35
+ /** Short role label under the schema title (e.g. "Source schema"). */
36
+ schemaRole?: string;
37
+ ports: FieldRemapPort[];
38
+ } & Record<string, unknown>;
39
+
40
+ export type FieldRemapTargetObjectNodeData = {
41
+ kind: 'target-object';
42
+ title: string;
43
+ /** Short role label under the schema title (e.g. "Target schema"). */
44
+ schemaRole?: string;
45
+ ports: FieldRemapPort[];
46
+ } & Record<string, unknown>;
47
+
48
+ export type FieldRemapTransformNodeData = {
49
+ kind: 'transform';
50
+ mappingEdgeId: string;
51
+ stepIndex: number;
52
+ transformId: string;
53
+ label: string;
54
+ /** Set by the Flow host when this step is selected in the detail panel. */
55
+ selected?: boolean;
56
+ } & Record<string, unknown>;
57
+
58
+ export type FieldRemapDraftTransformNodeData = {
59
+ kind: 'draft-transform';
60
+ localId: string;
61
+ transformId: string;
62
+ label: string;
63
+ sourceFieldId?: string;
64
+ targetSlotId?: string;
65
+ } & Record<string, unknown>;
66
+
67
+ export type FieldRemapCombineOperatorNodeData = {
68
+ kind: 'combine-operator';
69
+ operatorId: string;
70
+ label: string;
71
+ inputFieldIds: readonly string[];
72
+ outputSlotId: string;
73
+ } & Record<string, unknown>;
74
+
75
+ export type FieldRemapSplitOperatorNodeData = {
76
+ kind: 'split-operator';
77
+ operatorId: string;
78
+ label: string;
79
+ inputFieldId: string;
80
+ outputSlotIds: readonly string[];
81
+ } & Record<string, unknown>;
82
+
83
+ export type FieldRemapFlowNodeData =
84
+ | FieldRemapSourceObjectNodeData
85
+ | FieldRemapTargetObjectNodeData
86
+ | FieldRemapTransformNodeData
87
+ | FieldRemapDraftTransformNodeData
88
+ | FieldRemapCombineOperatorNodeData
89
+ | FieldRemapSplitOperatorNodeData;
90
+
91
+ export type FieldRemapFlowEdgeData = {
92
+ mappingEdgeId?: string;
93
+ draftLocalId?: string;
94
+ operatorId?: string;
95
+ segment:
96
+ 'in' | 'mid' | 'out' | 'direct' | 'draft-in' | 'draft-out' | 'operator-in' | 'operator-out';
97
+ } & Record<string, unknown>;
98
+
99
+ export const SOURCE_OBJECT_NODE_ID = 'obj:source' as const;
100
+ export const TARGET_OBJECT_NODE_ID = 'obj:target' as const;
101
+ export const DRAFT_NODE_PREFIX = 'draft:' as const;
102
+ export const OPERATOR_NODE_PREFIX = 'op:' as const;
103
+
104
+ const SOURCE_X = 24;
105
+ const TRANSFORM_X = 320;
106
+ const TARGET_X = 620;
107
+ const TRANSFORM_ROW_GAP = 72;
108
+
109
+ export function sourceObjectNodeId(): string {
110
+ return SOURCE_OBJECT_NODE_ID;
111
+ }
112
+
113
+ export function targetObjectNodeId(): string {
114
+ return TARGET_OBJECT_NODE_ID;
115
+ }
116
+
117
+ /** Handle id for a field/slot port on an object node. */
118
+ export function portHandleId(fieldOrSlotId: string): string {
119
+ return fieldOrSlotId;
120
+ }
121
+
122
+ export function transformNodeId(mappingEdgeId: string, stepIndex: number): string {
123
+ return `xf:${mappingEdgeId}:${stepIndex}`;
124
+ }
125
+
126
+ export function draftTransformNodeId(localId: string): string {
127
+ return `${DRAFT_NODE_PREFIX}${localId}`;
128
+ }
129
+
130
+ export function operatorNodeId(operatorId: string): string {
131
+ return `${OPERATOR_NODE_PREFIX}${operatorId}`;
132
+ }
133
+
134
+ export function parseDraftTransformNodeId(nodeId: string): string | undefined {
135
+ if (!nodeId.startsWith(DRAFT_NODE_PREFIX)) {
136
+ return undefined;
137
+ }
138
+ return nodeId.slice(DRAFT_NODE_PREFIX.length);
139
+ }
140
+
141
+ export function parseOperatorNodeId(nodeId: string): string | undefined {
142
+ if (!nodeId.startsWith(OPERATOR_NODE_PREFIX)) {
143
+ return undefined;
144
+ }
145
+ return nodeId.slice(OPERATOR_NODE_PREFIX.length);
146
+ }
147
+
148
+ /** Parse a persisted transform node id (`xf:<edgeId>:<stepIndex>`). */
149
+ export function parseTransformNodeId(
150
+ nodeId: string,
151
+ ): { mappingEdgeId: string; stepIndex: number } | undefined {
152
+ if (!nodeId.startsWith('xf:')) {
153
+ return undefined;
154
+ }
155
+ const rest = nodeId.slice('xf:'.length);
156
+ const lastColon = rest.lastIndexOf(':');
157
+ if (lastColon <= 0) {
158
+ return undefined;
159
+ }
160
+ const mappingEdgeId = rest.slice(0, lastColon);
161
+ const stepIndex = Number(rest.slice(lastColon + 1));
162
+ if (!mappingEdgeId || !Number.isInteger(stepIndex) || stepIndex < 0) {
163
+ return undefined;
164
+ }
165
+ return { mappingEdgeId, stepIndex };
166
+ }
167
+
168
+ function sliceEdgeTransformChain(
169
+ edge: MappingEdge,
170
+ fromInclusive: number,
171
+ toExclusive: number,
172
+ ): Pick<MappingEdge, 'transformIds' | 'transformOptionSteps'> {
173
+ const current = edge.transformIds ?? [];
174
+ const ids = current.slice(fromInclusive, toExclusive);
175
+ const steps = edge.transformOptionSteps?.slice(fromInclusive, toExclusive);
176
+ return {
177
+ transformIds: ids.length > 0 ? ids : undefined,
178
+ transformOptionSteps: resizeOptionSteps(steps, ids.length),
179
+ };
180
+ }
181
+
182
+ function fieldLabel(field: SourceField | TargetSlot): string {
183
+ return field.path ?? field.label;
184
+ }
185
+
186
+ /**
187
+ * Array item-schema fields (`*.item.*`) belong in list-context editors, not the
188
+ * main A/B schema columns (BINDINGS-style topology).
189
+ */
190
+ export function isSchemaColumnFieldId(fieldId: string): boolean {
191
+ return !fieldId.includes('.item.');
192
+ }
193
+
194
+ function toSchemaColumnPorts(fields: readonly (SourceField | TargetSlot)[]): FieldRemapPort[] {
195
+ return fields
196
+ .filter((field) => isSchemaColumnFieldId(field.id))
197
+ .map((field) => ({
198
+ fieldId: field.id,
199
+ label: fieldLabel(field),
200
+ dataType: field.dataType,
201
+ }));
202
+ }
203
+
204
+ /**
205
+ * Build React Flow nodes/edges from kit shapes + MappingEdge[].
206
+ * Layout: one multi-port source schema, optional convert (xf) nodes, one multi-port
207
+ * target schema — wires are MappingEdge segments (document stays edges-only).
208
+ */
209
+ export function mappingToFlowGraph(input: {
210
+ readonly sources: readonly SourceField[];
211
+ readonly targets: readonly TargetSlot[];
212
+ readonly edges: readonly MappingEdge[];
213
+ readonly transforms: ValueTransformRegistry;
214
+ readonly sourceTitle?: string;
215
+ readonly targetTitle?: string;
216
+ /** Ephemeral place-then-wire drafts (not persisted). */
217
+ readonly drafts?: readonly {
218
+ readonly localId: string;
219
+ readonly transformId: string;
220
+ readonly sourceFieldId?: string;
221
+ readonly targetSlotId?: string;
222
+ }[];
223
+ /** Document v2 n→m operators (display / multi-port Flow nodes). */
224
+ readonly operators?: readonly MappingOperator[];
225
+ }): { nodes: Node<FieldRemapFlowNodeData>[]; edges: Edge<FieldRemapFlowEdgeData>[] } {
226
+ const sourcePorts = toSchemaColumnPorts(flattenSourceFields(input.sources));
227
+ const targetPorts = toSchemaColumnPorts(flattenTargetSlots(input.targets));
228
+
229
+ const nodes: Node<FieldRemapFlowNodeData>[] = [
230
+ {
231
+ id: SOURCE_OBJECT_NODE_ID,
232
+ type: 'fieldRemapSourceObject',
233
+ position: { x: SOURCE_X, y: 24 },
234
+ data: {
235
+ kind: 'source-object',
236
+ title: input.sourceTitle?.trim() || 'A',
237
+ schemaRole: 'Source schema',
238
+ ports: sourcePorts,
239
+ },
240
+ draggable: true,
241
+ },
242
+ {
243
+ id: TARGET_OBJECT_NODE_ID,
244
+ type: 'fieldRemapTargetObject',
245
+ position: { x: TARGET_X, y: 24 },
246
+ data: {
247
+ kind: 'target-object',
248
+ title: input.targetTitle?.trim() || 'B',
249
+ schemaRole: 'Target schema',
250
+ ports: targetPorts,
251
+ },
252
+ draggable: true,
253
+ },
254
+ ];
255
+
256
+ const flowEdges: Edge<FieldRemapFlowEdgeData>[] = [];
257
+
258
+ input.edges.forEach((edge, edgeIndex) => {
259
+ const chain = edge.transformIds ?? [];
260
+ const baseY = 40 + edgeIndex * TRANSFORM_ROW_GAP;
261
+
262
+ if (chain.length === 0) {
263
+ flowEdges.push({
264
+ id: `fe:${edge.id}:direct`,
265
+ source: SOURCE_OBJECT_NODE_ID,
266
+ sourceHandle: portHandleId(edge.sourceFieldId),
267
+ target: TARGET_OBJECT_NODE_ID,
268
+ targetHandle: portHandleId(edge.targetSlotId),
269
+ type: 'smoothstep',
270
+ data: { mappingEdgeId: edge.id, segment: 'direct' },
271
+ });
272
+ return;
273
+ }
274
+
275
+ chain.forEach((transformId, stepIndex) => {
276
+ const definition = input.transforms.get(transformId);
277
+ nodes.push({
278
+ id: transformNodeId(edge.id, stepIndex),
279
+ type: 'fieldRemapTransform',
280
+ position: {
281
+ x: TRANSFORM_X + stepIndex * 36,
282
+ y: baseY + stepIndex * 48,
283
+ },
284
+ data: {
285
+ kind: 'transform',
286
+ mappingEdgeId: edge.id,
287
+ stepIndex,
288
+ transformId,
289
+ label: definition?.label ?? transformId,
290
+ },
291
+ draggable: true,
292
+ });
293
+ });
294
+
295
+ flowEdges.push({
296
+ id: `fe:${edge.id}:in`,
297
+ source: SOURCE_OBJECT_NODE_ID,
298
+ sourceHandle: portHandleId(edge.sourceFieldId),
299
+ target: transformNodeId(edge.id, 0),
300
+ targetHandle: 'in',
301
+ type: 'smoothstep',
302
+ data: { mappingEdgeId: edge.id, segment: 'in' },
303
+ });
304
+
305
+ for (let stepIndex = 0; stepIndex < chain.length - 1; stepIndex += 1) {
306
+ flowEdges.push({
307
+ id: `fe:${edge.id}:mid:${stepIndex}`,
308
+ source: transformNodeId(edge.id, stepIndex),
309
+ sourceHandle: 'out',
310
+ target: transformNodeId(edge.id, stepIndex + 1),
311
+ targetHandle: 'in',
312
+ type: 'smoothstep',
313
+ data: { mappingEdgeId: edge.id, segment: 'mid' },
314
+ });
315
+ }
316
+
317
+ flowEdges.push({
318
+ id: `fe:${edge.id}:out`,
319
+ source: transformNodeId(edge.id, chain.length - 1),
320
+ sourceHandle: 'out',
321
+ target: TARGET_OBJECT_NODE_ID,
322
+ targetHandle: portHandleId(edge.targetSlotId),
323
+ type: 'smoothstep',
324
+ data: { mappingEdgeId: edge.id, segment: 'out' },
325
+ });
326
+ });
327
+
328
+ (input.drafts ?? []).forEach((draft, draftIndex) => {
329
+ const definition = input.transforms.get(draft.transformId);
330
+ const nodeId = draftTransformNodeId(draft.localId);
331
+ nodes.push({
332
+ id: nodeId,
333
+ type: 'fieldRemapDraftTransform',
334
+ position: {
335
+ x: TRANSFORM_X,
336
+ y: 40 + (input.edges.length + draftIndex) * TRANSFORM_ROW_GAP,
337
+ },
338
+ data: {
339
+ kind: 'draft-transform',
340
+ localId: draft.localId,
341
+ transformId: draft.transformId,
342
+ label: definition?.label ?? draft.transformId,
343
+ sourceFieldId: draft.sourceFieldId,
344
+ targetSlotId: draft.targetSlotId,
345
+ },
346
+ draggable: true,
347
+ });
348
+
349
+ if (draft.sourceFieldId) {
350
+ flowEdges.push({
351
+ id: `fe:draft:${draft.localId}:in`,
352
+ source: SOURCE_OBJECT_NODE_ID,
353
+ sourceHandle: portHandleId(draft.sourceFieldId),
354
+ target: nodeId,
355
+ targetHandle: 'in',
356
+ type: 'smoothstep',
357
+ data: { draftLocalId: draft.localId, segment: 'draft-in' },
358
+ });
359
+ }
360
+ if (draft.targetSlotId) {
361
+ flowEdges.push({
362
+ id: `fe:draft:${draft.localId}:out`,
363
+ source: nodeId,
364
+ sourceHandle: 'out',
365
+ target: TARGET_OBJECT_NODE_ID,
366
+ targetHandle: portHandleId(draft.targetSlotId),
367
+ type: 'smoothstep',
368
+ data: { draftLocalId: draft.localId, segment: 'draft-out' },
369
+ });
370
+ }
371
+ });
372
+
373
+ const operatorBaseY = 40 + (input.edges.length + (input.drafts?.length ?? 0)) * TRANSFORM_ROW_GAP;
374
+
375
+ (input.operators ?? []).forEach((operator, operatorIndex) => {
376
+ const nodeId = operatorNodeId(operator.id);
377
+ const y = operatorBaseY + operatorIndex * TRANSFORM_ROW_GAP;
378
+
379
+ if (operator.kind === 'combine') {
380
+ nodes.push({
381
+ id: nodeId,
382
+ type: 'fieldRemapCombineOperator',
383
+ position: { x: TRANSFORM_X, y },
384
+ data: {
385
+ kind: 'combine-operator',
386
+ operatorId: operator.id,
387
+ label: 'Combine',
388
+ inputFieldIds: operator.inputFieldIds,
389
+ outputSlotId: operator.outputSlotId,
390
+ },
391
+ draggable: true,
392
+ });
393
+ for (const fieldId of operator.inputFieldIds) {
394
+ flowEdges.push({
395
+ id: `fe:op:${operator.id}:in:${fieldId}`,
396
+ source: SOURCE_OBJECT_NODE_ID,
397
+ sourceHandle: portHandleId(fieldId),
398
+ target: nodeId,
399
+ targetHandle: portHandleId(fieldId),
400
+ type: 'smoothstep',
401
+ data: { operatorId: operator.id, segment: 'operator-in' },
402
+ });
403
+ }
404
+ if (operator.outputSlotId) {
405
+ flowEdges.push({
406
+ id: `fe:op:${operator.id}:out`,
407
+ source: nodeId,
408
+ sourceHandle: 'out',
409
+ target: TARGET_OBJECT_NODE_ID,
410
+ targetHandle: portHandleId(operator.outputSlotId),
411
+ type: 'smoothstep',
412
+ data: { operatorId: operator.id, segment: 'operator-out' },
413
+ });
414
+ }
415
+ return;
416
+ }
417
+
418
+ nodes.push({
419
+ id: nodeId,
420
+ type: 'fieldRemapSplitOperator',
421
+ position: { x: TRANSFORM_X, y },
422
+ data: {
423
+ kind: 'split-operator',
424
+ operatorId: operator.id,
425
+ label: 'Split',
426
+ inputFieldId: operator.inputFieldId,
427
+ outputSlotIds: operator.outputSlotIds,
428
+ },
429
+ draggable: true,
430
+ });
431
+ if (operator.inputFieldId) {
432
+ flowEdges.push({
433
+ id: `fe:op:${operator.id}:in`,
434
+ source: SOURCE_OBJECT_NODE_ID,
435
+ sourceHandle: portHandleId(operator.inputFieldId),
436
+ target: nodeId,
437
+ targetHandle: 'in',
438
+ type: 'smoothstep',
439
+ data: { operatorId: operator.id, segment: 'operator-in' },
440
+ });
441
+ }
442
+ for (const slotId of operator.outputSlotIds) {
443
+ flowEdges.push({
444
+ id: `fe:op:${operator.id}:out:${slotId}`,
445
+ source: nodeId,
446
+ sourceHandle: portHandleId(slotId),
447
+ target: TARGET_OBJECT_NODE_ID,
448
+ targetHandle: portHandleId(slotId),
449
+ type: 'smoothstep',
450
+ data: { operatorId: operator.id, segment: 'operator-out' },
451
+ });
452
+ }
453
+ });
454
+
455
+ return { nodes, edges: flowEdges };
456
+ }
457
+
458
+ /**
459
+ * Optional field/slot lookup for type-gated connects.
460
+ * When omitted, only the topology allowlist is applied (tests / callers without shapes).
461
+ */
462
+ export type IsValidFieldRemapFlowConnectionContext = {
463
+ readonly sources: readonly SourceField[];
464
+ readonly targets: readonly TargetSlot[];
465
+ readonly edges: readonly MappingEdge[];
466
+ readonly transforms: ValueTransformRegistry;
467
+ };
468
+
469
+ function resolveConnectionPortIds(connection: {
470
+ readonly source: string | null | undefined;
471
+ readonly target: string | null | undefined;
472
+ readonly sourceHandle?: string | null;
473
+ readonly targetHandle?: string | null;
474
+ }): { sourceFieldId?: string; targetSlotId?: string } {
475
+ const source = connection.source;
476
+ const target = connection.target;
477
+ if (!source || !target) {
478
+ return {};
479
+ }
480
+
481
+ let sourceFieldId: string | undefined;
482
+ let targetSlotId: string | undefined;
483
+
484
+ if (source === SOURCE_OBJECT_NODE_ID && connection.sourceHandle) {
485
+ sourceFieldId = connection.sourceHandle;
486
+ } else if (source.startsWith('src:')) {
487
+ sourceFieldId = source.slice('src:'.length);
488
+ }
489
+
490
+ if (target === TARGET_OBJECT_NODE_ID && connection.targetHandle) {
491
+ targetSlotId = connection.targetHandle;
492
+ } else if (target.startsWith('tgt:')) {
493
+ targetSlotId = target.slice('tgt:'.length);
494
+ }
495
+
496
+ return { sourceFieldId, targetSlotId };
497
+ }
498
+
499
+ /**
500
+ * Supported connect matrix for state-changing canvas drags:
501
+ * - source-object port → target-object port (creates / replaces a `MappingEdge`)
502
+ * - legacy `src:*` → `tgt:*` single-field node ids (tests / older graphs)
503
+ * - source-object port → `draft:*` (bind draft input)
504
+ * - `draft:*` → target-object port (bind draft output; finalize when both ends set)
505
+ * - source-object port → `xf:*` `in` (rebind source; splice off earlier steps)
506
+ * - `xf:*` `out` → target-object port (rebind target; splice off later steps)
507
+ * - `xf:*` `out` → `xf:*` `in` on a different edge (append / merge chains)
508
+ *
509
+ * Invalid topologies are rejected here so `onConnect` never silently no-ops.
510
+ *
511
+ * When `context` is provided, topology-ok connects are further gated by
512
+ * {@link arePortsCompatible}: missing/`unknown` types stay permissive; known
513
+ * mismatches (including transform-mediated chains on an existing target edge)
514
+ * are rejected.
515
+ */
516
+ export function isValidFieldRemapFlowConnection(
517
+ connection: {
518
+ readonly source: string | null | undefined;
519
+ readonly target: string | null | undefined;
520
+ readonly sourceHandle?: string | null;
521
+ readonly targetHandle?: string | null;
522
+ },
523
+ context?: IsValidFieldRemapFlowConnectionContext & {
524
+ readonly drafts?: readonly {
525
+ readonly localId: string;
526
+ readonly transformId: string;
527
+ readonly sourceFieldId?: string;
528
+ readonly targetSlotId?: string;
529
+ }[];
530
+ },
531
+ ): boolean {
532
+ const source = connection.source;
533
+ const target = connection.target;
534
+ if (!source || !target) {
535
+ return false;
536
+ }
537
+
538
+ const draftTargetId = parseDraftTransformNodeId(target);
539
+ const draftSourceId = parseDraftTransformNodeId(source);
540
+
541
+ if (source === SOURCE_OBJECT_NODE_ID && draftTargetId && connection.sourceHandle) {
542
+ if (!context) {
543
+ return true;
544
+ }
545
+ const draft = context.drafts?.find((item) => item.localId === draftTargetId);
546
+ if (!draft) {
547
+ return false;
548
+ }
549
+ const sourceField = findSourceField(context.sources, connection.sourceHandle);
550
+ return arePortsCompatible({
551
+ sourceType: sourceField?.dataType,
552
+ targetType: 'unknown',
553
+ transformIds: [draft.transformId],
554
+ registry: context.transforms,
555
+ });
556
+ }
557
+
558
+ if (draftSourceId && target === TARGET_OBJECT_NODE_ID && connection.targetHandle) {
559
+ if (!context) {
560
+ return true;
561
+ }
562
+ const draft = context.drafts?.find((item) => item.localId === draftSourceId);
563
+ if (!draft) {
564
+ return false;
565
+ }
566
+ const sourceType = draft.sourceFieldId
567
+ ? findSourceField(context.sources, draft.sourceFieldId)?.dataType
568
+ : undefined;
569
+ const targetSlot = findTargetSlot(context.targets, connection.targetHandle);
570
+ return arePortsCompatible({
571
+ sourceType,
572
+ targetType: targetSlot?.dataType,
573
+ transformIds: [draft.transformId],
574
+ registry: context.transforms,
575
+ });
576
+ }
577
+
578
+ const xfTarget = parseTransformNodeId(target);
579
+ const xfSource = parseTransformNodeId(source);
580
+
581
+ if (source === SOURCE_OBJECT_NODE_ID && xfTarget && connection.sourceHandle) {
582
+ if (!context) {
583
+ return true;
584
+ }
585
+ const edge = context.edges.find((item) => item.id === xfTarget.mappingEdgeId);
586
+ if (!edge) {
587
+ return false;
588
+ }
589
+ const chain = edge.transformIds ?? [];
590
+ if (xfTarget.stepIndex >= chain.length) {
591
+ return false;
592
+ }
593
+ const sourceField = findSourceField(context.sources, connection.sourceHandle);
594
+ const targetSlot = findTargetSlot(context.targets, edge.targetSlotId);
595
+ return arePortsCompatible({
596
+ sourceType: sourceField?.dataType,
597
+ targetType: targetSlot?.dataType,
598
+ transformIds: chain.slice(xfTarget.stepIndex),
599
+ registry: context.transforms,
600
+ });
601
+ }
602
+
603
+ if (xfSource && target === TARGET_OBJECT_NODE_ID && connection.targetHandle) {
604
+ if (!context) {
605
+ return true;
606
+ }
607
+ const edge = context.edges.find((item) => item.id === xfSource.mappingEdgeId);
608
+ if (!edge) {
609
+ return false;
610
+ }
611
+ const chain = edge.transformIds ?? [];
612
+ if (xfSource.stepIndex >= chain.length) {
613
+ return false;
614
+ }
615
+ const sourceField = findSourceField(context.sources, edge.sourceFieldId);
616
+ const targetSlot = findTargetSlot(context.targets, connection.targetHandle);
617
+ return arePortsCompatible({
618
+ sourceType: sourceField?.dataType,
619
+ targetType: targetSlot?.dataType,
620
+ transformIds: chain.slice(0, xfSource.stepIndex + 1),
621
+ registry: context.transforms,
622
+ });
623
+ }
624
+
625
+ if (xfSource && xfTarget) {
626
+ if (xfSource.mappingEdgeId === xfTarget.mappingEdgeId) {
627
+ return false;
628
+ }
629
+ if (!context) {
630
+ return true;
631
+ }
632
+ const edgeA = context.edges.find((item) => item.id === xfSource.mappingEdgeId);
633
+ const edgeB = context.edges.find((item) => item.id === xfTarget.mappingEdgeId);
634
+ if (!edgeA || !edgeB) {
635
+ return false;
636
+ }
637
+ const chainA = edgeA.transformIds ?? [];
638
+ const chainB = edgeB.transformIds ?? [];
639
+ if (xfSource.stepIndex >= chainA.length || xfTarget.stepIndex >= chainB.length) {
640
+ return false;
641
+ }
642
+ const merged = [
643
+ ...chainA.slice(0, xfSource.stepIndex + 1),
644
+ ...chainB.slice(xfTarget.stepIndex),
645
+ ];
646
+ if (merged.length === 0 || merged.length > MAX_TRANSFORM_CHAIN) {
647
+ return false;
648
+ }
649
+ const sourceField = findSourceField(context.sources, edgeA.sourceFieldId);
650
+ const targetSlot = findTargetSlot(context.targets, edgeB.targetSlotId);
651
+ return arePortsCompatible({
652
+ sourceType: sourceField?.dataType,
653
+ targetType: targetSlot?.dataType,
654
+ transformIds: merged,
655
+ registry: context.transforms,
656
+ });
657
+ }
658
+
659
+ let topologyOk = false;
660
+ if (source === SOURCE_OBJECT_NODE_ID && target === TARGET_OBJECT_NODE_ID) {
661
+ topologyOk = Boolean(connection.sourceHandle && connection.targetHandle);
662
+ } else if (source.startsWith('src:') && target.startsWith('tgt:')) {
663
+ // Legacy single-field node ids (older graphs / tests).
664
+ topologyOk = true;
665
+ }
666
+
667
+ if (!topologyOk) {
668
+ return false;
669
+ }
670
+
671
+ if (!context) {
672
+ return true;
673
+ }
674
+
675
+ const { sourceFieldId, targetSlotId } = resolveConnectionPortIds(connection);
676
+ if (!sourceFieldId || !targetSlotId) {
677
+ return false;
678
+ }
679
+
680
+ const sourceField = findSourceField(context.sources, sourceFieldId);
681
+ const targetSlot = findTargetSlot(context.targets, targetSlotId);
682
+ const existing = context.edges.find((edge) => edge.targetSlotId === targetSlotId);
683
+
684
+ return arePortsCompatible({
685
+ sourceType: sourceField?.dataType,
686
+ targetType: targetSlot?.dataType,
687
+ transformIds: existing?.transformIds,
688
+ registry: context.transforms,
689
+ });
690
+ }
691
+
692
+ /** Result of a persisted (non-draft) Flow connect that updates mapping edges. */
693
+ export type FieldRemapFlowConnectResult = {
694
+ readonly edge: MappingEdge;
695
+ /** Donor edge removed when appending / merging two xf chains. */
696
+ readonly removeEdgeIds?: readonly string[];
697
+ };
698
+
699
+ /** Parse React Flow connection (object ports or legacy) into a kit MappingEdge. */
700
+ export function connectionToMappingEdge(input: {
701
+ readonly sourceNodeId: string;
702
+ readonly targetNodeId: string;
703
+ readonly sourceHandle?: string | null;
704
+ readonly targetHandle?: string | null;
705
+ readonly existing: readonly MappingEdge[];
706
+ }): MappingEdge | null {
707
+ let sourceFieldId: string | undefined;
708
+ let targetSlotId: string | undefined;
709
+
710
+ if (input.sourceNodeId === SOURCE_OBJECT_NODE_ID && input.sourceHandle) {
711
+ sourceFieldId = input.sourceHandle;
712
+ } else if (input.sourceNodeId.startsWith('src:')) {
713
+ // Legacy single-field node ids (older graphs / tests).
714
+ sourceFieldId = input.sourceNodeId.slice('src:'.length);
715
+ }
716
+
717
+ if (input.targetNodeId === TARGET_OBJECT_NODE_ID && input.targetHandle) {
718
+ targetSlotId = input.targetHandle;
719
+ } else if (input.targetNodeId.startsWith('tgt:')) {
720
+ targetSlotId = input.targetNodeId.slice('tgt:'.length);
721
+ }
722
+
723
+ if (!sourceFieldId || !targetSlotId) {
724
+ return null;
725
+ }
726
+
727
+ const existing = input.existing.find((edge) => edge.targetSlotId === targetSlotId);
728
+ return {
729
+ id: existing?.id ?? `e-${sourceFieldId}-${targetSlotId}-${Date.now()}`,
730
+ sourceFieldId,
731
+ targetSlotId,
732
+ ...(existing?.transformIds ? { transformIds: existing.transformIds } : {}),
733
+ ...(existing?.transformOptionSteps
734
+ ? { transformOptionSteps: existing.transformOptionSteps }
735
+ : {}),
736
+ ...(existing?.itemSourcePath ? { itemSourcePath: existing.itemSourcePath } : {}),
737
+ ...(existing?.itemEdges ? { itemEdges: existing.itemEdges } : {}),
738
+ };
739
+ }
740
+
741
+ /**
742
+ * Materialize a persisted Flow connection into an edge upsert (and optional removals).
743
+ * Covers object↔object, source→xf splice, xf→target splice, and xf→xf append/merge.
744
+ * Draft binds stay in the Flow host (`onConnect`).
745
+ */
746
+ export function applyFieldRemapFlowConnection(input: {
747
+ readonly sourceNodeId: string;
748
+ readonly targetNodeId: string;
749
+ readonly sourceHandle?: string | null;
750
+ readonly targetHandle?: string | null;
751
+ readonly existing: readonly MappingEdge[];
752
+ }): FieldRemapFlowConnectResult | null {
753
+ const xfTarget = parseTransformNodeId(input.targetNodeId);
754
+ const xfSource = parseTransformNodeId(input.sourceNodeId);
755
+
756
+ if (input.sourceNodeId === SOURCE_OBJECT_NODE_ID && xfTarget && input.sourceHandle) {
757
+ const edge = input.existing.find((item) => item.id === xfTarget.mappingEdgeId);
758
+ if (!edge) {
759
+ return null;
760
+ }
761
+ const chain = edge.transformIds ?? [];
762
+ if (xfTarget.stepIndex >= chain.length) {
763
+ return null;
764
+ }
765
+ const sliced = sliceEdgeTransformChain(edge, xfTarget.stepIndex, chain.length);
766
+ return {
767
+ edge: {
768
+ ...edge,
769
+ sourceFieldId: input.sourceHandle,
770
+ ...sliced,
771
+ },
772
+ };
773
+ }
774
+
775
+ if (xfSource && input.targetNodeId === TARGET_OBJECT_NODE_ID && input.targetHandle) {
776
+ const edge = input.existing.find((item) => item.id === xfSource.mappingEdgeId);
777
+ if (!edge) {
778
+ return null;
779
+ }
780
+ const chain = edge.transformIds ?? [];
781
+ if (xfSource.stepIndex >= chain.length) {
782
+ return null;
783
+ }
784
+ const sliced = sliceEdgeTransformChain(edge, 0, xfSource.stepIndex + 1);
785
+ return {
786
+ edge: {
787
+ ...edge,
788
+ targetSlotId: input.targetHandle,
789
+ ...sliced,
790
+ },
791
+ };
792
+ }
793
+
794
+ if (xfSource && xfTarget) {
795
+ if (xfSource.mappingEdgeId === xfTarget.mappingEdgeId) {
796
+ return null;
797
+ }
798
+ const edgeA = input.existing.find((item) => item.id === xfSource.mappingEdgeId);
799
+ const edgeB = input.existing.find((item) => item.id === xfTarget.mappingEdgeId);
800
+ if (!edgeA || !edgeB) {
801
+ return null;
802
+ }
803
+ const chainA = edgeA.transformIds ?? [];
804
+ const chainB = edgeB.transformIds ?? [];
805
+ if (xfSource.stepIndex >= chainA.length || xfTarget.stepIndex >= chainB.length) {
806
+ return null;
807
+ }
808
+ const prefix = sliceEdgeTransformChain(edgeA, 0, xfSource.stepIndex + 1);
809
+ const suffix = sliceEdgeTransformChain(edgeB, xfTarget.stepIndex, chainB.length);
810
+ const mergedIds = [...(prefix.transformIds ?? []), ...(suffix.transformIds ?? [])];
811
+ if (mergedIds.length === 0 || mergedIds.length > MAX_TRANSFORM_CHAIN) {
812
+ return null;
813
+ }
814
+ const mergedSteps = [
815
+ ...(prefix.transformOptionSteps ?? Array.from({ length: prefix.transformIds?.length ?? 0 })),
816
+ ...(suffix.transformOptionSteps ?? Array.from({ length: suffix.transformIds?.length ?? 0 })),
817
+ ];
818
+ return {
819
+ edge: {
820
+ ...edgeB,
821
+ sourceFieldId: edgeA.sourceFieldId,
822
+ targetSlotId: edgeB.targetSlotId,
823
+ transformIds: mergedIds,
824
+ transformOptionSteps: resizeOptionSteps(mergedSteps, mergedIds.length),
825
+ // Drop list-context from a merge; hosts re-enable via the detail panel.
826
+ itemSourcePath: undefined,
827
+ itemEdges: undefined,
828
+ itemTransformIds: undefined,
829
+ itemTransformOptionSteps: undefined,
830
+ itemTransformOptions: undefined,
831
+ },
832
+ removeEdgeIds: [edgeA.id],
833
+ };
834
+ }
835
+
836
+ const edge = connectionToMappingEdge(input);
837
+ return edge ? { edge } : null;
838
+ }