11// Copyright (c) Microsoft Corporation.
22// Licensed under the MIT License.
33
4+ import type { WebviewMessageChannel } from "@vscode-bicep-ui/messaging" ;
45import type { ComponentType } from "react" ;
56import type { NodeKind } from "./features/graph-engine/atoms" ;
7+ import type { DeploymentGraphPayload } from "./messages" ;
68
7- import { PanZoomProvider } from "@vscode-bicep-ui/components" ;
8- import { getDefaultStore , useSetAtom } from "jotai" ;
9- import { useEffect } from "react" ;
9+ import { PanZoomProvider , useGetPanZoomDimensions , usePanZoomControl } from "@vscode-bicep-ui/components" ;
10+ import {
11+ useWebviewMessageChannel ,
12+ useWebviewNotification ,
13+ WebviewMessageChannelProvider ,
14+ } from "@vscode-bicep-ui/messaging" ;
15+ import { getDefaultStore , useAtomValue } from "jotai" ;
16+ import { lazy , Suspense , useCallback , useEffect , useLayoutEffect , useRef , useState } from "react" ;
1017import { styled , ThemeProvider } from "styled-components" ;
1118import { GraphControlBar } from "./features/design-view/components/GraphControlBar" ;
1219import { ModuleDeclaration } from "./features/design-view/components/ModuleDeclaration" ;
1320import { ResourceDeclaration } from "./features/design-view/components/ResourceDeclaration" ;
14- import {
15- addAtomicNodeAtom ,
16- addCompoundNodeAtom ,
17- addEdgeAtom ,
18- edgesAtom ,
19- nodeConfigAtom ,
20- nodesAtom ,
21- } from "./features/graph-engine/atoms" ;
21+ import { graphVersionAtom , nodeConfigAtom } from "./features/graph-engine/atoms" ;
2222import { Canvas , Graph } from "./features/graph-engine/components" ;
23- import { runLayout } from "./features/graph-engine/layout/elk-layout" ;
23+ import { applyLayout , computeFitViewTransform , computeLayout } from "./features/graph-engine/layout/elk-layout" ;
2424import { GlobalStyle } from "./GlobalStyle" ;
25+ import { useApplyDeploymentGraph } from "./hooks/useDeploymentGraph" ;
26+ import { DEPLOYMENT_GRAPH_NOTIFICATION , READY_NOTIFICATION } from "./messages" ;
2527import { useTheme } from "./theming/useTheme" ;
2628
29+ const isDev = typeof acquireVsCodeApi === "undefined" ;
30+
31+ // Lazy-load dev-only modules so they are code-split into a separate
32+ // chunk and never downloaded in production (where acquireVsCodeApi exists).
33+ const LazyDevToolbar = isDev
34+ ? lazy ( ( ) => import ( "./dev/DevToolbar" ) . then ( ( m ) => ( { default : m . DevToolbar } ) ) )
35+ : undefined ;
36+
37+ const loadFakeMessageChannel = isDev
38+ ? ( ) => import ( "./dev/FakeMessageChannel" ) . then ( ( m ) => new m . FakeMessageChannel ( ) )
39+ : undefined ;
40+
2741const store = getDefaultStore ( ) ;
2842const nodeConfig = store . get ( nodeConfigAtom ) ;
2943
@@ -40,67 +54,142 @@ store.set(nodeConfigAtom, {
4054 ...nodeConfig . padding ,
4155 top : 50 ,
4256 } ,
43- getContentComponent : ( kind : NodeKind ) =>
44- ( kind === "atomic" ? ResourceDeclaration : ModuleDeclaration ) as ComponentType < { id : string ; data : unknown } > ,
57+ getContentComponent : ( kind : NodeKind , data : unknown ) => {
58+ if ( kind === "compound" ) {
59+ return ModuleDeclaration as ComponentType < { id : string ; data : unknown } > ;
60+ }
61+ // An atomic node with a "path" field is a module demoted to leaf (no children).
62+ const record = data as Record < string , unknown > | null ;
63+ if ( record && "path" in record ) {
64+ return ModuleDeclaration as ComponentType < { id : string ; data : unknown } > ;
65+ }
66+ return ResourceDeclaration as ComponentType < { id : string ; data : unknown } > ;
67+ } ,
4568} ) ;
4669
47- export function App ( ) {
48- const setNodesAtom = useSetAtom ( nodesAtom ) ;
49- const setEdgesAtom = useSetAtom ( edgesAtom ) ;
50- const addAtomicNode = useSetAtom ( addAtomicNodeAtom ) ;
51- const addCompoundNode = useSetAtom ( addCompoundNodeAtom ) ;
52- const addEdge = useSetAtom ( addEdgeAtom ) ;
70+ /**
71+ * Inner component that lives inside PanZoomProvider so it can
72+ * access both the messaging channel and the pan-zoom controls.
73+ */
74+ function GraphContainer ( ) {
75+ const applyGraph = useApplyDeploymentGraph ( ) ;
76+ const messageChannel = useWebviewMessageChannel ( ) ;
77+ const getPanZoomDimensions = useGetPanZoomDimensions ( ) ;
78+ const { transform } = usePanZoomControl ( ) ;
79+ const graphVersion = useAtomValue ( graphVersionAtom ) ;
5380
81+ // Send READY notification on mount
5482 useEffect ( ( ) => {
55- addAtomicNode (
56- "A" ,
57- { x : 200 , y : 200 } ,
58- { symbolicName : "foobar" , resourceType : "Microsoft.Compute/virtualMachines" } ,
59- ) ;
60- addAtomicNode ( "B" , { x : 500 , y : 200 } , { symbolicName : "bar" , resourceType : "Foo" } ) ;
61- addAtomicNode ( "C" , { x : 800 , y : 500 } , { symbolicName : "someRandomStorage" , resourceType : "Foo" } ) ;
62- addAtomicNode ( "D" , { x : 1200 , y : 700 } , { symbolicName : "Tricep" , resourceType : "Foo" } ) ;
63- addCompoundNode ( "E" , [ "A" , "C" ] , {
64- symbolicName : "myMod" ,
65- path : "modules/foooooooooooooooooooooooooooooooooooooobar.bicep" ,
83+ messageChannel . sendNotification ( {
84+ method : READY_NOTIFICATION ,
6685 } ) ;
86+ } , [ messageChannel ] ) ;
6787
68- addEdge ( "A->B" , "A" , "B" ) ;
69- addEdge ( "E->D" , "E" , "D" ) ;
70- addEdge ( "C->B" , "C" , "B" ) ;
88+ // Listen for deployment graph updates
89+ useWebviewNotification (
90+ DEPLOYMENT_GRAPH_NOTIFICATION ,
91+ useCallback (
92+ ( params : unknown ) => {
93+ const payload = params as DeploymentGraphPayload ;
94+ applyGraph ( payload . deploymentGraph ) ;
95+ } ,
96+ [ applyGraph ] ,
97+ ) ,
98+ ) ;
7199
72- // Wait for DOM measurement (two frames) then run auto-layout
73- const frame1 = requestAnimationFrame ( ( ) => {
74- const frame2 = requestAnimationFrame ( ( ) => {
75- void runLayout ( store ) ;
76- } ) ;
100+ // Run ELK layout after the DOM has been updated with the new graph.
101+ // useLayoutEffect fires synchronously after React commits DOM changes,
102+ // which is the reliable moment to measure and lay out.
103+ //
104+ // Guard against overlapping layouts: if a newer graph arrives while a
105+ // previous layout is still in flight, the stale layout's completion
106+ // is ignored (via the `cancelled` flag set in the cleanup function).
107+ useLayoutEffect ( ( ) => {
108+ if ( graphVersion === 0 ) {
109+ return ;
110+ }
77111
78- cleanup = ( ) => cancelAnimationFrame ( frame2 ) ;
79- } ) ;
112+ let cancelled = false ;
113+ // Always pass viewport dimensions so computeLayout can center
114+ // the graph in the viewport on every layout.
115+ const viewport = getPanZoomDimensions ( ) ;
116+ void computeLayout ( store , viewport ) . then ( async ( result ) => {
117+ if ( cancelled ) {
118+ return ;
119+ }
80120
81- let cleanup : ( ( ) => void ) | undefined ;
121+ // Compute and apply the fit-view transform immediately from the
122+ // ELK result (final positions are known), so the viewport adjusts
123+ // before the spring animations start.
124+ const { width, height } = viewport ;
125+ const { translateX, translateY, scale } = computeFitViewTransform ( result , width , height ) ;
126+ transform ( translateX , translateY , scale ) ;
127+
128+ await applyLayout ( store , result , /* animate */ true ) ;
129+ } ) ;
82130
83131 return ( ) => {
84- cancelAnimationFrame ( frame1 ) ;
85- cleanup ?.( ) ;
86- setEdgesAtom ( [ ] ) ;
87- setNodesAtom ( { } ) ;
132+ cancelled = true ;
88133 } ;
89- } , [ addCompoundNode , addAtomicNode , addEdge , setNodesAtom , setEdgesAtom ] ) ;
134+ } , [ graphVersion , getPanZoomDimensions , transform ] ) ;
90135
136+ return (
137+ < >
138+ < $ControlBarContainer >
139+ < GraphControlBar />
140+ </ $ControlBarContainer >
141+ < Canvas >
142+ < Graph />
143+ </ Canvas >
144+ </ >
145+ ) ;
146+ }
147+
148+ const $AppContainer = styled . div `
149+ flex: 1 1 auto;
150+ position: relative;
151+ overflow: hidden;
152+ ` ;
153+
154+ function VisualDesignerApp ( ) {
91155 const theme = useTheme ( ) ;
92156
93157 return (
94158 < ThemeProvider theme = { theme } >
95159 < GlobalStyle />
96- < PanZoomProvider >
97- < $ControlBarContainer >
98- < GraphControlBar />
99- </ $ControlBarContainer >
100- < Canvas >
101- < Graph />
102- </ Canvas >
103- </ PanZoomProvider >
160+ < $AppContainer >
161+ < PanZoomProvider >
162+ < GraphContainer />
163+ </ PanZoomProvider >
164+ </ $AppContainer >
104165 </ ThemeProvider >
105166 ) ;
106167}
168+
169+ export function App ( ) {
170+ const [ fakeChannel , setFakeChannel ] = useState < WebviewMessageChannel | undefined > ( undefined ) ;
171+ const fakeChannelRef = useRef < unknown > ( undefined ) ;
172+
173+ useEffect ( ( ) => {
174+ if ( ! loadFakeMessageChannel || fakeChannelRef . current ) return ;
175+ fakeChannelRef . current = true ; // prevent double-loading in StrictMode
176+ void loadFakeMessageChannel ( ) . then ( ( ch ) => {
177+ fakeChannelRef . current = ch ;
178+ setFakeChannel ( ch as unknown as WebviewMessageChannel ) ;
179+ } ) ;
180+ } , [ ] ) ;
181+
182+ // In production, render immediately. In dev, wait for the fake channel to load.
183+ if ( isDev && ! fakeChannel ) return null ;
184+
185+ return (
186+ < WebviewMessageChannelProvider messageChannel = { fakeChannel as unknown as WebviewMessageChannel } >
187+ { isDev && LazyDevToolbar && (
188+ < Suspense >
189+ < LazyDevToolbar channel = { fakeChannelRef . current as never } />
190+ </ Suspense >
191+ ) }
192+ < VisualDesignerApp />
193+ </ WebviewMessageChannelProvider >
194+ ) ;
195+ }
0 commit comments