@tldiagram/core-ui 1.95.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/api/client.d.ts +184 -3
  2. package/dist/components/ConnectorPanel.d.ts +5 -1
  3. package/dist/components/CrossBranchControls.d.ts +4 -3
  4. package/dist/components/ElementNode.d.ts +5 -0
  5. package/dist/components/ElementPanel.d.ts +6 -1
  6. package/dist/components/LayoutSection.d.ts +2 -1
  7. package/dist/components/MergeDialog.d.ts +16 -0
  8. package/dist/components/NodeContainer.d.ts +2 -0
  9. package/dist/components/ProxyConnectorPanel.d.ts +4 -1
  10. package/dist/components/ViewExplorer/index.d.ts +1 -1
  11. package/dist/components/ViewFloatingMenu-vscode.d.ts +5 -0
  12. package/dist/components/ViewFloatingMenu.d.ts +8 -1
  13. package/dist/components/ViewGridNode.d.ts +3 -0
  14. package/dist/components/ViewPanel.d.ts +2 -1
  15. package/dist/components/WorkspacePanel.d.ts +2 -0
  16. package/dist/components/ZUI/ZUICanvas.d.ts +4 -0
  17. package/dist/components/ZUI/focus.d.ts +32 -0
  18. package/dist/components/ZUI/focus.test.d.ts +1 -0
  19. package/dist/components/ZUI/layout.d.ts +2 -2
  20. package/dist/components/ZUI/proxy.d.ts +20 -4
  21. package/dist/components/ZUI/renderer.d.ts +35 -1
  22. package/dist/components/ZUI/types.d.ts +6 -0
  23. package/dist/components/ZUI/useZUIInteraction.d.ts +1 -0
  24. package/dist/context/WorkspaceVersionContext.d.ts +49 -0
  25. package/dist/crossBranch/resolve.d.ts +39 -2
  26. package/dist/crossBranch/resolve.test.d.ts +1 -0
  27. package/dist/crossBranch/settings.d.ts +6 -1
  28. package/dist/crossBranch/types.d.ts +8 -0
  29. package/dist/hooks/useElementSearch.d.ts +8 -0
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.js +16529 -14030
  32. package/dist/pages/InfiniteZoom.d.ts +1 -0
  33. package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +6 -1
  34. package/dist/pages/ViewEditor/hooks/useViewContextNeighbours.d.ts +2 -0
  35. package/dist/pages/ViewEditor/hooks/useViewData.d.ts +4 -2
  36. package/dist/pages/ViewEditor/hooks/useViewEditHistory.d.ts +13 -0
  37. package/dist/pages/viewsJumpSearch.d.ts +22 -0
  38. package/dist/pages/viewsJumpSearch.test.d.ts +1 -0
  39. package/dist/store/useStore.d.ts +3 -0
  40. package/dist/types/index.d.ts +9 -0
  41. package/dist/utils/elementIcon.d.ts +2 -0
  42. package/dist/utils/elementIcon.test.d.ts +1 -0
  43. package/dist/utils/sourceEditor.d.ts +7 -0
  44. package/dist/utils/watchDiffSummary.d.ts +34 -0
  45. package/package.json +2 -2
  46. package/src/App.tsx +12 -8
  47. package/src/api/client.ts +488 -26
  48. package/src/components/CodePreviewPanel.tsx +90 -16
  49. package/src/components/ConnectorPanel.tsx +34 -3
  50. package/src/components/ContextNeighborElement.tsx +2 -5
  51. package/src/components/CrossBranchControls.tsx +46 -17
  52. package/src/components/ElementNode.tsx +98 -47
  53. package/src/components/ElementPanel.tsx +62 -25
  54. package/src/components/InlineElementAdder.tsx +8 -3
  55. package/src/components/LayoutSection.tsx +4 -1
  56. package/src/components/MergeDialog.tsx +269 -0
  57. package/src/components/NodeContainer.tsx +55 -17
  58. package/src/components/ProxyConnectorPanel.tsx +58 -16
  59. package/src/components/ViewBezierConnector.tsx +116 -21
  60. package/src/components/ViewExplorer/index.tsx +1 -1
  61. package/src/components/ViewFloatingMenu-vscode.tsx +5 -0
  62. package/src/components/ViewFloatingMenu.tsx +110 -1
  63. package/src/components/ViewGridNode.tsx +59 -8
  64. package/src/components/ViewPanel.tsx +3 -2
  65. package/src/components/WorkspacePanel.tsx +938 -0
  66. package/src/components/ZUI/ZUICanvas.tsx +216 -122
  67. package/src/components/ZUI/focus.test.ts +534 -0
  68. package/src/components/ZUI/focus.ts +293 -0
  69. package/src/components/ZUI/layout.ts +7 -11
  70. package/src/components/ZUI/proxy.ts +470 -114
  71. package/src/components/ZUI/renderer.ts +510 -134
  72. package/src/components/ZUI/types.ts +6 -0
  73. package/src/components/ZUI/useZUIInteraction.ts +66 -29
  74. package/src/context/WorkspaceVersionContext.tsx +126 -0
  75. package/src/crossBranch/resolve.test.ts +342 -0
  76. package/src/crossBranch/resolve.ts +368 -68
  77. package/src/crossBranch/settings.ts +49 -3
  78. package/src/crossBranch/types.ts +9 -0
  79. package/src/hooks/useElementSearch.ts +45 -0
  80. package/src/index.css +11 -0
  81. package/src/index.ts +7 -0
  82. package/src/pages/AppearanceSettings.tsx +24 -1
  83. package/src/pages/Dependencies.tsx +231 -65
  84. package/src/pages/InfiniteZoom.tsx +41 -19
  85. package/src/pages/Settings.tsx +1 -1
  86. package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +103 -24
  87. package/src/pages/ViewEditor/hooks/useViewContextNeighbours.ts +102 -6
  88. package/src/pages/ViewEditor/hooks/useViewData.ts +42 -26
  89. package/src/pages/ViewEditor/hooks/useViewEditHistory.ts +62 -0
  90. package/src/pages/ViewEditor/index.tsx +549 -59
  91. package/src/pages/Views.tsx +112 -41
  92. package/src/pages/ViewsGrid.tsx +332 -113
  93. package/src/pages/viewsJumpSearch.test.ts +193 -0
  94. package/src/pages/viewsJumpSearch.ts +111 -0
  95. package/src/store/useStore.ts +58 -0
  96. package/src/types/index.ts +10 -0
  97. package/src/utils/elementIcon.test.ts +28 -0
  98. package/src/utils/elementIcon.ts +20 -0
  99. package/src/utils/sourceEditor.ts +46 -0
  100. package/src/utils/watchDiffSummary.ts +159 -0
@@ -1,7 +1,8 @@
1
1
  import type { Connector, PlacedElement } from '../types'
2
- import { CROSS_BRANCH_DEPTH_ALL } from './types'
2
+ import { CROSS_BRANCH_CONNECTOR_BUDGET_DEFAULT, CROSS_BRANCH_DEPTH_ALL } from './types'
3
3
  import type {
4
4
  AggregatedProxyConnector,
5
+ CrossBranchConnectorPriority,
5
6
  CrossBranchContextSettings,
6
7
  GraphPlacementRef,
7
8
  ProxyConnectorDetails,
@@ -12,6 +13,31 @@ import type {
12
13
  } from './types'
13
14
  import { allConnectors, findLowestCommonAncestorViewId, isDescendantView, relativeOwnerElementPath, viewName } from './graph'
14
15
 
16
+ const connectorsBySnapshotCache = new WeakMap<WorkspaceGraphSnapshot, Connector[]>()
17
+ const endpointPathCacheBySnapshot = new WeakMap<WorkspaceGraphSnapshot, Map<string, number[]>>()
18
+
19
+ function connectorsForSnapshot(snapshot: WorkspaceGraphSnapshot): Connector[] {
20
+ const cached = connectorsBySnapshotCache.get(snapshot)
21
+ if (cached) return cached
22
+
23
+ const connectors = allConnectors(snapshot)
24
+ connectorsBySnapshotCache.set(snapshot, connectors)
25
+ return connectors
26
+ }
27
+
28
+ function endpointPathCacheForSnapshot(snapshot: WorkspaceGraphSnapshot): Map<string, number[]> {
29
+ let cache = endpointPathCacheBySnapshot.get(snapshot)
30
+ if (!cache) {
31
+ cache = new Map()
32
+ endpointPathCacheBySnapshot.set(snapshot, cache)
33
+ }
34
+ return cache
35
+ }
36
+
37
+ function endpointPathCacheKey(ownerViewId: number, elementId: number): string {
38
+ return `${ownerViewId}:${elementId}`
39
+ }
40
+
15
41
  function firstPlacementForElement(snapshot: WorkspaceGraphSnapshot, elementId: number): GraphPlacementRef | null {
16
42
  return snapshot.placementsByElementId[elementId]?.[0] ?? null
17
43
  }
@@ -514,95 +540,306 @@ export interface ZUIResolvedConnector {
514
540
  direction: string
515
541
  style: string
516
542
  label: string
543
+ sourceDepth: number
544
+ targetDepth: number
545
+ maxDepth: number
546
+ details: ProxyConnectorDetails
547
+ }
548
+
549
+ export interface ZUIHiddenProxyBadge {
550
+ key: string
551
+ sourceAnchorElementId: number
552
+ targetAnchorElementId: number
553
+ sourceNodeId: string
554
+ targetNodeId: string
555
+ count: number
517
556
  details: ProxyConnectorDetails
518
557
  }
519
558
 
559
+ export interface ZUIProxyResolution {
560
+ connectors: ZUIResolvedConnector[]
561
+ hiddenBadges: ZUIHiddenProxyBadge[]
562
+ omittedConnectorCount: number
563
+ }
564
+
565
+ export interface ZUIViewportBounds {
566
+ minX: number
567
+ minY: number
568
+ maxX: number
569
+ maxY: number
570
+ centerX: number
571
+ centerY: number
572
+ }
573
+
574
+ export interface ZUIConnectorAnchorInfo {
575
+ nodeId: string
576
+ worldX: number
577
+ worldY: number
578
+ worldW: number
579
+ worldH: number
580
+ }
581
+
582
+ export interface ResolveZUIProxyConnectorOptions {
583
+ viewport?: ZUIViewportBounds | null
584
+ anchorsByElementId?: Map<number, ZUIConnectorAnchorInfo>
585
+ connectorPriority?: CrossBranchConnectorPriority
586
+ }
587
+
520
588
  function endpointPathForOwnerView(snapshot: WorkspaceGraphSnapshot, ownerViewId: number, elementId: number): number[] {
589
+ const cache = endpointPathCacheForSnapshot(snapshot)
590
+ const key = endpointPathCacheKey(ownerViewId, elementId)
591
+ const cached = cache.get(key)
592
+ if (cached) return cached
593
+
521
594
  const placement = chooseBestPlacement(snapshot, elementId, ownerViewId, ownerViewId)
522
- if (!placement) return [elementId]
595
+ if (!placement) {
596
+ const path = [elementId]
597
+ cache.set(key, path)
598
+ return path
599
+ }
523
600
  const owners = relativeOwnerElementPath(snapshot, snapshot.ancestorsByViewId[placement.viewId]?.[0] ?? placement.viewId, placement.viewId)
524
601
  const path = [...owners]
525
602
  if (path[path.length - 1] !== elementId) path.push(elementId)
526
- return path.length > 0 ? path : [elementId]
603
+ const resolvedPath = path.length > 0 ? path : [elementId]
604
+ cache.set(key, resolvedPath)
605
+ return resolvedPath
606
+ }
607
+
608
+ interface ZUIEndpointCandidate {
609
+ actualElementId: number
610
+ actualElementName: string
611
+ anchorElementId: number
612
+ anchorElementName: string
613
+ anchorViewId: number | null
614
+ anchorViewName: string | null
615
+ placementViewId: number | null
616
+ placementViewName: string | null
617
+ depth: number
618
+ }
619
+
620
+ function visibleEndpointCandidates(
621
+ snapshot: WorkspaceGraphSnapshot,
622
+ ownerViewId: number,
623
+ actualElementId: number,
624
+ visibleElements: Set<number>,
625
+ ): ZUIEndpointCandidate[] {
626
+ const path = endpointPathForOwnerView(snapshot, ownerViewId, actualElementId)
627
+ const visibleIndexes = path
628
+ .map((elementId, index) => visibleElements.has(elementId) ? index : -1)
629
+ .filter((index) => index >= 0)
630
+
631
+ if (visibleIndexes.length === 0) return []
632
+
633
+ const actualElementName = firstPlacementForElement(snapshot, actualElementId)?.element.name ?? `Element ${actualElementId}`
634
+ const deepestVisibleIndex = visibleIndexes[visibleIndexes.length - 1]
635
+ const candidateIndexes = [deepestVisibleIndex]
636
+ if (visibleIndexes.length >= 2) candidateIndexes.push(visibleIndexes[visibleIndexes.length - 2])
637
+
638
+ return candidateIndexes.map((pathIndex) => {
639
+ const anchorElementId = path[pathIndex]
640
+ const anchorPlacement = firstPlacementForElement(snapshot, anchorElementId)
641
+ return {
642
+ actualElementId,
643
+ actualElementName,
644
+ anchorElementId,
645
+ anchorElementName: anchorPlacement?.element.name ?? `Element ${anchorElementId}`,
646
+ anchorViewId: anchorPlacement?.viewId ?? ownerViewId,
647
+ anchorViewName: anchorPlacement?.viewName ?? viewName(snapshot, ownerViewId),
648
+ placementViewId: ownerViewId,
649
+ placementViewName: viewName(snapshot, ownerViewId),
650
+ depth: Math.max(0, path.length - 1 - pathIndex),
651
+ }
652
+ })
653
+ }
654
+
655
+ function isNativelyRenderedInZUI(
656
+ connector: Connector,
657
+ sourceAnchorElementId: number,
658
+ targetAnchorElementId: number,
659
+ visibleNodeIdsByElementId: Map<number, string>,
660
+ ): boolean {
661
+ return visibleNodeIdsByElementId.get(sourceAnchorElementId) === `d${connector.view_id}-o${sourceAnchorElementId}` &&
662
+ visibleNodeIdsByElementId.get(targetAnchorElementId) === `d${connector.view_id}-o${targetAnchorElementId}`
663
+ }
664
+
665
+ function visibleEndpointCandidateCacheKey(ownerViewId: number, actualElementId: number): string {
666
+ return `${ownerViewId}:${actualElementId}`
667
+ }
668
+
669
+ function anchorCenter(anchor: ZUIConnectorAnchorInfo) {
670
+ return {
671
+ x: anchor.worldX + anchor.worldW / 2,
672
+ y: anchor.worldY + anchor.worldH / 2,
673
+ }
674
+ }
675
+
676
+ function anchorIsInViewport(anchor: ZUIConnectorAnchorInfo, viewport: ZUIViewportBounds): boolean {
677
+ const center = anchorCenter(anchor)
678
+ return center.x >= viewport.minX &&
679
+ center.x <= viewport.maxX &&
680
+ center.y >= viewport.minY &&
681
+ center.y <= viewport.maxY
682
+ }
683
+
684
+ function normalizedDistanceToViewportCenter(anchor: ZUIConnectorAnchorInfo, viewport: ZUIViewportBounds): number {
685
+ const center = anchorCenter(anchor)
686
+ const dx = center.x - viewport.centerX
687
+ const dy = center.y - viewport.centerY
688
+ const diagonal = Math.max(1, Math.hypot(viewport.maxX - viewport.minX, viewport.maxY - viewport.minY))
689
+ return Math.hypot(dx, dy) / diagonal
690
+ }
691
+
692
+ function viewportPriorityScore(
693
+ connector: ZUIResolvedConnector,
694
+ options: ResolveZUIProxyConnectorOptions | undefined,
695
+ ): number {
696
+ const viewport = options?.viewport
697
+ const anchors = options?.anchorsByElementId
698
+ const source = anchors?.get(connector.sourceAnchorElementId)
699
+ const target = anchors?.get(connector.targetAnchorElementId)
700
+ if (!viewport || !source || !target) {
701
+ return connector.maxDepth * 100 + connector.sourceDepth + connector.targetDepth
702
+ }
703
+
704
+ const sourceDistance = normalizedDistanceToViewportCenter(source, viewport)
705
+ const targetDistance = normalizedDistanceToViewportCenter(target, viewport)
706
+ const nearDistance = Math.min(sourceDistance, targetDistance)
707
+ const farDistance = Math.max(sourceDistance, targetDistance)
708
+ const sourceInViewport = anchorIsInViewport(source, viewport)
709
+ const targetInViewport = anchorIsInViewport(target, viewport)
710
+ const inViewportCount = Number(sourceInViewport) + Number(targetInViewport)
711
+
712
+ if (connector.details.connectors.length === 0) return Number.MAX_SAFE_INTEGER
713
+
714
+ if (options?.connectorPriority === 'internal') {
715
+ return (sourceDistance + targetDistance) * 1000 + farDistance * 400 - inViewportCount * 250
716
+ }
717
+
718
+ return nearDistance * 1000 - farDistance * 320 - (inViewportCount > 0 ? 300 : 0) + (inViewportCount === 2 ? 160 : 0)
719
+ }
720
+
721
+ function connectorTouchesViewport(
722
+ connector: ZUIResolvedConnector,
723
+ options: ResolveZUIProxyConnectorOptions | undefined,
724
+ ): boolean {
725
+ const viewport = options?.viewport
726
+ const anchors = options?.anchorsByElementId
727
+ if (!viewport || !anchors) return true
728
+ const source = anchors.get(connector.sourceAnchorElementId)
729
+ const target = anchors.get(connector.targetAnchorElementId)
730
+ if (!source || !target) return false
731
+ return anchorIsInViewport(source, viewport) || anchorIsInViewport(target, viewport)
527
732
  }
528
733
 
529
734
  export function resolveZUIProxyConnectors(
530
735
  snapshot: WorkspaceGraphSnapshot | null,
531
736
  visibleNodeIdsByElementId: Map<number, string>,
532
737
  settings: CrossBranchContextSettings,
533
- ): ZUIResolvedConnector[] {
534
- if (!snapshot || !settings.enabled || visibleNodeIdsByElementId.size === 0) return []
738
+ options?: ResolveZUIProxyConnectorOptions,
739
+ ): ZUIProxyResolution {
740
+ if (!snapshot || !settings.enabled || visibleNodeIdsByElementId.size === 0) {
741
+ return { connectors: [], hiddenBadges: [], omittedConnectorCount: 0 }
742
+ }
535
743
 
536
744
  const visibleElements = new Set(visibleNodeIdsByElementId.keys())
745
+ const connectors = connectorsForSnapshot(snapshot)
746
+ const endpointCandidateCache = new Map<string, ZUIEndpointCandidate[]>()
747
+ const endpointCandidates = (ownerViewId: number, actualElementId: number): ZUIEndpointCandidate[] => {
748
+ const key = visibleEndpointCandidateCacheKey(ownerViewId, actualElementId)
749
+ const cached = endpointCandidateCache.get(key)
750
+ if (cached) return cached
751
+
752
+ const candidates = visibleEndpointCandidates(snapshot, ownerViewId, actualElementId, visibleElements)
753
+ endpointCandidateCache.set(key, candidates)
754
+ return candidates
755
+ }
537
756
  const grouped = new Map<string, ProxyConnectorLeaf[]>()
757
+ const nativeVisiblePairs = new Set<string>()
538
758
 
539
- for (const connector of allConnectors(snapshot)) {
540
- const sourcePath = endpointPathForOwnerView(snapshot, connector.view_id, connector.source_element_id)
541
- const targetPath = endpointPathForOwnerView(snapshot, connector.view_id, connector.target_element_id)
542
-
543
- const sourceAnchorElementId = [...sourcePath].reverse().find((elementId) => visibleElements.has(elementId))
544
- const targetAnchorElementId = [...targetPath].reverse().find((elementId) => visibleElements.has(elementId))
545
- if (sourceAnchorElementId == null || targetAnchorElementId == null) continue
546
- if (sourceAnchorElementId === targetAnchorElementId) continue
547
- // If both real endpoints are already visible, the normal edge renderer will
548
- // draw this connector in-place. Only keep connectors that need anchoring to
549
- // an ancestor/summary node.
550
- if (
551
- sourceAnchorElementId === connector.source_element_id &&
552
- targetAnchorElementId === connector.target_element_id
553
- ) continue
554
-
555
- const sourceDepth = Math.max(0, sourcePath.length - 1 - sourcePath.indexOf(sourceAnchorElementId))
556
- const targetDepth = Math.max(0, targetPath.length - 1 - targetPath.indexOf(targetAnchorElementId))
557
- if (settings.depth < CROSS_BRANCH_DEPTH_ALL && Math.max(sourceDepth, targetDepth) > settings.depth) continue
558
-
559
- const sourceEndpoint: ProxyEndpoint = {
560
- actualElementId: connector.source_element_id,
561
- actualElementName: firstPlacementForElement(snapshot, connector.source_element_id)?.element.name ?? `Element ${connector.source_element_id}`,
562
- anchorElementId: sourceAnchorElementId,
563
- anchorElementName: firstPlacementForElement(snapshot, sourceAnchorElementId)?.element.name ?? `Element ${sourceAnchorElementId}`,
564
- anchorViewId: firstPlacementForElement(snapshot, sourceAnchorElementId)?.viewId ?? connector.view_id,
565
- anchorViewName: firstPlacementForElement(snapshot, sourceAnchorElementId)?.viewName ?? viewName(snapshot, connector.view_id),
566
- placementViewId: connector.view_id,
567
- placementViewName: viewName(snapshot, connector.view_id),
568
- depth: sourceDepth,
569
- externalToView: sourceAnchorElementId !== connector.source_element_id,
570
- currentBranchElementId: null,
571
- commonAncestorViewId: null,
572
- commonAncestorViewName: null,
573
- }
574
- const targetEndpoint: ProxyEndpoint = {
575
- actualElementId: connector.target_element_id,
576
- actualElementName: firstPlacementForElement(snapshot, connector.target_element_id)?.element.name ?? `Element ${connector.target_element_id}`,
577
- anchorElementId: targetAnchorElementId,
578
- anchorElementName: firstPlacementForElement(snapshot, targetAnchorElementId)?.element.name ?? `Element ${targetAnchorElementId}`,
579
- anchorViewId: firstPlacementForElement(snapshot, targetAnchorElementId)?.viewId ?? connector.view_id,
580
- anchorViewName: firstPlacementForElement(snapshot, targetAnchorElementId)?.viewName ?? viewName(snapshot, connector.view_id),
581
- placementViewId: connector.view_id,
582
- placementViewName: viewName(snapshot, connector.view_id),
583
- depth: targetDepth,
584
- externalToView: targetAnchorElementId !== connector.target_element_id,
585
- currentBranchElementId: null,
586
- commonAncestorViewId: null,
587
- commonAncestorViewName: null,
588
- }
759
+ for (const connector of connectors) {
760
+ if (!visibleElements.has(connector.source_element_id) || !visibleElements.has(connector.target_element_id)) continue
761
+ if (!isNativelyRenderedInZUI(connector, connector.source_element_id, connector.target_element_id, visibleNodeIdsByElementId)) continue
762
+ const [leftAnchorElementId, rightAnchorElementId] = canonicalPairElements(connector.source_element_id, connector.target_element_id)
763
+ nativeVisiblePairs.add([leftAnchorElementId, rightAnchorElementId].join('::'))
764
+ }
589
765
 
590
- const leaf: ProxyConnectorLeaf = {
591
- connector,
592
- ownerViewId: connector.view_id,
593
- ownerViewName: viewName(snapshot, connector.view_id) ?? `View ${connector.view_id}`,
594
- source: sourceEndpoint,
595
- target: targetEndpoint,
766
+ for (const connector of connectors) {
767
+ const sourceCandidates = endpointCandidates(connector.view_id, connector.source_element_id)
768
+ const targetCandidates = endpointCandidates(connector.view_id, connector.target_element_id)
769
+ const seenPairsForConnector = new Set<string>()
770
+
771
+ for (const sourceCandidate of sourceCandidates) {
772
+ for (const targetCandidate of targetCandidates) {
773
+ if (sourceCandidate.anchorElementId === targetCandidate.anchorElementId) continue
774
+ if (
775
+ sourceCandidate.actualElementId === sourceCandidate.anchorElementId &&
776
+ targetCandidate.actualElementId === targetCandidate.anchorElementId &&
777
+ isNativelyRenderedInZUI(
778
+ connector,
779
+ sourceCandidate.anchorElementId,
780
+ targetCandidate.anchorElementId,
781
+ visibleNodeIdsByElementId,
782
+ )
783
+ ) {
784
+ continue
785
+ }
786
+
787
+ const sourceEndpoint: ProxyEndpoint = {
788
+ actualElementId: sourceCandidate.actualElementId,
789
+ actualElementName: sourceCandidate.actualElementName,
790
+ anchorElementId: sourceCandidate.anchorElementId,
791
+ anchorElementName: sourceCandidate.anchorElementName,
792
+ anchorViewId: sourceCandidate.anchorViewId,
793
+ anchorViewName: sourceCandidate.anchorViewName,
794
+ placementViewId: sourceCandidate.placementViewId,
795
+ placementViewName: sourceCandidate.placementViewName,
796
+ depth: sourceCandidate.depth,
797
+ externalToView: sourceCandidate.anchorElementId !== sourceCandidate.actualElementId,
798
+ currentBranchElementId: null,
799
+ commonAncestorViewId: null,
800
+ commonAncestorViewName: null,
801
+ }
802
+ const targetEndpoint: ProxyEndpoint = {
803
+ actualElementId: targetCandidate.actualElementId,
804
+ actualElementName: targetCandidate.actualElementName,
805
+ anchorElementId: targetCandidate.anchorElementId,
806
+ anchorElementName: targetCandidate.anchorElementName,
807
+ anchorViewId: targetCandidate.anchorViewId,
808
+ anchorViewName: targetCandidate.anchorViewName,
809
+ placementViewId: targetCandidate.placementViewId,
810
+ placementViewName: targetCandidate.placementViewName,
811
+ depth: targetCandidate.depth,
812
+ externalToView: targetCandidate.anchorElementId !== targetCandidate.actualElementId,
813
+ currentBranchElementId: null,
814
+ commonAncestorViewId: null,
815
+ commonAncestorViewName: null,
816
+ }
817
+
818
+ const leaf: ProxyConnectorLeaf = {
819
+ connector,
820
+ ownerViewId: connector.view_id,
821
+ ownerViewName: viewName(snapshot, connector.view_id) ?? `View ${connector.view_id}`,
822
+ source: sourceEndpoint,
823
+ target: targetEndpoint,
824
+ }
825
+
826
+ const [leftAnchorElementId, rightAnchorElementId] = canonicalPairElements(
827
+ sourceCandidate.anchorElementId,
828
+ targetCandidate.anchorElementId,
829
+ )
830
+ const key = [leftAnchorElementId, rightAnchorElementId].join('::')
831
+ const pairKey = `${connector.id}:${key}`
832
+ if (seenPairsForConnector.has(pairKey)) continue
833
+ seenPairsForConnector.add(pairKey)
834
+ const existing = grouped.get(key)
835
+ if (existing) existing.push(leaf)
836
+ else grouped.set(key, [leaf])
837
+ }
596
838
  }
597
-
598
- const [leftAnchorElementId, rightAnchorElementId] = canonicalPairElements(sourceAnchorElementId, targetAnchorElementId)
599
- const key = [leftAnchorElementId, rightAnchorElementId].join('::')
600
- const existing = grouped.get(key)
601
- if (existing) existing.push(leaf)
602
- else grouped.set(key, [leaf])
603
839
  }
604
840
 
605
841
  const resolved: ZUIResolvedConnector[] = []
842
+ const hiddenBadges: ZUIHiddenProxyBadge[] = []
606
843
  for (const [key, leaves] of grouped) {
607
844
  const [first] = leaves
608
845
  const { ownerViewIds, ownerViewNames } = ownerViewsFromLeaves(leaves)
@@ -611,6 +848,8 @@ export function resolveZUIProxyConnectors(
611
848
  first.target.anchorElementId,
612
849
  )
613
850
  const canonicalFirstIsSource = canonicalSourceAnchorElementId === first.source.anchorElementId
851
+ const canonicalSourceDepth = canonicalFirstIsSource ? first.source.depth : first.target.depth
852
+ const canonicalTargetDepth = canonicalFirstIsSource ? first.target.depth : first.source.depth
614
853
  const details: ProxyConnectorDetails = {
615
854
  key,
616
855
  label: proxyDisplayLabel(leaves),
@@ -624,6 +863,30 @@ export function resolveZUIProxyConnectors(
624
863
  connectors: leaves,
625
864
  }
626
865
 
866
+ const isDirectChildBadgeOnly = leaves.every((leaf) => {
867
+ if (Math.max(leaf.source.depth, leaf.target.depth) !== 1) return false
868
+ const sourceOk = leaf.source.actualElementId === leaf.source.anchorElementId ||
869
+ endpointCandidates(leaf.ownerViewId, leaf.source.actualElementId)[0]?.anchorElementId === leaf.source.anchorElementId
870
+ const targetOk = leaf.target.actualElementId === leaf.target.anchorElementId ||
871
+ endpointCandidates(leaf.ownerViewId, leaf.target.actualElementId)[0]?.anchorElementId === leaf.target.anchorElementId
872
+ return sourceOk && targetOk
873
+ })
874
+ const pairHasNativeDirect = nativeVisiblePairs.has(key)
875
+ if (pairHasNativeDirect) {
876
+ if (isDirectChildBadgeOnly) {
877
+ hiddenBadges.push({
878
+ key: `badge:${key}`,
879
+ sourceAnchorElementId: canonicalSourceAnchorElementId,
880
+ targetAnchorElementId: canonicalTargetAnchorElementId,
881
+ sourceNodeId: visibleNodeIdsByElementId.get(canonicalSourceAnchorElementId) ?? '',
882
+ targetNodeId: visibleNodeIdsByElementId.get(canonicalTargetAnchorElementId) ?? '',
883
+ count: details.count,
884
+ details,
885
+ })
886
+ }
887
+ continue
888
+ }
889
+
627
890
  resolved.push({
628
891
  key,
629
892
  sourceElementId: canonicalFirstIsSource ? first.source.actualElementId : first.target.actualElementId,
@@ -635,9 +898,46 @@ export function resolveZUIProxyConnectors(
635
898
  direction: 'merged',
636
899
  style: first.connector.style || 'bezier',
637
900
  label: details.label,
901
+ sourceDepth: canonicalSourceDepth,
902
+ targetDepth: canonicalTargetDepth,
903
+ maxDepth: Math.max(canonicalSourceDepth, canonicalTargetDepth),
638
904
  details,
639
905
  })
640
906
  }
641
907
 
642
- return resolved.filter((connector) => connector.sourceNodeId && connector.targetNodeId)
908
+ const visibleResolved = resolved
909
+ .filter((connector) => connector.sourceNodeId && connector.targetNodeId)
910
+ .filter((connector) => connectorTouchesViewport(connector, options))
911
+ .sort((left, right) => {
912
+ const scoreDelta = viewportPriorityScore(left, {
913
+ ...options,
914
+ connectorPriority: settings.connectorPriority,
915
+ }) - viewportPriorityScore(right, {
916
+ ...options,
917
+ connectorPriority: settings.connectorPriority,
918
+ })
919
+ if (scoreDelta !== 0) return scoreDelta
920
+ if (right.details.count !== left.details.count) return right.details.count - left.details.count
921
+ if (left.maxDepth !== right.maxDepth) return left.maxDepth - right.maxDepth
922
+ const depthDelta = (left.sourceDepth + left.targetDepth) - (right.sourceDepth + right.targetDepth)
923
+ if (depthDelta !== 0) return depthDelta
924
+ return left.key.localeCompare(right.key)
925
+ })
926
+ const maxGroups = settings.connectorBudget ?? settings.maxProxyConnectorGroups ?? CROSS_BRANCH_CONNECTOR_BUDGET_DEFAULT
927
+ const budgetedResolved = maxGroups > 0 ? visibleResolved.slice(0, maxGroups) : visibleResolved
928
+ const omittedConnectorIds = new Set<number>()
929
+ if (maxGroups > 0) {
930
+ for (const connector of visibleResolved.slice(maxGroups)) {
931
+ for (const leaf of connector.details.connectors) {
932
+ omittedConnectorIds.add(leaf.connector.id)
933
+ }
934
+ }
935
+ }
936
+ const omittedConnectorCount = omittedConnectorIds.size
937
+
938
+ return {
939
+ connectors: budgetedResolved,
940
+ hiddenBadges: hiddenBadges.filter((badge) => badge.sourceNodeId && badge.targetNodeId),
941
+ omittedConnectorCount,
942
+ }
643
943
  }
@@ -1,8 +1,16 @@
1
1
  import { useCallback, useEffect, useMemo, useState } from 'react'
2
- import type { CrossBranchContextSettings, CrossBranchSurface } from './types'
3
- import { CROSS_BRANCH_DEPTH_ALL } from './types'
2
+ import type { CrossBranchConnectorPriority, CrossBranchContextSettings, CrossBranchSurface } from './types'
3
+ import {
4
+ CROSS_BRANCH_CONNECTOR_BUDGET_DEFAULT,
5
+ CROSS_BRANCH_CONNECTOR_BUDGET_MAX,
6
+ CROSS_BRANCH_CONNECTOR_BUDGET_MIN,
7
+ CROSS_BRANCH_DEPTH_ALL,
8
+ } from './types'
4
9
 
5
10
  const STORAGE_PREFIX = 'diag:cross-branch'
11
+ export const DEFAULT_MIN_CONNECTOR_ANCHOR_ALPHA = 0.35
12
+ export const DEFAULT_MAX_PROXY_CONNECTOR_GROUPS = 32
13
+ export const DEFAULT_CONNECTOR_PRIORITY: CrossBranchConnectorPriority = 'external'
6
14
 
7
15
  function storageKey(surface: CrossBranchSurface) {
8
16
  return `${STORAGE_PREFIX}:${surface}`
@@ -12,9 +20,25 @@ function defaultSettings(surface: CrossBranchSurface): CrossBranchContextSetting
12
20
  return {
13
21
  enabled: surface !== 'zui-shared',
14
22
  depth: CROSS_BRANCH_DEPTH_ALL,
23
+ connectorBudget: CROSS_BRANCH_CONNECTOR_BUDGET_DEFAULT,
24
+ connectorPriority: DEFAULT_CONNECTOR_PRIORITY,
25
+ minConnectorAnchorAlpha: DEFAULT_MIN_CONNECTOR_ANCHOR_ALPHA,
26
+ maxProxyConnectorGroups: DEFAULT_MAX_PROXY_CONNECTOR_GROUPS,
15
27
  }
16
28
  }
17
29
 
30
+ function normalizeConnectorBudget(value: unknown, fallback: number): number {
31
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
32
+ return Math.max(
33
+ CROSS_BRANCH_CONNECTOR_BUDGET_MIN,
34
+ Math.min(CROSS_BRANCH_CONNECTOR_BUDGET_MAX, Math.round(value)),
35
+ )
36
+ }
37
+
38
+ function normalizeConnectorPriority(value: unknown, fallback: CrossBranchConnectorPriority): CrossBranchConnectorPriority {
39
+ return value === 'internal' || value === 'external' ? value : fallback
40
+ }
41
+
18
42
  function readSettings(surface: CrossBranchSurface): CrossBranchContextSettings {
19
43
  const defaults = defaultSettings(surface)
20
44
  if (typeof window === 'undefined') return defaults
@@ -25,6 +49,14 @@ function readSettings(surface: CrossBranchSurface): CrossBranchContextSettings {
25
49
  return {
26
50
  enabled: parsed.enabled ?? defaults.enabled,
27
51
  depth: typeof parsed.depth === 'number' ? parsed.depth : CROSS_BRANCH_DEPTH_ALL,
52
+ connectorBudget: normalizeConnectorBudget(parsed.connectorBudget, defaults.connectorBudget),
53
+ connectorPriority: normalizeConnectorPriority(parsed.connectorPriority, defaults.connectorPriority),
54
+ minConnectorAnchorAlpha: typeof parsed.minConnectorAnchorAlpha === 'number'
55
+ ? parsed.minConnectorAnchorAlpha
56
+ : defaults.minConnectorAnchorAlpha,
57
+ maxProxyConnectorGroups: typeof parsed.maxProxyConnectorGroups === 'number'
58
+ ? parsed.maxProxyConnectorGroups
59
+ : defaults.maxProxyConnectorGroups,
28
60
  }
29
61
  } catch {
30
62
  return defaults
@@ -51,9 +83,23 @@ export function useCrossBranchContextSettings(surface: CrossBranchSurface) {
51
83
  setSettings((prev) => ({ ...prev, depth }))
52
84
  }, [])
53
85
 
86
+ const setConnectorBudget = useCallback((connectorBudget: number) => {
87
+ setSettings((prev) => ({
88
+ ...prev,
89
+ connectorBudget: normalizeConnectorBudget(connectorBudget, prev.connectorBudget),
90
+ maxProxyConnectorGroups: normalizeConnectorBudget(connectorBudget, prev.connectorBudget),
91
+ }))
92
+ }, [])
93
+
94
+ const setConnectorPriority = useCallback((connectorPriority: CrossBranchConnectorPriority) => {
95
+ setSettings((prev) => ({ ...prev, connectorPriority }))
96
+ }, [])
97
+
54
98
  return useMemo(() => ({
55
99
  settings,
56
100
  setEnabled,
57
101
  setDepth,
58
- }), [settings, setEnabled, setDepth])
102
+ setConnectorBudget,
103
+ setConnectorPriority,
104
+ }), [settings, setEnabled, setDepth, setConnectorBudget, setConnectorPriority])
59
105
  }
@@ -3,12 +3,21 @@ import type { Connector, ExploreData, PlacedElement, ViewTreeNode } from '../typ
3
3
  export const CROSS_BRANCH_DEPTH_ALL = 5
4
4
  export const CROSS_BRANCH_DEPTH_MIN = 1
5
5
  export const CROSS_BRANCH_DEPTH_MAX = CROSS_BRANCH_DEPTH_ALL
6
+ export const CROSS_BRANCH_CONNECTOR_BUDGET_MIN = 10
7
+ export const CROSS_BRANCH_CONNECTOR_BUDGET_MAX = 200
8
+ export const CROSS_BRANCH_CONNECTOR_BUDGET_DEFAULT = 50
9
+
10
+ export type CrossBranchConnectorPriority = 'external' | 'internal'
6
11
 
7
12
  export type CrossBranchSurface = 'editor' | 'zui' | 'zui-shared'
8
13
 
9
14
  export interface CrossBranchContextSettings {
10
15
  enabled: boolean
11
16
  depth: number
17
+ connectorBudget: number
18
+ connectorPriority: CrossBranchConnectorPriority
19
+ minConnectorAnchorAlpha?: number
20
+ maxProxyConnectorGroups?: number
12
21
  }
13
22
 
14
23
  export interface GraphPlacementRef {
@@ -0,0 +1,45 @@
1
+ import { useEffect, useState } from 'react'
2
+ import type { LibraryElement } from '../types'
3
+ import { api } from '../api/client'
4
+
5
+ export interface ElementSearchResult {
6
+ query: string
7
+ setQuery: (q: string) => void
8
+ remoteElements: LibraryElement[]
9
+ fetching: boolean
10
+ }
11
+
12
+ export function useElementSearch(): ElementSearchResult {
13
+ const [query, setQuery] = useState('')
14
+ const [remoteElements, setRemoteElements] = useState<LibraryElement[]>([])
15
+ const [fetching, setFetching] = useState(false)
16
+
17
+ useEffect(() => {
18
+ const trimmed = query.trim()
19
+ if (!trimmed) {
20
+ setRemoteElements([])
21
+ setFetching(false)
22
+ return
23
+ }
24
+ let cancelled = false
25
+ setFetching(true)
26
+ const timer = setTimeout(() => {
27
+ api.elements.list({ limit: 0, offset: 0, search: trimmed })
28
+ .then((items) => {
29
+ if (!cancelled) setRemoteElements(items)
30
+ })
31
+ .catch(() => {
32
+ if (!cancelled) setRemoteElements([])
33
+ })
34
+ .finally(() => {
35
+ if (!cancelled) setFetching(false)
36
+ })
37
+ }, 150)
38
+ return () => {
39
+ cancelled = true
40
+ clearTimeout(timer)
41
+ }
42
+ }, [query])
43
+
44
+ return { query, setQuery, remoteElements, fetching }
45
+ }
package/src/index.css CHANGED
@@ -42,6 +42,17 @@
42
42
  --rf-panel-bl-bottom: 82px;
43
43
  }
44
44
 
45
+ html,
46
+ body,
47
+ #root {
48
+ height: 100%;
49
+ overflow: hidden;
50
+ }
51
+
52
+ body {
53
+ margin: 0;
54
+ }
55
+
45
56
  .react-flow {
46
57
  background-color: var(--bg-canvas);
47
58
  }
package/src/index.ts CHANGED
@@ -94,6 +94,13 @@ export { default as theme } from './theme'
94
94
  // ─── Contexts ────────────────────────────────────────────────────────────────
95
95
  export { ThemeProvider, useAccentColor, useTheme } from './context/ThemeContext'
96
96
  export { HeaderProvider, useSetHeader, useHeader } from './components/HeaderContext'
97
+ export {
98
+ WorkspaceVersionProvider,
99
+ buildWorkspaceVersionPreview,
100
+ useWorkspaceVersionPreview,
101
+ type WorkspaceVersionFollowTarget,
102
+ type WorkspaceVersionPreview,
103
+ } from './context/WorkspaceVersionContext'
97
104
 
98
105
  // ─── Types ───────────────────────────────────────────────────────────────────
99
106
  export * from './types'