@principal-ai/subsystems-react 0.17.0
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.
- package/README.md +93 -0
- package/dist/components/ConfigurationSelector.d.ts +35 -0
- package/dist/components/ConfigurationSelector.d.ts.map +1 -0
- package/dist/components/ConfigurationSelector.js +63 -0
- package/dist/components/ConfigurationSelector.js.map +1 -0
- package/dist/components/GraphRenderer.d.ts +295 -0
- package/dist/components/GraphRenderer.d.ts.map +1 -0
- package/dist/components/GraphRenderer.js +2008 -0
- package/dist/components/GraphRenderer.js.map +1 -0
- package/dist/components/MultiCanvasRenderer.d.ts +75 -0
- package/dist/components/MultiCanvasRenderer.d.ts.map +1 -0
- package/dist/components/MultiCanvasRenderer.js +203 -0
- package/dist/components/MultiCanvasRenderer.js.map +1 -0
- package/dist/components/NodeTooltip.d.ts +24 -0
- package/dist/components/NodeTooltip.d.ts.map +1 -0
- package/dist/components/NodeTooltip.js +160 -0
- package/dist/components/NodeTooltip.js.map +1 -0
- package/dist/components/SelectionSidebar.d.ts +18 -0
- package/dist/components/SelectionSidebar.d.ts.map +1 -0
- package/dist/components/SelectionSidebar.js +190 -0
- package/dist/components/SelectionSidebar.js.map +1 -0
- package/dist/components/SequenceDiagramRenderer.d.ts +77 -0
- package/dist/components/SequenceDiagramRenderer.d.ts.map +1 -0
- package/dist/components/SequenceDiagramRenderer.js +604 -0
- package/dist/components/SequenceDiagramRenderer.js.map +1 -0
- package/dist/components/WorkflowSequenceDiagram.d.ts +63 -0
- package/dist/components/WorkflowSequenceDiagram.d.ts.map +1 -0
- package/dist/components/WorkflowSequenceDiagram.js +147 -0
- package/dist/components/WorkflowSequenceDiagram.js.map +1 -0
- package/dist/components/dashboard/DashboardRenderer.d.ts +10 -0
- package/dist/components/dashboard/DashboardRenderer.d.ts.map +1 -0
- package/dist/components/dashboard/DashboardRenderer.js +179 -0
- package/dist/components/dashboard/DashboardRenderer.js.map +1 -0
- package/dist/components/dashboard/MetricPanel.d.ts +10 -0
- package/dist/components/dashboard/MetricPanel.d.ts.map +1 -0
- package/dist/components/dashboard/MetricPanel.js +103 -0
- package/dist/components/dashboard/MetricPanel.js.map +1 -0
- package/dist/components/dashboard/MockDataProvider.d.ts +30 -0
- package/dist/components/dashboard/MockDataProvider.d.ts.map +1 -0
- package/dist/components/dashboard/MockDataProvider.js +270 -0
- package/dist/components/dashboard/MockDataProvider.js.map +1 -0
- package/dist/components/dashboard/components/BarChart.d.ts +10 -0
- package/dist/components/dashboard/components/BarChart.d.ts.map +1 -0
- package/dist/components/dashboard/components/BarChart.js +167 -0
- package/dist/components/dashboard/components/BarChart.js.map +1 -0
- package/dist/components/dashboard/components/LineChart.d.ts +10 -0
- package/dist/components/dashboard/components/LineChart.d.ts.map +1 -0
- package/dist/components/dashboard/components/LineChart.js +141 -0
- package/dist/components/dashboard/components/LineChart.js.map +1 -0
- package/dist/components/dashboard/components/MetricCard.d.ts +9 -0
- package/dist/components/dashboard/components/MetricCard.d.ts.map +1 -0
- package/dist/components/dashboard/components/MetricCard.js +163 -0
- package/dist/components/dashboard/components/MetricCard.js.map +1 -0
- package/dist/components/dashboard/components/SourceLink.d.ts +9 -0
- package/dist/components/dashboard/components/SourceLink.d.ts.map +1 -0
- package/dist/components/dashboard/components/SourceLink.js +39 -0
- package/dist/components/dashboard/components/SourceLink.js.map +1 -0
- package/dist/components/dashboard/components/TimeRangeSelector.d.ts +9 -0
- package/dist/components/dashboard/components/TimeRangeSelector.d.ts.map +1 -0
- package/dist/components/dashboard/components/TimeRangeSelector.js +167 -0
- package/dist/components/dashboard/components/TimeRangeSelector.js.map +1 -0
- package/dist/components/dashboard/components/index.d.ts +6 -0
- package/dist/components/dashboard/components/index.d.ts.map +1 -0
- package/dist/components/dashboard/components/index.js +6 -0
- package/dist/components/dashboard/components/index.js.map +1 -0
- package/dist/components/dashboard/index.d.ts +6 -0
- package/dist/components/dashboard/index.d.ts.map +1 -0
- package/dist/components/dashboard/index.js +8 -0
- package/dist/components/dashboard/index.js.map +1 -0
- package/dist/components/dashboard/types.d.ts +74 -0
- package/dist/components/dashboard/types.d.ts.map +1 -0
- package/dist/components/dashboard/types.js +8 -0
- package/dist/components/dashboard/types.js.map +1 -0
- package/dist/components/session-events/SessionEventFeed.d.ts +34 -0
- package/dist/components/session-events/SessionEventFeed.d.ts.map +1 -0
- package/dist/components/session-events/SessionEventFeed.js +1196 -0
- package/dist/components/session-events/SessionEventFeed.js.map +1 -0
- package/dist/components/session-events/index.d.ts +7 -0
- package/dist/components/session-events/index.d.ts.map +1 -0
- package/dist/components/session-events/index.js +6 -0
- package/dist/components/session-events/index.js.map +1 -0
- package/dist/components/state-view/PipelineView.d.ts +14 -0
- package/dist/components/state-view/PipelineView.d.ts.map +1 -0
- package/dist/components/state-view/PipelineView.js +195 -0
- package/dist/components/state-view/PipelineView.js.map +1 -0
- package/dist/components/state-view/index.d.ts +14 -0
- package/dist/components/state-view/index.d.ts.map +1 -0
- package/dist/components/state-view/index.js +12 -0
- package/dist/components/state-view/index.js.map +1 -0
- package/dist/components/state-view/types.d.ts +188 -0
- package/dist/components/state-view/types.d.ts.map +1 -0
- package/dist/components/state-view/types.js +10 -0
- package/dist/components/state-view/types.js.map +1 -0
- package/dist/components/state-view/useStateView.d.ts +32 -0
- package/dist/components/state-view/useStateView.d.ts.map +1 -0
- package/dist/components/state-view/useStateView.js +129 -0
- package/dist/components/state-view/useStateView.js.map +1 -0
- package/dist/contexts/GraphEditContext.d.ts +20 -0
- package/dist/contexts/GraphEditContext.d.ts.map +1 -0
- package/dist/contexts/GraphEditContext.js +8 -0
- package/dist/contexts/GraphEditContext.js.map +1 -0
- package/dist/contexts/TooltipPortalContext.d.ts +8 -0
- package/dist/contexts/TooltipPortalContext.d.ts.map +1 -0
- package/dist/contexts/TooltipPortalContext.js +8 -0
- package/dist/contexts/TooltipPortalContext.js.map +1 -0
- package/dist/edges/CustomEdge.d.ts +24 -0
- package/dist/edges/CustomEdge.d.ts.map +1 -0
- package/dist/edges/CustomEdge.js +222 -0
- package/dist/edges/CustomEdge.js.map +1 -0
- package/dist/edges/GenericEdge.d.ts +18 -0
- package/dist/edges/GenericEdge.d.ts.map +1 -0
- package/dist/edges/GenericEdge.js +10 -0
- package/dist/edges/GenericEdge.js.map +1 -0
- package/dist/graphify/anchor.d.ts +28 -0
- package/dist/graphify/anchor.d.ts.map +1 -0
- package/dist/graphify/anchor.js +132 -0
- package/dist/graphify/anchor.js.map +1 -0
- package/dist/graphify/consolidated.d.ts +210 -0
- package/dist/graphify/consolidated.d.ts.map +1 -0
- package/dist/graphify/consolidated.js +11 -0
- package/dist/graphify/consolidated.js.map +1 -0
- package/dist/graphify/ids.d.ts +19 -0
- package/dist/graphify/ids.d.ts.map +1 -0
- package/dist/graphify/ids.js +46 -0
- package/dist/graphify/ids.js.map +1 -0
- package/dist/graphify/index.d.ts +18 -0
- package/dist/graphify/index.d.ts.map +1 -0
- package/dist/graphify/index.js +12 -0
- package/dist/graphify/index.js.map +1 -0
- package/dist/graphify/kind.d.ts +23 -0
- package/dist/graphify/kind.d.ts.map +1 -0
- package/dist/graphify/kind.js +77 -0
- package/dist/graphify/kind.js.map +1 -0
- package/dist/graphify/resolve.d.ts +48 -0
- package/dist/graphify/resolve.d.ts.map +1 -0
- package/dist/graphify/resolve.js +83 -0
- package/dist/graphify/resolve.js.map +1 -0
- package/dist/graphify/signature.d.ts +87 -0
- package/dist/graphify/signature.d.ts.map +1 -0
- package/dist/graphify/signature.js +276 -0
- package/dist/graphify/signature.js.map +1 -0
- package/dist/graphify/types.d.ts +141 -0
- package/dist/graphify/types.d.ts.map +1 -0
- package/dist/graphify/types.js +8 -0
- package/dist/graphify/types.js.map +1 -0
- package/dist/hooks/useElkLayout.d.ts +66 -0
- package/dist/hooks/useElkLayout.d.ts.map +1 -0
- package/dist/hooks/useElkLayout.js +136 -0
- package/dist/hooks/useElkLayout.js.map +1 -0
- package/dist/hooks/usePathBasedEvents.d.ts +42 -0
- package/dist/hooks/usePathBasedEvents.d.ts.map +1 -0
- package/dist/hooks/usePathBasedEvents.js +119 -0
- package/dist/hooks/usePathBasedEvents.js.map +1 -0
- package/dist/hooks/useSequenceLayout.d.ts +186 -0
- package/dist/hooks/useSequenceLayout.d.ts.map +1 -0
- package/dist/hooks/useSequenceLayout.js +327 -0
- package/dist/hooks/useSequenceLayout.js.map +1 -0
- package/dist/hooks/useUndoRedo.d.ts +80 -0
- package/dist/hooks/useUndoRedo.d.ts.map +1 -0
- package/dist/hooks/useUndoRedo.js +70 -0
- package/dist/hooks/useUndoRedo.js.map +1 -0
- package/dist/index.d.ts +73 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +51 -0
- package/dist/index.js.map +1 -0
- package/dist/nodes/CustomNode.d.ts +28 -0
- package/dist/nodes/CustomNode.d.ts.map +1 -0
- package/dist/nodes/CustomNode.js +820 -0
- package/dist/nodes/CustomNode.js.map +1 -0
- package/dist/nodes/GenericNode.d.ts +20 -0
- package/dist/nodes/GenericNode.d.ts.map +1 -0
- package/dist/nodes/GenericNode.js +20 -0
- package/dist/nodes/GenericNode.js.map +1 -0
- package/dist/nodes/otel/OtelBoundaryNode.d.ts +55 -0
- package/dist/nodes/otel/OtelBoundaryNode.d.ts.map +1 -0
- package/dist/nodes/otel/OtelBoundaryNode.js +81 -0
- package/dist/nodes/otel/OtelBoundaryNode.js.map +1 -0
- package/dist/nodes/otel/OtelEventNode.d.ts +59 -0
- package/dist/nodes/otel/OtelEventNode.d.ts.map +1 -0
- package/dist/nodes/otel/OtelEventNode.js +82 -0
- package/dist/nodes/otel/OtelEventNode.js.map +1 -0
- package/dist/nodes/otel/OtelResourceNode.d.ts +53 -0
- package/dist/nodes/otel/OtelResourceNode.d.ts.map +1 -0
- package/dist/nodes/otel/OtelResourceNode.js +105 -0
- package/dist/nodes/otel/OtelResourceNode.js.map +1 -0
- package/dist/nodes/otel/OtelScopeNode.d.ts +53 -0
- package/dist/nodes/otel/OtelScopeNode.d.ts.map +1 -0
- package/dist/nodes/otel/OtelScopeNode.js +81 -0
- package/dist/nodes/otel/OtelScopeNode.js.map +1 -0
- package/dist/nodes/otel/OtelSpanConventionNode.d.ts +61 -0
- package/dist/nodes/otel/OtelSpanConventionNode.d.ts.map +1 -0
- package/dist/nodes/otel/OtelSpanConventionNode.js +131 -0
- package/dist/nodes/otel/OtelSpanConventionNode.js.map +1 -0
- package/dist/nodes/otel/index.d.ts +17 -0
- package/dist/nodes/otel/index.d.ts.map +1 -0
- package/dist/nodes/otel/index.js +13 -0
- package/dist/nodes/otel/index.js.map +1 -0
- package/dist/nodes/otel/shared/NodeBadges.d.ts +10 -0
- package/dist/nodes/otel/shared/NodeBadges.d.ts.map +1 -0
- package/dist/nodes/otel/shared/NodeBadges.js +178 -0
- package/dist/nodes/otel/shared/NodeBadges.js.map +1 -0
- package/dist/nodes/otel/shared/NodeContent.d.ts +10 -0
- package/dist/nodes/otel/shared/NodeContent.d.ts.map +1 -0
- package/dist/nodes/otel/shared/NodeContent.js +96 -0
- package/dist/nodes/otel/shared/NodeContent.js.map +1 -0
- package/dist/nodes/otel/shared/index.d.ts +8 -0
- package/dist/nodes/otel/shared/index.d.ts.map +1 -0
- package/dist/nodes/otel/shared/index.js +8 -0
- package/dist/nodes/otel/shared/index.js.map +1 -0
- package/dist/nodes/otel/shared/types.d.ts +129 -0
- package/dist/nodes/otel/shared/types.d.ts.map +1 -0
- package/dist/nodes/otel/shared/types.js +5 -0
- package/dist/nodes/otel/shared/types.js.map +1 -0
- package/dist/nodes/otel/shared/useNodeBehavior.d.ts +42 -0
- package/dist/nodes/otel/shared/useNodeBehavior.d.ts.map +1 -0
- package/dist/nodes/otel/shared/useNodeBehavior.js +61 -0
- package/dist/nodes/otel/shared/useNodeBehavior.js.map +1 -0
- package/dist/pierre/PierreFileView.d.ts +17 -0
- package/dist/pierre/PierreFileView.d.ts.map +1 -0
- package/dist/pierre/PierreFileView.js +46 -0
- package/dist/pierre/PierreFileView.js.map +1 -0
- package/dist/pierre/PierreSnippetView.d.ts +28 -0
- package/dist/pierre/PierreSnippetView.d.ts.map +1 -0
- package/dist/pierre/PierreSnippetView.js +105 -0
- package/dist/pierre/PierreSnippetView.js.map +1 -0
- package/dist/pierre/PierreThroughlineCodeView.d.ts +22 -0
- package/dist/pierre/PierreThroughlineCodeView.d.ts.map +1 -0
- package/dist/pierre/PierreThroughlineCodeView.js +206 -0
- package/dist/pierre/PierreThroughlineCodeView.js.map +1 -0
- package/dist/pierre/constructColors.d.ts +24 -0
- package/dist/pierre/constructColors.d.ts.map +1 -0
- package/dist/pierre/constructColors.js +91 -0
- package/dist/pierre/constructColors.js.map +1 -0
- package/dist/pierre/index.d.ts +12 -0
- package/dist/pierre/index.d.ts.map +1 -0
- package/dist/pierre/index.js +7 -0
- package/dist/pierre/index.js.map +1 -0
- package/dist/pierre/pierreBackground.d.ts +15 -0
- package/dist/pierre/pierreBackground.d.ts.map +1 -0
- package/dist/pierre/pierreBackground.js +31 -0
- package/dist/pierre/pierreBackground.js.map +1 -0
- package/dist/pierre/pierreFileLang.d.ts +27 -0
- package/dist/pierre/pierreFileLang.d.ts.map +1 -0
- package/dist/pierre/pierreFileLang.js +52 -0
- package/dist/pierre/pierreFileLang.js.map +1 -0
- package/dist/pierre/pierreSyntaxTheme.d.ts +11 -0
- package/dist/pierre/pierreSyntaxTheme.d.ts.map +1 -0
- package/dist/pierre/pierreSyntaxTheme.js +20 -0
- package/dist/pierre/pierreSyntaxTheme.js.map +1 -0
- package/dist/pierre/scrollAnchor.d.ts +5 -0
- package/dist/pierre/scrollAnchor.d.ts.map +1 -0
- package/dist/pierre/scrollAnchor.js +9 -0
- package/dist/pierre/scrollAnchor.js.map +1 -0
- package/dist/pierre/sliceSnippet.d.ts +10 -0
- package/dist/pierre/sliceSnippet.d.ts.map +1 -0
- package/dist/pierre/sliceSnippet.js +16 -0
- package/dist/pierre/sliceSnippet.js.map +1 -0
- package/dist/pierre/syntaxTokens.d.ts +11 -0
- package/dist/pierre/syntaxTokens.d.ts.map +1 -0
- package/dist/pierre/syntaxTokens.js +51 -0
- package/dist/pierre/syntaxTokens.js.map +1 -0
- package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts +8 -0
- package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts.map +1 -0
- package/dist/stories/Subsystem/ComponentGraph/fixtures.js +116 -0
- package/dist/stories/Subsystem/ComponentGraph/fixtures.js.map +1 -0
- package/dist/subsystem/ComponentDeclaration.d.ts +87 -0
- package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -0
- package/dist/subsystem/ComponentDeclaration.js +374 -0
- package/dist/subsystem/ComponentDeclaration.js.map +1 -0
- package/dist/subsystem/FileDrawer.d.ts +17 -0
- package/dist/subsystem/FileDrawer.d.ts.map +1 -0
- package/dist/subsystem/FileDrawer.js +72 -0
- package/dist/subsystem/FileDrawer.js.map +1 -0
- package/dist/subsystem/GraphLayoutCover.d.ts +20 -0
- package/dist/subsystem/GraphLayoutCover.d.ts.map +1 -0
- package/dist/subsystem/GraphLayoutCover.js +42 -0
- package/dist/subsystem/GraphLayoutCover.js.map +1 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts +96 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -0
- package/dist/subsystem/SubsystemComponentGraph.js +1059 -0
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -0
- package/dist/subsystem/SubsystemFileTree.d.ts +24 -0
- package/dist/subsystem/SubsystemFileTree.d.ts.map +1 -0
- package/dist/subsystem/SubsystemFileTree.js +150 -0
- package/dist/subsystem/SubsystemFileTree.js.map +1 -0
- package/dist/subsystem/declarationRef.d.ts +39 -0
- package/dist/subsystem/declarationRef.d.ts.map +1 -0
- package/dist/subsystem/declarationRef.js +32 -0
- package/dist/subsystem/declarationRef.js.map +1 -0
- package/dist/subsystem/formatDeclaration.d.ts +11 -0
- package/dist/subsystem/formatDeclaration.d.ts.map +1 -0
- package/dist/subsystem/formatDeclaration.js +146 -0
- package/dist/subsystem/formatDeclaration.js.map +1 -0
- package/dist/subsystem/model.d.ts +353 -0
- package/dist/subsystem/model.d.ts.map +1 -0
- package/dist/subsystem/model.js +503 -0
- package/dist/subsystem/model.js.map +1 -0
- package/dist/subsystem/nodes.d.ts +69 -0
- package/dist/subsystem/nodes.d.ts.map +1 -0
- package/dist/subsystem/nodes.js +289 -0
- package/dist/subsystem/nodes.js.map +1 -0
- package/dist/subsystem/paths.d.ts +63 -0
- package/dist/subsystem/paths.d.ts.map +1 -0
- package/dist/subsystem/paths.js +86 -0
- package/dist/subsystem/paths.js.map +1 -0
- package/dist/subsystem/tokenizeComponent.d.ts +20 -0
- package/dist/subsystem/tokenizeComponent.d.ts.map +1 -0
- package/dist/subsystem/tokenizeComponent.js +56 -0
- package/dist/subsystem/tokenizeComponent.js.map +1 -0
- package/dist/subsystem/tokenizeFormatted.d.ts +12 -0
- package/dist/subsystem/tokenizeFormatted.d.ts.map +1 -0
- package/dist/subsystem/tokenizeFormatted.js +13 -0
- package/dist/subsystem/tokenizeFormatted.js.map +1 -0
- package/dist/utils/animationMapping.d.ts +53 -0
- package/dist/utils/animationMapping.d.ts.map +1 -0
- package/dist/utils/animationMapping.js +126 -0
- package/dist/utils/animationMapping.js.map +1 -0
- package/dist/utils/canvasBounds.d.ts +50 -0
- package/dist/utils/canvasBounds.d.ts.map +1 -0
- package/dist/utils/canvasBounds.js +88 -0
- package/dist/utils/canvasBounds.js.map +1 -0
- package/dist/utils/elkLayout.d.ts +137 -0
- package/dist/utils/elkLayout.d.ts.map +1 -0
- package/dist/utils/elkLayout.js +596 -0
- package/dist/utils/elkLayout.js.map +1 -0
- package/dist/utils/graphConverter.d.ts +18 -0
- package/dist/utils/graphConverter.d.ts.map +1 -0
- package/dist/utils/graphConverter.js +114 -0
- package/dist/utils/graphConverter.js.map +1 -0
- package/dist/utils/iconResolver.d.ts +29 -0
- package/dist/utils/iconResolver.d.ts.map +1 -0
- package/dist/utils/iconResolver.js +30 -0
- package/dist/utils/iconResolver.js.map +1 -0
- package/dist/utils/orientationUtils.d.ts +19 -0
- package/dist/utils/orientationUtils.d.ts.map +1 -0
- package/dist/utils/orientationUtils.js +62 -0
- package/dist/utils/orientationUtils.js.map +1 -0
- package/package.json +80 -0
- package/src/components/ConfigurationSelector.tsx +133 -0
- package/src/components/GraphRenderer.tsx +3195 -0
- package/src/components/MultiCanvasRenderer.tsx +295 -0
- package/src/components/NodeTooltip.tsx +279 -0
- package/src/components/PendingChanges.test.tsx +375 -0
- package/src/components/SelectionSidebar.tsx +352 -0
- package/src/components/SequenceDiagramRenderer.tsx +1156 -0
- package/src/components/WorkflowSequenceDiagram.tsx +270 -0
- package/src/components/dashboard/DashboardRenderer.tsx +317 -0
- package/src/components/dashboard/MetricPanel.tsx +254 -0
- package/src/components/dashboard/MockDataProvider.ts +330 -0
- package/src/components/dashboard/components/BarChart.tsx +299 -0
- package/src/components/dashboard/components/LineChart.tsx +279 -0
- package/src/components/dashboard/components/MetricCard.tsx +270 -0
- package/src/components/dashboard/components/SourceLink.tsx +63 -0
- package/src/components/dashboard/components/TimeRangeSelector.tsx +280 -0
- package/src/components/dashboard/components/index.ts +5 -0
- package/src/components/dashboard/index.ts +47 -0
- package/src/components/dashboard/types.ts +126 -0
- package/src/components/session-events/SessionEventFeed.tsx +1797 -0
- package/src/components/session-events/index.ts +13 -0
- package/src/components/state-view/PipelineView.tsx +347 -0
- package/src/components/state-view/index.ts +14 -0
- package/src/components/state-view/types.ts +261 -0
- package/src/components/state-view/useStateView.ts +205 -0
- package/src/contexts/GraphEditContext.tsx +27 -0
- package/src/contexts/TooltipPortalContext.ts +8 -0
- package/src/edges/CustomEdge.tsx +402 -0
- package/src/edges/GenericEdge.tsx +36 -0
- package/src/graphify/anchor.test.ts +124 -0
- package/src/graphify/anchor.ts +160 -0
- package/src/graphify/consolidated.ts +246 -0
- package/src/graphify/ids.ts +48 -0
- package/src/graphify/index.ts +72 -0
- package/src/graphify/kind.test.ts +108 -0
- package/src/graphify/kind.ts +120 -0
- package/src/graphify/resolve.test.ts +89 -0
- package/src/graphify/resolve.ts +115 -0
- package/src/graphify/signature.test.ts +278 -0
- package/src/graphify/signature.ts +354 -0
- package/src/graphify/types.ts +164 -0
- package/src/hooks/useElkLayout.test.ts +134 -0
- package/src/hooks/useElkLayout.ts +191 -0
- package/src/hooks/usePathBasedEvents.ts +192 -0
- package/src/hooks/useSequenceLayout.ts +543 -0
- package/src/hooks/useUndoRedo.ts +144 -0
- package/src/index.ts +341 -0
- package/src/nodes/CustomNode.tsx +1231 -0
- package/src/nodes/GenericNode.tsx +55 -0
- package/src/nodes/otel/OtelBoundaryNode.tsx +224 -0
- package/src/nodes/otel/OtelEventNode.tsx +225 -0
- package/src/nodes/otel/OtelResourceNode.tsx +251 -0
- package/src/nodes/otel/OtelScopeNode.tsx +221 -0
- package/src/nodes/otel/OtelSpanConventionNode.tsx +295 -0
- package/src/nodes/otel/index.ts +23 -0
- package/src/nodes/otel/shared/NodeBadges.tsx +295 -0
- package/src/nodes/otel/shared/NodeContent.tsx +204 -0
- package/src/nodes/otel/shared/index.ts +8 -0
- package/src/nodes/otel/shared/types.ts +138 -0
- package/src/nodes/otel/shared/useNodeBehavior.ts +114 -0
- package/src/pierre/PierreFileView.tsx +81 -0
- package/src/pierre/PierreSnippetView.tsx +184 -0
- package/src/pierre/PierreThroughlineCodeView.tsx +304 -0
- package/src/pierre/constructColors.test.ts +52 -0
- package/src/pierre/constructColors.ts +112 -0
- package/src/pierre/index.ts +18 -0
- package/src/pierre/pierreBackground.ts +33 -0
- package/src/pierre/pierreFileLang.test.ts +26 -0
- package/src/pierre/pierreFileLang.ts +55 -0
- package/src/pierre/pierreSyntaxTheme.ts +23 -0
- package/src/pierre/scrollAnchor.test.ts +13 -0
- package/src/pierre/scrollAnchor.ts +11 -0
- package/src/pierre/sliceSnippet.test.ts +21 -0
- package/src/pierre/sliceSnippet.ts +31 -0
- package/src/pierre/syntaxTokens.test.ts +35 -0
- package/src/pierre/syntaxTokens.ts +60 -0
- package/src/stories/Audit/BoundaryNodes.stories.tsx +385 -0
- package/src/stories/Audit/CanvasEdgeTypes.stories.tsx +1448 -0
- package/src/stories/Audit/CanvasNodeTypes.stories.tsx +1049 -0
- package/src/stories/Audit/ColorPriority.stories.tsx +895 -0
- package/src/stories/Audit/DiamondBadges.stories.tsx +629 -0
- package/src/stories/Audit/NodeDimensionsTesting.stories.tsx +724 -0
- package/src/stories/Audit/NodeFieldsAudit.stories.tsx +996 -0
- package/src/stories/Audit/NodeFontSizeTesting.stories.tsx +715 -0
- package/src/stories/Audit/NodeShapes.stories.tsx +1208 -0
- package/src/stories/Audit/WideNodesTesting.stories.tsx +1045 -0
- package/src/stories/Components/DashboardRenderer.stories.tsx +263 -0
- package/src/stories/Components/GraphRenderer.stories.tsx +3236 -0
- package/src/stories/Components/MultiCanvasRenderer.stories.tsx +370 -0
- package/src/stories/Components/SequenceDiagram.stories.tsx +255 -0
- package/src/stories/Components/StateView.stories.tsx +417 -0
- package/src/stories/Components/TraceViewer.stories.tsx +442 -0
- package/src/stories/Components/WorkflowSequenceDiagram.stories.tsx +802 -0
- package/src/stories/Features/AnimationWorkshop.stories.tsx +727 -0
- package/src/stories/Features/ChangeTypeVisual.stories.tsx +67 -0
- package/src/stories/Features/EventDrivenAnimations.stories.tsx +513 -0
- package/src/stories/Features/MultiConfig.stories.tsx +539 -0
- package/src/stories/Introduction.mdx +34 -0
- package/src/stories/Layout/ElkEdgeRouting.stories.tsx +539 -0
- package/src/stories/Layout/GraphOrientation.stories.tsx +500 -0
- package/src/stories/Layout/MultiDirectionalConnections.stories.tsx +352 -0
- package/src/stories/Nodes/NodeDefinitionComparison.stories.tsx +560 -0
- package/src/stories/Nodes/NodeNameFallbacks.stories.tsx +491 -0
- package/src/stories/Nodes/NumericColors.stories.tsx +440 -0
- package/src/stories/OTEL/EventNameDisplay.stories.tsx +694 -0
- package/src/stories/OTEL/EventNameFallback.stories.tsx +578 -0
- package/src/stories/OTEL/OtelComponents.stories.tsx +422 -0
- package/src/stories/OTEL/OtelNodeTypesPrototype.stories.tsx +806 -0
- package/src/stories/OTEL/RawEventCards.stories.tsx +57 -0
- package/src/stories/OTEL/SpanBadges.stories.tsx +840 -0
- package/src/stories/OTEL/ValidationEventsCanvas.stories.tsx +209 -0
- package/src/stories/Pierre/CodeView.stories.tsx +219 -0
- package/src/stories/RealWorld/BacklogMdCanvases.stories.tsx +721 -0
- package/src/stories/RealWorld/FileCitySequence.stories.tsx +516 -0
- package/src/stories/RealWorld/RealCanvasFiles.stories.tsx +336 -0
- package/src/stories/Subsystem/ComponentDeclarationAudit.stories.tsx +321 -0
- package/src/stories/Subsystem/ComponentGraph/Appearance.stories.tsx +365 -0
- package/src/stories/Subsystem/ComponentGraph/Basics.stories.tsx +137 -0
- package/src/stories/Subsystem/ComponentGraph/Captures.stories.tsx +238 -0
- package/src/stories/Subsystem/ComponentGraph/DetailPanel.stories.tsx +315 -0
- package/src/stories/Subsystem/ComponentGraph/Flows.stories.tsx +239 -0
- package/src/stories/Subsystem/ComponentGraph/FrameworkStereotype.stories.tsx +216 -0
- package/src/stories/Subsystem/ComponentGraph/GraphTitle.stories.tsx +70 -0
- package/src/stories/Subsystem/ComponentGraph/Processes.stories.tsx +68 -0
- package/src/stories/Subsystem/ComponentGraph/Scenarios.stories.tsx +959 -0
- package/src/stories/Subsystem/ComponentGraph/Spotlights.stories.tsx +681 -0
- package/src/stories/Subsystem/ComponentGraph/fixtures.ts +129 -0
- package/src/stories/__traces__/test-run.canvas.json +241 -0
- package/src/stories/data/activity-feed-analytics.dashboard.json +300 -0
- package/src/stories/data/file-city-images-transformed.otel.canvas.json +295 -0
- package/src/stories/data/file-city-workflows.json +269 -0
- package/src/stories/data/graph-converter-test-execution.json +443 -0
- package/src/stories/data/graph-converter-test.workflow.json +105 -0
- package/src/stories/data/graph-converter-validated-execution.json +58 -0
- package/src/stories/data/opencode-session-raw-events.json +273396 -0
- package/src/subsystem/ComponentDeclaration.tsx +643 -0
- package/src/subsystem/FileDrawer.tsx +106 -0
- package/src/subsystem/GraphLayoutCover.tsx +69 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +1644 -0
- package/src/subsystem/SubsystemFileTree.tsx +190 -0
- package/src/subsystem/declarationRef.test.ts +33 -0
- package/src/subsystem/declarationRef.ts +63 -0
- package/src/subsystem/formatDeclaration.ts +174 -0
- package/src/subsystem/model.test.ts +244 -0
- package/src/subsystem/model.ts +850 -0
- package/src/subsystem/nodes.test.ts +56 -0
- package/src/subsystem/nodes.tsx +443 -0
- package/src/subsystem/paths.test.ts +123 -0
- package/src/subsystem/paths.ts +127 -0
- package/src/subsystem/tokenizeComponent.ts +70 -0
- package/src/subsystem/tokenizeFormatted.ts +19 -0
- package/src/utils/animationMapping.ts +166 -0
- package/src/utils/canvasBounds.ts +146 -0
- package/src/utils/elkLayout.test.ts +240 -0
- package/src/utils/elkLayout.ts +779 -0
- package/src/utils/graphConverter.ts +148 -0
- package/src/utils/iconResolver.tsx +53 -0
- package/src/utils/orientationUtils.ts +73 -0
|
@@ -0,0 +1,1644 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubsystemComponentGraph — a clickable, read-only React Flow component graph
|
|
3
|
+
* for a subsystem snapshot.
|
|
4
|
+
*
|
|
5
|
+
* Nodes are positioned with ELK auto-layout (layered, minimized crossings,
|
|
6
|
+
* process-aware compound groups). Components sharing a `process` render
|
|
7
|
+
* inside one labeled boundary frame; nodes without one sit outside every
|
|
8
|
+
* boundary. Clicking a component invokes `onSelect`.
|
|
9
|
+
*
|
|
10
|
+
* This is a focused fork of the package's `GraphRenderer` pipeline (same ELK
|
|
11
|
+
* edge routing, delayed fitView, Background/Controls/MiniMap, node/edge type
|
|
12
|
+
* injection, onNodeClick) adapted to the subsystem model — read-only.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
16
|
+
import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react';
|
|
17
|
+
import {
|
|
18
|
+
ReactFlow,
|
|
19
|
+
ReactFlowProvider,
|
|
20
|
+
Background,
|
|
21
|
+
BackgroundVariant,
|
|
22
|
+
Controls,
|
|
23
|
+
useReactFlow,
|
|
24
|
+
useViewport,
|
|
25
|
+
type NodeTypes,
|
|
26
|
+
type EdgeTypes,
|
|
27
|
+
type Node,
|
|
28
|
+
type Edge,
|
|
29
|
+
type NodeMouseHandler,
|
|
30
|
+
type NodeChange,
|
|
31
|
+
type EdgeChange,
|
|
32
|
+
applyNodeChanges,
|
|
33
|
+
} from '@xyflow/react';
|
|
34
|
+
import { useTheme } from '@principal-ade/industry-theme';
|
|
35
|
+
import { ChevronDown, ChevronUp, X } from 'lucide-react';
|
|
36
|
+
import { IndustryMarkdownSlide } from 'themed-markdown';
|
|
37
|
+
import {
|
|
38
|
+
buildSubsystemGraph,
|
|
39
|
+
MECHANISM_COLOR,
|
|
40
|
+
MECHANISM_DESCRIPTIONS,
|
|
41
|
+
subsystemGraphLayoutKey,
|
|
42
|
+
type SubsystemComponentEdge,
|
|
43
|
+
type SubsystemComponent,
|
|
44
|
+
type SubsystemEdgeMechanism,
|
|
45
|
+
type SubsystemThroughline,
|
|
46
|
+
} from './model';
|
|
47
|
+
import type { SubsystemOpenFileOptions } from './declarationRef';
|
|
48
|
+
import { SubsystemComponentNode, SubsystemGroupNode, SubsystemEdge, SUBSYSTEM_CALLBACKS, hexWithAlpha, EDGE_DIM_ALPHA, fileMatchForNode, flowElementVisibility } from './nodes';
|
|
49
|
+
import { SubsystemFileTree } from './SubsystemFileTree';
|
|
50
|
+
import { GraphLayoutCover } from './GraphLayoutCover';
|
|
51
|
+
import { ComponentDeclaration } from './ComponentDeclaration';
|
|
52
|
+
import type { ComponentVerificationState } from './ComponentDeclaration';
|
|
53
|
+
import { FileDrawer } from './FileDrawer';
|
|
54
|
+
import { buildRepoGroups, repoAvatarUrl, type RepoGroup } from './paths';
|
|
55
|
+
|
|
56
|
+
/** Cap screen-space edge labels to this fraction of the edge's on-screen length. */
|
|
57
|
+
const EDGE_LABEL_MAX_EDGE_FRACTION = 0.55;
|
|
58
|
+
/** Rough monospace width at fontSize 10 + horizontal padding/border. */
|
|
59
|
+
const EDGE_LABEL_CHAR_PX = 6.2;
|
|
60
|
+
const EDGE_LABEL_PAD_PX = 18;
|
|
61
|
+
|
|
62
|
+
/** Context passed to `renderThroughlineViewer` when a flow/step is focused. */
|
|
63
|
+
export interface ThroughlineViewerContext {
|
|
64
|
+
throughline: SubsystemThroughline;
|
|
65
|
+
/** Focused step index; `null` means the whole flow (no specific step). */
|
|
66
|
+
stepIndex: number | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type DrawerTarget =
|
|
70
|
+
| { kind: 'file'; file: string; startLine?: number }
|
|
71
|
+
| { kind: 'throughline'; throughlineId: string; stepIndex: number | null };
|
|
72
|
+
|
|
73
|
+
export interface SubsystemComponentGraphProps {
|
|
74
|
+
components: SubsystemComponent[];
|
|
75
|
+
edges: SubsystemComponentEdge[];
|
|
76
|
+
/**
|
|
77
|
+
* Ordered execution stories over the graph's edges — one throughline per
|
|
78
|
+
* flow. When present the sidebar's bottom half offers a Files/Flows toggle:
|
|
79
|
+
* the flows panel lists each throughline's steps (`symbol` or `file:line`);
|
|
80
|
+
* clicking a flow row toggles its steps; clicking a step focuses that
|
|
81
|
+
* step's edge and (when `renderThroughlineViewer` is set) opens the bottom
|
|
82
|
+
* drawer on that flow's snippets. Opened flows stay on the canvas
|
|
83
|
+
* (unselected ones dimmed); everything else is hidden.
|
|
84
|
+
*/
|
|
85
|
+
throughlines?: SubsystemThroughline[];
|
|
86
|
+
onSelect?: (componentId: string) => void;
|
|
87
|
+
/** Called when an edge is clicked (relationship / mechanism + refs seam). */
|
|
88
|
+
onEdgeSelect?: (edge: SubsystemComponentEdge) => void;
|
|
89
|
+
/** Upper bound for node width in px; nodes grow with content up to this,
|
|
90
|
+
* then the name wraps. Defaults to 300 for components (320 for packages). */
|
|
91
|
+
maxNodeWidth?: number;
|
|
92
|
+
/** Show edge labels (mechanism names) on the graph. @default true */
|
|
93
|
+
showEdgeLabels?: boolean;
|
|
94
|
+
/** Subsystem title displayed in the sidebar. */
|
|
95
|
+
title?: string;
|
|
96
|
+
/**
|
|
97
|
+
* Suppresses the sidebar entirely (title, description, file tree,
|
|
98
|
+
* throughlines) for graph-only embeds. Pair with `graphTitle` to keep the
|
|
99
|
+
* subsystem name visible as an overlay on the canvas.
|
|
100
|
+
*/
|
|
101
|
+
hideSidebar?: boolean;
|
|
102
|
+
/**
|
|
103
|
+
* Subsystem title rendered as a non-interactive overlay chip on the graph
|
|
104
|
+
* canvas (top-center). Does not trigger the sidebar — for graph-only
|
|
105
|
+
* embeds that still need to name what they show.
|
|
106
|
+
*/
|
|
107
|
+
graphTitle?: string;
|
|
108
|
+
/** Markdown description rendered in the sidebar. */
|
|
109
|
+
description?: string;
|
|
110
|
+
/** Rendered over the graph canvas only (not the title/legend sidebar). */
|
|
111
|
+
canvasOverlay?: ReactNode;
|
|
112
|
+
/** Extra controls at the top of the title/legend sidebar. */
|
|
113
|
+
sidebarExtra?: ReactNode;
|
|
114
|
+
/** Rendered in the sidebar under the description (e.g. selection inspector). */
|
|
115
|
+
sidebarAfterDescription?: ReactNode;
|
|
116
|
+
/**
|
|
117
|
+
* Host-injected reader/renderer for the bottom file drawer, keyed by
|
|
118
|
+
* repo-root-relative path. Opening happens on declaration/file-tree clicks.
|
|
119
|
+
* Keeps this package free of fs and code-view dependencies.
|
|
120
|
+
*/
|
|
121
|
+
renderFileViewer?: (file: string, opts?: SubsystemOpenFileOptions) => ReactNode;
|
|
122
|
+
/**
|
|
123
|
+
* Host-injected multi-snippet viewer for a focused throughline. When set,
|
|
124
|
+
* clicking a flow step opens the bottom drawer with this content and
|
|
125
|
+
* updates it as the focused step changes.
|
|
126
|
+
*/
|
|
127
|
+
renderThroughlineViewer?: (ctx: ThroughlineViewerContext) => ReactNode;
|
|
128
|
+
/**
|
|
129
|
+
* Legacy component-keyed variant, kept for backward compatibility. When
|
|
130
|
+
* `renderFileViewer` is absent, drawer content resolves via the first
|
|
131
|
+
* component whose `file` matches the opened path.
|
|
132
|
+
*/
|
|
133
|
+
renderFileView?: (component: SubsystemComponent) => ReactNode;
|
|
134
|
+
/**
|
|
135
|
+
* Called when a file in the sidebar file tree is clicked (repo-root-relative
|
|
136
|
+
* path). The tree is derived from the components' `file` values.
|
|
137
|
+
*/
|
|
138
|
+
onFileSelect?: (file: string) => void;
|
|
139
|
+
/** Verify the selected component against graphify (declaration panel). */
|
|
140
|
+
onVerifyComponent?: (componentId: string) => void;
|
|
141
|
+
/** Live verification status for the selected component. */
|
|
142
|
+
componentVerification?: ComponentVerificationState | null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const nodeTypes: NodeTypes = {
|
|
146
|
+
'subsystem-component': SubsystemComponentNode,
|
|
147
|
+
'subsystem-group': SubsystemGroupNode,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const edgeTypes: EdgeTypes = {
|
|
151
|
+
'subsystem-edge': SubsystemEdge,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Memoized drawer body: only rebuilds children when the open target changes.
|
|
155
|
+
// Inner re-renders on every viewport pan/zoom and hover; recreating host
|
|
156
|
+
// elements then would churn their readFile closures and flash the file
|
|
157
|
+
// viewer's loading state on each render.
|
|
158
|
+
const FileDrawerContent = memo(function FileDrawerContent({
|
|
159
|
+
render,
|
|
160
|
+
file,
|
|
161
|
+
startLine,
|
|
162
|
+
}: {
|
|
163
|
+
render: (file: string, opts?: SubsystemOpenFileOptions) => ReactNode;
|
|
164
|
+
file: string;
|
|
165
|
+
startLine?: number;
|
|
166
|
+
}) {
|
|
167
|
+
return <>{render(file, startLine != null ? { startLine } : undefined)}</>;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const ThroughlineDrawerContent = memo(function ThroughlineDrawerContent({
|
|
171
|
+
render,
|
|
172
|
+
throughline,
|
|
173
|
+
stepIndex,
|
|
174
|
+
}: {
|
|
175
|
+
render: (ctx: ThroughlineViewerContext) => ReactNode;
|
|
176
|
+
throughline: SubsystemThroughline;
|
|
177
|
+
stepIndex: number | null;
|
|
178
|
+
}) {
|
|
179
|
+
return <>{render({ throughline, stepIndex })}</>;
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
interface InnerProps extends SubsystemComponentGraphProps {
|
|
183
|
+
measured: { w: number; h: number } | null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, hideSidebar, graphTitle, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, renderThroughlineViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
|
|
187
|
+
const { theme } = useTheme();
|
|
188
|
+
const { fitView } = useReactFlow();
|
|
189
|
+
const viewport = useViewport();
|
|
190
|
+
const [built, setBuilt] = useState<{ nodes: Node[]; edges: Edge[] }>({
|
|
191
|
+
nodes: [],
|
|
192
|
+
edges: [],
|
|
193
|
+
});
|
|
194
|
+
const [layoutReady, setLayoutReady] = useState(false);
|
|
195
|
+
const [selected, setSelected] = useState<SubsystemComponent | null>(null);
|
|
196
|
+
/** Bottom drawer: single file or throughline multi-snippet mode. */
|
|
197
|
+
const [drawerTarget, setDrawerTarget] = useState<DrawerTarget | null>(null);
|
|
198
|
+
// Component the pointer is over (null on leave) → transient tree highlight.
|
|
199
|
+
const [hoveredComponentId, setHoveredComponentId] = useState<string | null>(null);
|
|
200
|
+
// Throughline focus — selected flow (or step) is full strength; other
|
|
201
|
+
// opened-flow members stay visible but dimmed; everything else is hidden.
|
|
202
|
+
const [focusedThroughlineId, setFocusedThroughlineId] = useState<string | null>(null);
|
|
203
|
+
// `null` = whole flow focused; a number = that single step's edge focused.
|
|
204
|
+
const [focusedStepIndex, setFocusedStepIndex] = useState<number | null>(null);
|
|
205
|
+
// Sidebar bottom half: which panel is shown when throughlines exist.
|
|
206
|
+
const [sidebarView, setSidebarView] = useState<'files' | 'flows'>(() =>
|
|
207
|
+
throughlines?.length ? 'flows' : 'files',
|
|
208
|
+
);
|
|
209
|
+
// Throughline flows the user has expanded (via the title row). Closed by
|
|
210
|
+
// default so a graph with several flows doesn't dump every step list at once.
|
|
211
|
+
const [expandedThroughlines, setExpandedThroughlines] = useState<Set<string>>(new Set());
|
|
212
|
+
// Sidebar description visibility. Hidden by default so the files/flows
|
|
213
|
+
// panel gets the vertical room; the title-row toggle reveals it.
|
|
214
|
+
const [descriptionVisible, setDescriptionVisible] = useState(false);
|
|
215
|
+
const [descToggleHover, setDescToggleHover] = useState(false);
|
|
216
|
+
// `true` only when a description exists AND the user opened it.
|
|
217
|
+
const showDesc = !!description && descriptionVisible;
|
|
218
|
+
// Ref mirror of `selected` so the SUBSYSTEM_CALLBACKS click handler (a
|
|
219
|
+
// closure over the effect deps) can toggle without a stale value.
|
|
220
|
+
const selectedRef = useRef<SubsystemComponent | null>(null);
|
|
221
|
+
selectedRef.current = selected;
|
|
222
|
+
// Ref mirror of the open file drawer target for tree-click toggle.
|
|
223
|
+
const openFileRef = useRef<{ file: string; startLine?: number } | null>(null);
|
|
224
|
+
const openFile =
|
|
225
|
+
drawerTarget?.kind === 'file' ? drawerTarget.file : null;
|
|
226
|
+
openFileRef.current =
|
|
227
|
+
drawerTarget?.kind === 'file'
|
|
228
|
+
? { file: drawerTarget.file, startLine: drawerTarget.startLine }
|
|
229
|
+
: null;
|
|
230
|
+
|
|
231
|
+
const focusedThroughline = useMemo(() => {
|
|
232
|
+
if (drawerTarget?.kind !== 'throughline' || !throughlines) return null;
|
|
233
|
+
return throughlines.find((t) => t.id === drawerTarget.throughlineId) ?? null;
|
|
234
|
+
}, [drawerTarget, throughlines]);
|
|
235
|
+
|
|
236
|
+
const drawerTitle = useMemo(() => {
|
|
237
|
+
if (!drawerTarget) return null;
|
|
238
|
+
if (drawerTarget.kind === 'file') return drawerTarget.file;
|
|
239
|
+
const tl = focusedThroughline;
|
|
240
|
+
if (!tl) return null;
|
|
241
|
+
if (drawerTarget.stepIndex == null) return tl.title;
|
|
242
|
+
const step = tl.steps[drawerTarget.stepIndex];
|
|
243
|
+
if (!step) return tl.title;
|
|
244
|
+
const site = `${step.file.split('/').pop() ?? step.file}:${step.line}`;
|
|
245
|
+
return `${tl.title} · ${site}`;
|
|
246
|
+
}, [drawerTarget, focusedThroughline]);
|
|
247
|
+
|
|
248
|
+
// Refresh selected component when the components list updates (e.g. verify
|
|
249
|
+
// writes back declarationRef).
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
if (!selected) return;
|
|
252
|
+
const fresh = components.find((c) => c.id === selected.id);
|
|
253
|
+
if (fresh && fresh !== selected) setSelected(fresh);
|
|
254
|
+
}, [components, selected]);
|
|
255
|
+
|
|
256
|
+
// Pass 1: build with estimated widths so React Flow can measure the DOM.
|
|
257
|
+
// The pane stays hidden until Pass 2 completes. Key off layout-affecting
|
|
258
|
+
// fields only — declarationRef updates after verify must not re-run ELK.
|
|
259
|
+
const layoutKey = useMemo(
|
|
260
|
+
() => subsystemGraphLayoutKey({ components, edges }),
|
|
261
|
+
[components, edges],
|
|
262
|
+
);
|
|
263
|
+
const componentsRef = useRef(components);
|
|
264
|
+
const edgesRef = useRef(edges);
|
|
265
|
+
componentsRef.current = components;
|
|
266
|
+
edgesRef.current = edges;
|
|
267
|
+
|
|
268
|
+
useEffect(() => {
|
|
269
|
+
let alive = true;
|
|
270
|
+
pass2DoneRef.current = false;
|
|
271
|
+
measuredDimsRef.current = new Map();
|
|
272
|
+
prevMeasuredSigRef.current = '';
|
|
273
|
+
const doc = { components: componentsRef.current, edges: edgesRef.current };
|
|
274
|
+
void buildSubsystemGraph(doc, { maxNodeWidth, showEdgeLabels })
|
|
275
|
+
.then(({ nodes, edges: e }) => {
|
|
276
|
+
if (!alive) return;
|
|
277
|
+
setBuilt({ nodes, edges: e as Edge[] });
|
|
278
|
+
setLayoutReady(false);
|
|
279
|
+
})
|
|
280
|
+
.catch((err) => {
|
|
281
|
+
console.warn('[subsystem-graph] initial layout failed:', err);
|
|
282
|
+
if (!alive) return;
|
|
283
|
+
// Reveal the cover even on failure so the UI is not stuck forever.
|
|
284
|
+
setBuilt({ nodes: [], edges: [] });
|
|
285
|
+
setLayoutReady(true);
|
|
286
|
+
});
|
|
287
|
+
return () => { alive = false; };
|
|
288
|
+
}, [layoutKey, maxNodeWidth, showEdgeLabels]);
|
|
289
|
+
|
|
290
|
+
// Track measured dimensions from React Flow's dimension changes.
|
|
291
|
+
// These arrive as { type: 'dimensions', id, dimensions } in onNodesChange.
|
|
292
|
+
const measuredDimsRef = useRef(new Map<string, { width: number; height: number }>());
|
|
293
|
+
const pendingMeasuredRef = useRef(false);
|
|
294
|
+
|
|
295
|
+
// Pass 2: once every leaf node has a measured dimension, re-run ELK.
|
|
296
|
+
// Group parents are sized by ELK, not measured — exclude them or pass 2
|
|
297
|
+
// would wait forever for dimensions that never arrive.
|
|
298
|
+
const prevMeasuredSigRef = useRef('');
|
|
299
|
+
const pass2DoneRef = useRef(false);
|
|
300
|
+
const triggerPass2 = useCallback(() => {
|
|
301
|
+
if (pass2DoneRef.current) return;
|
|
302
|
+
const dims = measuredDimsRef.current;
|
|
303
|
+
const leafNodes = built.nodes.filter((n) => n.type !== 'subsystem-group');
|
|
304
|
+
if (dims.size < leafNodes.length) return;
|
|
305
|
+
const sig = leafNodes.map((n) => `${n.id}:${dims.get(n.id)?.width ?? '?'}`).join(',');
|
|
306
|
+
if (sig.includes('?:')) return;
|
|
307
|
+
if (sig === prevMeasuredSigRef.current) return;
|
|
308
|
+
prevMeasuredSigRef.current = sig;
|
|
309
|
+
pendingMeasuredRef.current = false;
|
|
310
|
+
pass2DoneRef.current = true;
|
|
311
|
+
|
|
312
|
+
const measuredWidths = new Map(leafNodes.map((n) => [n.id, dims.get(n.id)!.width]));
|
|
313
|
+
const measuredHeights = new Map(leafNodes.map((n) => [n.id, dims.get(n.id)!.height]));
|
|
314
|
+
let alive = true;
|
|
315
|
+
void buildSubsystemGraph(
|
|
316
|
+
{ components, edges },
|
|
317
|
+
{ maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights },
|
|
318
|
+
).then(({ nodes, edges: e }) => {
|
|
319
|
+
if (!alive) return;
|
|
320
|
+
setBuilt({ nodes, edges: e as Edge[] });
|
|
321
|
+
setLayoutReady(true);
|
|
322
|
+
});
|
|
323
|
+
return () => { alive = false; };
|
|
324
|
+
}, [built.nodes, components, edges, maxNodeWidth, showEdgeLabels]);
|
|
325
|
+
|
|
326
|
+
// Node components call SUBSYSTEM_CALLBACKS.onSelect on click (their inner
|
|
327
|
+
// onClick stops React Flow propagation), so wire selection + width through it.
|
|
328
|
+
// Lookup from edge id → the authored SubsystemComponentEdge (for onEdgeSelect).
|
|
329
|
+
const edgeById = useMemo(() => new Map(edges.map((e) => [e.id, e])), [edges]);
|
|
330
|
+
|
|
331
|
+
// Shared edge-selection logic used by both React Flow's onEdgeClick and the
|
|
332
|
+
// clickable edge label (SUBSYSTEM_CALLBACKS.onEdgeSelect).
|
|
333
|
+
const selectEdge = useCallback(
|
|
334
|
+
(edgeId: string) => {
|
|
335
|
+
if (selectedEdgeIdRef.current === edgeId) {
|
|
336
|
+
setSelectedEdgeId(null);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const src = edgeById.get(edgeId);
|
|
340
|
+
setSelectedEdgeId(edgeId);
|
|
341
|
+
// A direct edge selection on the canvas supersedes any throughline focus.
|
|
342
|
+
setFocusedThroughlineId(null);
|
|
343
|
+
setFocusedStepIndex(null);
|
|
344
|
+
if (src) onEdgeSelect?.(src);
|
|
345
|
+
},
|
|
346
|
+
[edgeById, onEdgeSelect],
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
useEffect(() => {
|
|
350
|
+
SUBSYSTEM_CALLBACKS.onSelect = (id: string) => {
|
|
351
|
+
const comp = components.find((c) => c.id === id);
|
|
352
|
+
if (comp) {
|
|
353
|
+
// Clicking the already-selected node unselects it (toggle off).
|
|
354
|
+
// Selection is independent of the file drawer — nodes never open it.
|
|
355
|
+
if (selectedRef.current?.id === comp.id) {
|
|
356
|
+
setSelected(null);
|
|
357
|
+
setSelectedEdgeId(null);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
setSelected(comp);
|
|
361
|
+
setSelectedEdgeId(null);
|
|
362
|
+
setFocusedThroughlineId(null);
|
|
363
|
+
setFocusedStepIndex(null);
|
|
364
|
+
onSelect?.(id);
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
SUBSYSTEM_CALLBACKS.onEdgeSelect = selectEdge;
|
|
368
|
+
SUBSYSTEM_CALLBACKS.maxNodeWidth = maxNodeWidth;
|
|
369
|
+
SUBSYSTEM_CALLBACKS.onHover = setHoveredComponentId;
|
|
370
|
+
return () => {
|
|
371
|
+
SUBSYSTEM_CALLBACKS.onSelect = undefined;
|
|
372
|
+
SUBSYSTEM_CALLBACKS.onEdgeSelect = undefined;
|
|
373
|
+
SUBSYSTEM_CALLBACKS.maxNodeWidth = undefined;
|
|
374
|
+
SUBSYSTEM_CALLBACKS.onHover = undefined;
|
|
375
|
+
};
|
|
376
|
+
}, [components, onSelect, maxNodeWidth, selectEdge]);
|
|
377
|
+
|
|
378
|
+
const { nodes, edges: convertedEdges } = built;
|
|
379
|
+
const xyflowNodesBase = nodes as Node[];
|
|
380
|
+
const baseEdges = convertedEdges as Edge[];
|
|
381
|
+
|
|
382
|
+
// When an edge is selected, dim every other edge + its label to focus it.
|
|
383
|
+
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
|
384
|
+
// Ref mirror so `selectEdge` (a useCallback over early deps) can toggle
|
|
385
|
+
// without a stale closure value.
|
|
386
|
+
const selectedEdgeIdRef = useRef<string | null>(null);
|
|
387
|
+
selectedEdgeIdRef.current = selectedEdgeId;
|
|
388
|
+
// Edge ids in throughline focus (an active flow's edge set, or a single
|
|
389
|
+
// step's edge). Used to frame the camera. `null` = no throughline focus.
|
|
390
|
+
const focusEdgeIds = useMemo(() => {
|
|
391
|
+
if (focusedThroughlineId == null || !throughlines) return null;
|
|
392
|
+
const tl = throughlines.find((t) => t.id === focusedThroughlineId);
|
|
393
|
+
if (!tl) return null;
|
|
394
|
+
if (focusedStepIndex != null) {
|
|
395
|
+
const step = tl.steps[focusedStepIndex];
|
|
396
|
+
return step ? new Set([step.edgeId]) : null;
|
|
397
|
+
}
|
|
398
|
+
return new Set(tl.steps.map((s) => s.edgeId));
|
|
399
|
+
}, [throughlines, focusedThroughlineId, focusedStepIndex]);
|
|
400
|
+
|
|
401
|
+
// 1-based step numbers per edge of the selected flow (an edge can appear
|
|
402
|
+
// in more than one step).
|
|
403
|
+
const selectedFlowStepNos = useMemo(() => {
|
|
404
|
+
if (focusedThroughlineId == null || !throughlines) return null;
|
|
405
|
+
const tl = throughlines.find((t) => t.id === focusedThroughlineId);
|
|
406
|
+
if (!tl) return null;
|
|
407
|
+
const map = new Map<string, number[]>();
|
|
408
|
+
tl.steps.forEach((s, i) => {
|
|
409
|
+
const list = map.get(s.edgeId) ?? [];
|
|
410
|
+
list.push(i + 1);
|
|
411
|
+
map.set(s.edgeId, list);
|
|
412
|
+
});
|
|
413
|
+
return map;
|
|
414
|
+
}, [throughlines, focusedThroughlineId]);
|
|
415
|
+
|
|
416
|
+
// Union of every expanded (opened) throughline's edges — the visible set.
|
|
417
|
+
const openedEdgeIds = useMemo(() => {
|
|
418
|
+
if (!throughlines || expandedThroughlines.size === 0) return null;
|
|
419
|
+
const ids = new Set<string>();
|
|
420
|
+
for (const tl of throughlines) {
|
|
421
|
+
if (!expandedThroughlines.has(tl.id)) continue;
|
|
422
|
+
for (const s of tl.steps) ids.add(s.edgeId);
|
|
423
|
+
}
|
|
424
|
+
return ids.size > 0 ? ids : null;
|
|
425
|
+
}, [throughlines, expandedThroughlines]);
|
|
426
|
+
|
|
427
|
+
const endpointsOf = (edgeIds: ReadonlySet<string> | null): Set<string> | null => {
|
|
428
|
+
if (!edgeIds) return null;
|
|
429
|
+
const ids = new Set<string>();
|
|
430
|
+
for (const e of baseEdges) {
|
|
431
|
+
if (!edgeIds.has(e.id)) continue;
|
|
432
|
+
ids.add(e.source);
|
|
433
|
+
ids.add(e.target);
|
|
434
|
+
}
|
|
435
|
+
return ids.size > 0 ? ids : null;
|
|
436
|
+
};
|
|
437
|
+
const openedNodeIds = useMemo(
|
|
438
|
+
() => endpointsOf(openedEdgeIds),
|
|
439
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
440
|
+
[baseEdges, openedEdgeIds],
|
|
441
|
+
);
|
|
442
|
+
// Endpoints of the bright set: the selected step's edge, or the whole flow
|
|
443
|
+
// when no step is focused.
|
|
444
|
+
const brightNodeIds = useMemo(
|
|
445
|
+
() => endpointsOf(focusEdgeIds),
|
|
446
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
447
|
+
[baseEdges, focusEdgeIds],
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
// Source + target of the focused step/flow (or a canvas-selected edge). If a
|
|
451
|
+
// file is open, those endpoints stay undimmed even when they don't live in
|
|
452
|
+
// that file.
|
|
453
|
+
const focusNodeIds = useMemo(() => {
|
|
454
|
+
if (brightNodeIds) return brightNodeIds;
|
|
455
|
+
if (selectedEdgeId) {
|
|
456
|
+
const e = baseEdges.find((x) => x.id === selectedEdgeId);
|
|
457
|
+
if (e) return new Set([e.source, e.target]);
|
|
458
|
+
}
|
|
459
|
+
return null;
|
|
460
|
+
}, [baseEdges, brightNodeIds, selectedEdgeId]);
|
|
461
|
+
|
|
462
|
+
// While a file is open in the drawer, tag each node with whether its
|
|
463
|
+
// component lives in that file — the node renderer spotlights matches and
|
|
464
|
+
// dims non-matches (mirrors the edge-dimming behavior on selection).
|
|
465
|
+
// Opened-but-unselected members are dimmed; a selected step further dims
|
|
466
|
+
// the rest of its own flow. Nodes not on any opened flow are hidden.
|
|
467
|
+
// `isSelected` rides in data because the node's stopPropagation() keeps
|
|
468
|
+
// React Flow's own selection state from updating.
|
|
469
|
+
const dispNodes = useMemo(() => {
|
|
470
|
+
return xyflowNodesBase.map((n) => {
|
|
471
|
+
// Boundary frames follow their members: hidden when no member is
|
|
472
|
+
// visible, dimmed when members are dimmed. Never selectable.
|
|
473
|
+
if (n.type === 'subsystem-group') {
|
|
474
|
+
const memberIds = ((n.data as { region?: { memberIds?: string[] } } | undefined)?.region?.memberIds) ?? [];
|
|
475
|
+
const vis = flowElementVisibility({
|
|
476
|
+
inOpened: memberIds.some((id) => openedNodeIds?.has(id) === true),
|
|
477
|
+
inSelected: memberIds.some((id) => brightNodeIds?.has(id) === true),
|
|
478
|
+
anyOpened: openedNodeIds != null,
|
|
479
|
+
anySelected: brightNodeIds != null,
|
|
480
|
+
});
|
|
481
|
+
return {
|
|
482
|
+
...n,
|
|
483
|
+
hidden: vis.hidden,
|
|
484
|
+
selectable: false,
|
|
485
|
+
data: {
|
|
486
|
+
...(n.data as object),
|
|
487
|
+
...(vis.dimmed && { dimmed: true }),
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
const comp = (n.data as { component?: SubsystemComponent } | undefined)?.component;
|
|
492
|
+
const fileMatch = fileMatchForNode(comp?.file, openFile, focusNodeIds?.has(n.id) === true);
|
|
493
|
+
const isSelected = selected?.id !== undefined && comp?.id === selected.id;
|
|
494
|
+
const vis = flowElementVisibility({
|
|
495
|
+
inOpened: openedNodeIds?.has(n.id) === true,
|
|
496
|
+
inSelected: brightNodeIds?.has(n.id) === true,
|
|
497
|
+
anyOpened: openedNodeIds != null,
|
|
498
|
+
anySelected: brightNodeIds != null,
|
|
499
|
+
});
|
|
500
|
+
if (fileMatch === undefined && !isSelected && !vis.dimmed) {
|
|
501
|
+
const { fileMatch: _f, isSelected: _s, dimmed: _d, ...rest } = n.data as Record<string, unknown>;
|
|
502
|
+
return { ...n, hidden: vis.hidden, data: rest };
|
|
503
|
+
}
|
|
504
|
+
return {
|
|
505
|
+
...n,
|
|
506
|
+
hidden: vis.hidden,
|
|
507
|
+
data: {
|
|
508
|
+
...(n.data as object),
|
|
509
|
+
...(fileMatch !== undefined && { fileMatch }),
|
|
510
|
+
...(isSelected && { isSelected }),
|
|
511
|
+
...(vis.dimmed && { dimmed: true }),
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
});
|
|
515
|
+
}, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds]);
|
|
516
|
+
|
|
517
|
+
const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
|
|
518
|
+
const baseEdgesKey = useMemo(
|
|
519
|
+
() => convertedEdges.map((e) => e.id).sort().join(','),
|
|
520
|
+
[convertedEdges],
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
const dispEdges = useMemo(() => {
|
|
524
|
+
const paint = (e: Edge, dimmed: boolean): Edge => {
|
|
525
|
+
const markerEnd = e.markerEnd;
|
|
526
|
+
const nextMarker =
|
|
527
|
+
dimmed && markerEnd && typeof markerEnd === 'object' && typeof markerEnd.color === 'string'
|
|
528
|
+
? { ...markerEnd, color: hexWithAlpha(markerEnd.color, EDGE_DIM_ALPHA) }
|
|
529
|
+
: markerEnd;
|
|
530
|
+
return {
|
|
531
|
+
...e,
|
|
532
|
+
data: { ...(e.data as object), dimmed },
|
|
533
|
+
markerEnd: nextMarker,
|
|
534
|
+
};
|
|
535
|
+
};
|
|
536
|
+
if (openedEdgeIds || focusEdgeIds) {
|
|
537
|
+
return baseEdges.map((e) => {
|
|
538
|
+
const vis = flowElementVisibility({
|
|
539
|
+
inOpened: openedEdgeIds?.has(e.id) === true,
|
|
540
|
+
inSelected: focusEdgeIds?.has(e.id) === true,
|
|
541
|
+
anyOpened: openedEdgeIds != null,
|
|
542
|
+
anySelected: focusEdgeIds != null,
|
|
543
|
+
});
|
|
544
|
+
return { ...paint(e, vis.dimmed), hidden: vis.hidden };
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
if (!selectedEdgeId) return baseEdges;
|
|
548
|
+
return baseEdges.map((e) => paint(e, e.id !== selectedEdgeId));
|
|
549
|
+
}, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds]);
|
|
550
|
+
|
|
551
|
+
const onNodesChange = useCallback(
|
|
552
|
+
(changes: NodeChange[]) => {
|
|
553
|
+
// Capture dimension changes (React Flow's measurement callback).
|
|
554
|
+
// Group parents are ELK-sized — ignore their measurements.
|
|
555
|
+
const groupIds = new Set(
|
|
556
|
+
dispNodes.filter((n) => n.type === 'subsystem-group').map((n) => n.id),
|
|
557
|
+
);
|
|
558
|
+
for (const ch of changes) {
|
|
559
|
+
if (ch.type === 'dimensions' && ch.dimensions && !groupIds.has(ch.id)) {
|
|
560
|
+
measuredDimsRef.current.set(ch.id, ch.dimensions);
|
|
561
|
+
pendingMeasuredRef.current = true;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
const result = applyNodeChanges(changes, dispNodes);
|
|
565
|
+
// After applying changes, check if we should trigger pass 2.
|
|
566
|
+
if (pendingMeasuredRef.current) {
|
|
567
|
+
// Use microtask so the state update from applyNodeChanges commits first.
|
|
568
|
+
queueMicrotask(() => triggerPass2());
|
|
569
|
+
}
|
|
570
|
+
return result;
|
|
571
|
+
},
|
|
572
|
+
[dispNodes, triggerPass2],
|
|
573
|
+
);
|
|
574
|
+
const onEdgesChange = useCallback(
|
|
575
|
+
(_changes: EdgeChange[]) => dispEdges,
|
|
576
|
+
[dispEdges],
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
useEffect(() => {
|
|
580
|
+
const t = setTimeout(() => {
|
|
581
|
+
fitView({ padding: 0.1, includeHiddenNodes: false, minZoom: 0.05, maxZoom: 2, duration: 200 });
|
|
582
|
+
}, 200);
|
|
583
|
+
return () => clearTimeout(t);
|
|
584
|
+
}, [baseNodesKey, baseEdgesKey, fitView]);
|
|
585
|
+
|
|
586
|
+
// When the file drawer opens or closes the canvas is resized but the
|
|
587
|
+
// camera stays put — no refit/zoom. Spotlight + dimming already convey
|
|
588
|
+
// which nodes belong to the open file.
|
|
589
|
+
const onNodeClick: NodeMouseHandler = useCallback(
|
|
590
|
+
(_e, node: Node) => {
|
|
591
|
+
const comp = (node.data as { component?: SubsystemComponent } | undefined)?.component;
|
|
592
|
+
setSelectedEdgeId(null);
|
|
593
|
+
setFocusedThroughlineId(null);
|
|
594
|
+
setFocusedStepIndex(null);
|
|
595
|
+
if (node.type === 'subsystem-component' && comp) {
|
|
596
|
+
// Clicking the already-selected node unselects it (toggle off).
|
|
597
|
+
// Selection is independent of the file drawer — nodes never open it.
|
|
598
|
+
if (selected?.id === comp.id) {
|
|
599
|
+
setSelected(null);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
setSelected(comp);
|
|
603
|
+
if (comp.id) onSelect?.(comp.id);
|
|
604
|
+
}
|
|
605
|
+
},
|
|
606
|
+
[onSelect, selected],
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
// React Flow's edge click → the same shared selection logic as the label.
|
|
610
|
+
const onEdgeClick = useCallback(
|
|
611
|
+
(_e: ReactMouseEvent, edge: Edge) => {
|
|
612
|
+
selectEdge(edge.id);
|
|
613
|
+
},
|
|
614
|
+
[selectEdge],
|
|
615
|
+
);
|
|
616
|
+
|
|
617
|
+
const onPaneClick = useCallback(() => {
|
|
618
|
+
setSelected(null);
|
|
619
|
+
setSelectedEdgeId(null);
|
|
620
|
+
setFocusedThroughlineId(null);
|
|
621
|
+
setFocusedStepIndex(null);
|
|
622
|
+
}, []);
|
|
623
|
+
|
|
624
|
+
// Sidebar file trees — one per repo on multi-repo graphs, each under its
|
|
625
|
+
// own owner-avatar header. Clicking a header collapses that repo's tree.
|
|
626
|
+
const repoGroups = useMemo(() => buildRepoGroups(components), [components]);
|
|
627
|
+
const hasThroughlines = useMemo(() => (throughlines?.length ?? 0) > 0, [throughlines]);
|
|
628
|
+
const [collapsedRepos, setCollapsedRepos] = useState<Set<string>>(new Set());
|
|
629
|
+
const toggleRepoCollapsed = useCallback((key: string) => {
|
|
630
|
+
setCollapsedRepos((prev) => {
|
|
631
|
+
const next = new Set(prev);
|
|
632
|
+
if (next.has(key)) next.delete(key);
|
|
633
|
+
else next.add(key);
|
|
634
|
+
return next;
|
|
635
|
+
});
|
|
636
|
+
}, []);
|
|
637
|
+
const treeFiles = useMemo(
|
|
638
|
+
() => repoGroups.groups.flatMap((g) => g.entries.map((e) => e.file)),
|
|
639
|
+
[repoGroups],
|
|
640
|
+
);
|
|
641
|
+
const onTreeSelectFile = useCallback(
|
|
642
|
+
(file: string) => {
|
|
643
|
+
if (openFileRef.current?.file === file && openFileRef.current.startLine == null) {
|
|
644
|
+
setDrawerTarget(null);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
setDrawerTarget({ kind: 'file', file });
|
|
648
|
+
onFileSelect?.(file);
|
|
649
|
+
},
|
|
650
|
+
[onFileSelect],
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
const onOpenDeclarationFile = useCallback(
|
|
654
|
+
(file: string, opts?: SubsystemOpenFileOptions) => {
|
|
655
|
+
const startLine = opts?.startLine;
|
|
656
|
+
if (
|
|
657
|
+
openFileRef.current?.file === file &&
|
|
658
|
+
openFileRef.current.startLine === startLine
|
|
659
|
+
) {
|
|
660
|
+
setDrawerTarget(null);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
setDrawerTarget({ kind: 'file', file, startLine });
|
|
664
|
+
onFileSelect?.(file);
|
|
665
|
+
},
|
|
666
|
+
[onFileSelect],
|
|
667
|
+
);
|
|
668
|
+
|
|
669
|
+
// Camera helper shared by the throughline interactions: frames the focused
|
|
670
|
+
// edges' endpoint nodes via `fitView({ nodes })`, which uses the store's
|
|
671
|
+
// live positions + measured dims — so the frame always includes BOTH
|
|
672
|
+
// components the edge attaches to (and therefore the edge line between them).
|
|
673
|
+
const fitFocusBounds = useCallback(
|
|
674
|
+
(ids: ReadonlySet<string>) => {
|
|
675
|
+
const nodeIds = new Set<string>();
|
|
676
|
+
for (const e of baseEdges) {
|
|
677
|
+
if (!ids.has(e.id)) continue;
|
|
678
|
+
nodeIds.add(e.source);
|
|
679
|
+
nodeIds.add(e.target);
|
|
680
|
+
}
|
|
681
|
+
if (nodeIds.size === 0) return;
|
|
682
|
+
fitView({
|
|
683
|
+
nodes: [...nodeIds].map((id) => ({ id })),
|
|
684
|
+
padding: 0.25,
|
|
685
|
+
duration: 300,
|
|
686
|
+
});
|
|
687
|
+
},
|
|
688
|
+
[baseEdges, fitView],
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
// Focus an entire flow: hide everything but the flow's nodes and edges, and
|
|
692
|
+
// frame the flow on the canvas. Selection state is cleared — the graph now
|
|
693
|
+
// reads as the narrative. No drawer: the code view only opens on a step
|
|
694
|
+
// click. A stale throughline drawer (from a previously focused flow's step)
|
|
695
|
+
// closes; an explicitly opened file drawer stays.
|
|
696
|
+
const focusThroughlineEdges = useCallback(
|
|
697
|
+
(tl: SubsystemThroughline) => {
|
|
698
|
+
setSelected(null);
|
|
699
|
+
setSelectedEdgeId(null);
|
|
700
|
+
setFocusedStepIndex(null);
|
|
701
|
+
setFocusedThroughlineId(tl.id);
|
|
702
|
+
fitFocusBounds(new Set(tl.steps.map((s) => s.edgeId)));
|
|
703
|
+
setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
|
|
704
|
+
},
|
|
705
|
+
[fitFocusBounds],
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
const clearThroughlineFocus = useCallback(() => {
|
|
709
|
+
setFocusedThroughlineId(null);
|
|
710
|
+
setFocusedStepIndex(null);
|
|
711
|
+
setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
|
|
712
|
+
}, []);
|
|
713
|
+
|
|
714
|
+
// Focus a single step's edge on the canvas and open/scroll the throughline
|
|
715
|
+
// drawer to that step's snippet.
|
|
716
|
+
const focusThroughlineStep = useCallback(
|
|
717
|
+
(tl: SubsystemThroughline, stepIndex: number) => {
|
|
718
|
+
const step = tl.steps[stepIndex];
|
|
719
|
+
if (!step) return;
|
|
720
|
+
setSelected(null);
|
|
721
|
+
setSelectedEdgeId(null);
|
|
722
|
+
setFocusedStepIndex(stepIndex);
|
|
723
|
+
setFocusedThroughlineId(tl.id);
|
|
724
|
+
fitFocusBounds(new Set([step.edgeId]));
|
|
725
|
+
if (renderThroughlineViewer) {
|
|
726
|
+
setDrawerTarget({
|
|
727
|
+
kind: 'throughline',
|
|
728
|
+
throughlineId: tl.id,
|
|
729
|
+
stepIndex,
|
|
730
|
+
});
|
|
731
|
+
} else {
|
|
732
|
+
setDrawerTarget({
|
|
733
|
+
kind: 'file',
|
|
734
|
+
file: step.file,
|
|
735
|
+
startLine: step.line,
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
},
|
|
739
|
+
[fitFocusBounds, renderThroughlineViewer],
|
|
740
|
+
);
|
|
741
|
+
|
|
742
|
+
// Arrow keys step through the focused throughline once a step is active
|
|
743
|
+
// (sidebar click or drawer open). Ignores typing targets and chords.
|
|
744
|
+
useEffect(() => {
|
|
745
|
+
if (focusedThroughlineId == null || focusedStepIndex == null || !throughlines) {
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
const tl = throughlines.find((t) => t.id === focusedThroughlineId);
|
|
749
|
+
if (!tl || tl.steps.length === 0) return;
|
|
750
|
+
|
|
751
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
752
|
+
const el = e.target as HTMLElement | null;
|
|
753
|
+
if (
|
|
754
|
+
el &&
|
|
755
|
+
(el.tagName === 'INPUT' ||
|
|
756
|
+
el.tagName === 'TEXTAREA' ||
|
|
757
|
+
el.tagName === 'SELECT' ||
|
|
758
|
+
el.isContentEditable)
|
|
759
|
+
) {
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
763
|
+
|
|
764
|
+
let next: number | null = null;
|
|
765
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
|
|
766
|
+
next = Math.min(tl.steps.length - 1, focusedStepIndex + 1);
|
|
767
|
+
} else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
|
|
768
|
+
next = Math.max(0, focusedStepIndex - 1);
|
|
769
|
+
} else {
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (next === focusedStepIndex) return;
|
|
773
|
+
e.preventDefault();
|
|
774
|
+
focusThroughlineStep(tl, next);
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
window.addEventListener('keydown', onKeyDown);
|
|
778
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
779
|
+
}, [
|
|
780
|
+
focusedThroughlineId,
|
|
781
|
+
focusedStepIndex,
|
|
782
|
+
throughlines,
|
|
783
|
+
focusThroughlineStep,
|
|
784
|
+
]);
|
|
785
|
+
|
|
786
|
+
const toggleThroughlineCollapsed = useCallback((tlId: string) => {
|
|
787
|
+
setExpandedThroughlines((prev) => {
|
|
788
|
+
const next = new Set(prev);
|
|
789
|
+
if (next.has(tlId)) next.delete(tlId);
|
|
790
|
+
else next.add(tlId);
|
|
791
|
+
return next;
|
|
792
|
+
});
|
|
793
|
+
}, []);
|
|
794
|
+
|
|
795
|
+
// Filename-badge clicks on nodes open the drawer through the same path as
|
|
796
|
+
// the declaration panel's file link (toggle + tree sync, no start line).
|
|
797
|
+
useEffect(() => {
|
|
798
|
+
SUBSYSTEM_CALLBACKS.onOpenFile = (componentId: string) => {
|
|
799
|
+
const comp = components.find((c) => c.id === componentId);
|
|
800
|
+
if (comp?.file) onOpenDeclarationFile(comp.file);
|
|
801
|
+
};
|
|
802
|
+
return () => {
|
|
803
|
+
SUBSYSTEM_CALLBACKS.onOpenFile = undefined;
|
|
804
|
+
};
|
|
805
|
+
}, [components, onOpenDeclarationFile]);
|
|
806
|
+
|
|
807
|
+
// Detail-panel links: related-name clicks select the matching component —
|
|
808
|
+
// resolved by id, name, or symbol (call labels may carry a trailing `()`).
|
|
809
|
+
const resolveRelatedComponent = useCallback(
|
|
810
|
+
(ref: string) => {
|
|
811
|
+
const clean = ref.replace(/\(\)$/, '');
|
|
812
|
+
const comp = components.find(
|
|
813
|
+
(c) =>
|
|
814
|
+
c.id === clean ||
|
|
815
|
+
c.name === clean ||
|
|
816
|
+
c.symbol === clean ||
|
|
817
|
+
c.symbol?.replace(/\(\)$/, '') === clean,
|
|
818
|
+
);
|
|
819
|
+
if (!comp || comp.id === selectedRef.current?.id) return;
|
|
820
|
+
setSelected(comp);
|
|
821
|
+
setSelectedEdgeId(null);
|
|
822
|
+
setFocusedThroughlineId(null);
|
|
823
|
+
setFocusedStepIndex(null);
|
|
824
|
+
onSelect?.(comp.id);
|
|
825
|
+
},
|
|
826
|
+
[components, onSelect],
|
|
827
|
+
);
|
|
828
|
+
|
|
829
|
+
// Edge label data for the overlay (rendered OUTSIDE ReactFlow so the pane
|
|
830
|
+
// doesn't intercept pointer events). Uses ELK-computed label midpoints from
|
|
831
|
+
// the actual edge path (not node-center approximations).
|
|
832
|
+
const edgeLabels = useMemo(() => {
|
|
833
|
+
return dispEdges
|
|
834
|
+
.filter((e) => !e.hidden)
|
|
835
|
+
.map((e) => {
|
|
836
|
+
const d = e.data as {
|
|
837
|
+
mechanism?: string;
|
|
838
|
+
dimmed?: boolean;
|
|
839
|
+
labelX?: number;
|
|
840
|
+
labelY?: number;
|
|
841
|
+
pathLength?: number;
|
|
842
|
+
} | undefined;
|
|
843
|
+
return {
|
|
844
|
+
id: e.id,
|
|
845
|
+
mechanism: d?.mechanism ?? 'imports',
|
|
846
|
+
dimmed: d?.dimmed === true,
|
|
847
|
+
midX: d?.labelX ?? 0,
|
|
848
|
+
midY: d?.labelY ?? 0,
|
|
849
|
+
pathLength: d?.pathLength ?? 0,
|
|
850
|
+
stepNos: selectedFlowStepNos?.get(e.id),
|
|
851
|
+
};
|
|
852
|
+
});
|
|
853
|
+
}, [dispEdges, selectedFlowStepNos]);
|
|
854
|
+
|
|
855
|
+
// Unique source files across components → sidebar file trees.
|
|
856
|
+
const treeFilePaths = treeFiles;
|
|
857
|
+
|
|
858
|
+
// The hovered node's file (null when not hovering / file-less component).
|
|
859
|
+
const hoveredFile = useMemo(() => {
|
|
860
|
+
if (!hoveredComponentId) return null;
|
|
861
|
+
return components.find((c) => c.id === hoveredComponentId)?.file ?? null;
|
|
862
|
+
}, [components, hoveredComponentId]);
|
|
863
|
+
|
|
864
|
+
// Drawer content renderer: prefer the path-keyed viewer; fall back to the
|
|
865
|
+
// legacy component-keyed one via a file → first-component lookup.
|
|
866
|
+
const fileViewer = useMemo(() => {
|
|
867
|
+
if (renderFileViewer) return renderFileViewer;
|
|
868
|
+
if (renderFileView) {
|
|
869
|
+
const byFile = new Map(
|
|
870
|
+
components.filter((c) => c.file).map((c) => [c.file, c] as const),
|
|
871
|
+
);
|
|
872
|
+
return (file: string, _opts?: SubsystemOpenFileOptions) => {
|
|
873
|
+
const comp = byFile.get(file);
|
|
874
|
+
return comp ? renderFileView(comp) : null;
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
return renderFileViewer;
|
|
878
|
+
}, [renderFileViewer, renderFileView, components]);
|
|
879
|
+
|
|
880
|
+
const fileViewerRef = useRef(fileViewer);
|
|
881
|
+
fileViewerRef.current = fileViewer;
|
|
882
|
+
const renderDrawerContent = useCallback(
|
|
883
|
+
(file: string, opts?: SubsystemOpenFileOptions) =>
|
|
884
|
+
fileViewerRef.current?.(file, opts) ?? null,
|
|
885
|
+
[],
|
|
886
|
+
);
|
|
887
|
+
|
|
888
|
+
const throughlineViewerRef = useRef(renderThroughlineViewer);
|
|
889
|
+
throughlineViewerRef.current = renderThroughlineViewer;
|
|
890
|
+
const renderThroughlineDrawerContent = useCallback(
|
|
891
|
+
(ctx: ThroughlineViewerContext) =>
|
|
892
|
+
throughlineViewerRef.current?.(ctx) ?? null,
|
|
893
|
+
[],
|
|
894
|
+
);
|
|
895
|
+
|
|
896
|
+
return (
|
|
897
|
+
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }}>
|
|
898
|
+
{/* Sidebar: scrollable title/description on top, files or flows pinned below.
|
|
899
|
+
The description hides by default so files/flows get the room; the
|
|
900
|
+
title-row toggle reveals it, and the lower panel yields back to 50%. */}
|
|
901
|
+
{!hideSidebar && (title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasThroughlines) && (
|
|
902
|
+
<div
|
|
903
|
+
style={{
|
|
904
|
+
width: 340,
|
|
905
|
+
minWidth: 340,
|
|
906
|
+
borderRight: `1px solid ${theme.colors.border}`,
|
|
907
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
908
|
+
display: 'flex',
|
|
909
|
+
flexDirection: 'column',
|
|
910
|
+
overflow: 'hidden',
|
|
911
|
+
}}
|
|
912
|
+
>
|
|
913
|
+
<div
|
|
914
|
+
style={{
|
|
915
|
+
flex: showDesc ? 1 : '0 0 auto',
|
|
916
|
+
minHeight: 0,
|
|
917
|
+
overflowY: 'auto',
|
|
918
|
+
padding: '16px',
|
|
919
|
+
display: 'flex',
|
|
920
|
+
flexDirection: 'column',
|
|
921
|
+
gap: 12,
|
|
922
|
+
}}
|
|
923
|
+
>
|
|
924
|
+
{sidebarExtra}
|
|
925
|
+
{(title || description) && (
|
|
926
|
+
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
|
927
|
+
{title && (
|
|
928
|
+
<h2
|
|
929
|
+
style={{
|
|
930
|
+
margin: 0,
|
|
931
|
+
flex: 1,
|
|
932
|
+
minWidth: 0,
|
|
933
|
+
fontSize: theme.fontSizes[2],
|
|
934
|
+
fontWeight: 600,
|
|
935
|
+
color: theme.colors.text,
|
|
936
|
+
fontFamily: theme.fonts.monospace,
|
|
937
|
+
}}
|
|
938
|
+
>
|
|
939
|
+
{title}
|
|
940
|
+
</h2>
|
|
941
|
+
)}
|
|
942
|
+
{description && (
|
|
943
|
+
<button
|
|
944
|
+
type="button"
|
|
945
|
+
aria-expanded={descriptionVisible}
|
|
946
|
+
aria-label={descriptionVisible ? 'Hide description' : 'Show description'}
|
|
947
|
+
title={descriptionVisible ? 'Hide description' : 'Show description'}
|
|
948
|
+
onMouseEnter={() => setDescToggleHover(true)}
|
|
949
|
+
onMouseLeave={() => setDescToggleHover(false)}
|
|
950
|
+
onClick={() => setDescriptionVisible((v) => !v)}
|
|
951
|
+
style={{
|
|
952
|
+
display: 'inline-flex',
|
|
953
|
+
alignItems: 'center',
|
|
954
|
+
justifyContent: 'center',
|
|
955
|
+
flexShrink: 0,
|
|
956
|
+
width: 22,
|
|
957
|
+
height: 22,
|
|
958
|
+
padding: 0,
|
|
959
|
+
border: 'none',
|
|
960
|
+
borderRadius: 4,
|
|
961
|
+
background: descToggleHover ? theme.colors.border : 'transparent',
|
|
962
|
+
color: descToggleHover ? theme.colors.text : (theme.colors.textMuted ?? theme.colors.textSecondary),
|
|
963
|
+
cursor: 'pointer',
|
|
964
|
+
transition: 'background 120ms ease, color 120ms ease',
|
|
965
|
+
}}
|
|
966
|
+
>
|
|
967
|
+
{descriptionVisible ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
|
968
|
+
</button>
|
|
969
|
+
)}
|
|
970
|
+
</div>
|
|
971
|
+
)}
|
|
972
|
+
{description && descriptionVisible && (
|
|
973
|
+
<div style={{ fontSize: theme.fontSizes[0], lineHeight: 1.5 }}>
|
|
974
|
+
<IndustryMarkdownSlide
|
|
975
|
+
content={description}
|
|
976
|
+
slideIdPrefix="subsystem-desc"
|
|
977
|
+
slideIndex={0}
|
|
978
|
+
isVisible={true}
|
|
979
|
+
theme={theme}
|
|
980
|
+
disableScroll={true}
|
|
981
|
+
fontSizeScale={0.9}
|
|
982
|
+
enableKeyboardScrolling={false}
|
|
983
|
+
autoFocusOnVisible={false}
|
|
984
|
+
/>
|
|
985
|
+
</div>
|
|
986
|
+
)}
|
|
987
|
+
{sidebarAfterDescription}
|
|
988
|
+
</div>
|
|
989
|
+
{(treeFilePaths.length > 0 || hasThroughlines) && (
|
|
990
|
+
<div
|
|
991
|
+
style={{
|
|
992
|
+
...(showDesc ? { height: '50%' as const } : { flex: 1, minHeight: 0 }),
|
|
993
|
+
minHeight: 160,
|
|
994
|
+
flexShrink: 0,
|
|
995
|
+
borderTop: `1px solid ${theme.colors.border}`,
|
|
996
|
+
display: 'flex',
|
|
997
|
+
flexDirection: 'column',
|
|
998
|
+
overflow: 'hidden',
|
|
999
|
+
}}
|
|
1000
|
+
>
|
|
1001
|
+
{hasThroughlines && (
|
|
1002
|
+
<div
|
|
1003
|
+
role="tablist"
|
|
1004
|
+
aria-label="Sidebar view"
|
|
1005
|
+
style={{
|
|
1006
|
+
display: 'flex',
|
|
1007
|
+
width: '100%',
|
|
1008
|
+
flexShrink: 0,
|
|
1009
|
+
borderBottom: `1px solid ${theme.colors.border}`,
|
|
1010
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
1011
|
+
}}
|
|
1012
|
+
>
|
|
1013
|
+
{(['flows', 'files'] as const).map((view) => (
|
|
1014
|
+
<button
|
|
1015
|
+
key={view}
|
|
1016
|
+
type="button"
|
|
1017
|
+
role="tab"
|
|
1018
|
+
aria-selected={sidebarView === view}
|
|
1019
|
+
onClick={() => setSidebarView(view)}
|
|
1020
|
+
style={{
|
|
1021
|
+
flex: 1,
|
|
1022
|
+
minWidth: 0,
|
|
1023
|
+
padding: '8px 8px',
|
|
1024
|
+
border: 'none',
|
|
1025
|
+
borderRadius: 0,
|
|
1026
|
+
background: sidebarView === view ? theme.colors.background : 'transparent',
|
|
1027
|
+
color:
|
|
1028
|
+
sidebarView === view
|
|
1029
|
+
? theme.colors.text
|
|
1030
|
+
: theme.colors.textSecondary,
|
|
1031
|
+
fontSize: theme.fontSizes[1],
|
|
1032
|
+
fontFamily: theme.fonts.monospace,
|
|
1033
|
+
textTransform: 'capitalize',
|
|
1034
|
+
cursor: 'pointer',
|
|
1035
|
+
}}
|
|
1036
|
+
>
|
|
1037
|
+
{view}
|
|
1038
|
+
</button>
|
|
1039
|
+
))}
|
|
1040
|
+
</div>
|
|
1041
|
+
)}
|
|
1042
|
+
{sidebarView === 'flows' && throughlines && throughlines.length > 0 ? (
|
|
1043
|
+
<div
|
|
1044
|
+
style={{
|
|
1045
|
+
flex: 1,
|
|
1046
|
+
minHeight: 0,
|
|
1047
|
+
overflowY: 'auto',
|
|
1048
|
+
padding: '0 4px 12px',
|
|
1049
|
+
}}
|
|
1050
|
+
>
|
|
1051
|
+
{throughlines.map((tl) => (
|
|
1052
|
+
<ThroughlineFlow
|
|
1053
|
+
key={tl.id}
|
|
1054
|
+
throughline={tl}
|
|
1055
|
+
edges={edges}
|
|
1056
|
+
collapsed={!expandedThroughlines.has(tl.id)}
|
|
1057
|
+
active={focusedThroughlineId === tl.id ? { stepIndex: focusedStepIndex } : null}
|
|
1058
|
+
onToggleCollapsed={toggleThroughlineCollapsed}
|
|
1059
|
+
onFocusFlow={focusThroughlineEdges}
|
|
1060
|
+
onClearFocus={clearThroughlineFocus}
|
|
1061
|
+
onFocusStep={focusThroughlineStep}
|
|
1062
|
+
/>
|
|
1063
|
+
))}
|
|
1064
|
+
</div>
|
|
1065
|
+
) : treeFilePaths.length > 0 ? (
|
|
1066
|
+
<>
|
|
1067
|
+
{repoGroups.groups.map((group, i) => {
|
|
1068
|
+
const groupKey = group.repoKey ?? '__no-repo__';
|
|
1069
|
+
const collapsed = collapsedRepos.has(groupKey);
|
|
1070
|
+
return (
|
|
1071
|
+
<div
|
|
1072
|
+
key={groupKey}
|
|
1073
|
+
style={{
|
|
1074
|
+
flex: collapsed ? '0 0 auto' : 1,
|
|
1075
|
+
minHeight: collapsed ? 0 : undefined,
|
|
1076
|
+
display: 'flex',
|
|
1077
|
+
flexDirection: 'column',
|
|
1078
|
+
borderTop: i > 0 ? `1px solid ${theme.colors.border}` : undefined,
|
|
1079
|
+
}}
|
|
1080
|
+
>
|
|
1081
|
+
{repoGroups.multiRepo && (
|
|
1082
|
+
<RepoGroupHeader
|
|
1083
|
+
group={group}
|
|
1084
|
+
collapsed={collapsed}
|
|
1085
|
+
onToggle={() => toggleRepoCollapsed(groupKey)}
|
|
1086
|
+
/>
|
|
1087
|
+
)}
|
|
1088
|
+
{!collapsed && (
|
|
1089
|
+
<SubsystemFileTree
|
|
1090
|
+
files={group.entries.map((e) => e.displayPath)}
|
|
1091
|
+
selectedFile={selected?.file ?? openFile}
|
|
1092
|
+
hoveredFile={hoveredFile}
|
|
1093
|
+
onSelectFile={onTreeSelectFile}
|
|
1094
|
+
headerless={repoGroups.multiRepo}
|
|
1095
|
+
/>
|
|
1096
|
+
)}
|
|
1097
|
+
</div>
|
|
1098
|
+
);
|
|
1099
|
+
})}
|
|
1100
|
+
</>
|
|
1101
|
+
) : null}
|
|
1102
|
+
</div>
|
|
1103
|
+
)}
|
|
1104
|
+
</div>
|
|
1105
|
+
)}
|
|
1106
|
+
|
|
1107
|
+
{/* Graph area */}
|
|
1108
|
+
<div style={{ flex: 1, position: 'relative', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
|
1109
|
+
{/* Canvas box — the edge-label overlay and legend anchor here, so
|
|
1110
|
+
overlays shift with the canvas, not the labels. */}
|
|
1111
|
+
<div style={{ position: 'relative', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
|
1112
|
+
{/* Edge-label overlay — sits above the ReactFlow pane so clicks land. */}
|
|
1113
|
+
{showEdgeLabels !== false && (
|
|
1114
|
+
<div
|
|
1115
|
+
style={{
|
|
1116
|
+
position: 'absolute',
|
|
1117
|
+
inset: 0,
|
|
1118
|
+
overflow: 'hidden',
|
|
1119
|
+
pointerEvents: 'none',
|
|
1120
|
+
zIndex: 5,
|
|
1121
|
+
}}
|
|
1122
|
+
>
|
|
1123
|
+
{edgeLabels.map((lbl) => {
|
|
1124
|
+
const mechanism = lbl.mechanism as SubsystemEdgeMechanism;
|
|
1125
|
+
const color = MECHANISM_COLOR[mechanism] ?? '#888';
|
|
1126
|
+
const verifiable = MECHANISM_DESCRIPTIONS.find(([m]) => m === mechanism)?.[2] ?? true;
|
|
1127
|
+
const screenX = lbl.midX * viewport.zoom + viewport.x;
|
|
1128
|
+
const screenY = lbl.midY * viewport.zoom + viewport.y;
|
|
1129
|
+
const text = lbl.stepNos?.length
|
|
1130
|
+
? `${lbl.stepNos.map((n) => `${n}:`).join(' ')} ${lbl.mechanism}`
|
|
1131
|
+
: lbl.mechanism;
|
|
1132
|
+
// Labels stay readable at full size until they'd exceed a share of
|
|
1133
|
+
// the edge's screen length, then shrink with zoom.
|
|
1134
|
+
const estWidth = text.length * EDGE_LABEL_CHAR_PX + EDGE_LABEL_PAD_PX;
|
|
1135
|
+
const screenEdgeLen = lbl.pathLength * viewport.zoom;
|
|
1136
|
+
const scale =
|
|
1137
|
+
lbl.pathLength > 0 && estWidth > 0
|
|
1138
|
+
? Math.min(1, (screenEdgeLen * EDGE_LABEL_MAX_EDGE_FRACTION) / estWidth)
|
|
1139
|
+
: 1;
|
|
1140
|
+
return (
|
|
1141
|
+
<div
|
|
1142
|
+
key={lbl.id}
|
|
1143
|
+
data-edge-label={lbl.id}
|
|
1144
|
+
title={verifiable ? undefined : 'Not directly verifiable with graphify'}
|
|
1145
|
+
onClick={(e) => {
|
|
1146
|
+
e.stopPropagation();
|
|
1147
|
+
selectEdge(lbl.id);
|
|
1148
|
+
}}
|
|
1149
|
+
style={{
|
|
1150
|
+
position: 'absolute',
|
|
1151
|
+
left: screenX,
|
|
1152
|
+
top: screenY,
|
|
1153
|
+
// Center on the flow-space midpoint. Labels live in screen
|
|
1154
|
+
// space (fixed size when zoomed out), so top-left anchoring
|
|
1155
|
+
// would drift them right/down of the edge as zoom drops.
|
|
1156
|
+
transform: `translate(-50%, -50%) scale(${scale})`,
|
|
1157
|
+
transformOrigin: 'center center',
|
|
1158
|
+
display: 'flex',
|
|
1159
|
+
alignItems: 'center',
|
|
1160
|
+
fontSize: 10,
|
|
1161
|
+
lineHeight: 1,
|
|
1162
|
+
fontFamily: theme.fonts.monospace,
|
|
1163
|
+
fontWeight: 500,
|
|
1164
|
+
color,
|
|
1165
|
+
background: 'rgba(21,21,21,0.9)',
|
|
1166
|
+
border: verifiable ? `0.5px solid ${color}` : `1px dashed ${color}`,
|
|
1167
|
+
borderRadius: verifiable ? 4 : '10px 14px 12px 16px / 14px 10px 16px 12px',
|
|
1168
|
+
padding: '3px 8px',
|
|
1169
|
+
cursor: 'pointer',
|
|
1170
|
+
pointerEvents: 'auto',
|
|
1171
|
+
opacity: lbl.dimmed ? 0.15 : 1,
|
|
1172
|
+
whiteSpace: 'nowrap',
|
|
1173
|
+
}}
|
|
1174
|
+
>
|
|
1175
|
+
{text}
|
|
1176
|
+
</div>
|
|
1177
|
+
);
|
|
1178
|
+
})}
|
|
1179
|
+
</div>
|
|
1180
|
+
)}
|
|
1181
|
+
<ReactFlow
|
|
1182
|
+
key={`${baseNodesKey}-${baseEdgesKey}`}
|
|
1183
|
+
nodes={dispNodes}
|
|
1184
|
+
edges={dispEdges}
|
|
1185
|
+
nodeTypes={nodeTypes}
|
|
1186
|
+
edgeTypes={edgeTypes}
|
|
1187
|
+
minZoom={0.05}
|
|
1188
|
+
maxZoom={4}
|
|
1189
|
+
onNodeClick={onNodeClick}
|
|
1190
|
+
onEdgeClick={onEdgeClick}
|
|
1191
|
+
onNodesChange={onNodesChange}
|
|
1192
|
+
onEdgesChange={onEdgesChange}
|
|
1193
|
+
proOptions={{ hideAttribution: true }}
|
|
1194
|
+
nodesDraggable={false}
|
|
1195
|
+
elementsSelectable
|
|
1196
|
+
selectNodesOnDrag={false}
|
|
1197
|
+
nodesConnectable={false}
|
|
1198
|
+
edgesReconnectable={false}
|
|
1199
|
+
onPaneClick={onPaneClick}
|
|
1200
|
+
panOnDrag
|
|
1201
|
+
panOnScroll
|
|
1202
|
+
zoomOnScroll={false}
|
|
1203
|
+
zoomOnPinch
|
|
1204
|
+
zoomOnDoubleClick={false}
|
|
1205
|
+
style={{
|
|
1206
|
+
width: '100%',
|
|
1207
|
+
height: '100%',
|
|
1208
|
+
flex: 1,
|
|
1209
|
+
minHeight: 0,
|
|
1210
|
+
background: theme.colors.background,
|
|
1211
|
+
}}
|
|
1212
|
+
>
|
|
1213
|
+
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
|
1214
|
+
<Controls showZoom showFitView showInteractive />
|
|
1215
|
+
</ReactFlow>
|
|
1216
|
+
{/* Graph title — non-interactive chip centered at the top of the canvas
|
|
1217
|
+
(clear of the top-right declaration card). Lets graph-only embeds
|
|
1218
|
+
name the subsystem they show. */}
|
|
1219
|
+
{graphTitle && (
|
|
1220
|
+
<div
|
|
1221
|
+
style={{
|
|
1222
|
+
position: 'absolute',
|
|
1223
|
+
top: 10,
|
|
1224
|
+
left: '50%',
|
|
1225
|
+
transform: 'translateX(-50%)',
|
|
1226
|
+
zIndex: 6,
|
|
1227
|
+
maxWidth: '60%',
|
|
1228
|
+
overflow: 'hidden',
|
|
1229
|
+
textOverflow: 'ellipsis',
|
|
1230
|
+
whiteSpace: 'nowrap',
|
|
1231
|
+
padding: '6px 18px',
|
|
1232
|
+
fontSize: theme.fontSizes[3],
|
|
1233
|
+
fontWeight: 600,
|
|
1234
|
+
fontFamily: theme.fonts.monospace,
|
|
1235
|
+
color: theme.colors.text,
|
|
1236
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
1237
|
+
border: `1px solid ${theme.colors.border}`,
|
|
1238
|
+
borderRadius: 6,
|
|
1239
|
+
boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
|
|
1240
|
+
pointerEvents: 'none',
|
|
1241
|
+
}}
|
|
1242
|
+
>
|
|
1243
|
+
{graphTitle}
|
|
1244
|
+
</div>
|
|
1245
|
+
)}
|
|
1246
|
+
{/* Selected-component declaration — floating card over the canvas
|
|
1247
|
+
(top-right). The graph never moves for it. */}
|
|
1248
|
+
{selected && (
|
|
1249
|
+
<div
|
|
1250
|
+
style={{
|
|
1251
|
+
position: 'absolute',
|
|
1252
|
+
top: 10,
|
|
1253
|
+
right: 10,
|
|
1254
|
+
zIndex: 8,
|
|
1255
|
+
width: 'min(560px, 70%)',
|
|
1256
|
+
maxHeight: '46%',
|
|
1257
|
+
overflowY: 'auto',
|
|
1258
|
+
background: theme.colors.background,
|
|
1259
|
+
border: `1px solid ${theme.colors.border}`,
|
|
1260
|
+
borderRadius: 8,
|
|
1261
|
+
boxShadow: '0 8px 24px rgba(0,0,0,0.35)',
|
|
1262
|
+
}}
|
|
1263
|
+
>
|
|
1264
|
+
<ComponentDeclaration
|
|
1265
|
+
component={selected}
|
|
1266
|
+
onOpenFile={onOpenDeclarationFile}
|
|
1267
|
+
onRelatedSelect={resolveRelatedComponent}
|
|
1268
|
+
onVerify={onVerifyComponent}
|
|
1269
|
+
verification={componentVerification}
|
|
1270
|
+
/>
|
|
1271
|
+
</div>
|
|
1272
|
+
)}
|
|
1273
|
+
</div>
|
|
1274
|
+
<FileDrawer title={drawerTitle} onClose={() => setDrawerTarget(null)}>
|
|
1275
|
+
{drawerTarget?.kind === 'throughline' &&
|
|
1276
|
+
focusedThroughline &&
|
|
1277
|
+
renderThroughlineViewer ? (
|
|
1278
|
+
<ThroughlineDrawerContent
|
|
1279
|
+
render={renderThroughlineDrawerContent}
|
|
1280
|
+
throughline={focusedThroughline}
|
|
1281
|
+
stepIndex={drawerTarget.stepIndex}
|
|
1282
|
+
/>
|
|
1283
|
+
) : drawerTarget?.kind === 'file' ? (
|
|
1284
|
+
<FileDrawerContent
|
|
1285
|
+
render={renderDrawerContent}
|
|
1286
|
+
file={drawerTarget.file}
|
|
1287
|
+
startLine={drawerTarget.startLine}
|
|
1288
|
+
/>
|
|
1289
|
+
) : null}
|
|
1290
|
+
</FileDrawer>
|
|
1291
|
+
{/* Startup cover — hides measurement, layout swap, and camera settle. */}
|
|
1292
|
+
<GraphLayoutCover revealed={layoutReady} />
|
|
1293
|
+
{canvasOverlay}
|
|
1294
|
+
</div>
|
|
1295
|
+
</div>
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/** Sidebar header for one repo's tree: owner avatar + repo name + file count.
|
|
1300
|
+
* Clicking toggles the tree's visibility. */
|
|
1301
|
+
function RepoGroupHeader({
|
|
1302
|
+
group,
|
|
1303
|
+
collapsed,
|
|
1304
|
+
onToggle,
|
|
1305
|
+
}: {
|
|
1306
|
+
group: RepoGroup;
|
|
1307
|
+
collapsed: boolean;
|
|
1308
|
+
onToggle: () => void;
|
|
1309
|
+
}) {
|
|
1310
|
+
const { theme } = useTheme();
|
|
1311
|
+
const avatar = repoAvatarUrl(group.repoKey);
|
|
1312
|
+
const label = group.repo ?? 'No repo';
|
|
1313
|
+
return (
|
|
1314
|
+
<div
|
|
1315
|
+
role="button"
|
|
1316
|
+
aria-expanded={!collapsed}
|
|
1317
|
+
onClick={(e) => {
|
|
1318
|
+
e.stopPropagation();
|
|
1319
|
+
onToggle();
|
|
1320
|
+
}}
|
|
1321
|
+
title={collapsed ? 'Show files' : 'Hide files'}
|
|
1322
|
+
style={{
|
|
1323
|
+
display: 'flex',
|
|
1324
|
+
alignItems: 'center',
|
|
1325
|
+
gap: 8,
|
|
1326
|
+
padding: '10px 16px 4px',
|
|
1327
|
+
flexShrink: 0,
|
|
1328
|
+
cursor: 'pointer',
|
|
1329
|
+
userSelect: 'none',
|
|
1330
|
+
}}
|
|
1331
|
+
>
|
|
1332
|
+
{avatar ? (
|
|
1333
|
+
<img
|
|
1334
|
+
src={avatar}
|
|
1335
|
+
alt=""
|
|
1336
|
+
width={16}
|
|
1337
|
+
height={16}
|
|
1338
|
+
loading="lazy"
|
|
1339
|
+
style={{ borderRadius: 4, flexShrink: 0, background: theme.colors.border }}
|
|
1340
|
+
/>
|
|
1341
|
+
) : group.owner ? (
|
|
1342
|
+
<span
|
|
1343
|
+
style={{
|
|
1344
|
+
width: 16,
|
|
1345
|
+
height: 16,
|
|
1346
|
+
borderRadius: 4,
|
|
1347
|
+
flexShrink: 0,
|
|
1348
|
+
display: 'inline-flex',
|
|
1349
|
+
alignItems: 'center',
|
|
1350
|
+
justifyContent: 'center',
|
|
1351
|
+
fontSize: 9,
|
|
1352
|
+
fontWeight: 700,
|
|
1353
|
+
fontFamily: theme.fonts.monospace,
|
|
1354
|
+
color: theme.colors.text,
|
|
1355
|
+
background: theme.colors.border,
|
|
1356
|
+
}}
|
|
1357
|
+
>
|
|
1358
|
+
{group.owner.charAt(0).toUpperCase()}
|
|
1359
|
+
</span>
|
|
1360
|
+
) : null}
|
|
1361
|
+
<span
|
|
1362
|
+
style={{
|
|
1363
|
+
fontSize: theme.fontSizes[1],
|
|
1364
|
+
fontFamily: theme.fonts.monospace,
|
|
1365
|
+
color: theme.colors.text,
|
|
1366
|
+
fontWeight: 600,
|
|
1367
|
+
overflow: 'hidden',
|
|
1368
|
+
textOverflow: 'ellipsis',
|
|
1369
|
+
whiteSpace: 'nowrap',
|
|
1370
|
+
}}
|
|
1371
|
+
>
|
|
1372
|
+
{label}
|
|
1373
|
+
</span>
|
|
1374
|
+
</div>
|
|
1375
|
+
);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
/** One collapsible throughline in the sidebar's flows panel. Clicking the
|
|
1379
|
+
* title: closed → open + select; open and unselected → select; open and
|
|
1380
|
+
* selected → close + clear focus. The right-aligned close button collapses
|
|
1381
|
+
* without selecting. A step row focuses that step's edge. */
|
|
1382
|
+
function ThroughlineFlow({
|
|
1383
|
+
throughline,
|
|
1384
|
+
edges,
|
|
1385
|
+
collapsed,
|
|
1386
|
+
active,
|
|
1387
|
+
onToggleCollapsed,
|
|
1388
|
+
onFocusFlow,
|
|
1389
|
+
onClearFocus,
|
|
1390
|
+
onFocusStep,
|
|
1391
|
+
}: {
|
|
1392
|
+
throughline: SubsystemThroughline;
|
|
1393
|
+
edges: SubsystemComponentEdge[];
|
|
1394
|
+
collapsed: boolean;
|
|
1395
|
+
/** `{ stepIndex: null }` = whole flow focused; `{ stepIndex }` = one step. */
|
|
1396
|
+
active: { stepIndex: number | null } | null;
|
|
1397
|
+
onToggleCollapsed: (tlId: string) => void;
|
|
1398
|
+
onFocusFlow: (tl: SubsystemThroughline) => void;
|
|
1399
|
+
onClearFocus: () => void;
|
|
1400
|
+
onFocusStep: (tl: SubsystemThroughline, stepIndex: number) => void;
|
|
1401
|
+
}) {
|
|
1402
|
+
const { theme } = useTheme();
|
|
1403
|
+
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
1404
|
+
const hoverBg = theme.colors.background;
|
|
1405
|
+
const edgeById = useMemo(() => new Map(edges.map((e) => [e.id, e])), [edges]);
|
|
1406
|
+
const wholeFlowActive = active !== null && active.stepIndex === null;
|
|
1407
|
+
const [headerHover, setHeaderHover] = useState(false);
|
|
1408
|
+
const [closeHover, setCloseHover] = useState(false);
|
|
1409
|
+
const [hoveredStep, setHoveredStep] = useState<number | null>(null);
|
|
1410
|
+
const stepButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
1411
|
+
|
|
1412
|
+
// Keep DOM focus on the active step so the browser focus ring (and
|
|
1413
|
+
// subsequent arrow keys) follow arrow navigation, not the originally
|
|
1414
|
+
// clicked button.
|
|
1415
|
+
useEffect(() => {
|
|
1416
|
+
if (active?.stepIndex == null) return;
|
|
1417
|
+
stepButtonRefs.current[active.stepIndex]?.focus({ preventScroll: true });
|
|
1418
|
+
}, [active?.stepIndex]);
|
|
1419
|
+
|
|
1420
|
+
return (
|
|
1421
|
+
<div style={{ margin: '4px 0', borderRadius: 8 }}>
|
|
1422
|
+
<div
|
|
1423
|
+
onMouseEnter={() => setHeaderHover(true)}
|
|
1424
|
+
onMouseLeave={() => setHeaderHover(false)}
|
|
1425
|
+
style={{
|
|
1426
|
+
display: 'flex',
|
|
1427
|
+
alignItems: 'center',
|
|
1428
|
+
gap: 4,
|
|
1429
|
+
borderRadius: 6,
|
|
1430
|
+
background: wholeFlowActive || headerHover ? hoverBg : 'transparent',
|
|
1431
|
+
transition: 'background 120ms ease',
|
|
1432
|
+
}}
|
|
1433
|
+
>
|
|
1434
|
+
<button
|
|
1435
|
+
type="button"
|
|
1436
|
+
onClick={() => {
|
|
1437
|
+
if (collapsed) {
|
|
1438
|
+
onToggleCollapsed(throughline.id);
|
|
1439
|
+
onFocusFlow(throughline);
|
|
1440
|
+
} else if (active === null) {
|
|
1441
|
+
onFocusFlow(throughline);
|
|
1442
|
+
} else {
|
|
1443
|
+
onToggleCollapsed(throughline.id);
|
|
1444
|
+
onClearFocus();
|
|
1445
|
+
}
|
|
1446
|
+
}}
|
|
1447
|
+
style={{
|
|
1448
|
+
flex: 1,
|
|
1449
|
+
display: 'flex',
|
|
1450
|
+
alignItems: 'center',
|
|
1451
|
+
minWidth: 0,
|
|
1452
|
+
padding: '6px 8px',
|
|
1453
|
+
borderRadius: 6,
|
|
1454
|
+
border: 'none',
|
|
1455
|
+
background: 'transparent',
|
|
1456
|
+
textAlign: 'left',
|
|
1457
|
+
cursor: 'pointer',
|
|
1458
|
+
}}
|
|
1459
|
+
>
|
|
1460
|
+
<span
|
|
1461
|
+
style={{
|
|
1462
|
+
overflow: 'hidden',
|
|
1463
|
+
textOverflow: 'ellipsis',
|
|
1464
|
+
whiteSpace: 'nowrap',
|
|
1465
|
+
fontSize: theme.fontSizes[1],
|
|
1466
|
+
fontFamily: theme.fonts.monospace,
|
|
1467
|
+
fontWeight: 600,
|
|
1468
|
+
color: theme.colors.text,
|
|
1469
|
+
}}
|
|
1470
|
+
>
|
|
1471
|
+
{throughline.title}
|
|
1472
|
+
</span>
|
|
1473
|
+
</button>
|
|
1474
|
+
{!collapsed && (
|
|
1475
|
+
<button
|
|
1476
|
+
type="button"
|
|
1477
|
+
aria-label={`Close ${throughline.title}`}
|
|
1478
|
+
onMouseEnter={() => setCloseHover(true)}
|
|
1479
|
+
onMouseLeave={() => setCloseHover(false)}
|
|
1480
|
+
onClick={(e) => {
|
|
1481
|
+
e.stopPropagation();
|
|
1482
|
+
onToggleCollapsed(throughline.id);
|
|
1483
|
+
if (active !== null) onClearFocus();
|
|
1484
|
+
}}
|
|
1485
|
+
style={{
|
|
1486
|
+
display: 'inline-flex',
|
|
1487
|
+
alignItems: 'center',
|
|
1488
|
+
justifyContent: 'center',
|
|
1489
|
+
flexShrink: 0,
|
|
1490
|
+
width: 22,
|
|
1491
|
+
height: 22,
|
|
1492
|
+
marginRight: 4,
|
|
1493
|
+
padding: 0,
|
|
1494
|
+
border: 'none',
|
|
1495
|
+
borderRadius: 4,
|
|
1496
|
+
background: closeHover ? theme.colors.border : 'transparent',
|
|
1497
|
+
color: closeHover ? theme.colors.text : muted,
|
|
1498
|
+
cursor: 'pointer',
|
|
1499
|
+
transition: 'background 120ms ease, color 120ms ease',
|
|
1500
|
+
}}
|
|
1501
|
+
>
|
|
1502
|
+
<X size={12} strokeWidth={2} />
|
|
1503
|
+
</button>
|
|
1504
|
+
)}
|
|
1505
|
+
</div>
|
|
1506
|
+
{!collapsed && (
|
|
1507
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
1508
|
+
{throughline.steps.map((step, i) => {
|
|
1509
|
+
const edge = edgeById.get(step.edgeId);
|
|
1510
|
+
const mech = edge?.mechanism;
|
|
1511
|
+
const color = mech ? MECHANISM_COLOR[mech] : muted;
|
|
1512
|
+
const stepActive = active !== null && active.stepIndex === i;
|
|
1513
|
+
return (
|
|
1514
|
+
<button
|
|
1515
|
+
key={`${step.edgeId}-${i}`}
|
|
1516
|
+
ref={(el) => {
|
|
1517
|
+
stepButtonRefs.current[i] = el;
|
|
1518
|
+
}}
|
|
1519
|
+
type="button"
|
|
1520
|
+
onMouseEnter={() => setHoveredStep(i)}
|
|
1521
|
+
onMouseLeave={() => setHoveredStep(null)}
|
|
1522
|
+
onClick={() => onFocusStep(throughline, i)}
|
|
1523
|
+
style={{
|
|
1524
|
+
display: 'flex',
|
|
1525
|
+
alignItems: 'center',
|
|
1526
|
+
gap: 8,
|
|
1527
|
+
minWidth: 0,
|
|
1528
|
+
padding: '4px 8px 4px 12px',
|
|
1529
|
+
textAlign: 'left',
|
|
1530
|
+
borderRadius: 6,
|
|
1531
|
+
border: 'none',
|
|
1532
|
+
outline: 'none',
|
|
1533
|
+
background: stepActive || hoveredStep === i ? hoverBg : 'transparent',
|
|
1534
|
+
cursor: 'pointer',
|
|
1535
|
+
transition: 'background 120ms ease',
|
|
1536
|
+
}}
|
|
1537
|
+
>
|
|
1538
|
+
<span
|
|
1539
|
+
style={{
|
|
1540
|
+
flexShrink: 0,
|
|
1541
|
+
width: 14,
|
|
1542
|
+
fontSize: theme.fontSizes[0] * 0.8,
|
|
1543
|
+
fontFamily: theme.fonts.monospace,
|
|
1544
|
+
color: stepActive ? theme.colors.text : muted,
|
|
1545
|
+
}}
|
|
1546
|
+
>
|
|
1547
|
+
{i + 1}
|
|
1548
|
+
</span>
|
|
1549
|
+
{step.symbol ? (
|
|
1550
|
+
<span
|
|
1551
|
+
style={{
|
|
1552
|
+
flex: 1,
|
|
1553
|
+
minWidth: 0,
|
|
1554
|
+
overflow: 'hidden',
|
|
1555
|
+
textOverflow: 'ellipsis',
|
|
1556
|
+
whiteSpace: 'nowrap',
|
|
1557
|
+
fontSize: theme.fontSizes[0],
|
|
1558
|
+
fontFamily: theme.fonts.monospace,
|
|
1559
|
+
color: stepActive ? theme.colors.text : muted,
|
|
1560
|
+
}}
|
|
1561
|
+
>
|
|
1562
|
+
{step.symbol}
|
|
1563
|
+
</span>
|
|
1564
|
+
) : (
|
|
1565
|
+
<>
|
|
1566
|
+
<span
|
|
1567
|
+
style={{
|
|
1568
|
+
flexShrink: 0,
|
|
1569
|
+
width: 74,
|
|
1570
|
+
overflow: 'hidden',
|
|
1571
|
+
textOverflow: 'ellipsis',
|
|
1572
|
+
whiteSpace: 'nowrap',
|
|
1573
|
+
fontSize: theme.fontSizes[0],
|
|
1574
|
+
fontFamily: theme.fonts.monospace,
|
|
1575
|
+
color,
|
|
1576
|
+
}}
|
|
1577
|
+
>
|
|
1578
|
+
{mech ?? step.edgeId}
|
|
1579
|
+
</span>
|
|
1580
|
+
<span
|
|
1581
|
+
style={{
|
|
1582
|
+
flex: 1,
|
|
1583
|
+
minWidth: 0,
|
|
1584
|
+
overflow: 'hidden',
|
|
1585
|
+
textOverflow: 'ellipsis',
|
|
1586
|
+
whiteSpace: 'nowrap',
|
|
1587
|
+
fontSize: theme.fontSizes[0],
|
|
1588
|
+
fontFamily: theme.fonts.monospace,
|
|
1589
|
+
color: stepActive ? theme.colors.text : muted,
|
|
1590
|
+
}}
|
|
1591
|
+
>
|
|
1592
|
+
{step.file.split('/').pop()}
|
|
1593
|
+
<span style={{ opacity: 0.7 }}>:{step.line}</span>
|
|
1594
|
+
</span>
|
|
1595
|
+
</>
|
|
1596
|
+
)}
|
|
1597
|
+
</button>
|
|
1598
|
+
);
|
|
1599
|
+
})}
|
|
1600
|
+
</div>
|
|
1601
|
+
)}
|
|
1602
|
+
</div>
|
|
1603
|
+
);
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
|
|
1607
|
+
const wrapRef = useRef<HTMLDivElement>(null);
|
|
1608
|
+
const [size, setSize] = useState<{ w: number; h: number } | null>(null);
|
|
1609
|
+
|
|
1610
|
+
// Measure the container so React Flow always has a real, non-zero pixel size
|
|
1611
|
+
// (responsive to the parent; avoids the %→0 blank canvas).
|
|
1612
|
+
useEffect(() => {
|
|
1613
|
+
const el = wrapRef.current;
|
|
1614
|
+
if (!el) return;
|
|
1615
|
+
const update = () => {
|
|
1616
|
+
const rect = el.getBoundingClientRect();
|
|
1617
|
+
if (rect.width > 0 && rect.height > 0) setSize({ w: rect.width, h: rect.height });
|
|
1618
|
+
};
|
|
1619
|
+
update();
|
|
1620
|
+
const ro = new ResizeObserver(update);
|
|
1621
|
+
ro.observe(el);
|
|
1622
|
+
return () => ro.disconnect();
|
|
1623
|
+
}, []);
|
|
1624
|
+
|
|
1625
|
+
return (
|
|
1626
|
+
<div
|
|
1627
|
+
ref={wrapRef}
|
|
1628
|
+
style={{
|
|
1629
|
+
position: 'relative',
|
|
1630
|
+
width: '100%',
|
|
1631
|
+
height: '100%',
|
|
1632
|
+
minHeight: 0,
|
|
1633
|
+
display: 'flex',
|
|
1634
|
+
flexDirection: 'column',
|
|
1635
|
+
}}
|
|
1636
|
+
>
|
|
1637
|
+
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
|
1638
|
+
<ReactFlowProvider>
|
|
1639
|
+
<Inner {...props} measured={size} />
|
|
1640
|
+
</ReactFlowProvider>
|
|
1641
|
+
</div>
|
|
1642
|
+
</div>
|
|
1643
|
+
);
|
|
1644
|
+
}
|