Skip to content

Commit c2b7e0c

Browse files
authored
Wire up LSP data source for visual designer (Migration Step 4) (#18995)
Connects the new React-based visual designer to the textDocument/deploymentGraph LSP notification via @vscode-bicep-ui/messaging. What's included - Message contract — READY_NOTIFICATION / DEPLOYMENT_GRAPH_NOTIFICATION types in messages.ts - Data binding — useDeploymentGraph hook maps DeploymentGraph → Jotai atoms with position snapshotting for smooth transitions, empty-module demotion, and :: hierarchy parsing - Dev mode — FakeMessageChannel + DevToolbar with 5 sample graphs (including a complex VWAN topology) and 11 mutations for testing incremental updates - Layout — ELK layered auto-layout with persistent centering offset, spring animations, and useFitView - Visual polish — error borders, Material-inspired dark theme (distinct border/edge colors), collection stacking (isCollection → ghost card), edge z-index under nodes, graph control bar ###### Microsoft Reviewers: [Open in CodeFlow](https://microsoft.github.io/open-pr/?codeflow=https://github.com/Azure/bicep/pull/18995)
1 parent 0fe9e4b commit c2b7e0c

File tree

18 files changed

+1692
-185
lines changed

18 files changed

+1692
-185
lines changed
Lines changed: 144 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,43 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4+
import type { WebviewMessageChannel } from "@vscode-bicep-ui/messaging";
45
import type { ComponentType } from "react";
56
import 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";
1017
import { styled, ThemeProvider } from "styled-components";
1118
import { GraphControlBar } from "./features/design-view/components/GraphControlBar";
1219
import { ModuleDeclaration } from "./features/design-view/components/ModuleDeclaration";
1320
import { 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";
2222
import { 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";
2424
import { GlobalStyle } from "./GlobalStyle";
25+
import { useApplyDeploymentGraph } from "./hooks/useDeploymentGraph";
26+
import { DEPLOYMENT_GRAPH_NOTIFICATION, READY_NOTIFICATION } from "./messages";
2527
import { 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+
2741
const store = getDefaultStore();
2842
const 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+
}

src/vscode-bicep-ui/apps/visual-designer/src/GlobalStyle.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,18 @@ export const GlobalStyle = createGlobalStyle`
99
margin: 0;
1010
padding: 0;
1111
display: flex;
12+
flex-direction: column;
1213
overflow: hidden;
1314
font-family: var(--vscode-font-family, sans-serif);
1415
background-color: ${({ theme }) => theme.canvas.background};
1516
color: ${({ theme }) => theme.text.primary};
1617
}
1718
1819
#root {
20+
position: relative;
1921
flex: 1 1 auto;
2022
overflow: hidden;
23+
display: flex;
24+
flex-direction: column;
2125
}
2226
`;
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import { styled } from "styled-components";
5+
import { FakeMessageChannel, GRAPH_MUTATIONS, SAMPLE_GRAPHS } from "./FakeMessageChannel";
6+
7+
interface DevToolbarProps {
8+
channel: FakeMessageChannel;
9+
}
10+
11+
const $Toolbar = styled.div`
12+
flex: 0 0 auto;
13+
display: flex;
14+
flex-wrap: wrap;
15+
align-items: center;
16+
gap: 6px 8px;
17+
padding: 8px 12px;
18+
background: rgba(30, 30, 30, 0.92);
19+
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
20+
font-family: system-ui, sans-serif;
21+
font-size: 12px;
22+
color: #ccc;
23+
`;
24+
25+
const $Label = styled.span`
26+
font-weight: 600;
27+
color: #e0a030;
28+
margin-right: 4px;
29+
user-select: none;
30+
`;
31+
32+
const $Separator = styled.div`
33+
width: 1px;
34+
height: 20px;
35+
background: rgba(255, 255, 255, 0.2);
36+
margin: 0 4px;
37+
`;
38+
39+
const $SectionLabel = styled.span`
40+
font-size: 11px;
41+
color: #999;
42+
user-select: none;
43+
`;
44+
45+
const $Button = styled.button`
46+
padding: 4px 10px;
47+
border: 1px solid rgba(255, 255, 255, 0.2);
48+
border-radius: 4px;
49+
background: rgba(255, 255, 255, 0.08);
50+
color: #ddd;
51+
font-size: 12px;
52+
cursor: pointer;
53+
white-space: nowrap;
54+
55+
&:hover {
56+
background: rgba(255, 255, 255, 0.16);
57+
border-color: rgba(255, 255, 255, 0.35);
58+
}
59+
60+
&:active {
61+
background: rgba(255, 255, 255, 0.24);
62+
}
63+
`;
64+
65+
/**
66+
* A floating toolbar rendered only in dev mode (`npm run dev`).
67+
* Each button pushes a different sample graph through the
68+
* {@link FakeMessageChannel}, simulating the extension host
69+
* sending `deploymentGraph` notifications.
70+
*/
71+
export function DevToolbar({ channel }: DevToolbarProps) {
72+
const applyMutation = (
73+
apply: (graph: import("../messages").DeploymentGraph) => import("../messages").DeploymentGraph,
74+
) => {
75+
const current = channel.getCurrentGraph();
76+
if (!current) return;
77+
channel.pushGraph(apply(current));
78+
};
79+
80+
return (
81+
<$Toolbar>
82+
<$Label>DEV</$Label>
83+
<$SectionLabel>Graphs</$SectionLabel>
84+
{Object.entries(SAMPLE_GRAPHS).map(([name, graph]) => (
85+
<$Button key={name} onClick={() => channel.pushGraph(graph)}>
86+
{name}
87+
</$Button>
88+
))}
89+
<$Separator />
90+
<$SectionLabel>Mutations</$SectionLabel>
91+
{GRAPH_MUTATIONS.map((mutation) => (
92+
<$Button key={mutation.label} title={mutation.description} onClick={() => applyMutation(mutation.apply)}>
93+
{mutation.label}
94+
</$Button>
95+
))}
96+
</$Toolbar>
97+
);
98+
}

0 commit comments

Comments
 (0)