diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index aab7aa8b797..9ff995ae492 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -35,7 +35,7 @@ dotnet build Bicep.sln # Run all tests dotnet test -# Build VS Code extension (requires Node.js 20+) +# Build VS Code extension (requires Node.js 22+) cd src/vscode-bicep && npm ci && npm run build # Build vscode-bicep-ui diff --git a/src/vscode-bicep-ui/apps/visual-designer/.github/instructions/react.instructions.md b/src/vscode-bicep-ui/apps/visual-designer/.github/instructions/react.instructions.md index 7566428b6e9..bd4c836cf98 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/.github/instructions/react.instructions.md +++ b/src/vscode-bicep-ui/apps/visual-designer/.github/instructions/react.instructions.md @@ -16,7 +16,7 @@ description: "Use when writing or modifying React components, hooks, or JSX in t - Wrap event handlers and callbacks passed to children in `useCallback`. - Use `useMemo` only for genuinely expensive computations — don't over-memoize. -- Extract shared logic into custom hooks (`use-*.ts`) co-located with the feature. +- Extract shared logic into custom hooks (`use-*.ts`) in the feature's `hooks/` folder. - Keep hooks side-effect free during render; effects belong in `useEffect`. ## Props & Types diff --git a/src/vscode-bicep-ui/apps/visual-designer/.github/instructions/state-management.instructions.md b/src/vscode-bicep-ui/apps/visual-designer/.github/instructions/state-management.instructions.md index febdf7e7cb6..4fdc310186f 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/.github/instructions/state-management.instructions.md +++ b/src/vscode-bicep-ui/apps/visual-designer/.github/instructions/state-management.instructions.md @@ -9,22 +9,25 @@ description: "Use when working with shared state, atoms, Jotai, or state managem - Use Jotai as the default for shared feature state. - Co-locate atoms with the feature they belong to. -- Export feature atoms through the feature `index.ts` barrel. +- Export only the atoms other layers need through the feature `index.ts` barrel; keep the rest internal. - Prefer small atoms over one large object atom. - Use derived atoms for view intent (e.g. `isExportCanvasCoverVisibleAtom`). -- Use action atoms (`open*`, `close*`, `reset*`) when the action touches multiple atoms. +- Use action atoms (`open*`, `close*`, `report*`, `reset*`) when the action touches multiple atoms, and + expose those rather than raw writable atoms across a feature boundary. - Use `useAtomValue` for reads and `useSetAtom` for writes to reduce accidental subscriptions. ## Project Layout -Core libraries: `src/lib/` (`graph/`, `messaging/`, `theming/`, `utils/`). -Feature slices: `src/features/` (`controls/`, `export/`, `layout/`, `status/`, `visualization/`, `devtools/`). +See the app [README](../../README.md) for module structure, dependency direction, and naming. Do not +duplicate that guidance here. -Per feature: +Atom placement follows from it: -- `feature/atoms.ts` — primary atoms, action atoms, derived atoms. -- `feature/components/*` — use atoms directly where practical. -- `feature/hooks/*` — orchestration logic that reacts to external events and writes atoms. +- `feature/atoms.ts` — primary atoms, action atoms, derived atoms. It sits at the feature root beside + `index.ts`, not inside `components/` or `hooks/`. Split into `atoms/` with an `index.ts` only once it + holds distinct state concerns. +- Components in `feature/components/` read atoms directly where practical. +- Orchestration that reacts to external events and writes atoms belongs in `feature/hooks/`. ## When NOT to Use Atoms diff --git a/src/vscode-bicep-ui/apps/visual-designer/README.md b/src/vscode-bicep-ui/apps/visual-designer/README.md new file mode 100644 index 00000000000..a03d31b3fb1 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/README.md @@ -0,0 +1,176 @@ +# Bicep Visual Designer + +The visual designer is a React webview for inspecting and editing a Bicep deployment graph. It +supports pan and zoom, source navigation, graph export, and experimental resource creation. + +Production runs inside the `vscode-bicep` extension. Development mode runs in a browser against the +fake host in `src/devtools`. + +## Development + +Use Node.js 22 or later. Install workspace dependencies from `src/vscode-bicep-ui`: + +```bash +npm ci +npm run build +``` + +Run app commands from `apps/visual-designer`: + +```bash +npm run dev +npm run build +npm run lint +npm run test +npm run e2e:install +npm run e2e +``` + +`npm run dev` loads a fake extension host. E2E tests use query parameters such as `catalogDelay` to +make loading and concurrency states deterministic. + +## Architecture + +| Area | Responsibility | +| -------------- | ------------------------------------------------------------------------- | +| `src/app` | App-wide store, host environment, synchronization, theme, and composition | +| `src/features` | Product capabilities and Bicep-specific state | +| `src/hooks` | Cross-cutting document and motion-policy synchronization | +| `src/lib` | Reusable graph and math libraries with no Bicep protocol knowledge | +| `src/ui` | Workflow-neutral components, motion tokens, and theme | +| `src/devtools` | Development-only fake host and controls | +| `src/utils` | Small shared helpers that do not belong to a library | + +Dependency direction is enforced by ESLint: + +```text +app -> features, hooks, lib, ui, utils, devtools +devtools -> features, hooks, lib, ui, utils +features -> hooks, lib, ui, utils, other feature barrels +ui -> lib, utils +hooks -> lib, utils +lib -> lib, utils +utils -> utils +``` + +Feature-to-feature imports go through the target feature's `index.ts` and must remain acyclic. + +### Source layout + +```text +src/ + app/ + App.tsx + AppEnvironment.tsx + GlobalStyle.ts + features/ + canvas/ + components/ + context/ + hooks/ + __tests__/ + api.ts + atoms.ts + graph-layout.ts + graph-model.ts + graph-update-coordinator.ts + types.ts + controls/ + export/ + palette/ + status/ + hooks/ + lib/ + graph/ + math/ + ui/ + utils/ +``` + +Feature folders contain only the surfaces they need: + +| Surface | Contents | +| ------------- | ------------------------------------------------------ | +| `components/` | React components | +| `context/` | Feature-scoped React contexts and consumer hooks | +| `hooks/` | Reusable hooks and orchestration | +| `api.ts` | Host message descriptors, payloads, and bound API hook | +| `atoms.ts` | Feature-owned Jotai state and actions | +| `types.ts` | Shared feature vocabulary | +| `__tests__/` | Unit tests for root-level feature modules | + +Components use PascalCase filenames. Hooks, non-component files, and folders use kebab-case. + +### Public boundaries + +Each feature, library, and `src/hooks` exposes one barrel: + +- Import other modules through `@/features/*`, `@/lib/*`, `@/ui`, `@/hooks`, or `@/utils`. +- Use relative imports within the same module. +- Export only symbols intended for other modules. + +### App environment and state + +`AppEnvironment` owns the Jotai store, real or fake message channel, document synchronization, motion +policy synchronization, and theme. `PanZoomProvider` remains in `App` because it belongs to the canvas +composition. + +Use Jotai for shared observable state and local React state for component-local interaction. Prefer +derived and action atoms over exposing writable atoms across feature boundaries. + +`Canvas` publishes these imperative actions through `CanvasActionsContext`: + +```ts +interface CanvasActions { + createResource(resourceType, clientPoint?): Promise; + canPlaceResourceAt(clientPoint): boolean; + resetGraphLayout(): Promise; +} +``` + +`ControlBar` and `Palette` consume them through `useCanvasActions`. + +## Canvas reconciliation + +The canvas keeps a client replica of the server graph and requests layout after React has measured +node sizes. + +| Module | Responsibility | +| ----------------------------- | ----------------------------------------------------------------------- | +| `graph-model.ts` | Client graph, patch application, measured projection, render comparison | +| `graph-layout.ts` | Layout invalidation, response extraction, and viewport centering | +| `graph-update-coordinator.ts` | Update/layout ordering, coalescing, and mutation serialization | +| `use-canvas-controller.ts` | API, model, coordinator, placement, and Jotai integration | +| `use-apply-graph.ts` | Reconcile graph nodes and edges | +| `use-apply-graph-layout.ts` | Reveal and animate server-computed positions | + +The coordinator enforces these rules: + +- Reconcile before layout. +- A reset layout takes precedence over automatic layout. +- A `graphChanged` layout response schedules reconciliation and retries the same layout mode. +- Source mutations run serially. +- An update response that overlaps a mutation is discarded and fetched again. +- Request promises settle when all currently pending work has completed. + +See [Architecture](./docs/architecture.md) for graph synchronization, layout, and resource creation. + +## Testing + +- Vitest covers atoms, graph model/layout behavior, export state, and coordinator ordering. +- Playwright covers canvas interaction, resource creation, catalog loading, search, and export. +- E2E assertions should poll animated state rather than sample positions immediately. + +Lint runs with zero warnings and rejects unused disable directives. + +## Current limitations + +- Graph update and layout responses share one `GraphPatch` union. +- Resource-creation failure UI is not covered by the fake-host E2E suite. +- Webview and extension protocol declarations are not generated from a shared schema. +- Long resource lists are not virtualized. + +## Further reading + +- [Architecture](./docs/architecture.md) +- [Project instructions](./.github/instructions/) diff --git a/src/vscode-bicep-ui/apps/visual-designer/architecture-notes.md b/src/vscode-bicep-ui/apps/visual-designer/architecture-notes.md deleted file mode 100644 index c59cfe97751..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/architecture-notes.md +++ /dev/null @@ -1,119 +0,0 @@ -# Visual Designer Architecture Notes - -This note captures the intended direction for organizing the visual designer app as it grows. It is not a rewrite plan; use it as a guide for future refactors when a touched area already needs cleanup. - -## Organizing Principle - -Keep the app split into three broad layers: - -- `app/`: top-level composition, providers, global style, and registration of graph node renderers. -- `features/`: user-facing product surfaces and workflows. -- `lib/`: app-local foundations such as graph infrastructure, protocol code, theming, and utilities. - -A file should live near the behavior it owns. Avoid moving code into shared folders only because it is visually reusable; shared folders should hold infrastructure that multiple features actually use. Introduce `ui/` only when the app has enough reusable visual primitives to justify a separate UI infrastructure layer. - -## Suggested Future Shape - -```text -src/ - app/ - App.tsx - providers.tsx - global-style.ts - node-config.ts - - features/ - canvas/ - GraphCanvas.tsx - fit-view.ts - pan-zoom.ts - nodes/ - ResourceNode.tsx - ModuleNode.tsx - node-data.ts - node-styles.ts - edges/ - StraightEdge.tsx - CurvedEdge.tsx - OrthogonalEdge.tsx - edge-shapes.ts - controls/ - ControlBar.tsx - use-reset-layout.ts - atoms.ts - export/ - status/ - devtools/ - - lib/ - graph/ - atoms/ - components/ - hooks/ - model.ts - geometry.ts - protocol/ - messages.ts - use-graph-update.ts - layout-invalidation.ts - use-visual-graph.ts - theming/ - themes.ts - use-theme.ts - utils/ - math/ - strings.ts - - ui/ # optional later - primitives/ -``` - -## Folder Roles - -### `features/nodes` - -Owns Bicep-specific graph node presentation, such as resource and module cards. These components know about symbolic names, resource types, module paths, collection state, error state, and Azure icons. They should stay out of generic `ui/` because they are semantic app surfaces, not primitives. - -### `features/edges` - -Use this if the app grows multiple edge presentations. Straight, curved, orthogonal, animated, dependency-highlighted, or error-state edges are user-facing graph visuals and fit better as a feature than as generic graph infrastructure. - -Keep route math and low-level geometry helpers in `lib/graph` or `lib/utils/math`; keep the actual rendered edge shapes and edge-specific affordances in `features/edges`. - -### `features/controls` - -Owns toolbar actions and other control-surface behavior. Hooks that exist only to support control actions, such as reset-layout single-flight guarding, belong here. - -### `features/export`, `features/status`, `features/devtools` - -Keep workflow-owned UI and state in the corresponding feature folder. Do not move export preview UI, status presentation, or dev-only graph tooling into shared `ui/` unless the code becomes a reusable primitive. - -### `lib/graph` - -Owns graph state and rendering infrastructure that should not know Bicep semantics: nodes, edges, boxes, bounds, drag state, graph atoms, graph hooks, and generic graph components. - -### `lib/protocol` - -A good future home for the server-driven visual graph protocol client. The current `lib/messaging` code is not generic messaging anymore; it owns visual graph update/layout requests, patch application, and protocol invalidation rules. - -### `lib/theming` - -Owns theme tokens, theme objects, styled-components theme typing, and theme hooks. Keep this in `lib` unless a broader `ui/` layer is introduced and theme infrastructure moves with other visual primitives. - -### `lib/utils` - -Keep this boring and generic. If a helper knows about graph state, protocol patches, Bicep declarations, or visual designer workflows, it probably belongs somewhere more specific. - -### `ui/` - -Optional. Use this only if the app intentionally introduces a UI infrastructure layer. Good candidates are reusable visual controls such as icon buttons, tooltips, dividers, and floating panels. `ui/theming` can also make sense later, but only if theming moves alongside a broader UI primitives layer. - -## Incremental Refactor Path - -Prefer small moves when touching related code: - -1. Rename `features/visualization` to `features/nodes`. -2. Introduce `features/edges` when adding a second edge shape or edge presentation mode. -3. Rename `lib/messaging` to `lib/protocol` once the visual graph protocol is the only messaging responsibility left there. -4. Keep `lib/theming` in place unless introducing a broader `ui/` layer. -5. Leave `lib/graph` in place until there is a clear split between graph state, graph rendering primitives, and app-specific node/edge visuals. diff --git a/src/vscode-bicep-ui/apps/visual-designer/docs/architecture.md b/src/vscode-bicep-ui/apps/visual-designer/docs/architecture.md new file mode 100644 index 00000000000..38ca15804f0 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/docs/architecture.md @@ -0,0 +1,254 @@ +# Visual Designer Architecture + +The visual designer is a React webview backed by the current Bicep compilation. The language server +owns graph construction and source generation; the webview owns rendering, measured layout, and user +interaction. + +## Participants + +| Participant | Responsibility | +| ----------------- | ------------------------------------------------------------------------------------------------- | +| Language server | Build the authoritative graph, compute layout, validate resource types, and generate Bicep syntax | +| VS Code extension | Bind requests to a document, forward LSP messages, apply edits, and publish settings | +| Webview | Maintain a client graph, render and measure nodes, coordinate updates, and provide interaction | + +The webview message contracts are defined by feature: + +- [Canvas API](../src/features/canvas/api.ts): graph updates, layout, source navigation, and resource creation +- [Palette API](../src/features/palette/api.ts): enablement and resource type catalog + +## Graph synchronization + +Graph reconciliation and layout are separate phases because layout uses dimensions measured after +React renders the nodes. + +```mermaid +sequenceDiagram + participant LS as Language server + participant Ext as VS Code extension + participant UI as Webview + + Ext-->>UI: documentDidChange + UI->>Ext: getGraphUpdate(current) + Ext->>LS: textDocument/visualGraphUpdate + LS-->>Ext: GraphPatch[] + Ext-->>UI: GraphPatch[] + UI->>UI: Update client graph + + opt layout required + UI->>UI: Render and measure nodes + UI->>Ext: getGraphLayout(measured graph) + Ext->>LS: textDocument/visualGraphLayout + alt graph still matches + LS-->>Ext: ok + layout patches + Ext-->>UI: ok + layout patches + UI->>UI: Center and apply positions + else graph changed + LS-->>Ext: graphChanged + Ext-->>UI: graphChanged + UI->>UI: Reconcile and retry layout + else layout failed + LS-->>Ext: layoutFailed + Ext-->>UI: layoutFailed + UI->>UI: Keep current positions + end + end +``` + +### Update contract + +`getGraphUpdate` submits the graph currently rendered by the webview, or `null` on first load. The +response is an ordered `GraphPatch[]` that transforms the submitted graph into the latest server +graph. + +### Layout contract + +`getGraphLayout` submits `RenderedGraph`, which contains topology, render-relevant metadata, and +measured node dimensions. Positions are not sent to the server. + +The response status controls the next step: + +| Status | Client action | +| -------------- | ---------------------------------------------- | +| `ok` | Apply node positions and optional graph bounds | +| `graphChanged` | Reconcile and retry the same layout mode | +| `layoutFailed` | Reveal the graph at its current positions | + +Both update and layout responses currently use this patch set: + +```text +clearGraph +addNode / removeNode / updateNode +addEdge / removeEdge +setNodeLayout +setGraphBounds +setErrorCount +``` + +Source locations are resolved on demand through `revealNodeSource` and are not stored in graph +metadata. + +### Layout invalidation + +Layout may be stale after: + +- Graph clear +- Node or edge addition/removal +- Changes to node `type`, `isCollection`, or `hasChildren` + +A correlated resource node with an explicit placement does not invalidate layout by itself. Changes +limited to `hasError`, error count, positions, or graph bounds do not invalidate layout. + +After an invalidating patch, the webview renders and measures the graph. It requests layout only when +topology or dimensions differ from the last successful layout input. + +- Automatic layout may skip unchanged input and fits the viewport after success. +- **Reset Graph Layout** bypasses the unchanged-input check and preserves the viewport. + +### Client implementation + +| Module | Responsibility | +| ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| [graph-model.ts](../src/features/canvas/graph-model.ts) | Client graph, patch application, measured projection, and render comparison | +| [graph-layout.ts](../src/features/canvas/graph-layout.ts) | Layout invalidation, response extraction, and centering | +| [graph-update-coordinator.ts](../src/features/canvas/graph-update-coordinator.ts) | Update/layout ordering, coalescing, and mutation serialization | +| [use-canvas-controller.ts](../src/features/canvas/hooks/use-canvas-controller.ts) | API, model, placement, and Jotai integration | +| [use-apply-graph.ts](../src/features/canvas/hooks/use-apply-graph.ts) | Node and edge reconciliation | +| [use-apply-graph-layout.ts](../src/features/canvas/hooks/use-apply-graph-layout.ts) | Graph reveal and position animation | + +The coordinator tracks pending update and layout work independently: + +- Reconciliation runs before layout. +- Reset layout takes precedence over automatic layout. +- Repeated update notifications coalesce. +- `graphChanged` schedules reconciliation and retries the same layout mode. +- Request promises settle after all currently pending work completes. + +The language server is stateless between requests. It rebuilds the authoritative graph from the live +compilation and validates measured layout input before computing positions. + +## Resource creation + +Resource creation is enabled with: + +```json +"bicep.visualizer.experimental.enableResourceCreation": true +``` + +It creates top-level Azure resources from the Resource Palette. The Bicep source file remains the +only durable source of truth. + +### Catalog and placement + +- Opening the palette loads provider namespaces and resource counts. +- Expanding a provider loads and caches its resource types. +- Search loads the complete searchable catalog once and filters it locally. +- Catalog responses carry a `catalogId`; stale responses are discarded. +- Pointer drops are accepted only over the canvas DOM subtree. +- Keyboard activation places a resource at the viewport center. + +Accepted client coordinates are converted using the canvas bounds and pan/zoom transform: + +```text +graphX = (clientX - canvasLeft - panX) / zoom +graphY = (clientY - canvasTop - panY) / zoom +``` + +### Creation flow + +```mermaid +sequenceDiagram + actor User + participant Canvas + participant Coordinator + participant Ext as VS Code extension + participant LS as Language server + participant Doc as Bicep document + + User->>Canvas: Place resource type + Canvas->>Canvas: Add pending card + Canvas->>Coordinator: Queue mutation + Coordinator->>Ext: resources/create + Ext->>LS: prepareVisualResource(version, type) + LS-->>Ext: Versioned WorkspaceEdit + expectedNodeId + Ext->>Doc: Verify version and apply edit + Ext-->>Coordinator: expectedNodeId + Coordinator->>Coordinator: Bind node ID to graph position + Coordinator->>Ext: getGraphUpdate + Ext->>LS: visualGraphUpdate + LS-->>Ext: addNode(expectedNodeId) + Ext-->>Coordinator: addNode(expectedNodeId) + Coordinator->>Canvas: Mount node and remove pending card +``` + +### Source generation + +The language server validates the exact resource type and API version, then generates: + +- A deterministic symbolic name with a numeric suffix when needed +- Compiler-known string literals for required properties +- Formatted Bicep syntax in a versioned `WorkspaceEdit` + +Properties without deterministic values are reported through `unresolvedRequiredProperties` and are +left to compiler diagnostics. + +The extension verifies the document version immediately before applying the edit. The edit uses +native dirty-file and undo/redo behavior and does not save the document. + +### Mutation interlock + +Resource creation uses the same graph coordinator: + +- Creation mutations run one at a time. +- A graph response that overlaps a mutation is discarded. +- The create response binds `expectedNodeId` to the requested graph position. +- Reconciliation places the matching node at that position. +- Failed mutations still trigger normal graph reconciliation. + +The explicitly placed node does not trigger automatic layout by itself. Unrelated topology changes +still request layout, and Reset Graph Layout may move the node later. + +Placement and pending state last for the visualizer session only. + +## State ownership + +| Area | State | +| --------------- | ------------------------------------------------------------------------------- | +| Canvas | Client graph, pending resources, placement correlation, and update coordination | +| Palette | Enablement, catalog, search, drag state, and preview | +| Export | Export options, preview visibility, target element, and progress | +| Status | User-facing graph status | +| App environment | Jotai store, message channel, document sync, motion policy, and theme | + +Jotai stores shared observable state. The canvas controller owns its client graph, mutation queue, and +expected-node placement map. Canvas actions are exposed through `useCanvasActions`. + +## Errors and limitations + +| Case | Behavior | +| ------------------------------------------- | ------------------------------------------------------ | +| Layout computation fails | Keep current positions and reveal the graph | +| Catalog request fails | Show retry UI | +| Drop is outside the canvas | Cancel without changing source | +| Resource type or API version is unavailable | Remove pending state and show an error | +| Document version changed | Reject the edit | +| Workspace edit rejected | Remove pending state and show an error | +| Required values are unresolved | Apply the declaration and rely on compiler diagnostics | + +Current limitations: + +- Update and layout responses share one `GraphPatch` union. +- Resource-creation failure UI is not covered by the fake-host E2E suite. +- Webview and extension protocol declarations are not generated from one schema. +- There is no pending-operation timeout. +- Resource lists are not virtualized. +- Manual layout is not persisted. + +## Validation + +- Language-server tests cover graph diffing, layout, catalog behavior, naming, source generation, and + insertion. +- Extension tests cover forwarding, settings, document version checks, and edit application. +- Vitest covers webview atoms, graph model/layout behavior, export state, and coordinator ordering. +- Playwright covers graph interaction, export, palette behavior, loading, search, pointer placement, + drop rejection, and keyboard creation. diff --git a/src/vscode-bicep-ui/apps/visual-designer/e2e/controls.spec.ts b/src/vscode-bicep-ui/apps/visual-designer/e2e/controls.spec.ts index e2b9f489d17..a0184eabbe0 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/e2e/controls.spec.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/e2e/controls.spec.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { expect, test } from "@playwright/test"; -import { getGraphTransform, loadSampleGraph, openVisualDesigner } from "./fixtures"; +import { getGraphTransform, loadSampleGraph, openVisualDesigner, waitForStableNodePosition } from "./fixtures"; test.describe("Status bar", () => { test.beforeEach(async ({ page }) => { @@ -88,6 +88,44 @@ test.describe("Control bar", () => { await page.getByTestId("control-fit-view").click(); await expect.poll(async () => await getGraphTransform(page), { timeout: 5_000 }).not.toBe(zoomed); }); + + test("reset layout returns a dragged node to its laid-out position", async ({ page }) => { + await loadSampleGraph(page, "flat"); + + // Nodes spring into place after layout; measuring or dragging before that settles races it. + const node = page.locator('[data-node-id="subnet"]'); + const laidOut = await waitForStableNodePosition(page, "subnet"); + const size = await node.boundingBox(); + + const from = { x: laidOut.x + size!.width / 2, y: laidOut.y + size!.height / 2 }; + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + for (let step = 1; step <= 10; step++) { + await page.mouse.move(from.x + step * 14, from.y + step * 11); + // d3-drag tracks movement per event; dispatched back to back they can coalesce, so give each + // move its own frame. + await page.waitForTimeout(16); + } + await page.mouse.up(); + + await expect + .poll(async () => { + const box = await node.boundingBox(); + return !!box && Math.abs(box.x - laidOut.x) > 100; + }) + .toBe(true); + + // Layout is derived from topology and measured sizes only -- the client never sends positions + // back -- so a reset is deterministic and restores the original coordinates exactly. + await page.getByTestId("control-reset-layout").click(); + + await expect + .poll(async () => { + const box = await node.boundingBox(); + return !!box && Math.abs(box.x - laidOut.x) <= 1 && Math.abs(box.y - laidOut.y) <= 1; + }) + .toBe(true); + }); }); test.describe("Export overlay", () => { diff --git a/src/vscode-bicep-ui/apps/visual-designer/e2e/fixtures.ts b/src/vscode-bicep-ui/apps/visual-designer/e2e/fixtures.ts index 641a74aee8a..68b093b7896 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/e2e/fixtures.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/e2e/fixtures.ts @@ -23,9 +23,14 @@ export type SampleGraphKey = keyof typeof SAMPLE_GRAPHS; * Navigate to the visual designer and wait for the React app to mount * and the initial sample graph (the dev fake channel pushes the * "Module graph" 50 ms after the READY notification) to render. + * + * `query` is appended to the URL to drive the dev fake channel, for example + * `{ catalogDelay: "3000" }` to hold resource-catalog loading states open. */ -export async function openVisualDesigner(page: Page): Promise { - await page.goto("/"); +export async function openVisualDesigner(page: Page, query: Record = {}): Promise { + const search = new URLSearchParams(query).toString(); + + await page.goto(search ? `/?${search}` : "/"); await expect(page.getByTestId("app-root")).toBeVisible(); await expect(page.getByTestId("graph-canvas")).toBeVisible(); await expect(page.getByTestId("dev-toolbar")).toBeVisible(); @@ -68,3 +73,33 @@ export async function getGraphTransform(page: Page): Promise { return layer.style.transform || getComputedStyle(layer).transform; }); } + +/** + * Wait until a node has stopped moving. + * + * Two animations run after a graph loads: the fit-view transform, and each node's ~0.6s spring to its + * laid-out position. They are independent, so a settled transform does not mean settled nodes — and a + * node measured or dragged mid-spring keeps travelling to its target afterwards. + */ +export async function waitForStableNodePosition(page: Page, nodeId: string): Promise<{ x: number; y: number }> { + const node = page.locator(`[data-node-id="${nodeId}"]`); + let previous: string | null = null; + + await expect + .poll( + async () => { + const box = await node.boundingBox(); + const current = box ? `${Math.round(box.x)},${Math.round(box.y)}` : null; + const stable = current !== null && current === previous; + previous = current; + + return stable; + }, + { timeout: 10_000 }, + ) + .toBe(true); + + const settled = await node.boundingBox(); + + return { x: settled!.x, y: settled!.y }; +} diff --git a/src/vscode-bicep-ui/apps/visual-designer/e2e/node-interactions.spec.ts b/src/vscode-bicep-ui/apps/visual-designer/e2e/node-interactions.spec.ts index f43ae913137..e3e94e33f8e 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/e2e/node-interactions.spec.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/e2e/node-interactions.spec.ts @@ -51,7 +51,7 @@ test.describe("Node interactions", () => { expect(elevated).toBeGreaterThan(1); }); - test("double-clicking a node sends a reveal-node-source notification", async ({ page }) => { + test("double-clicking a node reveals its source without bubbling to pan-zoom", async ({ page }) => { // The FakeMessageChannel logs reveal notifications to the console; // sniff that channel as a proxy for the outgoing message. const reveals: string[] = []; @@ -61,10 +61,22 @@ test.describe("Node interactions", () => { } }); + const canvas = page.getByTestId("graph-canvas"); + await canvas.evaluate((element) => { + element.addEventListener( + "dblclick", + () => { + element.setAttribute("data-node-double-click-bubbled", "true"); + }, + { once: true }, + ); + }); + await page.locator('[data-node-id="nsg"]').dblclick(); await expect.poll(() => reveals.length, { timeout: 5_000 }).toBeGreaterThan(0); expect(reveals[0]).toContain("nsg"); + await expect(canvas).not.toHaveAttribute("data-node-double-click-bubbled"); }); test("a graph swap survives multiple updates without losing nodes", async ({ page }) => { diff --git a/src/vscode-bicep-ui/apps/visual-designer/e2e/resource-creation.spec.ts b/src/vscode-bicep-ui/apps/visual-designer/e2e/resource-creation.spec.ts index 6280b8c59d0..0acfd43b065 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/e2e/resource-creation.spec.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/e2e/resource-creation.spec.ts @@ -72,26 +72,42 @@ test.describe("resource creation", () => { expect(canvasAfter).toEqual(canvasBefore); }); + test("animates the palette progress bar while the resource catalog loads", async ({ page }) => { + // Hold the catalog response open so the loading state can be asserted deterministically rather + // than racing the dev channel's default delay. + await openVisualDesigner(page, { catalogDelay: "5000" }); + await page.getByRole("button", { name: "Add Resources" }).click(); + await page.getByRole("textbox", { name: "Filter resource types" }).fill("storageAccounts"); + + const progress = page.getByTestId("resource-palette-progress"); + await expect(progress).toBeVisible(); + + // Read through the shadow root in one round trip, reporting a sentinel rather than throwing if + // the indicator is missing: a detached element yields an empty computed style, which would + // otherwise pass an "is not none" check by accident. + const readAnimationName = () => + progress.evaluate((element) => { + const indicator = element.shadowRoot?.querySelector(".indicator"); + + return indicator ? getComputedStyle(indicator).animationName : "indicator-missing"; + }); + + // Computed styles resolve a tick after the indicator attaches, so poll for a settled value + // rather than sampling once and racing that. + await expect.poll(readAnimationName).not.toBe(""); + + const progressAnimation = await readAnimationName(); + + expect(progressAnimation).not.toBe("indicator-missing"); + expect(progressAnimation).not.toBe("none"); + }); + test("searches all resource namespaces without expanding them first", async ({ page }) => { await openVisualDesigner(page); await page.getByRole("button", { name: "Add Resources" }).click(); const filter = page.getByRole("textbox", { name: "Filter resource types" }); await filter.fill("storageAccounts"); - const progress = page.getByTestId("resource-palette-progress"); - await expect(progress).toBeVisible(); - const progressAnimationName = await progress.evaluate( - (element) => getComputedStyle(element.shadowRoot!.querySelector(".indicator")!).animationName, - ); - expect(progressAnimationName).not.toBe("none"); - const initialProgressLeft = await progress.evaluate( - (element) => element.shadowRoot!.querySelector(".indicator")!.getBoundingClientRect().left, - ); - await page.waitForTimeout(120); - const nextProgressLeft = await progress.evaluate( - (element) => element.shadowRoot!.querySelector(".indicator")!.getBoundingClientRect().left, - ); - expect(Math.abs(nextProgressLeft - initialProgressLeft)).toBeGreaterThan(1); await expect(page.getByRole("button", { name: /storageAccounts/ })).toBeVisible(); await expect(page.locator("mark").filter({ hasText: "storageAccounts" })).toBeVisible(); await expect(page.getByRole("button", { name: /Microsoft\.Storage/ })).toHaveAttribute("aria-expanded", "true"); @@ -137,9 +153,26 @@ test.describe("resource creation", () => { await resourceButton.press("Enter"); await expect(page.getByTestId("graph-node")).toHaveCount(initialCount + 1); - const createdBox = await page.locator('[data-node-id="storageAccount"]').boundingBox(); - expect(createdBox).not.toBeNull(); - expect(createdBox!.x + createdBox!.width / 2).toBeCloseTo(canvasBox!.x + canvasBox!.width / 2, 0); - expect(createdBox!.y + createdBox!.height / 2).toBeCloseTo(canvasBox!.y + canvasBox!.height / 2, 0); + + // The node animates in and the graph springs to its layout over ~0.6s, so poll for the settled + // centre rather than sampling once. Under parallel load a single read lands mid-animation. + const createdNode = page.locator('[data-node-id="storageAccount"]'); + const canvasCentreX = canvasBox!.x + canvasBox!.width / 2; + const canvasCentreY = canvasBox!.y + canvasBox!.height / 2; + + await expect + .poll(async () => { + const box = await createdNode.boundingBox(); + + if (!box) { + return null; + } + + const offsetX = Math.abs(box.x + box.width / 2 - canvasCentreX); + const offsetY = Math.abs(box.y + box.height / 2 - canvasCentreY); + + return Math.max(offsetX, offsetY) <= 1; + }) + .toBe(true); }); }); diff --git a/src/vscode-bicep-ui/apps/visual-designer/eslint.config.mjs b/src/vscode-bicep-ui/apps/visual-designer/eslint.config.mjs new file mode 100644 index 00000000000..9a37c8a6806 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/eslint.config.mjs @@ -0,0 +1,92 @@ +import sharedConfig from "../../eslint.config.mjs"; + +// Layer boundaries for this app. See README.md: +// app -> features, ui, hooks, lib, utils, devtools | devtools -> features, ui, hooks, lib, utils +// features -> ui, hooks, lib, utils | ui -> lib, utils | hooks -> lib, utils +// lib -> lib, utils | utils -> utils +// Structure rules that are not machine-checked decay. +// +// devtools is not a feature: it impersonates the extension host, which is why it is the one module +// allowed to reach into every feature's api.ts. Keeping it a sibling of app makes that privilege +// explicit, so its cross-feature imports are distinguishable from features importing each other. +// +// ALIAS_ONLY_LAYERS exists because "hooks" and "utils" are also folder names inside most features. +// Matching `**/hooks/**` would flag every feature's own `../hooks/use-x` import, so those two layers +// are matched through the `@/` alias only -- which is how cross-layer imports are written anyway. +const LAYERS = [ + { + layer: "utils", + forbids: ["features", "ui", "app", "devtools", "hooks", "lib"], + }, + { + layer: "lib", + forbids: ["features", "ui", "app", "devtools", "hooks"], + }, + { + layer: "hooks", + forbids: ["features", "ui", "app", "devtools"], + }, + { + layer: "ui", + forbids: ["features", "app", "devtools", "hooks"], + }, + { + layer: "features", + forbids: ["app", "devtools"], + }, +]; + +const ALIAS_ONLY_LAYERS = new Set(["hooks", "utils"]); + +const layerPatterns = (layer, forbids) => + forbids.map((forbidden) => ({ + group: ALIAS_ONLY_LAYERS.has(forbidden) + ? [`@/${forbidden}`, `@/${forbidden}/**`] + : [`@/${forbidden}`, `@/${forbidden}/**`, `**/${forbidden}`, `**/${forbidden}/**`], + message: `"${layer}" must not import from "${forbidden}". See apps/visual-designer/README.md.`, + })); + +const layerBoundaries = LAYERS.map(({ layer, forbids }) => ({ + files: [`src/${layer}/**/*.{ts,tsx}`], + rules: { + "no-restricted-imports": ["error", { patterns: layerPatterns(layer, forbids) }], + }, +})); + +// lib/graph is a Bicep-agnostic rendering engine, so it must not know the host protocol. The layer +// rule above cannot catch this on its own, because lib/graph -> a messaging module is a legal +// lib -> lib edge. Bicep behaviour reaches the engine through nodeConfigAtom instead. +const graphEngineBoundary = { + files: ["src/lib/graph/**/*.{ts,tsx}"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: [ + "@/features", + "@/features/**", + "@/ui", + "@/ui/**", + "@/app", + "@/app/**", + "@/devtools", + "@/devtools/**", + "@/hooks", + "@/hooks/**", + ], + message: '"lib" must not import from a higher layer. See apps/visual-designer/README.md.', + }, + { + group: ["@vscode-bicep-ui/messaging"], + message: + "lib/graph is a Bicep-agnostic engine and must not know the host protocol. Inject the behaviour through nodeConfigAtom instead.", + }, + ], + }, + ], + }, +}; + +export default [...sharedConfig, ...layerBoundaries, graphEngineBoundary]; diff --git a/src/vscode-bicep-ui/apps/visual-designer/package.json b/src/vscode-bicep-ui/apps/visual-designer/package.json index 4a7822012e6..f684db54ef9 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/package.json +++ b/src/vscode-bicep-ui/apps/visual-designer/package.json @@ -23,6 +23,7 @@ "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^6.0.5", "@vscode-elements/webview-playground": "^1.1.3", + "happy-dom": "^20.11.6", "vite": "^8.2.1", "vitest": "^4.1.10" }, diff --git a/src/vscode-bicep-ui/apps/visual-designer/resource-creation-design.md b/src/vscode-bicep-ui/apps/visual-designer/resource-creation-design.md deleted file mode 100644 index e070939dd1a..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/resource-creation-design.md +++ /dev/null @@ -1,635 +0,0 @@ -# Visual Designer Resource Creation - -## Status - -- State: Implemented behind a VS Code experimental setting -- Scope: Create a top-level Azure resource by dragging or keyboard-activating a resource type from the Resource Palette -- Source of truth: The Bicep source file -- Related documents: - - [Visual Graph Protocol](visual-graph-protocol.md) - - [Visual Designer Architecture Notes](architecture-notes.md) - -## Enablement - -Resource creation is disabled by default. Enable it in VS Code settings: - -```json -"bicep.visualizer.experimental.enableResourceCreation": true -``` - -When disabled, the visual designer does not render the **Add Resources** launcher. Changing the setting updates open visualizer panels without requiring a reload. This is a VS Code extension setting, not a `bicepconfig.json` compiler feature. - -## User Experience - -The visual designer contains a collapsible floating **Resource Palette** over the full canvas: - -- The collapsed launcher uses the library icon and the same control primitives as the visualizer toolbar. -- Opening and closing the palette does not resize the canvas. -- The search box and progress bar remain sticky while resource groups scroll. -- Provider groups use the shared accessible Accordion. -- Scrollbars use VS Code scrollbar theme tokens. -- API versions are shown in palette rows but omitted from drag and pending previews. - -### Lazy browsing - -Opening the palette loads only Azure resource-provider namespaces and their resource-type counts. Expanding a provider: - -1. Rotates the disclosure caret immediately. -2. Starts a namespace-specific catalog request if that namespace is not cached. -3. Shows the shared progress bar while the request is active. -4. Fades and slides the complete list into view after it loads. - -Collapsing and reopening a loaded provider does not reload it. - -### Global search - -Search remains global even though normal browsing is lazy: - -1. Input is debounced by 250 ms. -2. The first non-empty search requests the complete searchable catalog. -3. The language server materializes any remaining namespace projections once. -4. The webview caches the returned catalog. -5. Subsequent searches filter the webview cache and do not call the extension or language server. - -A request-generation guard prevents an older initial search response from replacing a newer query. Matching text is highlighted in provider headers and resource type names. Search expansion is controlled separately from browsing expansion, so clearing the query restores the user's previous browsing state. - -### Drag and keyboard creation - -Pointer-down on a resource row starts a local Pointer Events drag: - -- The preview appears immediately and stays centered under the pointer. -- Pointer capture keeps the interaction in one webview. -- Escape cancels the drag. -- The preview subscribes directly to pan/zoom state so its size remains aligned with the transformed canvas. -- Pointer-up is accepted only when the topmost DOM element under the pointer belongs to the canvas subtree. - -The final hit-test is important because the floating palette overlays the canvas rectangle. Dropping over the palette or another non-canvas overlay cancels creation even though the coordinates are geometrically inside the canvas bounds. - -Pressing Enter or Space on a resource row creates it at the current viewport center. - -### Creation feedback - -After an accepted drop: - -1. The viewport point is converted to graph coordinates. -2. An opaque compact pending card appears at that center. -3. The source mutation is queued. -4. The extension applies the language-server-generated edit. -5. A document-change notification triggers graph reconciliation. -6. The canonical resource node mounts at the stored center. -7. The pending card is removed. -8. The canonical card and icon grow from preview dimensions to normal node dimensions. - -Preview and pending cards are intentionally identical. There is no opacity, border, API-version, or spinner difference because the pending period is usually short and those changes produced visible flashing. - -Creation failures remove the pending card and show a dismissible error surface. Required properties that cannot be generated are left to normal compiler diagnostics; the current UI does not show a separate unresolved-properties notification. - -## Detailed Interaction Sequences - -### Pointer drag and drop acceptance - -```mermaid -sequenceDiagram - actor User - participant Row as Resource row - participant Drag as usePaletteDrag - participant Overlay as PaletteDragOverlay - participant DOM as Browser DOM - participant Canvas as Canvas - participant Coordinator as createResource - participant Pending as pendingResourcesAtom - - User->>Row: Pointer down - Row->>Drag: startDrag(type, pointer event) - Drag->>Drag: Reject non-primary button - Drag->>Row: setPointerCapture(pointerId) - Drag->>Overlay: Set paletteDragAtom(type, clientX, clientY) - Overlay-->>User: Render cursor-centered preview - - loop Pointer movement - User->>Drag: pointermove - Drag->>Overlay: Update clientX/clientY - Overlay-->>User: Move preview without rerendering graph - end - - alt Escape or pointercancel - User->>Drag: Cancel - Drag->>Overlay: Clear paletteDragAtom - Overlay-->>User: Remove preview - else Pointer up - User->>Drag: pointerup - Drag->>DOM: elementFromPoint(clientX, clientY) - Drag->>Canvas: Read canvas bounds - - alt Topmost element is outside canvas subtree - Drag->>Overlay: Clear paletteDragAtom - Note over Drag,Canvas: Palette and other overlays are rejected even though
their coordinates overlap the canvas rectangle. - else Topmost element belongs to canvas subtree - Drag->>Canvas: Convert viewport point through pan/zoom transform - Canvas->>Coordinator: createResource(type, graph origin) - Coordinator->>Pending: Add compact pending resource immediately - Drag->>Overlay: Clear paletteDragAtom - end - end -``` - -Pointer capture is used for interaction continuity, but it is not used as proof that the final pointer location is a valid target. The final DOM hit-test decides whether the drop is accepted. - -### Successful source commit and pending-node reconciliation - -```mermaid -sequenceDiagram - participant UI as Resource creation coordinator - participant Pending as Pending resource layer - participant Queue as Mutation queue - participant Ext as VS Code extension - participant LS as Language server - participant Doc as Bicep document - participant Update as Graph update loop - participant Graph as Canonical graph UI - - UI->>Pending: Add pending(operationId, type, origin) - UI->>Queue: Enqueue mutation - Queue->>Queue: mutationInFlight = true - Queue->>Ext: resources/create(version=1, operationId, type) - Ext->>Doc: Open document and capture version N - Ext->>LS: prepareVisualResource(document version N, operationId, type) - LS->>LS: Validate exact type/API version - LS->>LS: Generate symbolic name, body and syntax - LS-->>Ext: WorkspaceEdit(version N) + expectedNodeId - Ext->>Doc: Verify current version is still N - Ext->>Doc: workspace.applyEdit(edit) - Doc-->>Ext: Text-document change event - Ext-->>UI: Success(expectedNodeId, symbolicName, unresolved properties) - - UI->>UI: Map expectedNodeId -> drop origin - UI->>Pending: Attach expectedNodeId to pending operation - Queue->>Queue: mutationInFlight = false - Queue->>Update: requestGraphUpdate() - Update->>Ext: getGraphUpdate(current rendered graph) - Ext->>LS: visualGraphUpdate(current rendered graph) - LS->>LS: Build graph from latest compilation - LS-->>Ext: Patch delta including addNode(expectedNodeId) - Ext-->>Update: Patch delta - - Update->>Update: Match addNode to stored origin - Update->>Update: Mark node's committing atom before mount - Update->>Graph: Apply canonical topology with explicit origin - Update->>Pending: Remove matching pending operation - Update->>Update: Consume placement entry - Update->>Graph: Reveal without automatic layout/fit-view - Graph-->>UI: Animate compact card/icon to canonical dimensions - Graph->>Graph: Clear committing atom after animation -``` - -`workspace.applyEdit` is the durable commit. The pending card is removed only when the graph delta contains the expected canonical node, not merely when the extension reports that the edit was applied. - -### Concurrent document changes and graph requests - -```mermaid -sequenceDiagram - actor User - participant Doc as Bicep document - participant Ext as VS Code extension - participant Coordinator as Mutation coordinator - participant Update as Graph update loop - participant LS as Language server - participant Pending as Pending resource layer - - alt Document notification arrives while mutation is active - Doc-->>Ext: document changed - Ext-->>Update: documentDidChange - Update->>Update: mutationInFlight is true - Update->>Update: Set dirty to true and defer graph request - else Graph request was already active when mutation starts - Update->>LS: visualGraphUpdate(current graph) - Coordinator->>Coordinator: mutationInFlight = true - LS-->>Update: Patch response - Update->>Update: Detect active mutation - Update->>Update: Set dirty to true and discard response - Note over Update: The response may already contain the new node,
but its expected ID/origin is not bound yet. - end - - Coordinator->>Ext: resources/create - Ext->>LS: prepareVisualResource(version N) - LS-->>Ext: Versioned edit + expectedNodeId - - alt User changed document before edit application - User->>Doc: Edit document to version N+1 - Ext->>Doc: Compare current version with N - Ext-->>Coordinator: documentChanged error - Coordinator->>Pending: Remove failed pending operation - Coordinator->>Coordinator: mutationInFlight = false - Coordinator->>Update: Request normal reconciliation - else Prepared edit is still current - Ext->>Doc: Apply edit - Ext-->>Coordinator: Success(expectedNodeId) - Coordinator->>Coordinator: Bind expectedNodeId -> origin - Coordinator->>Coordinator: mutationInFlight = false - Coordinator->>Update: Request fresh graph update - Update->>LS: visualGraphUpdate(latest current graph) - LS-->>Update: Complete delta from latest compilation - - alt Delta contains expected addNode only - Update->>Update: Apply explicit origin and suppress layout - Update->>Pending: Remove matching pending operation - else Delta also contains unrelated topology changes - Update->>Update: Keep explicit origin for expected addNode - Update->>Update: Mark unrelated topology as layout-affecting - Update->>LS: Request layout for reconciled graph - Update->>Pending: Remove matching pending operation - else Expected node is not present yet - Update->>Pending: Keep pending operation and placement map - Note over Update,Pending: A later document/diagnostic notification retries convergence.
There is currently no pending timeout. - end - end -``` - -The interlock preserves three invariants: - -1. A canonical `addNode` is never consumed before its expected node ID is associated with the drop origin. -2. A source edit is never applied after its prepared document version becomes stale. -3. Suppressing layout for the explicitly placed resource never suppresses layout required by unrelated concurrent topology changes. - -## Architecture - -```mermaid -flowchart LR - subgraph Webview[Visual designer webview] - Palette[Resource Palette] - Drag[Pointer drag + preview] - Creation[Resource creation coordinator] - Pending[Pending resource layer] - Graph[Canonical graph mirror] - Canvas[Canvas] - end - - subgraph Extension[VS Code extension host] - Bridge[Typed webview bridge] - Apply[WorkspaceEdit application] - Settings[VS Code setting bridge] - end - - subgraph LS[Bicep language server] - NamespaceCatalog[Namespace catalog handler] - TypeCatalog[Resource type catalog handler] - Prepare[Prepare resource handler] - Service[Resource creation service] - GraphBuilder[Visual graph builder] - end - - Source[(Bicep source)] - - Settings --> Palette - Palette --> Drag - Drag --> Creation - Creation --> Pending - Creation --> Bridge - Bridge --> NamespaceCatalog - Bridge --> TypeCatalog - Bridge --> Prepare - NamespaceCatalog --> Service - TypeCatalog --> Service - Prepare --> Service - Prepare --> Bridge - Bridge --> Apply - Apply --> Source - Source --> GraphBuilder - GraphBuilder --> Bridge - Bridge --> Graph - Pending --> Canvas - Graph --> Canvas -``` - -### Ownership - -| Area | Owns | Does not own | -|---|---|---| -| Resource Palette | Namespace presentation, search, lazy loading state, resource rows, drag initiation and preview | Source generation, pending mutation state, canonical graph | -| Resource creation | Pending/error/committing state, compact preview card, creation transition | Catalog browsing or canonical deployment state | -| Visual graph client | Canonical graph patches, node identity, position preservation and layout invalidation | Source mutation UI | -| Extension | Document binding, VS Code settings, LSP forwarding, edit application and webview responses | Bicep syntax generation or graph coordinates | -| Language server | Catalog indexing, exact type validation, symbolic naming, syntax generation and versioned edits | Palette presentation or canvas placement | - -### Visual designer feature organization - -```text -src/features/ - accessibility/ - MotionAwareProgressBar.tsx - use-motion-policy-sync.ts - resource-creation/ - animations.ts - atoms.ts - PendingResourceLayer.tsx - ResourceCreationError.tsx - ResourcePreviewCard.tsx - resource-palette/ - PaletteDragOverlay.tsx - ResourcePalette.tsx - ResourcePaletteControls.tsx - ResourcePaletteLayer.tsx - ResourceTypeGroups.tsx - atoms.ts - contracts.ts - use-palette-drag.ts - use-resource-creation-enablement.ts - use-resource-type-search.ts -``` - -The generic graph package accepts new-node origin overrides but has no knowledge of resource creation operations. The standalone `apps/resource-type-explorer` remains independent and is not a product dependency of the visual designer. - -## Resource Catalog - -### Language-server contracts - -The initial namespace request returns a catalog identity plus provider counts: - -```csharp -record VisualResourceTypeNamespacesResult( - string CatalogId, - IReadOnlyList Namespaces); - -record VisualResourceTypeNamespace( - string Name, - int ResourceTypeCount); -``` - -Resource type requests support one provider or a global query and remain paged: - -```csharp -record VisualResourceTypesParams( - TextDocumentIdentifier TextDocument, - string? ProviderNamespace, - string? Query, - bool IncludePreview, - int PageSize, - string? ContinuationToken); - -record VisualResourceTypesResult( - string CatalogId, - IReadOnlyList Items, - string? ContinuationToken); -``` - -The extension validates that every page has the same catalog identity before returning grouped webview results. - -### Caching - -The Azure resource type provider already exposes a lazy `TypeReferencesByType` index. The resource creation service builds on that index: - -- A `ConditionalWeakTable` keys catalog indexes by provider identity. -- The index contains immutable provider-namespace groups. -- Each namespace has thread-safe `Lazy>` projections for stable-only and preview-inclusive results. -- The latest allowed API version is selected once per resource type with `ApiVersionComparer`. -- Stable versions win over same-date preview versions. -- Global query results use a bounded normalized-query cache. - -The built-in provider is process-lifetime, so its cache is effectively process-lifetime. Dynamically loaded provider instances have separate indexes. - -The normal palette requests stable versions only. Preview entries are supported by the protocol/service but are not exposed by the current palette UI. - -## Source Creation - -### Webview request - -```ts -interface CreateVisualResourceRequest { - version: 1; - operationId: string; - resourceType: { - fullyQualifiedType: string; - apiVersion: string; - }; -} - -interface CreateVisualResourceResponse { - version: 1; - operationId: string; - expectedNodeId: string; - symbolicName: string; - unresolvedRequiredProperties: string[]; -} -``` - -The graph coordinate is deliberately absent. It remains webview-local and is correlated first by `operationId`, then by `expectedNodeId`. - -### Language-server preparation - -Each JSON-RPC handler is in its own file: - -- `VisualResourceTypeNamespacesHandler` -- `VisualResourceTypesHandler` -- `PrepareVisualResourceHandler` - -The prepare handler: - -1. Resolves the active compilation. -2. Verifies the target is a Bicep file. -3. Validates the exact resource type and API version with the Azure provider. -4. Generates a deterministic symbolic name. -5. Generates deterministic body properties. -6. Builds and formats Bicep syntax. -7. Self-validates the generated declaration for lexer/parser errors. -8. Returns a versioned `WorkspaceEdit`. - -### Symbolic naming - -The base name comes from the final resource type segment: - -1. Apply conservative singularization. -2. Convert the first character to lower case. -3. Remove characters that cannot participate in a Bicep identifier. -4. Fall back to `resource` if the result is invalid or empty. -5. Compare with all top-level declaration names case-insensitively. -6. Append the smallest available positive numeric suffix on collision. - -Examples: - -```text -storageAccount -storageAccount1 -storageAccount2 -``` - -### Resource body - -Only compiler-known deterministic values are emitted: - -- A required property with a `StringLiteralType` receives that literal. -- Other required properties are returned through `unresolvedRequiredProperties`. -- Discriminated object bodies report the discriminator as unresolved. - -The generator does not invent names, locations, SKUs, IDs, secrets, empty strings, nulls or snippet tab stops. - -### Insertion and formatting - -The declaration is inserted: - -- One blank line after the last resource declaration, or -- After preamble declarations, parameters and variables when no resource exists, or -- At the beginning when modules/outputs are the first declarations. - -Insertion preserves comments and whitespace attached to following declarations. Generated syntax runs through the casing and read-only-property rewriters, `PrettyPrinterV2`, and parser self-validation. The edit does not save the document and participates in native dirty-file and undo/redo behavior. - -## Commit and Reconciliation - -The source edit is the only durable commit. Pending visual state is optimistic feedback, not a second model. - -The webview serializes creation mutations: - -- A new operation receives a UUID and pending placement. -- Only one prepare/apply mutation runs at a time. -- Graph-update requests that complete while a mutation is active are discarded and retried after the expected node ID is known. -- The create response binds the expected canonical node ID to the drop origin. -- The next graph delta places that node at the stored origin. - -The extension performs an explicit document-version check immediately before `workspace.applyEdit`. This is required because converting the LSP `WorkspaceEdit` to the VS Code representation does not preserve the LSP document-version guard. - -If the edit is rejected or the document version changed, the webview removes the pending resource and displays an error. It does not automatically retry, which avoids accidental duplicate creation. - -## Placement and Layout - -Viewport coordinates are converted to graph coordinates using the current canvas bounds and pan/zoom transform: - -```text -graphX = (clientX - canvasLeft - panX) / zoom -graphY = (clientY - canvasTop - panY) / zoom -``` - -The stored point is the desired node center. - -For the correlated `addNode` patch: - -- The new node receives the explicit origin. -- Existing node atoms and boxes are preserved. -- The placement entry is consumed once. -- The addition is excluded from automatic layout invalidation. -- No fit-view request runs. - -Unrelated topology changes still invalidate layout normally. Explicit **Reset Layout** may later move the manually placed resource. - -Placement persists for the lifetime of the visualizer panel only. It is not written into Bicep source or the canonical graph protocol. - -## State and Performance - -Jotai is used where subscription isolation matters: - -- Pointer drag state rerenders only the drag overlay. -- Namespace atoms rerender only the loading provider group. -- Pending/error state belongs to resource creation surfaces. -- Per-node committing atoms rerender only the newly committed resource. - -Local React state remains appropriate for palette open state, search query and controlled Accordion expansion. - -Other performance choices: - -- Namespace browsing avoids building the full presentation catalog. -- The first global search caches the full searchable catalog in the webview. -- Subsequent searches are local. -- Pointer movement never rerenders the graph subtree. -- Azure SVG imports are cached per normalized resource type with `jotai-family`. -- Source generation formats one generated declaration, not the complete source file. -- Correlated creation avoids MSAGL layout and fit-view. - -Long resource lists are not virtualized in the current implementation. - -## Motion and Accessibility - -The shared Accordion provides: - -- Native button headers -- Controlled single/multiple expansion -- `aria-expanded`, `aria-controls`, `aria-labelledby` and region semantics -- Arrow Up/Down, Home and End focus navigation -- DOM-order-aware keyboard movement after group reordering - -Accordion panels remain mounted and reveal immediately. Newly loaded resource rows use a short 160 ms fade/slide transition instead of height animation, which avoids clipping partially visible large groups. - -The progress component lives in `@vscode-bicep-ui/components` and renders the VSCode Elements progress bar. The extension forwards the effective `workbench.reduceMotion` policy: - -- `auto` leaves VSCode Elements to follow the operating system. -- `on` forces the static reduced-motion presentation. -- `off` forces the same indeterminate keyframes used by VSCode Elements even when Windows animations are disabled. - -The setting bridge updates open visualizers at runtime. - -## Errors and Current Limitations - -| Case | Current behavior | -|---|---| -| Resource creation setting disabled | Hide the Add Resources launcher | -| Namespace-list load fails | Show a top-level retry action | -| Namespace load fails | Show an inline retry action in that group | -| Search load fails | Show an inline search error | -| Drop outside the canvas DOM subtree | Cancel without a source change | -| Exact type/API version unavailable | Remove pending state and show an error | -| Document changes before edit application | Reject as `documentChanged` | -| Workspace edit rejected | Remove pending state and show an error | -| Generated declaration fails parsing | Return a generation failure and apply nothing | -| Required properties cannot be generated | Apply the declaration and rely on compiler diagnostics | -| Existing source contains diagnostics | Allow creation when compilation/type resolution still succeeds | -| Multiple fast drops | Serialize them | -| Visualizer closes during an operation | Dispose visual state; do not reopen the panel | - -There is currently no pending-operation timeout or special warning when an applied edit never produces the expected graph node. There is also no separate unresolved-required-properties notification, list virtualization, layout persistence or creation telemetry. - -## Security and Privacy - -- Webview data is treated as untrusted. -- Resource types are validated against the active language-server provider. -- The extension binds operations to the visualizer's document; the webview cannot select an arbitrary file. -- Source strings are generated through syntax APIs, not interpolated from the webview payload. -- Page sizes are clamped server-side. -- The webview performs no catalog network access. -- No Azure credentials or live resource instances are read. -- No source text, property values, paths or coordinates are emitted as telemetry. - -## Validation - -Implemented coverage includes: - -- Language-server unit tests for naming, body generation, catalog namespaces, latest-version selection, paging, filtering and insertion. -- Language-server integration tests for namespace/catalog requests, preparation, collisions, unresolved discriminators and unknown types. -- Shared component tests for Accordion single/multiple/controlled behavior, ARIA state and keyboard navigation. -- Extension unit tests for visualizer placement, catalog grouping, edit failure classification and motion-policy mapping. -- Visual-designer unit tests for graph layout invalidation and per-node committing atom isolation. -- Playwright tests for: - - Experimental setting disabled - - Palette open/close without canvas resizing - - Zoomed preview/pending center and size alignment - - Lazy global search, progress and match highlighting - - Cached follow-up search - - Drop rejection over the Resource Palette - - Keyboard creation at viewport center - -## Non-goals and Future Work - -Not included: - -- Module or Azure Verified Module creation -- Delete, rename or property editing -- Relationship editing -- Nested-resource creation -- Dropping onto a parent node -- Collision avoidance -- Custom undo/redo -- Persisted manual layout -- Resource-list virtualization - -The source-mutation pipeline can support those operations later while preserving the same invariant: source mutation is the only durable commit, and the visual graph is accepted only from the resulting compilation. - -## Key Decisions - -| Decision | Rationale | -|---|---| -| Floating same-webview Resource Palette | Avoids cross-webview drag interception and preserves full canvas space when closed | -| VS Code experimental setting | Allows opt-in rollout without changing Bicep compilation semantics | -| Lazy provider catalogs plus webview search cache | Keeps normal browsing fast while retaining complete global search | -| Language-server source generation | Uses the active compilation, type provider, syntax APIs and formatter | -| Versioned edit returned to the extension | Keeps application and native undo under the originating request | -| Pending card outside the canonical graph | Provides immediate feedback without creating a second source of truth | -| Explicit drop origin kept in the webview | Canvas coordinates are presentation state | -| Mutation serialization and expected-node correlation | Prevents stale edits, duplicate names and placement races | -| Correlated layout suppression | Preserves existing node positions and viewport | -| Deterministic properties only | Avoids fabricated deployment configuration | diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/App.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/App.tsx deleted file mode 100644 index ad586b221b4..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/App.tsx +++ /dev/null @@ -1,222 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import type { ModuleDeclarationProps, ResourceDeclarationProps } from "./features/visualization"; -import type { NodeContentRenderProps, NodeKind } from "./lib/graph"; -import type { DocumentDidChangePayload } from "./lib/messaging"; - -import { PanZoomProvider, useGetPanZoomDimensions } from "@vscode-bicep-ui/components"; -import { - useWebviewMessageChannel, - useWebviewNotification, - WebviewMessageChannelProvider, -} from "@vscode-bicep-ui/messaging"; -import { getDefaultStore, useAtomValue, useSetAtom } from "jotai"; -import { Suspense, useCallback, useEffect, useRef } from "react"; -import { styled, ThemeProvider } from "styled-components"; -import { ControlBar } from "./features/controls"; -import { useMotionPolicySync } from "./features/accessibility"; -import { loadDevAppShell } from "./features/devtools"; -import { - effectiveExportThemeAtom, - ExportAreaCover, - ExportAreaPreview, - exportCanvasElementAtom, - exportFileStemAtom, - ExportOverlay, - isExportCanvasCoverVisibleAtom, - isExportPreviewVisibleAtom, -} from "./features/export"; -import { PendingResourceLayer, ResourceCreationError } from "./features/resource-creation"; -import { ResourcePaletteLayer } from "./features/resource-palette"; -import { StatusBar } from "./features/status"; -import { ModuleDeclaration, ResourceDeclaration } from "./features/visualization"; -import { GlobalStyle } from "./GlobalStyle"; -import { Canvas, Graph, nodeConfigAtom } from "./lib/graph"; -import { useFitViewToBounds } from "./lib/graph/hooks"; -import { DOCUMENT_DID_CHANGE_NOTIFICATION, READY_NOTIFICATION, useGraphUpdate } from "./lib/messaging"; -import { useTheme } from "./lib/theming"; - -const DevAppShell = loadDevAppShell(); - -const store = getDefaultStore(); -const nodeConfig = store.get(nodeConfigAtom); - -const $ControlBarContainer = styled.div` - position: absolute; - top: 16px; - right: 16px; - z-index: 100; -`; - -const $CanvasWrapper = styled.div` - position: absolute; - inset: 0; -`; - -function deriveExportFileStem(documentPath?: string, documentFileName?: string): string { - const fileName = (documentFileName || documentPath || "").split(/[\\/]/).pop() ?? ""; - const stem = fileName.replace(/\.[^.]+$/, "").trim(); - - return stem || "bicep-graph"; -} - -function renderNodeContent(kind: NodeKind, { id, data }: NodeContentRenderProps) { - if (kind === "compound") { - return ; - } - - return ; -} - -store.set(nodeConfigAtom, { - ...nodeConfig, - padding: { - ...nodeConfig.padding, - top: 50, - }, - renderContent: renderNodeContent, -}); - -/** - * Inner component that lives inside PanZoomProvider so it can - * access both the messaging channel and the pan-zoom controls. - */ -function GraphContainer() { - const getPanZoomDimensions = useGetPanZoomDimensions(); - const getViewportCenter = useCallback(() => { - const { width, height } = getPanZoomDimensions(); - return { x: width / 2, y: height / 2 }; - }, [getPanZoomDimensions]); - const fitViewToBounds = useFitViewToBounds(); - const { requestGraphUpdate, createResource, resetLayout } = useGraphUpdate(getViewportCenter, fitViewToBounds); - const messageChannel = useWebviewMessageChannel(); - const exportTheme = useAtomValue(effectiveExportThemeAtom); - const setExportFileStem = useSetAtom(exportFileStemAtom); - const setExportCanvasElement = useSetAtom(exportCanvasElementAtom); - const canvasElementRef = useRef(null); - - // Send READY notification on mount - useEffect(() => { - messageChannel.sendNotification({ - method: READY_NOTIFICATION, - }); - }, [messageChannel]); - - // Listen for "the graph may have changed" notifications. The webview - // pulls the update itself, submitting the graph it currently displays and applying the patches. - useWebviewNotification( - DOCUMENT_DID_CHANGE_NOTIFICATION, - useCallback( - (params: unknown) => { - const payload = params as DocumentDidChangePayload; - messageChannel.setState({ documentPath: payload.documentUri }); - setExportFileStem(deriveExportFileStem(payload.documentUri)); - void requestGraphUpdate(); - }, - [messageChannel, requestGraphUpdate, setExportFileStem], - ), - ); - - const canvasTheme = exportTheme; - - const handleCanvasRef = useCallback( - (element: HTMLDivElement | null) => { - canvasElementRef.current = element; - setExportCanvasElement(element); - }, - [setExportCanvasElement], - ); - - const getCanvasElement = useCallback(() => canvasElementRef.current, []); - - return ( - <> - <$ControlBarContainer> - - - - - <$CanvasWrapper ref={handleCanvasRef}> - - - - - - - - - - ); -} - -function ExportUILayer() { - const isExportPreviewVisible = useAtomValue(isExportPreviewVisibleAtom); - - if (!isExportPreviewVisible) { - return null; - } - - return ( - <> - - - - ); -} - -function ExportCanvasCoverLayer() { - const isExportCanvasCoverVisible = useAtomValue(isExportCanvasCoverVisibleAtom); - - if (!isExportCanvasCoverVisible) { - return null; - } - - return ; -} - -const $AppContainer = styled.div` - flex: 1 1 auto; - position: relative; - overflow: hidden; -`; - -function AppCore() { - const theme = useTheme(); - useMotionPolicySync(); - - return ( - - - <$AppContainer data-testid="app-root"> - - - - - - - - ); -} - -export function App() { - // In dev mode, the lazy-loaded DevAppShell provides - // a FakeMessageChannel, the DevToolbar, and the message-channel - // context. In production, we render straight into the provider - // which creates its own channel via acquireVsCodeApi. - if (DevAppShell) { - return ( - - - - - - ); - } - - return ( - - - - ); -} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/app/App.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/app/App.tsx new file mode 100644 index 00000000000..789b93dca14 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/app/App.tsx @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { PanZoomProvider } from "@vscode-bicep-ui/components"; +import { styled } from "styled-components"; +import { Canvas, ResourceCreationError } from "@/features/canvas"; +import { ControlBar } from "@/features/controls"; +import { Palette } from "@/features/palette"; +import { StatusBar } from "@/features/status"; +import { AppEnvironment } from "./AppEnvironment"; + +const $AppContainer = styled.div` + flex: 1 1 auto; + position: relative; + overflow: hidden; +`; + +export function App() { + return ( + + <$AppContainer data-testid="app-root"> + + + + + + + + + + + ); +} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/app/AppEnvironment.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/app/AppEnvironment.tsx new file mode 100644 index 00000000000..f4d050db377 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/app/AppEnvironment.tsx @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ReactNode } from "react"; + +import { WebviewMessageChannelProvider } from "@vscode-bicep-ui/messaging"; +import { Provider as JotaiProvider } from "jotai"; +import { Suspense } from "react"; +import { ThemeProvider } from "styled-components"; +import { loadDevAppShell } from "@/devtools"; +import { useDocumentSync, useMotionPolicySync } from "@/hooks"; +import { useTheme } from "@/ui/theme"; +import { GlobalStyle } from "./GlobalStyle"; + +const DevAppShell = loadDevAppShell(); + +function MessageChannelBoundary({ children }: { children: ReactNode }) { + if (DevAppShell) { + return ( + + {children} + + ); + } + + return {children}; +} + +function AppRuntime({ children }: { children: ReactNode }) { + const theme = useTheme(); + + // Mount the cross-cutting slices. Both own their own host conversation; app only decides that they + // are active for the whole session rather than tied to any subtree. + useMotionPolicySync(); + useDocumentSync(); + + return ( + + + {children} + + ); +} + +/** + * Establishes the app-wide store, host environment, synchronization, and theme. + */ +export function AppEnvironment({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/GlobalStyle.ts b/src/vscode-bicep-ui/apps/visual-designer/src/app/GlobalStyle.ts similarity index 91% rename from src/vscode-bicep-ui/apps/visual-designer/src/GlobalStyle.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/app/GlobalStyle.ts index d03b1072284..46c8f37cdf5 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/GlobalStyle.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/app/GlobalStyle.ts @@ -24,7 +24,7 @@ export const GlobalStyle = createGlobalStyle` overflow: hidden; display: flex; flex-direction: column; - background-color: ${({ theme }) => theme.canvas.background}; + background-color: ${({ theme }) => theme.viewport.background}; } *, *::before, *::after { diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/DevAppShell.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/devtools/components/DevAppShell.tsx similarity index 71% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/DevAppShell.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/devtools/components/DevAppShell.tsx index 2ec389890da..38c3551a305 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/DevAppShell.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/devtools/components/DevAppShell.tsx @@ -1,12 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { WebviewMessageChannel } from "@vscode-bicep-ui/messaging"; import type { ReactNode } from "react"; import { WebviewMessageChannelProvider } from "@vscode-bicep-ui/messaging"; +import { useDevChannel } from "../hooks/use-dev-channel"; import { DevToolbar } from "./DevToolbar"; -import { useDevChannel } from "./use-dev-channel"; interface DevAppShellProps { children: ReactNode; @@ -18,6 +17,9 @@ interface DevAppShellProps { * It creates a {@link FakeMessageChannel}, renders the * {@link DevToolbar}, and provides the channel to the rest * of the app via {@link WebviewMessageChannelProvider}. + * + * The channel is passed without a cast on purpose: the provider accepts the channel *interface*, so + * the compiler checks that the fake still implements everything the app calls. */ export function DevAppShell({ children }: DevAppShellProps) { const channel = useDevChannel(); @@ -25,7 +27,7 @@ export function DevAppShell({ children }: DevAppShellProps) { if (!channel) return null; return ( - + {children} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/DevToolbar.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/devtools/components/DevToolbar.tsx similarity index 94% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/DevToolbar.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/devtools/components/DevToolbar.tsx index fac4f14ea5d..e003c46f708 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/DevToolbar.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/devtools/components/DevToolbar.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { styled } from "styled-components"; -import { FakeMessageChannel, GRAPH_MUTATIONS, SAMPLE_GRAPHS } from "./fake-message-channel"; +import { FakeMessageChannel, GRAPH_MUTATIONS, SAMPLE_GRAPHS } from "../fakes/fake-message-channel"; interface DevToolbarProps { channel: FakeMessageChannel; @@ -69,9 +69,7 @@ const $Button = styled.button` */ export function DevToolbar({ channel }: DevToolbarProps) { const applyMutation = ( - apply: ( - graph: import("@/lib/messaging/messages").DeploymentGraph, - ) => import("@/lib/messaging/messages").DeploymentGraph, + apply: (graph: import("../fakes/sample-graph").SampleGraph) => import("../fakes/sample-graph").SampleGraph, ) => { const current = channel.getCurrentGraph(); if (!current) return; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/fake-graph-differ.ts b/src/vscode-bicep-ui/apps/visual-designer/src/devtools/fakes/fake-graph-differ.ts similarity index 94% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/fake-graph-differ.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/devtools/fakes/fake-graph-differ.ts index 0134c70d225..2d2b1587d8a 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/fake-graph-differ.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/devtools/fakes/fake-graph-differ.ts @@ -2,8 +2,6 @@ // Licensed under the MIT License. import type { - DeploymentGraph, - DeploymentGraphNode, GraphBounds, GraphEdge, GraphNode, @@ -11,7 +9,8 @@ import type { GraphPatch, NodeLayout, RenderedGraph, -} from "@/lib/messaging"; +} from "@/features/canvas"; +import type { SampleGraph, SampleGraphNode } from "./sample-graph"; /** * A throwaway, dev-only stand-in for the language server's `VisualGraphDiffer`. It lets the @@ -19,7 +18,7 @@ import type { * → patches) without a running extension or language server. * * It is intentionally simpler than the real C# differ: it knows the full target graph (the dev - * toolbar's sample/mutated `DeploymentGraph`) and emits a complete delta transforming the graph + * toolbar's sample/mutated `SampleGraph`) and emits a complete delta transforming the graph * the webview submitted into that target. */ @@ -82,7 +81,7 @@ function buildFakeLayout(nodes: GraphNode[]): { layout: Map; return { layout, bounds: { width: root.width, height: root.height } }; } -function toCanonicalNode(node: DeploymentGraphNode): GraphNode { +function toCanonicalNode(node: SampleGraphNode): GraphNode { return { id: node.id, kind: node.type === "" ? "module" : "resource", @@ -100,7 +99,7 @@ function toCanonicalNode(node: DeploymentGraphNode): GraphNode { * (the dev toolbar's graph). Mirrors the ordering the real server guarantees for graph updates: * remove edges, remove nodes deepest-first, add/update nodes shallowest-first, add edges, then error count. */ -export function diffGraph(current: RenderedGraph | null, target: DeploymentGraph | null): GraphPatch[] { +export function diffGraph(current: RenderedGraph | null, target: SampleGraph | null): GraphPatch[] { const targetNodes = new Map(); const targetEdges = new Map(); let errorCount = 0; @@ -177,7 +176,7 @@ export function diffGraph(current: RenderedGraph | null, target: DeploymentGraph return patches; } -export function hasTopologyChange(current: RenderedGraph | null, target: DeploymentGraph | null): boolean { +export function hasTopologyChange(current: RenderedGraph | null, target: SampleGraph | null): boolean { if (!target) { return (current?.nodes.length ?? 0) > 0; } @@ -201,7 +200,7 @@ export function hasTopologyChange(current: RenderedGraph | null, target: Deploym return currentEdges.some((edge) => !targetEdgeIds.has(edge.id)); } -export function layoutGraph(current: RenderedGraph, target: DeploymentGraph | null): GraphPatch[] | undefined { +export function layoutGraph(current: RenderedGraph, target: SampleGraph | null): GraphPatch[] | undefined { if (hasTopologyChange(current, target)) { return undefined; } diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/fake-message-channel.ts b/src/vscode-bicep-ui/apps/visual-designer/src/devtools/fakes/fake-message-channel.ts similarity index 82% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/fake-message-channel.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/devtools/fakes/fake-message-channel.ts index 13ec14129e0..3105522f7be 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/fake-message-channel.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/devtools/fakes/fake-message-channel.ts @@ -1,88 +1,76 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { WebviewNotificationCallback, WebviewNotificationMessage } from "@vscode-bicep-ui/messaging"; import type { - CreateVisualResourceRequest, - CreateVisualResourceResponse, - DeploymentGraph, - GetGraphLayoutRequest, - GetGraphLayoutResponse, - GetGraphUpdateRequest, - GetGraphUpdateResponse, -} from "@/lib/messaging"; - -import { - CREATE_RESOURCE_REQUEST, - DOCUMENT_DID_CHANGE_NOTIFICATION, - GET_GRAPH_LAYOUT_REQUEST, - GET_GRAPH_UPDATE_REQUEST, - READY_NOTIFICATION, - REVEAL_FILE_RANGE_NOTIFICATION, - REVEAL_NODE_SOURCE_NOTIFICATION, - SHOW_PROBLEMS_PANEL_NOTIFICATION, -} from "@/lib/messaging/messages"; + MessageArgs, + NotificationDescriptor, + RequestDescriptor, + WebviewMessageChannelApi, + WebviewNotificationCallback, + WebviewNotificationMessage, +} from "@vscode-bicep-ui/messaging"; +import type { + CreateResourceParams, + CreateResourceResult, + GetGraphLayoutParams, + GetGraphLayoutResult, + GetGraphUpdateParams, + GetGraphUpdateResult, +} from "@/features/canvas"; +import type { SampleGraph } from "./sample-graph"; + +// The fake host implements the whole protocol, so it is the one legitimate consumer of every +// feature's `api` surface. +import { createResource, getGraphLayout, getGraphUpdate, revealNodeSource } from "@/features/canvas"; +import { getResourceCreationEnablement, getResourceTypeNamespaces, loadResourceTypeCatalog } from "@/features/palette"; +import { showProblemsPanel } from "@/features/status"; +import { documentDidChange, getMotionPolicy, ready } from "@/hooks"; import { diffGraph, layoutGraph } from "./fake-graph-differ"; const FAKE_FILE_PATH = "file:///main.bicep"; -const ZERO_RANGE = { - start: { line: 0, character: 0 }, - end: { line: 0, character: 0 }, -}; - // ─── Sample graphs ─────────────────────────────────────────────────────────── /** * A module with two child resources, plus two standalone resources. * Edges only connect nodes within the same scope (no cross-boundary edges). */ -const MODULE_GRAPH: DeploymentGraph = { +const MODULE_GRAPH: SampleGraph = { nodes: [ { id: "myModule", type: "", isCollection: false, - range: ZERO_RANGE, hasChildren: true, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "myModule::vmResource", type: "Microsoft.Compute/virtualMachines", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "myModule::storageAccount", type: "Microsoft.Storage/storageAccounts", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "networkInterface", type: "Microsoft.Network/networkInterfaces", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "publicIp", type: "Microsoft.Network/publicIPAddresses", isCollection: true, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, ], edges: [ @@ -96,43 +84,35 @@ const MODULE_GRAPH: DeploymentGraph = { }; /** Flat graph with no modules — just four standalone resources in a chain. */ -const FLAT_GRAPH: DeploymentGraph = { +const FLAT_GRAPH: SampleGraph = { nodes: [ { id: "vnet", type: "Microsoft.Network/virtualNetworks", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "subnet", type: "Microsoft.Network/virtualNetworks/subnets", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "nsg", type: "Microsoft.Network/networkSecurityGroups", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "pip", type: "Microsoft.Network/publicIPAddresses", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, ], edges: [ @@ -144,43 +124,35 @@ const FLAT_GRAPH: DeploymentGraph = { }; /** Graph containing nodes with errors and a collection. */ -const ERROR_GRAPH: DeploymentGraph = { +const ERROR_GRAPH: SampleGraph = { nodes: [ { id: "brokenStorage", type: "Microsoft.Storage/storageAccounts", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: true, - filePath: FAKE_FILE_PATH, }, { id: "webApps", type: "Microsoft.Web/sites", isCollection: true, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "badModule", type: "", isCollection: false, - range: ZERO_RANGE, hasChildren: true, hasError: true, - filePath: FAKE_FILE_PATH, }, { id: "badModule::db", type: "Microsoft.Sql/servers", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: true, - filePath: FAKE_FILE_PATH, }, ], edges: [{ sourceId: "webApps", targetId: "brokenStorage" }], @@ -191,26 +163,22 @@ const ERROR_GRAPH: DeploymentGraph = { * Complex graph modeled after modules-vwan-to-vnet-s2s-with-fw Bicep sample. * 2 resource groups, 13 modules with child resources, and rich inter-module dependencies. */ -const COMPLEX_GRAPH: DeploymentGraph = { +const COMPLEX_GRAPH: SampleGraph = { nodes: [ // ── Top-level resources ────────────────────────────────────────────── { id: "hubrg", type: "Microsoft.Resources/resourceGroups", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "vwanrg", type: "Microsoft.Resources/resourceGroups", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, // ── vnet module (scope: hubrg) ─────────────────────────────────────── @@ -218,37 +186,29 @@ const COMPLEX_GRAPH: DeploymentGraph = { id: "vnet", type: "", isCollection: false, - range: ZERO_RANGE, hasChildren: true, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "vnet::servernsg", type: "Microsoft.Network/networkSecurityGroups", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "vnet::bastionnsg", type: "Microsoft.Network/networkSecurityGroups", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "vnet::vnet", type: "Microsoft.Network/virtualNetworks", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, // ── vpngw module (scope: hubrg, depends on: vnet) ──────────────────── @@ -256,28 +216,22 @@ const COMPLEX_GRAPH: DeploymentGraph = { id: "vpngw", type: "", isCollection: false, - range: ZERO_RANGE, hasChildren: true, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "vpngw::vpngwpip", type: "Microsoft.Network/publicIPAddresses", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "vpngw::vpngw", type: "Microsoft.Network/virtualNetworkGateways", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, // ── fwpolicy module (scope: hubrg) ─────────────────────────────────── @@ -285,28 +239,22 @@ const COMPLEX_GRAPH: DeploymentGraph = { id: "fwpolicy", type: "", isCollection: false, - range: ZERO_RANGE, hasChildren: true, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "fwpolicy::policy", type: "Microsoft.Network/firewallPolicies", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "fwpolicy::platformrcgroup", type: "Microsoft.Network/firewallPolicies/ruleCollectionGroups", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, // ── fwpip module (scope: hubrg) ────────────────────────────────────── @@ -314,28 +262,22 @@ const COMPLEX_GRAPH: DeploymentGraph = { id: "fwpip", type: "", isCollection: false, - range: ZERO_RANGE, hasChildren: true, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "fwpip::fwipprefix", type: "Microsoft.Network/publicIPPrefixes", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "fwpip::fwip", type: "Microsoft.Network/publicIPAddresses", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, // ── fw module (scope: hubrg, depends on: fwpolicy, fwpip, vnet) ────── @@ -343,19 +285,15 @@ const COMPLEX_GRAPH: DeploymentGraph = { id: "fw", type: "", isCollection: false, - range: ZERO_RANGE, hasChildren: true, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "fw::firewall", type: "Microsoft.Network/azureFirewalls", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, // ── vwan module (scope: vwanrg) ────────────────────────────────────── @@ -363,19 +301,15 @@ const COMPLEX_GRAPH: DeploymentGraph = { id: "vwan", type: "", isCollection: false, - range: ZERO_RANGE, hasChildren: true, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "vwan::wan", type: "Microsoft.Network/virtualWans", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, // ── vhub module (scope: vwanrg, depends on: vwan) ──────────────────── @@ -383,19 +317,15 @@ const COMPLEX_GRAPH: DeploymentGraph = { id: "vhub", type: "", isCollection: false, - range: ZERO_RANGE, hasChildren: true, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "vhub::hub", type: "Microsoft.Network/virtualHubs", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, // ── vhubfwpolicy module (scope: vwanrg) ────────────────────────────── @@ -403,28 +333,22 @@ const COMPLEX_GRAPH: DeploymentGraph = { id: "vhubfwpolicy", type: "", isCollection: false, - range: ZERO_RANGE, hasChildren: true, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "vhubfwpolicy::policy", type: "Microsoft.Network/firewallPolicies", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "vhubfwpolicy::platformrcgroup", type: "Microsoft.Network/firewallPolicies/ruleCollectionGroups", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, // ── vhubfw module (scope: vwanrg, depends on: vhub, vhubfwpolicy) ──── @@ -432,19 +356,15 @@ const COMPLEX_GRAPH: DeploymentGraph = { id: "vhubfw", type: "", isCollection: false, - range: ZERO_RANGE, hasChildren: true, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "vhubfw::firewall", type: "Microsoft.Network/azureFirewalls", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, // ── vhubvpngw module (scope: vwanrg, depends on: vhub) ────────────── @@ -452,19 +372,15 @@ const COMPLEX_GRAPH: DeploymentGraph = { id: "vhubvpngw", type: "", isCollection: false, - range: ZERO_RANGE, hasChildren: true, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "vhubvpngw::hubvpngw", type: "Microsoft.Network/vpnGateways", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, // ── vwanvpnsite module (scope: vwanrg, depends on: vnet, vpngw, vwan) @@ -472,19 +388,15 @@ const COMPLEX_GRAPH: DeploymentGraph = { id: "vwanvpnsite", type: "", isCollection: false, - range: ZERO_RANGE, hasChildren: true, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "vwanvpnsite::vpnsite", type: "Microsoft.Network/vpnSites", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, // ── vhubs2s module (scope: vwanrg, depends on: vhubvpngw, vwanvpnsite) @@ -492,19 +404,15 @@ const COMPLEX_GRAPH: DeploymentGraph = { id: "vhubs2s", type: "", isCollection: false, - range: ZERO_RANGE, hasChildren: true, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "vhubs2s::hubvpnconnection", type: "Microsoft.Network/vpnGateways/vpnConnections", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, // ── vnets2s module (scope: hubrg, depends on: vhub, vhubvpngw, vpngw) @@ -512,28 +420,22 @@ const COMPLEX_GRAPH: DeploymentGraph = { id: "vnets2s", type: "", isCollection: false, - range: ZERO_RANGE, hasChildren: true, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "vnets2s::localnetworkgw", type: "Microsoft.Network/localNetworkGateways", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: "vnets2s::s2sconnection", type: "Microsoft.Network/connections", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, ], edges: [ @@ -585,7 +487,7 @@ const COMPLEX_GRAPH: DeploymentGraph = { /** * Named sample graphs available in the dev toolbar. */ -export const SAMPLE_GRAPHS: Record = { +export const SAMPLE_GRAPHS: Record = { "Module graph": MODULE_GRAPH, "Flat graph": FLAT_GRAPH, "Error graph": ERROR_GRAPH, @@ -601,10 +503,27 @@ function getScope(id: string): string { return idx === -1 ? "" : id.slice(0, idx); } +/** + * Resource-catalog responses are deliberately delayed so the dev shell exercises loading states. + * The `catalogDelay` query parameter overrides that delay (in milliseconds) so end-to-end tests can + * hold the loading state open long enough to assert on it instead of racing the default timing. + */ +function getCatalogDelayMs(defaultDelayMs: number): number { + const raw = new URLSearchParams(window.location.search).get("catalogDelay"); + + if (raw === null) { + return defaultDelayMs; + } + + const override = Number(raw); + + return Number.isFinite(override) && override >= 0 ? override : defaultDelayMs; +} + export interface GraphMutation { label: string; description: string; - apply: (graph: DeploymentGraph) => DeploymentGraph; + apply: (graph: SampleGraph) => SampleGraph; } /** All available mutations for testing incremental updates. */ @@ -625,10 +544,8 @@ export const GRAPH_MUTATIONS: GraphMutation[] = [ id: newId, type: "Microsoft.Web/sites", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, ], edges: firstTopLevel ? [...graph.edges, { sourceId: newId, targetId: firstTopLevel.id }] : graph.edges, @@ -650,19 +567,15 @@ export const GRAPH_MUTATIONS: GraphMutation[] = [ id: moduleId, type: "", isCollection: false, - range: ZERO_RANGE, hasChildren: true, hasError: false, - filePath: FAKE_FILE_PATH, }, { id: childId, type: "Microsoft.Storage/storageAccounts", isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: false, - filePath: FAKE_FILE_PATH, }, ], }; @@ -792,7 +705,7 @@ export const GRAPH_MUTATIONS: GraphMutation[] = [ * Graph changes are announced with `documentDidChange`; the webview then pulls patch * and layout responses through the same request flow used in production. */ -export class FakeMessageChannel { +export class FakeMessageChannel implements WebviewMessageChannelApi { private readonly notificationSubscriptions: Record> = {}; private readonly onWindowMessage = (event: MessageEvent) => { if ( @@ -818,11 +731,11 @@ export class FakeMessageChannel { } sendRequest(requestMessage: { method: string; params?: unknown }): Promise { - if (requestMessage.method === "motionPolicy/get") { + if (requestMessage.method === getMotionPolicy.method) { return Promise.resolve("animate" as T); } - if (requestMessage.method === "resourceCreation/isEnabled") { + if (requestMessage.method === getResourceCreationEnablement.method) { return Promise.resolve((new URLSearchParams(window.location.search).get("resourceCreation") !== "false") as T); } @@ -837,7 +750,7 @@ export class FakeMessageChannel { }, ]; - if (requestMessage.method === "resourceTypeCatalog/namespaces") { + if (requestMessage.method === getResourceTypeNamespaces.method) { return new Promise((resolve) => { setTimeout( () => @@ -853,7 +766,7 @@ export class FakeMessageChannel { }); } - if (requestMessage.method === "resourceTypeCatalog/load") { + if (requestMessage.method === loadResourceTypeCatalog.method) { const { providerNamespace, query, loadAll } = (requestMessage.params ?? {}) as { providerNamespace?: string; query?: string; @@ -873,28 +786,28 @@ export class FakeMessageChannel { .filter((group) => group.resourceTypes.length > 0); return new Promise((resolve) => { - setTimeout(() => resolve({ catalogId: "dev-catalog", groups } as T), loadAll ? 600 : 200); + setTimeout(() => resolve({ catalogId: "dev-catalog", groups } as T), getCatalogDelayMs(loadAll ? 600 : 200)); }); } - if (requestMessage.method === GET_GRAPH_UPDATE_REQUEST) { - const { current } = requestMessage.params as GetGraphUpdateRequest; + if (requestMessage.method === getGraphUpdate.method) { + const { current } = requestMessage.params as GetGraphUpdateParams; const patches = diffGraph(current, this.currentGraph); - return Promise.resolve({ patches } as GetGraphUpdateResponse as T); + return Promise.resolve({ patches } as GetGraphUpdateResult as T); } - if (requestMessage.method === GET_GRAPH_LAYOUT_REQUEST) { - const { current } = requestMessage.params as GetGraphLayoutRequest; + if (requestMessage.method === getGraphLayout.method) { + const { current } = requestMessage.params as GetGraphLayoutParams; const patches = layoutGraph(current, this.currentGraph); - const result: GetGraphLayoutResponse = patches + const result: GetGraphLayoutResult = patches ? { status: "ok", patches } : { status: "graphChanged", patches: [] }; return Promise.resolve(result as T); } - if (requestMessage.method === CREATE_RESOURCE_REQUEST) { - const request = requestMessage.params as CreateVisualResourceRequest; + if (requestMessage.method === createResource.method) { + const request = requestMessage.params as CreateResourceParams; const current = this.currentGraph ?? { nodes: [], edges: [], errorCount: 0 }; const baseName = request.resourceType.fullyQualifiedType.split("/").slice(-1)[0]?.replace(/s$/, "") ?? "resource"; let symbolicName = baseName.charAt(0).toLocaleLowerCase() + baseName.slice(1); @@ -915,10 +828,8 @@ export class FakeMessageChannel { id: symbolicName, type: request.resourceType.fullyQualifiedType, isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: true, - filePath: FAKE_FILE_PATH, }, ], }); @@ -929,7 +840,7 @@ export class FakeMessageChannel { expectedNodeId: symbolicName, symbolicName, unresolvedRequiredProperties: ["name"], - } satisfies CreateVisualResourceResponse as T); + } satisfies CreateResourceResult as T); }, 300); }); } @@ -938,38 +849,47 @@ export class FakeMessageChannel { } /** The last graph pushed, so mutations can build on top of it. */ - private currentGraph: DeploymentGraph | null = null; + private currentGraph: SampleGraph | null = null; sendNotification(notificationMessage: WebviewNotificationMessage) { - if (notificationMessage.method === READY_NOTIFICATION) { + if (notificationMessage.method === ready.method) { // Simulate async response from the extension host: // after a short delay, present the sample deployment graph. setTimeout(() => { this.pushGraph(MODULE_GRAPH); }, 50); - } else if (notificationMessage.method === REVEAL_FILE_RANGE_NOTIFICATION) { - console.log("[FakeMessageChannel] revealFileRange:", notificationMessage.params); - } else if (notificationMessage.method === REVEAL_NODE_SOURCE_NOTIFICATION) { + } else if (notificationMessage.method === revealNodeSource.method) { // The real host would resolve the node's source location via the language server and reveal it. console.log("[FakeMessageChannel] revealNodeSource:", notificationMessage.params); - } else if (notificationMessage.method === SHOW_PROBLEMS_PANEL_NOTIFICATION) { + } else if (notificationMessage.method === showProblemsPanel.method) { console.log("[FakeMessageChannel] showProblemsPanel: would open VS Code Problems panel"); } } + request( + descriptor: RequestDescriptor, + ...args: MessageArgs + ): Promise { + return this.sendRequest({ method: descriptor.method, params: args[0] }); + } + + notify(descriptor: NotificationDescriptor, ...args: MessageArgs): void { + this.sendNotification({ method: descriptor.method, params: args[0] }); + } + setState(state: T): T { return state; } /** Returns the most recently pushed graph (for mutations). */ - getCurrentGraph(): DeploymentGraph | null { + getCurrentGraph(): SampleGraph | null { return this.currentGraph; } /** Simulate the extension host announcing that the graph may have changed. */ - pushGraph(graph: DeploymentGraph | null) { + pushGraph(graph: SampleGraph | null) { this.currentGraph = graph; - this.dispatchNotification(DOCUMENT_DID_CHANGE_NOTIFICATION, { + this.dispatchNotification(documentDidChange.method, { documentUri: FAKE_FILE_PATH, }); } diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/devtools/fakes/sample-graph.ts b/src/vscode-bicep-ui/apps/visual-designer/src/devtools/fakes/sample-graph.ts new file mode 100644 index 00000000000..319ab196d97 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/devtools/fakes/sample-graph.ts @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * The dev playground's stand-in for the document a language server would compile. + * + * The real protocol never carries a whole graph — the server sends patches and the webview submits + * what it has rendered. The fake needs a whole-graph model anyway, because that is what the toolbar + * lets you switch between and mutate, and `diffGraph` turns the difference into the patches the + * protocol does carry. + * + * Only the fields that survive the trip are modelled: anything else would be written here and + * dropped at the boundary. + */ +export interface SampleGraph { + nodes: SampleGraphNode[]; + edges: SampleGraphEdge[]; + errorCount: number; +} + +export interface SampleGraphNode { + id: string; + type: string; + isCollection: boolean; + hasChildren: boolean; + hasError: boolean; +} + +export interface SampleGraphEdge { + sourceId: string; + targetId: string; +} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/use-dev-channel.ts b/src/vscode-bicep-ui/apps/visual-designer/src/devtools/hooks/use-dev-channel.ts similarity index 91% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/use-dev-channel.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/devtools/hooks/use-dev-channel.ts index a41b2ee5e3a..4aed86676f4 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/use-dev-channel.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/devtools/hooks/use-dev-channel.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { useEffect, useRef, useState } from "react"; -import { FakeMessageChannel } from "./fake-message-channel"; +import { FakeMessageChannel } from "../fakes/fake-message-channel"; /** * Lazily create a {@link FakeMessageChannel} for use in the diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/devtools/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/devtools/index.ts new file mode 100644 index 00000000000..a3fab8b7264 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/devtools/index.ts @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ComponentType, LazyExoticComponent, ReactNode } from "react"; + +import { lazy } from "react"; + +/** + * Development scaffolding: a fake extension host so the webview runs standalone. + * + * This is deliberately *not* a feature. Features are slices of the product; devtools impersonates + * the other side of the wire, which is why it is the one module allowed to import every feature's + * `api.ts`. Nothing but `app` may import it. + */ + +/** + * Lazily load the {@link DevAppShell} component. Returns `undefined` in production builds + * (`import.meta.env.DEV === false`), allowing Rollup to tree-shake the entire devtools chunk. + */ +export function loadDevAppShell(): LazyExoticComponent> | undefined { + if (!import.meta.env.DEV) { + return undefined; + } + + return lazy(() => import("./components/DevAppShell").then((m) => ({ default: m.DevAppShell }))); +} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/accessibility/atoms.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/accessibility/atoms.ts deleted file mode 100644 index a472c0532bb..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/accessibility/atoms.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import type { MotionPolicy } from "@/lib/messaging"; - -import { atom } from "jotai"; - -export const motionPolicyAtom = atom("system"); diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/accessibility/use-motion-policy-sync.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/accessibility/use-motion-policy-sync.ts deleted file mode 100644 index c2b7eb2bc07..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/accessibility/use-motion-policy-sync.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import type { MotionPolicy } from "@/lib/messaging"; - -import { useWebviewNotification, useWebviewRequest } from "@vscode-bicep-ui/messaging"; -import { useSetAtom } from "jotai"; -import { useCallback, useEffect } from "react"; -import { - GET_MOTION_POLICY_REQUEST, - MOTION_POLICY_DID_CHANGE_NOTIFICATION, -} from "@/lib/messaging"; -import { motionPolicyAtom } from "./atoms"; - -export function useMotionPolicySync() { - const setMotionPolicy = useSetAtom(motionPolicyAtom); - const [initialMotionPolicy] = useWebviewRequest(GET_MOTION_POLICY_REQUEST); - - useEffect(() => { - if (initialMotionPolicy) { - setMotionPolicy(initialMotionPolicy); - } - }, [initialMotionPolicy, setMotionPolicy]); - - useWebviewNotification( - MOTION_POLICY_DID_CHANGE_NOTIFICATION, - useCallback( - (policy: unknown) => { - if (policy === "system" || policy === "reduce" || policy === "animate") { - setMotionPolicy(policy); - } - }, - [setMotionPolicy], - ), - ); -} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-creation/__tests__/atoms.test.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/__tests__/atoms.test.ts similarity index 100% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/resource-creation/__tests__/atoms.test.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/__tests__/atoms.test.ts diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/__tests__/graph-layout.test.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/__tests__/graph-layout.test.ts new file mode 100644 index 00000000000..233173ffd28 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/__tests__/graph-layout.test.ts @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { GraphNode, GraphPatch } from "../api"; + +import { describe, expect, it } from "vitest"; +import { centerGraphLayout, extractGraphLayout, patchMayAffectLayout } from "../graph-layout"; + +function node(overrides: Partial = {}): GraphNode { + return { + id: "n", + kind: "resource", + parentId: null, + type: "Microsoft.Storage/storageAccounts", + symbolName: "n", + isCollection: false, + hasChildren: false, + hasError: false, + ...overrides, + }; +} + +function graphOf(...nodes: GraphNode[]) { + return { nodes: new Map(nodes.map((graphNode) => [graphNode.id, graphNode])) }; +} + +/** Mirror the server's `updateNode`: only the changed metadata fields are sent. */ +function fullUpdate(graphNode: GraphNode, changes: Partial = {}): GraphPatch { + const merged = { ...graphNode, ...changes }; + return { + op: "updateNode", + nodeId: graphNode.id, + changes: { + type: merged.type, + isCollection: merged.isCollection, + hasChildren: merged.hasChildren, + hasError: merged.hasError, + }, + }; +} + +describe("patchMayAffectLayout", () => { + const graphNode = node({ id: "a" }); + const graph = graphOf(graphNode); + + it("treats structural patches as layout-affecting", () => { + const structural: GraphPatch[] = [ + { op: "clearGraph" }, + { op: "addNode", node: node({ id: "b" }) }, + { op: "removeNode", nodeId: "a" }, + { op: "addEdge", edge: { id: "a>b", sourceId: "a", targetId: "b" } }, + { op: "removeEdge", edgeId: "a>b" }, + ]; + + for (const patch of structural) { + expect(patchMayAffectLayout(graph, patch)).toBe(true); + } + }); + + it("does not reflow an addNode patch with an explicit placement", () => { + const patch: GraphPatch = { op: "addNode", node: node({ id: "placed" }) }; + + expect(patchMayAffectLayout(graph, patch, new Set(["placed"]))).toBe(false); + expect(patchMayAffectLayout(graph, patch, new Set(["other"]))).toBe(true); + }); + + it("treats layout and error-count patches as non-affecting", () => { + expect(patchMayAffectLayout(graph, { op: "setNodeLayout", nodeId: "a", layout: { x: 1, y: 2 } })).toBe(false); + expect(patchMayAffectLayout(graph, { op: "setErrorCount", errorCount: 3 })).toBe(false); + }); + + it("does not reflow when an updateNode only toggles hasError", () => { + expect(patchMayAffectLayout(graph, fullUpdate(graphNode, { hasError: true }))).toBe(false); + }); + + it("ignores null update fields as omitted metadata", () => { + expect( + patchMayAffectLayout(graph, { + op: "updateNode", + nodeId: "a", + changes: { type: null, isCollection: null, hasChildren: null, hasError: true }, + }), + ).toBe(false); + }); + + it("reflows when a size-affecting field actually changes", () => { + expect(patchMayAffectLayout(graph, fullUpdate(graphNode, { type: "Microsoft.Web/sites" }))).toBe(true); + expect(patchMayAffectLayout(graph, fullUpdate(graphNode, { isCollection: true }))).toBe(true); + expect(patchMayAffectLayout(graph, fullUpdate(graphNode, { hasChildren: true }))).toBe(true); + }); + + it("does not reflow for an updateNode targeting an unknown node", () => { + expect(patchMayAffectLayout(graph, fullUpdate(node({ id: "missing" })))).toBe(false); + }); +}); + +describe("extractGraphLayout", () => { + it("extracts layout patches and ignores unrelated patches", () => { + const patches: GraphPatch[] = [ + { op: "setNodeLayout", nodeId: "a", layout: { x: 1, y: 2 } }, + { op: "setErrorCount", errorCount: 1 }, + { op: "setNodeLayout", nodeId: "b", layout: { x: 3, y: 4 } }, + ]; + + const { nodeLayouts, graphBounds } = extractGraphLayout(patches); + + expect([...nodeLayouts]).toEqual([ + ["a", { x: 1, y: 2 }], + ["b", { x: 3, y: 4 }], + ]); + expect(graphBounds).toBeNull(); + }); + + it("takes the last bounds when several are present", () => { + const patches: GraphPatch[] = [ + { op: "setGraphBounds", bounds: { width: 10, height: 10 } }, + { op: "setGraphBounds", bounds: { width: 20, height: 30 } }, + ]; + + expect(extractGraphLayout(patches).graphBounds).toEqual({ width: 20, height: 30 }); + }); +}); + +describe("centerGraphLayout", () => { + it("passes layouts through untouched when there are no bounds to centre against", () => { + const layouts = new Map([["a", { x: 5, y: 5 }]]); + const result = centerGraphLayout(layouts, null, { x: 100, y: 100 }); + + expect(result.bounds).toBeNull(); + expect(result.nodeLayouts).toBe(layouts); + }); + + it("shifts every node by the same offset and reports matching bounds", () => { + const layouts = new Map([ + ["a", { x: 0, y: 0 }], + ["b", { x: 100, y: 50 }], + ]); + + const { nodeLayouts, bounds } = centerGraphLayout(layouts, { width: 100, height: 50 }, { x: 500, y: 300 }); + + expect(nodeLayouts.get("a")).toEqual({ x: 450, y: 275 }); + expect(nodeLayouts.get("b")).toEqual({ x: 550, y: 325 }); + expect(bounds).toEqual({ min: { x: 450, y: 275 }, max: { x: 550, y: 325 } }); + }); + + it("leaves the graph centred on the viewport centre", () => { + const { bounds } = centerGraphLayout(new Map(), { width: 200, height: 100 }, { x: 640, y: 400 }); + + expect((bounds!.min.x + bounds!.max.x) / 2).toBe(640); + expect((bounds!.min.y + bounds!.max.y) / 2).toBe(400); + }); +}); diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/__tests__/graph-model.test.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/__tests__/graph-model.test.ts new file mode 100644 index 00000000000..e01f9f68c73 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/__tests__/graph-model.test.ts @@ -0,0 +1,253 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { Box } from "@/lib/math"; +import type { GraphEdge, GraphNode, GraphPatch, NodeLayout, RenderedGraph, RenderedGraphNode } from "../api"; +import type { ClientGraph } from "../graph-model"; + +import { describe, expect, it } from "vitest"; +import { + applyGraphPatch, + buildRenderedGraph, + clientGraphsRenderEqually, + createClientGraph, + renderedGraphsEqual, +} from "../graph-model"; + +function node(overrides: Partial = {}): GraphNode { + return { + id: "n", + kind: "resource", + parentId: null, + type: "Microsoft.Storage/storageAccounts", + symbolName: "n", + isCollection: false, + hasChildren: false, + hasError: false, + ...overrides, + }; +} + +function apply(patches: GraphPatch[]) { + const graph = createClientGraph(); + const nodeLayouts = new Map(); + + for (const patch of patches) { + applyGraphPatch(graph, nodeLayouts, patch); + } + + return { graph, nodeLayouts }; +} + +const box = (width: number, height: number): Box => ({ min: { x: 0, y: 0 }, max: { x: width, y: height } }); + +describe("applyGraphPatch", () => { + it("adds, updates and removes nodes", () => { + const { graph } = apply([ + { op: "addNode", node: node({ id: "a" }) }, + { op: "addNode", node: node({ id: "b" }) }, + { op: "updateNode", nodeId: "a", changes: { hasError: true } }, + { op: "removeNode", nodeId: "b" }, + ]); + + expect([...graph.nodes.keys()]).toEqual(["a"]); + expect(graph.nodes.get("a")?.hasError).toBe(true); + }); + + it("leaves fields the server omitted untouched", () => { + // The server sends only changed metadata, and models "unchanged" as undefined or null. + const { graph } = apply([ + { op: "addNode", node: node({ id: "a", type: "Microsoft.Web/sites", isCollection: true }) }, + { op: "updateNode", nodeId: "a", changes: { hasError: true, type: undefined, isCollection: null } }, + ]); + + const updated = graph.nodes.get("a"); + expect(updated?.type).toBe("Microsoft.Web/sites"); + expect(updated?.isCollection).toBe(true); + expect(updated?.hasError).toBe(true); + }); + + it("ignores an update for a node it does not hold", () => { + const { graph } = apply([{ op: "updateNode", nodeId: "ghost", changes: { hasError: true } }]); + + expect(graph.nodes.size).toBe(0); + }); + + it("collects setNodeLayout separately from the graph", () => { + const { graph, nodeLayouts } = apply([ + { op: "addNode", node: node({ id: "a" }) }, + { op: "setNodeLayout", nodeId: "a", layout: { x: 10, y: 20 } }, + ]); + + expect(nodeLayouts.get("a")).toEqual({ x: 10, y: 20 }); + expect(graph.nodes.get("a")).not.toHaveProperty("x"); + }); + + it("clears everything on clearGraph", () => { + const { graph } = apply([ + { op: "addNode", node: node({ id: "a" }) }, + { op: "addEdge", edge: { id: "e", sourceId: "a", targetId: "a" } }, + { op: "setErrorCount", errorCount: 3 }, + { op: "clearGraph" }, + ]); + + expect(graph.nodes.size).toBe(0); + expect(graph.edges.size).toBe(0); + expect(graph.errorCount).toBe(0); + }); + + it("tracks the error count", () => { + const { graph } = apply([{ op: "setErrorCount", errorCount: 2 }]); + + expect(graph.errorCount).toBe(2); + }); +}); + +describe("buildRenderedGraph", () => { + it("reports measured sizes, and zero for a node not yet measured", () => { + const { graph } = apply([ + { op: "addNode", node: node({ id: "measured" }) }, + { op: "addNode", node: node({ id: "unmeasured" }) }, + ]); + + const rendered = buildRenderedGraph(graph, new Map([["measured", box(220, 80)]])); + + expect(rendered.nodes).toEqual([ + expect.objectContaining({ id: "measured", width: 220, height: 80 }), + expect.objectContaining({ id: "unmeasured", width: 0, height: 0 }), + ]); + }); + + it("carries edges through by id", () => { + const { graph } = apply([ + { op: "addNode", node: node({ id: "a" }) }, + { op: "addEdge", edge: { id: "a->b", sourceId: "a", targetId: "b" } }, + ]); + + expect(buildRenderedGraph(graph, new Map()).edges).toEqual([{ id: "a->b", sourceId: "a", targetId: "b" }]); + }); +}); + +function renderedNode(overrides: Partial = {}): RenderedGraphNode { + return { + id: "a", + kind: "resource", + parentId: null, + type: "Microsoft.Storage/storageAccounts", + isCollection: false, + hasChildren: false, + hasError: false, + width: 220, + height: 80, + ...overrides, + }; +} + +describe("renderedGraphsEqual", () => { + const base: RenderedGraph = { + nodes: [renderedNode({ id: "a" }), renderedNode({ id: "b" })], + edges: [{ id: "a>b", sourceId: "a", targetId: "b" }], + }; + + it("returns false when the previous input is null", () => { + expect(renderedGraphsEqual(null, base)).toBe(false); + }); + + it("returns true for the same graph regardless of node and edge order", () => { + const reordered: RenderedGraph = { + nodes: [renderedNode({ id: "b" }), renderedNode({ id: "a" })], + edges: [{ id: "a>b", sourceId: "a", targetId: "b" }], + }; + expect(renderedGraphsEqual(base, reordered)).toBe(true); + }); + + it("returns false when a node count differs", () => { + const extra: RenderedGraph = { nodes: [...base.nodes, renderedNode({ id: "c" })], edges: base.edges }; + expect(renderedGraphsEqual(base, extra)).toBe(false); + }); + + it("returns false when a measured size differs", () => { + const widened: RenderedGraph = { + nodes: [renderedNode({ id: "a", width: 221 }), renderedNode({ id: "b" })], + edges: base.edges, + }; + expect(renderedGraphsEqual(base, widened)).toBe(false); + }); + + it("returns false when containment differs", () => { + const reparented: RenderedGraph = { + nodes: [renderedNode({ id: "a", parentId: "b" }), renderedNode({ id: "b" })], + edges: base.edges, + }; + expect(renderedGraphsEqual(base, reparented)).toBe(false); + }); + + it("returns false when the edge set differs", () => { + const rewired: RenderedGraph = { + nodes: base.nodes, + edges: [{ id: "b>a", sourceId: "b", targetId: "a" }], + }; + expect(renderedGraphsEqual(base, rewired)).toBe(false); + }); +}); + +function makeClientGraph(nodes: GraphNode[], edges: GraphEdge[] = [], errorCount = 0): ClientGraph { + return { + nodes: new Map(nodes.map((graphNode) => [graphNode.id, graphNode])), + edges: new Map(edges.map((edge) => [edge.id, edge])), + errorCount, + }; +} + +describe("clientGraphsRenderEqually", () => { + it("reports equal for a graph rebuilt from identical parts", () => { + expect(clientGraphsRenderEqually(makeClientGraph([node()]), makeClientGraph([node()]))).toBe(true); + }); + + it("ignores node and edge ordering", () => { + const a = node({ id: "a" }); + const b = node({ id: "b" }); + + expect(clientGraphsRenderEqually(makeClientGraph([a, b]), makeClientGraph([b, a]))).toBe(true); + }); + + it("ignores fields the canvas never reads", () => { + const before = makeClientGraph([node({ symbolName: "before" })]); + const after = makeClientGraph([node({ symbolName: "after" })]); + + expect(clientGraphsRenderEqually(before, after)).toBe(true); + }); + + it("treats two nulls as equal but a null on one side as a change", () => { + expect(clientGraphsRenderEqually(null, null)).toBe(true); + expect(clientGraphsRenderEqually(null, makeClientGraph([node()]))).toBe(false); + expect(clientGraphsRenderEqually(makeClientGraph([node()]), null)).toBe(false); + }); + + it.each([ + ["id", { id: "other" }], + ["type", { type: "Microsoft.Web/sites" }], + ["isCollection", { isCollection: true }], + ["hasChildren", { hasChildren: true }], + ["hasError", { hasError: true }], + ])("reports a change when %s differs", (_field, overrides) => { + const before = makeClientGraph([node()]); + const after = makeClientGraph([node(overrides as Partial)]); + + expect(clientGraphsRenderEqually(before, after)).toBe(false); + }); + + it("reports a change when the error count differs", () => { + expect(clientGraphsRenderEqually(makeClientGraph([node()], [], 0), makeClientGraph([node()], [], 1))).toBe(false); + }); + + it("reports a change when an edge is added or retargeted", () => { + const nodes = [node({ id: "a" }), node({ id: "b" })]; + const none = makeClientGraph(nodes); + const one = makeClientGraph(nodes, [{ id: "e", sourceId: "a", targetId: "b" }]); + const other = makeClientGraph(nodes, [{ id: "e", sourceId: "b", targetId: "a" }]); + + expect(clientGraphsRenderEqually(none, one)).toBe(false); + expect(clientGraphsRenderEqually(one, other)).toBe(false); + }); +}); diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/__tests__/graph-update-coordinator.test.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/__tests__/graph-update-coordinator.test.ts new file mode 100644 index 00000000000..2cbd6dc22c4 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/__tests__/graph-update-coordinator.test.ts @@ -0,0 +1,372 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { GraphLayoutMode, GraphLayoutResult, GraphUpdateOperations } from "../graph-update-coordinator"; + +import { describe, expect, it } from "vitest"; +import { GraphUpdateCoordinator } from "../graph-update-coordinator"; + +/** A promise the test resolves by hand, so request completion order is exact rather than timed. */ +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + + return { promise, resolve }; +} + +/** Lets a test observe whether a promise has settled without waiting on it. */ +function trackSettled(promise: Promise) { + const state = { settled: false }; + + void promise.then(() => { + state.settled = true; + }); + + return state; +} + +/** Yield long enough for any already-resolved promise chain to run to completion. */ +async function flush() { + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } +} + +type Call = "fetch" | "apply" | `layout:${GraphLayoutMode}`; + +/** + * Records the operation sequence and lets each test decide what every call returns. + * + * Operations resolve immediately unless the test installs a gate, which is how a mutation can be made + * to start while a response is in flight, or a request made to arrive mid-pass. + */ +function createHarness(overrides: Partial<{ layoutRequired: boolean; layoutResults: GraphLayoutResult[] }> = {}) { + const calls: Call[] = []; + const layoutResults = [...(overrides.layoutResults ?? [])]; + let fetchCount = 0; + let fetchGateAt = 1; + let fetchGate: { promise: Promise; resolve: (value: void) => void } | null = null; + let layoutGate: { promise: Promise; resolve: (value: void) => void } | null = null; + let failFetch = false; + + const operations: GraphUpdateOperations<{ id: number }> = { + fetchUpdate: async () => { + calls.push("fetch"); + fetchCount += 1; + if (fetchGate && fetchCount === fetchGateAt) { + await fetchGate.promise; + } + if (failFetch) { + failFetch = false; + throw new Error("host unavailable"); + } + return { id: calls.length }; + }, + applyUpdate: async () => { + calls.push("apply"); + return { layoutRequired: overrides.layoutRequired ?? false }; + }, + runGraphLayout: async (mode) => { + calls.push(`layout:${mode}`); + if (layoutGate) { + await layoutGate.promise; + } + return layoutResults.shift() ?? "completed"; + }, + }; + + const coordinator = new GraphUpdateCoordinator(operations); + + return { + calls, + operations, + coordinator, + /** Stall the nth `fetchUpdate`, so a request can be made to arrive while it is in flight. */ + gateFetch(occurrence = 1) { + fetchGateAt = occurrence; + fetchGate = deferred(); + return fetchGate; + }, + openFetchGate() { + fetchGate?.resolve(); + fetchGate = null; + }, + /** Make the next `fetchUpdate` reject, as a failed host round-trip would. */ + failNextFetch() { + failFetch = true; + }, + gateLayout() { + layoutGate = deferred(); + return layoutGate; + }, + openLayoutGate() { + layoutGate?.resolve(); + layoutGate = null; + }, + }; +} + +describe("update and layout ordering", () => { + it("runs a layout after an update that reports one is required", async () => { + const harness = createHarness({ layoutRequired: true }); + + await harness.coordinator.requestUpdate(); + + expect(harness.calls).toEqual(["fetch", "apply", "layout:auto"]); + }); + + it("does not lay out when the update reports none is required", async () => { + const harness = createHarness({ layoutRequired: false }); + + await harness.coordinator.requestUpdate(); + + expect(harness.calls).toEqual(["fetch", "apply"]); + }); + + it("reconciles before laying out, so Reset Layout applies to the reconciled graph", async () => { + const harness = createHarness(); + const gate = harness.gateFetch(); + + const first = harness.coordinator.requestUpdate(); + // Reset Layout arrives while the update is in flight. + const reset = harness.coordinator.requestResetGraphLayout(); + gate.resolve(); + await Promise.all([first, reset]); + + expect(harness.calls).toEqual(["fetch", "apply", "layout:reset"]); + }); + + it("coalesces notifications that arrive during a pass into one follow-up", async () => { + const harness = createHarness(); + const gate = harness.gateFetch(); + + const first = harness.coordinator.requestUpdate(); + const second = harness.coordinator.requestUpdate(); + const third = harness.coordinator.requestUpdate(); + harness.openFetchGate(); + gate.resolve(); + await Promise.all([first, second, third]); + + // Three notifications, two passes: the one running plus a single coalesced follow-up. + expect(harness.calls).toEqual(["fetch", "apply", "fetch", "apply"]); + }); +}); + +describe("graphChanged handling", () => { + it("reconciles and retries the layout, so a hidden graph is still revealed", async () => { + const harness = createHarness({ layoutRequired: true, layoutResults: ["graphChanged", "completed"] }); + + await harness.coordinator.requestUpdate(); + + // The retry must happen: the first layout never revealed the graph. + expect(harness.calls).toEqual(["fetch", "apply", "layout:auto", "fetch", "apply", "layout:auto"]); + }); + + it("keeps a reset layout a reset across the retry", async () => { + const harness = createHarness({ layoutResults: ["graphChanged", "completed"] }); + + await harness.coordinator.requestResetGraphLayout(); + + expect(harness.calls).toEqual(["layout:reset", "fetch", "apply", "layout:reset"]); + }); +}); + +describe("layout mode precedence", () => { + it("does not downgrade a pending reset to an automatic layout", async () => { + const harness = createHarness({ layoutRequired: true }); + const gate = harness.gateFetch(); + + // The update is in flight and will ask for an automatic layout; Reset Layout arrives meanwhile. + const update = harness.coordinator.requestUpdate(); + const reset = harness.coordinator.requestResetGraphLayout(); + gate.resolve(); + await Promise.all([update, reset]); + + // One layout, and it is the reset. + expect(harness.calls).toEqual(["fetch", "apply", "layout:reset"]); + }); + + it("upgrades a pending automatic layout to a reset", async () => { + const harness = createHarness({ layoutRequired: true, layoutResults: ["graphChanged", "completed"] }); + + // The automatic layout reports graphChanged, so it is re-pended behind a reconciliation. Reset + // Layout arrives while that retry's fetch is in flight, when an automatic layout is already owed. + const gate = harness.gateFetch(2); + const update = harness.coordinator.requestUpdate(); + await flush(); + + const reset = harness.coordinator.requestResetGraphLayout(); + gate.resolve(); + harness.openFetchGate(); + await Promise.all([update, reset]); + + // The retry must be a reset. An automatic layout short-circuits when measured sizes are unchanged, + // which is exactly the case Reset Layout exists to override. + expect(harness.calls).toEqual(["fetch", "apply", "layout:auto", "fetch", "apply", "layout:reset"]); + }); +}); + +describe("mutation interlock", () => { + it("abandons a response that arrives after a mutation starts, then reconciles again", async () => { + const harness = createHarness(); + const gate = harness.gateFetch(); + + const update = harness.coordinator.requestUpdate(); + const mutation = harness.coordinator.runMutation(async () => { + // The update's response lands mid-mutation; applying it would place the new node by layout. + gate.resolve(); + await Promise.resolve(); + }); + + await Promise.all([update, mutation]); + + // First fetch abandoned without an apply; the mutation's follow-up reconciles. + expect(harness.calls).toEqual(["fetch", "fetch", "apply"]); + }); + + it("reconciles after a mutation completes", async () => { + const harness = createHarness(); + + await harness.coordinator.runMutation(async () => {}); + + expect(harness.calls).toEqual(["fetch", "apply"]); + }); + + it("serializes mutations", async () => { + const harness = createHarness(); + const order: string[] = []; + const first = deferred(); + + const one = harness.coordinator.runMutation(async () => { + order.push("one:start"); + await first.promise; + order.push("one:end"); + }); + const two = harness.coordinator.runMutation(async () => { + order.push("two:start"); + }); + + first.resolve(); + await Promise.all([one, two]); + + expect(order).toEqual(["one:start", "one:end", "two:start"]); + }); + + it("keeps running mutations after one rejects", async () => { + const harness = createHarness(); + const order: string[] = []; + + const failing = harness.coordinator.runMutation(async () => { + order.push("failing"); + throw new Error("edit rejected"); + }); + const following = harness.coordinator.runMutation(async () => { + order.push("following"); + }); + + await expect(failing).rejects.toThrow("edit rejected"); + await following; + + expect(order).toEqual(["failing", "following"]); + }); +}); + +describe("request completion", () => { + it("keeps a mid-pass Reset Layout pending until its layout finishes", async () => { + const harness = createHarness({ layoutRequired: true }); + const fetchGate = harness.gateFetch(); + const layoutGate = harness.gateLayout(); + + const update = harness.coordinator.requestUpdate(); + const reset = harness.coordinator.requestResetGraphLayout(); + const resetState = trackSettled(reset); + + // The reset arrived while the update pass was already draining, so it only recorded work. + fetchGate.resolve(); + harness.openFetchGate(); + await flush(); + + expect(harness.calls).toEqual(["fetch", "apply", "layout:reset"]); + expect(resetState.settled).toBe(false); + + layoutGate.resolve(); + harness.openLayoutGate(); + await Promise.all([update, reset]); + + expect(resetState.settled).toBe(true); + }); + + it("runs one layout when Reset Layout is awaited before being requested again", async () => { + const harness = createHarness(); + const layoutGate = harness.gateLayout(); + + // A caller that deduplicates on the returned promise — as `useResetGraphLayout` does — holds its lock + // for as long as the layout runs, so the second request never reaches the coordinator. + const first = harness.coordinator.requestResetGraphLayout(); + const firstState = trackSettled(first); + + await flush(); + expect(firstState.settled).toBe(false); + + layoutGate.resolve(); + harness.openLayoutGate(); + await first; + + await harness.coordinator.requestResetGraphLayout(); + + expect(harness.calls).toEqual(["layout:reset", "layout:reset"]); + }); + + it("keeps a mutation pending until the reconciliation it triggers completes", async () => { + const harness = createHarness({ layoutRequired: true }); + const layoutGate = harness.gateLayout(); + + // A pass is already draining and stalled in its layout. + const update = harness.coordinator.requestUpdate(); + await flush(); + + const order: string[] = []; + const mutation = harness.coordinator.runMutation(async () => { + order.push("mutate"); + }); + void mutation.then(() => order.push("mutation settled")); + const following = harness.coordinator.runMutation(async () => { + order.push("next mutation"); + }); + + await flush(); + + // The mutation's own reconciliation is owed but has not run, so it must not have settled and let + // the next queued mutation prepare an edit against a document version it has not yet seen. + expect(order).toEqual(["mutate"]); + + layoutGate.resolve(); + harness.openLayoutGate(); + await Promise.all([update, mutation, following]); + + expect(order).toEqual(["mutate", "mutation settled", "next mutation"]); + }); + + it("releases a mid-pass caller when an operation fails", async () => { + const harness = createHarness(); + const gate = harness.gateFetch(); + + harness.failNextFetch(); + + // A pass is in flight and about to fail; Reset Layout arrives while it is still draining. + const update = harness.coordinator.requestUpdate(); + const reset = harness.coordinator.requestResetGraphLayout(); + const resetState = trackSettled(reset); + + gate.resolve(); + harness.openFetchGate(); + await expect(update).rejects.toThrow("host unavailable"); + await flush(); + + // Without this, the promise never settles at all: useResetGraphLayout awaits it to release its + // deduplication lock, so the button would stay dead for the rest of the session. + expect(resetState.settled).toBe(true); + }); +}); diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/messages.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/api.ts similarity index 59% rename from src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/messages.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/api.ts index 981e7ffa0df..04ab6a61155 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/messages.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/api.ts @@ -1,91 +1,44 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -export interface DeploymentGraph { - nodes: DeploymentGraphNode[]; - edges: DeploymentGraphEdge[]; - errorCount: number; -} - -export interface DeploymentGraphNode { - id: string; - type: string; - isCollection: boolean; - range: Range; - hasChildren: boolean; - hasError: boolean; - filePath: string; -} +import type { ResourceTypeReference } from "./types"; -export interface DeploymentGraphEdge { - sourceId: string; - targetId: string; -} +import { defineNotification, defineRequest, useWebviewMessageChannel } from "@vscode-bicep-ui/messaging"; +import { useMemo } from "react"; -export interface Range { - start: Position; - end: Position; -} +// ── Source locations ── -export interface Position { +interface Position { line: number; character: number; } -// ── Notification: Webview → Extension ── -// Sent when the user wants to navigate to a source range -export const REVEAL_FILE_RANGE_NOTIFICATION = "revealFileRange"; - -export interface RevealFileRangePayload { - filePath: string; - range: Range; +export interface Range { + start: Position; + end: Position; } // ── Notification: Webview → Extension ── -// Sent when the user wants to reveal a node whose source location is resolved on demand. The canonical -// graph no longer carries range/filePath, so the webview asks the host (which asks the server) to resolve -// and reveal the node by id. This keeps volatile source locations out of the per-edit graph diff. -export const REVEAL_NODE_SOURCE_NOTIFICATION = "revealNodeSource"; +// Sent when the user wants to reveal a node's source. The canonical graph carries no source +// locations — they shift on edits that change nothing visible, so including them would put churn in +// every diff — and the host asks the server to resolve the node id on demand instead. +export const revealNodeSource = defineNotification("revealNodeSource"); -export interface RevealNodeSourcePayload { +interface RevealNodeSourceParams { nodeId: string; } -// ── Notification: Webview → Extension ── -// Sent when the user clicks "Show errors" to open the VS Code Problems panel -export const SHOW_PROBLEMS_PANEL_NOTIFICATION = "showProblemsPanel"; - -// ── Notification: Webview → Extension ── -// Sent when the webview has initialized and is ready to receive data -export const READY_NOTIFICATION = "ready"; - -// ── Motion policy ── - -export type MotionPolicy = "system" | "reduce" | "animate"; -export const GET_MOTION_POLICY_REQUEST = "motionPolicy/get"; -export const MOTION_POLICY_DID_CHANGE_NOTIFICATION = "motionPolicy/didChange"; - -// ── Experimental resource creation ── - -export const GET_RESOURCE_CREATION_ENABLEMENT_REQUEST = "resourceCreation/isEnabled"; -export const RESOURCE_CREATION_ENABLEMENT_DID_CHANGE_NOTIFICATION = "resourceCreation/enablementDidChange"; - // ── Resource creation ── -export const CREATE_RESOURCE_REQUEST = "resources/create"; - -export interface VisualResourceTypeReference { - fullyQualifiedType: string; - apiVersion: string; -} +export const createResource = defineRequest("resources/create"); -export interface CreateVisualResourceRequest { +export interface CreateResourceParams { version: 1; operationId: string; - resourceType: VisualResourceTypeReference; + resourceType: ResourceTypeReference; } -export interface CreateVisualResourceResponse { +export interface CreateResourceResult { version: 1; operationId: string; expectedNodeId: string; @@ -93,7 +46,16 @@ export interface CreateVisualResourceResponse { unresolvedRequiredProperties: string[]; } -export interface CreateVisualResourceError { +/** + * The error shape the extension host sends when resource creation fails. + * + * Nothing references this today: the webview shows `error.message` without distinguishing codes, and + * the host builds these objects inline, so the interface currently enforces nothing on either side of + * the wire. It is kept because the code enumeration is real protocol knowledge worth not losing — + * `retryable` in particular is the signal a retry affordance would need. `export` is what keeps it + * alive; without it `noUnusedLocals` reports it as dead. + */ +export interface CreateResourceErrorResult { version: 1; operationId?: string; code: @@ -108,48 +70,39 @@ export interface CreateVisualResourceError { } // ────────────────────────────────────────────────────────────────────────── -// Server-driven visual graph protocol +// Server-driven graph protocol // -// The extension announces that the graph may have changed, then the webview pulls -// topology/metadata patches and a measured layout through request/response messages: +// The extension announces that the graph may have changed and the webview pulls the update: // 1. Extension → Webview: DOCUMENT_DID_CHANGE notification ("the graph may have changed"). // 2. Webview → Extension: GET_GRAPH_UPDATE request carrying the graph it currently displays. // 3. Webview → Extension: GET_GRAPH_LAYOUT request after rendered node sizes are measured. // ────────────────────────────────────────────────────────────────────────── -// ── Notification: Extension → Webview ── -// "The graph may have changed; request an update when ready." -export const DOCUMENT_DID_CHANGE_NOTIFICATION = "documentDidChange"; - -export interface DocumentDidChangePayload { - documentUri: string; -} - // ── Request: Webview → Extension ── // The webview submits the graph it currently displays (null on first load) and receives a // complete patch delta transforming it into the server's latest graph. -export const GET_GRAPH_UPDATE_REQUEST = "getGraphUpdate"; +export const getGraphUpdate = defineRequest("getGraphUpdate"); -export interface GetGraphUpdateRequest { +export interface GetGraphUpdateParams { current: RenderedGraph | null; } -export interface GetGraphUpdateResponse { +export interface GetGraphUpdateResult { patches: GraphPatch[]; } -export const GET_GRAPH_LAYOUT_REQUEST = "getGraphLayout"; +export const getGraphLayout = defineRequest("getGraphLayout"); -export interface GetGraphLayoutRequest { +export interface GetGraphLayoutParams { current: RenderedGraph; } -export interface GetGraphLayoutResponse { +export interface GetGraphLayoutResult { status: "ok" | "graphChanged" | "layoutFailed"; patches: GraphPatch[]; } -export type GraphNodeKind = "resource" | "module"; +type GraphNodeKind = "resource" | "module"; /** The graph as currently rendered by the webview, sent with each update request for the server to diff against. */ export interface RenderedGraph { @@ -174,7 +127,7 @@ export interface RenderedGraphNode { height: number; } -export interface RenderedGraphEdge { +interface RenderedGraphEdge { id: string; sourceId: string; targetId: string; @@ -238,3 +191,27 @@ export type GraphPatch = | { op: "setNodeLayout"; nodeId: string; layout: NodeLayout } | { op: "setGraphBounds"; bounds: GraphBounds } | { op: "setErrorCount"; errorCount: number }; + +/** + * The deployment graph's operations against the extension host. + * + * Callers get bound methods rather than a channel and a descriptor to combine themselves, so this is + * the only place in the feature that touches the transport, and a test can substitute the whole + * surface by stubbing this hook. + * + * Only imperative calls belong here. Subscriptions stay declarative at the call site via + * `useNotification(descriptor, handler)`, which composes better with React's lifecycle. + */ +export function useCanvasApi() { + const channel = useWebviewMessageChannel(); + + return useMemo( + () => ({ + fetchUpdate: (current: RenderedGraph | null) => channel.request(getGraphUpdate, { current }), + fetchGraphLayout: (current: RenderedGraph) => channel.request(getGraphLayout, { current }), + createResource: (params: CreateResourceParams) => channel.request(createResource, params), + revealNodeSource: (nodeId: string) => channel.notify(revealNodeSource, { nodeId }), + }), + [channel], + ); +} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/atoms.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/atoms.ts new file mode 100644 index 00000000000..988beff6f37 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/atoms.ts @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { Point } from "@/lib/math"; +import type { ResourceTypeReference } from "./types"; + +import { atom } from "jotai"; +import { atomFamily } from "jotai-family"; + +export interface PendingResource { + operationId: string; + resourceType: ResourceTypeReference; + origin: Point; + expectedNodeId?: string; +} + +export const pendingResourcesAtom = atom([]); +export const resourceCreationErrorAtom = atom(null); +export const resourceNodeIsCommittingAtomFamily = atomFamily((_nodeId: string) => atom(false)); + +/** + * The pending-resource lifecycle, as four transitions rather than array surgery at the call site. + * + * A resource is optimistically pending from the moment the user drops it, gains an `expectedNodeId` + * once the host has prepared the edit, and is removed when the canonical node arrives — or when the + * attempt fails. + */ + +export const beginResourceCreationAtom = atom(null, (_get, set, resource: PendingResource) => { + set(pendingResourcesAtom, (pending) => [...pending, resource]); + set(resourceCreationErrorAtom, null); +}); + +/** Correlate a pending resource with the canonical node the host says it will become. */ +export const bindExpectedNodeAtom = atom( + null, + (_get, set, { operationId, expectedNodeId }: { operationId: string; expectedNodeId: string }) => { + set(pendingResourcesAtom, (pending) => + pending.map((resource) => (resource.operationId === operationId ? { ...resource, expectedNodeId } : resource)), + ); + }, +); + +export const failResourceCreationAtom = atom( + null, + (_get, set, { operationId, message }: { operationId: string; message: string }) => { + set(pendingResourcesAtom, (pending) => pending.filter((resource) => resource.operationId !== operationId)); + set(resourceCreationErrorAtom, message); + }, +); + +/** Drop the placeholders whose canonical nodes have now arrived. */ +export const commitPendingResourcesAtom = atom(null, (_get, set, committedNodeIds: ReadonlySet) => { + set(pendingResourcesAtom, (pending) => + pending.filter((resource) => !resource.expectedNodeId || !committedNodeIds.has(resource.expectedNodeId)), + ); +}); diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/Canvas.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/Canvas.tsx new file mode 100644 index 00000000000..388d08cf661 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/Canvas.tsx @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ReactNode } from "react"; +import type { Point } from "@/lib/math"; +import type { CanvasActions } from "../context/CanvasActionsContext"; +import type { ResourceTypeReference } from "../types"; + +import { useGetPanZoomDimensions, useGetPanZoomTransform } from "@vscode-bicep-ui/components"; +import { useNotification } from "@vscode-bicep-ui/messaging"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useCallback, useMemo, useState } from "react"; +import { styled, ThemeProvider } from "styled-components"; +import { + effectiveExportThemeAtom, + ExportAreaCover, + exportCanvasElementAtom, + ExportPreviewLayer, +} from "@/features/export"; +import { documentDidChange } from "@/hooks"; +import { Graph, useFitViewToBounds, Viewport } from "@/lib/graph"; +import { CanvasActionsContext } from "../context/CanvasActionsContext"; +import { useCanvasController } from "../hooks/use-canvas-controller"; +import { NodeContentProvider } from "./nodes/NodeContentProvider"; +import { PendingResourceLayer } from "./PendingResourceLayer"; + +const $CanvasWrapper = styled.div` + position: absolute; + inset: 0; +`; + +function viewportToGraphPoint( + clientPoint: Point, + canvasBounds: Pick, + transform: { x: number; y: number; scale: number }, +): Point | null { + if ( + !Number.isFinite(clientPoint.x) || + !Number.isFinite(clientPoint.y) || + !Number.isFinite(transform.x) || + !Number.isFinite(transform.y) || + !Number.isFinite(transform.scale) || + transform.scale <= 0 + ) { + return null; + } + + return { + x: (clientPoint.x - canvasBounds.left - transform.x) / transform.scale, + y: (clientPoint.y - canvasBounds.top - transform.y) / transform.scale, + }; +} + +export interface CanvasProps { + /** Layered over the canvas and able to call `useCanvasActions`. */ + children: ReactNode; +} + +/** The Bicep design surface, its runtime, and the actions exposed to layered features. */ +export function Canvas({ children }: CanvasProps) { + const getPanZoomDimensions = useGetPanZoomDimensions(); + const getPanZoomTransform = useGetPanZoomTransform(); + const getViewportCenter = useCallback(() => { + const { width, height } = getPanZoomDimensions(); + return { x: width / 2, y: height / 2 }; + }, [getPanZoomDimensions]); + const fitViewToBounds = useFitViewToBounds(); + const { requestGraphUpdate, resetGraphLayout, createResourceAt } = useCanvasController( + getViewportCenter, + fitViewToBounds, + ); + const exportTheme = useAtomValue(effectiveExportThemeAtom); + const setExportCanvasElement = useSetAtom(exportCanvasElementAtom); + const [canvasElement, setCanvasElement] = useState(null); + + useNotification( + documentDidChange, + useCallback(() => { + void requestGraphUpdate(); + }, [requestGraphUpdate]), + ); + + const handleCanvasRef = useCallback( + (element: HTMLDivElement | null) => { + setCanvasElement(element); + setExportCanvasElement(element); + }, + [setExportCanvasElement], + ); + + const canPlaceResourceAt = useCallback( + ({ x, y }: Point) => { + if (!canvasElement) { + return false; + } + + const bounds = canvasElement.getBoundingClientRect(); + const elementAtPoint = document.elementFromPoint(x, y); + + return ( + !!elementAtPoint && + canvasElement.contains(elementAtPoint) && + x >= bounds.left && + x <= bounds.right && + y >= bounds.top && + y <= bounds.bottom + ); + }, + [canvasElement], + ); + + const createResource = useCallback( + async (resourceType: ResourceTypeReference, clientPoint?: Point) => { + if (!canvasElement) { + return; + } + + const bounds = canvasElement.getBoundingClientRect(); + // No point means "wherever this surface puts things by default", which for keyboard + // activation is the middle of the visible canvas. + const point = clientPoint ?? { + x: bounds.left + bounds.width / 2, + y: bounds.top + bounds.height / 2, + }; + const origin = viewportToGraphPoint(point, bounds, getPanZoomTransform()); + + if (origin) { + await createResourceAt(resourceType, origin); + } + }, + [canvasElement, createResourceAt, getPanZoomTransform], + ); + + const actions = useMemo( + () => ({ createResource, canPlaceResourceAt, resetGraphLayout }), + [canPlaceResourceAt, createResource, resetGraphLayout], + ); + + return ( + + + + <$CanvasWrapper ref={handleCanvasRef}> + + + + + + + + + + {children} + + ); +} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-creation/PendingResourceLayer.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/PendingResourceLayer.tsx similarity index 87% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/resource-creation/PendingResourceLayer.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/PendingResourceLayer.tsx index 42a85c0a335..9ddb8cefb2f 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-creation/PendingResourceLayer.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/PendingResourceLayer.tsx @@ -4,8 +4,8 @@ import { PanZoomTransformed } from "@vscode-bicep-ui/components"; import { useAtomValue } from "jotai"; import styled from "styled-components"; -import { pendingResourcesAtom } from "./atoms"; -import { ResourcePreviewCard } from "./ResourcePreviewCard"; +import { pendingResourcesAtom } from "../atoms"; +import { ResourceNodePreview } from "./nodes/ResourceNodePreview"; const $Layer = styled(PanZoomTransformed)` position: absolute; @@ -28,7 +28,7 @@ export function PendingResourceLayer() { {pendingResources.map((pending) => ( <$PendingPosition key={pending.operationId} style={{ left: pending.origin.x, top: pending.origin.y }}>
- diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-creation/ResourceCreationError.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/ResourceCreationError.tsx similarity index 96% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/resource-creation/ResourceCreationError.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/ResourceCreationError.tsx index be97db0e6ac..cb6ae9d0d62 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-creation/ResourceCreationError.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/ResourceCreationError.tsx @@ -3,7 +3,7 @@ import { useAtomValue, useSetAtom } from "jotai"; import styled from "styled-components"; -import { resourceCreationErrorAtom } from "./atoms"; +import { resourceCreationErrorAtom } from "../atoms"; const $ResourceCreationError = styled.div` position: absolute; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/visualization/ModuleDeclaration.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/ModuleNode.tsx similarity index 93% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/visualization/ModuleDeclaration.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/ModuleNode.tsx index 73d931a50bc..ad665f91cf1 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/visualization/ModuleDeclaration.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/ModuleNode.tsx @@ -1,22 +1,17 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { Range } from "@/lib/messaging"; - import { AzureIcon } from "@vscode-bicep-ui/components"; import { useAtomValue } from "jotai"; import { styled } from "styled-components"; import { focusedNodeIdAtom } from "@/lib/graph"; -export interface ModuleDeclarationProps { +export interface ModuleNodeProps { id: string; data: { symbolicName: string; - path: string; isCollection?: boolean; hasError?: boolean; - range?: Range; - filePath?: string; }; } @@ -92,7 +87,7 @@ const $SymbolicNameContainer = styled.div` text-overflow: ellipsis; `; -export function ModuleDeclaration({ id, data }: ModuleDeclarationProps) { +export function ModuleNode({ id, data }: ModuleNodeProps) { const { symbolicName, isCollection, hasError } = data; const focusedNodeId = useAtomValue(focusedNodeIdAtom); const isFocused = focusedNodeId === id; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/NodeContentProvider.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/NodeContentProvider.tsx new file mode 100644 index 00000000000..2b3fc739445 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/NodeContentProvider.tsx @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ReactNode } from "react"; +import type { NodeContentRenderProps, NodeKind } from "@/lib/graph"; +import type { ModuleNodeProps } from "./ModuleNode"; +import type { ResourceNodeProps } from "./ResourceNode"; + +import { useStore } from "jotai"; +import { useHydrateAtoms } from "jotai/utils"; +import { useEffect, useRef } from "react"; +import { styled } from "styled-components"; +import { nodeConfigAtom } from "@/lib/graph"; +import { useCanvasApi } from "../../api"; +import { ModuleNode } from "./ModuleNode"; +import { ResourceNode } from "./ResourceNode"; + +/** Extra headroom above a compound node's children, so the module label has room to sit. */ +const COMPOUND_NODE_LABEL_INSET = 50; + +const $NodeContent = styled.div` + display: contents; +`; + +function CanvasNodeContent({ kind, id, data }: NodeContentRenderProps & { kind: NodeKind }) { + const ref = useRef(null); + const api = useCanvasApi(); + + useEffect(() => { + const element = ref.current; + + if (!element) { + return; + } + + const revealSource = (event: MouseEvent) => { + event.stopPropagation(); + api.revealNodeSource(id); + }; + + element.addEventListener("dblclick", revealSource); + return () => element.removeEventListener("dblclick", revealSource); + }, [api, id]); + + return ( + <$NodeContent ref={ref}> + {kind === "compound" ? ( + + ) : ( + + )} + + ); +} + +function renderNodeContent(kind: NodeKind, { id, data }: NodeContentRenderProps) { + return ; +} + +/** + * Teaches the generic graph engine how to render Bicep node content. + * + * `lib/graph` is Bicep-agnostic and reaches product code only through `nodeConfigAtom`. Hydrating + * during render rather than in an effect guarantees the config is in place before any node mounts, + * and scoping the write to the store from context keeps it out of module scope so tests can supply + * their own store. + */ +export function NodeContentProvider({ children }: { children: ReactNode }) { + const store = useStore(); + const defaults = store.get(nodeConfigAtom); + + useHydrateAtoms([ + [ + nodeConfigAtom, + { + ...defaults, + padding: { ...defaults.padding, top: COMPOUND_NODE_LABEL_INSET }, + renderContent: renderNodeContent, + }, + ], + ] as const); + + return <>{children}; +} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/visualization/ResourceDeclaration.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/ResourceNode.tsx similarity index 87% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/visualization/ResourceDeclaration.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/ResourceNode.tsx index d32dead02a4..04d77716a59 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/visualization/ResourceDeclaration.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/ResourceNode.tsx @@ -1,35 +1,28 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { Range } from "@/lib/messaging"; - import { AzureIcon } from "@vscode-bicep-ui/components"; import { useAtom, useAtomValue } from "jotai"; import { motion } from "motion/react"; import { useEffect } from "react"; import { styled } from "styled-components"; -import { - RESOURCE_CREATION_TRANSITION, - RESOURCE_PREVIEW_CARD_HEIGHT, - RESOURCE_PREVIEW_CARD_WIDTH, - resourceNodeIsCommittingAtomFamily, -} from "@/features/resource-creation"; import { focusedNodeIdAtom } from "@/lib/graph"; -import { camelCaseToWords } from "@/lib/utils"; +import { EXPAND_TRANSITION } from "@/ui"; +import { camelCaseToWords } from "@/utils"; +import { resourceNodeIsCommittingAtomFamily } from "../../atoms"; +import { RESOURCE_NODE_PREVIEW_HEIGHT, RESOURCE_NODE_PREVIEW_WIDTH } from "./ResourceNodePreview"; -export interface ResourceDeclarationProps { +export interface ResourceNodeProps { id: string; data: { symbolicName: string; resourceType: string; isCollection?: boolean; hasError?: boolean; - range?: Range; - filePath?: string; }; } -const $ResourceDeclaration = styled(motion.div)<{ +const $ResourceNode = styled(motion.div)<{ $hasError?: boolean; $isCollection?: boolean; $isFocused?: boolean; @@ -130,7 +123,7 @@ const $ResourceTypeContainer = styled.div` text-overflow: ellipsis; `; -export function ResourceDeclaration({ id, data }: ResourceDeclarationProps) { +export function ResourceNode({ id, data }: ResourceNodeProps) { const { symbolicName, resourceType, isCollection, hasError } = data; const normalizedResourceType = resourceType ?? ""; const resourceTypeDisplayName = camelCaseToWords(normalizedResourceType.split("/").pop()); @@ -141,8 +134,8 @@ export function ResourceDeclaration({ id, data }: ResourceDeclarationProps) { const focusedNodeId = useAtomValue(focusedNodeIdAtom); const [isCommitting, setIsCommitting] = useAtom(resourceNodeIsCommittingAtomFamily(id)); const isFocused = focusedNodeId === id; - const initialCardScaleX = RESOURCE_PREVIEW_CARD_WIDTH / 220; - const initialCardScaleY = RESOURCE_PREVIEW_CARD_HEIGHT / 76; + const initialCardScaleX = RESOURCE_NODE_PREVIEW_WIDTH / 220; + const initialCardScaleY = RESOURCE_NODE_PREVIEW_HEIGHT / 76; const initialIconScaleX = 18 / (36 * initialCardScaleX); const initialIconScaleY = 18 / (36 * initialCardScaleY); @@ -154,10 +147,10 @@ export function ResourceDeclaration({ id, data }: ResourceDeclarationProps) { ); return ( - <$ResourceDeclaration + <$ResourceNode initial={isCommitting ? { scaleX: initialCardScaleX, scaleY: initialCardScaleY } : false} animate={{ scaleX: 1, scaleY: 1 }} - transition={RESOURCE_CREATION_TRANSITION} + transition={EXPAND_TRANSITION} onAnimationComplete={() => { if (isCommitting) { setIsCommitting(false); @@ -171,7 +164,7 @@ export function ResourceDeclaration({ id, data }: ResourceDeclarationProps) { <$ResourceIcon initial={isCommitting ? { scaleX: initialIconScaleX, scaleY: initialIconScaleY } : false} animate={{ scaleX: 1, scaleY: 1 }} - transition={RESOURCE_CREATION_TRANSITION} + transition={EXPAND_TRANSITION} > @@ -179,6 +172,6 @@ export function ResourceDeclaration({ id, data }: ResourceDeclarationProps) { <$SymbolicNameContainer>{symbolicName} <$ResourceTypeContainer>{resourceTypeDisplayName} - + ); } diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-creation/ResourcePreviewCard.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/ResourceNodePreview.tsx similarity index 75% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/resource-creation/ResourcePreviewCard.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/ResourceNodePreview.tsx index 282602b8028..493e25cd871 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-creation/ResourcePreviewCard.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/ResourceNodePreview.tsx @@ -4,18 +4,18 @@ import { AzureIcon } from "@vscode-bicep-ui/components"; import styled from "styled-components"; -export interface ResourcePreviewCardProps { +export interface ResourceNodePreviewProps { fullyQualifiedType: string; testId?: string; } -export const RESOURCE_PREVIEW_CARD_WIDTH = 140; -export const RESOURCE_PREVIEW_CARD_HEIGHT = 42; +export const RESOURCE_NODE_PREVIEW_WIDTH = 140; +export const RESOURCE_NODE_PREVIEW_HEIGHT = 42; const $Card = styled.div` display: grid; - width: ${RESOURCE_PREVIEW_CARD_WIDTH}px; - height: ${RESOURCE_PREVIEW_CARD_HEIGHT}px; + width: ${RESOURCE_NODE_PREVIEW_WIDTH}px; + height: ${RESOURCE_NODE_PREVIEW_HEIGHT}px; grid-template-columns: 20px minmax(0, 1fr); align-items: center; gap: 8px; @@ -38,7 +38,7 @@ const $TypeName = styled.span` overflow-wrap: anywhere; `; -export function ResourcePreviewCard({ fullyQualifiedType, testId }: ResourcePreviewCardProps) { +export function ResourceNodePreview({ fullyQualifiedType, testId }: ResourceNodePreviewProps) { return ( <$Card data-testid={testId}> diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/context/CanvasActionsContext.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/context/CanvasActionsContext.ts new file mode 100644 index 00000000000..0e144af4da0 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/context/CanvasActionsContext.ts @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { Point } from "@/lib/math"; +import type { ResourceTypeReference } from "../types"; + +import { createContext } from "react"; + +export interface CanvasActions { + /** + * Create a resource at a client-coordinate point. Omit `clientPoint` to use the canvas's default + * placement, which is how keyboard activation creates a resource. + */ + createResource: (resourceType: ResourceTypeReference, clientPoint?: Point) => Promise; + /** Whether a resource can be placed at a client-coordinate point on the canvas. */ + canPlaceResourceAt: (clientPoint: Point) => boolean; + /** Re-run graph layout without changing the user's viewport. */ + resetGraphLayout: () => Promise; +} + +export const CanvasActionsContext = createContext(undefined); diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/export/types.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/context/index.ts similarity index 55% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/export/types.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/context/index.ts index e052e7574e8..00dce5cb532 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/export/types.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/context/index.ts @@ -1,4 +1,4 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -export type ExportBackgroundMode = "transparent" | "solid"; +export { useCanvasActions } from "./use-canvas-actions"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/context/use-canvas-actions.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/context/use-canvas-actions.ts new file mode 100644 index 00000000000..0931e7ead95 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/context/use-canvas-actions.ts @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useContext } from "react"; +import { CanvasActionsContext } from "./CanvasActionsContext"; + +/** + * The canvas actions available to features layered over it. + * + * Context keeps this imperative surface scoped to the provider that owns the canvas runtime. + */ +export function useCanvasActions() { + const actions = useContext(CanvasActionsContext); + + if (!actions) { + throw new Error("useCanvasActions must be used within a Canvas."); + } + + return actions; +} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/graph-layout.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/graph-layout.ts new file mode 100644 index 00000000000..8c98411b9c1 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/graph-layout.ts @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { Box, Point } from "@/lib/math"; +import type { GraphBounds, GraphNode, GraphPatch, NodeLayout } from "./api"; + +/** + * The node metadata fields that influence rendered size and therefore layout. Keep this consistent + * with the rendered graph comparison and the language server's layout validation. + */ +const LAYOUT_AFFECTING_NODE_FIELDS = ["type", "isCollection", "hasChildren"] as const; + +type LayoutRelevantNode = Pick; + +interface LayoutRelevantGraph { + nodes: ReadonlyMap; +} + +/** Whether applying a patch may invalidate the current graph layout. */ +export function patchMayAffectLayout( + graph: LayoutRelevantGraph, + patch: GraphPatch, + explicitlyPlacedNodeIds: ReadonlySet = new Set(), +): boolean { + switch (patch.op) { + case "clearGraph": + case "removeNode": + case "addEdge": + case "removeEdge": + return true; + case "addNode": + return !explicitlyPlacedNodeIds.has(patch.node.id); + case "updateNode": { + const node = graph.nodes.get(patch.nodeId); + if (!node) { + return false; + } + const { changes } = patch; + return LAYOUT_AFFECTING_NODE_FIELDS.some( + (field) => changes[field] !== undefined && changes[field] !== null && changes[field] !== node[field], + ); + } + case "setNodeLayout": + case "setGraphBounds": + case "setErrorCount": + return false; + } +} + +/** Extract the server-computed positions and final bounds from a graph layout patch list. */ +export function extractGraphLayout(patches: readonly GraphPatch[]): { + nodeLayouts: Map; + graphBounds: GraphBounds | null; +} { + const nodeLayouts = new Map(); + let graphBounds: GraphBounds | null = null; + + for (const patch of patches) { + if (patch.op === "setNodeLayout") { + nodeLayouts.set(patch.nodeId, patch.layout); + } else if (patch.op === "setGraphBounds") { + graphBounds = patch.bounds; + } + } + + return { nodeLayouts, graphBounds }; +} + +/** + * Shift a server layout so the graph sits centred on `viewportCenter`. + * + * Returns the shifted positions and the graph's bounds in the same space, which is what fit-view + * needs. + */ +export function centerGraphLayout( + nodeLayouts: Map, + graphBounds: GraphBounds | null, + viewportCenter: Point, +): { nodeLayouts: Map; bounds: Box | null } { + if (!graphBounds) { + return { nodeLayouts, bounds: null }; + } + + const offsetX = viewportCenter.x - graphBounds.width / 2; + const offsetY = viewportCenter.y - graphBounds.height / 2; + const centeredLayouts = new Map(); + + for (const [nodeId, layout] of nodeLayouts) { + centeredLayouts.set(nodeId, { x: layout.x + offsetX, y: layout.y + offsetY }); + } + + return { + nodeLayouts: centeredLayouts, + bounds: { + min: { x: offsetX, y: offsetY }, + max: { x: offsetX + graphBounds.width, y: offsetY + graphBounds.height }, + }, + }; +} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/graph-model.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/graph-model.ts new file mode 100644 index 00000000000..0ddf6048d73 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/graph-model.ts @@ -0,0 +1,180 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { Box } from "@/lib/math"; +import type { GraphEdge, GraphNode, GraphPatch, NodeLayout, RenderedGraph } from "./api"; + +/** + * The client's local replica of the server's canonical graph. + * + * The server rebuilds its authoritative copy from the live compilation on every request, while this + * replica is only as fresh as the last patch applied to it. That is why the server validates each + * layout request against its own graph rather than trusting this one, and why a stale copy comes back + * as a `graphChanged` response. + * + * Keyed by id, and mutated in place, so applying a patch delta is O(1) per patch. + */ +export interface ClientGraph { + nodes: Map; + edges: Map; + errorCount: number; +} + +export function createClientGraph(): ClientGraph { + return { nodes: new Map(), edges: new Map(), errorCount: 0 }; +} + +/** Apply one server patch to the client's copy. Layout patches are collected into `nodeLayouts` instead. */ +export function applyGraphPatch(graph: ClientGraph, nodeLayouts: Map, patch: GraphPatch): void { + switch (patch.op) { + case "clearGraph": + graph.nodes.clear(); + graph.edges.clear(); + graph.errorCount = 0; + return; + case "addNode": + graph.nodes.set(patch.node.id, patch.node); + return; + case "removeNode": + graph.nodes.delete(patch.nodeId); + return; + case "updateNode": { + const node = graph.nodes.get(patch.nodeId); + if (node) { + // Only defined fields in `changes` override the node; the rest are left untouched. + const next = { ...node }; + for (const [key, value] of Object.entries(patch.changes)) { + if (value !== undefined && value !== null) { + (next as Record)[key] = value; + } + } + graph.nodes.set(patch.nodeId, next); + } + return; + } + case "addEdge": + graph.edges.set(patch.edge.id, patch.edge); + return; + case "removeEdge": + graph.edges.delete(patch.edgeId); + return; + case "setNodeLayout": + nodeLayouts.set(patch.nodeId, patch.layout); + return; + case "setGraphBounds": + // Graph bounds drive fit-view in the layout flow, not the client's graph. + return; + case "setErrorCount": + graph.errorCount = patch.errorCount; + return; + } +} + +/** + * Build the `RenderedGraph` to submit with a request: the topology the webview holds plus the size it + * has measured for each node. + * + * `measuredBoxes` supplies those sizes, keyed by node id; a node with no entry reports zero, which is + * the state before it has been laid out and measured. + */ +export function buildRenderedGraph(graph: ClientGraph, measuredBoxes: ReadonlyMap): RenderedGraph { + return { + nodes: [...graph.nodes.values()].map((node) => { + const box = measuredBoxes.get(node.id); + + return { + id: node.id, + kind: node.kind, + parentId: node.parentId, + type: node.type, + isCollection: node.isCollection, + hasChildren: node.hasChildren, + hasError: node.hasError, + width: box ? box.max.x - box.min.x : 0, + height: box ? box.max.y - box.min.y : 0, + }; + }), + edges: [...graph.edges.values()].map((edge) => ({ + id: edge.id, + sourceId: edge.sourceId, + targetId: edge.targetId, + })), + }; +} + +/** + * Whether two client graphs would produce the same canvas. + * + * Compares exactly the fields the apply path reads. Nodes and edges are keyed by id, so ordering is + * irrelevant. + */ +export function clientGraphsRenderEqually(left: ClientGraph | null, right: ClientGraph | null): boolean { + if (left === right) { + return true; + } + + if (!left || !right) { + return false; + } + + if ( + left.errorCount !== right.errorCount || + left.nodes.size !== right.nodes.size || + left.edges.size !== right.edges.size + ) { + return false; + } + + for (const [nodeId, rightNode] of right.nodes) { + const leftNode = left.nodes.get(nodeId); + + if ( + !leftNode || + leftNode.type !== rightNode.type || + leftNode.isCollection !== rightNode.isCollection || + leftNode.hasChildren !== rightNode.hasChildren || + leftNode.hasError !== rightNode.hasError + ) { + return false; + } + } + + for (const [edgeId, rightEdge] of right.edges) { + const leftEdge = left.edges.get(edgeId); + + if (!leftEdge || leftEdge.sourceId !== rightEdge.sourceId || leftEdge.targetId !== rightEdge.targetId) { + return false; + } + } + + return true; +} + +/** + * Whether two measured graphs are equivalent layout inputs: the same node set, containment, + * measured sizes, and edge set. Node and edge order is irrelevant. + */ +export function renderedGraphsEqual(left: RenderedGraph | null, right: RenderedGraph): boolean { + if (!left || left.nodes.length !== right.nodes.length || left.edges.length !== right.edges.length) { + return false; + } + + const leftNodes = new Map(left.nodes.map((node) => [node.id, node])); + + for (const rightNode of right.nodes) { + const leftNode = leftNodes.get(rightNode.id); + if ( + !leftNode || + leftNode.kind !== rightNode.kind || + leftNode.parentId !== rightNode.parentId || + leftNode.width !== rightNode.width || + leftNode.height !== rightNode.height + ) { + return false; + } + } + + const leftEdges = new Set(left.edges.map((edge) => `${edge.id}|${edge.sourceId}|${edge.targetId}`)); + + return right.edges.every((edge) => leftEdges.has(`${edge.id}|${edge.sourceId}|${edge.targetId}`)); +} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/graph-update-coordinator.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/graph-update-coordinator.ts new file mode 100644 index 00000000000..2846f547f6f --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/graph-update-coordinator.ts @@ -0,0 +1,282 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Ordering and interlocks for graph reconciliation, with no React or Jotai dependency. + * + * The caller supplies the operations below and this decides when each runs. Keeping the rules here + * makes them testable with controlled promises, which matters because every rule exists for an + * ordering hazard that is impractical to force end to end. + */ + +/** + * Who asked for the layout, which decides both how it runs and what it is allowed to disturb. + * + * `auto` follows a change to the graph: it may skip when nothing was resized, and frames what + * arrived. `reset` is the user asking to re-tidy the same graph, so it must run unconditionally — + * dragging a node changes its position, never its size, so the layout input is unchanged and an + * `auto` layout would do nothing — and must leave their viewport alone. + */ +export type GraphLayoutMode = "auto" | "reset"; + +/** `graphChanged` means the server's graph moved on; the client must reconcile and retry. */ +export type GraphLayoutResult = "completed" | "graphChanged"; + +export interface GraphUpdateOperations { + /** Submit the displayed graph and return the server's delta. */ + fetchUpdate: () => Promise; + /** Apply a delta, reporting whether the result still owes a layout. */ + applyUpdate: (update: TUpdate) => Promise<{ layoutRequired: boolean }>; + /** Measure and lay out the displayed graph. */ + runGraphLayout: (mode: GraphLayoutMode) => Promise; +} + +/** + * What is owed, as opposed to what is running. + * + * `update` and `layout` are tracked apart because they answer different questions: whether the + * server's graph may have moved, and whether what we display has been laid out. Collapsing them is + * what let a `graphChanged` layout response drop the second question and leave the graph hidden + * behind its visibility gate. + * + * Held as an immutable value replaced wholesale, and every rule below is a total function over it. + * The loop that consumes this awaits between decisions, so in-place mutation would spread each rule + * across suspension points where the next bug is easy to write and hard to see. + */ +interface PendingWork { + readonly update: boolean; + readonly layout: GraphLayoutMode | "none"; +} + +const NOTHING_PENDING: PendingWork = { update: false, layout: "none" }; + +function isPending(work: PendingWork): boolean { + return work.update || work.layout !== "none"; +} + +function pendUpdate(work: PendingWork): PendingWork { + return { ...work, update: true }; +} + +/** A reset outranks an automatic layout: Reset Layout must not be downgraded by an ordinary pass. */ +function pendLayout(work: PendingWork, mode: GraphLayoutMode): PendingWork { + return mode === "reset" || work.layout === "none" ? { ...work, layout: mode } : work; +} + +/** The next thing to do, and what is still owed once it has been taken. */ +type NextStep = + | { kind: "update"; remaining: PendingWork } + | { kind: "layout"; mode: GraphLayoutMode; remaining: PendingWork } + | { kind: "idle" }; + +/** + * Reconcile before laying out, so a layout always applies to the current graph. Reset Layout in + * particular must not run against a graph a pending update is about to replace. + */ +function takeNextStep(work: PendingWork): NextStep { + if (work.update) { + return { kind: "update", remaining: { ...work, update: false } }; + } + + if (work.layout !== "none") { + return { kind: "layout", mode: work.layout, remaining: { ...work, layout: "none" } }; + } + + return { kind: "idle" }; +} + +export class GraphUpdateCoordinator { + private operations: GraphUpdateOperations | null; + private pending: PendingWork = NOTHING_PENDING; + private draining = false; + private mutating = false; + private mutationQueue: Promise = Promise.resolve(); + private idleWaiters: PromiseWithResolvers | null = null; + + constructor(operations?: GraphUpdateOperations) { + this.operations = operations ?? null; + } + + /** Rebind React-backed operations without replacing this coordinator's pending work. */ + setOperations(operations: GraphUpdateOperations): void { + this.operations = operations; + } + + /** The document may have changed. Coalesces with any pass already running. */ + requestUpdate(): Promise { + this.pending = pendUpdate(this.pending); + + return this.drain(); + } + + /** Reset Layout. Runs against the reconciled graph, not whatever is displayed now. */ + requestResetGraphLayout(): Promise { + this.pending = pendLayout(this.pending, "reset"); + + return this.drain(); + } + + /** Run a source mutation, serialized against other mutations and against reconciliation. */ + runMutation(mutate: () => Promise): Promise { + const run = async () => { + this.mutating = true; + + try { + await mutate(); + } finally { + this.mutating = false; + this.pending = pendUpdate(this.pending); + await this.drain(); + } + }; + + // Mutations run one at a time: each prepares an edit against a document version, so overlapping + // them would let the second be prepared against a document the first has already changed. A + // rejected mutation must not stall the queue, so both settlements continue it. + const queued = this.mutationQueue.then(run, run); + + this.mutationQueue = queued; + + return queued; + } + + private requireOperations(): GraphUpdateOperations { + if (!this.operations) { + throw new Error("GraphUpdateCoordinator was driven before its operations were set."); + } + + return this.operations; + } + + private isIdle(): boolean { + return !isPending(this.pending) && !this.draining && !this.mutating; + } + + /** + * Resolves once the coordinator next runs out of work. + * + * This is what a caller arriving mid-pass gets back. Resolving such a call immediately would + * report the work done when it is merely recorded, and callers use that promise to decide when to + * let the next request through: `useResetGraphLayout` holds its deduplication lock for exactly this + * long, so an early resolution lets a second click queue a second server layout behind the first. + * + * Waiting for quiescence rather than for the caller's own work is deliberate — passes coalesce, so + * "my update specifically" is not a thing the loop can still identify. + */ + private whenIdle(): Promise { + if (this.isIdle()) { + return Promise.resolve(); + } + + this.idleWaiters ??= Promise.withResolvers(); + + return this.idleWaiters.promise; + } + + private resolveIfIdle(): void { + if (!this.isIdle()) { + return; + } + + this.releaseIdle(); + } + + /** + * Settle everyone waiting, whether or not work remains owed. + * + * Only for a failed pass, where the usual condition cannot be met: the work that failed is still + * pending, but this drain is over and no other is coming, so waiting on it would wait forever. The + * work stays pending and the next request picks it up rather than being retried here, which would + * hammer an operation that has just failed. + */ + private releaseIdle(): void { + const idleWaiters = this.idleWaiters; + + this.idleWaiters = null; + idleWaiters?.resolve(); + } + + /** + * Reconcile once. + * + * Returns false when a mutation began while the response was in flight. That response may already + * contain the created node, but its expected id is not yet bound to the drop origin, so applying it + * would place the node by layout instead of where the user dropped it. The mutation re-drains once + * it has recorded the binding. + */ + private async runUpdatePass(): Promise { + const update = await this.requireOperations().fetchUpdate(); + + if (this.mutating) { + this.pending = pendUpdate(this.pending); + return false; + } + + const { layoutRequired } = await this.requireOperations().applyUpdate(update); + + if (layoutRequired) { + this.pending = pendLayout(this.pending, "auto"); + } + + return true; + } + + private async drain(): Promise { + // A mutation owns the next reconciliation: it must bind the new node to its drop origin first. + if (this.draining || this.mutating) { + return this.whenIdle(); + } + + this.draining = true; + + try { + await this.runPasses(); + } catch (error) { + // A failed pass still ends this drain. Release anyone waiting on it before rethrowing to the + // caller that started it: their promise would otherwise never settle at all, and a caller that + // gates on it — `useResetGraphLayout` holds its deduplication lock for exactly that long — would + // stay locked for the rest of the session. + this.draining = false; + this.releaseIdle(); + + throw error; + } + + this.draining = false; + + // A drain requested while this one was unwinding found `draining` still set and did nothing, so + // it would be lost. This happens whenever a mutation finishes in the same microtask turn that + // abandons a pass, which is the common case rather than a rare one. + if (!this.mutating && isPending(this.pending)) { + return this.drain(); + } + + this.resolveIfIdle(); + } + + private async runPasses(): Promise { + for (;;) { + const step = takeNextStep(this.pending); + + if (step.kind === "idle") { + return; + } + + this.pending = step.remaining; + + if (step.kind === "update") { + if (!(await this.runUpdatePass())) { + return; + } + + continue; + } + + if ((await this.requireOperations().runGraphLayout(step.mode)) === "graphChanged") { + // Reconcile, then retry this layout — keeping its mode, so a Reset Layout stays a reset and + // a graph still behind its visibility gate is guaranteed another chance to be revealed. + this.pending = pendLayout(pendUpdate(this.pending), step.mode); + } + } + } +} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/hooks/use-apply-graph-layout.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/hooks/use-apply-graph-layout.ts new file mode 100644 index 00000000000..2da86e3f697 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/hooks/use-apply-graph-layout.ts @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { createStore, PrimitiveAtom } from "jotai"; +import type { AnimationPlaybackControlsWithThen } from "motion"; +import type { Box } from "@/lib/math"; +import type { NodeLayout } from "../api"; + +import { useSetAtom, useStore } from "jotai"; +import { animate, transform } from "motion"; +import { useCallback, useEffect, useRef } from "react"; +import { layoutReadyAtom, nodesByIdAtom } from "@/lib/graph"; +import { translateBox } from "@/lib/math"; + +type Store = ReturnType; + +/** Duration (in seconds) of the spring animation when nodes move to new positions. */ +const ANIMATION_DURATION_S = 0.6; + +function waitForAnimationFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +/** + * Spring a node's boxAtom from its current position to a target position. + * Returns the animation control so it can be cancelled if a newer layout + * arrives before it settles. + */ +function springNodeTo(store: Store, boxAtom: PrimitiveAtom, targetX: number, targetY: number) { + const box = store.get(boxAtom); + const fromX = box.min.x; + const fromY = box.min.y; + + const opts = { clamp: false }; + const xTransform = transform([0, 100], [fromX, targetX], opts); + const yTransform = transform([0, 100], [fromY, targetY], opts); + + return animate(0, 100, { + type: "spring", + duration: ANIMATION_DURATION_S, + onUpdate: (latest) => { + const x = xTransform(latest); + const y = yTransform(latest); + store.set(boxAtom, (box) => translateBox(box, x - box.min.x, y - box.min.y)); + }, + }); +} + +/** Applies server-computed positions and reveals the graph once its nodes have mounted. */ +export function useApplyGraphLayout() { + const store = useStore(); + const setLayoutReady = useSetAtom(layoutReadyAtom); + const activeAnimationsRef = useRef([]); + + useEffect( + () => () => { + for (const animation of activeAnimationsRef.current) { + animation.stop(); + } + activeAnimationsRef.current = []; + }, + [], + ); + + return useCallback( + async (nodeLayouts: ReadonlyMap): Promise => { + if (!store.get(layoutReadyAtom)) { + await waitForAnimationFrame(); + setLayoutReady(true); + } + + if (nodeLayouts.size === 0) { + return; + } + + for (const animation of activeAnimationsRef.current) { + animation.stop(); + } + activeAnimationsRef.current = []; + + const nodes = store.get(nodesByIdAtom); + + for (const [nodeId, layout] of nodeLayouts) { + const node = nodes[nodeId]; + + if (node?.kind === "atomic") { + activeAnimationsRef.current.push(springNodeTo(store, node.boxAtom, layout.x, layout.y)); + } + } + }, + [setLayoutReady, store], + ); +} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/use-visual-graph.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/hooks/use-apply-graph.ts similarity index 55% rename from src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/use-visual-graph.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/hooks/use-apply-graph.ts index c9faa0c7f50..954e4f4300a 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/use-visual-graph.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/hooks/use-apply-graph.ts @@ -1,16 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { PrimitiveAtom } from "jotai"; -import type { AnimationPlaybackControlsWithThen } from "motion"; -import type { Box } from "@/lib/utils/math"; -import type { Point } from "@/lib/utils/math/geometry"; -import type { DeploymentGraph, NodeLayout } from "./messages"; +import type { createStore } from "jotai"; +import type { Point } from "@/lib/math"; +import type { ClientGraph } from "../graph-model"; -import { getDefaultStore, useSetAtom } from "jotai"; -import { animate, transform } from "motion"; +import { useSetAtom, useStore } from "jotai"; import { useCallback, useRef } from "react"; -import { errorCountAtom, hasNodesAtom } from "@/features/status"; +import { reportGraphStatusAtom } from "@/features/status"; import { addAtomicNodeAtom, addCompoundNodeAtom, @@ -20,76 +17,9 @@ import { nodesByIdAtom, removeNodesAtom, } from "@/lib/graph"; -import { isDeploymentGraphEqual } from "@/lib/utils/deployment-graph-equality"; -import { translateBox } from "@/lib/utils/math"; +import { clientGraphsRenderEqually } from "../graph-model"; -const store = getDefaultStore(); - -/** Duration (in seconds) of the spring animation when nodes move to new positions. */ -const ANIMATION_DURATION_S = 0.6; - -/** - * Animations still settling from the most recent graph layout. They are - * cancelled before a new layout is applied so overlapping springs don't - * fight over the same boxes. - */ -let activeLayoutAnimations: AnimationPlaybackControlsWithThen[] = []; - -function waitForAnimationFrame(): Promise { - return new Promise((resolve) => requestAnimationFrame(() => resolve())); -} - -/** - * Spring a node's boxAtom from its current position to a target position. - * Returns the animation control so it can be cancelled if a newer layout - * arrives before it settles. - */ -function springNodeTo(boxAtom: PrimitiveAtom, targetX: number, targetY: number) { - const box = store.get(boxAtom); - const fromX = box.min.x; - const fromY = box.min.y; - - const opts = { clamp: false }; - const xTransform = transform([0, 100], [fromX, targetX], opts); - const yTransform = transform([0, 100], [fromY, targetY], opts); - - return animate(0, 100, { - type: "spring", - duration: ANIMATION_DURATION_S, - onUpdate: (latest) => { - const x = xTransform(latest); - const y = yTransform(latest); - store.set(boxAtom, (b) => translateBox(b, x - b.min.x, y - b.min.y)); - }, - }); -} - -/** Apply a server-computed layout to the current graph. */ -export async function applyGraphLayout(nodeLayouts: Map): Promise { - if (!store.get(layoutReadyAtom)) { - await waitForAnimationFrame(); - store.set(layoutReadyAtom, true); - } - - if (nodeLayouts.size === 0) { - return; - } - - for (const animation of activeLayoutAnimations) { - animation.stop(); - } - activeLayoutAnimations = []; - - const nodes = store.get(nodesByIdAtom); - - for (const [nodeId, layout] of nodeLayouts) { - const node = nodes[nodeId]; - - if (node?.kind === "atomic") { - activeLayoutAnimations.push(springNodeTo(node.boxAtom, layout.x, layout.y)); - } - } -} +type Store = ReturnType; /** * Snapshot the current position (box.min) of every node so we can @@ -97,7 +27,7 @@ export async function applyGraphLayout(nodeLayouts: Map): Pr * them a smooth transition to their new server-computed location * instead of jumping from (0,0). */ -function snapshotNodePositions(): Map { +function snapshotNodePositions(store: Store): Map { const positions = new Map(); const nodes = store.get(nodesByIdAtom); @@ -114,44 +44,39 @@ function snapshotNodePositions(): Map { return positions; } -export function useApplyVisualGraph(getViewportCenter: () => Point) { +export function useApplyGraph(getViewportCenter: () => Point) { + const store = useStore(); const setEdgesAtom = useSetAtom(edgesAtom); const addAtomicNode = useSetAtom(addAtomicNodeAtom); const addCompoundNode = useSetAtom(addCompoundNodeAtom); const addEdge = useSetAtom(addEdgeAtom); const removeNodes = useSetAtom(removeNodesAtom); const setLayoutReady = useSetAtom(layoutReadyAtom); - const previousGraphRef = useRef(null); + const appliedGraphRef = useRef(null); return useCallback( - (graph: DeploymentGraph | null, newNodeOrigins: ReadonlyMap = new Map()) => { - // Update status bar atoms - store.set(errorCountAtom, graph?.errorCount ?? 0); - store.set(hasNodesAtom, (graph?.nodes.length ?? 0) > 0); - - // If the graph topology hasn't changed (only ranges differ due - // to trivial edits like adding blank lines), update ranges on - // existing nodes in-place without tearing down and re-laying out. - if (isDeploymentGraphEqual(previousGraphRef.current, graph)) { - if (graph) { - const nodes = store.get(nodesByIdAtom); - for (const node of graph.nodes) { - const existing = nodes[node.id]; - if (existing) { - store.set(existing.dataAtom, (prev: Record) => ({ - ...prev, - range: node.range, - filePath: node.filePath, - })); - } - } - } - previousGraphRef.current = graph; + (graph: ClientGraph | null, newNodeOrigins: ReadonlyMap = new Map()) => { + // Report the graph facts that features/status derives its display from. + store.set(reportGraphStatusAtom, { + errorCount: graph?.errorCount ?? 0, + hasNodes: (graph?.nodes.size ?? 0) > 0, + }); + + // Nothing the canvas shows has changed, so leave the mounted nodes alone rather than tearing + // the graph down and re-laying it out. Most keystrokes land here. + if (clientGraphsRenderEqually(appliedGraphRef.current, graph)) { return; } - previousGraphRef.current = graph; - if (!graph || graph.nodes.length === 0) { + // The graph is mutated in place, so keep a shallow copy: holding the live reference would + // compare it against itself on the next pass and report equal every time. + appliedGraphRef.current = graph && { + nodes: new Map(graph.nodes), + edges: new Map(graph.edges), + errorCount: graph.errorCount, + }; + + if (!graph || graph.nodes.size === 0) { // Empty graph — clear everything and re-engage the // visibility gate so the next non-empty graph can spawn // from the center without flashing. @@ -163,13 +88,13 @@ export function useApplyVisualGraph(getViewportCenter: () => Point) { // Snapshot positions before modifying so surviving nodes // can animate from their current location. - const previousPositions = snapshotNodePositions(); + const previousPositions = snapshotNodePositions(store); // ── Classify incoming nodes ── const compoundNodeIds = new Set(); const parentChildMap = new Map(); // parentId → childIds[] - for (const node of graph.nodes) { + for (const node of graph.nodes.values()) { if (node.hasChildren) { compoundNodeIds.add(node.id); parentChildMap.set(node.id, []); @@ -177,7 +102,7 @@ export function useApplyVisualGraph(getViewportCenter: () => Point) { } // Build parent-child relationships from :: delimited IDs - for (const node of graph.nodes) { + for (const node of graph.nodes.values()) { const segments = node.id.split("::"); if (segments.length > 1) { const parentId = segments.slice(0, -1).join("::"); @@ -199,7 +124,7 @@ export function useApplyVisualGraph(getViewportCenter: () => Point) { // ── Diff-and-patch: update in-place instead of clear-and-rebuild ── const currentNodes = store.get(nodesByIdAtom); - const newNodeIds = new Set(graph.nodes.map((n) => n.id)); + const newNodeIds = new Set(graph.nodes.keys()); const currentNodeIds = new Set(Object.keys(currentNodes)); // Phase 1: Remove nodes that no longer exist. @@ -218,7 +143,7 @@ export function useApplyVisualGraph(getViewportCenter: () => Point) { // graph layout computes. Incremental edits (adding/removing a few nodes) // keep the graph visible for smooth in-place animation. const survivingCount = currentNodeIds.size - idsToRemove.size; - const survivalRatio = graph.nodes.length > 0 ? survivingCount / graph.nodes.length : 0; + const survivalRatio = graph.nodes.size > 0 ? survivingCount / graph.nodes.size : 0; if (survivalRatio < 0.5) { setLayoutReady(false); } @@ -239,7 +164,7 @@ export function useApplyVisualGraph(getViewportCenter: () => Point) { : getViewportCenter(); // Phase 3: Update surviving nodes in-place / add new atomic nodes. - for (const node of graph.nodes) { + for (const node of graph.nodes.values()) { if (compoundNodeIds.has(node.id)) { continue; // Compound nodes handled in Phase 4. } @@ -255,56 +180,28 @@ export function useApplyVisualGraph(getViewportCenter: () => Point) { removeNodes(new Set([node.id])); } else { // Same kind — update data in-place, skip re-creation. - store.set(existing.dataAtom, () => - node.type === "" - ? { - symbolicName: symbol, - resourceType: node.type, - path: node.filePath, - isCollection: node.isCollection, - hasError: node.hasError, - range: node.range, - filePath: node.filePath, - } - : { - symbolicName: symbol, - resourceType: node.type, - isCollection: node.isCollection, - hasError: node.hasError, - range: node.range, - filePath: node.filePath, - }, - ); + store.set(existing.dataAtom, () => ({ + symbolicName: symbol, + resourceType: node.type, + isCollection: node.isCollection, + hasError: node.hasError, + })); continue; } } // New node (or re-added after kind change) — create it. const origin = newNodeOrigins.get(node.id) ?? previousPositions.get(node.id) ?? defaultOrigin; - if (node.type === "") { - addAtomicNode(node.id, origin, { - symbolicName: symbol, - resourceType: node.type, - path: node.filePath, - isCollection: node.isCollection, - hasError: node.hasError, - range: node.range, - filePath: node.filePath, - }); - } else { - addAtomicNode(node.id, origin, { - symbolicName: symbol, - resourceType: node.type, - isCollection: node.isCollection, - hasError: node.hasError, - range: node.range, - filePath: node.filePath, - }); - } + addAtomicNode(node.id, origin, { + symbolicName: symbol, + resourceType: node.type, + isCollection: node.isCollection, + hasError: node.hasError, + }); } // Phase 4: Update surviving compound nodes / add new ones. - for (const node of graph.nodes) { + for (const node of graph.nodes.values()) { if (!compoundNodeIds.has(node.id)) { continue; } @@ -318,11 +215,8 @@ export function useApplyVisualGraph(getViewportCenter: () => Point) { store.set(existing.childIdsAtom, childIds); store.set(existing.dataAtom, () => ({ symbolicName: symbol, - path: node.filePath, isCollection: node.isCollection, hasError: node.hasError, - range: node.range, - filePath: node.filePath, })); } else { // New compound node (or kind changed from atomic → compound). @@ -332,18 +226,15 @@ export function useApplyVisualGraph(getViewportCenter: () => Point) { } addCompoundNode(node.id, childIds, { symbolicName: symbol, - path: node.filePath, isCollection: node.isCollection, hasError: node.hasError, - range: node.range, - filePath: node.filePath, }); } } // Phase 5: Diff edges — replace only if the set changed. const currentEdges = store.get(edgesAtom); - const newEdgeIds = new Set(graph.edges.map((e) => `${e.sourceId}>${e.targetId}`)); + const newEdgeIds = new Set([...graph.edges.values()].map((e) => `${e.sourceId}>${e.targetId}`)); const currentEdgeIds = new Set(currentEdges.map((e) => e.id)); const edgesChanged = @@ -353,7 +244,7 @@ export function useApplyVisualGraph(getViewportCenter: () => Point) { // Rebuild edges in one shot (edges are lightweight value objects // with no atom identity to preserve). setEdgesAtom([]); - for (const edge of graph.edges) { + for (const edge of graph.edges.values()) { addEdge(`${edge.sourceId}>${edge.targetId}`, edge.sourceId, edge.targetId); } } @@ -362,6 +253,6 @@ export function useApplyVisualGraph(getViewportCenter: () => Point) { // applyGraphLayout once the server returns the computed layout. The visibility // gate set above is preserved until then. }, - [setEdgesAtom, addAtomicNode, addCompoundNode, addEdge, removeNodes, setLayoutReady, getViewportCenter], + [setEdgesAtom, addAtomicNode, addCompoundNode, addEdge, removeNodes, setLayoutReady, getViewportCenter, store], ); } diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/hooks/use-canvas-controller.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/hooks/use-canvas-controller.ts new file mode 100644 index 00000000000..c749d624864 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/hooks/use-canvas-controller.ts @@ -0,0 +1,226 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { createStore } from "jotai"; +import type { Box, Point } from "@/lib/math"; +import type { GetGraphUpdateResult, NodeLayout, RenderedGraph } from "../api"; +import type { ClientGraph } from "../graph-model"; +import type { GraphLayoutMode, GraphLayoutResult } from "../graph-update-coordinator"; +import type { ResourceTypeReference } from "../types"; + +import { useStore } from "jotai"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { nodesByIdAtom } from "@/lib/graph"; +import { getErrorMessage } from "@/utils"; +import { useCanvasApi } from "../api"; +import { + beginResourceCreationAtom, + bindExpectedNodeAtom, + commitPendingResourcesAtom, + failResourceCreationAtom, + resourceNodeIsCommittingAtomFamily, +} from "../atoms"; +import { centerGraphLayout, extractGraphLayout, patchMayAffectLayout } from "../graph-layout"; +import { applyGraphPatch, buildRenderedGraph, createClientGraph, renderedGraphsEqual } from "../graph-model"; +import { GraphUpdateCoordinator } from "../graph-update-coordinator"; +import { useApplyGraph } from "./use-apply-graph"; +import { useApplyGraphLayout } from "./use-apply-graph-layout"; + +function waitForAnimationFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +/** Snapshot the measured box of every mounted node, keyed by id. */ +function measureNodes(store: ReturnType): Map { + const renderedNodes = store.get(nodesByIdAtom); + const boxes = new Map(); + + for (const [nodeId, rendered] of Object.entries(renderedNodes)) { + boxes.set(nodeId, store.get(rendered.boxAtom)); + } + + return boxes; +} + +export interface CanvasController { + requestGraphUpdate: () => Promise; + resetGraphLayout: () => Promise; + createResourceAt: (resourceType: ResourceTypeReference, origin: Point) => Promise; +} + +/** + * Owns one canvas's client graph and asynchronous update lifecycle. + * + * Ordering lives in `GraphUpdateCoordinator`, which has no React dependency and is unit tested + * directly — the rules there govern hazards that are impractical to force end to end. + */ +export function useCanvasController( + getViewportCenter: () => Point, + fitViewToBounds: (bounds: Box) => void, +): CanvasController { + const store = useStore(); + const applyGraph = useApplyGraph(getViewportCenter); + const applyGraphLayout = useApplyGraphLayout(); + const api = useCanvasApi(); + + /** + * The two graphs the client holds: its copy of the server's canonical graph, and the last + * `RenderedGraph` it submitted for layout — kept so a pass can skip layout when measured sizes are + * unchanged. + */ + const clientGraphsRef = useRef<{ graph: ClientGraph; rendered: RenderedGraph | null }>({ + graph: createClientGraph(), + rendered: null, + }); + + /** Expected node ids mapped to the graph position the user dropped them at. */ + const placementsRef = useRef>(new Map()); + + const fetchUpdate = useCallback(() => { + const graph = clientGraphsRef.current.graph; + const current: RenderedGraph | null = + graph.nodes.size === 0 ? null : buildRenderedGraph(graph, measureNodes(store)); + + return api.fetchUpdate(current); + }, [api, store]); + + const applyUpdate = useCallback( + async (response: GetGraphUpdateResult): Promise<{ layoutRequired: boolean }> => { + const graph = clientGraphsRef.current.graph; + const nodeLayouts = new Map(); + const newNodeOrigins = new Map(); + const explicitlyPlacedNodeIds = new Set(placementsRef.current.keys()); + let layoutMayBeStale = false; + + for (const patch of response.patches) { + layoutMayBeStale ||= patchMayAffectLayout(graph, patch, explicitlyPlacedNodeIds); + if (patch.op === "addNode") { + const origin = placementsRef.current.get(patch.node.id); + if (origin) { + newNodeOrigins.set(patch.node.id, origin); + } + } + applyGraphPatch(graph, nodeLayouts, patch); + } + + const layoutRequired = layoutMayBeStale && graph.nodes.size > 0; + + if (graph.nodes.size === 0) { + clientGraphsRef.current.rendered = null; + } + + for (const nodeId of newNodeOrigins.keys()) { + // Set before applyGraph mounts the node so Motion sees the compact initial state. + store.set(resourceNodeIsCommittingAtomFamily(nodeId), true); + } + + // Apply the new topology. Visibility is preserved for incremental edits (so nodes animate in + // place) and gated for major changes; positions arrive with the layout. + applyGraph(graph, newNodeOrigins); + + if (newNodeOrigins.size > 0) { + for (const nodeId of newNodeOrigins.keys()) { + placementsRef.current.delete(nodeId); + } + store.set(commitPendingResourcesAtom, new Set(newNodeOrigins.keys())); + + if (!layoutRequired) { + // An explicitly placed node is already where the user dropped it, so no layout is owed -- + // but the graph may still be behind the visibility gate, so reveal it. + await applyGraphLayout(new Map()); + } + } + + return { layoutRequired }; + }, + [applyGraph, applyGraphLayout, store], + ); + + const runGraphLayout = useCallback( + async (mode: GraphLayoutMode): Promise => { + const isReset = mode === "reset"; + const graph = clientGraphsRef.current.graph; + + if (graph.nodes.size === 0) { + clientGraphsRef.current.rendered = null; + return "completed"; + } + + await waitForAnimationFrame(); + + const measuredGraph = buildRenderedGraph(graph, measureNodes(store)); + + if (!isReset && renderedGraphsEqual(clientGraphsRef.current.rendered, measuredGraph)) { + // Nothing was resized since the last layout, so the positions still hold. Reveal the graph in + // case it is still behind the visibility gate. A reset skips this: the measurements are + // unchanged when the user has only dragged nodes, which is exactly when it must still run. + await applyGraphLayout(new Map()); + return "completed"; + } + + const layoutResponse = await api.fetchGraphLayout(measuredGraph); + + if (layoutResponse.status === "graphChanged") { + return "graphChanged"; + } + + if (layoutResponse.status === "layoutFailed") { + // No usable layout — reveal the graph as-is so it isn't stuck hidden. + await applyGraphLayout(new Map()); + return "completed"; + } + + const { nodeLayouts, graphBounds } = extractGraphLayout(layoutResponse.patches); + const { nodeLayouts: centeredNodeLayouts, bounds } = centerGraphLayout( + nodeLayouts, + graphBounds, + getViewportCenter(), + ); + clientGraphsRef.current.rendered = measuredGraph; + + // Fit the viewport to the server-computed graph bounds before the nodes settle there. A reset + // re-arranges the same graph the user is already looking at, so it must not move their camera. + if (bounds && !isReset) { + fitViewToBounds(bounds); + } + + await applyGraphLayout(centeredNodeLayouts); + + return "completed"; + }, + [api, applyGraphLayout, fitViewToBounds, getViewportCenter, store], + ); + + const [coordinator] = useState(() => new GraphUpdateCoordinator()); + + useEffect(() => { + coordinator.setOperations({ fetchUpdate, applyUpdate, runGraphLayout }); + }, [applyUpdate, coordinator, fetchUpdate, runGraphLayout]); + + const createResourceAt = useCallback( + (resourceType: ResourceTypeReference, origin: Point): Promise => { + const operationId = window.crypto.randomUUID(); + store.set(beginResourceCreationAtom, { operationId, resourceType, origin }); + + return coordinator.runMutation(async () => { + try { + const { expectedNodeId } = await api.createResource({ version: 1, operationId, resourceType }); + + placementsRef.current.set(expectedNodeId, origin); + store.set(bindExpectedNodeAtom, { operationId, expectedNodeId }); + } catch (error) { + store.set(failResourceCreationAtom, { + operationId, + message: getErrorMessage(error, "Failed to create the resource."), + }); + } + }); + }, + [api, coordinator, store], + ); + + const requestGraphUpdate = useCallback(() => coordinator.requestUpdate(), [coordinator]); + const resetGraphLayout = useCallback(() => coordinator.requestResetGraphLayout(), [coordinator]); + + return { requestGraphUpdate, resetGraphLayout, createResourceAt }; +} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/index.ts new file mode 100644 index 00000000000..9b5e3175218 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/index.ts @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export { ResourceCreationError } from "./components/ResourceCreationError"; +export { ResourceNodePreview } from "./components/nodes/ResourceNodePreview"; +export { Canvas } from "./components/Canvas"; +export { useCanvasActions } from "./context"; +export * from "./api"; +export * from "./types"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/types.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/types.ts new file mode 100644 index 00000000000..a9e566d1251 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/types.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * A resource type the user can create. Mirrors the host's resource-creation contract. + * + * This lives here rather than with the palette because it is the parameter of `resources/create`, + * the request this feature declares. The palette produces these values; the canvas owns the contract + * they satisfy. + */ +export interface ResourceTypeReference { + fullyQualifiedType: string; + apiVersion: string; +} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/controls/atoms.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/controls/atoms.ts index d92af38b1a7..bacd7a82cbf 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/controls/atoms.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/controls/atoms.ts @@ -6,7 +6,7 @@ import { hasNodesAtom } from "@/features/status"; export interface GraphControlAvailability { canFitView: boolean; - canResetLayout: boolean; + canResetGraphLayout: boolean; canExportGraph: boolean; } @@ -18,7 +18,7 @@ export const graphControlAvailabilityAtom = atom((get) return { canFitView: hasNodes, - canResetLayout: hasNodes, + canResetGraphLayout: hasNodes, canExportGraph: hasNodes, }; }); diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/controls/ControlBar.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/controls/components/ControlBar.tsx similarity index 55% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/controls/ControlBar.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/features/controls/components/ControlBar.tsx index 07990ee8ce1..0372d57dc99 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/controls/ControlBar.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/controls/components/ControlBar.tsx @@ -4,38 +4,43 @@ import { Codicon, usePanZoomControl } from "@vscode-bicep-ui/components"; import { useAtomValue, useSetAtom } from "jotai"; import { styled } from "styled-components"; +import { useCanvasActions } from "@/features/canvas"; import { openExportOverlayAtom } from "@/features/export"; import { useFitView } from "@/lib/graph"; -import { graphControlAvailabilityAtom } from "./atoms"; -import { ControlButton, ControlSurface } from "./ControlPrimitives"; -import { useResetLayout } from "./use-reset-layout"; +import { FloatingPanel, IconButton } from "@/ui"; +import { graphControlAvailabilityAtom } from "../atoms"; +import { useResetGraphLayout } from "../hooks/use-reset-graph-layout"; + +const $ControlBar = styled(FloatingPanel)` + position: absolute; + top: 16px; + right: 16px; + z-index: 100; +`; const $Divider = styled.div` height: 1px; margin: 2px 4px; - background-color: ${({ theme }) => theme.controlBar.border}; + background-color: ${({ theme }) => theme.panel.border}; `; -interface ControlBarProps { - requestLayout: () => Promise; -} - -export function ControlBar({ requestLayout }: ControlBarProps) { +export function ControlBar() { const { zoomIn, zoomOut } = usePanZoomControl(); const fitView = useFitView(); - const resetLayout = useResetLayout(requestLayout); + const { resetGraphLayout: requestResetGraphLayout } = useCanvasActions(); + const resetGraphLayout = useResetGraphLayout(requestResetGraphLayout); const controls = useAtomValue(graphControlAvailabilityAtom); const openExportOverlay = useSetAtom(openExportOverlayAtom); return ( - - zoomIn(1.5)} title="Zoom In" aria-label="Zoom In" data-testid="control-zoom-in"> + <$ControlBar data-testid="control-bar"> + zoomIn(1.5)} title="Zoom In" aria-label="Zoom In" data-testid="control-zoom-in"> - - zoomOut(1.5)} title="Zoom Out" aria-label="Zoom Out" data-testid="control-zoom-out"> + + zoomOut(1.5)} title="Zoom Out" aria-label="Zoom Out" data-testid="control-zoom-out"> - - + - - + - + <$Divider /> - openExportOverlay()} title="Export Graph" aria-label="Export Graph" @@ -62,7 +67,7 @@ export function ControlBar({ requestLayout }: ControlBarProps) { data-testid="control-export" > - - + + ); } diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/controls/use-reset-layout.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/controls/hooks/use-reset-graph-layout.ts similarity index 78% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/controls/use-reset-layout.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/features/controls/hooks/use-reset-graph-layout.ts index bf316b602ae..df13b34a0ef 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/controls/use-reset-layout.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/controls/hooks/use-reset-graph-layout.ts @@ -7,16 +7,16 @@ import { useCallback, useRef } from "react"; * Returns a callback that requests a fresh graph layout. Concurrent invocations * are deduplicated; if a layout is already in flight the callback is a no-op. */ -export function useResetLayout(requestLayout: () => Promise) { +export function useResetGraphLayout(requestGraphLayout: () => Promise) { const layoutInFlight = useRef(false); return useCallback(async () => { if (layoutInFlight.current) return; layoutInFlight.current = true; try { - await requestLayout(); + await requestGraphLayout(); } finally { layoutInFlight.current = false; } - }, [requestLayout]); + }, [requestGraphLayout]); } diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/controls/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/controls/index.ts index d2f977af50e..aec09d30359 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/controls/index.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/controls/index.ts @@ -1,6 +1,4 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -export { ControlBar } from "./ControlBar"; -export { ControlButton, ControlSurface } from "./ControlPrimitives"; -export * from "./atoms"; +export { ControlBar } from "./components/ControlBar"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/index.ts deleted file mode 100644 index 6aefdabd4c0..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import type { ComponentType, LazyExoticComponent, ReactNode } from "react"; - -import { lazy } from "react"; - -/** - * Lazily load the {@link DevAppShell} component. - * - * Returns `undefined` in production builds (`import.meta.env.DEV === false`), - * allowing Rollup to tree-shake the entire devtools chunk. - */ -export function loadDevAppShell(): LazyExoticComponent> | undefined { - if (!import.meta.env.DEV) { - return undefined; - } - - return lazy(() => import("./DevAppShell").then((m) => ({ default: m.DevAppShell }))); -} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/export/__tests__/atoms.test.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/export/__tests__/atoms.test.ts new file mode 100644 index 00000000000..10794741bdc --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/export/__tests__/atoms.test.ts @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// @vitest-environment happy-dom +// The export atoms reach `ui/theme`, which reads `document.body` at module scope. + +import { createStore } from "jotai"; +import { describe, expect, it } from "vitest"; +import { documentUriAtom } from "@/hooks"; +import { DEFAULT_EXPORT_FILE_STEM, exportFileStemAtom } from "../atoms"; + +describe("export file stem", () => { + it.each([ + ["file:///c:/src/main.bicep", "main"], + ["file:///src/my.module.bicep", "my.module"], + ["c:\\src\\windows-path.bicep", "windows-path"], + ["file:///noextension", "noextension"], + ])("derives %s as %s", (documentUri, expected) => { + const store = createStore(); + store.set(documentUriAtom, documentUri); + + expect(store.get(exportFileStemAtom)).toBe(expected); + }); + + it.each([[null], [""], ["file:///.bicep"]])("falls back to the default for %s", (documentUri) => { + const store = createStore(); + store.set(documentUriAtom, documentUri); + + expect(store.get(exportFileStemAtom)).toBe(DEFAULT_EXPORT_FILE_STEM); + }); + + it("tracks the document without anyone pushing the name across", () => { + const store = createStore(); + store.set(documentUriAtom, "file:///first.bicep"); + expect(store.get(exportFileStemAtom)).toBe("first"); + + store.set(documentUriAtom, "file:///second.bicep"); + expect(store.get(exportFileStemAtom)).toBe("second"); + }); +}); diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/export/atoms.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/export/atoms.ts index 3a45aaf45ab..aff91815b89 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/export/atoms.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/export/atoms.ts @@ -2,10 +2,12 @@ // Licensed under the MIT License. import type { DefaultTheme } from "styled-components"; -import type { ExportBackgroundMode } from "./types"; import { atom } from "jotai"; -import { activeThemeAtom, getThemeByName } from "@/lib/theming"; +import { documentUriAtom } from "@/hooks"; +import { activeThemeAtom, getThemeByName } from "@/ui/theme"; + +export type ExportBackgroundMode = "transparent" | "solid"; export const DEFAULT_EXPORT_FILE_STEM = "bicep-graph"; export const DEFAULT_EXPORT_PADDING = 40; @@ -14,17 +16,29 @@ export const isExportOverlayOpenAtom = atom(false); export const exportPaddingAtom = atom(DEFAULT_EXPORT_PADDING); export const exportBackgroundModeAtom = atom("transparent"); export const exportThemeOverrideAtom = atom(null); -export const exportFileStemAtom = atom(DEFAULT_EXPORT_FILE_STEM); export const isExportInProgressAtom = atom(false); export const exportCanvasElementAtom = atom(null); +/** + * The exported file is named after the document it was captured from: "main.bicep" -> "main". + * + * Derived from `documentUriAtom` rather than written by whoever handles the change notification, so + * the name cannot drift from the document. + */ +export const exportFileStemAtom = atom((get) => { + const fileName = (get(documentUriAtom) ?? "").split(/[\\/]/).pop() ?? ""; + const stem = fileName.replace(/\.[^.]+$/, "").trim(); + + return stem || DEFAULT_EXPORT_FILE_STEM; +}); + export const effectiveExportThemeAtom = atom((get) => { const override = get(exportThemeOverrideAtom); return override ? getThemeByName(override) : get(activeThemeAtom); }); -export const exportBackgroundColorAtom = atom((get) => get(effectiveExportThemeAtom).canvas.background); +export const exportBackgroundColorAtom = atom((get) => get(effectiveExportThemeAtom).viewport.background); export const isExportPreviewVisibleAtom = atom((get) => get(isExportOverlayOpenAtom)); diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/export/ExportAreaCover.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/export/components/ExportAreaCover.tsx similarity index 73% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/export/ExportAreaCover.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/features/export/components/ExportAreaCover.tsx index c2c11f7465b..3d6cd9a3a0f 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/export/ExportAreaCover.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/export/components/ExportAreaCover.tsx @@ -4,19 +4,20 @@ import { useAtomValue } from "jotai"; import { useTheme } from "styled-components"; import { graphBoundsAtom } from "@/lib/graph"; -import { exportPaddingAtom } from "./atoms"; +import { exportPaddingAtom, isExportCanvasCoverVisibleAtom } from "../atoms"; /** * Solid background rectangle rendered inside PanZoom (graph-space) - * between CanvasBackground and Graph. Covers the dot pattern within + * between ViewportBackground and Graph. Covers the dot pattern within * the export boundary so the user previews the actual JPEG output. */ export function ExportAreaCover() { const theme = useTheme(); const padding = useAtomValue(exportPaddingAtom); const graphBounds = useAtomValue(graphBoundsAtom); + const isVisible = useAtomValue(isExportCanvasCoverVisibleAtom); - if (!graphBounds) return null; + if (!isVisible || !graphBounds) return null; return (
+ + + + ); +} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/export/ExportToolbar.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/export/components/ExportToolbar.tsx similarity index 90% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/export/ExportToolbar.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/features/export/components/ExportToolbar.tsx index 4095694be18..edd455bd404 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/export/ExportToolbar.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/export/components/ExportToolbar.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import type { DefaultTheme } from "styled-components"; -import type { ExportBackgroundMode } from "./types"; +import type { ExportBackgroundMode } from "../atoms"; import { Codicon } from "@vscode-bicep-ui/components"; import { VscodeOption, VscodeSingleSelect } from "@vscode-elements/react-elements"; @@ -18,8 +18,8 @@ import { exportPaddingAtom, exportThemeOverrideAtom, isExportInProgressAtom, -} from "./atoms"; -import { captureGraphElement, saveDataUrl } from "./capture-element"; +} from "../atoms"; +import { captureGraphElement, saveDataUrl } from "../utils/capture-element"; /* ------------------------------------------------------------------ */ /* Styled components */ @@ -30,8 +30,8 @@ const $Toolbar = styled.div` align-items: center; gap: 6px; padding: 6px 8px; - background-color: ${({ theme }) => theme.controlBar.background}; - border: 1px solid ${({ theme }) => theme.controlBar.border}; + background-color: ${({ theme }) => theme.panel.background}; + border: 1px solid ${({ theme }) => theme.panel.border}; border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); backdrop-filter: blur(8px); @@ -49,15 +49,15 @@ const $Toolbar = styled.div` --vscode-foreground: ${({ theme }) => theme.text.primary}; --vscode-focusBorder: ${({ theme }) => theme.focusBorder}; - --vscode-settings-dropdownBackground: ${({ theme }) => theme.controlBar.background}; + --vscode-settings-dropdownBackground: ${({ theme }) => theme.panel.background}; --vscode-settings-dropdownForeground: ${({ theme }) => theme.text.primary}; - --vscode-settings-dropdownBorder: ${({ theme }) => theme.controlBar.border}; - --vscode-settings-dropdownListBorder: ${({ theme }) => theme.controlBar.border}; + --vscode-settings-dropdownBorder: ${({ theme }) => theme.panel.border}; + --vscode-settings-dropdownListBorder: ${({ theme }) => theme.panel.border}; --vscode-settings-checkboxBackground: ${({ theme }) => theme.node.background}; - --vscode-list-hoverBackground: ${({ theme }) => theme.controlBar.hoverBackground}; + --vscode-list-hoverBackground: ${({ theme }) => theme.iconButton.hoverBackground}; --vscode-list-hoverForeground: ${({ theme }) => theme.text.primary}; - --vscode-list-activeSelectionBackground: ${({ theme }) => theme.controlBar.activeBackground}; + --vscode-list-activeSelectionBackground: ${({ theme }) => theme.iconButton.activeBackground}; --vscode-list-activeSelectionForeground: ${({ theme }) => theme.text.primary}; --vscode-list-focusOutline: ${({ theme }) => theme.focusBorder}; --vscode-list-focusHighlightForeground: ${({ theme }) => theme.focusBorder}; @@ -93,7 +93,7 @@ const $Separator = styled.div` width: 1px; align-self: stretch; margin: 2px 0; - background-color: ${({ theme }) => theme.controlBar.border}; + background-color: ${({ theme }) => theme.panel.border}; `; /* ---- Primary action button -------------------------------------- */ @@ -148,16 +148,16 @@ const $IconButton = styled.button` border: none; border-radius: 4px; background-color: transparent; - color: ${({ theme }) => theme.controlBar.icon}; + color: ${({ theme }) => theme.iconButton.color}; cursor: pointer; transition: background-color 0.15s ease; &:hover { - background-color: ${({ theme }) => theme.controlBar.hoverBackground}; + background-color: ${({ theme }) => theme.iconButton.hoverBackground}; } &:active { - background-color: ${({ theme }) => theme.controlBar.activeBackground}; + background-color: ${({ theme }) => theme.iconButton.activeBackground}; } &:focus-visible { @@ -186,7 +186,7 @@ const $StepperGroup = styled.div` align-items: center; border-radius: 4px; overflow: hidden; - border: 1px solid ${({ theme }) => theme.controlBar.border}; + border: 1px solid ${({ theme }) => theme.panel.border}; `; const $StepButton = styled.button` @@ -203,11 +203,11 @@ const $StepButton = styled.button` transition: background-color 0.15s ease; &:hover { - background-color: ${({ theme }) => theme.controlBar.hoverBackground}; + background-color: ${({ theme }) => theme.iconButton.hoverBackground}; } &:active { - background-color: ${({ theme }) => theme.controlBar.activeBackground}; + background-color: ${({ theme }) => theme.iconButton.activeBackground}; } &:focus-visible { @@ -220,8 +220,8 @@ const $PaddingInput = styled.input` width: 32px; padding: 2px 0; border: none; - border-left: 1px solid ${({ theme }) => theme.controlBar.border}; - border-right: 1px solid ${({ theme }) => theme.controlBar.border}; + border-left: 1px solid ${({ theme }) => theme.panel.border}; + border-right: 1px solid ${({ theme }) => theme.panel.border}; border-radius: 0; background-color: transparent; color: ${({ theme }) => theme.text.primary}; @@ -231,7 +231,7 @@ const $PaddingInput = styled.input` &:focus { outline: none; - background-color: ${({ theme }) => theme.controlBar.hoverBackground}; + background-color: ${({ theme }) => theme.iconButton.hoverBackground}; } /* Hide spinner arrows */ diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/export/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/export/index.ts index 0547cb468ad..339eeb6d1b0 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/export/index.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/export/index.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -export { ExportAreaCover } from "./ExportAreaCover"; -export { ExportAreaPreview } from "./ExportAreaPreview"; -export { ExportOverlay } from "./ExportOverlay"; +export { ExportAreaCover } from "./components/ExportAreaCover"; +export { ExportAreaPreview } from "./components/ExportAreaPreview"; +export { ExportOverlay } from "./components/ExportOverlay"; +export { ExportPreviewLayer } from "./components/ExportPreviewLayer"; export * from "./atoms"; -export type { ExportBackgroundMode } from "./types"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/export/capture-element.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/export/utils/capture-element.ts similarity index 98% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/export/capture-element.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/features/export/utils/capture-element.ts index edf6a52e646..79d6f4d5441 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/export/capture-element.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/export/utils/capture-element.ts @@ -1,8 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import type { createStore } from "jotai"; + import { toPng } from "html-to-image"; -import { getDefaultStore } from "jotai"; import { nodesByIdAtom } from "@/lib/graph"; interface SaveFilePickerOptions { @@ -28,7 +29,7 @@ declare global { } } -type Store = ReturnType; +type Store = ReturnType; /** * Compute the bounding box of all graph nodes from the Jotai store. diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/api.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/api.ts new file mode 100644 index 00000000000..05fb0627135 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/api.ts @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ResourceTypeCatalog, ResourceTypeNamespace } from "./types"; + +import { defineNotification, defineRequest, useWebviewMessageChannel } from "@vscode-bicep-ui/messaging"; +import { useMemo } from "react"; + +// ── Experimental resource creation ── +// The palette is hidden entirely when the host reports the feature as disabled. + +export const getResourceCreationEnablement = defineRequest("resourceCreation/isEnabled"); + +export const resourceCreationEnablementDidChange = defineNotification("resourceCreation/enablementDidChange"); + +// ── Resource type catalog ── +// The catalog is versioned by `catalogId`. The host derives it from the document's resource type +// provider, so editing the file (adding an `extension` declaration, say) can mint a new catalog; +// responses carrying a stale id must be discarded rather than merged. + +export interface GetResourceTypeNamespacesResult { + catalogId: string; + namespaces: ResourceTypeNamespace[]; +} + +export const getResourceTypeNamespaces = defineRequest( + "resourceTypeCatalog/namespaces", +); + +export interface LoadResourceTypeCatalogParams { + providerNamespace?: string; + query?: string; + /** Load every namespace at once, so searching can filter locally instead of round-tripping. */ + loadAll?: boolean; +} + +export const loadResourceTypeCatalog = defineRequest( + "resourceTypeCatalog/load", +); + +/** + * The palette's operations against the extension host. + * + * Callers get bound methods rather than a channel and a descriptor to combine themselves, so this is + * the only place in the feature that touches the transport, and a test can substitute the whole + * surface by stubbing this hook. + * + * Only imperative calls belong here. Subscriptions stay declarative at the call site via + * `useNotification(descriptor, handler)`, which composes better with React's lifecycle. + */ +export function usePaletteApi() { + const channel = useWebviewMessageChannel(); + + return useMemo( + () => ({ + getNamespaces: () => channel.request(getResourceTypeNamespaces), + loadCatalog: (params: LoadResourceTypeCatalogParams) => channel.request(loadResourceTypeCatalog, params), + }), + [channel], + ); +} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/atoms.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/atoms.ts similarity index 89% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/atoms.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/features/palette/atoms.ts index 164d0856329..6a4855b4a72 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/atoms.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/atoms.ts @@ -1,13 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { VisualResourceTypeReference } from "@/lib/messaging/messages"; +import type { ResourceTypeReference } from "@/features/canvas"; import { atom } from "jotai"; import { atomFamily } from "jotai-family"; export interface PaletteDragState { - item: VisualResourceTypeReference; + item: ResourceTypeReference; clientX: number; clientY: number; } diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/accessibility/MotionAwareProgressBar.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/MotionAwareProgressBar.tsx similarity index 66% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/accessibility/MotionAwareProgressBar.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/MotionAwareProgressBar.tsx index c07133b9e39..7cda84fb9aa 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/accessibility/MotionAwareProgressBar.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/MotionAwareProgressBar.tsx @@ -3,15 +3,9 @@ import { ProgressBar } from "@vscode-bicep-ui/components"; import { useAtomValue } from "jotai"; -import { motionPolicyAtom } from "./atoms"; +import { motionPolicyAtom } from "@/hooks"; -export function MotionAwareProgressBar({ - testId, - ariaLabel, -}: { - testId?: string; - ariaLabel: string; -}) { +export function MotionAwareProgressBar({ testId, ariaLabel }: { testId?: string; ariaLabel: string }) { const policy = useAtomValue(motionPolicyAtom); return ; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/Palette.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/Palette.tsx new file mode 100644 index 00000000000..6c2bdb5ea7f --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/Palette.tsx @@ -0,0 +1,232 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { PaletteDragState } from "../atoms"; + +import { Codicon } from "@vscode-bicep-ui/components"; +import { AnimatePresence, motion } from "motion/react"; +import { useCallback, useState } from "react"; +import { styled } from "styled-components"; +import { useCanvasActions } from "@/features/canvas"; +import { EXPAND_TRANSITION, FloatingPanel, IconButton } from "@/ui"; +import { usePaletteDrag } from "../hooks/use-palette-drag"; +import { useResourceCreationEnablement } from "../hooks/use-resource-creation-enablement"; +import { useResourceTypeCatalog } from "../hooks/use-resource-type-catalog"; +import { PaletteContent } from "./PaletteContent"; +import { PaletteDragOverlay } from "./PaletteDragOverlay"; + +const MotionFloatingPanel = motion.create(FloatingPanel); + +const $PaletteLauncher = styled(MotionFloatingPanel)` + position: absolute; + top: 16px; + left: 16px; + z-index: 200; +`; + +const $PaletteIsland = styled(motion.aside)` + position: absolute; + top: 16px; + bottom: 32px; + left: 16px; + z-index: 200; + display: flex; + width: min(340px, calc(100vw - 48px)); + max-height: 640px; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--vscode-widget-border); + border-radius: 11px; + color: var(--vscode-foreground); + background: color-mix(in srgb, var(--vscode-editorWidget-background) 96%, transparent); + box-shadow: 0 12px 36px var(--vscode-widget-shadow); + backdrop-filter: blur(12px); +`; + +const $PaletteBody = styled(motion.div)` + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; +`; + +const $PaletteHeader = styled.header` + display: flex; + min-height: 38px; + align-items: center; + justify-content: space-between; + padding: 0 8px 0 11px; + border-bottom: 1px solid var(--vscode-widget-border); +`; + +const $PaletteTitle = styled.div` + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 600; +`; + +const $PaletteIcon = styled(motion.span)` + display: inline-flex; + align-items: center; + justify-content: center; +`; + +const $PaletteClose = styled.button` + display: grid; + width: 28px; + height: 28px; + place-items: center; + border: 0; + border-radius: 6px; + color: inherit; + background: transparent; + cursor: pointer; + + &:hover { + background: var(--vscode-toolbar-hoverBackground); + } +`; + +const $PaletteScrollArea = styled.div` + min-height: 0; + overflow: auto; + flex: 1; + scrollbar-width: thin; + scrollbar-color: var(--vscode-scrollbarSlider-background) transparent; + + &::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + border: 2px solid transparent; + border-radius: 999px; + background: var(--vscode-scrollbarSlider-background); + background-clip: content-box; + } + + &::-webkit-scrollbar-thumb:hover { + background-color: var(--vscode-scrollbarSlider-hoverBackground); + } + + &::-webkit-scrollbar-thumb:active { + background-color: var(--vscode-scrollbarSlider-activeBackground); + } +`; + +function EnabledPalette() { + const [isOpen, setIsOpen] = useState(false); + const { createResource, canPlaceResourceAt } = useCanvasActions(); + const { catalogId, namespaces, namespaceError, loadNamespace, search, refresh } = useResourceTypeCatalog(); + + const placeResource = useCallback( + (resourceType: PaletteDragState["item"], clientX: number, clientY: number) => { + void createResource(resourceType, { x: clientX, y: clientY }); + }, + [createResource], + ); + + const activateResource = useCallback( + (resourceType: PaletteDragState["item"]) => { + // No point: the graph surface decides where a keyboard-activated resource lands. + void createResource(resourceType); + }, + [createResource], + ); + + const { startDrag } = usePaletteDrag(canPlaceResourceAt, placeResource); + + return ( + <> + + {isOpen ? ( + <$PaletteIsland + key="palette" + initial={{ + opacity: 0, + clipPath: "inset(0 calc(100% - 38px) calc(100% - 38px) 0 round 8px)", + }} + animate={{ + opacity: 1, + clipPath: "inset(0 0 0 0 round 11px)", + }} + exit={{ + opacity: 0, + clipPath: "inset(0 calc(100% - 38px) calc(100% - 38px) 0 round 8px)", + }} + transition={EXPAND_TRANSITION} + > + <$PaletteBody + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + exit={{ opacity: 0 }} + transition={{ duration: 0.08, delay: 0.04 }} + > + <$PaletteHeader> + <$PaletteTitle> + <$PaletteIcon layoutId="resource-palette-icon"> + + + Add Resources + + <$PaletteClose aria-label="Close Resource Palette" onClick={() => setIsOpen(false)}> + + + + <$PaletteScrollArea> + + + + + ) : ( + <$PaletteLauncher + key="launcher" + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + exit={{ opacity: 0 }} + transition={{ duration: 0.06 }} + > + setIsOpen(true)} + > + <$PaletteIcon layoutId="resource-palette-icon"> + + + + + )} + + + + ); +} + +/** + * The Resource Palette: a launcher button that expands into a searchable list of Azure resource types, + * which the user can drag onto the graph or activate with the keyboard. Renders nothing when the + * experimental resource-creation setting is off. + */ +export function Palette() { + const enabled = useResourceCreationEnablement(); + + return enabled ? : null; +} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/ResourcePalette.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/PaletteContent.tsx similarity index 68% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/ResourcePalette.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/PaletteContent.tsx index 8baf6ab7533..c1903e4dd29 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/ResourcePalette.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/PaletteContent.tsx @@ -1,7 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { ResourcePaletteProps } from "./ResourcePalette.types"; +import type { PointerEvent } from "react"; +import type { ResourceTypeReference } from "@/features/canvas"; +import type { ResourceTypeCatalog, ResourceTypeNamespace } from "../types"; import { useAtomValue } from "jotai"; import { useEffect } from "react"; @@ -9,19 +11,23 @@ import { getNamespaceResourceTypesKey, namespaceResourceTypesAtomFamily, resourceTypeCatalogLoadingCountAtom, -} from "./atoms"; -import { ResourcePaletteControls } from "./ResourcePaletteControls"; -import { - LazyResourceTypeGroups, - PaletteMessage, - PaletteRetry, - SearchResourceTypeGroups, -} from "./ResourceTypeGroups"; -import { useResourceTypeSearch } from "./use-resource-type-search"; +} from "../atoms"; +import { useResourceTypeSearch } from "../hooks/use-resource-type-search"; +import { PaletteControls } from "./PaletteControls"; +import { LazyResourceTypeGroups, PaletteMessage, PaletteRetry, SearchResourceTypeGroups } from "./ResourceTypeGroups"; -export type * from "./ResourcePalette.types"; +export interface PaletteContentProps { + catalogId?: string; + namespaces?: ResourceTypeNamespace[]; + namespaceError?: unknown; + loadNamespace: (providerNamespace: string) => Promise; + search: (query: string) => Promise; + onRetryNamespaces: () => void; + onResourceTypeActivate?: (resourceType: ResourceTypeReference) => void; + onResourceTypePointerDown?: (resourceType: ResourceTypeReference, event: PointerEvent) => void; +} -export function ResourcePalette({ +export function PaletteContent({ catalogId, namespaces, namespaceError, @@ -30,7 +36,7 @@ export function ResourcePalette({ onRetryNamespaces, onResourceTypeActivate, onResourceTypePointerDown, -}: ResourcePaletteProps) { +}: PaletteContentProps) { const { activeState: searchState, expandedGroups: searchExpandedGroups, @@ -55,13 +61,11 @@ export function ResourcePalette({ const searchGroups = searchState.status === "loaded" ? searchState.groups : []; const showProgress = - (!namespaces && !namespaceError) || - (isSearching && searchState.status === "loading") || - namespaceLoadingCount > 0; + (!namespaces && !namespaceError) || (isSearching && searchState.status === "loading") || namespaceLoadingCount > 0; return ( <> - + {namespaceError ? ( Failed to load resource provider namespaces. diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/ResourcePaletteControls.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/PaletteControls.tsx similarity index 87% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/ResourcePaletteControls.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/PaletteControls.tsx index ef445be7d2b..385bb86af9d 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/ResourcePaletteControls.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/PaletteControls.tsx @@ -3,7 +3,7 @@ import { Codicon } from "@vscode-bicep-ui/components"; import styled from "styled-components"; -import { MotionAwareProgressBar } from "@/features/accessibility"; +import { MotionAwareProgressBar } from "./MotionAwareProgressBar"; const $StickyControls = styled.div` position: sticky; @@ -50,7 +50,7 @@ const $ProgressTrack = styled.div` overflow: hidden; `; -export function ResourcePaletteControls({ +export function PaletteControls({ query, setQuery, showProgress, @@ -72,10 +72,7 @@ export function ResourcePaletteControls({ <$ProgressTrack aria-hidden={!showProgress}> {showProgress && ( - + )} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/PaletteDragOverlay.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/PaletteDragOverlay.tsx similarity index 76% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/PaletteDragOverlay.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/PaletteDragOverlay.tsx index cff28c0fe8e..ffba6e9f59a 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/PaletteDragOverlay.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/PaletteDragOverlay.tsx @@ -1,14 +1,14 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { PaletteDragState } from "./atoms"; +import type { PaletteDragState } from "../atoms"; import { usePanZoomTransform } from "@vscode-bicep-ui/components"; import { useAtomValue } from "jotai"; import { createPortal } from "react-dom"; import styled from "styled-components"; -import { paletteDragAtom } from "./atoms"; -import { ResourcePreviewCard } from "@/features/resource-creation"; +import { ResourceNodePreview } from "@/features/canvas"; +import { paletteDragAtom } from "../atoms"; const $Positioner = styled.div` position: fixed; @@ -23,10 +23,7 @@ function ActivePaletteDragOverlay({ drag }: { drag: PaletteDragState }) { return createPortal( <$Positioner data-testid="palette-drag-preview" style={{ left: drag.clientX, top: drag.clientY }}>
- +
, document.body, diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/ResourceTypeGroups.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/ResourceTypeGroups.tsx similarity index 90% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/ResourceTypeGroups.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/ResourceTypeGroups.tsx index e6ca99ac873..13ebd82d9b8 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/ResourceTypeGroups.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/ResourceTypeGroups.tsx @@ -2,24 +2,21 @@ // Licensed under the MIT License. import type { ReactNode } from "react"; -import type { - ResourcePaletteProps, - ResourceTypeCatalogGroup, - ResourceTypeNamespace, -} from "./ResourcePalette.types"; -import type { ResourceTypeCatalogEntry } from "./atoms"; +import type { ResourceTypeCatalogEntry } from "../atoms"; +import type { ResourceTypeCatalogGroup, ResourceTypeNamespace } from "../types"; +import type { PaletteContentProps } from "./PaletteContent"; import { Accordion, AzureIcon, Codicon, useAccordionItem } from "@vscode-bicep-ui/components"; import { useAtomValue, useSetAtom, useStore } from "jotai"; import { motion } from "motion/react"; import { useCallback, useMemo, useState } from "react"; import styled from "styled-components"; +import { getErrorMessage } from "@/utils"; import { getNamespaceResourceTypesKey, namespaceResourceTypesAtomFamily, resourceTypeCatalogLoadingCountAtom, -} from "./atoms"; -import { getErrorMessage } from "./resource-palette-utils"; +} from "../atoms"; const $Groups = styled.div` display: flex; @@ -196,8 +193,8 @@ function ResourceTypeItems({ group: string; resourceTypes: ResourceTypeCatalogEntry[]; highlightQuery?: string; - onResourceTypeActivate?: ResourcePaletteProps["onResourceTypeActivate"]; - onResourceTypePointerDown?: ResourcePaletteProps["onResourceTypePointerDown"]; + onResourceTypeActivate?: PaletteContentProps["onResourceTypeActivate"]; + onResourceTypePointerDown?: PaletteContentProps["onResourceTypePointerDown"]; }) { return ( <$Items> @@ -267,9 +264,9 @@ function LazyResourceTypeGroup({ }: { catalogId: string; namespace: ResourceTypeNamespace; - loadNamespace: ResourcePaletteProps["loadNamespace"]; - onResourceTypeActivate?: ResourcePaletteProps["onResourceTypeActivate"]; - onResourceTypePointerDown?: ResourcePaletteProps["onResourceTypePointerDown"]; + loadNamespace: PaletteContentProps["loadNamespace"]; + onResourceTypeActivate?: PaletteContentProps["onResourceTypeActivate"]; + onResourceTypePointerDown?: PaletteContentProps["onResourceTypePointerDown"]; }) { const stateAtom = useMemo( () => namespaceResourceTypesAtomFamily(getNamespaceResourceTypesKey(catalogId, namespace.name)), @@ -345,8 +342,8 @@ export function SearchResourceTypeGroups({ expandedGroups: readonly string[]; highlightQuery: string; setExpandedGroups: (groups: readonly string[]) => void; - onResourceTypeActivate?: ResourcePaletteProps["onResourceTypeActivate"]; - onResourceTypePointerDown?: ResourcePaletteProps["onResourceTypePointerDown"]; + onResourceTypeActivate?: PaletteContentProps["onResourceTypeActivate"]; + onResourceTypePointerDown?: PaletteContentProps["onResourceTypePointerDown"]; }) { return ( <$Groups> @@ -378,19 +375,15 @@ export function LazyResourceTypeGroups({ }: { catalogId: string; namespaces: ResourceTypeNamespace[]; - loadNamespace: ResourcePaletteProps["loadNamespace"]; - onResourceTypeActivate?: ResourcePaletteProps["onResourceTypeActivate"]; - onResourceTypePointerDown?: ResourcePaletteProps["onResourceTypePointerDown"]; + loadNamespace: PaletteContentProps["loadNamespace"]; + onResourceTypeActivate?: PaletteContentProps["onResourceTypeActivate"]; + onResourceTypePointerDown?: PaletteContentProps["onResourceTypePointerDown"]; }) { const [expandedGroups, setExpandedGroups] = useState([]); return ( <$Groups> - setExpandedGroups(value.map(String))} - > + setExpandedGroups(value.map(String))}> {namespaces.map((namespace) => ( HTMLElement | null, + canPlaceResourceAt: (clientPoint: { x: number; y: number }) => boolean, onDrop: (item: PaletteDragState["item"], clientX: number, clientY: number) => void, ) { const activeDragRef = useRef(null); @@ -41,19 +41,7 @@ export function usePaletteDrag( return; } - const canvas = getCanvasElement(); - const bounds = canvas?.getBoundingClientRect(); - const elementAtPointer = document.elementFromPoint(event.clientX, event.clientY); - if ( - canvas && - bounds && - elementAtPointer && - canvas.contains(elementAtPointer) && - event.clientX >= bounds.left && - event.clientX <= bounds.right && - event.clientY >= bounds.top && - event.clientY <= bounds.bottom - ) { + if (canPlaceResourceAt({ x: event.clientX, y: event.clientY })) { onDrop(drag.item, event.clientX, event.clientY); } cancelDrag(); @@ -74,7 +62,7 @@ export function usePaletteDrag( window.removeEventListener("pointercancel", cancelDrag); window.removeEventListener("keydown", handleKeyDown); }; - }, [cancelDrag, getCanvasElement, onDrop, setDragState]); + }, [canPlaceResourceAt, cancelDrag, onDrop, setDragState]); const startDrag = useCallback( (item: PaletteDragState["item"], event: ReactPointerEvent) => { diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/hooks/use-resource-creation-enablement.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/hooks/use-resource-creation-enablement.ts new file mode 100644 index 00000000000..ec448beb688 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/hooks/use-resource-creation-enablement.ts @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useNotification, useRequest } from "@vscode-bicep-ui/messaging"; +import { useCallback, useState } from "react"; +import { getResourceCreationEnablement, resourceCreationEnablementDidChange } from "../api"; + +export function useResourceCreationEnablement(): boolean { + const [initialEnablement] = useRequest(getResourceCreationEnablement); + const [updatedEnablement, setUpdatedEnablement] = useState(); + + useNotification( + resourceCreationEnablementDidChange, + useCallback((enabled: boolean) => setUpdatedEnablement(enabled), []), + ); + + return updatedEnablement ?? initialEnablement ?? false; +} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/hooks/use-resource-type-catalog.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/hooks/use-resource-type-catalog.ts new file mode 100644 index 00000000000..cb55257aa78 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/hooks/use-resource-type-catalog.ts @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { GetResourceTypeNamespacesResult, LoadResourceTypeCatalogParams } from "../api"; +import type { ResourceTypeCatalog, ResourceTypeNamespace } from "../types"; + +import { useNotification } from "@vscode-bicep-ui/messaging"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { documentDidChange } from "@/hooks"; +import { usePaletteApi } from "../api"; + +/** Edits arrive in bursts, so refreshes are debounced. The first load is immediate. */ +const REFRESH_DEBOUNCE_MS = 250; + +type NamespaceCatalogState = + | { status: "loading" } + | { status: "loaded"; catalog: GetResourceTypeNamespacesResult } + | { status: "error"; error: unknown }; + +export interface ResourceTypeCatalogSource { + catalogId?: string; + namespaces?: ResourceTypeNamespace[]; + namespaceError?: unknown; + loadNamespace: (providerNamespace: string) => Promise; + search: (query: string) => Promise; + refresh: () => void; +} + +/** + * Loads the resource-type catalog from the host and keeps it current as the document changes. + * + * The catalog is versioned by `catalogId`: the host may rebuild it at any time, and a response from an + * older catalog cannot be mixed with newer namespace data. Every load therefore checks the id it came + * back with and forces a refresh on mismatch, and in-flight namespace requests are matched against a + * generation counter so a slow response cannot overwrite a newer one. + */ +export function useResourceTypeCatalog(): ResourceTypeCatalogSource { + const api = usePaletteApi(); + const [namespaceCatalogState, setNamespaceCatalogState] = useState({ status: "loading" }); + const [refreshGeneration, setRefreshGeneration] = useState(0); + const namespaceRequestGenerationRef = useRef(0); + const searchableCatalogRef = useRef(undefined); + + const refresh = useCallback(() => { + setRefreshGeneration((generation) => generation + 1); + }, []); + + useNotification( + documentDidChange, + useCallback(() => refresh(), [refresh]), + ); + + useEffect(() => { + const requestGeneration = ++namespaceRequestGenerationRef.current; + const timeout = window.setTimeout( + () => { + setNamespaceCatalogState((current) => (current.status === "loaded" ? current : { status: "loading" })); + void api.getNamespaces().then( + (catalog) => { + if (requestGeneration === namespaceRequestGenerationRef.current) { + if (searchableCatalogRef.current?.catalogId !== catalog.catalogId) { + searchableCatalogRef.current = undefined; + } + setNamespaceCatalogState({ status: "loaded", catalog }); + } + }, + (error: unknown) => { + if (requestGeneration === namespaceRequestGenerationRef.current) { + setNamespaceCatalogState({ status: "error", error }); + } + }, + ); + }, + refreshGeneration === 0 ? 0 : REFRESH_DEBOUNCE_MS, + ); + + return () => window.clearTimeout(timeout); + }, [api, refreshGeneration]); + + const requestCatalog = useCallback( + async (params: LoadResourceTypeCatalogParams): Promise => { + const catalog = await api.loadCatalog(params); + const currentCatalogId = + namespaceCatalogState.status === "loaded" ? namespaceCatalogState.catalog.catalogId : undefined; + + if (!currentCatalogId || currentCatalogId !== catalog.catalogId) { + refresh(); + throw new Error("The resource type catalog changed. Refreshing the Resource Palette."); + } + + return catalog; + }, + [api, namespaceCatalogState, refresh], + ); + + const loadNamespace = useCallback( + (providerNamespace: string) => requestCatalog({ providerNamespace }), + [requestCatalog], + ); + + const search = useCallback( + async (query: string): Promise => { + // Searching needs every namespace, so the full catalog is fetched once and filtered locally. + let catalog = searchableCatalogRef.current; + if (!catalog) { + catalog = await requestCatalog({ loadAll: true }); + searchableCatalogRef.current = catalog; + } + + const normalizedQuery = query.toLocaleLowerCase(); + return { + catalogId: catalog.catalogId, + groups: catalog.groups + .map((group) => ({ + ...group, + resourceTypes: group.resourceTypes.filter((resourceType) => + `${group.group}/${resourceType.resourceType}`.toLocaleLowerCase().includes(normalizedQuery), + ), + })) + .filter((group) => group.resourceTypes.length > 0), + }; + }, + [requestCatalog], + ); + + return { + catalogId: namespaceCatalogState.status === "loaded" ? namespaceCatalogState.catalog.catalogId : undefined, + namespaces: namespaceCatalogState.status === "loaded" ? namespaceCatalogState.catalog.namespaces : undefined, + namespaceError: namespaceCatalogState.status === "error" ? namespaceCatalogState.error : undefined, + loadNamespace, + search, + refresh, + }; +} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/use-resource-type-search.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/hooks/use-resource-type-search.ts similarity index 88% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/use-resource-type-search.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/features/palette/hooks/use-resource-type-search.ts index 22adee9f8c9..3756d1d94ff 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/use-resource-type-search.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/hooks/use-resource-type-search.ts @@ -1,10 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { ResourcePaletteProps, ResourceTypeCatalogGroup } from "./ResourcePalette.types"; +import type { PaletteContentProps } from "../components/PaletteContent"; +import type { ResourceTypeCatalogGroup } from "../types"; import { useEffect, useRef, useState } from "react"; -import { getErrorMessage } from "./resource-palette-utils"; +import { getErrorMessage } from "@/utils"; type SearchState = | { status: "idle" } @@ -12,7 +13,7 @@ type SearchState = | { status: "loaded"; query: string; groups: ResourceTypeCatalogGroup[] } | { status: "error"; query: string; message: string }; -export function useResourceTypeSearch(search: ResourcePaletteProps["search"]) { +export function useResourceTypeSearch(search: PaletteContentProps["search"]) { const [query, setQuery] = useState(""); const [expandedGroups, setExpandedGroups] = useState([]); const [state, setState] = useState({ status: "idle" }); diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/index.ts new file mode 100644 index 00000000000..59c29295e2d --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/index.ts @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export { Palette } from "./components/Palette"; +export * from "./api"; +export * from "./types"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/types.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/types.ts new file mode 100644 index 00000000000..6453d3eae7e --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/types.ts @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ResourceTypeCatalogEntry } from "./atoms"; + +export interface ResourceTypeNamespace { + name: string; + resourceTypeCount: number; +} + +export interface ResourceTypeCatalogGroup { + group: string; + resourceTypes: ResourceTypeCatalogEntry[]; +} + +export interface ResourceTypeCatalog { + catalogId: string; + groups: ResourceTypeCatalogGroup[]; +} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-creation/animations.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-creation/animations.ts deleted file mode 100644 index b1f2277c769..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-creation/animations.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -export const RESOURCE_CREATION_TRANSITION = { - duration: 0.16, - ease: [0.2, 0.8, 0.2, 1] as const, -}; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-creation/atoms.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-creation/atoms.ts deleted file mode 100644 index 4a71e579544..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-creation/atoms.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import type { VisualResourceTypeReference } from "@/lib/messaging/messages"; -import type { Point } from "@/lib/utils/math/geometry"; - -import { atom } from "jotai"; -import { atomFamily } from "jotai-family"; - -export interface PendingResource { - operationId: string; - resourceType: VisualResourceTypeReference; - origin: Point; - expectedNodeId?: string; -} - -export const pendingResourcesAtom = atom([]); -export const resourceCreationErrorAtom = atom(null); -export const resourceNodeIsCommittingAtomFamily = atomFamily((_nodeId: string) => atom(false)); diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-creation/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-creation/index.ts deleted file mode 100644 index 4f936871c54..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-creation/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -export * from "./atoms"; -export * from "./animations"; -export * from "./PendingResourceLayer"; -export * from "./ResourceCreationError"; -export * from "./ResourcePreviewCard"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/ResourcePalette.types.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/ResourcePalette.types.ts deleted file mode 100644 index 87e9f7897b3..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/ResourcePalette.types.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import type { ResourceTypeCatalogEntry } from "./atoms"; -import type { PointerEvent } from "react"; - -export interface ResourceTypeNamespace { - name: string; - resourceTypeCount: number; -} - -export interface ResourceTypeCatalogGroup { - group: string; - resourceTypes: ResourceTypeCatalogEntry[]; -} - -export interface ResourceTypeCatalog { - catalogId: string; - groups: ResourceTypeCatalogGroup[]; -} - -export interface ResourceTypeReference { - fullyQualifiedType: string; - apiVersion: string; -} - -export interface ResourcePaletteProps { - catalogId?: string; - namespaces?: ResourceTypeNamespace[]; - namespaceError?: unknown; - loadNamespace: (providerNamespace: string) => Promise; - search: (query: string) => Promise; - onRetryNamespaces: () => void; - onResourceTypeActivate?: (resourceType: ResourceTypeReference) => void; - onResourceTypePointerDown?: (resourceType: ResourceTypeReference, event: PointerEvent) => void; -} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/ResourcePaletteLayer.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/ResourcePaletteLayer.tsx deleted file mode 100644 index 50cf360e1a4..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/ResourcePaletteLayer.tsx +++ /dev/null @@ -1,362 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import type { GraphUpdateActions } from "@/lib/messaging/use-graph-update"; - -import { Codicon, useGetPanZoomTransform } from "@vscode-bicep-ui/components"; -import { useWebviewMessageChannel, useWebviewNotification } from "@vscode-bicep-ui/messaging"; -import { AnimatePresence, motion } from "motion/react"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { styled } from "styled-components"; -import { ControlButton, ControlSurface } from "@/features/controls"; -import { DOCUMENT_DID_CHANGE_NOTIFICATION } from "@/lib/messaging"; -import { RESOURCE_CREATION_TRANSITION } from "@/features/resource-creation"; -import type { PaletteDragState } from "./atoms"; -import { PaletteDragOverlay } from "./PaletteDragOverlay"; -import { - ResourcePalette, - type ResourceTypeCatalog, - type ResourceTypeNamespace, -} from "./ResourcePalette"; -import { usePaletteDrag } from "./use-palette-drag"; -import { useResourceCreationEnablement } from "./use-resource-creation-enablement"; -import { viewportToGraphPoint } from "./contracts"; - -interface ResourcePaletteLayerProps { - createResource: GraphUpdateActions["createResource"]; - getCanvasElement: () => HTMLElement | null; -} - -interface ResourceTypeNamespaceCatalog { - catalogId: string; - namespaces: ResourceTypeNamespace[]; -} - -type NamespaceCatalogState = - | { status: "loading" } - | { status: "loaded"; catalog: ResourceTypeNamespaceCatalog } - | { status: "error"; error: unknown }; - -const MotionControlSurface = motion.create(ControlSurface); - -const $ResourcePaletteLauncher = styled(MotionControlSurface)` - position: absolute; - top: 16px; - left: 16px; - z-index: 200; -`; - -const $ResourcePaletteIsland = styled(motion.aside)` - position: absolute; - top: 16px; - bottom: 32px; - left: 16px; - z-index: 200; - display: flex; - width: min(340px, calc(100vw - 48px)); - max-height: 640px; - flex-direction: column; - overflow: hidden; - border: 1px solid var(--vscode-widget-border); - border-radius: 11px; - color: var(--vscode-foreground); - background: color-mix(in srgb, var(--vscode-editorWidget-background) 96%, transparent); - box-shadow: 0 12px 36px var(--vscode-widget-shadow); - backdrop-filter: blur(12px); -`; - -const $ResourcePaletteBody = styled(motion.div)` - display: flex; - min-height: 0; - flex: 1; - flex-direction: column; -`; - -const $ResourcePaletteHeader = styled.header` - display: flex; - min-height: 38px; - align-items: center; - justify-content: space-between; - padding: 0 8px 0 11px; - border-bottom: 1px solid var(--vscode-widget-border); -`; - -const $ResourcePaletteTitle = styled.div` - display: flex; - align-items: center; - gap: 8px; - font-size: 12px; - font-weight: 600; -`; - -const $ResourcePaletteIcon = styled(motion.span)` - display: inline-flex; - align-items: center; - justify-content: center; -`; - -const $ResourcePaletteClose = styled.button` - display: grid; - width: 28px; - height: 28px; - place-items: center; - border: 0; - border-radius: 6px; - color: inherit; - background: transparent; - cursor: pointer; - - &:hover { - background: var(--vscode-toolbar-hoverBackground); - } -`; - -const $ResourcePaletteContent = styled.div` - min-height: 0; - overflow: auto; - flex: 1; - scrollbar-width: thin; - scrollbar-color: var(--vscode-scrollbarSlider-background) transparent; - - &::-webkit-scrollbar { - width: 10px; - height: 10px; - } - - &::-webkit-scrollbar-track { - background: transparent; - } - - &::-webkit-scrollbar-thumb { - border: 2px solid transparent; - border-radius: 999px; - background: var(--vscode-scrollbarSlider-background); - background-clip: content-box; - } - - &::-webkit-scrollbar-thumb:hover { - background-color: var(--vscode-scrollbarSlider-hoverBackground); - } - - &::-webkit-scrollbar-thumb:active { - background-color: var(--vscode-scrollbarSlider-activeBackground); - } -`; - -function EnabledResourcePaletteLayer({ createResource, getCanvasElement }: ResourcePaletteLayerProps) { - const getPanZoomTransform = useGetPanZoomTransform(); - const messageChannel = useWebviewMessageChannel(); - const [isOpen, setIsOpen] = useState(false); - const [namespaceCatalogState, setNamespaceCatalogState] = useState({ - status: "loading", - }); - const [refreshGeneration, setRefreshGeneration] = useState(0); - const namespaceRequestGenerationRef = useRef(0); - const searchableCatalogRef = useRef(undefined); - - const refreshNamespaces = useCallback(() => { - setRefreshGeneration((generation) => generation + 1); - }, []); - - useWebviewNotification( - DOCUMENT_DID_CHANGE_NOTIFICATION, - useCallback(() => refreshNamespaces(), [refreshNamespaces]), - ); - - useEffect(() => { - const requestGeneration = ++namespaceRequestGenerationRef.current; - const timeout = window.setTimeout( - () => { - setNamespaceCatalogState((current) => (current.status === "loaded" ? current : { status: "loading" })); - void messageChannel - .sendRequest({ method: "resourceTypeCatalog/namespaces" }) - .then( - (catalog) => { - if (requestGeneration === namespaceRequestGenerationRef.current) { - if (searchableCatalogRef.current?.catalogId !== catalog.catalogId) { - searchableCatalogRef.current = undefined; - } - setNamespaceCatalogState({ status: "loaded", catalog }); - } - }, - (error: unknown) => { - if (requestGeneration === namespaceRequestGenerationRef.current) { - setNamespaceCatalogState({ status: "error", error }); - } - }, - ); - }, - refreshGeneration === 0 ? 0 : 250, - ); - - return () => window.clearTimeout(timeout); - }, [messageChannel, refreshGeneration]); - - const requestCatalog = useCallback( - async (params: { providerNamespace?: string; query?: string; loadAll?: boolean }): Promise => { - const catalog = await messageChannel.sendRequest({ - method: "resourceTypeCatalog/load", - params, - }); - const currentCatalogId = - namespaceCatalogState.status === "loaded" ? namespaceCatalogState.catalog.catalogId : undefined; - - if (!currentCatalogId || currentCatalogId !== catalog.catalogId) { - refreshNamespaces(); - throw new Error("The resource type catalog changed. Refreshing the Resource Palette."); - } - - return catalog; - }, - [messageChannel, namespaceCatalogState, refreshNamespaces], - ); - - const loadNamespace = useCallback( - (providerNamespace: string) => requestCatalog({ providerNamespace }), - [requestCatalog], - ); - - const search = useCallback( - async (query: string): Promise => { - let catalog = searchableCatalogRef.current; - if (!catalog) { - catalog = await requestCatalog({ loadAll: true }); - searchableCatalogRef.current = catalog; - } - - const normalizedQuery = query.toLocaleLowerCase(); - return { - catalogId: catalog.catalogId, - groups: catalog.groups - .map((group) => ({ - ...group, - resourceTypes: group.resourceTypes.filter((resourceType) => - `${group.group}/${resourceType.resourceType}`.toLocaleLowerCase().includes(normalizedQuery), - ), - })) - .filter((group) => group.resourceTypes.length > 0), - }; - }, - [requestCatalog], - ); - - const placeResource = useCallback( - (resourceType: PaletteDragState["item"], clientX: number, clientY: number) => { - const canvas = getCanvasElement(); - if (!canvas) { - return; - } - - const origin = viewportToGraphPoint( - { x: clientX, y: clientY }, - canvas.getBoundingClientRect(), - getPanZoomTransform(), - ); - if (origin) { - void createResource(resourceType, origin); - } - }, - [createResource, getCanvasElement, getPanZoomTransform], - ); - - const activateResource = useCallback( - (resourceType: PaletteDragState["item"]) => { - const canvas = getCanvasElement(); - if (!canvas) { - return; - } - - const bounds = canvas.getBoundingClientRect(); - placeResource(resourceType, bounds.left + bounds.width / 2, bounds.top + bounds.height / 2); - }, - [getCanvasElement, placeResource], - ); - - const { startDrag } = usePaletteDrag(getCanvasElement, placeResource); - - return ( - <> - - {isOpen ? ( - <$ResourcePaletteIsland - key="palette" - initial={{ - opacity: 0, - clipPath: "inset(0 calc(100% - 38px) calc(100% - 38px) 0 round 8px)", - }} - animate={{ - opacity: 1, - clipPath: "inset(0 0 0 0 round 11px)", - }} - exit={{ - opacity: 0, - clipPath: "inset(0 calc(100% - 38px) calc(100% - 38px) 0 round 8px)", - }} - transition={RESOURCE_CREATION_TRANSITION} - > - <$ResourcePaletteBody - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - exit={{ opacity: 0 }} - transition={{ duration: 0.08, delay: 0.04 }} - > - <$ResourcePaletteHeader> - <$ResourcePaletteTitle> - <$ResourcePaletteIcon layoutId="resource-palette-icon"> - - - Add Resources - - <$ResourcePaletteClose aria-label="Close Resource Palette" onClick={() => setIsOpen(false)}> - - - - <$ResourcePaletteContent> - - - - - ) : ( - <$ResourcePaletteLauncher - key="launcher" - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - exit={{ opacity: 0 }} - transition={{ duration: 0.06 }} - > - setIsOpen(true)} - > - <$ResourcePaletteIcon layoutId="resource-palette-icon"> - - - - - )} - - - - ); -} - -export function ResourcePaletteLayer(props: ResourcePaletteLayerProps) { - const enabled = useResourceCreationEnablement(); - - return enabled ? : null; -} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/contracts.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/contracts.ts deleted file mode 100644 index cc312433409..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/contracts.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import type { Point } from "@/lib/utils/math/geometry"; - -export function viewportToGraphPoint( - clientPoint: Point, - canvasBounds: Pick, - transform: { x: number; y: number; scale: number }, -): Point | null { - if ( - !Number.isFinite(clientPoint.x) || - !Number.isFinite(clientPoint.y) || - !Number.isFinite(transform.x) || - !Number.isFinite(transform.y) || - !Number.isFinite(transform.scale) || - transform.scale <= 0 - ) { - return null; - } - - return { - x: (clientPoint.x - canvasBounds.left - transform.x) / transform.scale, - y: (clientPoint.y - canvasBounds.top - transform.y) / transform.scale, - }; -} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/index.ts deleted file mode 100644 index 37edf9f5a8f..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -export * from "./atoms"; -export * from "./contracts"; -export * from "./PaletteDragOverlay"; -export * from "./ResourcePalette"; -export * from "./ResourcePaletteLayer"; -export * from "./use-palette-drag"; -export * from "./use-resource-creation-enablement"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/use-resource-creation-enablement.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/use-resource-creation-enablement.ts deleted file mode 100644 index cd0efb41d5a..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/use-resource-creation-enablement.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { useWebviewNotification, useWebviewRequest } from "@vscode-bicep-ui/messaging"; -import { useCallback, useState } from "react"; -import { - GET_RESOURCE_CREATION_ENABLEMENT_REQUEST, - RESOURCE_CREATION_ENABLEMENT_DID_CHANGE_NOTIFICATION, -} from "@/lib/messaging"; - -export function useResourceCreationEnablement(): boolean { - const [initialEnablement] = useWebviewRequest(GET_RESOURCE_CREATION_ENABLEMENT_REQUEST); - const [updatedEnablement, setUpdatedEnablement] = useState(); - - useWebviewNotification( - RESOURCE_CREATION_ENABLEMENT_DID_CHANGE_NOTIFICATION, - useCallback((value: unknown) => { - if (typeof value === "boolean") { - setUpdatedEnablement(value); - } - }, []), - ); - - return updatedEnablement ?? initialEnablement ?? false; -} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/status/api.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/status/api.ts new file mode 100644 index 00000000000..6df3ad566f8 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/status/api.ts @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { defineNotification, useWebviewMessageChannel } from "@vscode-bicep-ui/messaging"; +import { useMemo } from "react"; + +/** Sent when the user clicks "Show errors" to open the VS Code Problems panel. */ +export const showProblemsPanel = defineNotification("showProblemsPanel"); + +/** The status bar's operations against the extension host. */ +export function useStatusApi() { + const channel = useWebviewMessageChannel(); + + return useMemo(() => ({ showProblemsPanel: () => channel.notify(showProblemsPanel) }), [channel]); +} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/status/atoms.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/status/atoms.ts index 4c4e212992d..1269e98285e 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/status/atoms.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/status/atoms.ts @@ -33,3 +33,16 @@ export const graphStatusAtom = atom((get) => { return { kind: "ready" }; }); + +/** + * Publishes the graph facts that status is derived from. Owning the write here keeps + * `features/status` the sole authority on how status is computed: callers report what the + * graph contains, not what the status bar should say. + */ +export const reportGraphStatusAtom = atom( + null, + (_get, set, { errorCount, hasNodes }: { errorCount: number; hasNodes: boolean }) => { + set(errorCountAtom, errorCount); + set(hasNodesAtom, hasNodes); + }, +); diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/status/StatusBar.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/status/components/StatusBar.tsx similarity index 86% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/status/StatusBar.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/features/status/components/StatusBar.tsx index 25bdbe76903..66a18895f1b 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/status/StatusBar.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/status/components/StatusBar.tsx @@ -1,12 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { useWebviewMessageChannel } from "@vscode-bicep-ui/messaging"; import { useAtomValue } from "jotai"; import { useCallback } from "react"; import { styled } from "styled-components"; -import { SHOW_PROBLEMS_PANEL_NOTIFICATION } from "@/lib/messaging"; -import { graphStatusAtom } from "./atoms"; +import { useStatusApi } from "../api"; +import { graphStatusAtom } from "../atoms"; const $StatusBarContainer = styled.div` position: absolute; @@ -50,13 +49,11 @@ const $ErrorLink = styled.span` export function StatusBar() { const graphStatus = useAtomValue(graphStatusAtom); - const messageChannel = useWebviewMessageChannel(); + const api = useStatusApi(); const handleShowProblems = useCallback(() => { - messageChannel.sendNotification({ - method: SHOW_PROBLEMS_PANEL_NOTIFICATION, - }); - }, [messageChannel]); + api.showProblemsPanel(); + }, [api]); const errorCount = graphStatus.kind === "errors" ? graphStatus.errorCount : 0; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/status/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/status/index.ts index c37ef8160f1..340925ed356 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/status/index.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/status/index.ts @@ -1,5 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -export * from "./atoms"; -export { StatusBar } from "./StatusBar"; +export { StatusBar } from "./components/StatusBar"; +export { hasNodesAtom, reportGraphStatusAtom } from "./atoms"; +export * from "./api"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/visualization/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/visualization/index.ts deleted file mode 100644 index 4731c09ec91..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/visualization/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -export * from "./ModuleDeclaration"; -export * from "./ResourceDeclaration"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/accessibility/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/hooks/index.ts similarity index 73% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/accessibility/index.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/hooks/index.ts index 4cc08589b43..4056a741665 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/accessibility/index.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/hooks/index.ts @@ -1,5 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -export * from "./MotionAwareProgressBar"; +export * from "./use-document-sync"; export * from "./use-motion-policy-sync"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/hooks/use-document-sync.ts b/src/vscode-bicep-ui/apps/visual-designer/src/hooks/use-document-sync.ts new file mode 100644 index 00000000000..43d7c5715df --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/hooks/use-document-sync.ts @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { defineNotification, useNotification, useWebviewMessageChannel } from "@vscode-bicep-ui/messaging"; +import { atom, useSetAtom } from "jotai"; +import { useCallback, useEffect, useMemo } from "react"; + +/** + * The webview's conversation with the host about the Bicep document it is showing. + * + * Cross-cutting rather than a feature: nothing here renders, and no single capability owns it. The + * canvas, the palette and the export all derive something from the same document. + * + * `ready` and `documentDidChange` belong together because they are two halves of one exchange -- the + * webview announces it is mounted, and the host answers by sending the document and re-announcing it + * on every edit. Both hosts treat it that way: the extension sets `readyToRender` and renders, and + * the dev fake responds by pushing the sample graph. + */ + +/** "The webview has mounted; start sending me the document." */ +export const ready = defineNotification("ready"); + +interface DocumentDidChangeParams { + documentUri: string; +} + +/** "The document changed; re-fetch whatever you derive from it." */ +export const documentDidChange = defineNotification("documentDidChange"); + +/** The document currently being visualized, or null before the host has sent one. */ +export const documentUriAtom = atom(null); + +/** + * Opens and maintains the document conversation. Mounted once, by the app. + * + * Consumers split by what they need: those that want the document's *identity* read + * `documentUriAtom` and derive from it, while those that want the *event* subscribe to + * `documentDidChange` themselves, because a change can arrive with an unchanged URI. + */ +export function useDocumentSync() { + const channel = useWebviewMessageChannel(); + const setDocumentUri = useSetAtom(documentUriAtom); + + const api = useMemo( + () => ({ + announceReady: () => channel.notify(ready), + /** Persist which document this webview is showing so VS Code can restore it. */ + rememberDocument: (documentPath: string) => channel.setState({ documentPath }), + }), + [channel], + ); + + useEffect(() => { + api.announceReady(); + }, [api]); + + useNotification( + documentDidChange, + useCallback( + ({ documentUri }: DocumentDidChangeParams) => { + setDocumentUri(documentUri); + api.rememberDocument(documentUri); + }, + [api, setDocumentUri], + ), + ); +} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/hooks/use-motion-policy-sync.ts b/src/vscode-bicep-ui/apps/visual-designer/src/hooks/use-motion-policy-sync.ts new file mode 100644 index 00000000000..0598a771836 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/hooks/use-motion-policy-sync.ts @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { defineNotification, defineRequest, useNotification, useRequest } from "@vscode-bicep-ui/messaging"; +import { atom, useSetAtom } from "jotai"; +import { useCallback, useEffect } from "react"; + +/** + * The user's effective motion preference, resolved by the host from the VS Code setting and the + * OS-level reduced-motion preference. + * + * Cross-cutting rather than a feature: accessibility policy is something the whole app consults, not + * a capability with a surface of its own. + */ + +type MotionPolicy = "system" | "reduce" | "animate"; + +export const getMotionPolicy = defineRequest("motionPolicy/get"); + +const motionPolicyDidChange = defineNotification("motionPolicy/didChange"); + +export const motionPolicyAtom = atom("system"); + +/** Keeps {@link motionPolicyAtom} in step with the host. Mounted once, by the app. */ +export function useMotionPolicySync() { + const setMotionPolicy = useSetAtom(motionPolicyAtom); + const [initialMotionPolicy] = useRequest(getMotionPolicy); + + useEffect(() => { + if (initialMotionPolicy) { + setMotionPolicy(initialMotionPolicy); + } + }, [initialMotionPolicy, setMotionPolicy]); + + useNotification( + motionPolicyDidChange, + useCallback((policy: MotionPolicy) => setMotionPolicy(policy), [setMotionPolicy]), + ); +} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/index.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/index.tsx index c0e6579425e..2f73a658475 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/index.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/index.tsx @@ -3,7 +3,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import { App } from "./App"; +import { App } from "./app/App"; if (import.meta.env.DEV) { import("@vscode-elements/webview-playground"); diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/atoms/graph.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/atoms/graph.ts index 934f1a6fa3d..2535554431a 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/atoms/graph.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/atoms/graph.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { Box } from "@/lib/utils/math"; +import type { Box } from "@/lib/math"; import { atom } from "jotai"; import { nodesByIdAtom } from "./nodes"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/atoms/nodes.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/atoms/nodes.ts index 6a850a92267..e819036dafb 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/atoms/nodes.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/atoms/nodes.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import type { Atom, PrimitiveAtom } from "jotai"; -import type { Box, Point } from "@/lib/utils/math/geometry"; +import type { Box, Point } from "@/lib/math"; import { atom } from "jotai"; import { nodeConfigAtom } from "./configs"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/AtomicNode.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/AtomicNode.tsx index 01d01a345c8..8853a882a5a 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/AtomicNode.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/AtomicNode.tsx @@ -1,59 +1,24 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { AtomicNodeState } from "@/lib/graph/atoms/nodes"; -import type { Range } from "@/lib/messaging/messages"; +import type { AtomicNodeState } from "../atoms/nodes"; import useResizeObserver from "@react-hook/resize-observer"; -import { useWebviewMessageChannel } from "@vscode-bicep-ui/messaging"; import { useAtomValue, useStore } from "jotai"; import { frame } from "motion/react"; -import { useEffect, useLayoutEffect, useRef } from "react"; -import { focusedNodeIdAtom, getNodeZIndex } from "@/lib/graph/atoms/nodes"; -import { useBoxUpdate, useDragListener } from "@/lib/graph/hooks"; -import { REVEAL_FILE_RANGE_NOTIFICATION, REVEAL_NODE_SOURCE_NOTIFICATION } from "@/lib/messaging/messages"; -import { translateBox } from "@/lib/utils/math"; +import { useLayoutEffect, useRef } from "react"; +import { translateBox } from "@/lib/math"; +import { focusedNodeIdAtom, getNodeZIndex } from "../atoms/nodes"; +import { useBoxUpdate, useDragListener } from "../hooks"; import { BaseNode } from "./BaseNode"; import { NodeContent } from "./NodeContent"; export function AtomicNode({ id, boxAtom, dataAtom }: AtomicNodeState) { const ref = useRef(null); const store = useStore(); - const messageChannel = useWebviewMessageChannel(); const focusedNodeId = useAtomValue(focusedNodeIdAtom); const zIndex = getNodeZIndex(id, "atomic", focusedNodeId); - // Use a native dblclick listener so we can call stopPropagation() - // before d3-zoom's handler (on the PanZoom ancestor) fires. - useEffect(() => { - const el = ref.current; - if (!el) { - return; - } - - const handler = (e: MouseEvent) => { - e.stopPropagation(); - - const data = store.get(dataAtom) as { range?: Range; filePath?: string }; - if (data?.range && data?.filePath) { - // Legacy push path: the node still carries an inline source location. - messageChannel.sendNotification({ - method: REVEAL_FILE_RANGE_NOTIFICATION, - params: { filePath: data.filePath, range: data.range }, - }); - } else { - // Server-driven path: source location is resolved on demand by node id. - messageChannel.sendNotification({ - method: REVEAL_NODE_SOURCE_NOTIFICATION, - params: { nodeId: id }, - }); - } - }; - - el.addEventListener("dblclick", handler); - return () => el.removeEventListener("dblclick", handler); - }, [store, dataAtom, messageChannel, id]); - useLayoutEffect(() => { if (!ref.current) { return; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/BaseNode.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/BaseNode.tsx index 2352c4fb777..ee78d87150d 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/BaseNode.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/BaseNode.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import type { PropsWithChildren } from "react"; -import type { NodeKind } from "@/lib/graph/atoms"; +import type { NodeKind } from "../atoms"; import { forwardRef } from "react"; import { styled } from "styled-components"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/CompoundNode.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/CompoundNode.tsx index c006f3c6fbf..7e5b26fbb17 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/CompoundNode.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/CompoundNode.tsx @@ -1,59 +1,24 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { CompoundNodeState } from "@/lib/graph/atoms/nodes"; -import type { Range } from "@/lib/messaging/messages"; +import type { CompoundNodeState } from "../atoms/nodes"; -import { useWebviewMessageChannel } from "@vscode-bicep-ui/messaging"; import { useAtomValue, useStore } from "jotai"; import { frame } from "motion/react"; -import { useEffect, useRef } from "react"; -import { nodesByIdAtom } from "@/lib/graph/atoms"; -import { focusedNodeIdAtom, getNodeZIndex } from "@/lib/graph/atoms/nodes"; -import { useBoxUpdate, useDragListener } from "@/lib/graph/hooks"; -import { REVEAL_FILE_RANGE_NOTIFICATION, REVEAL_NODE_SOURCE_NOTIFICATION } from "@/lib/messaging/messages"; -import { translateBox } from "@/lib/utils/math"; +import { useRef } from "react"; +import { translateBox } from "@/lib/math"; +import { nodesByIdAtom } from "../atoms"; +import { focusedNodeIdAtom, getNodeZIndex } from "../atoms/nodes"; +import { useBoxUpdate, useDragListener } from "../hooks"; import { BaseNode } from "./BaseNode"; import { NodeContent } from "./NodeContent"; export function CompoundNode({ id, childIdsAtom, boxAtom, dataAtom }: CompoundNodeState) { const ref = useRef(null); const store = useStore(); - const messageChannel = useWebviewMessageChannel(); const focusedNodeId = useAtomValue(focusedNodeIdAtom); const zIndex = getNodeZIndex(id, "compound", focusedNodeId); - // Use a native dblclick listener so we can call stopPropagation() - // before d3-zoom's handler (on the PanZoom ancestor) fires. - useEffect(() => { - const el = ref.current; - if (!el) { - return; - } - - const handler = (e: MouseEvent) => { - e.stopPropagation(); - - const data = store.get(dataAtom) as { range?: Range; filePath?: string }; - if (data?.range && data?.filePath) { - // Legacy push path: the node still carries an inline source location. - messageChannel.sendNotification({ - method: REVEAL_FILE_RANGE_NOTIFICATION, - params: { filePath: data.filePath, range: data.range }, - }); - } else { - // Server-driven path: source location is resolved on demand by node id. - messageChannel.sendNotification({ - method: REVEAL_NODE_SOURCE_NOTIFICATION, - params: { nodeId: id }, - }); - } - }; - - el.addEventListener("dblclick", handler); - return () => el.removeEventListener("dblclick", handler); - }, [store, dataAtom, messageChannel, id]); - useDragListener(ref, (dx: number, dy: number) => { const translateChildren = (childIds: string[]) => { for (const childId of childIds) { diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/EdgeLayer.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/EdgeLayer.tsx index d364d3d0279..60c91a954b9 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/EdgeLayer.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/EdgeLayer.tsx @@ -4,8 +4,8 @@ import { useAtomValue } from "jotai"; import { useMemo } from "react"; import { styled } from "styled-components"; -import { edgesAtom } from "@/lib/graph/atoms/edges"; -import { focusedNodeIdAtom, getNodeZIndex } from "@/lib/graph/atoms/nodes"; +import { edgesAtom } from "../atoms/edges"; +import { focusedNodeIdAtom, getNodeZIndex } from "../atoms/nodes"; import { StraightEdge } from "./StraightEdge"; const $Svg = styled.svg<{ $zIndex: number }>` diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/Graph.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/Graph.tsx index 303c4270329..34ee07b2cca 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/Graph.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/Graph.tsx @@ -4,7 +4,7 @@ import { PanZoomTransformed } from "@vscode-bicep-ui/components"; import { useAtomValue } from "jotai"; import { styled } from "styled-components"; -import { layoutReadyAtom } from "@/lib/graph/atoms"; +import { layoutReadyAtom } from "../atoms"; import { InnerEdgeLayer, OuterEdgeLayer } from "./EdgeLayer"; import { EdgeMarkerDefs } from "./EdgeMarkerDefs"; import { NodeLayer } from "./NodeLayer"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/NodeContent.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/NodeContent.tsx index 48515e5cf6a..e60ac003029 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/NodeContent.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/NodeContent.tsx @@ -2,10 +2,10 @@ // Licensed under the MIT License. import type { Atom } from "jotai"; -import type { NodeKind } from "@/lib/graph/atoms"; +import type { NodeKind } from "../atoms"; import { useAtomValue } from "jotai"; -import { nodeConfigAtom } from "@/lib/graph/atoms"; +import { nodeConfigAtom } from "../atoms"; export interface NodeContentProps { id: string; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/NodeLayer.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/NodeLayer.tsx index f14a062a1a9..1618c553a14 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/NodeLayer.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/NodeLayer.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { useAtomValue } from "jotai"; -import { nodesByIdAtom } from "@/lib/graph/atoms"; +import { nodesByIdAtom } from "../atoms"; import { AtomicNode } from "./AtomicNode"; import { CompoundNode } from "./CompoundNode"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/StraightEdge.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/StraightEdge.tsx index 0f911c1c55a..2aec45ce55e 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/StraightEdge.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/StraightEdge.tsx @@ -1,13 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { EdgeAtomValue } from "@/lib/graph/atoms/edges"; +import type { EdgeAtomValue } from "../atoms/edges"; import { atom, useStore } from "jotai"; import { useEffect, useMemo, useRef } from "react"; import { styled, useTheme } from "styled-components"; -import { nodesByIdAtom } from "@/lib/graph/atoms"; -import { boxesOverlap, getBoxCenter, getBoxCenterSegmentIntersection } from "@/lib/utils/math"; +import { boxesOverlap, getBoxCenter, getBoxCenterSegmentIntersection } from "@/lib/math"; +import { nodesByIdAtom } from "../atoms"; const $EdgePath = styled.path` transition: stroke 180ms ease; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/Canvas.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/Viewport.tsx similarity index 92% rename from src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/Canvas.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/Viewport.tsx index b78a7edff28..5516732b0bb 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/Canvas.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/Viewport.tsx @@ -7,8 +7,8 @@ import { PanZoom } from "@vscode-bicep-ui/components"; import { useStore } from "jotai"; import { useEffect, useRef } from "react"; import styled, { useTheme } from "styled-components"; -import { focusedNodeIdAtom } from "@/lib/graph/atoms/nodes"; -import { CanvasBackground } from "./CanvasBackground"; +import { focusedNodeIdAtom } from "../atoms/nodes"; +import { ViewportBackground } from "./ViewportBackground"; const CURSOR_SIZE = 32; @@ -45,12 +45,12 @@ const $GrabCursor = styled.div<{ $background: string; $blur: number }>` z-index: 9999; `; -export interface CanvasProps extends PropsWithChildren { +export interface ViewportProps extends PropsWithChildren { /** When false the dot-pattern background is hidden. Defaults to true. */ showBackground?: boolean; } -export function Canvas({ children, showBackground = true }: CanvasProps) { +export function Viewport({ children, showBackground = true }: ViewportProps) { const theme = useTheme(); const store = useStore(); const containerRef = useRef(null); @@ -130,7 +130,7 @@ export function Canvas({ children, showBackground = true }: CanvasProps) { return ( <$Container ref={containerRef} data-testid="graph-canvas"> <$PanZoom> - {showBackground && } + {showBackground && } {children} <$GrabCursor ref={cursorRef} $background={theme.grabCursor.background} $blur={theme.grabCursor.blur} /> diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/CanvasBackground.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/ViewportBackground.tsx similarity index 94% rename from src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/CanvasBackground.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/ViewportBackground.tsx index b2ccd5a49c1..79a2f623785 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/CanvasBackground.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/ViewportBackground.tsx @@ -7,8 +7,8 @@ import { styled } from "styled-components"; const $Svg = styled.svg` overflow: visible; - background-color: ${({ theme }) => theme.canvas.background}; - color: ${({ theme }) => theme.canvas.dotColor}; + background-color: ${({ theme }) => theme.viewport.background}; + color: ${({ theme }) => theme.viewport.dotColor}; position: absolute; pointer-events: none; `; @@ -27,7 +27,7 @@ function getEffectiveScale(actualScale: number): number { return actualScale; } -export function CanvasBackground() { +export function ViewportBackground() { const patternRef = useRef(null); const circleRef = useRef(null); diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/index.ts index c757f1b581a..2a8ca4d3b69 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/index.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/index.ts @@ -1,5 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -export * from "./Canvas"; +export * from "./Viewport"; export * from "./Graph"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/hooks/use-box-update.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/hooks/use-box-update.ts index 8aea997f5a2..431e8e68795 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/hooks/use-box-update.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/hooks/use-box-update.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import type { Atom, createStore } from "jotai"; -import type { Box } from "@/lib/utils/math"; +import type { Box } from "@/lib/math"; import { useEffect } from "react"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/hooks/use-fit-view.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/hooks/use-fit-view.ts index 9e92d472f35..a841c389dd2 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/hooks/use-fit-view.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/hooks/use-fit-view.ts @@ -1,13 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { Box } from "@/lib/utils/math/geometry"; +import type { Box } from "@/lib/math"; import { useGetPanZoomDimensions, usePanZoomControl } from "@vscode-bicep-ui/components"; import { useAtomCallback } from "jotai/utils"; import { useCallback } from "react"; -import { graphBoundsAtom } from "@/lib/graph/atoms"; -import { getBoxCenter, getBoxHeight, getBoxWidth } from "@/lib/utils/math/geometry"; +import { getBoxCenter, getBoxHeight, getBoxWidth } from "@/lib/math"; +import { graphBoundsAtom } from "../atoms"; /** * Returns a callback that applies a pan-zoom transform to center the diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/index.ts index e3bcdb3ad77..052b4f58f2f 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/index.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/index.ts @@ -4,3 +4,4 @@ export * from "./atoms"; export * from "./components"; export * from "./hooks"; +export * from "./theme"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/theme.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/theme.ts new file mode 100644 index 00000000000..fd97ceb6a31 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/theme.ts @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * The theme tokens the graph engine needs. + * + * The engine reads these through styled-components' `ThemeProvider` context, which means it consumes + * them without importing anything — invisible to both the reader and the layer lint rule. Declaring + * the requirement here makes it explicit and compile-checked: `ui/theme` satisfies this interface, so + * removing a token the engine depends on is a type error rather than a runtime surprise. + * + * Reading theme is not a layer violation. The test for `lib` is Bicep knowledge, not styling, and a + * dot grid and an edge colour have none. What matters is that the engine states what it needs instead + * of reaching into a shape the app happens to own. + */ +export interface GraphTheme { + viewport: { + background: string; + dotColor: string; + }; + edge: { + color: string; + }; + grabCursor: { + /** Semi-transparent background color for the cursor overlay (CSS color value). */ + background: string; + /** Backdrop-filter blur radius in pixels. */ + blur: number; + }; +} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/utils/math/comparison.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/math/comparison.ts similarity index 100% rename from src/vscode-bicep-ui/apps/visual-designer/src/lib/utils/math/comparison.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/lib/math/comparison.ts diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/utils/math/geometry/box.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/math/geometry/box.ts similarity index 100% rename from src/vscode-bicep-ui/apps/visual-designer/src/lib/utils/math/geometry/box.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/lib/math/geometry/box.ts diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/utils/math/geometry/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/math/geometry/index.ts similarity index 100% rename from src/vscode-bicep-ui/apps/visual-designer/src/lib/utils/math/geometry/index.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/lib/math/geometry/index.ts diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/utils/math/geometry/point.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/math/geometry/point.ts similarity index 81% rename from src/vscode-bicep-ui/apps/visual-designer/src/lib/utils/math/geometry/point.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/lib/math/geometry/point.ts index 51734e5d1f6..0e627fdb18a 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/utils/math/geometry/point.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/math/geometry/point.ts @@ -1,15 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { equal } from "@/lib/utils/math/comparison"; +import { equal } from "../comparison"; export interface Point { x: number; y: number; } -export type Position = Point; - export function pointsEqual(a: Point, b: Point): boolean { return equal(a.x, b.x) && equal(a.y, b.y); } diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/utils/math/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/math/index.ts similarity index 100% rename from src/vscode-bicep-ui/apps/visual-designer/src/lib/utils/math/index.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/lib/math/index.ts diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/__tests__/layout-invalidation.test.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/__tests__/layout-invalidation.test.ts deleted file mode 100644 index 0cd46f43ac0..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/__tests__/layout-invalidation.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import type { GraphNode, GraphPatch, RenderedGraph, RenderedGraphNode } from "../messages"; - -import { describe, expect, it } from "vitest"; -import { patchMayAffectLayout, renderedGraphsEqual } from "../layout-invalidation"; - -function makeNode(overrides: Partial = {}): GraphNode { - return { - id: "n", - kind: "resource", - parentId: null, - type: "Microsoft.Storage/storageAccounts", - symbolName: "n", - isCollection: false, - hasChildren: false, - hasError: false, - ...overrides, - }; -} - -function graphOf(...nodes: GraphNode[]) { - return { nodes: new Map(nodes.map((node) => [node.id, node])) }; -} - -/** Mirror the server's `updateNode`: only the changed metadata fields are sent. */ -function fullUpdate(node: GraphNode, changes: Partial = {}): GraphPatch { - const merged = { ...node, ...changes }; - return { - op: "updateNode", - nodeId: node.id, - changes: { - type: merged.type, - isCollection: merged.isCollection, - hasChildren: merged.hasChildren, - hasError: merged.hasError, - }, - }; -} - -describe("patchMayAffectLayout", () => { - const node = makeNode({ id: "a" }); - const graph = graphOf(node); - - it("treats structural patches as layout-affecting", () => { - const structural: GraphPatch[] = [ - { op: "clearGraph" }, - { op: "addNode", node: makeNode({ id: "b" }) }, - { op: "removeNode", nodeId: "a" }, - { op: "addEdge", edge: { id: "a>b", sourceId: "a", targetId: "b" } }, - { op: "removeEdge", edgeId: "a>b" }, - ]; - - for (const patch of structural) { - expect(patchMayAffectLayout(graph, patch)).toBe(true); - } - }); - - it("does not reflow an addNode patch with an explicit placement", () => { - const patch: GraphPatch = { op: "addNode", node: makeNode({ id: "placed" }) }; - - expect(patchMayAffectLayout(graph, patch, new Set(["placed"]))).toBe(false); - expect(patchMayAffectLayout(graph, patch, new Set(["other"]))).toBe(true); - }); - - it("treats setNodeLayout and setErrorCount as non-affecting", () => { - expect(patchMayAffectLayout(graph, { op: "setNodeLayout", nodeId: "a", layout: { x: 1, y: 2 } })).toBe(false); - expect(patchMayAffectLayout(graph, { op: "setErrorCount", errorCount: 3 })).toBe(false); - }); - - it("does not reflow when an updateNode only toggles hasError", () => { - expect(patchMayAffectLayout(graph, fullUpdate(node, { hasError: true }))).toBe(false); - }); - - it("ignores null update fields as omitted metadata", () => { - expect( - patchMayAffectLayout(graph, { - op: "updateNode", - nodeId: "a", - changes: { type: null, isCollection: null, hasChildren: null, hasError: true }, - }), - ).toBe(false); - }); - - it("reflows when a size-affecting field actually changes", () => { - expect(patchMayAffectLayout(graph, fullUpdate(node, { type: "Microsoft.Web/sites" }))).toBe(true); - expect(patchMayAffectLayout(graph, fullUpdate(node, { isCollection: true }))).toBe(true); - expect(patchMayAffectLayout(graph, fullUpdate(node, { hasChildren: true }))).toBe(true); - }); - - it("does not reflow for an updateNode targeting an unknown node", () => { - expect(patchMayAffectLayout(graph, fullUpdate(makeNode({ id: "missing" })))).toBe(false); - }); -}); - -describe("renderedGraphsEqual", () => { - function rnode(overrides: Partial = {}): RenderedGraphNode { - return { - id: "a", - kind: "resource", - parentId: null, - type: "Microsoft.Storage/storageAccounts", - isCollection: false, - hasChildren: false, - hasError: false, - width: 220, - height: 80, - ...overrides, - }; - } - - const base: RenderedGraph = { - nodes: [rnode({ id: "a" }), rnode({ id: "b" })], - edges: [{ id: "a>b", sourceId: "a", targetId: "b" }], - }; - - it("returns false when the previous input is null", () => { - expect(renderedGraphsEqual(null, base)).toBe(false); - }); - - it("returns true for the same graph regardless of node and edge order", () => { - const reordered: RenderedGraph = { - nodes: [rnode({ id: "b" }), rnode({ id: "a" })], - edges: [{ id: "a>b", sourceId: "a", targetId: "b" }], - }; - expect(renderedGraphsEqual(base, reordered)).toBe(true); - }); - - it("returns false when a node count differs", () => { - const extra: RenderedGraph = { nodes: [...base.nodes, rnode({ id: "c" })], edges: base.edges }; - expect(renderedGraphsEqual(base, extra)).toBe(false); - }); - - it("returns false when a measured size differs (the sub-pixel case)", () => { - const widened: RenderedGraph = { - nodes: [rnode({ id: "a", width: 221 }), rnode({ id: "b" })], - edges: base.edges, - }; - expect(renderedGraphsEqual(base, widened)).toBe(false); - }); - - it("returns false when containment (parentId) differs", () => { - const reparented: RenderedGraph = { - nodes: [rnode({ id: "a", parentId: "b" }), rnode({ id: "b" })], - edges: base.edges, - }; - expect(renderedGraphsEqual(base, reparented)).toBe(false); - }); - - it("returns false when the edge set differs", () => { - const rewired: RenderedGraph = { - nodes: base.nodes, - edges: [{ id: "b>a", sourceId: "b", targetId: "a" }], - }; - expect(renderedGraphsEqual(base, rewired)).toBe(false); - }); -}); diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/index.ts deleted file mode 100644 index 21dbefdb1ab..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -export * from "./use-visual-graph"; -export * from "./use-graph-update"; -export * from "./messages"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/layout-invalidation.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/layout-invalidation.ts deleted file mode 100644 index be9fec5067e..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/layout-invalidation.ts +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import type { GraphNode, GraphPatch, RenderedGraph } from "./messages"; - -/** - * The node metadata fields that influence a node's rendered size, and therefore the layout. - * - * "What affects layout" is decided in three places that must stay consistent: - * - * 1. {@link patchMayAffectLayout} here — the cheap client pre-filter that decides whether an - * applied `updateNode` patch is worth a re-measure. - * 2. {@link renderedGraphsEqual} here — the authoritative check that compares the freshly measured - * graph (structure + measured sizes) against the last graph that produced a layout. - * 3. The language server's `VisualGraphDiffer.HasTopologyChange` — which validates a measured - * layout request against the live compilation (node id/kind/parent and the edge set). - * - * If you add a field that changes a node's rendered size, add it here and confirm (2) and (3) still - * capture it; otherwise range-only edits may wrongly reflow, or a real change may be missed. - */ -const LAYOUT_AFFECTING_NODE_FIELDS = ["type", "isCollection", "hasChildren"] as const; - -type LayoutRelevantNode = Pick; - -interface LayoutRelevantGraph { - nodes: ReadonlyMap; -} - -/** - * Whether applying `patch` to the client graph may change the layout. Used as a cheap pre-filter - * before the (more expensive) render + measure + {@link renderedGraphsEqual} confirmation. - * - * Must be called BEFORE the patch is applied: for `updateNode` it compares the incoming values - * against the node's CURRENT values. Only an actual change to a size-affecting field counts; - * `hasError` never affects layout. - */ -export function patchMayAffectLayout( - graph: LayoutRelevantGraph, - patch: GraphPatch, - explicitlyPlacedNodeIds: ReadonlySet = new Set(), -): boolean { - switch (patch.op) { - case "clearGraph": - case "removeNode": - case "addEdge": - case "removeEdge": - return true; - case "addNode": - return !explicitlyPlacedNodeIds.has(patch.node.id); - case "updateNode": { - const node = graph.nodes.get(patch.nodeId); - if (!node) { - return false; - } - const { changes } = patch; - return LAYOUT_AFFECTING_NODE_FIELDS.some( - (field) => changes[field] !== undefined && changes[field] !== null && changes[field] !== node[field], - ); - } - case "setNodeLayout": - case "setGraphBounds": - case "setErrorCount": - return false; - } -} - -/** - * Whether two measured graphs are equivalent layout inputs: the same node set (id, kind, parent, - * and measured width/height) and the same edge set. This is the authoritative second tier after - * {@link patchMayAffectLayout}; a layout request is sent only when this returns false. Node and - * edge order is irrelevant (compared by id). - */ -export function renderedGraphsEqual(left: RenderedGraph | null, right: RenderedGraph): boolean { - if (!left || left.nodes.length !== right.nodes.length || left.edges.length !== right.edges.length) { - return false; - } - - const leftNodes = new Map(left.nodes.map((node) => [node.id, node])); - - for (const rightNode of right.nodes) { - const leftNode = leftNodes.get(rightNode.id); - if ( - !leftNode || - leftNode.kind !== rightNode.kind || - leftNode.parentId !== rightNode.parentId || - leftNode.width !== rightNode.width || - leftNode.height !== rightNode.height - ) { - return false; - } - } - - const leftEdges = new Set(left.edges.map((edge) => `${edge.id}|${edge.sourceId}|${edge.targetId}`)); - - return right.edges.every((edge) => leftEdges.has(`${edge.id}|${edge.sourceId}|${edge.targetId}`)); -} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/use-graph-update.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/use-graph-update.ts deleted file mode 100644 index 12ff0efe090..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/use-graph-update.ts +++ /dev/null @@ -1,468 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import type { Box } from "@/lib/utils/math"; -import type { Point } from "@/lib/utils/math/geometry"; -import type { - CreateVisualResourceRequest, - CreateVisualResourceResponse, - DeploymentGraph, - GetGraphLayoutRequest, - GetGraphLayoutResponse, - GetGraphUpdateRequest, - GetGraphUpdateResponse, - GraphBounds, - GraphEdge, - GraphNode, - GraphPatch, - NodeLayout, - Range, - RenderedGraph, - VisualResourceTypeReference, -} from "./messages"; - -import { useWebviewMessageChannel } from "@vscode-bicep-ui/messaging"; -import { getDefaultStore } from "jotai"; -import { useCallback, useRef } from "react"; -import { - pendingResourcesAtom, - resourceCreationErrorAtom, - resourceNodeIsCommittingAtomFamily, -} from "@/features/resource-creation"; -import { nodesByIdAtom } from "@/lib/graph"; -import { patchMayAffectLayout, renderedGraphsEqual } from "./layout-invalidation"; -import { CREATE_RESOURCE_REQUEST, GET_GRAPH_LAYOUT_REQUEST, GET_GRAPH_UPDATE_REQUEST } from "./messages"; -import { applyGraphLayout, useApplyVisualGraph } from "./use-visual-graph"; - -const store = getDefaultStore(); - -/** - * The client-side mirror of the server's canonical graph. This is the graph the webview - * "currently displays": the server diffs against it and returns a complete patch delta, so the - * webview submits exactly this topology back on the next update request. - */ -interface ClientGraph { - nodes: Map; - edges: Map; - errorCount: number; -} - -function createClientGraph(): ClientGraph { - return { nodes: new Map(), edges: new Map(), errorCount: 0 }; -} - -function applyPatch(graph: ClientGraph, nodeLayouts: Map, patch: GraphPatch): void { - switch (patch.op) { - case "clearGraph": - graph.nodes.clear(); - graph.edges.clear(); - graph.errorCount = 0; - return; - case "addNode": - graph.nodes.set(patch.node.id, patch.node); - return; - case "removeNode": - graph.nodes.delete(patch.nodeId); - return; - case "updateNode": { - const node = graph.nodes.get(patch.nodeId); - if (node) { - // Only defined fields in `changes` override the node; the rest are left untouched. - const next = { ...node }; - for (const [key, value] of Object.entries(patch.changes)) { - if (value !== undefined && value !== null) { - (next as Record)[key] = value; - } - } - graph.nodes.set(patch.nodeId, next); - } - return; - } - case "addEdge": - graph.edges.set(patch.edge.id, patch.edge); - return; - case "removeEdge": - graph.edges.delete(patch.edgeId); - return; - case "setNodeLayout": - nodeLayouts.set(patch.nodeId, patch.layout); - return; - case "setGraphBounds": - // Graph bounds drive fit-view in the layout flow, not the client graph mirror. - return; - case "setErrorCount": - graph.errorCount = patch.errorCount; - return; - } -} - -/** - * Translate the canonical client graph into the graph shape consumed by the existing - * position-preserving apply path. - * - * The canonical graph no longer carries source locations, so `range`/`filePath` are filled with empty - * placeholders here. Reveal is driven on demand by node id (see `REVEAL_NODE_SOURCE_NOTIFICATION`), - * which is why the empty `filePath` is intentional and not a missing value. - */ -function toDeploymentGraph(graph: ClientGraph): DeploymentGraph { - const emptyRange: Range = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }; - - return { - nodes: [...graph.nodes.values()].map((node) => ({ - id: node.id, - type: node.type, - isCollection: node.isCollection, - range: emptyRange, - hasChildren: node.hasChildren, - hasError: node.hasError, - filePath: "", - })), - edges: [...graph.edges.values()].map((edge) => ({ - sourceId: edge.sourceId, - targetId: edge.targetId, - })), - errorCount: graph.errorCount, - }; -} - -/** - * Build the `RenderedGraph` to submit with an update request: the topology the webview holds plus - * the size it has measured for each node (zero until the node has been laid out and measured). - */ -function buildRenderedGraph(graph: ClientGraph): RenderedGraph { - const renderedNodes = store.get(nodesByIdAtom); - - return { - nodes: [...graph.nodes.values()].map((node) => { - const rendered = renderedNodes[node.id]; - const box = rendered ? store.get(rendered.boxAtom) : undefined; - - return { - id: node.id, - kind: node.kind, - parentId: node.parentId, - type: node.type, - isCollection: node.isCollection, - hasChildren: node.hasChildren, - hasError: node.hasError, - width: box ? box.max.x - box.min.x : 0, - height: box ? box.max.y - box.min.y : 0, - }; - }), - edges: [...graph.edges.values()].map((edge) => ({ - id: edge.id, - sourceId: edge.sourceId, - targetId: edge.targetId, - })), - }; -} - -function waitForAnimationFrame(): Promise { - return new Promise((resolve) => requestAnimationFrame(() => resolve())); -} - -function collectNodeLayouts(patches: GraphPatch[]): Map { - const nodeLayouts = new Map(); - - for (const patch of patches) { - if (patch.op === "setNodeLayout") { - nodeLayouts.set(patch.nodeId, patch.layout); - } - } - - return nodeLayouts; -} - -/** Pick the graph bounds out of a layout response, if the server emitted them. */ -function collectGraphBounds(patches: GraphPatch[]): GraphBounds | null { - let bounds: GraphBounds | null = null; - - for (const patch of patches) { - if (patch.op === "setGraphBounds") { - bounds = patch.bounds; - } - } - - return bounds; -} - -function centerGraphLayout( - nodeLayouts: Map, - graphBounds: GraphBounds | null, - viewportCenter: Point, -): { nodeLayouts: Map; bounds: Box | null } { - if (!graphBounds) { - return { nodeLayouts, bounds: null }; - } - - const offsetX = viewportCenter.x - graphBounds.width / 2; - const offsetY = viewportCenter.y - graphBounds.height / 2; - const centeredLayouts = new Map(); - - for (const [nodeId, layout] of nodeLayouts) { - centeredLayouts.set(nodeId, { x: layout.x + offsetX, y: layout.y + offsetY }); - } - - return { - nodeLayouts: centeredLayouts, - bounds: { - min: { x: offsetX, y: offsetY }, - max: { x: offsetX + graphBounds.width, y: offsetY + graphBounds.height }, - }, - }; -} - -/** - * Drives the notify-then-request loop for server-driven graph updates. - * - * Returns a function to call on each `documentDidChange` notification. It enforces the single - * in-flight request + dirty-flag convergence pattern from the migration plan: only one request is - * outstanding at a time, and notifications that arrive while a request is in flight collapse into a - * single follow-up request. Because every response is a complete delta against the graph that was - * submitted, applying only the latest response is always correct without version tokens. - */ -export interface GraphUpdateActions { - requestGraphUpdate: () => Promise; - createResource: (resourceType: VisualResourceTypeReference, origin: Point) => Promise; - /** - * Re-run layout for the current graph and apply it, bypassing the - * "sizes unchanged since last layout" short-circuit so it re-lays out (and - * animates) even after the user has only dragged nodes around. Backs the Reset - * Layout button. Shares the single in-flight slot with {@link requestGraphUpdate}, - * so it never races a concurrent document-change update. - */ - resetLayout: () => Promise; -} - -export function useGraphUpdate( - getViewportCenter: () => Point, - fitViewToBounds: (bounds: Box) => void, -): GraphUpdateActions { - const applyGraph = useApplyVisualGraph(getViewportCenter); - const messageChannel = useWebviewMessageChannel(); - const clientGraphRef = useRef(createClientGraph()); - const lastLayoutInputRef = useRef(null); - const inFlightRef = useRef(false); - const dirtyRef = useRef(false); - const forceLayoutRef = useRef(false); - const mutationInFlightRef = useRef(false); - const mutationQueueRef = useRef>(Promise.resolve()); - const pendingPlacementsRef = useRef>(new Map()); - - const requestGraphLayout = useCallback( - async (force = false) => { - const graph = clientGraphRef.current; - - if (graph.nodes.size === 0) { - lastLayoutInputRef.current = null; - return; - } - - await waitForAnimationFrame(); - - const measuredGraph = buildRenderedGraph(graph); - - if (!force && renderedGraphsEqual(lastLayoutInputRef.current, measuredGraph)) { - // Sizes are unchanged since the last layout, so positions still hold. - // Just make sure the graph is revealed in case it was hidden. - await applyGraphLayout(new Map()); - return; - } - - const layoutRequest: GetGraphLayoutRequest = { current: measuredGraph }; - const layoutResponse = await messageChannel.sendRequest({ - method: GET_GRAPH_LAYOUT_REQUEST, - params: layoutRequest, - }); - - if (layoutResponse.status === "graphChanged") { - dirtyRef.current = true; - return; - } - - if (layoutResponse.status === "layoutFailed") { - // No usable layout — reveal the graph as-is so it isn't stuck hidden. - await applyGraphLayout(new Map()); - return; - } - - const { nodeLayouts, bounds } = centerGraphLayout( - collectNodeLayouts(layoutResponse.patches), - collectGraphBounds(layoutResponse.patches), - getViewportCenter(), - ); - lastLayoutInputRef.current = measuredGraph; - - // Fit the viewport to the server-computed graph bounds before the nodes settle there. Reset Layout - // (force) only re-runs the layout and must not touch the user's pan/zoom, so it skips the fit. - if (bounds && !force) { - fitViewToBounds(bounds); - } - - await applyGraphLayout(nodeLayouts); - }, - [fitViewToBounds, getViewportCenter, messageChannel], - ); - - const requestGraphUpdate = useCallback(async () => { - if (mutationInFlightRef.current) { - dirtyRef.current = true; - return; - } - - if (inFlightRef.current) { - // A request is already outstanding; mark dirty so it issues one more round when it returns. - dirtyRef.current = true; - return; - } - - inFlightRef.current = true; - - try { - do { - // A forced layout (Reset Layout) takes priority over a normal update pass: re-run the - // graph layout without the size-unchanged short-circuit, then fall through to drain any - // document-change update that arrived in the meantime. - if (forceLayoutRef.current) { - forceLayoutRef.current = false; - await requestGraphLayout(true); - continue; - } - - dirtyRef.current = false; - - const graph = clientGraphRef.current; - const current: RenderedGraph | null = graph.nodes.size === 0 ? null : buildRenderedGraph(graph); - const request: GetGraphUpdateRequest = { current }; - - const response = await messageChannel.sendRequest({ - method: GET_GRAPH_UPDATE_REQUEST, - params: request, - }); - - if (mutationInFlightRef.current) { - // The mutation response carries the expected node ID needed to correlate placement. A graph response - // that completes first may already contain that node, so discard it and let the mutation's finally - // block request a fresh update after recording the placement. - dirtyRef.current = true; - return; - } - - const nodeLayouts = new Map(); - const newNodeOrigins = new Map(); - const explicitlyPlacedNodeIds = new Set(pendingPlacementsRef.current.keys()); - let layoutMayBeStale = false; - - for (const patch of response.patches) { - layoutMayBeStale ||= patchMayAffectLayout(graph, patch, explicitlyPlacedNodeIds); - if (patch.op === "addNode") { - const origin = pendingPlacementsRef.current.get(patch.node.id); - if (origin) { - newNodeOrigins.set(patch.node.id, origin); - } - } - applyPatch(graph, nodeLayouts, patch); - } - - const shouldMeasureLayout = layoutMayBeStale && graph.nodes.size > 0; - - if (graph.nodes.size === 0) { - lastLayoutInputRef.current = null; - } - - // Apply the new topology. Visibility is preserved for incremental - // edits (so nodes animate in place) and gated for major changes; - // positions arrive in the layout phase below. - if (newNodeOrigins.size > 0) { - for (const nodeId of newNodeOrigins.keys()) { - // Set before applyGraph mounts the node so Motion sees the compact initial state. - store.set(resourceNodeIsCommittingAtomFamily(nodeId), true); - } - } - applyGraph(toDeploymentGraph(graph), newNodeOrigins); - - if (newNodeOrigins.size > 0) { - for (const nodeId of newNodeOrigins.keys()) { - pendingPlacementsRef.current.delete(nodeId); - } - store.set(pendingResourcesAtom, (pending) => - pending.filter((resource) => !resource.expectedNodeId || !newNodeOrigins.has(resource.expectedNodeId)), - ); - } - - if (shouldMeasureLayout) { - await requestGraphLayout(); - } else if (newNodeOrigins.size > 0) { - await applyGraphLayout(new Map()); - } - } while (dirtyRef.current || forceLayoutRef.current); - } finally { - inFlightRef.current = false; - } - }, [applyGraph, requestGraphLayout, messageChannel]); - - const resetLayout = useCallback(async () => { - forceLayoutRef.current = true; - - if (inFlightRef.current) { - // A request is already outstanding; the in-flight loop drains forceLayoutRef before it - // releases the lock, so the reset runs there instead of racing it. - return; - } - - await requestGraphUpdate(); - }, [requestGraphUpdate]); - - const createResource = useCallback( - (resourceType: VisualResourceTypeReference, origin: Point): Promise => { - const operationId = window.crypto.randomUUID(); - store.set(pendingResourcesAtom, (pending) => [...pending, { operationId, resourceType, origin }]); - store.set(resourceCreationErrorAtom, null); - - const execute = async () => { - mutationInFlightRef.current = true; - - try { - const request: CreateVisualResourceRequest = { - version: 1, - operationId, - resourceType, - }; - const response = await messageChannel.sendRequest({ - method: CREATE_RESOURCE_REQUEST, - params: request, - }); - - pendingPlacementsRef.current.set(response.expectedNodeId, origin); - store.set(pendingResourcesAtom, (pending) => - pending.map((resource) => - resource.operationId === operationId - ? { ...resource, expectedNodeId: response.expectedNodeId } - : resource, - ), - ); - } catch (error) { - store.set(pendingResourcesAtom, (pending) => - pending.filter((resource) => resource.operationId !== operationId), - ); - store.set( - resourceCreationErrorAtom, - typeof error === "object" && error !== null && "message" in error - ? String(error.message) - : "Failed to create the resource.", - ); - } finally { - mutationInFlightRef.current = false; - await requestGraphUpdate(); - } - }; - - const queued = mutationQueueRef.current.then(execute, execute); - mutationQueueRef.current = queued; - return queued; - }, - [messageChannel, requestGraphUpdate], - ); - - return { requestGraphUpdate, createResource, resetLayout }; -} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/utils/deployment-graph-equality.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/utils/deployment-graph-equality.ts deleted file mode 100644 index 2355181523b..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/utils/deployment-graph-equality.ts +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import type { DeploymentGraph } from "@/lib/messaging/messages"; - -/** - * Compare two deployment graphs for structural equality, ignoring - * `range` fields. Range values change on trivial edits (e.g. - * inserting a blank line) and should NOT trigger a full re-layout. - * - * Returns `true` when the graph topology is identical — same nodes - * (by id, type, isCollection, hasChildren, hasError, filePath), - * same edges, and same errorCount. - */ -export function isDeploymentGraphEqual(a: DeploymentGraph | null, b: DeploymentGraph | null): boolean { - if (a === b) { - return true; - } - - if (!a || !b) { - return false; - } - - if (a.errorCount !== b.errorCount) { - return false; - } - - if (a.nodes.length !== b.nodes.length || a.edges.length !== b.edges.length) { - return false; - } - - for (let i = 0; i < a.nodes.length; i++) { - const na = a.nodes[i]!; - const nb = b.nodes[i]!; - - if ( - na.id !== nb.id || - na.type !== nb.type || - na.isCollection !== nb.isCollection || - na.hasChildren !== nb.hasChildren || - na.hasError !== nb.hasError || - na.filePath !== nb.filePath - ) { - return false; - } - } - - for (let i = 0; i < a.edges.length; i++) { - const ea = a.edges[i]!; - const eb = b.edges[i]!; - - if (ea.sourceId !== eb.sourceId || ea.targetId !== eb.targetId) { - return false; - } - } - - return true; -} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/state-management-guidelines.md b/src/vscode-bicep-ui/apps/visual-designer/src/state-management-guidelines.md deleted file mode 100644 index 1c7fc0a171a..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/state-management-guidelines.md +++ /dev/null @@ -1,54 +0,0 @@ -# Visual Designer State Management Guidelines - -Use Jotai as the default for shared feature state. Keep component-local state only for transient UI input that should not be shared. - -## Core rules - -1. Co-locate atoms with the feature they belong to. -2. Export feature atoms through the feature `index.ts` barrel. -3. Prefer small atoms over one large object atom. -4. Use derived atoms for view intent (for example: `isExportCanvasCoverVisibleAtom`). -5. Keep imperative writes in action atoms (`open*`, `close*`, `reset*`) when the action touches multiple atoms. -6. Use `useAtomValue` for reads and `useSetAtom` for writes to reduce accidental subscriptions. -7. Keep ephemeral typing state local (`useState`) only when needed for in-progress input UX. - -## Recommended layout - -Core libraries live under `src/lib/` (`graph/`, `messaging/`, `theming/`, `utils/`). -User-facing feature slices live under `src/features/` (`control/`, `export/`, `layout/`, `status/`, `visualization/`, `devtools/`). - -- `feature/atoms.ts`: primary atoms, action atoms, derived atoms. -- `feature/components/*`: use atoms directly where practical. -- `feature/hooks/*`: orchestration logic that reacts to external events and writes atoms. - -## Patterns in this app - -1. Export flow: - -- Source of truth in `features/export/atoms.ts`. -- UI visibility via derived atoms. -- Export execution reads atoms directly in toolbar. - -2. Theming: - -- VS Code body theme is observed once via `theming/atoms.ts` (`activeThemeAtom.onMount`). -- Consumers use `useTheme()` backed by the shared atom. - -3. Status (`features/status/`): - -- Owns graph metadata atoms (`errorCountAtom`, `hasNodesAtom`) written by the messaging layer. -- `graphStatusAtom` derives semantic status (`errors | empty | ready`). -- `StatusBar` component lives here as it renders diagnostic status. -- Kept separate from `graph/`, which only handles rendering and geometry. - -4. Control (`features/controls/`): - -- `graphControlAvailabilityAtom` derives which controls are actionable from `hasNodesAtom`. -- `ControlBar` component lives here as it orchestrates graph interactions. -- Depends on `status` (availability), `export` (open overlay), `graph/` (fit view), `layout` (reset). - -## When NOT to use atoms - -1. Purely presentational local toggles that never leave a component. -2. One-off temporary values with no cross-component relevance. -3. Expensive values better memoized from props inside one component. diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/ui/components/FloatingPanel.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/ui/components/FloatingPanel.tsx new file mode 100644 index 00000000000..ff3464d3056 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/ui/components/FloatingPanel.tsx @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import styled from "styled-components"; + +/** + * A panel that floats above the viewport: the chrome shared by the control bar and the palette + * launcher. + */ +export const FloatingPanel = styled.div` + display: flex; + flex-direction: column; + gap: 1px; + padding: 4px; + background-color: ${({ theme }) => theme.panel.background}; + border: 1px solid ${({ theme }) => theme.panel.border}; + border-radius: 8px; + box-shadow: + 0 1px 3px rgba(0, 0, 0, 0.08), + 0 4px 12px rgba(0, 0, 0, 0.06); + backdrop-filter: blur(12px); +`; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/controls/ControlPrimitives.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/ui/components/IconButton.tsx similarity index 56% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/controls/ControlPrimitives.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/ui/components/IconButton.tsx index f25c2710419..296528e1eb5 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/controls/ControlPrimitives.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/ui/components/IconButton.tsx @@ -3,21 +3,10 @@ import styled from "styled-components"; -export const ControlSurface = styled.div` - display: flex; - flex-direction: column; - gap: 1px; - padding: 4px; - background-color: ${({ theme }) => theme.controlBar.background}; - border: 1px solid ${({ theme }) => theme.controlBar.border}; - border-radius: 8px; - box-shadow: - 0 1px 3px rgba(0, 0, 0, 0.08), - 0 4px 12px rgba(0, 0, 0, 0.06); - backdrop-filter: blur(12px); -`; - -export const ControlButton = styled.button` +/** + * A compact square icon button sized for toolbars and floating panels. + */ +export const IconButton = styled.button` display: flex; align-items: center; justify-content: center; @@ -27,18 +16,18 @@ export const ControlButton = styled.button` border: none; border-radius: 6px; background-color: transparent; - color: ${({ theme }) => theme.controlBar.icon}; + color: ${({ theme }) => theme.iconButton.color}; cursor: pointer; transition: background-color 150ms ease, transform 150ms ease; &:hover { - background-color: ${({ theme }) => theme.controlBar.hoverBackground}; + background-color: ${({ theme }) => theme.iconButton.hoverBackground}; } &:active { - background-color: ${({ theme }) => theme.controlBar.activeBackground}; + background-color: ${({ theme }) => theme.iconButton.activeBackground}; transform: scale(0.95); } diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/ui/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/ui/index.ts new file mode 100644 index 00000000000..14f59badfba --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/ui/index.ts @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export * from "./components/IconButton"; +export * from "./components/FloatingPanel"; +export * from "./motion"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/ui/motion.ts b/src/vscode-bicep-ui/apps/visual-designer/src/ui/motion.ts new file mode 100644 index 00000000000..4d1a65eecea --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/ui/motion.ts @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Shared motion tokens. + * + * Timing curves are presentation vocabulary, not any one feature's property. This curve is used + * wherever an element expands into view -- a node appearing on the graph, the palette panel opening. + */ +export const EXPAND_TRANSITION = { + duration: 0.16, + ease: [0.2, 0.8, 0.2, 1] as const, +}; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/theming/atoms.ts b/src/vscode-bicep-ui/apps/visual-designer/src/ui/theme/atoms.ts similarity index 100% rename from src/vscode-bicep-ui/apps/visual-designer/src/lib/theming/atoms.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/ui/theme/atoms.ts diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/theming/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/ui/theme/index.ts similarity index 100% rename from src/vscode-bicep-ui/apps/visual-designer/src/lib/theming/index.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/ui/theme/index.ts diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/styled.d.ts b/src/vscode-bicep-ui/apps/visual-designer/src/ui/theme/styled.d.ts similarity index 76% rename from src/vscode-bicep-ui/apps/visual-designer/src/styled.d.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/ui/theme/styled.d.ts index ae75ad98e0e..82299c16de9 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/styled.d.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/ui/theme/styled.d.ts @@ -3,13 +3,16 @@ import "styled-components"; +import type { GraphTheme } from "@/lib/graph"; + declare module "styled-components" { - export interface DefaultTheme { + /** + * `GraphTheme` carries the tokens `lib/graph` requires. Extending it makes the app's theme the + * thing that satisfies the engine's contract, so dropping a token the engine reads fails to + * compile. + */ + export interface DefaultTheme extends GraphTheme { name: "light" | "dark" | "high-contrast" | "high-contrast-light"; - canvas: { - background: string; - dotColor: string; - }; node: { background: string; compoundBackground: string; @@ -39,24 +42,19 @@ declare module "styled-components" { primary: string; secondary: string; }; - edge: { - color: string; - }; - controlBar: { + /** Chrome for floating panels layered over the viewport. */ + panel: { background: string; border: string; - icon: string; + }; + /** Icon button states, used inside panels and toolbars. */ + iconButton: { + color: string; hoverBackground: string; activeBackground: string; }; focusBorder: string; error: string; success: string; - grabCursor: { - /** Semi-transparent background color for the cursor overlay (CSS color value). */ - background: string; - /** Backdrop-filter blur radius in pixels. */ - blur: number; - }; } } diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/theming/themes.ts b/src/vscode-bicep-ui/apps/visual-designer/src/ui/theme/themes.ts similarity index 95% rename from src/vscode-bicep-ui/apps/visual-designer/src/lib/theming/themes.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/ui/theme/themes.ts index e4d640dce0d..e1f0df723ac 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/theming/themes.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/ui/theme/themes.ts @@ -18,7 +18,7 @@ import type { DefaultTheme } from "styled-components"; export const lightTheme: DefaultTheme = { name: "light", - canvas: { + viewport: { background: "#f4f5f7", dotColor: "#d4d6db", }, @@ -46,10 +46,12 @@ export const lightTheme: DefaultTheme = { edge: { color: "#c7cad0", }, - controlBar: { + panel: { background: "rgba(255, 255, 255, 0.92)", border: "rgba(0, 0, 0, 0.08)", - icon: "#4b5563", + }, + iconButton: { + color: "#4b5563", hoverBackground: "rgba(0, 0, 0, 0.05)", activeBackground: "rgba(0, 0, 0, 0.09)", }, @@ -64,7 +66,7 @@ export const lightTheme: DefaultTheme = { export const darkTheme: DefaultTheme = { name: "dark", - canvas: { + viewport: { background: "#1a1a1a", dotColor: "#2e2e2e", }, @@ -92,10 +94,12 @@ export const darkTheme: DefaultTheme = { edge: { color: "#3f3f46", }, - controlBar: { + panel: { background: "rgba(38, 38, 38, 0.92)", border: "rgba(255, 255, 255, 0.08)", - icon: "#a1a1aa", + }, + iconButton: { + color: "#a1a1aa", hoverBackground: "rgba(255, 255, 255, 0.06)", activeBackground: "rgba(255, 255, 255, 0.10)", }, @@ -110,7 +114,7 @@ export const darkTheme: DefaultTheme = { export const highContrastTheme: DefaultTheme = { name: "high-contrast", - canvas: { + viewport: { background: "#000000", dotColor: "transparent", }, @@ -138,10 +142,12 @@ export const highContrastTheme: DefaultTheme = { edge: { color: "#ffd700", }, - controlBar: { + panel: { background: "#000000", border: "#ffd700", - icon: "#ffffff", + }, + iconButton: { + color: "#ffffff", hoverBackground: "rgba(255, 215, 0, 0.2)", activeBackground: "rgba(255, 215, 0, 0.3)", }, @@ -156,7 +162,7 @@ export const highContrastTheme: DefaultTheme = { export const highContrastLightTheme: DefaultTheme = { name: "high-contrast-light", - canvas: { + viewport: { background: "#ffffff", dotColor: "transparent", }, @@ -184,10 +190,12 @@ export const highContrastLightTheme: DefaultTheme = { edge: { color: "#000000", }, - controlBar: { + panel: { background: "#ffffff", border: "#000000", - icon: "#000000", + }, + iconButton: { + color: "#000000", hoverBackground: "rgba(0, 0, 0, 0.1)", activeBackground: "rgba(0, 0, 0, 0.2)", }, diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/theming/use-theme.ts b/src/vscode-bicep-ui/apps/visual-designer/src/ui/theme/use-theme.ts similarity index 100% rename from src/vscode-bicep-ui/apps/visual-designer/src/lib/theming/use-theme.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/ui/theme/use-theme.ts diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/resource-palette-utils.ts b/src/vscode-bicep-ui/apps/visual-designer/src/utils/errors.ts similarity index 100% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/resource-palette-utils.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/utils/errors.ts diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/utils/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/utils/index.ts similarity index 59% rename from src/vscode-bicep-ui/apps/visual-designer/src/lib/utils/index.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/utils/index.ts index 374a65de3da..da0371e9584 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/utils/index.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/utils/index.ts @@ -1,6 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -export * from "./math"; +export * from "./errors"; export * from "./text"; -export * from "./deployment-graph-equality"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/utils/text.ts b/src/vscode-bicep-ui/apps/visual-designer/src/utils/text.ts similarity index 100% rename from src/vscode-bicep-ui/apps/visual-designer/src/lib/utils/text.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/utils/text.ts diff --git a/src/vscode-bicep-ui/apps/visual-designer/visual-graph-protocol.md b/src/vscode-bicep-ui/apps/visual-designer/visual-graph-protocol.md deleted file mode 100644 index f7f44a0e95c..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/visual-graph-protocol.md +++ /dev/null @@ -1,273 +0,0 @@ -# Visual Graph Protocol - -This document describes the server-driven visual graph protocol used by the Bicep visual designer. - -The protocol is intentionally split into two phases: - -1. Reconcile graph topology and metadata. -2. Render and measure nodes on the client, then request layout using actual node sizes. - -This split exists because the language server cannot know the final rendered dimensions of React node cards before the webview renders them. - -## Participants - -- **Language server** builds the canonical graph from the live Bicep compilation, diffs topology and metadata, validates measured layout requests, and runs MSAGL. -- **VS Code extension host** forwards webview requests to language-server requests and forwards responses back. It does not compute topology or layout. -- **React webview** owns rendering, node measurement, pan/zoom, fit-view, and applying graph/layout patches. - -## Message Flow - -```mermaid -sequenceDiagram - participant LS as Language server - participant Ext as VS Code extension - participant UI as React webview - - Note over LS,Ext: compilation or diagnostics update - Ext-->>UI: documentDidChange - - alt request already in flight - UI->>UI: mark dirty - else idle - UI->>Ext: getGraphUpdate(current topology) - Ext->>LS: textDocument/visualGraphUpdate(current topology) - LS->>LS: rebuild canonical graph and diff topology/metadata - LS-->>Ext: topology/metadata patches - Ext-->>UI: topology/metadata patches - UI->>UI: apply patches and detect layout-affecting changes - - opt layout may be stale - UI->>UI: render graph and measure node boxes - UI->>UI: compare measured graph with last layout input - alt measured layout input changed - UI->>Ext: getGraphLayout(measured graph) - Ext->>LS: textDocument/visualGraphLayout(measured graph) - LS->>LS: rebuild canonical graph and validate measured topology - alt topology still matches - LS->>LS: run MSAGL with measured node sizes - LS-->>Ext: ok + setNodeLayout patches - Ext-->>UI: ok + setNodeLayout patches - UI->>UI: apply layout patches and fit view - else topology changed - LS-->>Ext: graphChanged - Ext-->>UI: graphChanged - UI->>UI: mark dirty and restart graph update - end - else measured layout input unchanged - UI->>UI: keep existing positions - end - end - - opt dirty - UI->>UI: request graph update again - end - end -``` - -## Graph Update - -The graph update request reconciles topology and metadata only. It does not run layout. - -```ts -interface GetGraphUpdateRequest { - current: RenderedGraph | null; -} - -interface GetGraphUpdateResponse { - patches: GraphPatch[]; -} -``` - -The extension forwards this as: - -```ts -interface VisualGraphUpdateParams { - textDocument: { uri: string }; - current: RenderedGraph | null; -} - -interface VisualGraphUpdateResult { - patches: GraphPatch[]; -} -``` - -### Update Sequence - -```mermaid -sequenceDiagram - participant UI as React webview - participant Ext as VS Code extension - participant LS as Language server - - UI->>Ext: getGraphUpdate(current) - Ext->>LS: textDocument/visualGraphUpdate(current) - LS->>LS: build canonical graph - LS->>LS: diff current topology/metadata against canonical graph - LS-->>Ext: GraphPatch[] - Ext-->>UI: GraphPatch[] - UI->>UI: apply patches to client graph mirror - UI->>UI: record whether patches may affect layout -``` - -## Client Layout Invalidation - -The client decides whether layout may be stale while applying patches. This avoids making the server guess whether metadata changes affect rendered dimensions. - -Layout-affecting patches: - -- `clearGraph` -- `addNode` -- `removeNode` -- `addEdge` -- `removeEdge` -- `updateNode` when `type`, `isCollection`, or `hasChildren` changes - -Non-layout-affecting patches: - -- `updateNode` when only `hasError` changes -- `setErrorCount` -- `setNodeLayout` -- `setGraphBounds` - -Notably, `hasError` does not trigger layout. - -The server diffs node metadata per field and emits `updateNode` only when metadata actually changes. The client still compares each incoming field against the value it currently holds and treats the patch as layout-affecting only when a layout-relevant field (`type`, `isCollection`, or `hasChildren`) changed value. Source locations are resolved on demand and are not part of graph metadata, so whitespace-only edits do not produce node patches. - -If a patch may affect layout, the client renders the updated graph, measures actual node boxes, builds a measured `RenderedGraph`, and compares it with the last measured graph that produced a layout. The client sends a layout request only when measured topology, sizes, or layout options changed. - -```mermaid -flowchart TD - A[Apply graph update patches] --> B{Any patch may affect layout?} - B -- No --> C[Keep current positions] - B -- Yes --> D[Render updated graph] - D --> E[Measure actual node sizes] - E --> F{Measured graph equals last layout input?} - F -- Yes --> C - F -- No --> G[Send measured layout request] -``` - -## Measured Layout - -The layout request is sent only after the graph has rendered and node dimensions have been measured. - -```ts -interface GetGraphLayoutRequest { - current: RenderedGraph; -} - -interface GetGraphLayoutResponse { - status: "ok" | "graphChanged" | "layoutFailed"; - patches: GraphPatch[]; -} -``` - -The extension forwards this as: - -```ts -interface VisualGraphLayoutParams { - textDocument: { uri: string }; - current: RenderedGraph; - options?: VisualGraphLayoutOptions; -} - -interface VisualGraphLayoutResult { - status: "ok" | "graphChanged" | "layoutFailed"; - patches: GraphPatch[]; -} -``` - -Successful layout responses contain `setNodeLayout` patches and, when available, one `setGraphBounds` patch used for fit-view. - -### Layout Sequence - -```mermaid -sequenceDiagram - participant UI as React webview - participant Ext as VS Code extension - participant LS as Language server - - UI->>Ext: getGraphLayout(measured graph) - Ext->>LS: textDocument/visualGraphLayout(measured graph) - LS->>LS: rebuild canonical graph from live compilation - LS->>LS: compare measured topology with canonical topology - - alt topology matches - LS->>LS: run MSAGL with measured node sizes - LS-->>Ext: ok + setNodeLayout patches - Ext-->>UI: ok + setNodeLayout patches - UI->>UI: apply positions and fit view - else topology changed - LS-->>Ext: graphChanged - Ext-->>UI: graphChanged - UI->>UI: mark dirty and request graph update - else layout failed recoverably - LS-->>Ext: layoutFailed - Ext-->>UI: layoutFailed - UI->>UI: keep existing positions - end -``` - -## Rendered Graph - -`RenderedGraph` carries topology plus measured node sizes. It intentionally does not send current positions back to the server. - -```ts -interface RenderedGraph { - nodes: RenderedGraphNode[]; - edges: RenderedGraphEdge[]; -} - -interface RenderedGraphNode { - id: string; - kind: "resource" | "module"; - parentId: string | null; - type: string; - isCollection: boolean; - hasChildren: boolean; - hasError: boolean; - width: number; - height: number; -} - -interface RenderedGraphEdge { - id: string; - sourceId: string; - targetId: string; -} -``` - -## Patch Shape - -```ts -type GraphPatch = - | { op: "clearGraph" } - | { op: "addNode"; node: GraphNode } - | { op: "removeNode"; nodeId: string } - | { op: "updateNode"; nodeId: string; changes: GraphNodeChanges } - | { op: "addEdge"; edge: GraphEdge } - | { op: "removeEdge"; edgeId: string } - | { op: "setNodeLayout"; nodeId: string; layout: NodeLayout } - | { op: "setGraphBounds"; bounds: GraphBounds } - | { op: "setErrorCount"; errorCount: number }; -``` - -## Concurrency Rules - -Each visualizer keeps one in-flight visual graph request at a time. A visual graph request is either a graph update request or a layout request. - -```mermaid -stateDiagram-v2 - [*] --> Idle - Idle --> Updating: documentDidChange - Updating --> Measuring: patches may affect layout - Updating --> Idle: no layout needed - Measuring --> Layouting: measured graph changed - Measuring --> Idle: measured graph unchanged - Layouting --> Idle: ok or layoutFailed - Layouting --> Updating: graphChanged or dirty - Updating --> Updating: dirty after response -``` - -If `documentDidChange` arrives while a request is in flight, the client sets a dirty flag. When the current request finishes, the client sends a fresh graph update if dirty is set. - -The server remains stateless per request. It validates each measured layout request against the current live compilation instead of tracking graph revisions. diff --git a/src/vscode-bicep-ui/eslint.config.mjs b/src/vscode-bicep-ui/eslint.config.mjs index 60b691f808e..463284102bd 100644 --- a/src/vscode-bicep-ui/eslint.config.mjs +++ b/src/vscode-bicep-ui/eslint.config.mjs @@ -1,6 +1,6 @@ // For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format -import eslint from "@eslint/js"; import { fixupConfigRules, fixupPluginRules } from "@eslint/compat"; +import eslint from "@eslint/js"; import notice from "eslint-plugin-notice"; import reactPlugin from "eslint-plugin-react"; import reactHooksPlugin from "eslint-plugin-react-hooks"; diff --git a/src/vscode-bicep-ui/package-lock.json b/src/vscode-bicep-ui/package-lock.json index c282af2f7a1..02312ea7dff 100644 --- a/src/vscode-bicep-ui/package-lock.json +++ b/src/vscode-bicep-ui/package-lock.json @@ -149,6 +149,7 @@ "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^6.0.5", "@vscode-elements/webview-playground": "^1.1.3", + "happy-dom": "^20.11.6", "vite": "^8.2.1", "vitest": "^4.1.10" } @@ -187,6 +188,24 @@ } } }, + "apps/visual-designer/node_modules/happy-dom": { + "version": "20.11.6", + "integrity": "sha1-guUFzBrPPou6mb8PBHdhx1ZHdoI=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "apps/visual-designer/node_modules/undici-types": { "version": "8.3.0", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", @@ -6543,7 +6562,6 @@ }, "node_modules/jotai-family": { "version": "1.1.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jotai-family/-/jotai-family-1.1.0.tgz", "integrity": "sha1-h+q5xuiyQfO8ObbwSjtVJH69QLs=", "license": "MIT", "engines": { @@ -9499,8 +9517,8 @@ "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^6.0.5", "@vitest/coverage-v8": "^4.1.10", - "@vscode/codicons": "^0.0.45", "@vscode-elements/elements": "^2.5.1", + "@vscode/codicons": "^0.0.45", "eslint-plugin-storybook": "^10.5.8", "happy-dom": "^20.11.2", "storybook": "^10.5.8", @@ -9512,6 +9530,7 @@ "vitest": "^4.1.10" }, "peerDependencies": { + "@vscode-elements/elements": "^2.5.1", "d3-drag": "^3.0.0", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0", @@ -9519,8 +9538,7 @@ "jotai-scope": "^0.12.2", "react": "^19.2.4", "react-dom": "^19.2.4", - "styled-components": "^6.1.11", - "@vscode-elements/elements": "^2.5.1" + "styled-components": "^6.1.11" } }, "packages/components/node_modules/@vitejs/plugin-react": { diff --git a/src/vscode-bicep-ui/packages/messaging/package.json b/src/vscode-bicep-ui/packages/messaging/package.json index e62b56b1277..3d7e7d94816 100644 --- a/src/vscode-bicep-ui/packages/messaging/package.json +++ b/src/vscode-bicep-ui/packages/messaging/package.json @@ -3,6 +3,7 @@ "private": true, "version": "0.0.0", "type": "module", + "main": "dist/index.js", "module": "dist/index.js", "types": "dist/index.d.ts", "files": [ diff --git a/src/vscode-bicep-ui/packages/messaging/src/WebviewRequestChannelProvider.tsx b/src/vscode-bicep-ui/packages/messaging/src/WebviewRequestChannelProvider.tsx index 68f8475f710..a84089ceec0 100644 --- a/src/vscode-bicep-ui/packages/messaging/src/WebviewRequestChannelProvider.tsx +++ b/src/vscode-bicep-ui/packages/messaging/src/WebviewRequestChannelProvider.tsx @@ -2,19 +2,24 @@ // Licensed under the MIT License. import type { ReactNode } from "react"; +import type { WebviewMessageChannelApi } from "./webviewMessageChannel"; import { createContext, useCallback, useEffect, useRef } from "react"; import { WebviewMessageChannel } from "./webviewMessageChannel"; export interface WebviewMessageChannelProviderProps { - messageChannel?: WebviewMessageChannel; + /** + * A channel to use instead of the real one. Typed as the interface rather than the class so dev and + * test doubles are checked against the surface the app actually calls. + */ + messageChannel?: WebviewMessageChannelApi; children: ReactNode; } -export const WebviewMessageChannelContext = createContext<(() => WebviewMessageChannel) | undefined>(undefined); +export const WebviewMessageChannelContext = createContext<(() => WebviewMessageChannelApi) | undefined>(undefined); export function WebviewMessageChannelProvider({ messageChannel, children }: WebviewMessageChannelProviderProps) { - const messageChannelRef = useRef(messageChannel); + const messageChannelRef = useRef(messageChannel); const getMessageChannel = useCallback(() => { if (!messageChannelRef.current) { diff --git a/src/vscode-bicep-ui/packages/messaging/src/index.ts b/src/vscode-bicep-ui/packages/messaging/src/index.ts index 4f80c2ffaa5..e8acce7b958 100644 --- a/src/vscode-bicep-ui/packages/messaging/src/index.ts +++ b/src/vscode-bicep-ui/packages/messaging/src/index.ts @@ -1,6 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +export * from "./messageDescriptor"; +export * from "./useNotification"; +export * from "./useRequest"; export * from "./useWebviewMessageChannel"; export * from "./useWebviewNotification"; export * from "./useWebviewRequest"; diff --git a/src/vscode-bicep-ui/packages/messaging/src/messageDescriptor.ts b/src/vscode-bicep-ui/packages/messaging/src/messageDescriptor.ts new file mode 100644 index 00000000000..46489d52e3e --- /dev/null +++ b/src/vscode-bicep-ui/packages/messaging/src/messageDescriptor.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Typed descriptions of the messages exchanged with the extension host. + * + * A descriptor binds a method name to its parameter and result types in one declaration, so a call + * site cannot pair the wrong types with a method. Without it, `sendRequest({ method })` takes the + * method and the result type as independent, unchecked arguments, and nothing stops them from + * disagreeing. + * + * Descriptors carry no runtime behaviour beyond the method name. The phantom members exist only so + * the compiler can recover the types; they are never present at runtime, and they also stop two + * descriptors with different types from being structurally interchangeable. + */ + +declare const paramsBrand: unique symbol; +declare const resultBrand: unique symbol; + +export interface RequestDescriptor { + readonly method: string; + readonly [paramsBrand]?: (params: TParams) => void; + readonly [resultBrand]?: (result: TResult) => void; +} + +export interface NotificationDescriptor { + readonly method: string; + readonly [paramsBrand]?: (params: TParams) => void; +} + +/** Arguments a message takes: none when it was declared with `void` parameters. */ +export type MessageArgs = [TParams] extends [void] ? [] : [params: TParams]; + +/** Declares a request: `method`, sent with `TParams`, resolving to `TResult`. */ +export function defineRequest(method: string): RequestDescriptor { + return { method }; +} + +/** Declares a notification: `method`, sent with `TParams`. */ +export function defineNotification(method: string): NotificationDescriptor { + return { method }; +} diff --git a/src/vscode-bicep-ui/packages/messaging/src/useNotification.ts b/src/vscode-bicep-ui/packages/messaging/src/useNotification.ts new file mode 100644 index 00000000000..93b015a3bcf --- /dev/null +++ b/src/vscode-bicep-ui/packages/messaging/src/useNotification.ts @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { NotificationDescriptor } from "./messageDescriptor"; + +import { useEffect } from "react"; +import { useWebviewMessageChannel } from "./useWebviewMessageChannel"; + +/** + * Subscribes to a declared notification. The callback receives the descriptor's parameter type, so + * handlers no longer begin by casting or re-validating an `unknown`. + */ +export function useNotification( + descriptor: NotificationDescriptor, + callback: (params: TParams) => void, +) { + const messageChannel = useWebviewMessageChannel(); + + useEffect(() => { + const subscription = (params?: unknown) => callback(params as TParams); + + messageChannel.subscribeToNotification(descriptor.method, subscription); + + return () => { + messageChannel.unsubscribeFromNotification(descriptor.method, subscription); + }; + }, [descriptor.method, callback, messageChannel]); +} diff --git a/src/vscode-bicep-ui/packages/messaging/src/useRequest.ts b/src/vscode-bicep-ui/packages/messaging/src/useRequest.ts new file mode 100644 index 00000000000..a1e002fb6f7 --- /dev/null +++ b/src/vscode-bicep-ui/packages/messaging/src/useRequest.ts @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { MessageArgs, RequestDescriptor } from "./messageDescriptor"; + +import { useEffect, useState } from "react"; +import { useWebviewMessageChannel } from "./useWebviewMessageChannel"; + +/** + * Issues a declared request once on mount and returns `[result, error]`. + * + * The result type comes from the descriptor rather than a caller-supplied generic, so it cannot + * disagree with the method being sent. + */ +export function useRequest( + descriptor: RequestDescriptor, + ...args: MessageArgs +) { + const messageChannel = useWebviewMessageChannel(); + const [result, setResult] = useState(undefined); + const [error, setError] = useState(undefined); + const params = args[0]; + + useEffect(() => { + const invokeRequest = async () => { + try { + setResult(await messageChannel.sendRequest({ method: descriptor.method, params })); + } catch (error) { + setError(error); + } + }; + + void invokeRequest(); + }, [descriptor.method, params, messageChannel]); + + return [result, error] as const; +} diff --git a/src/vscode-bicep-ui/packages/messaging/src/webviewMessageChannel.ts b/src/vscode-bicep-ui/packages/messaging/src/webviewMessageChannel.ts index 5825d87c8fb..acacbd3d1d9 100644 --- a/src/vscode-bicep-ui/packages/messaging/src/webviewMessageChannel.ts +++ b/src/vscode-bicep-ui/packages/messaging/src/webviewMessageChannel.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import type { WebviewApi } from "vscode-webview"; +import type { MessageArgs, NotificationDescriptor, RequestDescriptor } from "./messageDescriptor"; export interface WebviewRequestMessage { id: string; @@ -22,6 +23,29 @@ export interface WebviewNotificationMessage { export type WebviewNotificationCallback = (params?: unknown) => void; +/** + * The channel surface consumers depend on. + * + * This exists so test and dev doubles can be checked against the real channel. `WebviewMessageChannel` + * has private fields, so a double could never be structurally assignable to the class itself, and the + * dev shell previously bridged that with `as unknown as WebviewMessageChannel` — which silently + * accepted a fake that was missing methods the app called at runtime. + */ +export interface WebviewMessageChannelApi { + revive(): void; + dispose(): void; + sendRequest(requestMessage: Omit): Promise; + sendNotification(notificationMessage: WebviewNotificationMessage): void; + request( + descriptor: RequestDescriptor, + ...args: MessageArgs + ): Promise; + notify(descriptor: NotificationDescriptor, ...args: MessageArgs): void; + setState(state: T): T; + subscribeToNotification(method: string, callback: WebviewNotificationCallback): void; + unsubscribeFromNotification(method: string, callback: WebviewNotificationCallback): void; +} + type WebviewResponseCallback = (result?: unknown, error?: unknown) => void; function isResponseMessage(message: unknown): message is WebviewResponseMessage { @@ -32,7 +56,7 @@ function isNotificationMessage(message: unknown): message is WebviewNotification return typeof message === "object" && message !== null && "method" in message; } -export class WebviewMessageChannel { +export class WebviewMessageChannel implements WebviewMessageChannelApi { private readonly webviewApi: WebviewApi; private readonly responseCallbacks: Record; private readonly notificationSubscriptions: Record>; @@ -105,6 +129,22 @@ export class WebviewMessageChannel { this.webviewApi.postMessage(notificationMessage); } + /** + * Sends a declared request. Params and result are both taken from the descriptor, so the method, + * what it is sent with, and what it resolves to cannot drift apart at a call site. + */ + request( + descriptor: RequestDescriptor, + ...args: MessageArgs + ): Promise { + return this.sendRequest({ method: descriptor.method, params: args[0] }); + } + + /** Sends a declared notification, with its parameters checked against the descriptor. */ + notify(descriptor: NotificationDescriptor, ...args: MessageArgs): void { + this.sendNotification({ method: descriptor.method, params: args[0] }); + } + setState(state: T): T { return this.webviewApi.setState(state); } diff --git a/src/vscode-bicep-ui/tsconfig.base.json b/src/vscode-bicep-ui/tsconfig.base.json index 0bddf7d552e..428420ebc5f 100644 --- a/src/vscode-bicep-ui/tsconfig.base.json +++ b/src/vscode-bicep-ui/tsconfig.base.json @@ -1,24 +1,24 @@ -{ - "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - "verbatimModuleSyntax": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - } -} +{ + "compilerOptions": { + "target": "ES2024", + "useDefineForClassFields": true, + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "verbatimModuleSyntax": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + } +} diff --git a/src/vscode-bicep/src/features/visualization/visualizer-view.ts b/src/vscode-bicep/src/features/visualization/visualizer-view.ts index a529fe63433..dd83e94b1cd 100644 --- a/src/vscode-bicep/src/features/visualization/visualizer-view.ts +++ b/src/vscode-bicep/src/features/visualization/visualizer-view.ts @@ -437,12 +437,6 @@ export class BicepVisualizerView extends Disposable { this.render(); return; - case "revealFileRange": { - const payload = notification.params as { filePath: string; range: Range }; - this.revealFileRange(payload.filePath, payload.range); - return; - } - case "revealNodeSource": { const payload = notification.params as { nodeId: string }; void this.handleRevealNodeSource(payload.nodeId);