@workbench-kit/shell-react 0.0.2-prototype.0.2.40 → 0.0.2-prototype.0.2.43

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 (35) hide show
  1. package/README.md +42 -2
  2. package/package.json +13 -10
  3. package/src/extensions/extension-enablement-controller.ts +40 -9
  4. package/src/extensions/theme-selection-protection.ts +80 -55
  5. package/src/field-remap/chrome-labels.ts +18 -0
  6. package/src/field-remap/convert-note-editor.tsx +30 -24
  7. package/src/field-remap/convert-palette.tsx +6 -0
  8. package/src/field-remap/demo.tsx +8 -2
  9. package/src/field-remap/detail-panel.tsx +225 -192
  10. package/src/field-remap/drag-payload.ts +48 -0
  11. package/src/field-remap/flow-adapter.ts +216 -88
  12. package/src/field-remap/flow-ops.ts +150 -0
  13. package/src/field-remap/flow.tsx +1055 -162
  14. package/src/field-remap/index.ts +2 -0
  15. package/src/field-remap/io-class-browse.tsx +34 -22
  16. package/src/field-remap/keyboard.ts +16 -0
  17. package/src/field-remap/modal-detail.tsx +34 -0
  18. package/src/field-remap/panel.tsx +50 -14
  19. package/src/field-remap/transform-options-editor.tsx +5 -1
  20. package/src/field-remap/view.css +61 -42
  21. package/src/index.ts +7 -0
  22. package/src/management/keybinding-overrides-storage.ts +48 -23
  23. package/src/management/keybinding-settings.tsx +10 -1
  24. package/src/management/use-keybinding-management.ts +69 -19
  25. package/src/shell/appearance-catalog.ts +453 -0
  26. package/src/shell/appearance-controller.ts +331 -0
  27. package/src/shell/appearance-presentation.ts +269 -0
  28. package/src/shell/provider.tsx +147 -18
  29. package/src/shell/settings.tsx +282 -153
  30. package/src/shell/shell.tsx +88 -18
  31. package/src/workbench/appearance-storage.ts +2 -2
  32. package/src/workbench/command-host-controller.tsx +223 -0
  33. package/src/workbench/command-host.tsx +100 -150
  34. package/src/workbench/keybinding-bridge.ts +84 -38
  35. package/src/workbench/shell-command-registration.ts +27 -2
@@ -204,6 +204,8 @@ export function mappingToFlowGraph(input: {
204
204
  readonly sourceFieldId?: string;
205
205
  readonly targetSlotId?: string;
206
206
  }[];
207
+ /** Mapper-local positions for ephemeral drafts created by canvas drop. */
208
+ readonly draftPositions?: ReadonlyMap<string, { readonly x: number; readonly y: number }>;
207
209
  /** Document v2 n→m operators (display / multi-port Flow nodes). */
208
210
  readonly operators?: readonly MappingOperator[];
209
211
  }): { nodes: Node<FieldRemapFlowNodeData>[]; edges: Edge<FieldRemapFlowEdgeData>[] } {
@@ -221,7 +223,6 @@ export function mappingToFlowGraph(input: {
221
223
  schemaRole: 'Source schema',
222
224
  ports: sourcePorts,
223
225
  },
224
- draggable: true,
225
226
  },
226
227
  {
227
228
  id: TARGET_OBJECT_NODE_ID,
@@ -233,7 +234,6 @@ export function mappingToFlowGraph(input: {
233
234
  schemaRole: 'Target schema',
234
235
  ports: targetPorts,
235
236
  },
236
- draggable: true,
237
237
  },
238
238
  ];
239
239
 
@@ -272,7 +272,6 @@ export function mappingToFlowGraph(input: {
272
272
  transformId,
273
273
  label: definition?.label ?? transformId,
274
274
  },
275
- draggable: true,
276
275
  });
277
276
  });
278
277
 
@@ -315,7 +314,7 @@ export function mappingToFlowGraph(input: {
315
314
  nodes.push({
316
315
  id: nodeId,
317
316
  type: 'fieldRemapDraftTransform',
318
- position: {
317
+ position: input.draftPositions?.get(draft.localId) ?? {
319
318
  x: TRANSFORM_X,
320
319
  y: 40 + (input.edges.length + draftIndex) * TRANSFORM_ROW_GAP,
321
320
  },
@@ -327,7 +326,6 @@ export function mappingToFlowGraph(input: {
327
326
  sourceFieldId: draft.sourceFieldId,
328
327
  targetSlotId: draft.targetSlotId,
329
328
  },
330
- draggable: true,
331
329
  });
332
330
 
333
331
  if (draft.sourceFieldId) {
@@ -372,7 +370,6 @@ export function mappingToFlowGraph(input: {
372
370
  inputFieldIds: operator.inputFieldIds,
373
371
  outputSlotId: operator.outputSlotId,
374
372
  },
375
- draggable: true,
376
373
  });
377
374
  for (const fieldId of operator.inputFieldIds) {
378
375
  flowEdges.push({
@@ -410,7 +407,6 @@ export function mappingToFlowGraph(input: {
410
407
  inputFieldId: operator.inputFieldId,
411
408
  outputSlotIds: operator.outputSlotIds,
412
409
  },
413
- draggable: true,
414
410
  });
415
411
  if (operator.inputFieldId) {
416
412
  flowEdges.push({
@@ -443,13 +439,82 @@ export function mappingToFlowGraph(input: {
443
439
  * Optional field/slot lookup for type-gated connects.
444
440
  * When omitted, only the topology allowlist is applied (tests / callers without shapes).
445
441
  */
442
+ export type FieldRemapFlowConnectionInput = {
443
+ readonly source: string | null | undefined;
444
+ readonly target: string | null | undefined;
445
+ readonly sourceHandle?: string | null;
446
+ readonly targetHandle?: string | null;
447
+ };
448
+
446
449
  export type IsValidFieldRemapFlowConnectionContext = {
447
450
  readonly sources: readonly SourceField[];
448
451
  readonly targets: readonly TargetSlot[];
449
452
  readonly edges: readonly MappingEdge[];
450
453
  readonly transforms: ValueTransformRegistry;
454
+ readonly drafts?: readonly {
455
+ readonly localId: string;
456
+ readonly transformId: string;
457
+ readonly sourceFieldId?: string;
458
+ readonly targetSlotId?: string;
459
+ }[];
451
460
  };
452
461
 
462
+ export type FieldRemapFlowConnectionRejectionReason =
463
+ | 'incomplete-connection'
464
+ | 'unsupported-topology'
465
+ | 'missing-port-endpoint'
466
+ | 'missing-draft-endpoint'
467
+ | 'missing-transform-edge'
468
+ | 'missing-transform-step'
469
+ | 'missing-transform-definition'
470
+ | 'same-edge-transform-splice'
471
+ | 'transform-chain-limit'
472
+ | 'incompatible-port-types';
473
+
474
+ export type FieldRemapFlowConnectionEvaluation =
475
+ | { readonly status: 'accepted' }
476
+ | { readonly status: 'rewire'; readonly impactedEdgeIds: readonly string[] }
477
+ | {
478
+ readonly status: 'rejected';
479
+ readonly reason: FieldRemapFlowConnectionRejectionReason;
480
+ };
481
+
482
+ const acceptedConnection = { status: 'accepted' } as const;
483
+
484
+ function rejectConnection(
485
+ reason: FieldRemapFlowConnectionRejectionReason,
486
+ ): FieldRemapFlowConnectionEvaluation {
487
+ return { status: 'rejected', reason };
488
+ }
489
+
490
+ function acceptOrRewire(impactedEdgeIds: readonly string[]): FieldRemapFlowConnectionEvaluation {
491
+ return impactedEdgeIds.length > 0 ? { status: 'rewire', impactedEdgeIds } : acceptedConnection;
492
+ }
493
+
494
+ function evaluatePortCompatibility(
495
+ context: Pick<IsValidFieldRemapFlowConnectionContext, 'transforms'>,
496
+ sourceType: SourceField['dataType'],
497
+ targetType: TargetSlot['dataType'],
498
+ transformIds: readonly string[],
499
+ ): FieldRemapFlowConnectionEvaluation | undefined {
500
+ if (!transformIds.every((transformId) => context.transforms.get(transformId) !== undefined)) {
501
+ return rejectConnection('missing-transform-definition');
502
+ }
503
+ return arePortsCompatible({ sourceType, targetType, transformIds, registry: context.transforms })
504
+ ? undefined
505
+ : rejectConnection('incompatible-port-types');
506
+ }
507
+
508
+ function targetRewireEdgeIds(
509
+ edges: readonly MappingEdge[],
510
+ targetSlotId: string,
511
+ excludedEdgeId?: string,
512
+ ): string[] {
513
+ return edges
514
+ .filter((edge) => edge.targetSlotId === targetSlotId && edge.id !== excludedEdgeId)
515
+ .map((edge) => edge.id);
516
+ }
517
+
453
518
  function resolveConnectionPortIds(connection: {
454
519
  readonly source: string | null | undefined;
455
520
  readonly target: string | null | undefined;
@@ -490,26 +555,14 @@ function resolveConnectionPortIds(connection: {
490
555
  * mismatches (including transform-mediated chains on an existing target edge)
491
556
  * are rejected.
492
557
  */
493
- export function isValidFieldRemapFlowConnection(
494
- connection: {
495
- readonly source: string | null | undefined;
496
- readonly target: string | null | undefined;
497
- readonly sourceHandle?: string | null;
498
- readonly targetHandle?: string | null;
499
- },
500
- context?: IsValidFieldRemapFlowConnectionContext & {
501
- readonly drafts?: readonly {
502
- readonly localId: string;
503
- readonly transformId: string;
504
- readonly sourceFieldId?: string;
505
- readonly targetSlotId?: string;
506
- }[];
507
- },
508
- ): boolean {
558
+ export function evaluateFieldRemapFlowConnection(
559
+ connection: FieldRemapFlowConnectionInput,
560
+ context?: IsValidFieldRemapFlowConnectionContext,
561
+ ): FieldRemapFlowConnectionEvaluation {
509
562
  const source = connection.source;
510
563
  const target = connection.target;
511
564
  if (!source || !target) {
512
- return false;
565
+ return rejectConnection('incomplete-connection');
513
566
  }
514
567
 
515
568
  const draftTargetId = parseDraftTransformNodeId(target);
@@ -517,39 +570,72 @@ export function isValidFieldRemapFlowConnection(
517
570
 
518
571
  if (source === SOURCE_OBJECT_NODE_ID && draftTargetId && connection.sourceHandle) {
519
572
  if (!context) {
520
- return true;
573
+ return acceptedConnection;
521
574
  }
522
575
  const draft = context.drafts?.find((item) => item.localId === draftTargetId);
523
576
  if (!draft) {
524
- return false;
577
+ return rejectConnection('missing-draft-endpoint');
525
578
  }
526
579
  const sourceField = findSourceField(context.sources, connection.sourceHandle);
527
- return arePortsCompatible({
528
- sourceType: sourceField?.dataType,
529
- targetType: 'unknown',
530
- transformIds: [draft.transformId],
531
- registry: context.transforms,
532
- });
580
+ if (!sourceField) {
581
+ return rejectConnection('missing-port-endpoint');
582
+ }
583
+ const targetSlot = draft.targetSlotId
584
+ ? findTargetSlot(context.targets, draft.targetSlotId)
585
+ : undefined;
586
+ if (draft.targetSlotId && !targetSlot) {
587
+ return rejectConnection('missing-draft-endpoint');
588
+ }
589
+ const rejection = evaluatePortCompatibility(
590
+ context,
591
+ sourceField.dataType,
592
+ targetSlot?.dataType ?? 'unknown',
593
+ [draft.transformId],
594
+ );
595
+ if (rejection) {
596
+ return rejection;
597
+ }
598
+ return acceptOrRewire(
599
+ draft.targetSlotId ? targetRewireEdgeIds(context.edges, draft.targetSlotId) : [],
600
+ );
601
+ }
602
+
603
+ if (source === SOURCE_OBJECT_NODE_ID && draftTargetId) {
604
+ return rejectConnection('incomplete-connection');
533
605
  }
534
606
 
535
607
  if (draftSourceId && target === TARGET_OBJECT_NODE_ID && connection.targetHandle) {
536
608
  if (!context) {
537
- return true;
609
+ return acceptedConnection;
538
610
  }
539
611
  const draft = context.drafts?.find((item) => item.localId === draftSourceId);
540
612
  if (!draft) {
541
- return false;
613
+ return rejectConnection('missing-draft-endpoint');
542
614
  }
543
- const sourceType = draft.sourceFieldId
544
- ? findSourceField(context.sources, draft.sourceFieldId)?.dataType
545
- : undefined;
546
615
  const targetSlot = findTargetSlot(context.targets, connection.targetHandle);
547
- return arePortsCompatible({
548
- sourceType,
549
- targetType: targetSlot?.dataType,
550
- transformIds: [draft.transformId],
551
- registry: context.transforms,
552
- });
616
+ if (!targetSlot) {
617
+ return rejectConnection('missing-port-endpoint');
618
+ }
619
+ const sourceField = draft.sourceFieldId
620
+ ? findSourceField(context.sources, draft.sourceFieldId)
621
+ : undefined;
622
+ if (draft.sourceFieldId && !sourceField) {
623
+ return rejectConnection('missing-draft-endpoint');
624
+ }
625
+ const rejection = evaluatePortCompatibility(
626
+ context,
627
+ sourceField?.dataType,
628
+ targetSlot.dataType,
629
+ [draft.transformId],
630
+ );
631
+ if (rejection) {
632
+ return rejection;
633
+ }
634
+ return acceptOrRewire(targetRewireEdgeIds(context.edges, connection.targetHandle));
635
+ }
636
+
637
+ if (draftSourceId && target === TARGET_OBJECT_NODE_ID) {
638
+ return rejectConnection('incomplete-connection');
553
639
  }
554
640
 
555
641
  const xfTarget = parseTransformNodeId(target);
@@ -557,80 +643,109 @@ export function isValidFieldRemapFlowConnection(
557
643
 
558
644
  if (source === SOURCE_OBJECT_NODE_ID && xfTarget && connection.sourceHandle) {
559
645
  if (!context) {
560
- return true;
646
+ return acceptedConnection;
561
647
  }
562
648
  const edge = context.edges.find((item) => item.id === xfTarget.mappingEdgeId);
563
649
  if (!edge) {
564
- return false;
650
+ return rejectConnection('missing-transform-edge');
565
651
  }
566
652
  const chain = edge.transformIds ?? [];
567
653
  if (xfTarget.stepIndex >= chain.length) {
568
- return false;
654
+ return rejectConnection('missing-transform-step');
569
655
  }
570
656
  const sourceField = findSourceField(context.sources, connection.sourceHandle);
571
657
  const targetSlot = findTargetSlot(context.targets, edge.targetSlotId);
572
- return arePortsCompatible({
573
- sourceType: sourceField?.dataType,
574
- targetType: targetSlot?.dataType,
575
- transformIds: chain.slice(xfTarget.stepIndex),
576
- registry: context.transforms,
577
- });
658
+ if (!sourceField || !targetSlot) {
659
+ return rejectConnection('missing-port-endpoint');
660
+ }
661
+ const retainedChain = chain.slice(xfTarget.stepIndex);
662
+ return (
663
+ evaluatePortCompatibility(
664
+ context,
665
+ sourceField.dataType,
666
+ targetSlot.dataType,
667
+ retainedChain,
668
+ ) ?? acceptedConnection
669
+ );
670
+ }
671
+
672
+ if (source === SOURCE_OBJECT_NODE_ID && xfTarget) {
673
+ return rejectConnection('incomplete-connection');
578
674
  }
579
675
 
580
676
  if (xfSource && target === TARGET_OBJECT_NODE_ID && connection.targetHandle) {
581
677
  if (!context) {
582
- return true;
678
+ return acceptedConnection;
583
679
  }
584
680
  const edge = context.edges.find((item) => item.id === xfSource.mappingEdgeId);
585
681
  if (!edge) {
586
- return false;
682
+ return rejectConnection('missing-transform-edge');
587
683
  }
588
684
  const chain = edge.transformIds ?? [];
589
685
  if (xfSource.stepIndex >= chain.length) {
590
- return false;
686
+ return rejectConnection('missing-transform-step');
591
687
  }
592
688
  const sourceField = findSourceField(context.sources, edge.sourceFieldId);
593
689
  const targetSlot = findTargetSlot(context.targets, connection.targetHandle);
594
- return arePortsCompatible({
595
- sourceType: sourceField?.dataType,
596
- targetType: targetSlot?.dataType,
597
- transformIds: chain.slice(0, xfSource.stepIndex + 1),
598
- registry: context.transforms,
599
- });
690
+ if (!sourceField || !targetSlot) {
691
+ return rejectConnection('missing-port-endpoint');
692
+ }
693
+ const retainedChain = chain.slice(0, xfSource.stepIndex + 1);
694
+ const rejection = evaluatePortCompatibility(
695
+ context,
696
+ sourceField.dataType,
697
+ targetSlot.dataType,
698
+ retainedChain,
699
+ );
700
+ if (rejection) {
701
+ return rejection;
702
+ }
703
+ return acceptOrRewire(targetRewireEdgeIds(context.edges, connection.targetHandle, edge.id));
704
+ }
705
+
706
+ if (xfSource && target === TARGET_OBJECT_NODE_ID) {
707
+ return rejectConnection('incomplete-connection');
600
708
  }
601
709
 
602
710
  if (xfSource && xfTarget) {
603
711
  if (xfSource.mappingEdgeId === xfTarget.mappingEdgeId) {
604
- return false;
712
+ return rejectConnection('same-edge-transform-splice');
605
713
  }
606
714
  if (!context) {
607
- return true;
715
+ return acceptedConnection;
608
716
  }
609
717
  const edgeA = context.edges.find((item) => item.id === xfSource.mappingEdgeId);
610
718
  const edgeB = context.edges.find((item) => item.id === xfTarget.mappingEdgeId);
611
719
  if (!edgeA || !edgeB) {
612
- return false;
720
+ return rejectConnection('missing-transform-edge');
613
721
  }
614
722
  const chainA = edgeA.transformIds ?? [];
615
723
  const chainB = edgeB.transformIds ?? [];
616
724
  if (xfSource.stepIndex >= chainA.length || xfTarget.stepIndex >= chainB.length) {
617
- return false;
725
+ return rejectConnection('missing-transform-step');
618
726
  }
619
727
  const merged = [
620
728
  ...chainA.slice(0, xfSource.stepIndex + 1),
621
729
  ...chainB.slice(xfTarget.stepIndex),
622
730
  ];
623
731
  if (merged.length === 0 || merged.length > MAX_TRANSFORM_CHAIN) {
624
- return false;
732
+ return rejectConnection('transform-chain-limit');
625
733
  }
626
734
  const sourceField = findSourceField(context.sources, edgeA.sourceFieldId);
627
735
  const targetSlot = findTargetSlot(context.targets, edgeB.targetSlotId);
628
- return arePortsCompatible({
629
- sourceType: sourceField?.dataType,
630
- targetType: targetSlot?.dataType,
631
- transformIds: merged,
632
- registry: context.transforms,
633
- });
736
+ if (!sourceField || !targetSlot) {
737
+ return rejectConnection('missing-port-endpoint');
738
+ }
739
+ const rejection = evaluatePortCompatibility(
740
+ context,
741
+ sourceField.dataType,
742
+ targetSlot.dataType,
743
+ merged,
744
+ );
745
+ if (rejection) {
746
+ return rejection;
747
+ }
748
+ return acceptOrRewire([edgeA.id]);
634
749
  }
635
750
 
636
751
  const topologyOk =
@@ -639,28 +754,44 @@ export function isValidFieldRemapFlowConnection(
639
754
  Boolean(connection.sourceHandle && connection.targetHandle);
640
755
 
641
756
  if (!topologyOk) {
642
- return false;
757
+ return source === SOURCE_OBJECT_NODE_ID && target === TARGET_OBJECT_NODE_ID
758
+ ? rejectConnection('incomplete-connection')
759
+ : rejectConnection('unsupported-topology');
643
760
  }
644
761
 
645
762
  if (!context) {
646
- return true;
763
+ return acceptedConnection;
647
764
  }
648
765
 
649
766
  const { sourceFieldId, targetSlotId } = resolveConnectionPortIds(connection);
650
767
  if (!sourceFieldId || !targetSlotId) {
651
- return false;
768
+ return rejectConnection('incomplete-connection');
652
769
  }
653
770
 
654
771
  const sourceField = findSourceField(context.sources, sourceFieldId);
655
772
  const targetSlot = findTargetSlot(context.targets, targetSlotId);
773
+ if (!sourceField || !targetSlot) {
774
+ return rejectConnection('missing-port-endpoint');
775
+ }
656
776
  const existing = context.edges.find((edge) => edge.targetSlotId === targetSlotId);
777
+ const retainedChain = existing?.transformIds ?? [];
778
+ const rejection = evaluatePortCompatibility(
779
+ context,
780
+ sourceField.dataType,
781
+ targetSlot.dataType,
782
+ retainedChain,
783
+ );
784
+ if (rejection) {
785
+ return rejection;
786
+ }
787
+ return acceptOrRewire(targetRewireEdgeIds(context.edges, targetSlotId));
788
+ }
657
789
 
658
- return arePortsCompatible({
659
- sourceType: sourceField?.dataType,
660
- targetType: targetSlot?.dataType,
661
- transformIds: existing?.transformIds,
662
- registry: context.transforms,
663
- });
790
+ export function isValidFieldRemapFlowConnection(
791
+ connection: FieldRemapFlowConnectionInput,
792
+ context?: IsValidFieldRemapFlowConnectionContext,
793
+ ): boolean {
794
+ return evaluateFieldRemapFlowConnection(connection, context).status !== 'rejected';
664
795
  }
665
796
 
666
797
  /** Result of a persisted (non-draft) Flow connect that updates mapping edges. */
@@ -690,16 +821,13 @@ export function connectionToMappingEdge(input: {
690
821
  }
691
822
 
692
823
  const existing = input.existing.find((edge) => edge.targetSlotId === targetSlotId);
824
+ if (existing) {
825
+ return { ...existing, sourceFieldId, targetSlotId };
826
+ }
693
827
  return {
694
- id: existing?.id ?? `e-${sourceFieldId}-${targetSlotId}-${Date.now()}`,
828
+ id: `e-${sourceFieldId}-${targetSlotId}-${Date.now()}`,
695
829
  sourceFieldId,
696
830
  targetSlotId,
697
- ...(existing?.transformIds ? { transformIds: existing.transformIds } : {}),
698
- ...(existing?.transformOptionSteps
699
- ? { transformOptionSteps: existing.transformOptionSteps }
700
- : {}),
701
- ...(existing?.itemSourcePath ? { itemSourcePath: existing.itemSourcePath } : {}),
702
- ...(existing?.itemEdges ? { itemEdges: existing.itemEdges } : {}),
703
831
  };
704
832
  }
705
833
 
@@ -28,6 +28,156 @@ export type FieldRemapSelection =
28
28
  | { readonly kind: 'operator'; readonly operatorId: string }
29
29
  | null;
30
30
 
31
+ /** Mapper-local durable membership. It is intentionally not part of the public Flow props. */
32
+ export type FieldRemapBulkSelectionRef =
33
+ | { readonly kind: 'edge'; readonly edgeId: string }
34
+ | { readonly kind: 'transformStep'; readonly edgeId: string; readonly stepIndex: number };
35
+
36
+ export type FieldRemapSelectionGesture = 'plain' | 'toggle' | 'add';
37
+
38
+ export function fieldRemapBulkSelectionKey(ref: FieldRemapBulkSelectionRef): string {
39
+ return ref.kind === 'edge'
40
+ ? `edge:${ref.edgeId}`
41
+ : `transformStep:${ref.edgeId}:${ref.stepIndex}`;
42
+ }
43
+
44
+ export function fieldRemapSelectionKey(selection: FieldRemapSelection): string {
45
+ if (selection === null) {
46
+ return 'none';
47
+ }
48
+ if (selection.kind === 'draft') {
49
+ return `draft:${selection.localId}`;
50
+ }
51
+ if (selection.kind === 'operator') {
52
+ return `operator:${selection.operatorId}`;
53
+ }
54
+ return fieldRemapBulkSelectionKey(selection);
55
+ }
56
+
57
+ export function asFieldRemapBulkSelectionRef(
58
+ selection: FieldRemapSelection,
59
+ ): FieldRemapBulkSelectionRef | undefined {
60
+ return selection?.kind === 'edge' || selection?.kind === 'transformStep' ? selection : undefined;
61
+ }
62
+
63
+ /** One semantic order shared by selection, stale reconciliation, deletion and focus. */
64
+ export function listFieldRemapBulkSelectionRefs(
65
+ edges: readonly MappingEdge[],
66
+ ): FieldRemapBulkSelectionRef[] {
67
+ const refs: FieldRemapBulkSelectionRef[] = [];
68
+ for (const edge of edges) {
69
+ refs.push({ kind: 'edge', edgeId: edge.id });
70
+ for (let stepIndex = 0; stepIndex < (edge.transformIds?.length ?? 0); stepIndex += 1) {
71
+ refs.push({ kind: 'transformStep', edgeId: edge.id, stepIndex });
72
+ }
73
+ }
74
+ return refs;
75
+ }
76
+
77
+ export function normalizeFieldRemapBulkSelection(
78
+ edges: readonly MappingEdge[],
79
+ refs: readonly FieldRemapBulkSelectionRef[],
80
+ ): FieldRemapBulkSelectionRef[] {
81
+ const requested = new Set(refs.map(fieldRemapBulkSelectionKey));
82
+ return listFieldRemapBulkSelectionRefs(edges).filter((ref) =>
83
+ requested.has(fieldRemapBulkSelectionKey(ref)),
84
+ );
85
+ }
86
+
87
+ export function updateFieldRemapBulkSelection(input: {
88
+ readonly edges: readonly MappingEdge[];
89
+ readonly membership: readonly FieldRemapBulkSelectionRef[];
90
+ readonly primary: FieldRemapSelection;
91
+ readonly target: FieldRemapBulkSelectionRef;
92
+ readonly gesture: FieldRemapSelectionGesture;
93
+ }): {
94
+ readonly membership: readonly FieldRemapBulkSelectionRef[];
95
+ readonly primary: FieldRemapSelection;
96
+ } {
97
+ const targetKey = fieldRemapBulkSelectionKey(input.target);
98
+ const canonical = listFieldRemapBulkSelectionRefs(input.edges);
99
+ if (!canonical.some((ref) => fieldRemapBulkSelectionKey(ref) === targetKey)) {
100
+ return {
101
+ membership: normalizeFieldRemapBulkSelection(input.edges, input.membership),
102
+ primary: input.primary,
103
+ };
104
+ }
105
+
106
+ const primaryRef = asFieldRemapBulkSelectionRef(input.primary);
107
+ const primaryKey = primaryRef ? fieldRemapBulkSelectionKey(primaryRef) : undefined;
108
+ const primaryIsValid = primaryKey
109
+ ? canonical.some((ref) => fieldRemapBulkSelectionKey(ref) === primaryKey)
110
+ : false;
111
+ if (input.gesture === 'plain' || primaryKey === undefined || !primaryIsValid) {
112
+ return { membership: [input.target], primary: input.target };
113
+ }
114
+
115
+ const membership = normalizeFieldRemapBulkSelection(input.edges, input.membership);
116
+ const keys = new Set(membership.map(fieldRemapBulkSelectionKey));
117
+ if (input.gesture === 'add') {
118
+ keys.add(targetKey);
119
+ } else if (targetKey !== primaryKey) {
120
+ if (keys.has(targetKey)) {
121
+ keys.delete(targetKey);
122
+ } else {
123
+ keys.add(targetKey);
124
+ }
125
+ }
126
+ keys.add(primaryKey);
127
+
128
+ return {
129
+ membership: canonical.filter((ref) => keys.has(fieldRemapBulkSelectionKey(ref))),
130
+ primary: input.primary,
131
+ };
132
+ }
133
+
134
+ export type FieldRemapBulkDeletePlan =
135
+ | { readonly status: 'invalid' | 'unchanged' }
136
+ | { readonly status: 'changed'; readonly edges: readonly MappingEdge[] };
137
+
138
+ /** Plan one immutable, all-or-nothing durable removal from one edge snapshot. */
139
+ export function planFieldRemapBulkDelete(
140
+ edges: readonly MappingEdge[],
141
+ refs: readonly FieldRemapBulkSelectionRef[],
142
+ ): FieldRemapBulkDeletePlan {
143
+ if (refs.length === 0) {
144
+ return { status: 'unchanged' };
145
+ }
146
+
147
+ const validKeys = new Set(listFieldRemapBulkSelectionRefs(edges).map(fieldRemapBulkSelectionKey));
148
+ if (refs.some((ref) => !validKeys.has(fieldRemapBulkSelectionKey(ref)))) {
149
+ return { status: 'invalid' };
150
+ }
151
+
152
+ const selectedEdges = new Set(refs.filter((ref) => ref.kind === 'edge').map((ref) => ref.edgeId));
153
+ const selectedSteps = new Map<string, Set<number>>();
154
+ for (const ref of refs) {
155
+ if (ref.kind === 'transformStep' && !selectedEdges.has(ref.edgeId)) {
156
+ const indices = selectedSteps.get(ref.edgeId) ?? new Set<number>();
157
+ indices.add(ref.stepIndex);
158
+ selectedSteps.set(ref.edgeId, indices);
159
+ }
160
+ }
161
+
162
+ const nextEdges = edges
163
+ .filter((edge) => !selectedEdges.has(edge.id))
164
+ .map((edge) => {
165
+ const indices = selectedSteps.get(edge.id);
166
+ if (!indices) {
167
+ return edge;
168
+ }
169
+ const transformIds = (edge.transformIds ?? []).filter((_, index) => !indices.has(index));
170
+ const optionSteps = edge.transformOptionSteps?.filter((_, index) => !indices.has(index));
171
+ return {
172
+ ...edge,
173
+ transformIds: transformIds.length > 0 ? transformIds : undefined,
174
+ transformOptionSteps: resizeOptionSteps(optionSteps, transformIds.length),
175
+ };
176
+ });
177
+
178
+ return { status: 'changed', edges: nextEdges };
179
+ }
180
+
31
181
  /**
32
182
  * Ephemeral place-then-wire transform. Not part of `FieldRemapDocument` until
33
183
  * both ports are bound and {@link finalizeDraftTransform} succeeds.