From 91128d9f55d22bd6ec1baad9cb03ed78d9630dc0 Mon Sep 17 00:00:00 2001 From: Shenglong Li Date: Thu, 27 Aug 2026 23:07:33 -0700 Subject: [PATCH 1/5] Refactor visual designer into feature-oriented layers Establish app, feature, UI, library, and development boundaries; normalize feature structure and enforce dependency direction. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 91f8ba77-9474-4393-aa97-78e82fb5381b --- .../instructions/react.instructions.md | 2 +- .../state-management.instructions.md | 19 +- .../visual-designer/architecture-notes.md | 511 +++++++++++++++--- .../apps/visual-designer/e2e/fixtures.ts | 9 +- .../e2e/resource-creation.spec.ts | 37 +- .../apps/visual-designer/src/App.tsx | 222 -------- .../apps/visual-designer/src/app/App.tsx | 80 +++ .../visual-designer/src/app/AppProviders.tsx | 47 ++ .../src/{ => app}/GlobalStyle.ts | 0 .../controls/{ => components}/ControlBar.tsx | 30 +- .../controls/{ => hooks}/use-reset-layout.ts | 0 .../src/features/controls/index.ts | 4 +- .../__tests__/atoms.test.ts | 0 .../animations.ts | 0 .../atoms.ts | 6 +- .../components/DeploymentGraphView.tsx | 169 ++++++ .../components}/PendingResourceLayer.tsx | 6 +- .../components}/ResourceCreationError.tsx | 2 +- .../components/nodes/ModuleNode.tsx} | 4 +- .../components/nodes/NodeContentProvider.tsx | 81 +++ .../components/nodes/ResourceNode.tsx} | 23 +- .../components/nodes/ResourceNodePreview.tsx} | 12 +- .../hooks/use-apply-graph.ts} | 21 +- .../hooks}/use-graph-update.ts | 33 +- .../src/features/deployment-graph/index.ts | 7 + .../__tests__/layout-invalidation.test.ts | 2 +- .../deployment-graph/utils/graph-equality.ts} | 2 +- .../utils}/layout-invalidation.ts | 2 +- .../devtools/{ => components}/DevAppShell.tsx | 2 +- .../devtools/{ => components}/DevToolbar.tsx | 6 +- .../devtools/{ => fakes}/fake-graph-differ.ts | 0 .../{ => fakes}/fake-message-channel.ts | 35 +- .../devtools/{ => hooks}/use-dev-channel.ts | 2 +- .../src/features/devtools/index.ts | 2 +- .../src/features/export/atoms.ts | 5 +- .../{ => components}/ExportAreaCover.tsx | 2 +- .../{ => components}/ExportAreaPreview.tsx | 2 +- .../export/{ => components}/ExportOverlay.tsx | 2 +- .../export/{ => components}/ExportToolbar.tsx | 6 +- .../src/features/export/index.ts | 7 +- .../export/{ => utils}/capture-element.ts | 0 .../{resource-palette => palette}/atoms.ts | 4 +- .../features/palette/components/Palette.tsx | 236 ++++++++ .../components/PaletteContent.tsx} | 38 +- .../components/PaletteControls.tsx} | 9 +- .../components}/PaletteDragOverlay.tsx | 11 +- .../components}/ResourceTypeGroups.tsx | 39 +- .../hooks/use-drag.ts} | 22 +- .../use-resource-creation-enablement.ts | 0 .../hooks/use-resource-type-catalog.ts | 146 +++++ .../hooks}/use-resource-type-search.ts | 7 +- .../{export/types.ts => palette/index.ts} | 2 +- .../src/features/palette/types.ts | 19 + .../src/features/resource-creation/index.ts | 8 - .../resource-palette/ResourcePalette.types.ts | 36 -- .../resource-palette/ResourcePaletteLayer.tsx | 362 ------------- .../src/features/resource-palette/index.ts | 10 - .../src/features/status/atoms.ts | 13 + .../status/{ => components}/StatusBar.tsx | 2 +- .../src/features/status/index.ts | 4 +- .../src/features/visualization/index.ts | 5 - .../apps/visual-designer/src/index.tsx | 2 +- .../{features => lib}/accessibility/atoms.ts | 0 .../{features => lib}/accessibility/index.ts | 2 +- .../accessibility/use-motion-policy-sync.ts | 5 +- .../src/lib/graph/atoms/configs.ts | 5 + .../src/lib/graph/atoms/graph.ts | 2 +- .../src/lib/graph/atoms/nodes.ts | 2 +- .../src/lib/graph/components/AtomicNode.tsx | 41 +- .../src/lib/graph/components/CompoundNode.tsx | 41 +- .../src/lib/graph/components/StraightEdge.tsx | 2 +- .../src/lib/graph/hooks/index.ts | 1 + .../src/lib/graph/hooks/use-box-update.ts | 2 +- .../src/lib/graph/hooks/use-fit-view.ts | 4 +- .../lib/graph/hooks/use-node-activation.ts | 40 ++ .../visual-designer/src/lib/graph/index.ts | 1 + .../contracts.ts => lib/graph/viewport.ts} | 2 +- .../src/lib/messaging/index.ts | 2 - .../src/lib/messaging/messages.ts | 17 +- .../utils/errors.ts} | 0 .../visual-designer/src/lib/utils/index.ts | 2 +- .../src/state-management-guidelines.md | 54 -- .../IconButton.tsx} | 19 +- .../MotionAwareProgressBar.tsx | 10 +- .../apps/visual-designer/src/ui/Surface.tsx | 21 + .../apps/visual-designer/src/ui/index.ts | 6 + .../src/{lib/theming => ui/theme}/atoms.ts | 0 .../src/{lib/theming => ui/theme}/index.ts | 0 .../src/{ => ui/theme}/styled.d.ts | 0 .../src/{lib/theming => ui/theme}/themes.ts | 0 .../{lib/theming => ui/theme}/use-theme.ts | 0 src/vscode-bicep-ui/eslint.config.mjs | 64 ++- 92 files changed, 1618 insertions(+), 1104 deletions(-) delete mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/App.tsx create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/app/App.tsx create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/app/AppProviders.tsx rename src/vscode-bicep-ui/apps/visual-designer/src/{ => app}/GlobalStyle.ts (100%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/controls/{ => components}/ControlBar.tsx (72%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/controls/{ => hooks}/use-reset-layout.ts (100%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/{resource-creation => deployment-graph}/__tests__/atoms.test.ts (100%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/{resource-creation => deployment-graph}/animations.ts (100%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/{resource-creation => deployment-graph}/atoms.ts (73%) create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/DeploymentGraphView.tsx rename src/vscode-bicep-ui/apps/visual-designer/src/features/{resource-creation => deployment-graph/components}/PendingResourceLayer.tsx (87%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/{resource-creation => deployment-graph/components}/ResourceCreationError.tsx (96%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/{visualization/ModuleDeclaration.tsx => deployment-graph/components/nodes/ModuleNode.tsx} (96%) create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/nodes/NodeContentProvider.tsx rename src/vscode-bicep-ui/apps/visual-designer/src/features/{visualization/ResourceDeclaration.tsx => deployment-graph/components/nodes/ResourceNode.tsx} (91%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/{resource-creation/ResourcePreviewCard.tsx => deployment-graph/components/nodes/ResourceNodePreview.tsx} (75%) rename src/vscode-bicep-ui/apps/visual-designer/src/{lib/messaging/use-visual-graph.ts => features/deployment-graph/hooks/use-apply-graph.ts} (95%) rename src/vscode-bicep-ui/apps/visual-designer/src/{lib/messaging => features/deployment-graph/hooks}/use-graph-update.ts (94%) create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/index.ts rename src/vscode-bicep-ui/apps/visual-designer/src/{lib/messaging => features/deployment-graph/utils}/__tests__/layout-invalidation.test.ts (99%) rename src/vscode-bicep-ui/apps/visual-designer/src/{lib/utils/deployment-graph-equality.ts => features/deployment-graph/utils/graph-equality.ts} (95%) rename src/vscode-bicep-ui/apps/visual-designer/src/{lib/messaging => features/deployment-graph/utils}/layout-invalidation.ts (97%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/{ => components}/DevAppShell.tsx (94%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/{ => components}/DevToolbar.tsx (94%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/{ => fakes}/fake-graph-differ.ts (100%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/{ => fakes}/fake-message-channel.ts (96%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/{ => hooks}/use-dev-channel.ts (91%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/export/{ => components}/ExportAreaCover.tsx (95%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/export/{ => components}/ExportAreaPreview.tsx (98%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/export/{ => components}/ExportOverlay.tsx (95%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/export/{ => components}/ExportToolbar.tsx (98%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/export/{ => utils}/capture-element.ts (100%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/{resource-palette => palette}/atoms.ts (89%) create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/Palette.tsx rename src/vscode-bicep-ui/apps/visual-designer/src/features/{resource-palette/ResourcePalette.tsx => palette/components/PaletteContent.tsx} (68%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/{resource-palette/ResourcePaletteControls.tsx => palette/components/PaletteControls.tsx} (87%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/{resource-palette => palette/components}/PaletteDragOverlay.tsx (76%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/{resource-palette => palette/components}/ResourceTypeGroups.tsx (90%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/{resource-palette/use-palette-drag.ts => palette/hooks/use-drag.ts} (79%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/{resource-palette => palette/hooks}/use-resource-creation-enablement.ts (100%) create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/palette/hooks/use-resource-type-catalog.ts rename src/vscode-bicep-ui/apps/visual-designer/src/features/{resource-palette => palette/hooks}/use-resource-type-search.ts (88%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/{export/types.ts => palette/index.ts} (55%) create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/palette/types.ts delete mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/resource-creation/index.ts delete mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/ResourcePalette.types.ts delete mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/ResourcePaletteLayer.tsx delete mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/index.ts rename src/vscode-bicep-ui/apps/visual-designer/src/features/status/{ => components}/StatusBar.tsx (98%) delete mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/visualization/index.ts rename src/vscode-bicep-ui/apps/visual-designer/src/{features => lib}/accessibility/atoms.ts (100%) rename src/vscode-bicep-ui/apps/visual-designer/src/{features => lib}/accessibility/index.ts (73%) rename src/vscode-bicep-ui/apps/visual-designer/src/{features => lib}/accessibility/use-motion-policy-sync.ts (90%) create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/hooks/use-node-activation.ts rename src/vscode-bicep-ui/apps/visual-designer/src/{features/resource-palette/contracts.ts => lib/graph/viewport.ts} (92%) rename src/vscode-bicep-ui/apps/visual-designer/src/{features/resource-palette/resource-palette-utils.ts => lib/utils/errors.ts} (100%) delete mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/state-management-guidelines.md rename src/vscode-bicep-ui/apps/visual-designer/src/{features/controls/ControlPrimitives.tsx => ui/IconButton.tsx} (69%) rename src/vscode-bicep-ui/apps/visual-designer/src/{features/accessibility => ui}/MotionAwareProgressBar.tsx (66%) create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/ui/Surface.tsx create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/ui/index.ts rename src/vscode-bicep-ui/apps/visual-designer/src/{lib/theming => ui/theme}/atoms.ts (100%) rename src/vscode-bicep-ui/apps/visual-designer/src/{lib/theming => ui/theme}/index.ts (100%) rename src/vscode-bicep-ui/apps/visual-designer/src/{ => ui/theme}/styled.d.ts (100%) rename src/vscode-bicep-ui/apps/visual-designer/src/{lib/theming => ui/theme}/themes.ts (100%) rename src/vscode-bicep-ui/apps/visual-designer/src/{lib/theming => ui/theme}/use-theme.ts (100%) 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..7cb70350b7f 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 `architecture-notes.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/architecture-notes.md b/src/vscode-bicep-ui/apps/visual-designer/architecture-notes.md index c59cfe97751..c10e7e9ce26 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/architecture-notes.md +++ b/src/vscode-bicep-ui/apps/visual-designer/architecture-notes.md @@ -1,119 +1,472 @@ -# Visual Designer Architecture Notes +# Visual Designer Architecture -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. +This describes how the app is structured and why. The code currently matches it; where the two +disagree, the code is wrong. -## Organizing Principle +Apply it incrementally when changing related areas. Do not treat it as a mandate for one large rewrite. -Keep the app split into three broad layers: +## 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. +| Layer | Path | Contains | +| ---------- | --------------- | -------------------------------------------------------------------------------- | +| `app` | `src/app/` | Composition root: provider stack, wiring, global style. No product logic. | +| `features` | `src/features/` | User-facing capabilities. Owns product state and Bicep vocabulary. | +| `ui` | `src/ui/` | Workflow-neutral visual primitives and theme. Knows nothing about Bicep. | +| `lib` | `src/lib/` | Workflow-neutral infrastructure: headless graph engine, transport, policy, math. | -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. +Allowed dependency directions: -## Suggested Future Shape +```text +app -> features, ui, lib +features -> ui, lib, other features (barrel only, acyclic) +ui -> lib +lib -> lib +``` + +Forbidden: + +- `lib -> features`, `lib -> ui`, `lib -> app` +- `ui -> features`, `ui -> app` +- cycles between features + +Feature-to-feature imports are permitted but discouraged. They must go through the target feature's +`index.ts`, and they must import a component, a derived atom, or an action atom — never a raw writable +atom that both features write. When two features need the same visual element, move the element to +`ui/` rather than importing across the boundary. + +The distinction between `lib` and `features` is **not** "logic vs. UI". It is "would this still make +sense in an app that had nothing to do with Bicep?" A headless graph engine and a motion-preference +policy would. A pending-resource reconciler would not. + +## Naming + +These rules are enforced by review, not tooling. Where a rule and the code disagree, the code is wrong. + +- **Components**: PascalCase; the filename equals the exported component name. One primary component + per file. A file named for a plural or a category must be split, which is why `ControlPrimitives.tsx` + became `ui/Surface.tsx` and `ui/IconButton.tsx`. +- **Non-components** (hooks, atoms, utils, types): kebab-case. +- **Folders**: kebab-case. +- **Prefer one-word folders under `features/` and `lib/`**, and treat a compound name as a prompt to + check whether the folder is doing two jobs or borrowing a qualifier to dodge a collision. The + features `palette`, `controls`, `export`, `status` and `devtools` pass, as do the modules `graph`, + `messaging`, `accessibility` and `utils`. `resource-palette` did not earn its prefix — there is one + palette — so it is `palette`, and its components dropped the matching prefix with it. +- **Accuracy in the shared layer outranks brevity in the feature layer.** `features/deployment-graph` + keeps its qualifier because the one-word alternative would require renaming `lib/graph`, and `graph` + is precisely what that module is about. The qualifier is also not a dodge: "deployment graph" is the + Bicep product's own name for this concept, not a webview coinage. It is the JSON-RPC method + `bicep/getDeploymentGraph` in `Bicep.Cli/Rpc/ICliJsonRpcProtocol.cs` and it appears in the public API + surface of `Azure.Bicep.RpcClient`. A two-word name that names one real concept is fine; a two-word + name invented to avoid a collision is the smell. +- Name a feature for the capability it delivers, not the surface it draws on. `features/canvas` would + fail twice over: it describes the substrate rather than the capability, and it collides with the + `Canvas` component that `lib/graph` exports. `visualization`, `visualizer` and `designer` are + likewise unavailable — they name the entire app (`bicep.visualizer`, "Open Bicep Visualizer", "Bicep + visual designer"), so using one for a single feature would imply the others are outside it. +- Do not prefix a file with the folder that already contains it: inside `palette/`, a file named + `resource-palette-utils.ts` should just be `utils.ts`, and `use-palette-drag.ts` becomes + `use-drag.ts`. Components follow the same rule but keep whatever their exported name needs to stand + alone at the call site: `ResourcePalette.tsx` becomes `Palette.tsx` because `` still reads, + while `components/nodes/NodeContentProvider.tsx` keeps its `Node` because `` would + be meaningless where it is mounted. +- Put shared domain vocabulary in the feature's `types.ts`, alongside `atoms.ts` at the feature root. + Component props stay in the file that declares the component, because they are already colocated + with the only code that owns them — `PaletteProps` lives in `Palette.tsx`, not `types.ts`. A single + type used in one place does not need a home of its own: `ExportBackgroundMode` sits beside the export + atoms that consume it. +- Name a thing for what it is in the domain, not for its visual container or its layer. Prefer + `ResourceNodePreview` over `ResourcePreviewCard`: it is the preview of a resource node, and "card" + describes a border radius. Drop meaningless qualifiers such as the `Visual` in + `VisualResourceTypeReference` and `useApplyVisualGraph`. +- `lib/graph` owns the generic node containers (`BaseNode`, `AtomicNode`, `CompoundNode`). + `features/deployment-graph/components/nodes/` owns the Bicep content rendered inside them. Both may + use "Node"; the folder disambiguates. Prefer `ResourceNode` / `ModuleNode` over `ResourceDeclaration` + / `ModuleDeclaration`, because these render graph nodes, not source declarations. + +## Folder structure inside a feature + +**Every feature has the same shape.** A reader who opens one feature should be able to guess where +things are in any other, so features and `lib` modules organise their contents the same way: + +| Folder | Holds | +| ------------- | ------------------------------------------------------------------------- | +| `components/` | Components, including any that are only used inside the feature. | +| `hooks/` | Reusable `use-*` hooks. | +| `utils/` | Pure helpers with no React dependency. | +| `atoms.ts` | Feature state. Splits into `atoms/` only when it holds distinct concerns. | + +Files that belong to the feature as a whole stay at its root next to `index.ts`: `atoms.ts` for state, +`types.ts` for shared domain vocabulary, and concept-named files such as `animations.ts` for shared +transition constants. Only include the folders a feature actually needs, and add one when its first +file arrives rather than scaffolding it empty. + +This is deliberately uniform rather than minimal. A `components/` folder holding one file carries no +information on its own, and grouping by file type does split some cohesive subsystems: the graph sync +pipeline now spans `hooks/use-graph-update.ts` and `utils/layout-invalidation.ts`. The trade is +accepted because predictability across features is worth more than locally optimal grouping, and +because `lib/graph` already used `atoms/`, `components/`, `hooks/` — so the alternative was not "no +type folders" but "type folders in `lib`, flat features", which is the worse kind of inconsistency. + +Concept subfolders are still allowed **inside** a type folder when they name a real seam. +`components/nodes/` is the interchangeable content plugged into `lib/graph`'s node containers through +`renderContent`; those four files share a preview and a commit transition, and the grouping survives +because it says something the surrounding folder does not. + +Colocate tests in a `__tests__/` folder beside the code they cover, at whatever depth that code lives. + +## Shape + +Abbreviated: folders whose contents are unremarkable are shown by name only. ```text src/ - app/ + app/ # composition root App.tsx - providers.tsx - global-style.ts - node-config.ts + AppProviders.tsx + GlobalStyle.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 + deployment-graph/ # the Bicep deployment graph surface + components/ + DeploymentGraphView.tsx # canvas subtree, update loop, client-coordinate contract + PendingResourceLayer.tsx + ResourceCreationError.tsx + nodes/ # content rendered inside lib/graph node containers + NodeContentProvider.tsx # registers the renderers below + ResourceNode.tsx + ModuleNode.tsx + ResourceNodePreview.tsx + hooks/ + use-graph-update.ts # the update state machine + use-apply-graph.ts + utils/ + layout-invalidation.ts + graph-equality.ts + animations.ts + atoms.ts + + palette/ + components/ # Palette, PaletteContent, PaletteControls, + # PaletteDragOverlay, ResourceTypeGroups + hooks/ # use-drag, use-resource-type-catalog, + # use-resource-type-search, + # use-resource-creation-enablement atoms.ts - export/ - status/ - devtools/ + types.ts # namespace and resource-type vocabulary + + controls/ # components/ControlBar, hooks/use-reset-layout, atoms.ts + export/ # components/, utils/capture-element.ts, atoms.ts + status/ # components/StatusBar, atoms.ts + devtools/ # components/, hooks/, fakes/ 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/ + accessibility/ # motion policy state and host synchronisation + graph/ # atoms/, components/, hooks/, viewport.ts + messaging/ # messages.ts: protocol types and method constants only + utils/ # math/, text.ts, errors.ts + + ui/ # shared primitives, flat + IconButton.tsx + Surface.tsx + MotionAwareProgressBar.tsx + theme/ ``` -## Folder Roles +Create folders as their contents move. Do not scaffold empty directories. + +## Ownership + +### `features/deployment-graph` -### `features/nodes` +Owns everything Bicep-specific about the graph surface: + +- resource and module node presentation, and the mapping from generic node kind to that presentation; +- the notify-then-request graph update state machine, including single-in-flight and dirty-flag + convergence; +- patch application to the `lib/graph` atoms, layout centering, and layout invalidation; +- pending resource placement, preview rendering, and reconciliation to canonical nodes; +- the per-node creation transition and the creation error surface; +- fit-view and reset-layout behavior. + +It exposes a narrow contract to other features, stated in **client coordinates**: + +```ts +createResource(resourceType: ResourceTypeReference, clientPoint?: Point): Promise +canPlaceAt(clientPoint: Point): boolean +``` -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. +Omitting `clientPoint` means "use the feature's default placement", which is how keyboard activation +should create a resource. `features/deployment-graph` owns what that default is; today the viewport-center rule +lives in `App.tsx` instead. -### `features/edges` +Client coordinates are the boundary on purpose. Converting a pointer position into a graph position +needs the canvas rect and the pan/zoom transform, both of which are graph knowledge; handing those to a +caller would push the geometry into whichever feature happened to ask. `canPlaceAt` covers the one +question the palette legitimately has: whether a pointer release landed on the graph surface at all, +which decides between creating and silently cancelling a drag. -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. +`useGraphUpdate` is a single-instance state machine: it holds the client-side mirror of the server's +canonical graph, and a second instance would diverge from the first and corrupt patch application. +Instantiate it in `DeploymentGraphView` and pass its actions down as explicit props. Do not expose it +as a free `useCanvasActions()`-style hook that any component may call, because the plain-hook form of +that API silently creates a second state machine. If a context-backed accessor is ever needed, it must +wrap one provider-held instance. -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`. +This feature publishes user-facing status through a `features/status` action atom rather than writing +`errorCountAtom` and `hasNodesAtom` directly, so `features/status` keeps sole ownership of how status +is derived. -### `features/controls` +`PendingResourceLayer` positions a `ResourceNodePreview` per pending operation. `PaletteDragOverlay` +renders the same component, so `ResourceNodePreview` is the single definition of what an +about-to-exist resource looks like. -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/palette` -### `features/export`, `features/status`, `features/devtools` +Owns resource discovery and selection: feature enablement, namespace and resource-type loading, +search, palette interaction state, and pointer/keyboard initiation. It does not own graph patches, +pending-node reconciliation, placement math, or source edits. -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. +It works in client coordinates throughout. It should not import `viewportToGraphPoint`, hold a canvas +DOM handle, or read the pan/zoom transform; it hands `features/deployment-graph` a client point and lets it +decide where that lands in the graph. + +It must not define its own `ResourceTypeReference`; the protocol type in `lib/messaging` is the single +definition. Catalog vocabulary lives in `types.ts`, and `PaletteProps` sits in `Palette.tsx` beside +the component that takes it. + +### Other features + +- `features/controls`: toolbar composition and action availability. Graph, export, and status commands + are provided by their owning features; the control bar only arranges them. +- `features/export`: export state, preview, capture, and output options. +- `features/status`: user-facing graph and diagnostic status. +- `features/devtools`: development-only controls, fake data, and the fake message channel. + +## Shared infrastructure ### `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. +A headless, Bicep-agnostic graph engine, and it must stay one. It owns the canonical node and edge +atoms, boxes, bounds and focus; the generic node containers; `Canvas`, `Graph`, `CanvasBackground`, +`EdgeLayer`, `EdgeMarkerDefs` and `StraightEdge`; and the fit-view, drag and measurement hooks. -### `lib/protocol` +Keep the name. `graph` is what this module is actually about — nodes, edges and layout — and renaming +it to free the word for a feature would trade accuracy in the shared layer for brevity in one folder +name. The Bicep feature carries the qualifier instead, which is also where the qualifier is true. -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. +Bicep enters only through `nodeConfigAtom`, which is dependency injection working as intended. It +carries two things: `renderContent`, which maps a generic node kind to Bicep node content, and +`onNodeActivate`, which decides what a double-click means. `NodeContentProvider` fills both, hydrating +the config during render rather than in an effect — the default `renderContent` throws, so an effect +would be too late if a node mounted in the first pass. Scoping the write to the store from context also +keeps it out of module scope, where an earlier version ran at import time against the default store and +could not be undone or scoped to a test store. -### `lib/theming` +`onNodeActivate` exists because the engine had in fact grown host knowledge: `AtomicNode` and +`CompoundNode` each carried an identical double-click handler that cast node data to +`{ range, filePath }` and sent `revealFileRange` / `revealNodeSource` notifications directly. The +layer rule could not catch it, because `lib/graph -> lib/messaging` is a legal `lib -> lib` edge. A +second, narrower lint zone now forbids `lib/graph` from importing any messaging module at all, so the +claim at the top of this section is machine-checked rather than aspirational. -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. +`viewportToGraphPoint` belongs here, not in the palette. It converts client coordinates using the +pan/zoom transform, which is engine knowledge the palette merely consumes. -### `lib/utils` +### `lib/accessibility` + +Owns cross-cutting accessibility policy: the effective motion preference and its synchronization with +VS Code settings. This is policy infrastructure with no user-facing surface of its own, so it is `lib` +rather than a feature. Placing it in `lib` is also what lets `ui/MotionAwareProgressBar` read it +without a `ui -> features` violation. + +Component-specific keyboard and ARIA behavior stays with the component. + +### `lib/messaging` -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. +The transport contract only: protocol types, method-name constants, and payload shapes. The graph +update state machine, the graph apply path, and layout invalidation are Bicep workflow logic and +belong to `features/deployment-graph`. Relocating them is what removes both `lib -> features` inversions; no +callback indirection or inversion-of-control shim is needed. -### `ui/` +`messages.ts` is already sectioned by protocol area. Splitting it into `graph-protocol.ts`, +`resource-catalog-protocol.ts`, `resource-creation-protocol.ts` and `host-protocol.ts` is optional +polish, not a correctness fix. If done, keep re-exporting through `lib/messaging/index.ts`. -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. +### `ui` -## Incremental Refactor Path +Workflow-neutral visual primitives with more than one consumer, plus theme. Keep components directly +under `ui/` while the set is small. -Prefer small moves when touching related code: +Do not introduce `ui/primitives/`. It fails the same test as `components/`, and more sharply: `ui` is +_defined_ as workflow-neutral primitives, so the folder restates the layer's own name and partitions +nothing. It also never becomes correct with growth — at fifteen components the useful split is +`forms/`, `overlays/` or `menus/`, by concept, and every one of those is still a primitive. + +The resulting asymmetry between loose `.tsx` files and a `theme/` folder is intentional and +informative: `theme/` is a cohesive non-component subsystem, and the loose files are components. That +distinction is real, so the shape reflects it. Symmetry is not itself a goal. + +`ui/theme` owns theme tokens, theme objects, the styled-components module augmentation, and VS Code +theme synchronization. This makes `ui` stateful, which is allowed: theme is read by `app`, `features` +and `ui` alike, and depends on nothing above it. + +`Surface` and `IconButton` are deliberately named for what they are rather than where they came from. +As `ControlSurface` and `ControlButton` in `features/controls` they were already being used by the +palette launcher, so the "Control" prefix pointed at a layer they did not belong to. + +Their theme tokens have not followed yet: both still read `theme.controlBar.*`, so the palette launcher +styles itself from control-bar tokens, and `Palette` hand-rolls a second floating-panel style from +raw `var(--vscode-*)` values at a different radius. Unifying those on one `Surface` with neutrally +named tokens is the kind of duplication `ui/` exists to remove. + +Semantic cards, palette rows, status messages and export panels stay with their features. + +### `lib/utils` -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. +Generic and dependency-free: `math/`, `text.ts`, and `errors.ts`. Bicep-shaped helpers such as +deployment-graph equality belong to `features/deployment-graph`. + +## State ownership + +| State | Owner | +| --------------------------------------------------------- | --------------------------- | +| Canonical graph nodes, edges, boxes, bounds, focus | `lib/graph` | +| Node content renderer registration | `features/deployment-graph` | +| Pending resource placement and canonical-node correlation | `features/deployment-graph` | +| Per-node creation transition | `features/deployment-graph` | +| Palette interaction and resource catalog state | `features/palette` | +| Export workflow state | `features/export` | +| User-facing graph status | `features/status` | +| Effective motion policy | `lib/accessibility` | +| Active theme | `ui/theme` | + +Use Jotai for shared state that benefits from isolated subscriptions. Keep transient state local when +it has one owner. Across a boundary, expose derived values and action atoms rather than raw writable +atoms. + +Start with `atoms.ts`. Split into `atoms/` with an `index.ts` re-export only once it holds distinct +state concerns, and split it along the same concepts as the surrounding folders rather than into one +file per atom. + +## Public surface + +Each feature and each `lib` module exposes exactly one entry point: its `index.ts`. + +- Import through the barrel: `@/lib/graph`, `@/features/export`. +- Do not deep-import across a boundary. `@/lib/messaging/messages` is `@/lib/messaging`, and + `@/lib/utils/math/geometry` is `@/lib/utils`. +- Deep imports within the same module are fine. +- Feature barrels export the intended surface, not `export *` over every file. The `lib` and `ui` + barrels keep `export *`: they are broad shared surfaces with many legitimate consumers, so + enumerating them would be churn without a boundary benefit. + +## Enforcement + +Structure rules that are not machine-checked decay. `src/vscode-bicep-ui/eslint.config.mjs` carries an +import-boundary rule scoped to this app, built on the core `no-restricted-imports` rule with flat-config +`files` zones, so it needs no extra plugin: + +- `src/lib/**` may not import `src/features/**`, `src/ui/**`, `src/app/**` +- `src/ui/**` may not import `src/features/**`, `src/app/**` +- `src/features/**` may not import `src/app/**` +- `src/lib/graph/**` may not import any messaging module + +The last one is not a layer rule. It exists because the layer rules alone permit `lib -> lib`, which +let the graph engine acquire host-protocol knowledge unnoticed. When a module's value depends on it +_not_ knowing something, say so explicitly rather than trusting the layer diagram to imply it. + +The rules are registered at `error`. The lint script also runs with `--max-warnings 0` and +`--report-unused-disable-directives`, so neither a warning nor a stale suppression can accumulate. + +There was no boundary rule at all before this, which is why two `lib -> features` imports were able to +land. + +## Deliberate non-moves + +- **`StraightEdge` stays in `lib/graph`.** It computes a segment between two box centers and reads a + theme token. It has no Bicep knowledge, so moving it into a feature would relocate generic code into + the product layer and invert the dependency rule. +- **`Canvas` and `CanvasBackground` stay in `lib/graph`.** They are generic pan/zoom surfaces. + `DeploymentGraphView` is the Bicep composition that mounts them, and it is named for the capability + rather than the surface precisely so the two do not blur together. +- **Generic geometry stays in `lib/utils/math`.** `Point`, `Box` and box-segment intersection are + ordinary math with several consumers. Only transform-aware conversion lives in `lib/graph`. +- **`useResetLayout` stays beside `ControlBar`.** It is a generic in-flight dedupe wrapper with no + graph knowledge and a single consumer, so it is neither graph-sync behaviour nor shared + infrastructure. +- **Graph actions stay explicit props.** They are drilled exactly one level, from `DeploymentGraphView` + to `ControlBar` and `Palette`. Props keep the dependency visible and testable, and a + free-standing `useCanvasActions()` hook would invite a second `useGraphUpdate` instance, which is a + correctness bug rather than a style preference. + +## Known tensions + +Places where the structure is a considered compromise rather than an ideal, recorded so they are not +rediscovered as bugs. + +**`features/deployment-graph` is much larger than any other feature.** It holds around eighteen files +while the rest hold three to ten, and it owns presentation, host sync, mutation and placement. The +cleaner decomposition would add a fourth layer between `lib` and `features` for logic that is +Bicep-aware but headless — the sync pipeline, protocol mapping and graph equality — leaving `features` +strictly user-facing. That is rejected here only on size: a four-layer model for a sixty-file app costs +more in indirection and ceremony than it returns. If the sync pipeline keeps growing, promoting +`hooks/use-graph-update.ts` and `utils/` into a `src/domain/` layer is the intended next step rather +than a reversal. + +**`ui/MotionAwareProgressBar` reads global state.** A primitive that reaches into an atom is not +really a primitive. The purer shape is a `ProgressBar` taking an `animated` prop, with callers reading +the motion policy. With a single consumer today the wrapper is a reasonable convenience, but a second +consumer with different needs should trigger the split rather than another variant. + +**`features/status` is thin** — three files behind one status bar. It stays separate because +`features/controls` derives action availability from it and merging the two would create the +bidirectional coupling the layering rules exist to prevent. + +## Tests + +Run `npm run build`, `npm run lint` and `npm test` for any change; run `npm run e2e` when touching +graph updates, placement, or app composition. + +Most behavioural coverage is end-to-end in `e2e/` (Playwright), not unit tests: pointer placement, +keyboard placement, failed edits, concurrent document changes and pending-to-canonical reconciliation +all live in `e2e/resource-creation.spec.ts`. The unit tests cover the two pieces with logic worth +isolating, `features/deployment-graph/__tests__/atoms.test.ts` and +`features/deployment-graph/utils/__tests__/layout-invalidation.test.ts`. + +Tests live in a `__tests__/` folder beside the code they cover, at whatever depth that code lives. +`tsconfig.app.json` already excludes them from the app build. + +Prefer assertions that cannot race. The dev fake channel delays resource-catalog responses to exercise +loading states, and its `catalogDelay` query parameter lets a test hold that state open rather than +competing with the default timing. + +## Possible next steps + +Not required, and not worth doing without a reason: + +- Split `lib/messaging/messages.ts` by protocol area behind the existing barrel, if it keeps growing. +- Give `Surface` and `IconButton` neutral theme tokens. Both still read `theme.controlBar.*`, so the + palette launcher styles itself from control-bar tokens, and `Palette` hand-rolls a second + floating-panel style from raw `var(--vscode-*)` values at a different radius. Unifying those is the + kind of duplication `ui/` exists to remove. +- Replace the two module-scope `getDefaultStore()` handles in `features/deployment-graph/hooks` with + the store from context, so the sync pipeline can be driven by a scoped store in tests. +- Extract `lib/graph` into `packages/` **when a second consumer appears, not before**. It is already + prepared: a clean barrel, a documented injection seam (`nodeConfigAtom`), and no Bicep knowledge, + enforced by lint. Deliberately not done yet, because today the visual designer is the only consumer — + neither `deploy-pane` nor `resource-type-explorer` references a graph concept — and a package would + cost real friction: `packages/components` resolves through `dist/`, with no source alias in the app's + vite config, so every engine edit would need a package rebuild. The likely second consumer is the + Bicep playground, which is a larger job than a file move: `src/playground` sits outside this npm + workspace and shares none of the engine's runtime dependencies (jotai, styled-components, motion). + The engine's own dependency on `lib/utils` geometry has to be resolved at the same time. + +## Related documents + +This file is the single source of truth for module structure, dependency direction and naming. + +- `.github/instructions/state-management.instructions.md` covers Jotai conventions and defers to this + file for layout. Keep it that way: add atom guidance there, structural guidance here. +- `resource-creation-design.md` and `visual-graph-protocol.md` describe behaviour and protocol, not + structure. Their file-path references predate this layout and are stale in places. 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..424574e42c1 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(); 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..437ddc81bda 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,35 @@ 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 the animation state in a single round trip, and report a sentinel rather than a boolean + // 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 progressAnimation = await progress.evaluate((element) => { + const indicator = element.shadowRoot?.querySelector(".indicator"); + + return indicator ? getComputedStyle(indicator).animationName : "indicator-missing"; + }); + + expect(progressAnimation).not.toBe("indicator-missing"); + expect(progressAnimation).not.toBe("none"); + expect(progressAnimation).not.toBe(""); + }); + 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"); 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..e607a4cae90 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/app/App.tsx @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { PanZoomProvider } from "@vscode-bicep-ui/components"; +import { useAtomValue } from "jotai"; +import { styled } from "styled-components"; +import { ControlBar } from "@/features/controls"; +import { DeploymentGraphView, ResourceCreationError } from "@/features/deployment-graph"; +import { + ExportAreaCover, + ExportAreaPreview, + ExportOverlay, + isExportCanvasCoverVisibleAtom, + isExportPreviewVisibleAtom, +} from "@/features/export"; +import { Palette } from "@/features/palette"; +import { StatusBar } from "@/features/status"; +import { AppProviders } from "./AppProviders"; + +const $AppContainer = styled.div` + flex: 1 1 auto; + position: relative; + overflow: hidden; +`; + +const $ControlBarContainer = styled.div` + position: absolute; + top: 16px; + right: 16px; + z-index: 100; +`; + +function ExportUILayer() { + const isExportPreviewVisible = useAtomValue(isExportPreviewVisibleAtom); + + if (!isExportPreviewVisible) { + return null; + } + + return ( + <> + + + + ); +} + +function ExportCanvasCoverLayer() { + const isExportCanvasCoverVisible = useAtomValue(isExportCanvasCoverVisibleAtom); + + if (!isExportCanvasCoverVisible) { + return null; + } + + return ; +} + +export function App() { + return ( + + <$AppContainer data-testid="app-root"> + + }> + {({ canPlaceAt, createResource, resetLayout }) => ( + <> + <$ControlBarContainer> + + + + + + )} + + + + + + + ); +} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/app/AppProviders.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/app/AppProviders.tsx new file mode 100644 index 00000000000..1475c8a52ae --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/app/AppProviders.tsx @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ReactNode } from "react"; + +import { WebviewMessageChannelProvider } from "@vscode-bicep-ui/messaging"; +import { Suspense } from "react"; +import { ThemeProvider } from "styled-components"; +import { loadDevAppShell } from "@/features/devtools"; +import { useMotionPolicySync } from "@/lib/accessibility"; +import { useTheme } from "@/ui/theme"; +import { GlobalStyle } from "./GlobalStyle"; + +const DevAppShell = loadDevAppShell(); + +function ThemedApp({ children }: { children: ReactNode }) { + const theme = useTheme(); + useMotionPolicySync(); + + return ( + + + {children} + + ); +} + +/** + * The provider stack. + * + * In dev, the lazy-loaded DevAppShell supplies a FakeMessageChannel, the DevToolbar, and the + * message-channel context. In production we render straight into the provider, which creates its own + * channel via acquireVsCodeApi. + */ +export function AppProviders({ children }: { children: ReactNode }) { + const themed = {children}; + + if (DevAppShell) { + return ( + + {themed} + + ); + } + + return {themed}; +} 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 100% 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 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 72% 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..874e614841b 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 @@ -6,9 +6,9 @@ import { useAtomValue, useSetAtom } from "jotai"; import { styled } from "styled-components"; 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 { IconButton, Surface } from "@/ui"; +import { graphControlAvailabilityAtom } from "../atoms"; +import { useResetLayout } from "../hooks/use-reset-layout"; const $Divider = styled.div` height: 1px; @@ -28,14 +28,14 @@ export function ControlBar({ requestLayout }: ControlBarProps) { const openExportOverlay = useSetAtom(openExportOverlayAtom); return ( - - zoomIn(1.5)} title="Zoom In" aria-label="Zoom In" data-testid="control-zoom-in"> + + 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 +62,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-layout.ts similarity index 100% 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-layout.ts 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/resource-creation/__tests__/atoms.test.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/__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/deployment-graph/__tests__/atoms.test.ts 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/deployment-graph/animations.ts similarity index 100% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/resource-creation/animations.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/animations.ts 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/deployment-graph/atoms.ts similarity index 73% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/resource-creation/atoms.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/atoms.ts index 4a71e579544..501775bd64e 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-creation/atoms.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/atoms.ts @@ -1,15 +1,15 @@ // 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 type { ResourceTypeReference } from "@/lib/messaging"; +import type { Point } from "@/lib/utils"; import { atom } from "jotai"; import { atomFamily } from "jotai-family"; export interface PendingResource { operationId: string; - resourceType: VisualResourceTypeReference; + resourceType: ResourceTypeReference; origin: Point; expectedNodeId?: string; } diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/DeploymentGraphView.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/DeploymentGraphView.tsx new file mode 100644 index 00000000000..d207395d8da --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/DeploymentGraphView.tsx @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ReactNode } from "react"; +import type { DocumentDidChangePayload, ResourceTypeReference } from "@/lib/messaging"; +import type { Point } from "@/lib/utils"; + +import { useGetPanZoomDimensions, useGetPanZoomTransform } from "@vscode-bicep-ui/components"; +import { useWebviewMessageChannel, useWebviewNotification } from "@vscode-bicep-ui/messaging"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { styled, ThemeProvider } from "styled-components"; +import { effectiveExportThemeAtom, exportCanvasElementAtom, exportFileStemAtom } from "@/features/export"; +import { Canvas, Graph, useFitViewToBounds, viewportToGraphPoint } from "@/lib/graph"; +import { DOCUMENT_DID_CHANGE_NOTIFICATION, READY_NOTIFICATION } from "@/lib/messaging"; +import { useGraphUpdate } from "../hooks/use-graph-update"; +import { NodeContentProvider } from "./nodes/NodeContentProvider"; +import { PendingResourceLayer } from "./PendingResourceLayer"; + +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"; +} + +export interface DeploymentGraphSurface { + /** + * Create a resource at a client-coordinate point. Omit `clientPoint` to use the surface's default + * placement, which is how keyboard activation creates a resource. + */ + createResource: (resourceType: ResourceTypeReference, clientPoint?: Point) => Promise; + /** Whether a client-coordinate point falls on the graph surface. */ + canPlaceAt: (clientPoint: Point) => boolean; + resetLayout: () => Promise; +} + +export interface DeploymentGraphViewProps { + /** Rendered inside the canvas, beneath the graph, for export overlays. */ + canvasOverlay?: ReactNode; + children: (surface: DeploymentGraphSurface) => ReactNode; +} + +/** + * The Bicep deployment graph surface: owns the update loop, the canvas subtree, and the pending + * resource layer. + * + * The surface handed to `children` is stated in client coordinates on purpose. Converting a pointer + * position into a graph position needs the canvas rect and the pan/zoom transform, both of which are + * graph knowledge; exposing them would push that geometry into whichever feature happened to call. + * + * Actions are passed as explicit props rather than exposed as a free hook because `useGraphUpdate` is + * a single-instance state machine holding the client's mirror of the server's canonical graph, and a + * second instance would diverge and corrupt patch application. + */ +export function DeploymentGraphView({ canvasOverlay, children }: DeploymentGraphViewProps) { + 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, + createResource: createResourceAtOrigin, + resetLayout, + } = useGraphUpdate(getViewportCenter, fitViewToBounds); + const messageChannel = useWebviewMessageChannel(); + const exportTheme = useAtomValue(effectiveExportThemeAtom); + const setExportFileStem = useSetAtom(exportFileStemAtom); + const setExportCanvasElement = useSetAtom(exportCanvasElementAtom); + const [canvasElement, setCanvasElement] = useState(null); + + 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 handleCanvasRef = useCallback( + (element: HTMLDivElement | null) => { + setCanvasElement(element); + setExportCanvasElement(element); + }, + [setExportCanvasElement], + ); + + const canPlaceAt = 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 createResourceAtOrigin(resourceType, origin); + } + }, + [canvasElement, createResourceAtOrigin, getPanZoomTransform], + ); + + const surface = useMemo( + () => ({ createResource, canPlaceAt, resetLayout }), + [canPlaceAt, createResource, resetLayout], + ); + + return ( + + + <$CanvasWrapper ref={handleCanvasRef}> + + {canvasOverlay} + + + + + + {children(surface)} + + ); +} 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/deployment-graph/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/deployment-graph/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/deployment-graph/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/deployment-graph/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/deployment-graph/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/deployment-graph/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/deployment-graph/components/nodes/ModuleNode.tsx similarity index 96% 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/deployment-graph/components/nodes/ModuleNode.tsx index 73d931a50bc..dae2c62a39e 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/visualization/ModuleDeclaration.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/nodes/ModuleNode.tsx @@ -8,7 +8,7 @@ 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; @@ -92,7 +92,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/deployment-graph/components/nodes/NodeContentProvider.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/nodes/NodeContentProvider.tsx new file mode 100644 index 00000000000..e399e0b8e46 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/nodes/NodeContentProvider.tsx @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ReactNode } from "react"; +import type { NodeContentRenderProps, NodeKind } from "@/lib/graph"; +import type { Range } from "@/lib/messaging"; +import type { ModuleNodeProps } from "./ModuleNode"; +import type { ResourceNodeProps } from "./ResourceNode"; + +import { useWebviewMessageChannel } from "@vscode-bicep-ui/messaging"; +import { useStore } from "jotai"; +import { useHydrateAtoms } from "jotai/utils"; +import { useCallback } from "react"; +import { nodeConfigAtom } from "@/lib/graph"; +import { REVEAL_FILE_RANGE_NOTIFICATION, REVEAL_NODE_SOURCE_NOTIFICATION } from "@/lib/messaging"; +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; + +/** The source-location fields a node may carry. Absent on the server-driven path. */ +type NodeSourceLocation = { range?: Range; filePath?: string }; + +function renderNodeContent(kind: NodeKind, { id, data }: NodeContentRenderProps) { + if (kind === "compound") { + return ; + } + + return ; +} + +/** + * Teaches the generic graph engine how to render Bicep node content and what activating a node means. + * + * `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); + const messageChannel = useWebviewMessageChannel(); + + const revealNodeSource = useCallback( + (id: string, data: unknown) => { + const { range, filePath } = (data ?? {}) as NodeSourceLocation; + + if (range && filePath) { + // Legacy push path: the node still carries an inline source location. + messageChannel.sendNotification({ + method: REVEAL_FILE_RANGE_NOTIFICATION, + params: { filePath, range }, + }); + return; + } + + // Server-driven path: source location is resolved on demand by node id. + messageChannel.sendNotification({ + method: REVEAL_NODE_SOURCE_NOTIFICATION, + params: { nodeId: id }, + }); + }, + [messageChannel], + ); + + useHydrateAtoms([ + [ + nodeConfigAtom, + { + ...defaults, + padding: { ...defaults.padding, top: COMPOUND_NODE_LABEL_INSET }, + renderContent: renderNodeContent, + onNodeActivate: revealNodeSource, + }, + ], + ] 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/deployment-graph/components/nodes/ResourceNode.tsx similarity index 91% 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/deployment-graph/components/nodes/ResourceNode.tsx index d32dead02a4..6692ed9d7de 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/visualization/ResourceDeclaration.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/nodes/ResourceNode.tsx @@ -8,16 +8,13 @@ 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 { RESOURCE_CREATION_TRANSITION } from "../../animations"; +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; @@ -29,7 +26,7 @@ export interface ResourceDeclarationProps { }; } -const $ResourceDeclaration = styled(motion.div)<{ +const $ResourceNode = styled(motion.div)<{ $hasError?: boolean; $isCollection?: boolean; $isFocused?: boolean; @@ -130,7 +127,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 +138,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,7 +151,7 @@ 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} @@ -179,6 +176,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/deployment-graph/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/deployment-graph/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/deployment-graph/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/lib/messaging/use-visual-graph.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/hooks/use-apply-graph.ts similarity index 95% 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/deployment-graph/hooks/use-apply-graph.ts index c9faa0c7f50..9d1cc9c5224 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/deployment-graph/hooks/use-apply-graph.ts @@ -3,14 +3,13 @@ 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 { DeploymentGraph, NodeLayout } from "@/lib/messaging"; +import type { Box, Point } from "@/lib/utils"; import { getDefaultStore, useSetAtom } from "jotai"; import { animate, transform } from "motion"; import { useCallback, useRef } from "react"; -import { errorCountAtom, hasNodesAtom } from "@/features/status"; +import { reportGraphStatusAtom } from "@/features/status"; import { addAtomicNodeAtom, addCompoundNodeAtom, @@ -20,8 +19,8 @@ import { nodesByIdAtom, removeNodesAtom, } from "@/lib/graph"; -import { isDeploymentGraphEqual } from "@/lib/utils/deployment-graph-equality"; -import { translateBox } from "@/lib/utils/math"; +import { translateBox } from "@/lib/utils"; +import { isDeploymentGraphEqual } from "../utils/graph-equality"; const store = getDefaultStore(); @@ -114,7 +113,7 @@ function snapshotNodePositions(): Map { return positions; } -export function useApplyVisualGraph(getViewportCenter: () => Point) { +export function useApplyGraph(getViewportCenter: () => Point) { const setEdgesAtom = useSetAtom(edgesAtom); const addAtomicNode = useSetAtom(addAtomicNodeAtom); const addCompoundNode = useSetAtom(addCompoundNodeAtom); @@ -125,9 +124,11 @@ export function useApplyVisualGraph(getViewportCenter: () => Point) { 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); + // Report the graph facts that features/status derives its display from. + store.set(reportGraphStatusAtom, { + errorCount: graph?.errorCount ?? 0, + hasNodes: (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 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/features/deployment-graph/hooks/use-graph-update.ts similarity index 94% rename from src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/use-graph-update.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/hooks/use-graph-update.ts index 12ff0efe090..472e6f0d34d 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/use-graph-update.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/hooks/use-graph-update.ts @@ -1,11 +1,9 @@ // 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, + CreateResourceRequest, + CreateResourceResponse, DeploymentGraph, GetGraphLayoutRequest, GetGraphLayoutResponse, @@ -18,21 +16,18 @@ import type { NodeLayout, Range, RenderedGraph, - VisualResourceTypeReference, -} from "./messages"; + ResourceTypeReference, +} from "@/lib/messaging"; +import type { Box, Point } from "@/lib/utils"; 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"; +import { CREATE_RESOURCE_REQUEST, GET_GRAPH_LAYOUT_REQUEST, GET_GRAPH_UPDATE_REQUEST } from "@/lib/messaging"; +import { pendingResourcesAtom, resourceCreationErrorAtom, resourceNodeIsCommittingAtomFamily } from "../atoms"; +import { patchMayAffectLayout, renderedGraphsEqual } from "../utils/layout-invalidation"; +import { applyGraphLayout, useApplyGraph } from "./use-apply-graph"; const store = getDefaultStore(); @@ -223,7 +218,7 @@ function centerGraphLayout( */ export interface GraphUpdateActions { requestGraphUpdate: () => Promise; - createResource: (resourceType: VisualResourceTypeReference, origin: Point) => Promise; + createResource: (resourceType: ResourceTypeReference, 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 @@ -238,7 +233,7 @@ export function useGraphUpdate( getViewportCenter: () => Point, fitViewToBounds: (bounds: Box) => void, ): GraphUpdateActions { - const applyGraph = useApplyVisualGraph(getViewportCenter); + const applyGraph = useApplyGraph(getViewportCenter); const messageChannel = useWebviewMessageChannel(); const clientGraphRef = useRef(createClientGraph()); const lastLayoutInputRef = useRef(null); @@ -414,7 +409,7 @@ export function useGraphUpdate( }, [requestGraphUpdate]); const createResource = useCallback( - (resourceType: VisualResourceTypeReference, origin: Point): Promise => { + (resourceType: ResourceTypeReference, origin: Point): Promise => { const operationId = window.crypto.randomUUID(); store.set(pendingResourcesAtom, (pending) => [...pending, { operationId, resourceType, origin }]); store.set(resourceCreationErrorAtom, null); @@ -423,12 +418,12 @@ export function useGraphUpdate( mutationInFlightRef.current = true; try { - const request: CreateVisualResourceRequest = { + const request: CreateResourceRequest = { version: 1, operationId, resourceType, }; - const response = await messageChannel.sendRequest({ + const response = await messageChannel.sendRequest({ method: CREATE_RESOURCE_REQUEST, params: request, }); diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/index.ts new file mode 100644 index 00000000000..c0020cc39f0 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/index.ts @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export { DeploymentGraphView, type DeploymentGraphSurface } from "./components/DeploymentGraphView"; +export { ResourceCreationError } from "./components/ResourceCreationError"; +export { RESOURCE_CREATION_TRANSITION } from "./animations"; +export { ResourceNodePreview } from "./components/nodes/ResourceNodePreview"; 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/features/deployment-graph/utils/__tests__/layout-invalidation.test.ts similarity index 99% rename from src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/__tests__/layout-invalidation.test.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/utils/__tests__/layout-invalidation.test.ts index 0cd46f43ac0..e3ffa078340 100644 --- 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/features/deployment-graph/utils/__tests__/layout-invalidation.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { GraphNode, GraphPatch, RenderedGraph, RenderedGraphNode } from "../messages"; +import type { GraphNode, GraphPatch, RenderedGraph, RenderedGraphNode } from "@/lib/messaging"; import { describe, expect, it } from "vitest"; import { patchMayAffectLayout, renderedGraphsEqual } from "../layout-invalidation"; 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/features/deployment-graph/utils/graph-equality.ts similarity index 95% rename from src/vscode-bicep-ui/apps/visual-designer/src/lib/utils/deployment-graph-equality.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/utils/graph-equality.ts index 2355181523b..6eafd92e8c5 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/utils/deployment-graph-equality.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/utils/graph-equality.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { DeploymentGraph } from "@/lib/messaging/messages"; +import type { DeploymentGraph } from "@/lib/messaging"; /** * Compare two deployment graphs for structural equality, ignoring 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/features/deployment-graph/utils/layout-invalidation.ts similarity index 97% rename from src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/layout-invalidation.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/utils/layout-invalidation.ts index be9fec5067e..b70c3c992c1 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/layout-invalidation.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/utils/layout-invalidation.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { GraphNode, GraphPatch, RenderedGraph } from "./messages"; +import type { GraphNode, GraphPatch, RenderedGraph } from "@/lib/messaging"; /** * The node metadata fields that influence a node's rendered size, and therefore the layout. diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/DevAppShell.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/components/DevAppShell.tsx similarity index 94% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/DevAppShell.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/components/DevAppShell.tsx index 2ec389890da..ae2f9f08f08 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/DevAppShell.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/components/DevAppShell.tsx @@ -5,8 +5,8 @@ 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; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/DevToolbar.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/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/features/devtools/components/DevToolbar.tsx index fac4f14ea5d..1f82b091741 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/DevToolbar.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/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("@/lib/messaging").DeploymentGraph) => import("@/lib/messaging").DeploymentGraph, ) => { 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/features/devtools/fakes/fake-graph-differ.ts similarity index 100% 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/features/devtools/fakes/fake-graph-differ.ts 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/features/devtools/fakes/fake-message-channel.ts similarity index 96% 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/features/devtools/fakes/fake-message-channel.ts index 13ec14129e0..4d7397ce7e1 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/features/devtools/fakes/fake-message-channel.ts @@ -3,8 +3,8 @@ import type { WebviewNotificationCallback, WebviewNotificationMessage } from "@vscode-bicep-ui/messaging"; import type { - CreateVisualResourceRequest, - CreateVisualResourceResponse, + CreateResourceRequest, + CreateResourceResponse, DeploymentGraph, GetGraphLayoutRequest, GetGraphLayoutResponse, @@ -17,11 +17,13 @@ import { DOCUMENT_DID_CHANGE_NOTIFICATION, GET_GRAPH_LAYOUT_REQUEST, GET_GRAPH_UPDATE_REQUEST, + GET_RESOURCE_TYPE_NAMESPACES_REQUEST, + LOAD_RESOURCE_TYPE_CATALOG_REQUEST, READY_NOTIFICATION, REVEAL_FILE_RANGE_NOTIFICATION, REVEAL_NODE_SOURCE_NOTIFICATION, SHOW_PROBLEMS_PANEL_NOTIFICATION, -} from "@/lib/messaging/messages"; +} from "@/lib/messaging"; import { diffGraph, layoutGraph } from "./fake-graph-differ"; const FAKE_FILE_PATH = "file:///main.bicep"; @@ -601,6 +603,23 @@ 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; @@ -837,7 +856,7 @@ export class FakeMessageChannel { }, ]; - if (requestMessage.method === "resourceTypeCatalog/namespaces") { + if (requestMessage.method === GET_RESOURCE_TYPE_NAMESPACES_REQUEST) { return new Promise((resolve) => { setTimeout( () => @@ -853,7 +872,7 @@ export class FakeMessageChannel { }); } - if (requestMessage.method === "resourceTypeCatalog/load") { + if (requestMessage.method === LOAD_RESOURCE_TYPE_CATALOG_REQUEST) { const { providerNamespace, query, loadAll } = (requestMessage.params ?? {}) as { providerNamespace?: string; query?: string; @@ -873,7 +892,7 @@ 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)); }); } @@ -894,7 +913,7 @@ export class FakeMessageChannel { } if (requestMessage.method === CREATE_RESOURCE_REQUEST) { - const request = requestMessage.params as CreateVisualResourceRequest; + const request = requestMessage.params as CreateResourceRequest; 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); @@ -929,7 +948,7 @@ export class FakeMessageChannel { expectedNodeId: symbolicName, symbolicName, unresolvedRequiredProperties: ["name"], - } satisfies CreateVisualResourceResponse as T); + } satisfies CreateResourceResponse as T); }, 300); }); } 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/features/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/features/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/features/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/features/devtools/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/index.ts index 6aefdabd4c0..45db780e252 100644 --- 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 @@ -16,5 +16,5 @@ export function loadDevAppShell(): LazyExoticComponent import("./DevAppShell").then((m) => ({ default: m.DevAppShell }))); + return lazy(() => import("./components/DevAppShell").then((m) => ({ default: m.DevAppShell }))); } 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..65c8db1c33e 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,11 @@ // 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 { activeThemeAtom, getThemeByName } from "@/ui/theme"; + +export type ExportBackgroundMode = "transparent" | "solid"; export const DEFAULT_EXPORT_FILE_STEM = "bicep-graph"; export const DEFAULT_EXPORT_PADDING = 40; 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 95% 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..d17497cfefb 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,7 +4,7 @@ import { useAtomValue } from "jotai"; import { useTheme } from "styled-components"; import { graphBoundsAtom } from "@/lib/graph"; -import { exportPaddingAtom } from "./atoms"; +import { exportPaddingAtom } from "../atoms"; /** * Solid background rectangle rendered inside PanZoom (graph-space) diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/export/ExportAreaPreview.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/export/components/ExportAreaPreview.tsx similarity index 98% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/export/ExportAreaPreview.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/features/export/components/ExportAreaPreview.tsx index b4e9a72fa86..ea1498f1473 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/export/ExportAreaPreview.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/export/components/ExportAreaPreview.tsx @@ -6,7 +6,7 @@ import { VscodeBadge } from "@vscode-elements/react-elements"; import { useAtomValue } from "jotai"; import { styled, useTheme } from "styled-components"; import { graphBoundsAtom } from "@/lib/graph"; -import { exportPaddingAtom } from "./atoms"; +import { exportPaddingAtom } from "../atoms"; const $Overlay = styled.div` position: absolute; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/export/ExportOverlay.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/export/components/ExportOverlay.tsx similarity index 95% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/export/ExportOverlay.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/features/export/components/ExportOverlay.tsx index 4f7bb2e52c1..1fb7695b46c 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/export/ExportOverlay.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/export/components/ExportOverlay.tsx @@ -4,7 +4,7 @@ import { useSetAtom } from "jotai"; import { useCallback, useLayoutEffect } from "react"; import { styled } from "styled-components"; -import { closeExportOverlayAtom } from "./atoms"; +import { closeExportOverlayAtom } from "../atoms"; import { ExportToolbar } from "./ExportToolbar"; const $OverlayContainer = styled.div` 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 98% 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..fc4a5f6c624 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 */ 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..cd2eb5e2d64 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,7 @@ // 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 * 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 100% 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 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..9c1b32b57c4 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 "@/lib/messaging"; 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/palette/components/Palette.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/Palette.tsx new file mode 100644 index 00000000000..12c4c7a0b31 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/Palette.tsx @@ -0,0 +1,236 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { DeploymentGraphSurface } from "@/features/deployment-graph"; +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 { RESOURCE_CREATION_TRANSITION } from "@/features/deployment-graph"; +import { IconButton, Surface } from "@/ui"; +import { usePaletteDrag } from "../hooks/use-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"; + +interface PaletteProps { + createResource: DeploymentGraphSurface["createResource"]; + canPlaceAt: DeploymentGraphSurface["canPlaceAt"]; +} +const MotionSurface = motion.create(Surface); + +const $PaletteLauncher = styled(MotionSurface)` + 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({ createResource, canPlaceAt }: PaletteProps) { + const [isOpen, setIsOpen] = useState(false); + 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(canPlaceAt, 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={RESOURCE_CREATION_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(props: PaletteProps) { + 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..88ff8c943d8 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 "@/lib/messaging"; +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..713d27c4c2e 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 "@/ui"; 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..df74cf14098 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/deployment-graph"; +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..b6c9d38509b 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 "@/lib/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, + canPlaceAt: (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 (canPlaceAt({ 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]); + }, [canPlaceAt, cancelDrag, onDrop, setDragState]); const startDrag = useCallback( (item: PaletteDragState["item"], event: ReactPointerEvent) => { 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/palette/hooks/use-resource-creation-enablement.ts similarity index 100% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/use-resource-creation-enablement.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/features/palette/hooks/use-resource-creation-enablement.ts 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..76ce5fc55c8 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/hooks/use-resource-type-catalog.ts @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ResourceTypeCatalog, ResourceTypeNamespace } from "../types"; + +import { useWebviewMessageChannel, useWebviewNotification } from "@vscode-bicep-ui/messaging"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + DOCUMENT_DID_CHANGE_NOTIFICATION, + GET_RESOURCE_TYPE_NAMESPACES_REQUEST, + LOAD_RESOURCE_TYPE_CATALOG_REQUEST, +} from "@/lib/messaging"; + +/** Edits arrive in bursts, so refreshes are debounced. The first load is immediate. */ +const REFRESH_DEBOUNCE_MS = 250; + +interface ResourceTypeNamespaceCatalog { + catalogId: string; + namespaces: ResourceTypeNamespace[]; +} + +type NamespaceCatalogState = + | { status: "loading" } + | { status: "loaded"; catalog: ResourceTypeNamespaceCatalog } + | { 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 messageChannel = useWebviewMessageChannel(); + 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); + }, []); + + useWebviewNotification( + DOCUMENT_DID_CHANGE_NOTIFICATION, + useCallback(() => refresh(), [refresh]), + ); + + useEffect(() => { + const requestGeneration = ++namespaceRequestGenerationRef.current; + const timeout = window.setTimeout( + () => { + setNamespaceCatalogState((current) => (current.status === "loaded" ? current : { status: "loading" })); + void messageChannel + .sendRequest({ method: GET_RESOURCE_TYPE_NAMESPACES_REQUEST }) + .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); + }, [messageChannel, refreshGeneration]); + + const requestCatalog = useCallback( + async (params: { providerNamespace?: string; query?: string; loadAll?: boolean }): Promise => { + const catalog = await messageChannel.sendRequest({ + method: LOAD_RESOURCE_TYPE_CATALOG_REQUEST, + 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; + }, + [messageChannel, 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..57b01c54f75 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 "@/lib/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/export/types.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/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/palette/index.ts index e052e7574e8..da0b480dc16 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/export/types.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/index.ts @@ -1,4 +1,4 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -export type ExportBackgroundMode = "transparent" | "solid"; +export { Palette } from "./components/Palette"; 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/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/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/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 98% 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..249c526d691 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 @@ -6,7 +6,7 @@ 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 { graphStatusAtom } from "../atoms"; const $StatusBarContainer = styled.div` position: absolute; 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..91a969c131e 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,5 @@ // 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"; 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/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/features/accessibility/atoms.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/atoms.ts similarity index 100% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/accessibility/atoms.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/atoms.ts diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/accessibility/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/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/lib/accessibility/index.ts index 4cc08589b43..4d6e7a4f617 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/accessibility/index.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/index.ts @@ -1,5 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -export * from "./MotionAwareProgressBar"; +export * from "./atoms"; export * from "./use-motion-policy-sync"; 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/lib/accessibility/use-motion-policy-sync.ts similarity index 90% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/accessibility/use-motion-policy-sync.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/use-motion-policy-sync.ts index c2b7eb2bc07..5ef78159450 100644 --- 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/lib/accessibility/use-motion-policy-sync.ts @@ -6,10 +6,7 @@ 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 { GET_MOTION_POLICY_REQUEST, MOTION_POLICY_DID_CHANGE_NOTIFICATION } from "@/lib/messaging"; import { motionPolicyAtom } from "./atoms"; export function useMotionPolicySync() { diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/atoms/configs.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/atoms/configs.ts index 5245b959629..0e4f2bd1380 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/atoms/configs.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/atoms/configs.ts @@ -21,6 +21,11 @@ export interface NodeContentRenderProps { export interface NodeConfig { padding: Padding; renderContent: (kind: NodeState["kind"], props: NodeContentRenderProps) => ReactNode; + /** + * Invoked when the user activates a node (double-click). Optional: a graph with no activation + * behaviour is legitimate, which is why this does not throw the way `renderContent` does. + */ + onNodeActivate?: (id: string, data: unknown) => void; } export const nodeConfigAtom = atom({ 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..ade01359cec 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/utils"; 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..73745b18f55 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/utils"; 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..5421307cfcf 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 @@ -2,57 +2,24 @@ // Licensed under the MIT License. import type { AtomicNodeState } from "@/lib/graph/atoms/nodes"; -import type { Range } from "@/lib/messaging/messages"; 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 { 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 { useBoxUpdate, useDragListener, useNodeActivation } from "@/lib/graph/hooks"; +import { translateBox } from "@/lib/utils"; 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]); + useNodeActivation(ref, id, dataAtom); useLayoutEffect(() => { if (!ref.current) { 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..03efe5d8654 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 @@ -2,57 +2,24 @@ // Licensed under the MIT License. import type { CompoundNodeState } from "@/lib/graph/atoms/nodes"; -import type { Range } from "@/lib/messaging/messages"; -import { useWebviewMessageChannel } from "@vscode-bicep-ui/messaging"; import { useAtomValue, useStore } from "jotai"; import { frame } from "motion/react"; -import { useEffect, useRef } from "react"; +import { 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 { useBoxUpdate, useDragListener, useNodeActivation } from "@/lib/graph/hooks"; +import { translateBox } from "@/lib/utils"; 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]); + useNodeActivation(ref, id, dataAtom); useDragListener(ref, (dx: number, dy: number) => { const translateChildren = (childIds: string[]) => { 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..b2d12953ded 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 @@ -7,7 +7,7 @@ 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/utils"; const $EdgePath = styled.path` transition: stroke 180ms ease; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/hooks/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/hooks/index.ts index 6970f41405a..87a76b6391d 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/hooks/index.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/hooks/index.ts @@ -4,3 +4,4 @@ export * from "./use-box-update"; export * from "./use-drag-listener"; export * from "./use-fit-view"; +export * from "./use-node-activation"; 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..6840a2a7808 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/utils"; 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..0e7fc585846 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/utils"; 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/utils"; /** * 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/hooks/use-node-activation.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/hooks/use-node-activation.ts new file mode 100644 index 00000000000..a8e3ae80c0e --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/hooks/use-node-activation.ts @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { Atom } from "jotai"; +import type { RefObject } from "react"; + +import { useStore } from "jotai"; +import { useEffect } from "react"; +import { nodeConfigAtom } from "@/lib/graph/atoms"; + +/** + * Calls the configured `onNodeActivate` when a node is double-clicked. + * + * What activation *means* belongs to the product, not the engine: `lib/graph` reports that a node was + * activated and lets `nodeConfigAtom` decide what happens. This is the same injection seam + * `renderContent` uses, and it is what keeps this module free of any host-protocol knowledge. + * + * Uses a native listener rather than an `onDoubleClick` prop so it can `stopPropagation()` before + * d3-zoom's handler on the PanZoom ancestor sees the event. + */ +export function useNodeActivation(ref: RefObject, id: string, dataAtom: Atom) { + const store = useStore(); + + useEffect(() => { + const element = ref.current; + + if (!element) { + return; + } + + const handler = (event: MouseEvent) => { + event.stopPropagation(); + store.get(nodeConfigAtom).onNodeActivate?.(id, store.get(dataAtom)); + }; + + element.addEventListener("dblclick", handler); + + return () => element.removeEventListener("dblclick", handler); + }, [dataAtom, id, ref, store]); +} 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..8df169721d2 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 "./viewport"; 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/lib/graph/viewport.ts similarity index 92% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/contracts.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/viewport.ts index cc312433409..581449363b7 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/resource-palette/contracts.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/viewport.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { Point } from "@/lib/utils/math/geometry"; +import type { Point } from "@/lib/utils"; export function viewportToGraphPoint( clientPoint: Point, 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 index 21dbefdb1ab..6db4afea5c4 100644 --- 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 @@ -1,6 +1,4 @@ // 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/messages.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/messages.ts index 981e7ffa0df..e80bb6b6c55 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/messages.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/messages.ts @@ -70,22 +70,29 @@ export const MOTION_POLICY_DID_CHANGE_NOTIFICATION = "motionPolicy/didChange"; export const GET_RESOURCE_CREATION_ENABLEMENT_REQUEST = "resourceCreation/isEnabled"; export const RESOURCE_CREATION_ENABLEMENT_DID_CHANGE_NOTIFICATION = "resourceCreation/enablementDidChange"; +// ── Resource type catalog ── +// The catalog is versioned by `catalogId`; the host may rebuild it at any time, so responses carrying +// a stale id must be discarded rather than merged. + +export const GET_RESOURCE_TYPE_NAMESPACES_REQUEST = "resourceTypeCatalog/namespaces"; +export const LOAD_RESOURCE_TYPE_CATALOG_REQUEST = "resourceTypeCatalog/load"; + // ── Resource creation ── export const CREATE_RESOURCE_REQUEST = "resources/create"; -export interface VisualResourceTypeReference { +export interface ResourceTypeReference { fullyQualifiedType: string; apiVersion: string; } -export interface CreateVisualResourceRequest { +export interface CreateResourceRequest { version: 1; operationId: string; - resourceType: VisualResourceTypeReference; + resourceType: ResourceTypeReference; } -export interface CreateVisualResourceResponse { +export interface CreateResourceResponse { version: 1; operationId: string; expectedNodeId: string; @@ -93,7 +100,7 @@ export interface CreateVisualResourceResponse { unresolvedRequiredProperties: string[]; } -export interface CreateVisualResourceError { +export interface CreateResourceError { version: 1; operationId?: string; code: 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/lib/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/lib/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/lib/utils/index.ts index 374a65de3da..8f1f795efa6 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/utils/index.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/utils/index.ts @@ -3,4 +3,4 @@ export * from "./math"; export * from "./text"; -export * from "./deployment-graph-equality"; +export * from "./errors"; 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/features/controls/ControlPrimitives.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/ui/IconButton.tsx similarity index 69% 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/IconButton.tsx index f25c2710419..b73aecf6451 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/controls/ControlPrimitives.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/ui/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 surfaces. + */ +export const IconButton = styled.button` display: flex; align-items: center; justify-content: center; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/accessibility/MotionAwareProgressBar.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/ui/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/ui/MotionAwareProgressBar.tsx index c07133b9e39..76b4e0b942e 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/accessibility/MotionAwareProgressBar.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/ui/MotionAwareProgressBar.tsx @@ -3,15 +3,9 @@ import { ProgressBar } from "@vscode-bicep-ui/components"; import { useAtomValue } from "jotai"; -import { motionPolicyAtom } from "./atoms"; +import { motionPolicyAtom } from "@/lib/accessibility"; -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/ui/Surface.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/ui/Surface.tsx new file mode 100644 index 00000000000..8bd6d82c9e8 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/ui/Surface.tsx @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import styled from "styled-components"; + +/** + * A floating panel: the visual container for controls and islands layered over the canvas. + */ +export const Surface = 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); +`; 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..4f673c8055e --- /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 "./IconButton"; +export * from "./MotionAwareProgressBar"; +export * from "./Surface"; 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 100% 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 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 100% 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 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/eslint.config.mjs b/src/vscode-bicep-ui/eslint.config.mjs index 60b691f808e..8219279fd3a 100644 --- a/src/vscode-bicep-ui/eslint.config.mjs +++ b/src/vscode-bicep-ui/eslint.config.mjs @@ -1,12 +1,72 @@ // 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"; import reactRefreshPlugin from "eslint-plugin-react-refresh"; import tseslint from "typescript-eslint"; +// Layer boundaries for apps/visual-designer. See its architecture-notes.md: +// app -> features, ui, lib | features -> ui, lib | ui -> lib | lib -> lib +// Structure rules that are not machine-checked decay, and two lib -> features +// imports had already landed before this rule existed. +const VISUAL_DESIGNER_LAYERS = [ + { + layer: "lib", + forbids: ["features", "ui", "app"], + }, + { + layer: "ui", + forbids: ["features", "app"], + }, + { + layer: "features", + forbids: ["app"], + }, +]; + +const visualDesignerLayerBoundaries = VISUAL_DESIGNER_LAYERS.map(({ layer, forbids }) => ({ + files: [`apps/visual-designer/src/${layer}/**/*.{ts,tsx}`], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: forbids.map((forbidden) => ({ + group: [`@/${forbidden}`, `@/${forbidden}/**`, `**/${forbidden}`, `**/${forbidden}/**`], + message: `"${layer}" must not import from "${forbidden}". See apps/visual-designer/architecture-notes.md.`, + })), + }, + ], + }, +})); + +// lib/graph is a Bicep-agnostic rendering engine, so it must not know the host protocol. The layer +// rule above cannot catch this because lib/graph -> lib/messaging is a legal lib -> lib edge, and the +// engine had in fact grown a double-click handler that sent reveal-source notifications directly. +// Bicep behaviour reaches the engine through nodeConfigAtom instead. +const visualDesignerGraphEngineBoundary = { + files: ["apps/visual-designer/src/lib/graph/**/*.{ts,tsx}"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: ["@/features", "@/features/**", "@/ui", "@/ui/**", "@/app", "@/app/**"], + message: '"lib" must not import from a higher layer. See apps/visual-designer/architecture-notes.md.', + }, + { + group: ["@/lib/messaging", "@/lib/messaging/**", "@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 tseslint.config( { ignores: ["**/*.{js,cjs,mjs}", "**/.turbo/", "**/dist/", "**/e2e/.results/", "**/e2e/.report/"], @@ -47,4 +107,6 @@ export default tseslint.config( ], }, }, + ...visualDesignerLayerBoundaries, + visualDesignerGraphEngineBoundary, ); From 1d656b5f8a7dcfc41240ba4d6ea4036d2dd15be8 Mon Sep 17 00:00:00 2001 From: Shenglong Li Date: Fri, 28 Aug 2026 23:57:17 -0700 Subject: [PATCH 2/5] Add typed visual designer messaging APIs Define feature-owned message descriptors and typed request, notification, and channel hooks across the visual designer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 91f8ba77-9474-4393-aa97-78e82fb5381b --- .../visual-designer/architecture-notes.md | 93 ++++++++--- .../e2e/resource-creation.spec.ts | 46 ++++-- .../visual-designer/src/app/AppProviders.tsx | 9 +- .../deployment-graph/api.ts} | 154 +++++++++--------- .../src/features/deployment-graph/atoms.ts | 2 +- .../components/DeploymentGraphView.tsx | 28 ++-- .../components/nodes/ModuleNode.tsx | 2 +- .../components/nodes/NodeContentProvider.tsx | 23 +-- .../components/nodes/ResourceNode.tsx | 2 +- .../deployment-graph/hooks/use-apply-graph.ts | 2 +- .../hooks/use-graph-update.ts | 42 ++--- .../src/features/deployment-graph/index.ts | 1 + .../__tests__/layout-invalidation.test.ts | 2 +- .../deployment-graph/utils/graph-equality.ts | 2 +- .../utils/layout-invalidation.ts | 2 +- .../devtools/components/DevAppShell.tsx | 6 +- .../devtools/components/DevToolbar.tsx | 4 +- .../devtools/fakes/fake-graph-differ.ts | 2 +- .../devtools/fakes/fake-message-channel.ts | 95 ++++++----- .../src/features/palette/api.ts | 61 +++++++ .../src/features/palette/atoms.ts | 2 +- .../palette/components/PaletteContent.tsx | 3 +- .../hooks/use-resource-creation-enablement.ts | 19 +-- .../hooks/use-resource-type-catalog.ts | 64 +++----- .../src/features/palette/index.ts | 2 + .../src/features/palette/types.ts | 6 + .../src/features/status/api.ts | 15 ++ .../features/status/components/StatusBar.tsx | 11 +- .../src/features/status/index.ts | 1 + .../src/lib/accessibility/api.ts | 14 ++ .../src/lib/accessibility/atoms.ts | 2 +- .../src/lib/accessibility/index.ts | 1 + .../accessibility/use-motion-policy-sync.ts | 21 +-- .../apps/visual-designer/src/lib/host.ts | 42 +++++ .../src/lib/messaging/index.ts | 4 - .../src/lib/utils/math/geometry/point.ts | 2 - .../src/WebviewRequestChannelProvider.tsx | 11 +- .../packages/messaging/src/index.ts | 3 + .../messaging/src/messageDescriptor.ts | 42 +++++ .../packages/messaging/src/useNotification.ts | 28 ++++ .../packages/messaging/src/useRequest.ts | 37 +++++ .../messaging/src/webviewMessageChannel.ts | 42 ++++- 42 files changed, 640 insertions(+), 310 deletions(-) rename src/vscode-bicep-ui/apps/visual-designer/src/{lib/messaging/messages.ts => features/deployment-graph/api.ts} (67%) create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/palette/api.ts create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/status/api.ts create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/api.ts create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/lib/host.ts delete mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/index.ts create mode 100644 src/vscode-bicep-ui/packages/messaging/src/messageDescriptor.ts create mode 100644 src/vscode-bicep-ui/packages/messaging/src/useNotification.ts create mode 100644 src/vscode-bicep-ui/packages/messaging/src/useRequest.ts diff --git a/src/vscode-bicep-ui/apps/visual-designer/architecture-notes.md b/src/vscode-bicep-ui/apps/visual-designer/architecture-notes.md index c10e7e9ce26..e8bd8ebc91c 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/architecture-notes.md +++ b/src/vscode-bicep-ui/apps/visual-designer/architecture-notes.md @@ -94,12 +94,14 @@ things are in any other, so features and `lib` modules organise their contents t | `components/` | Components, including any that are only used inside the feature. | | `hooks/` | Reusable `use-*` hooks. | | `utils/` | Pure helpers with no React dependency. | +| `api.ts` | The host protocol this feature uses: method constants and payload shapes. | | `atoms.ts` | Feature state. Splits into `atoms/` only when it holds distinct concerns. | +| `types.ts` | Shared domain vocabulary. | -Files that belong to the feature as a whole stay at its root next to `index.ts`: `atoms.ts` for state, -`types.ts` for shared domain vocabulary, and concept-named files such as `animations.ts` for shared -transition constants. Only include the folders a feature actually needs, and add one when its first -file arrives rather than scaffolding it empty. +Files that belong to the feature as a whole stay at its root next to `index.ts`, alongside +concept-named files such as `animations.ts` for shared transition constants. Only include the folders +and files a feature actually needs, and add one when its first member arrives rather than scaffolding +it empty. This is deliberately uniform rather than minimal. A `components/` folder holding one file carries no information on its own, and grouping by file type does split some cohesive subsystems: the graph sync @@ -143,6 +145,7 @@ src/ utils/ layout-invalidation.ts graph-equality.ts + api.ts # graph, layout and resource-creation messages animations.ts atoms.ts @@ -152,8 +155,9 @@ src/ hooks/ # use-drag, use-resource-type-catalog, # use-resource-type-search, # use-resource-creation-enablement + api.ts # enablement and resource-type catalog messages atoms.ts - types.ts # namespace and resource-type vocabulary + types.ts # resource-type vocabulary controls/ # components/ControlBar, hooks/use-reset-layout, atoms.ts export/ # components/, utils/capture-element.ts, atoms.ts @@ -163,7 +167,7 @@ src/ lib/ accessibility/ # motion policy state and host synchronisation graph/ # atoms/, components/, hooks/, viewport.ts - messaging/ # messages.ts: protocol types and method constants only + host.ts # webview lifecycle: ready, document-changed utils/ # math/, text.ts, errors.ts ui/ # shared primitives, flat @@ -231,9 +235,9 @@ It works in client coordinates throughout. It should not import `viewportToGraph DOM handle, or read the pan/zoom transform; it hands `features/deployment-graph` a client point and lets it decide where that lands in the graph. -It must not define its own `ResourceTypeReference`; the protocol type in `lib/messaging` is the single -definition. Catalog vocabulary lives in `types.ts`, and `PaletteProps` sits in `Palette.tsx` beside -the component that takes it. +It owns `ResourceTypeReference` in its `types.ts`, and `deployment-graph` imports it through the +barrel to type the creation request — the graph creates what the palette selected. Catalog vocabulary +lives in the same file, and `PaletteProps` sits in `Palette.tsx` beside the component that takes it. ### Other features @@ -266,7 +270,7 @@ could not be undone or scoped to a test store. `onNodeActivate` exists because the engine had in fact grown host knowledge: `AtomicNode` and `CompoundNode` each carried an identical double-click handler that cast node data to `{ range, filePath }` and sent `revealFileRange` / `revealNodeSource` notifications directly. The -layer rule could not catch it, because `lib/graph -> lib/messaging` is a legal `lib -> lib` edge. A +layer rule could not catch it, because `lib/graph -> lib/messaging` was a legal `lib -> lib` edge. A second, narrower lint zone now forbids `lib/graph` from importing any messaging module at all, so the claim at the top of this section is machine-checked rather than aspirational. @@ -282,16 +286,61 @@ without a `ui -> features` violation. Component-specific keyboard and ARIA behavior stays with the component. -### `lib/messaging` - -The transport contract only: protocol types, method-name constants, and payload shapes. The graph -update state machine, the graph apply path, and layout invalidation are Bicep workflow logic and -belong to `features/deployment-graph`. Relocating them is what removes both `lib -> features` inversions; no -callback indirection or inversion-of-control shim is needed. - -`messages.ts` is already sectioned by protocol area. Splitting it into `graph-protocol.ts`, -`resource-catalog-protocol.ts`, `resource-creation-protocol.ts` and `host-protocol.ts` is optional -polish, not a correctness fix. If done, keep re-exporting through `lib/messaging/index.ts`. +### Protocol declarations + +Each feature declares the host protocol it uses in its own `api.ts`. Measured across the app, this is +what the code already wanted: of the symbols in the former shared `messages.ts`, all but two belonged +to exactly one feature. `deployment-graph` owns the graph update, layout and resource-creation +messages; `palette` owns enablement and the resource-type catalog; `status` owns the problems-panel +notification; `lib/accessibility` owns motion policy. + +`lib/host.ts` holds what no feature owns: `ready` (the webview mounted) and `documentDidChange` (a +broadcast that several features independently react to). + +Each `api.ts` has three layers: + +| | Example | +| ----------------------------------- | ------------------------- | +| Descriptor, named for the operation | `createResource` | +| Outgoing payload, suffixed `Params` | `CreateResourceParams` | +| Incoming payload, suffixed `Result` | `CreateResourceResult` | +| Method on the feature's API hook | `api.createResource(...)` | + +A descriptor and the API method that sends it may share a name, and often should — they are the same +operation named at two levels, and `channel.request(createResource, params)` supplies the verb from +context. This is safe rather than merely tolerable: an object property is not a binding, so +`createResource: (params) => channel.request(createResource, params)` resolves the argument to the +module-level descriptor exactly as intended. + +Shadowing would only occur where a file both imports a descriptor and binds that name locally, and the +API hooks removed that possibility: components call `api.revealNodeSource(id)` and no longer import +descriptors at all. Only `api.ts` and the fake host reference them, plus the subscription sites that +must pass one to `useNotification`. + +`Params`/`Result` replace an earlier mix of `Payload`, `Request` and `Params` for the same idea. +Domain vocabulary keeps its own name: `loadResourceTypeCatalog` resolves to `ResourceTypeCatalog`, not +a `...Result` envelope, because the catalog is a shared type in `types.ts` rather than a shape that +exists only to be a response. + +Notification names follow direction rather than a single tense. `documentDidChange` is an event the +host announces; `revealNodeSource` is a command the webview sends. That distinction is worth keeping. + +The split also removes a name collision. `@vscode-bicep-ui/messaging` is the transport — the channel +and its hooks — and an app-local module called `messaging` alongside it invited confusion about which +was which. The package owns _how_ to talk; each feature owns _what it says_; `lib/host` owns the +lifecycle in between. + +A feature's `api.ts` is exported through its barrel, because the protocol is part of its public +contract. `features/devtools` is the one legitimate cross-feature consumer: it fakes the entire host, +so it must implement every feature's messages. + +Descriptors are partly redundant for requests, and that is accepted. A request reached only through +its API hook could just as well inline the method string there, since the hook's own signature already +states the params and result. They are kept because two consumers cannot go through the hook: +subscriptions, which are declarative and lifecycle-bound +(`useNotification(documentDidChangeMessage, handler)`), and the fake host, which matches nine incoming +methods against `descriptor.method`. Having one way to declare every message is worth a line per +message over having two. ### `ui` @@ -354,7 +403,7 @@ file per atom. Each feature and each `lib` module exposes exactly one entry point: its `index.ts`. - Import through the barrel: `@/lib/graph`, `@/features/export`. -- Do not deep-import across a boundary. `@/lib/messaging/messages` is `@/lib/messaging`, and +- Do not deep-import across a boundary. A feature's `api.ts` is reached through its barrel, and `@/lib/utils/math/geometry` is `@/lib/utils`. - Deep imports within the same module are fine. - Feature barrels export the intended surface, not `export *` over every file. The `lib` and `ui` @@ -445,7 +494,7 @@ competing with the default timing. Not required, and not worth doing without a reason: -- Split `lib/messaging/messages.ts` by protocol area behind the existing barrel, if it keeps growing. +- Fold the legacy `DeploymentGraph` shape out of `deployment-graph/api.ts` once the position-preserving apply path no longer needs it. - Give `Surface` and `IconButton` neutral theme tokens. Both still read `theme.controlBar.*`, so the palette launcher styles itself from control-bar tokens, and `Palette` hand-rolls a second floating-panel style from raw `var(--vscode-*)` values at a different radius. Unifying those is the 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 437ddc81bda..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 @@ -81,18 +81,25 @@ test.describe("resource creation", () => { const progress = page.getByTestId("resource-palette-progress"); await expect(progress).toBeVisible(); - // Read the animation state in a single round trip, and report a sentinel rather than a boolean - // if the indicator is missing: a detached element yields an empty computed style, which would + + // 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 progressAnimation = await progress.evaluate((element) => { - const indicator = element.shadowRoot?.querySelector(".indicator"); + const readAnimationName = () => + progress.evaluate((element) => { + const indicator = element.shadowRoot?.querySelector(".indicator"); + + return indicator ? getComputedStyle(indicator).animationName : "indicator-missing"; + }); - 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"); - expect(progressAnimation).not.toBe(""); }); test("searches all resource namespaces without expanding them first", async ({ page }) => { @@ -146,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/src/app/AppProviders.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/app/AppProviders.tsx index 1475c8a52ae..a5f38264b4b 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/app/AppProviders.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/app/AppProviders.tsx @@ -4,10 +4,11 @@ import type { ReactNode } from "react"; import { WebviewMessageChannelProvider } from "@vscode-bicep-ui/messaging"; -import { Suspense } from "react"; +import { Suspense, useEffect } from "react"; import { ThemeProvider } from "styled-components"; import { loadDevAppShell } from "@/features/devtools"; import { useMotionPolicySync } from "@/lib/accessibility"; +import { useHostApi } from "@/lib/host"; import { useTheme } from "@/ui/theme"; import { GlobalStyle } from "./GlobalStyle"; @@ -15,8 +16,14 @@ const DevAppShell = loadDevAppShell(); function ThemedApp({ children }: { children: ReactNode }) { const theme = useTheme(); + const hostApi = useHostApi(); useMotionPolicySync(); + // "The webview has mounted." This is app lifecycle, not any one feature's concern. + useEffect(() => { + hostApi.announceReady(); + }, [hostApi]); + return ( 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/deployment-graph/api.ts similarity index 67% 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/deployment-graph/api.ts index e80bb6b6c55..23607645888 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/messages.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/api.ts @@ -1,25 +1,16 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -export interface DeploymentGraph { - nodes: DeploymentGraphNode[]; - edges: DeploymentGraphEdge[]; - errorCount: number; -} +import type { ResourceTypeReference } from "@/features/palette"; -export interface DeploymentGraphNode { - id: string; - type: string; - isCollection: boolean; - range: Range; - hasChildren: boolean; - hasError: boolean; - filePath: string; -} +import { defineNotification, defineRequest, useWebviewMessageChannel } from "@vscode-bicep-ui/messaging"; +import { useMemo } from "react"; -export interface DeploymentGraphEdge { - sourceId: string; - targetId: string; +// ── Source locations ── + +export interface Position { + line: number; + character: number; } export interface Range { @@ -27,16 +18,11 @@ export interface Range { end: Position; } -export 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"; +// Sent when the user wants to navigate to a source range. +export const revealFileRange = defineNotification("revealFileRange"); -export interface RevealFileRangePayload { +export interface RevealFileRangeParams { filePath: string; range: Range; } @@ -45,54 +31,23 @@ export interface RevealFileRangePayload { // 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"; +export const revealNodeSource = defineNotification("revealNodeSource"); -export interface RevealNodeSourcePayload { +export 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 type catalog ── -// The catalog is versioned by `catalogId`; the host may rebuild it at any time, so responses carrying -// a stale id must be discarded rather than merged. - -export const GET_RESOURCE_TYPE_NAMESPACES_REQUEST = "resourceTypeCatalog/namespaces"; -export const LOAD_RESOURCE_TYPE_CATALOG_REQUEST = "resourceTypeCatalog/load"; - // ── Resource creation ── -export const CREATE_RESOURCE_REQUEST = "resources/create"; +export const createResource = defineRequest("resources/create"); -export interface ResourceTypeReference { - fullyQualifiedType: string; - apiVersion: string; -} - -export interface CreateResourceRequest { +export interface CreateResourceParams { version: 1; operationId: string; resourceType: ResourceTypeReference; } -export interface CreateResourceResponse { +export interface CreateResourceResult { version: 1; operationId: string; expectedNodeId: string; @@ -100,7 +55,7 @@ export interface CreateResourceResponse { unresolvedRequiredProperties: string[]; } -export interface CreateResourceError { +export interface CreateResourceErrorResult { version: 1; operationId?: string; code: @@ -115,43 +70,34 @@ export interface CreateResourceError { } // ────────────────────────────────────────────────────────────────────────── -// 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[]; } @@ -245,3 +191,53 @@ export type GraphPatch = | { op: "setNodeLayout"; nodeId: string; layout: NodeLayout } | { op: "setGraphBounds"; bounds: GraphBounds } | { op: "setErrorCount"; errorCount: number }; + +// ── Legacy graph shape ── +// The position-preserving apply path still consumes this. Source locations are filled with empty +// placeholders on the server-driven path; reveal is driven by node id instead. + +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; +} + +export interface DeploymentGraphEdge { + sourceId: string; + targetId: string; +} + +/** + * 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 useDeploymentGraphApi() { + const channel = useWebviewMessageChannel(); + + return useMemo( + () => ({ + fetchUpdate: (current: RenderedGraph | null) => channel.request(getGraphUpdate, { current }), + fetchLayout: (current: RenderedGraph) => channel.request(getGraphLayout, { current }), + createResource: (params: CreateResourceParams) => channel.request(createResource, params), + revealFileRange: (params: RevealFileRangeParams) => channel.notify(revealFileRange, params), + revealNodeSource: (nodeId: string) => channel.notify(revealNodeSource, { nodeId }), + }), + [channel], + ); +} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/atoms.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/atoms.ts index 501775bd64e..bf8bfc124f1 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/atoms.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/atoms.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { ResourceTypeReference } from "@/lib/messaging"; +import type { ResourceTypeReference } from "@/features/palette"; import type { Point } from "@/lib/utils"; import { atom } from "jotai"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/DeploymentGraphView.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/DeploymentGraphView.tsx index d207395d8da..e3ea533634e 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/DeploymentGraphView.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/DeploymentGraphView.tsx @@ -2,17 +2,18 @@ // Licensed under the MIT License. import type { ReactNode } from "react"; -import type { DocumentDidChangePayload, ResourceTypeReference } from "@/lib/messaging"; +import type { ResourceTypeReference } from "@/features/palette"; +import type { DocumentDidChangeParams } from "@/lib/host"; import type { Point } from "@/lib/utils"; import { useGetPanZoomDimensions, useGetPanZoomTransform } from "@vscode-bicep-ui/components"; -import { useWebviewMessageChannel, useWebviewNotification } from "@vscode-bicep-ui/messaging"; +import { useNotification } from "@vscode-bicep-ui/messaging"; import { useAtomValue, useSetAtom } from "jotai"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { styled, ThemeProvider } from "styled-components"; import { effectiveExportThemeAtom, exportCanvasElementAtom, exportFileStemAtom } from "@/features/export"; import { Canvas, Graph, useFitViewToBounds, viewportToGraphPoint } from "@/lib/graph"; -import { DOCUMENT_DID_CHANGE_NOTIFICATION, READY_NOTIFICATION } from "@/lib/messaging"; +import { documentDidChange, useHostApi } from "@/lib/host"; import { useGraphUpdate } from "../hooks/use-graph-update"; import { NodeContentProvider } from "./nodes/NodeContentProvider"; import { PendingResourceLayer } from "./PendingResourceLayer"; @@ -71,28 +72,23 @@ export function DeploymentGraphView({ canvasOverlay, children }: DeploymentGraph createResource: createResourceAtOrigin, resetLayout, } = useGraphUpdate(getViewportCenter, fitViewToBounds); - const messageChannel = useWebviewMessageChannel(); + const hostApi = useHostApi(); const exportTheme = useAtomValue(effectiveExportThemeAtom); const setExportFileStem = useSetAtom(exportFileStemAtom); const setExportCanvasElement = useSetAtom(exportCanvasElementAtom); const [canvasElement, setCanvasElement] = useState(null); - 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, + useNotification( + documentDidChange, useCallback( - (params: unknown) => { - const payload = params as DocumentDidChangePayload; - messageChannel.setState({ documentPath: payload.documentUri }); - setExportFileStem(deriveExportFileStem(payload.documentUri)); + ({ documentUri }: DocumentDidChangeParams) => { + hostApi.rememberDocument(documentUri); + setExportFileStem(deriveExportFileStem(documentUri)); void requestGraphUpdate(); }, - [messageChannel, requestGraphUpdate, setExportFileStem], + [hostApi, requestGraphUpdate, setExportFileStem], ), ); diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/nodes/ModuleNode.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/nodes/ModuleNode.tsx index dae2c62a39e..86c5f81b706 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/nodes/ModuleNode.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/nodes/ModuleNode.tsx @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { Range } from "@/lib/messaging"; +import type { Range } from "../../api"; import { AzureIcon } from "@vscode-bicep-ui/components"; import { useAtomValue } from "jotai"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/nodes/NodeContentProvider.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/nodes/NodeContentProvider.tsx index e399e0b8e46..95fdb2ba53b 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/nodes/NodeContentProvider.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/nodes/NodeContentProvider.tsx @@ -3,16 +3,15 @@ import type { ReactNode } from "react"; import type { NodeContentRenderProps, NodeKind } from "@/lib/graph"; -import type { Range } from "@/lib/messaging"; +import type { Range } from "../../api"; import type { ModuleNodeProps } from "./ModuleNode"; import type { ResourceNodeProps } from "./ResourceNode"; -import { useWebviewMessageChannel } from "@vscode-bicep-ui/messaging"; import { useStore } from "jotai"; import { useHydrateAtoms } from "jotai/utils"; import { useCallback } from "react"; import { nodeConfigAtom } from "@/lib/graph"; -import { REVEAL_FILE_RANGE_NOTIFICATION, REVEAL_NODE_SOURCE_NOTIFICATION } from "@/lib/messaging"; +import { useDeploymentGraphApi } from "../../api"; import { ModuleNode } from "./ModuleNode"; import { ResourceNode } from "./ResourceNode"; @@ -41,28 +40,22 @@ function renderNodeContent(kind: NodeKind, { id, data }: NodeContentRenderProps) export function NodeContentProvider({ children }: { children: ReactNode }) { const store = useStore(); const defaults = store.get(nodeConfigAtom); - const messageChannel = useWebviewMessageChannel(); + const api = useDeploymentGraphApi(); - const revealNodeSource = useCallback( + const handleNodeActivate = useCallback( (id: string, data: unknown) => { const { range, filePath } = (data ?? {}) as NodeSourceLocation; if (range && filePath) { // Legacy push path: the node still carries an inline source location. - messageChannel.sendNotification({ - method: REVEAL_FILE_RANGE_NOTIFICATION, - params: { filePath, range }, - }); + api.revealFileRange({ filePath, range }); return; } // Server-driven path: source location is resolved on demand by node id. - messageChannel.sendNotification({ - method: REVEAL_NODE_SOURCE_NOTIFICATION, - params: { nodeId: id }, - }); + api.revealNodeSource(id); }, - [messageChannel], + [api], ); useHydrateAtoms([ @@ -72,7 +65,7 @@ export function NodeContentProvider({ children }: { children: ReactNode }) { ...defaults, padding: { ...defaults.padding, top: COMPOUND_NODE_LABEL_INSET }, renderContent: renderNodeContent, - onNodeActivate: revealNodeSource, + onNodeActivate: handleNodeActivate, }, ], ] as const); diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/nodes/ResourceNode.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/nodes/ResourceNode.tsx index 6692ed9d7de..2d5b5d1e27b 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/nodes/ResourceNode.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/nodes/ResourceNode.tsx @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { Range } from "@/lib/messaging"; +import type { Range } from "../../api"; import { AzureIcon } from "@vscode-bicep-ui/components"; import { useAtom, useAtomValue } from "jotai"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/hooks/use-apply-graph.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/hooks/use-apply-graph.ts index 9d1cc9c5224..d68df66552b 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/hooks/use-apply-graph.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/hooks/use-apply-graph.ts @@ -3,8 +3,8 @@ import type { PrimitiveAtom } from "jotai"; import type { AnimationPlaybackControlsWithThen } from "motion"; -import type { DeploymentGraph, NodeLayout } from "@/lib/messaging"; import type { Box, Point } from "@/lib/utils"; +import type { DeploymentGraph, NodeLayout } from "../api"; import { getDefaultStore, useSetAtom } from "jotai"; import { animate, transform } from "motion"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/hooks/use-graph-update.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/hooks/use-graph-update.ts index 472e6f0d34d..181cba9ec0d 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/hooks/use-graph-update.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/hooks/use-graph-update.ts @@ -1,14 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import type { ResourceTypeReference } from "@/features/palette"; +import type { Box, Point } from "@/lib/utils"; import type { - CreateResourceRequest, - CreateResourceResponse, DeploymentGraph, - GetGraphLayoutRequest, - GetGraphLayoutResponse, - GetGraphUpdateRequest, - GetGraphUpdateResponse, GraphBounds, GraphEdge, GraphNode, @@ -16,15 +12,12 @@ import type { NodeLayout, Range, RenderedGraph, - ResourceTypeReference, -} from "@/lib/messaging"; -import type { Box, Point } from "@/lib/utils"; +} from "../api"; -import { useWebviewMessageChannel } from "@vscode-bicep-ui/messaging"; import { getDefaultStore } from "jotai"; import { useCallback, useRef } from "react"; import { nodesByIdAtom } from "@/lib/graph"; -import { CREATE_RESOURCE_REQUEST, GET_GRAPH_LAYOUT_REQUEST, GET_GRAPH_UPDATE_REQUEST } from "@/lib/messaging"; +import { useDeploymentGraphApi } from "../api"; import { pendingResourcesAtom, resourceCreationErrorAtom, resourceNodeIsCommittingAtomFamily } from "../atoms"; import { patchMayAffectLayout, renderedGraphsEqual } from "../utils/layout-invalidation"; import { applyGraphLayout, useApplyGraph } from "./use-apply-graph"; @@ -234,7 +227,7 @@ export function useGraphUpdate( fitViewToBounds: (bounds: Box) => void, ): GraphUpdateActions { const applyGraph = useApplyGraph(getViewportCenter); - const messageChannel = useWebviewMessageChannel(); + const api = useDeploymentGraphApi(); const clientGraphRef = useRef(createClientGraph()); const lastLayoutInputRef = useRef(null); const inFlightRef = useRef(false); @@ -264,11 +257,7 @@ export function useGraphUpdate( return; } - const layoutRequest: GetGraphLayoutRequest = { current: measuredGraph }; - const layoutResponse = await messageChannel.sendRequest({ - method: GET_GRAPH_LAYOUT_REQUEST, - params: layoutRequest, - }); + const layoutResponse = await api.fetchLayout(measuredGraph); if (layoutResponse.status === "graphChanged") { dirtyRef.current = true; @@ -296,7 +285,7 @@ export function useGraphUpdate( await applyGraphLayout(nodeLayouts); }, - [fitViewToBounds, getViewportCenter, messageChannel], + [fitViewToBounds, getViewportCenter, api], ); const requestGraphUpdate = useCallback(async () => { @@ -328,12 +317,7 @@ export function useGraphUpdate( 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, - }); + const response = await api.fetchUpdate(current); if (mutationInFlightRef.current) { // The mutation response carries the expected node ID needed to correlate placement. A graph response @@ -394,7 +378,7 @@ export function useGraphUpdate( } finally { inFlightRef.current = false; } - }, [applyGraph, requestGraphLayout, messageChannel]); + }, [applyGraph, requestGraphLayout, api]); const resetLayout = useCallback(async () => { forceLayoutRef.current = true; @@ -418,14 +402,10 @@ export function useGraphUpdate( mutationInFlightRef.current = true; try { - const request: CreateResourceRequest = { + const response = await api.createResource({ version: 1, operationId, resourceType, - }; - const response = await messageChannel.sendRequest({ - method: CREATE_RESOURCE_REQUEST, - params: request, }); pendingPlacementsRef.current.set(response.expectedNodeId, origin); @@ -456,7 +436,7 @@ export function useGraphUpdate( mutationQueueRef.current = queued; return queued; }, - [messageChannel, requestGraphUpdate], + [api, requestGraphUpdate], ); return { requestGraphUpdate, createResource, resetLayout }; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/index.ts index c0020cc39f0..6a97008afce 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/index.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/index.ts @@ -5,3 +5,4 @@ export { DeploymentGraphView, type DeploymentGraphSurface } from "./components/D export { ResourceCreationError } from "./components/ResourceCreationError"; export { RESOURCE_CREATION_TRANSITION } from "./animations"; export { ResourceNodePreview } from "./components/nodes/ResourceNodePreview"; +export * from "./api"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/utils/__tests__/layout-invalidation.test.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/utils/__tests__/layout-invalidation.test.ts index e3ffa078340..736ac417e79 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/utils/__tests__/layout-invalidation.test.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/utils/__tests__/layout-invalidation.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { GraphNode, GraphPatch, RenderedGraph, RenderedGraphNode } from "@/lib/messaging"; +import type { GraphNode, GraphPatch, RenderedGraph, RenderedGraphNode } from "../../api"; import { describe, expect, it } from "vitest"; import { patchMayAffectLayout, renderedGraphsEqual } from "../layout-invalidation"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/utils/graph-equality.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/utils/graph-equality.ts index 6eafd92e8c5..85f94997e60 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/utils/graph-equality.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/utils/graph-equality.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { DeploymentGraph } from "@/lib/messaging"; +import type { DeploymentGraph } from "../api"; /** * Compare two deployment graphs for structural equality, ignoring diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/utils/layout-invalidation.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/utils/layout-invalidation.ts index b70c3c992c1..eeae4cee9a2 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/utils/layout-invalidation.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/utils/layout-invalidation.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { GraphNode, GraphPatch, RenderedGraph } from "@/lib/messaging"; +import type { GraphNode, GraphPatch, RenderedGraph } from "../api"; /** * The node metadata fields that influence a node's rendered size, and therefore the layout. diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/components/DevAppShell.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/components/DevAppShell.tsx index ae2f9f08f08..38c3551a305 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/components/DevAppShell.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/components/DevAppShell.tsx @@ -1,7 +1,6 @@ // 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"; @@ -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/components/DevToolbar.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/components/DevToolbar.tsx index 1f82b091741..b1b66306e7d 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/components/DevToolbar.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/components/DevToolbar.tsx @@ -69,7 +69,9 @@ const $Button = styled.button` */ export function DevToolbar({ channel }: DevToolbarProps) { const applyMutation = ( - apply: (graph: import("@/lib/messaging").DeploymentGraph) => import("@/lib/messaging").DeploymentGraph, + apply: ( + graph: import("@/features/deployment-graph").DeploymentGraph, + ) => import("@/features/deployment-graph").DeploymentGraph, ) => { const current = channel.getCurrentGraph(); if (!current) return; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/fakes/fake-graph-differ.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/fakes/fake-graph-differ.ts index 0134c70d225..1230758c698 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/fakes/fake-graph-differ.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/fakes/fake-graph-differ.ts @@ -11,7 +11,7 @@ import type { GraphPatch, NodeLayout, RenderedGraph, -} from "@/lib/messaging"; +} from "@/features/deployment-graph"; /** * A throwaway, dev-only stand-in for the language server's `VisualGraphDiffer`. It lets the diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/fakes/fake-message-channel.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/fakes/fake-message-channel.ts index 4d7397ce7e1..4c905e2a6a9 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/fakes/fake-message-channel.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/fakes/fake-message-channel.ts @@ -1,29 +1,37 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { WebviewNotificationCallback, WebviewNotificationMessage } from "@vscode-bicep-ui/messaging"; import type { - CreateResourceRequest, - CreateResourceResponse, + MessageArgs, + NotificationDescriptor, + RequestDescriptor, + WebviewMessageChannelApi, + WebviewNotificationCallback, + WebviewNotificationMessage, +} from "@vscode-bicep-ui/messaging"; +import type { + CreateResourceParams, + CreateResourceResult, DeploymentGraph, - GetGraphLayoutRequest, - GetGraphLayoutResponse, - GetGraphUpdateRequest, - GetGraphUpdateResponse, -} from "@/lib/messaging"; + GetGraphLayoutParams, + GetGraphLayoutResult, + GetGraphUpdateParams, + GetGraphUpdateResult, +} from "@/features/deployment-graph"; +// The fake host implements the whole protocol, so it is the one legitimate consumer of every +// feature's `api` surface. import { - CREATE_RESOURCE_REQUEST, - DOCUMENT_DID_CHANGE_NOTIFICATION, - GET_GRAPH_LAYOUT_REQUEST, - GET_GRAPH_UPDATE_REQUEST, - GET_RESOURCE_TYPE_NAMESPACES_REQUEST, - LOAD_RESOURCE_TYPE_CATALOG_REQUEST, - READY_NOTIFICATION, - REVEAL_FILE_RANGE_NOTIFICATION, - REVEAL_NODE_SOURCE_NOTIFICATION, - SHOW_PROBLEMS_PANEL_NOTIFICATION, -} from "@/lib/messaging"; + createResource, + getGraphLayout, + getGraphUpdate, + revealFileRange, + revealNodeSource, +} from "@/features/deployment-graph"; +import { getResourceCreationEnablement, getResourceTypeNamespaces, loadResourceTypeCatalog } from "@/features/palette"; +import { showProblemsPanel } from "@/features/status"; +import { getMotionPolicy } from "@/lib/accessibility"; +import { documentDidChange, ready } from "@/lib/host"; import { diffGraph, layoutGraph } from "./fake-graph-differ"; const FAKE_FILE_PATH = "file:///main.bicep"; @@ -811,7 +819,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 ( @@ -837,11 +845,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); } @@ -856,7 +864,7 @@ export class FakeMessageChannel { }, ]; - if (requestMessage.method === GET_RESOURCE_TYPE_NAMESPACES_REQUEST) { + if (requestMessage.method === getResourceTypeNamespaces.method) { return new Promise((resolve) => { setTimeout( () => @@ -872,7 +880,7 @@ export class FakeMessageChannel { }); } - if (requestMessage.method === LOAD_RESOURCE_TYPE_CATALOG_REQUEST) { + if (requestMessage.method === loadResourceTypeCatalog.method) { const { providerNamespace, query, loadAll } = (requestMessage.params ?? {}) as { providerNamespace?: string; query?: string; @@ -896,24 +904,24 @@ export class FakeMessageChannel { }); } - 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 CreateResourceRequest; + 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); @@ -948,7 +956,7 @@ export class FakeMessageChannel { expectedNodeId: symbolicName, symbolicName, unresolvedRequiredProperties: ["name"], - } satisfies CreateResourceResponse as T); + } satisfies CreateResourceResult as T); }, 300); }); } @@ -960,22 +968,33 @@ export class FakeMessageChannel { private currentGraph: DeploymentGraph | 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) { + } else if (notificationMessage.method === revealFileRange.method) { 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; } @@ -988,7 +1007,7 @@ export class FakeMessageChannel { /** Simulate the extension host announcing that the graph may have changed. */ pushGraph(graph: DeploymentGraph | 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/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/palette/atoms.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/atoms.ts index 9c1b32b57c4..a23921f47a8 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/atoms.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/atoms.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { ResourceTypeReference } from "@/lib/messaging"; +import type { ResourceTypeReference } from "./types"; import { atom } from "jotai"; import { atomFamily } from "jotai-family"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/PaletteContent.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/PaletteContent.tsx index 88ff8c943d8..a89388aa2cf 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/PaletteContent.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/PaletteContent.tsx @@ -2,8 +2,7 @@ // Licensed under the MIT License. import type { PointerEvent } from "react"; -import type { ResourceTypeReference } from "@/lib/messaging"; -import type { ResourceTypeCatalog, ResourceTypeNamespace } from "../types"; +import type { ResourceTypeCatalog, ResourceTypeNamespace, ResourceTypeReference } from "../types"; import { useAtomValue } from "jotai"; import { useEffect } from "react"; 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 index cd0efb41d5a..ec448beb688 100644 --- 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 @@ -1,24 +1,17 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { useWebviewNotification, useWebviewRequest } from "@vscode-bicep-ui/messaging"; +import { useNotification, useRequest } 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"; +import { getResourceCreationEnablement, resourceCreationEnablementDidChange } from "../api"; export function useResourceCreationEnablement(): boolean { - const [initialEnablement] = useWebviewRequest(GET_RESOURCE_CREATION_ENABLEMENT_REQUEST); + const [initialEnablement] = useRequest(getResourceCreationEnablement); const [updatedEnablement, setUpdatedEnablement] = useState(); - useWebviewNotification( - RESOURCE_CREATION_ENABLEMENT_DID_CHANGE_NOTIFICATION, - useCallback((value: unknown) => { - if (typeof value === "boolean") { - setUpdatedEnablement(value); - } - }, []), + 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 index 76ce5fc55c8..b4a8c79f043 100644 --- 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 @@ -1,27 +1,20 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import type { GetResourceTypeNamespacesResult, LoadResourceTypeCatalogParams } from "../api"; import type { ResourceTypeCatalog, ResourceTypeNamespace } from "../types"; -import { useWebviewMessageChannel, useWebviewNotification } from "@vscode-bicep-ui/messaging"; +import { useNotification } from "@vscode-bicep-ui/messaging"; import { useCallback, useEffect, useRef, useState } from "react"; -import { - DOCUMENT_DID_CHANGE_NOTIFICATION, - GET_RESOURCE_TYPE_NAMESPACES_REQUEST, - LOAD_RESOURCE_TYPE_CATALOG_REQUEST, -} from "@/lib/messaging"; +import { documentDidChange } from "@/lib/host"; +import { usePaletteApi } from "../api"; /** Edits arrive in bursts, so refreshes are debounced. The first load is immediate. */ const REFRESH_DEBOUNCE_MS = 250; -interface ResourceTypeNamespaceCatalog { - catalogId: string; - namespaces: ResourceTypeNamespace[]; -} - type NamespaceCatalogState = | { status: "loading" } - | { status: "loaded"; catalog: ResourceTypeNamespaceCatalog } + | { status: "loaded"; catalog: GetResourceTypeNamespacesResult } | { status: "error"; error: unknown }; export interface ResourceTypeCatalogSource { @@ -42,7 +35,7 @@ export interface ResourceTypeCatalogSource { * generation counter so a slow response cannot overwrite a newer one. */ export function useResourceTypeCatalog(): ResourceTypeCatalogSource { - const messageChannel = useWebviewMessageChannel(); + const api = usePaletteApi(); const [namespaceCatalogState, setNamespaceCatalogState] = useState({ status: "loading" }); const [refreshGeneration, setRefreshGeneration] = useState(0); const namespaceRequestGenerationRef = useRef(0); @@ -52,8 +45,8 @@ export function useResourceTypeCatalog(): ResourceTypeCatalogSource { setRefreshGeneration((generation) => generation + 1); }, []); - useWebviewNotification( - DOCUMENT_DID_CHANGE_NOTIFICATION, + useNotification( + documentDidChange, useCallback(() => refresh(), [refresh]), ); @@ -62,36 +55,31 @@ export function useResourceTypeCatalog(): ResourceTypeCatalogSource { const timeout = window.setTimeout( () => { setNamespaceCatalogState((current) => (current.status === "loaded" ? current : { status: "loading" })); - void messageChannel - .sendRequest({ method: GET_RESOURCE_TYPE_NAMESPACES_REQUEST }) - .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 }); + 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); - }, [messageChannel, refreshGeneration]); + }, [api, refreshGeneration]); const requestCatalog = useCallback( - async (params: { providerNamespace?: string; query?: string; loadAll?: boolean }): Promise => { - const catalog = await messageChannel.sendRequest({ - method: LOAD_RESOURCE_TYPE_CATALOG_REQUEST, - params, - }); + async (params: LoadResourceTypeCatalogParams): Promise => { + const catalog = await api.loadCatalog(params); const currentCatalogId = namespaceCatalogState.status === "loaded" ? namespaceCatalogState.catalog.catalogId : undefined; @@ -102,7 +90,7 @@ export function useResourceTypeCatalog(): ResourceTypeCatalogSource { return catalog; }, - [messageChannel, namespaceCatalogState, refresh], + [api, namespaceCatalogState, refresh], ); const loadNamespace = useCallback( 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 index da0b480dc16..59c29295e2d 100644 --- 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 @@ -2,3 +2,5 @@ // 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 index 6453d3eae7e..d812f9259f4 100644 --- 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 @@ -17,3 +17,9 @@ export interface ResourceTypeCatalog { catalogId: string; groups: ResourceTypeCatalogGroup[]; } + +/** A resource type the user can create. Mirrors the host's resource-creation contract. */ +export interface ResourceTypeReference { + fullyQualifiedType: string; + apiVersion: string; +} 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/components/StatusBar.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/status/components/StatusBar.tsx index 249c526d691..66a18895f1b 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/status/components/StatusBar.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/status/components/StatusBar.tsx @@ -1,11 +1,10 @@ // 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 { useStatusApi } from "../api"; import { graphStatusAtom } from "../atoms"; const $StatusBarContainer = styled.div` @@ -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 91a969c131e..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 @@ -3,3 +3,4 @@ export { StatusBar } from "./components/StatusBar"; export { hasNodesAtom, reportGraphStatusAtom } from "./atoms"; +export * from "./api"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/api.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/api.ts new file mode 100644 index 00000000000..12c274e1098 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/api.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { defineNotification, defineRequest } from "@vscode-bicep-ui/messaging"; + +// ── Motion policy ── +// The host resolves the user's effective motion preference, combining the VS Code setting with the +// OS-level reduced-motion preference. + +export type MotionPolicy = "system" | "reduce" | "animate"; + +export const getMotionPolicy = defineRequest("motionPolicy/get"); + +export const motionPolicyDidChange = defineNotification("motionPolicy/didChange"); diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/atoms.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/atoms.ts index a472c0532bb..b7293309eae 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/atoms.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/atoms.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { MotionPolicy } from "@/lib/messaging"; +import type { MotionPolicy } from "./api"; import { atom } from "jotai"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/index.ts index 4d6e7a4f617..f2cafe64d5f 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/index.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/index.ts @@ -3,3 +3,4 @@ export * from "./atoms"; export * from "./use-motion-policy-sync"; +export * from "./api"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/use-motion-policy-sync.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/use-motion-policy-sync.ts index 5ef78159450..f88fb7308b6 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/use-motion-policy-sync.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/use-motion-policy-sync.ts @@ -1,17 +1,17 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { MotionPolicy } from "@/lib/messaging"; +import type { MotionPolicy } from "./api"; -import { useWebviewNotification, useWebviewRequest } from "@vscode-bicep-ui/messaging"; +import { useNotification, useRequest } 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 { getMotionPolicy, motionPolicyDidChange } from "./api"; import { motionPolicyAtom } from "./atoms"; export function useMotionPolicySync() { const setMotionPolicy = useSetAtom(motionPolicyAtom); - const [initialMotionPolicy] = useWebviewRequest(GET_MOTION_POLICY_REQUEST); + const [initialMotionPolicy] = useRequest(getMotionPolicy); useEffect(() => { if (initialMotionPolicy) { @@ -19,15 +19,8 @@ export function useMotionPolicySync() { } }, [initialMotionPolicy, setMotionPolicy]); - useWebviewNotification( - MOTION_POLICY_DID_CHANGE_NOTIFICATION, - useCallback( - (policy: unknown) => { - if (policy === "system" || policy === "reduce" || policy === "animate") { - setMotionPolicy(policy); - } - }, - [setMotionPolicy], - ), + useNotification( + motionPolicyDidChange, + useCallback((policy: MotionPolicy) => setMotionPolicy(policy), [setMotionPolicy]), ); } diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/host.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/host.ts new file mode 100644 index 00000000000..d943a4ef2d7 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/host.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { defineNotification, useWebviewMessageChannel } from "@vscode-bicep-ui/messaging"; +import { useMemo } from "react"; + +/** + * Lifecycle messages exchanged with the extension host. + * + * Everything else in this protocol belongs to one feature and is declared there, in that feature's + * `api.ts`. What remains here is what no single feature owns: the webview's own readiness, and a + * document-changed broadcast that several features independently react to. + * + * This is the *contract*, not the transport. Sending and receiving live in + * `@vscode-bicep-ui/messaging`. + */ + +/** Sent once the webview has mounted and can receive data. */ +export const ready = defineNotification("ready"); + +export interface DocumentDidChangeParams { + documentUri: string; +} + +/** "The document changed; re-fetch whatever you derive from it." */ +export const documentDidChange = defineNotification("documentDidChange"); + +/** + * Host-level operations: announcing readiness, and persisting which document this webview is showing + * so VS Code can restore it. + */ +export function useHostApi() { + const channel = useWebviewMessageChannel(); + + return useMemo( + () => ({ + announceReady: () => channel.notify(ready), + rememberDocument: (documentPath: string) => channel.setState({ documentPath }), + }), + [channel], + ); +} 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 6db4afea5c4..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/messaging/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -export * from "./messages"; 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/utils/math/geometry/point.ts index 51734e5d1f6..7abf314194f 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/utils/math/geometry/point.ts @@ -8,8 +8,6 @@ export interface Point { 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/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); } From 35842ffc0a408af5e14f998b0230532dbabf8ec5 Mon Sep 17 00:00:00 2001 From: Shenglong Li Date: Sat, 29 Aug 2026 13:54:43 -0700 Subject: [PATCH 3/5] Finalize visual designer module boundaries Hoist devtools and cross-cutting hooks, normalize naming and imports, and establish reusable graph, math, theme, and UI modules. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 91f8ba77-9474-4393-aa97-78e82fb5381b --- .../state-management.instructions.md | 4 +- .../apps/visual-designer/README.md | 239 ++++++++ .../visual-designer/architecture-notes.md | 521 ------------------ .../{ => docs}/resource-creation-design.md | 116 ++-- .../{ => docs}/visual-graph-protocol.md | 4 +- .../apps/visual-designer/eslint.config.mjs | 92 ++++ .../apps/visual-designer/package.json | 1 + .../apps/visual-designer/src/app/App.tsx | 6 +- .../visual-designer/src/app/AppProviders.tsx | 17 +- .../visual-designer/src/app/GlobalStyle.ts | 2 +- .../devtools/components/DevAppShell.tsx | 0 .../devtools/components/DevToolbar.tsx | 4 +- .../devtools/fakes/fake-graph-differ.ts | 2 +- .../devtools/fakes/fake-message-channel.ts | 13 +- .../devtools/hooks/use-dev-channel.ts | 0 .../visual-designer/src/devtools/index.ts | 26 + .../__tests__/atoms.test.ts | 0 .../{deployment-graph => canvas}/api.ts | 25 +- .../{deployment-graph => canvas}/atoms.ts | 4 +- .../components/CanvasView.tsx} | 54 +- .../components/PendingResourceLayer.tsx | 0 .../components/ResourceCreationError.tsx | 0 .../components/nodes/ModuleNode.tsx | 0 .../components/nodes/NodeContentProvider.tsx | 4 +- .../components/nodes/ResourceNode.tsx | 8 +- .../components/nodes/ResourceNodePreview.tsx | 0 .../hooks/use-apply-graph.ts | 13 +- .../hooks/use-graph-update.ts | 8 +- .../{deployment-graph => canvas}/index.ts | 4 +- .../src/features/canvas/types.ts | 14 + .../__tests__/layout-invalidation.test.ts | 85 ++- .../utils/layout-invalidation.ts | 73 ++- .../canvas/utils}/viewport.ts | 2 +- .../controls/components/ControlBar.tsx | 8 +- .../features/deployment-graph/animations.ts | 7 - .../deployment-graph/utils/graph-equality.ts | 58 -- .../src/features/devtools/index.ts | 20 - .../features/export/__tests__/atoms.test.ts | 40 ++ .../src/features/export/atoms.ts | 17 +- .../export/components/ExportAreaCover.tsx | 4 +- .../export/components/ExportToolbar.tsx | 34 +- .../src/features/palette/atoms.ts | 2 +- .../components}/MotionAwareProgressBar.tsx | 2 +- .../features/palette/components/Palette.tsx | 17 +- .../palette/components/PaletteContent.tsx | 3 +- .../palette/components/PaletteControls.tsx | 2 +- .../palette/components/PaletteDragOverlay.tsx | 2 +- .../palette/components/ResourceTypeGroups.tsx | 2 +- .../{use-drag.ts => use-palette-drag.ts} | 0 .../hooks/use-resource-type-catalog.ts | 2 +- .../palette/hooks/use-resource-type-search.ts | 2 +- .../src/features/palette/types.ts | 6 - .../src/{lib/accessibility => hooks}/index.ts | 3 +- .../src/hooks/use-document-sync.ts | 67 +++ .../src/hooks/use-motion-policy-sync.ts | 39 ++ .../src/lib/accessibility/api.ts | 14 - .../src/lib/accessibility/atoms.ts | 8 - .../accessibility/use-motion-policy-sync.ts | 26 - .../src/lib/graph/atoms/graph.ts | 2 +- .../src/lib/graph/atoms/nodes.ts | 2 +- .../src/lib/graph/components/AtomicNode.tsx | 8 +- .../src/lib/graph/components/BaseNode.tsx | 2 +- .../src/lib/graph/components/CompoundNode.tsx | 10 +- .../src/lib/graph/components/EdgeLayer.tsx | 4 +- .../src/lib/graph/components/Graph.tsx | 2 +- .../src/lib/graph/components/NodeContent.tsx | 4 +- .../src/lib/graph/components/NodeLayer.tsx | 2 +- .../src/lib/graph/components/StraightEdge.tsx | 6 +- .../components/{Canvas.tsx => Viewport.tsx} | 10 +- ...sBackground.tsx => ViewportBackground.tsx} | 6 +- .../src/lib/graph/components/index.ts | 2 +- .../src/lib/graph/hooks/use-box-update.ts | 2 +- .../src/lib/graph/hooks/use-fit-view.ts | 6 +- .../lib/graph/hooks/use-node-activation.ts | 2 +- .../visual-designer/src/lib/graph/index.ts | 2 +- .../visual-designer/src/lib/graph/theme.ts | 30 + .../apps/visual-designer/src/lib/host.ts | 42 -- .../src/lib/{utils => }/math/comparison.ts | 0 .../src/lib/{utils => }/math/geometry/box.ts | 0 .../lib/{utils => }/math/geometry/index.ts | 0 .../lib/{utils => }/math/geometry/point.ts | 2 +- .../src/lib/{utils => }/math/index.ts | 0 .../FloatingPanel.tsx} | 9 +- .../src/ui/{ => components}/IconButton.tsx | 8 +- .../apps/visual-designer/src/ui/index.ts | 6 +- .../apps/visual-designer/src/ui/motion.ts | 13 + .../visual-designer/src/ui/theme/styled.d.ts | 30 +- .../visual-designer/src/ui/theme/themes.ts | 32 +- .../src/{lib => }/utils/errors.ts | 0 .../src/{lib => }/utils/index.ts | 3 +- .../src/{lib => }/utils/text.ts | 0 src/vscode-bicep-ui/eslint.config.mjs | 62 --- src/vscode-bicep-ui/package-lock.json | 26 +- .../packages/messaging/package.json | 1 + 94 files changed, 1006 insertions(+), 1056 deletions(-) create mode 100644 src/vscode-bicep-ui/apps/visual-designer/README.md delete mode 100644 src/vscode-bicep-ui/apps/visual-designer/architecture-notes.md rename src/vscode-bicep-ui/apps/visual-designer/{ => docs}/resource-creation-design.md (79%) rename src/vscode-bicep-ui/apps/visual-designer/{ => docs}/visual-graph-protocol.md (97%) create mode 100644 src/vscode-bicep-ui/apps/visual-designer/eslint.config.mjs rename src/vscode-bicep-ui/apps/visual-designer/src/{features => }/devtools/components/DevAppShell.tsx (100%) rename src/vscode-bicep-ui/apps/visual-designer/src/{features => }/devtools/components/DevToolbar.tsx (94%) rename src/vscode-bicep-ui/apps/visual-designer/src/{features => }/devtools/fakes/fake-graph-differ.ts (99%) rename src/vscode-bicep-ui/apps/visual-designer/src/{features => }/devtools/fakes/fake-message-channel.ts (99%) rename src/vscode-bicep-ui/apps/visual-designer/src/{features => }/devtools/hooks/use-dev-channel.ts (100%) create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/devtools/index.ts rename src/vscode-bicep-ui/apps/visual-designer/src/features/{deployment-graph => canvas}/__tests__/atoms.test.ts (100%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/{deployment-graph => canvas}/api.ts (90%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/{deployment-graph => canvas}/atoms.ts (83%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/{deployment-graph/components/DeploymentGraphView.tsx => canvas/components/CanvasView.tsx} (72%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/{deployment-graph => canvas}/components/PendingResourceLayer.tsx (100%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/{deployment-graph => canvas}/components/ResourceCreationError.tsx (100%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/{deployment-graph => canvas}/components/nodes/ModuleNode.tsx (100%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/{deployment-graph => canvas}/components/nodes/NodeContentProvider.tsx (96%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/{deployment-graph => canvas}/components/nodes/ResourceNode.tsx (96%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/{deployment-graph => canvas}/components/nodes/ResourceNodePreview.tsx (100%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/{deployment-graph => canvas}/hooks/use-apply-graph.ts (96%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/{deployment-graph => canvas}/hooks/use-graph-update.ts (98%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/{deployment-graph => canvas}/index.ts (60%) create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/types.ts rename src/vscode-bicep-ui/apps/visual-designer/src/features/{deployment-graph => canvas}/utils/__tests__/layout-invalidation.test.ts (62%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/{deployment-graph => canvas}/utils/layout-invalidation.ts (57%) rename src/vscode-bicep-ui/apps/visual-designer/src/{lib/graph => features/canvas/utils}/viewport.ts (94%) delete mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/animations.ts delete mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/utils/graph-equality.ts delete mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/index.ts create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/export/__tests__/atoms.test.ts rename src/vscode-bicep-ui/apps/visual-designer/src/{ui => features/palette/components}/MotionAwareProgressBar.tsx (88%) rename src/vscode-bicep-ui/apps/visual-designer/src/features/palette/hooks/{use-drag.ts => use-palette-drag.ts} (100%) rename src/vscode-bicep-ui/apps/visual-designer/src/{lib/accessibility => hooks}/index.ts (71%) create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/hooks/use-document-sync.ts create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/hooks/use-motion-policy-sync.ts delete mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/api.ts delete mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/atoms.ts delete mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/use-motion-policy-sync.ts rename src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/{Canvas.tsx => Viewport.tsx} (92%) rename src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/components/{CanvasBackground.tsx => ViewportBackground.tsx} (94%) create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/theme.ts delete mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/lib/host.ts rename src/vscode-bicep-ui/apps/visual-designer/src/lib/{utils => }/math/comparison.ts (100%) rename src/vscode-bicep-ui/apps/visual-designer/src/lib/{utils => }/math/geometry/box.ts (100%) rename src/vscode-bicep-ui/apps/visual-designer/src/lib/{utils => }/math/geometry/index.ts (100%) rename src/vscode-bicep-ui/apps/visual-designer/src/lib/{utils => }/math/geometry/point.ts (87%) rename src/vscode-bicep-ui/apps/visual-designer/src/lib/{utils => }/math/index.ts (100%) rename src/vscode-bicep-ui/apps/visual-designer/src/ui/{Surface.tsx => components/FloatingPanel.tsx} (55%) rename src/vscode-bicep-ui/apps/visual-designer/src/ui/{ => components}/IconButton.tsx (82%) create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/ui/motion.ts rename src/vscode-bicep-ui/apps/visual-designer/src/{lib => }/utils/errors.ts (100%) rename src/vscode-bicep-ui/apps/visual-designer/src/{lib => }/utils/index.ts (84%) rename src/vscode-bicep-ui/apps/visual-designer/src/{lib => }/utils/text.ts (100%) 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 7cb70350b7f..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 @@ -18,8 +18,8 @@ description: "Use when working with shared state, atoms, Jotai, or state managem ## Project Layout -See `architecture-notes.md` for module structure, dependency direction, and naming. Do not duplicate -that guidance here. +See the app [README](../../README.md) for module structure, dependency direction, and naming. Do not +duplicate that guidance here. Atom placement follows from it: 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..d5c533c2e35 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/README.md @@ -0,0 +1,239 @@ +# Bicep Visual Designer + +A React webview that renders a Bicep file's deployment graph and lets you edit it — pan and zoom the +canvas, reveal a node's source, export a diagram, and create resources from a palette. + +It runs inside the `vscode-bicep` extension, which builds it and serves the bundle from +`out/visual-designer/`. In development it runs standalone against a fake extension host, so you can +work on it without launching VS Code. + +## Getting started + +Run everything from the workspace root (`src/vscode-bicep-ui`) so sibling packages resolve: + +```bash +npm install +npm run build # turbo: builds packages, then apps +``` + +Then, from `apps/visual-designer`: + +```bash +npm run dev # standalone dev server against the fake host +npm run test # vitest unit tests +npm run e2e # playwright end-to-end tests (npm run e2e:install first) +npm run lint +``` + +`npm run build` must run from the workspace root: `tsc -b` in the app cannot resolve +`@vscode-bicep-ui/*` on its own. `lint` runs with `--max-warnings 0` and +`--report-unused-disable-directives`, so warnings and stale suppressions both fail. + +The dev server loads `devtools/`, a fake extension host that implements the whole protocol. Query +parameters drive it — `?catalogDelay=…` holds the palette's loading state open, for instance — which +is how e2e reaches states the real host would race. + +## Project structure + +| Layer | Path | Contains | +| ---------- | --------------- | ------------------------------------------------------------------------- | +| `app` | `src/app/` | Composition root: provider stack, wiring, global style. No product logic. | +| `features` | `src/features/` | User-facing capabilities. Owns product state and Bicep vocabulary. | +| `devtools` | `src/devtools/` | A fake extension host so the webview runs standalone. Dev-only. | +| `hooks` | `src/hooks/` | Cross-cutting concerns, each owning its own host conversation. | +| `ui` | `src/ui/` | Workflow-neutral primitives, motion tokens and theme. No Bicep knowledge. | +| `lib` | `src/lib/` | Reusable libraries: the headless graph engine and the math library. | +| `utils` | `src/utils/` | Shared helpers belonging to no library: text casing, error messages. | + +```text +app -> features, ui, hooks, lib, utils, devtools +devtools -> features, ui, hooks, lib, utils +features -> ui, hooks, lib, utils, other features (barrel only, acyclic) +ui -> lib, utils +hooks -> lib, utils +lib -> lib, utils +utils -> utils +``` + +Everything not listed is forbidden, including `ui -> hooks`, which keeps primitives taking props +rather than reaching into global state. + +```text +src/ + app/ # App, AppProviders, GlobalStyle + features/ + canvas/ # the design surface: hydrates and edits the deployment graph + components/ # CanvasView, PendingResourceLayer, nodes/ + hooks/ # use-graph-update (the update state machine), use-apply-graph + utils/ # layout-invalidation, viewport + api.ts atoms.ts types.ts + palette/ # resource type catalog, search, drag-to-create + controls/ export/ status/ + hooks/ # use-document-sync, use-motion-policy-sync + devtools/ # components/, hooks/, fakes/ + lib/ + graph/ # atoms/, components/, hooks/, theme.ts + math/ # geometry/, comparison.ts + ui/ # components/, motion.ts, theme/ + utils/ # text.ts, errors.ts +``` + +### What goes where + +The line between `lib` and `features` is **not** "logic vs. UI". It is _would this still make sense in +an app that had nothing to do with Bicep?_ A headless graph engine would. A pending-resource +reconciler would not. + +`lib` holds **libraries** — code with a subject of its own, which is why `geometry/` sits inside +`lib/math` rather than beside it. `utils` holds what is left when every library has taken its own. + +A **cross-cutting concern is not a feature**, even when it owns protocol and state. Motion policy and +the document are consulted by the whole app and render nothing, so they live in `src/hooks/` as single +self-contained files: descriptor, atom and sync hook are one thought. + +`devtools` is **not a feature** either. 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`. +Only `app` may import it, and `loadDevAppShell` returns `undefined` in production so the chunk +tree-shakes away. + +Feature-to-feature imports are fine — `palette` renders the node preview a dropped resource will +become — but they must go through the target's `index.ts` and must not form a cycle. Resolve a cycle +by putting each shared symbol with its real owner, not by forbidding the edge. + +### Feature shape + +Every feature and `lib` module organises its contents the same way, so a reader who opens one can +guess where things are in any other: + +| Folder / file | Holds | +| ------------- | ------------------------------------------------------------------------- | +| `components/` | Components, including any used only inside the feature. | +| `hooks/` | Reusable `use-*` hooks. | +| `utils/` | Pure helpers with no React dependency. | +| `api.ts` | The host protocol this feature uses: descriptors and payload shapes. | +| `atoms.ts` | Feature state. Splits into `atoms/` only when it holds distinct concerns. | +| `types.ts` | Shared domain vocabulary. | + +Include only what a feature needs, and add a folder when its first member arrives rather than +scaffolding it empty. Concept subfolders are allowed inside a type folder when they name a real seam: +`components/nodes/` is the content plugged into `lib/graph`'s node containers. Tests live in +`__tests__/` beside the code they cover, at whatever depth that is. + +### Naming + +- **Components**: PascalCase, filename equals the exported component. One primary component per file. +- **Everything else** (hooks, atoms, utils, types) and **folders**: kebab-case. +- **A hook file is named for the hook it exports** — `use-palette-drag.ts` exports `usePaletteDrag`. + Worth a mechanical check when adding one: the drift is invisible at the import site. +- **Name a feature for the capability it delivers, not the data it displays.** "Deployment graph" is + Bicep's own term for the payload the host sends, so it names the wire types (`DeploymentGraph`, + `DeploymentGraphNode`) — while `CanvasView`, `CanvasSurface` and `useCanvasApi` name the surface. + One set of words must not do both jobs. +- **Name a thing for what it is in the domain, not its visual container.** `ResourceNodePreview`, not + `ResourcePreviewCard`; "card" describes a border radius. +- Prefer one-word folders, and treat a compound name as a prompt to check whether the folder is doing + two jobs or dodging a collision. A two-word name for one real concept is fine. +- Do not prefix a file with the folder containing it. This yields to the hook-file rule above. + +### Public surface + +Each feature, `lib` module and `src/hooks/` exposes exactly one entry point: its `index.ts`. + +- Import through the barrel: `@/lib/graph`, `@/features/export`, `@/hooks`. +- Do not deep-import across a boundary; `@/lib/math/geometry` is `@/lib/math`. +- **Within a module, import relatively** — never `@/its-own-name/...`. Reaching your own siblings + through the barrel creates a cycle, and an aliased deep path sits one keystroke from the form that + does. +- Feature barrels export an intended surface; `lib` and `ui` keep `export *`. +- Export only what crosses the boundary. Because `api.ts` is re-exported through the barrel, + `noUnusedLocals` stops seeing a symbol once it is exported, so unused payload types accumulate + silently. + +### State + +| State | Owner | +| --------------------------------------------------------- | ------------------------------ | +| Canonical graph nodes, edges, boxes, bounds, focus | `lib/graph` | +| Node content registration, pending resources, transitions | `features/canvas` | +| Palette interaction and resource catalog | `features/palette` | +| Export workflow | `features/export` | +| User-facing graph status | `features/status` | +| Effective motion policy | `hooks/use-motion-policy-sync` | +| Document being visualized | `hooks/use-document-sync` | +| Active theme | `ui/theme` | + +Jotai for shared state that benefits from isolated subscriptions; local state when it has one owner. +Across a boundary expose derived values and action atoms, never raw writable atoms. + +**Graph actions are explicit props**, drilled one level from `CanvasView` to `ControlBar` and +`Palette`. A free-standing hook would invite a second `useGraphUpdate` instance, and that hook is a +single-instance state machine holding the client's mirror of the server's graph — a second one would +corrupt patch application. This is correctness, not style. + +### Protocol + +Each feature declares the host messages it uses in its own `api.ts`, and exposes them through an API +hook (`useCanvasApi`, `usePaletteApi`) so callers make method calls instead of hand-assembling +messages. Descriptors are typed via `defineRequest` / `defineNotification` from +`@vscode-bicep-ui/messaging`, which owns _how_ to talk while each feature owns _what it says_. + +Payloads are suffixed `Params` and `Result`. A descriptor and the API method that sends it share a +name deliberately — they are one operation named at two levels. + +See [visual-graph-protocol.md](./docs/visual-graph-protocol.md) for the wire contract itself. + +### Enforcement + +Structure rules that are not machine-checked decay. `../../eslint.config.mjs` carries import-boundary +rules scoped to this app, built on core `no-restricted-imports` with flat-config `files` zones: + +- each layer's forbidden imports, per the table above +- `src/lib/graph/**` may not import any messaging module + +The last is not a layer rule. `lib/graph -> a messaging module` is a legal `lib -> lib` edge, so +nothing else would stop the engine from learning the host protocol; Bicep behaviour reaches it through +`nodeConfigAtom` instead. `lib/graph/theme.ts` closes the same kind of gap for styling: the engine +declares the theme tokens it needs, and `DefaultTheme` extends that interface, so dropping one is a +compile error rather than a blank canvas. + +The shared-`hooks` and `utils` layers are matched through the `@/` alias only, because both are also +folder names inside features and `**/hooks/**` would flag every feature's own `../hooks/use-x`. + +## Testing + +Most behavioural coverage is end-to-end in `e2e/` (Playwright): palette visibility, pointer and +keyboard placement, drop rejection, catalog loading and search. Unit tests cover the pieces with logic +worth isolating in a store — graph atoms, layout invalidation, the export file stem. + +Prefer assertions that cannot race. Poll for a settled value rather than sampling once: nodes animate +in and the graph springs to its layout over ~0.6s, so a single read taken when a node appears can land +mid-flight. + +**The resource-creation failure path has no coverage.** The host reports failures as +`CreateResourceErrorResult` from four call sites, but the dev fake always succeeds, so neither the +error atom nor `ResourceCreationError` is exercised. Teaching the fake to fail on demand — the way +`catalogDelay` makes the loading state reachable — is the missing piece. + +## Known gaps + +- Fold the legacy `DeploymentGraph` shape out of `canvas/api.ts` once the position-preserving apply + path no longer needs it. +- `Palette` hand-rolls a second floating-panel style from raw `var(--vscode-*)` values at a different + radius; folding it onto `FloatingPanel` is the duplication `ui/` exists to remove. +- Two module-scope `getDefaultStore()` handles in `features/canvas/hooks` should come from context, so + the sync pipeline can be driven by a scoped store in tests. +- Share the protocol declarations with the extension host. `vscode-bicep` dispatches on raw string + literals and casts params with `as`, so the two sides agree only by convention. It has no npm + dependency on `vscode-bicep-ui` today, so this needs a `file:` dependency and a build-order + constraint — and should be per-feature modules in a shared package, not one central protocol file. +- Extract `lib/graph` into `packages/` **when a second consumer appears, not before**. It is prepared: + a clean barrel, a documented injection seam (`nodeConfigAtom`), a declared theme contract, and no + Bicep knowledge, enforced by lint. The likely consumer is the playground, which sits outside this npm + workspace and shares none of the engine's runtime dependencies. + +## Further reading + +- [visual-graph-protocol.md](./docs/visual-graph-protocol.md) — the server-driven graph and layout protocol. +- [resource-creation-design.md](./docs/resource-creation-design.md) — the resource creation feature design. +- [.github/instructions/](./.github/instructions) — React, state management and styling conventions, + applied automatically by Copilot when editing matching files. 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 e8bd8ebc91c..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/architecture-notes.md +++ /dev/null @@ -1,521 +0,0 @@ -# Visual Designer Architecture - -This describes how the app is structured and why. The code currently matches it; where the two -disagree, the code is wrong. - -Apply it incrementally when changing related areas. Do not treat it as a mandate for one large rewrite. - -## Layers - -| Layer | Path | Contains | -| ---------- | --------------- | -------------------------------------------------------------------------------- | -| `app` | `src/app/` | Composition root: provider stack, wiring, global style. No product logic. | -| `features` | `src/features/` | User-facing capabilities. Owns product state and Bicep vocabulary. | -| `ui` | `src/ui/` | Workflow-neutral visual primitives and theme. Knows nothing about Bicep. | -| `lib` | `src/lib/` | Workflow-neutral infrastructure: headless graph engine, transport, policy, math. | - -Allowed dependency directions: - -```text -app -> features, ui, lib -features -> ui, lib, other features (barrel only, acyclic) -ui -> lib -lib -> lib -``` - -Forbidden: - -- `lib -> features`, `lib -> ui`, `lib -> app` -- `ui -> features`, `ui -> app` -- cycles between features - -Feature-to-feature imports are permitted but discouraged. They must go through the target feature's -`index.ts`, and they must import a component, a derived atom, or an action atom — never a raw writable -atom that both features write. When two features need the same visual element, move the element to -`ui/` rather than importing across the boundary. - -The distinction between `lib` and `features` is **not** "logic vs. UI". It is "would this still make -sense in an app that had nothing to do with Bicep?" A headless graph engine and a motion-preference -policy would. A pending-resource reconciler would not. - -## Naming - -These rules are enforced by review, not tooling. Where a rule and the code disagree, the code is wrong. - -- **Components**: PascalCase; the filename equals the exported component name. One primary component - per file. A file named for a plural or a category must be split, which is why `ControlPrimitives.tsx` - became `ui/Surface.tsx` and `ui/IconButton.tsx`. -- **Non-components** (hooks, atoms, utils, types): kebab-case. -- **Folders**: kebab-case. -- **Prefer one-word folders under `features/` and `lib/`**, and treat a compound name as a prompt to - check whether the folder is doing two jobs or borrowing a qualifier to dodge a collision. The - features `palette`, `controls`, `export`, `status` and `devtools` pass, as do the modules `graph`, - `messaging`, `accessibility` and `utils`. `resource-palette` did not earn its prefix — there is one - palette — so it is `palette`, and its components dropped the matching prefix with it. -- **Accuracy in the shared layer outranks brevity in the feature layer.** `features/deployment-graph` - keeps its qualifier because the one-word alternative would require renaming `lib/graph`, and `graph` - is precisely what that module is about. The qualifier is also not a dodge: "deployment graph" is the - Bicep product's own name for this concept, not a webview coinage. It is the JSON-RPC method - `bicep/getDeploymentGraph` in `Bicep.Cli/Rpc/ICliJsonRpcProtocol.cs` and it appears in the public API - surface of `Azure.Bicep.RpcClient`. A two-word name that names one real concept is fine; a two-word - name invented to avoid a collision is the smell. -- Name a feature for the capability it delivers, not the surface it draws on. `features/canvas` would - fail twice over: it describes the substrate rather than the capability, and it collides with the - `Canvas` component that `lib/graph` exports. `visualization`, `visualizer` and `designer` are - likewise unavailable — they name the entire app (`bicep.visualizer`, "Open Bicep Visualizer", "Bicep - visual designer"), so using one for a single feature would imply the others are outside it. -- Do not prefix a file with the folder that already contains it: inside `palette/`, a file named - `resource-palette-utils.ts` should just be `utils.ts`, and `use-palette-drag.ts` becomes - `use-drag.ts`. Components follow the same rule but keep whatever their exported name needs to stand - alone at the call site: `ResourcePalette.tsx` becomes `Palette.tsx` because `` still reads, - while `components/nodes/NodeContentProvider.tsx` keeps its `Node` because `` would - be meaningless where it is mounted. -- Put shared domain vocabulary in the feature's `types.ts`, alongside `atoms.ts` at the feature root. - Component props stay in the file that declares the component, because they are already colocated - with the only code that owns them — `PaletteProps` lives in `Palette.tsx`, not `types.ts`. A single - type used in one place does not need a home of its own: `ExportBackgroundMode` sits beside the export - atoms that consume it. -- Name a thing for what it is in the domain, not for its visual container or its layer. Prefer - `ResourceNodePreview` over `ResourcePreviewCard`: it is the preview of a resource node, and "card" - describes a border radius. Drop meaningless qualifiers such as the `Visual` in - `VisualResourceTypeReference` and `useApplyVisualGraph`. -- `lib/graph` owns the generic node containers (`BaseNode`, `AtomicNode`, `CompoundNode`). - `features/deployment-graph/components/nodes/` owns the Bicep content rendered inside them. Both may - use "Node"; the folder disambiguates. Prefer `ResourceNode` / `ModuleNode` over `ResourceDeclaration` - / `ModuleDeclaration`, because these render graph nodes, not source declarations. - -## Folder structure inside a feature - -**Every feature has the same shape.** A reader who opens one feature should be able to guess where -things are in any other, so features and `lib` modules organise their contents the same way: - -| Folder | Holds | -| ------------- | ------------------------------------------------------------------------- | -| `components/` | Components, including any that are only used inside the feature. | -| `hooks/` | Reusable `use-*` hooks. | -| `utils/` | Pure helpers with no React dependency. | -| `api.ts` | The host protocol this feature uses: method constants and payload shapes. | -| `atoms.ts` | Feature state. Splits into `atoms/` only when it holds distinct concerns. | -| `types.ts` | Shared domain vocabulary. | - -Files that belong to the feature as a whole stay at its root next to `index.ts`, alongside -concept-named files such as `animations.ts` for shared transition constants. Only include the folders -and files a feature actually needs, and add one when its first member arrives rather than scaffolding -it empty. - -This is deliberately uniform rather than minimal. A `components/` folder holding one file carries no -information on its own, and grouping by file type does split some cohesive subsystems: the graph sync -pipeline now spans `hooks/use-graph-update.ts` and `utils/layout-invalidation.ts`. The trade is -accepted because predictability across features is worth more than locally optimal grouping, and -because `lib/graph` already used `atoms/`, `components/`, `hooks/` — so the alternative was not "no -type folders" but "type folders in `lib`, flat features", which is the worse kind of inconsistency. - -Concept subfolders are still allowed **inside** a type folder when they name a real seam. -`components/nodes/` is the interchangeable content plugged into `lib/graph`'s node containers through -`renderContent`; those four files share a preview and a commit transition, and the grouping survives -because it says something the surrounding folder does not. - -Colocate tests in a `__tests__/` folder beside the code they cover, at whatever depth that code lives. - -## Shape - -Abbreviated: folders whose contents are unremarkable are shown by name only. - -```text -src/ - app/ # composition root - App.tsx - AppProviders.tsx - GlobalStyle.ts - - features/ - deployment-graph/ # the Bicep deployment graph surface - components/ - DeploymentGraphView.tsx # canvas subtree, update loop, client-coordinate contract - PendingResourceLayer.tsx - ResourceCreationError.tsx - nodes/ # content rendered inside lib/graph node containers - NodeContentProvider.tsx # registers the renderers below - ResourceNode.tsx - ModuleNode.tsx - ResourceNodePreview.tsx - hooks/ - use-graph-update.ts # the update state machine - use-apply-graph.ts - utils/ - layout-invalidation.ts - graph-equality.ts - api.ts # graph, layout and resource-creation messages - animations.ts - atoms.ts - - palette/ - components/ # Palette, PaletteContent, PaletteControls, - # PaletteDragOverlay, ResourceTypeGroups - hooks/ # use-drag, use-resource-type-catalog, - # use-resource-type-search, - # use-resource-creation-enablement - api.ts # enablement and resource-type catalog messages - atoms.ts - types.ts # resource-type vocabulary - - controls/ # components/ControlBar, hooks/use-reset-layout, atoms.ts - export/ # components/, utils/capture-element.ts, atoms.ts - status/ # components/StatusBar, atoms.ts - devtools/ # components/, hooks/, fakes/ - - lib/ - accessibility/ # motion policy state and host synchronisation - graph/ # atoms/, components/, hooks/, viewport.ts - host.ts # webview lifecycle: ready, document-changed - utils/ # math/, text.ts, errors.ts - - ui/ # shared primitives, flat - IconButton.tsx - Surface.tsx - MotionAwareProgressBar.tsx - theme/ -``` - -Create folders as their contents move. Do not scaffold empty directories. - -## Ownership - -### `features/deployment-graph` - -Owns everything Bicep-specific about the graph surface: - -- resource and module node presentation, and the mapping from generic node kind to that presentation; -- the notify-then-request graph update state machine, including single-in-flight and dirty-flag - convergence; -- patch application to the `lib/graph` atoms, layout centering, and layout invalidation; -- pending resource placement, preview rendering, and reconciliation to canonical nodes; -- the per-node creation transition and the creation error surface; -- fit-view and reset-layout behavior. - -It exposes a narrow contract to other features, stated in **client coordinates**: - -```ts -createResource(resourceType: ResourceTypeReference, clientPoint?: Point): Promise -canPlaceAt(clientPoint: Point): boolean -``` - -Omitting `clientPoint` means "use the feature's default placement", which is how keyboard activation -should create a resource. `features/deployment-graph` owns what that default is; today the viewport-center rule -lives in `App.tsx` instead. - -Client coordinates are the boundary on purpose. Converting a pointer position into a graph position -needs the canvas rect and the pan/zoom transform, both of which are graph knowledge; handing those to a -caller would push the geometry into whichever feature happened to ask. `canPlaceAt` covers the one -question the palette legitimately has: whether a pointer release landed on the graph surface at all, -which decides between creating and silently cancelling a drag. - -`useGraphUpdate` is a single-instance state machine: it holds the client-side mirror of the server's -canonical graph, and a second instance would diverge from the first and corrupt patch application. -Instantiate it in `DeploymentGraphView` and pass its actions down as explicit props. Do not expose it -as a free `useCanvasActions()`-style hook that any component may call, because the plain-hook form of -that API silently creates a second state machine. If a context-backed accessor is ever needed, it must -wrap one provider-held instance. - -This feature publishes user-facing status through a `features/status` action atom rather than writing -`errorCountAtom` and `hasNodesAtom` directly, so `features/status` keeps sole ownership of how status -is derived. - -`PendingResourceLayer` positions a `ResourceNodePreview` per pending operation. `PaletteDragOverlay` -renders the same component, so `ResourceNodePreview` is the single definition of what an -about-to-exist resource looks like. - -### `features/palette` - -Owns resource discovery and selection: feature enablement, namespace and resource-type loading, -search, palette interaction state, and pointer/keyboard initiation. It does not own graph patches, -pending-node reconciliation, placement math, or source edits. - -It works in client coordinates throughout. It should not import `viewportToGraphPoint`, hold a canvas -DOM handle, or read the pan/zoom transform; it hands `features/deployment-graph` a client point and lets it -decide where that lands in the graph. - -It owns `ResourceTypeReference` in its `types.ts`, and `deployment-graph` imports it through the -barrel to type the creation request — the graph creates what the palette selected. Catalog vocabulary -lives in the same file, and `PaletteProps` sits in `Palette.tsx` beside the component that takes it. - -### Other features - -- `features/controls`: toolbar composition and action availability. Graph, export, and status commands - are provided by their owning features; the control bar only arranges them. -- `features/export`: export state, preview, capture, and output options. -- `features/status`: user-facing graph and diagnostic status. -- `features/devtools`: development-only controls, fake data, and the fake message channel. - -## Shared infrastructure - -### `lib/graph` - -A headless, Bicep-agnostic graph engine, and it must stay one. It owns the canonical node and edge -atoms, boxes, bounds and focus; the generic node containers; `Canvas`, `Graph`, `CanvasBackground`, -`EdgeLayer`, `EdgeMarkerDefs` and `StraightEdge`; and the fit-view, drag and measurement hooks. - -Keep the name. `graph` is what this module is actually about — nodes, edges and layout — and renaming -it to free the word for a feature would trade accuracy in the shared layer for brevity in one folder -name. The Bicep feature carries the qualifier instead, which is also where the qualifier is true. - -Bicep enters only through `nodeConfigAtom`, which is dependency injection working as intended. It -carries two things: `renderContent`, which maps a generic node kind to Bicep node content, and -`onNodeActivate`, which decides what a double-click means. `NodeContentProvider` fills both, hydrating -the config during render rather than in an effect — the default `renderContent` throws, so an effect -would be too late if a node mounted in the first pass. Scoping the write to the store from context also -keeps it out of module scope, where an earlier version ran at import time against the default store and -could not be undone or scoped to a test store. - -`onNodeActivate` exists because the engine had in fact grown host knowledge: `AtomicNode` and -`CompoundNode` each carried an identical double-click handler that cast node data to -`{ range, filePath }` and sent `revealFileRange` / `revealNodeSource` notifications directly. The -layer rule could not catch it, because `lib/graph -> lib/messaging` was a legal `lib -> lib` edge. A -second, narrower lint zone now forbids `lib/graph` from importing any messaging module at all, so the -claim at the top of this section is machine-checked rather than aspirational. - -`viewportToGraphPoint` belongs here, not in the palette. It converts client coordinates using the -pan/zoom transform, which is engine knowledge the palette merely consumes. - -### `lib/accessibility` - -Owns cross-cutting accessibility policy: the effective motion preference and its synchronization with -VS Code settings. This is policy infrastructure with no user-facing surface of its own, so it is `lib` -rather than a feature. Placing it in `lib` is also what lets `ui/MotionAwareProgressBar` read it -without a `ui -> features` violation. - -Component-specific keyboard and ARIA behavior stays with the component. - -### Protocol declarations - -Each feature declares the host protocol it uses in its own `api.ts`. Measured across the app, this is -what the code already wanted: of the symbols in the former shared `messages.ts`, all but two belonged -to exactly one feature. `deployment-graph` owns the graph update, layout and resource-creation -messages; `palette` owns enablement and the resource-type catalog; `status` owns the problems-panel -notification; `lib/accessibility` owns motion policy. - -`lib/host.ts` holds what no feature owns: `ready` (the webview mounted) and `documentDidChange` (a -broadcast that several features independently react to). - -Each `api.ts` has three layers: - -| | Example | -| ----------------------------------- | ------------------------- | -| Descriptor, named for the operation | `createResource` | -| Outgoing payload, suffixed `Params` | `CreateResourceParams` | -| Incoming payload, suffixed `Result` | `CreateResourceResult` | -| Method on the feature's API hook | `api.createResource(...)` | - -A descriptor and the API method that sends it may share a name, and often should — they are the same -operation named at two levels, and `channel.request(createResource, params)` supplies the verb from -context. This is safe rather than merely tolerable: an object property is not a binding, so -`createResource: (params) => channel.request(createResource, params)` resolves the argument to the -module-level descriptor exactly as intended. - -Shadowing would only occur where a file both imports a descriptor and binds that name locally, and the -API hooks removed that possibility: components call `api.revealNodeSource(id)` and no longer import -descriptors at all. Only `api.ts` and the fake host reference them, plus the subscription sites that -must pass one to `useNotification`. - -`Params`/`Result` replace an earlier mix of `Payload`, `Request` and `Params` for the same idea. -Domain vocabulary keeps its own name: `loadResourceTypeCatalog` resolves to `ResourceTypeCatalog`, not -a `...Result` envelope, because the catalog is a shared type in `types.ts` rather than a shape that -exists only to be a response. - -Notification names follow direction rather than a single tense. `documentDidChange` is an event the -host announces; `revealNodeSource` is a command the webview sends. That distinction is worth keeping. - -The split also removes a name collision. `@vscode-bicep-ui/messaging` is the transport — the channel -and its hooks — and an app-local module called `messaging` alongside it invited confusion about which -was which. The package owns _how_ to talk; each feature owns _what it says_; `lib/host` owns the -lifecycle in between. - -A feature's `api.ts` is exported through its barrel, because the protocol is part of its public -contract. `features/devtools` is the one legitimate cross-feature consumer: it fakes the entire host, -so it must implement every feature's messages. - -Descriptors are partly redundant for requests, and that is accepted. A request reached only through -its API hook could just as well inline the method string there, since the hook's own signature already -states the params and result. They are kept because two consumers cannot go through the hook: -subscriptions, which are declarative and lifecycle-bound -(`useNotification(documentDidChangeMessage, handler)`), and the fake host, which matches nine incoming -methods against `descriptor.method`. Having one way to declare every message is worth a line per -message over having two. - -### `ui` - -Workflow-neutral visual primitives with more than one consumer, plus theme. Keep components directly -under `ui/` while the set is small. - -Do not introduce `ui/primitives/`. It fails the same test as `components/`, and more sharply: `ui` is -_defined_ as workflow-neutral primitives, so the folder restates the layer's own name and partitions -nothing. It also never becomes correct with growth — at fifteen components the useful split is -`forms/`, `overlays/` or `menus/`, by concept, and every one of those is still a primitive. - -The resulting asymmetry between loose `.tsx` files and a `theme/` folder is intentional and -informative: `theme/` is a cohesive non-component subsystem, and the loose files are components. That -distinction is real, so the shape reflects it. Symmetry is not itself a goal. - -`ui/theme` owns theme tokens, theme objects, the styled-components module augmentation, and VS Code -theme synchronization. This makes `ui` stateful, which is allowed: theme is read by `app`, `features` -and `ui` alike, and depends on nothing above it. - -`Surface` and `IconButton` are deliberately named for what they are rather than where they came from. -As `ControlSurface` and `ControlButton` in `features/controls` they were already being used by the -palette launcher, so the "Control" prefix pointed at a layer they did not belong to. - -Their theme tokens have not followed yet: both still read `theme.controlBar.*`, so the palette launcher -styles itself from control-bar tokens, and `Palette` hand-rolls a second floating-panel style from -raw `var(--vscode-*)` values at a different radius. Unifying those on one `Surface` with neutrally -named tokens is the kind of duplication `ui/` exists to remove. - -Semantic cards, palette rows, status messages and export panels stay with their features. - -### `lib/utils` - -Generic and dependency-free: `math/`, `text.ts`, and `errors.ts`. Bicep-shaped helpers such as -deployment-graph equality belong to `features/deployment-graph`. - -## State ownership - -| State | Owner | -| --------------------------------------------------------- | --------------------------- | -| Canonical graph nodes, edges, boxes, bounds, focus | `lib/graph` | -| Node content renderer registration | `features/deployment-graph` | -| Pending resource placement and canonical-node correlation | `features/deployment-graph` | -| Per-node creation transition | `features/deployment-graph` | -| Palette interaction and resource catalog state | `features/palette` | -| Export workflow state | `features/export` | -| User-facing graph status | `features/status` | -| Effective motion policy | `lib/accessibility` | -| Active theme | `ui/theme` | - -Use Jotai for shared state that benefits from isolated subscriptions. Keep transient state local when -it has one owner. Across a boundary, expose derived values and action atoms rather than raw writable -atoms. - -Start with `atoms.ts`. Split into `atoms/` with an `index.ts` re-export only once it holds distinct -state concerns, and split it along the same concepts as the surrounding folders rather than into one -file per atom. - -## Public surface - -Each feature and each `lib` module exposes exactly one entry point: its `index.ts`. - -- Import through the barrel: `@/lib/graph`, `@/features/export`. -- Do not deep-import across a boundary. A feature's `api.ts` is reached through its barrel, and - `@/lib/utils/math/geometry` is `@/lib/utils`. -- Deep imports within the same module are fine. -- Feature barrels export the intended surface, not `export *` over every file. The `lib` and `ui` - barrels keep `export *`: they are broad shared surfaces with many legitimate consumers, so - enumerating them would be churn without a boundary benefit. - -## Enforcement - -Structure rules that are not machine-checked decay. `src/vscode-bicep-ui/eslint.config.mjs` carries an -import-boundary rule scoped to this app, built on the core `no-restricted-imports` rule with flat-config -`files` zones, so it needs no extra plugin: - -- `src/lib/**` may not import `src/features/**`, `src/ui/**`, `src/app/**` -- `src/ui/**` may not import `src/features/**`, `src/app/**` -- `src/features/**` may not import `src/app/**` -- `src/lib/graph/**` may not import any messaging module - -The last one is not a layer rule. It exists because the layer rules alone permit `lib -> lib`, which -let the graph engine acquire host-protocol knowledge unnoticed. When a module's value depends on it -_not_ knowing something, say so explicitly rather than trusting the layer diagram to imply it. - -The rules are registered at `error`. The lint script also runs with `--max-warnings 0` and -`--report-unused-disable-directives`, so neither a warning nor a stale suppression can accumulate. - -There was no boundary rule at all before this, which is why two `lib -> features` imports were able to -land. - -## Deliberate non-moves - -- **`StraightEdge` stays in `lib/graph`.** It computes a segment between two box centers and reads a - theme token. It has no Bicep knowledge, so moving it into a feature would relocate generic code into - the product layer and invert the dependency rule. -- **`Canvas` and `CanvasBackground` stay in `lib/graph`.** They are generic pan/zoom surfaces. - `DeploymentGraphView` is the Bicep composition that mounts them, and it is named for the capability - rather than the surface precisely so the two do not blur together. -- **Generic geometry stays in `lib/utils/math`.** `Point`, `Box` and box-segment intersection are - ordinary math with several consumers. Only transform-aware conversion lives in `lib/graph`. -- **`useResetLayout` stays beside `ControlBar`.** It is a generic in-flight dedupe wrapper with no - graph knowledge and a single consumer, so it is neither graph-sync behaviour nor shared - infrastructure. -- **Graph actions stay explicit props.** They are drilled exactly one level, from `DeploymentGraphView` - to `ControlBar` and `Palette`. Props keep the dependency visible and testable, and a - free-standing `useCanvasActions()` hook would invite a second `useGraphUpdate` instance, which is a - correctness bug rather than a style preference. - -## Known tensions - -Places where the structure is a considered compromise rather than an ideal, recorded so they are not -rediscovered as bugs. - -**`features/deployment-graph` is much larger than any other feature.** It holds around eighteen files -while the rest hold three to ten, and it owns presentation, host sync, mutation and placement. The -cleaner decomposition would add a fourth layer between `lib` and `features` for logic that is -Bicep-aware but headless — the sync pipeline, protocol mapping and graph equality — leaving `features` -strictly user-facing. That is rejected here only on size: a four-layer model for a sixty-file app costs -more in indirection and ceremony than it returns. If the sync pipeline keeps growing, promoting -`hooks/use-graph-update.ts` and `utils/` into a `src/domain/` layer is the intended next step rather -than a reversal. - -**`ui/MotionAwareProgressBar` reads global state.** A primitive that reaches into an atom is not -really a primitive. The purer shape is a `ProgressBar` taking an `animated` prop, with callers reading -the motion policy. With a single consumer today the wrapper is a reasonable convenience, but a second -consumer with different needs should trigger the split rather than another variant. - -**`features/status` is thin** — three files behind one status bar. It stays separate because -`features/controls` derives action availability from it and merging the two would create the -bidirectional coupling the layering rules exist to prevent. - -## Tests - -Run `npm run build`, `npm run lint` and `npm test` for any change; run `npm run e2e` when touching -graph updates, placement, or app composition. - -Most behavioural coverage is end-to-end in `e2e/` (Playwright), not unit tests: pointer placement, -keyboard placement, failed edits, concurrent document changes and pending-to-canonical reconciliation -all live in `e2e/resource-creation.spec.ts`. The unit tests cover the two pieces with logic worth -isolating, `features/deployment-graph/__tests__/atoms.test.ts` and -`features/deployment-graph/utils/__tests__/layout-invalidation.test.ts`. - -Tests live in a `__tests__/` folder beside the code they cover, at whatever depth that code lives. -`tsconfig.app.json` already excludes them from the app build. - -Prefer assertions that cannot race. The dev fake channel delays resource-catalog responses to exercise -loading states, and its `catalogDelay` query parameter lets a test hold that state open rather than -competing with the default timing. - -## Possible next steps - -Not required, and not worth doing without a reason: - -- Fold the legacy `DeploymentGraph` shape out of `deployment-graph/api.ts` once the position-preserving apply path no longer needs it. -- Give `Surface` and `IconButton` neutral theme tokens. Both still read `theme.controlBar.*`, so the - palette launcher styles itself from control-bar tokens, and `Palette` hand-rolls a second - floating-panel style from raw `var(--vscode-*)` values at a different radius. Unifying those is the - kind of duplication `ui/` exists to remove. -- Replace the two module-scope `getDefaultStore()` handles in `features/deployment-graph/hooks` with - the store from context, so the sync pipeline can be driven by a scoped store in tests. -- Extract `lib/graph` into `packages/` **when a second consumer appears, not before**. It is already - prepared: a clean barrel, a documented injection seam (`nodeConfigAtom`), and no Bicep knowledge, - enforced by lint. Deliberately not done yet, because today the visual designer is the only consumer — - neither `deploy-pane` nor `resource-type-explorer` references a graph concept — and a package would - cost real friction: `packages/components` resolves through `dist/`, with no source alias in the app's - vite config, so every engine edit would need a package rebuild. The likely second consumer is the - Bicep playground, which is a larger job than a file move: `src/playground` sits outside this npm - workspace and shares none of the engine's runtime dependencies (jotai, styled-components, motion). - The engine's own dependency on `lib/utils` geometry has to be resolved at the same time. - -## Related documents - -This file is the single source of truth for module structure, dependency direction and naming. - -- `.github/instructions/state-management.instructions.md` covers Jotai conventions and defers to this - file for layout. Keep it that way: add atom guidance there, structural guidance here. -- `resource-creation-design.md` and `visual-graph-protocol.md` describe behaviour and protocol, not - structure. Their file-path references predate this layout and are stale in places. diff --git a/src/vscode-bicep-ui/apps/visual-designer/resource-creation-design.md b/src/vscode-bicep-ui/apps/visual-designer/docs/resource-creation-design.md similarity index 79% rename from src/vscode-bicep-ui/apps/visual-designer/resource-creation-design.md rename to src/vscode-bicep-ui/apps/visual-designer/docs/resource-creation-design.md index e070939dd1a..3b4e7312a06 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/resource-creation-design.md +++ b/src/vscode-bicep-ui/apps/visual-designer/docs/resource-creation-design.md @@ -5,9 +5,11 @@ - 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 +- Builds on: the [Visual Graph Protocol](visual-graph-protocol.md). That document defines how the + webview, extension and language server keep the graph in sync. This one describes a feature layered + on top of it, and adds one rule to it: the mutation interlock below. - Related documents: - - [Visual Graph Protocol](visual-graph-protocol.md) - - [Visual Designer Architecture Notes](architecture-notes.md) + - [Visual Designer README](../README.md) ## Enablement @@ -186,6 +188,12 @@ sequenceDiagram ### Concurrent document changes and graph requests +Two interlocks are in play, and they come from different places. The graph loop already allows one +in-flight request at a time and sets a dirty flag when a change arrives during one — that is the +protocol's [concurrency rule](visual-graph-protocol.md#concurrency-rules), independent of this +feature. Resource creation adds a second: while a mutation is in flight, graph responses are deferred, +because a response may already contain the new node before its expected ID is bound to a drop origin. + ```mermaid sequenceDiagram actor User @@ -303,39 +311,19 @@ flowchart LR ### 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 | +| 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 webview side lives in `src/features/palette` (browsing, search, drag initiation) and +`src/features/canvas` (pending state, placement, commit reconciliation). See the +[README](../README.md) for the layer rules those follow. 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. @@ -556,21 +544,21 @@ 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 | +| 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. @@ -593,15 +581,17 @@ Implemented coverage includes: - 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. +- Visual-designer unit tests for graph layout invalidation, range-only change detection, per-node committing atom isolation and export file naming. - 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 + - Progress while the catalog loads + - Lazy global search without expanding providers first - Drop rejection over the Resource Palette - - Keyboard creation at viewport center + - Keyboard creation at canvas center + +Not covered: the creation **failure** path. The host reports failures as `CreateResourceErrorResult` from four call sites, but the dev fake always succeeds, so neither the error atom nor `ResourceCreationError` is exercised. ## Non-goals and Future Work @@ -621,15 +611,15 @@ The source-mutation pipeline can support those operations later while preserving ## 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 | +| 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/visual-graph-protocol.md b/src/vscode-bicep-ui/apps/visual-designer/docs/visual-graph-protocol.md similarity index 97% rename from src/vscode-bicep-ui/apps/visual-designer/visual-graph-protocol.md rename to src/vscode-bicep-ui/apps/visual-designer/docs/visual-graph-protocol.md index f7f44a0e95c..083cde09397 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/visual-graph-protocol.md +++ b/src/vscode-bicep-ui/apps/visual-designer/docs/visual-graph-protocol.md @@ -1,6 +1,8 @@ # Visual Graph Protocol -This document describes the server-driven visual graph protocol used by the Bicep visual designer. +This document describes the server-driven visual graph protocol used by the Bicep visual designer. It +covers the graph and layout contract only; features layered on it, such as +[resource creation](resource-creation-design.md), document their own messages and any rule they add. The protocol is intentionally split into two phases: 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/src/app/App.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/app/App.tsx index e607a4cae90..ff141f6ed07 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/app/App.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/app/App.tsx @@ -4,8 +4,8 @@ import { PanZoomProvider } from "@vscode-bicep-ui/components"; import { useAtomValue } from "jotai"; import { styled } from "styled-components"; +import { CanvasView, ResourceCreationError } from "@/features/canvas"; import { ControlBar } from "@/features/controls"; -import { DeploymentGraphView, ResourceCreationError } from "@/features/deployment-graph"; import { ExportAreaCover, ExportAreaPreview, @@ -60,7 +60,7 @@ export function App() { <$AppContainer data-testid="app-root"> - }> + }> {({ canPlaceAt, createResource, resetLayout }) => ( <> <$ControlBarContainer> @@ -70,7 +70,7 @@ export function App() { )} - + diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/app/AppProviders.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/app/AppProviders.tsx index a5f38264b4b..368af48df37 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/app/AppProviders.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/app/AppProviders.tsx @@ -4,11 +4,10 @@ import type { ReactNode } from "react"; import { WebviewMessageChannelProvider } from "@vscode-bicep-ui/messaging"; -import { Suspense, useEffect } from "react"; +import { Suspense } from "react"; import { ThemeProvider } from "styled-components"; -import { loadDevAppShell } from "@/features/devtools"; -import { useMotionPolicySync } from "@/lib/accessibility"; -import { useHostApi } from "@/lib/host"; +import { loadDevAppShell } from "@/devtools"; +import { useDocumentSync, useMotionPolicySync } from "@/hooks"; import { useTheme } from "@/ui/theme"; import { GlobalStyle } from "./GlobalStyle"; @@ -16,13 +15,11 @@ const DevAppShell = loadDevAppShell(); function ThemedApp({ children }: { children: ReactNode }) { const theme = useTheme(); - const hostApi = useHostApi(); - useMotionPolicySync(); - // "The webview has mounted." This is app lifecycle, not any one feature's concern. - useEffect(() => { - hostApi.announceReady(); - }, [hostApi]); + // 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 ( diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/app/GlobalStyle.ts b/src/vscode-bicep-ui/apps/visual-designer/src/app/GlobalStyle.ts index d03b1072284..46c8f37cdf5 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/app/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/components/DevAppShell.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/devtools/components/DevAppShell.tsx similarity index 100% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/components/DevAppShell.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/devtools/components/DevAppShell.tsx diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/components/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/components/DevToolbar.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/devtools/components/DevToolbar.tsx index b1b66306e7d..be8cf8120df 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/components/DevToolbar.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/devtools/components/DevToolbar.tsx @@ -69,9 +69,7 @@ const $Button = styled.button` */ export function DevToolbar({ channel }: DevToolbarProps) { const applyMutation = ( - apply: ( - graph: import("@/features/deployment-graph").DeploymentGraph, - ) => import("@/features/deployment-graph").DeploymentGraph, + apply: (graph: import("@/features/canvas").DeploymentGraph) => import("@/features/canvas").DeploymentGraph, ) => { const current = channel.getCurrentGraph(); if (!current) return; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/fakes/fake-graph-differ.ts b/src/vscode-bicep-ui/apps/visual-designer/src/devtools/fakes/fake-graph-differ.ts similarity index 99% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/fakes/fake-graph-differ.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/devtools/fakes/fake-graph-differ.ts index 1230758c698..fde7b3b4f50 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/fakes/fake-graph-differ.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/devtools/fakes/fake-graph-differ.ts @@ -11,7 +11,7 @@ import type { GraphPatch, NodeLayout, RenderedGraph, -} from "@/features/deployment-graph"; +} from "@/features/canvas"; /** * A throwaway, dev-only stand-in for the language server's `VisualGraphDiffer`. It lets the diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/fakes/fake-message-channel.ts b/src/vscode-bicep-ui/apps/visual-designer/src/devtools/fakes/fake-message-channel.ts similarity index 99% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/fakes/fake-message-channel.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/devtools/fakes/fake-message-channel.ts index 4c905e2a6a9..a48e576c618 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/fakes/fake-message-channel.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/devtools/fakes/fake-message-channel.ts @@ -17,21 +17,14 @@ import type { GetGraphLayoutResult, GetGraphUpdateParams, GetGraphUpdateResult, -} from "@/features/deployment-graph"; +} from "@/features/canvas"; // The fake host implements the whole protocol, so it is the one legitimate consumer of every // feature's `api` surface. -import { - createResource, - getGraphLayout, - getGraphUpdate, - revealFileRange, - revealNodeSource, -} from "@/features/deployment-graph"; +import { createResource, getGraphLayout, getGraphUpdate, revealFileRange, revealNodeSource } from "@/features/canvas"; import { getResourceCreationEnablement, getResourceTypeNamespaces, loadResourceTypeCatalog } from "@/features/palette"; import { showProblemsPanel } from "@/features/status"; -import { getMotionPolicy } from "@/lib/accessibility"; -import { documentDidChange, ready } from "@/lib/host"; +import { documentDidChange, getMotionPolicy, ready } from "@/hooks"; import { diffGraph, layoutGraph } from "./fake-graph-differ"; const FAKE_FILE_PATH = "file:///main.bicep"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/hooks/use-dev-channel.ts b/src/vscode-bicep-ui/apps/visual-designer/src/devtools/hooks/use-dev-channel.ts similarity index 100% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/hooks/use-dev-channel.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/devtools/hooks/use-dev-channel.ts 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/deployment-graph/__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/deployment-graph/__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/deployment-graph/api.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/api.ts similarity index 90% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/api.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/api.ts index 23607645888..6393e757cbc 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/api.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/api.ts @@ -1,14 +1,14 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { ResourceTypeReference } from "@/features/palette"; +import type { ResourceTypeReference } from "./types"; import { defineNotification, defineRequest, useWebviewMessageChannel } from "@vscode-bicep-ui/messaging"; import { useMemo } from "react"; // ── Source locations ── -export interface Position { +interface Position { line: number; character: number; } @@ -22,7 +22,7 @@ export interface Range { // Sent when the user wants to navigate to a source range. export const revealFileRange = defineNotification("revealFileRange"); -export interface RevealFileRangeParams { +interface RevealFileRangeParams { filePath: string; range: Range; } @@ -33,7 +33,7 @@ export interface RevealFileRangeParams { // and reveal the node by id. This keeps volatile source locations out of the per-edit graph diff. export const revealNodeSource = defineNotification("revealNodeSource"); -export interface RevealNodeSourceParams { +interface RevealNodeSourceParams { nodeId: string; } @@ -55,6 +55,15 @@ export interface CreateResourceResult { unresolvedRequiredProperties: string[]; } +/** + * 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; @@ -102,7 +111,7 @@ export interface GetGraphLayoutResult { 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 { @@ -127,7 +136,7 @@ export interface RenderedGraphNode { height: number; } -export interface RenderedGraphEdge { +interface RenderedGraphEdge { id: string; sourceId: string; targetId: string; @@ -212,7 +221,7 @@ export interface DeploymentGraphNode { filePath: string; } -export interface DeploymentGraphEdge { +interface DeploymentGraphEdge { sourceId: string; targetId: string; } @@ -227,7 +236,7 @@ export interface DeploymentGraphEdge { * 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 useDeploymentGraphApi() { +export function useCanvasApi() { const channel = useWebviewMessageChannel(); return useMemo( diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/atoms.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/atoms.ts similarity index 83% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/atoms.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/atoms.ts index bf8bfc124f1..499c23fced2 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/atoms.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/atoms.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { ResourceTypeReference } from "@/features/palette"; -import type { Point } from "@/lib/utils"; +import type { Point } from "@/lib/math"; +import type { ResourceTypeReference } from "./types"; import { atom } from "jotai"; import { atomFamily } from "jotai-family"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/DeploymentGraphView.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/CanvasView.tsx similarity index 72% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/DeploymentGraphView.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/CanvasView.tsx index e3ea533634e..50c48df552e 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/DeploymentGraphView.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/CanvasView.tsx @@ -2,19 +2,19 @@ // Licensed under the MIT License. import type { ReactNode } from "react"; -import type { ResourceTypeReference } from "@/features/palette"; -import type { DocumentDidChangeParams } from "@/lib/host"; -import type { Point } from "@/lib/utils"; +import type { Point } from "@/lib/math"; +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, exportCanvasElementAtom, exportFileStemAtom } from "@/features/export"; -import { Canvas, Graph, useFitViewToBounds, viewportToGraphPoint } from "@/lib/graph"; -import { documentDidChange, useHostApi } from "@/lib/host"; +import { effectiveExportThemeAtom, exportCanvasElementAtom } from "@/features/export"; +import { documentDidChange } from "@/hooks"; +import { Graph, useFitViewToBounds, Viewport } from "@/lib/graph"; import { useGraphUpdate } from "../hooks/use-graph-update"; +import { viewportToGraphPoint } from "../utils/viewport"; import { NodeContentProvider } from "./nodes/NodeContentProvider"; import { PendingResourceLayer } from "./PendingResourceLayer"; @@ -23,14 +23,7 @@ const $CanvasWrapper = styled.div` inset: 0; `; -function deriveExportFileStem(documentPath?: string, documentFileName?: string): string { - const fileName = (documentFileName || documentPath || "").split(/[\\/]/).pop() ?? ""; - const stem = fileName.replace(/\.[^.]+$/, "").trim(); - - return stem || "bicep-graph"; -} - -export interface DeploymentGraphSurface { +export interface CanvasSurface { /** * Create a resource at a client-coordinate point. Omit `clientPoint` to use the surface's default * placement, which is how keyboard activation creates a resource. @@ -41,15 +34,14 @@ export interface DeploymentGraphSurface { resetLayout: () => Promise; } -export interface DeploymentGraphViewProps { +export interface CanvasViewProps { /** Rendered inside the canvas, beneath the graph, for export overlays. */ canvasOverlay?: ReactNode; - children: (surface: DeploymentGraphSurface) => ReactNode; + children: (surface: CanvasSurface) => ReactNode; } /** - * The Bicep deployment graph surface: owns the update loop, the canvas subtree, and the pending - * resource layer. + * The Bicep design surface: owns the update loop, the canvas subtree, and the pending resource layer. * * The surface handed to `children` is stated in client coordinates on purpose. Converting a pointer * position into a graph position needs the canvas rect and the pan/zoom transform, both of which are @@ -59,7 +51,7 @@ export interface DeploymentGraphViewProps { * a single-instance state machine holding the client's mirror of the server's canonical graph, and a * second instance would diverge and corrupt patch application. */ -export function DeploymentGraphView({ canvasOverlay, children }: DeploymentGraphViewProps) { +export function CanvasView({ canvasOverlay, children }: CanvasViewProps) { const getPanZoomDimensions = useGetPanZoomDimensions(); const getPanZoomTransform = useGetPanZoomTransform(); const getViewportCenter = useCallback(() => { @@ -72,24 +64,18 @@ export function DeploymentGraphView({ canvasOverlay, children }: DeploymentGraph createResource: createResourceAtOrigin, resetLayout, } = useGraphUpdate(getViewportCenter, fitViewToBounds); - const hostApi = useHostApi(); const exportTheme = useAtomValue(effectiveExportThemeAtom); - const setExportFileStem = useSetAtom(exportFileStemAtom); const setExportCanvasElement = useSetAtom(exportCanvasElementAtom); const [canvasElement, setCanvasElement] = useState(null); - // Listen for "the graph may have changed" notifications. The webview pulls the update itself, - // submitting the graph it currently displays and applying the patches. + // "The graph may have changed." The webview pulls the update itself, submitting the graph it + // currently displays and applying the patches. Other features subscribe to this same notification + // independently for their own concerns. useNotification( documentDidChange, - useCallback( - ({ documentUri }: DocumentDidChangeParams) => { - hostApi.rememberDocument(documentUri); - setExportFileStem(deriveExportFileStem(documentUri)); - void requestGraphUpdate(); - }, - [hostApi, requestGraphUpdate, setExportFileStem], - ), + useCallback(() => { + void requestGraphUpdate(); + }, [requestGraphUpdate]), ); const handleCanvasRef = useCallback( @@ -143,7 +129,7 @@ export function DeploymentGraphView({ canvasOverlay, children }: DeploymentGraph [canvasElement, createResourceAtOrigin, getPanZoomTransform], ); - const surface = useMemo( + const surface = useMemo( () => ({ createResource, canPlaceAt, resetLayout }), [canPlaceAt, createResource, resetLayout], ); @@ -152,11 +138,11 @@ export function DeploymentGraphView({ canvasOverlay, children }: DeploymentGraph <$CanvasWrapper ref={handleCanvasRef}> - + {canvasOverlay} - + {children(surface)} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/PendingResourceLayer.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/PendingResourceLayer.tsx similarity index 100% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/PendingResourceLayer.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/PendingResourceLayer.tsx diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/ResourceCreationError.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/ResourceCreationError.tsx similarity index 100% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/ResourceCreationError.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/ResourceCreationError.tsx diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/nodes/ModuleNode.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/ModuleNode.tsx similarity index 100% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/nodes/ModuleNode.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/ModuleNode.tsx diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/nodes/NodeContentProvider.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/NodeContentProvider.tsx similarity index 96% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/nodes/NodeContentProvider.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/NodeContentProvider.tsx index 95fdb2ba53b..c7ee279a666 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/nodes/NodeContentProvider.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/NodeContentProvider.tsx @@ -11,7 +11,7 @@ import { useStore } from "jotai"; import { useHydrateAtoms } from "jotai/utils"; import { useCallback } from "react"; import { nodeConfigAtom } from "@/lib/graph"; -import { useDeploymentGraphApi } from "../../api"; +import { useCanvasApi } from "../../api"; import { ModuleNode } from "./ModuleNode"; import { ResourceNode } from "./ResourceNode"; @@ -40,7 +40,7 @@ function renderNodeContent(kind: NodeKind, { id, data }: NodeContentRenderProps) export function NodeContentProvider({ children }: { children: ReactNode }) { const store = useStore(); const defaults = store.get(nodeConfigAtom); - const api = useDeploymentGraphApi(); + const api = useCanvasApi(); const handleNodeActivate = useCallback( (id: string, data: unknown) => { diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/nodes/ResourceNode.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/ResourceNode.tsx similarity index 96% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/nodes/ResourceNode.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/ResourceNode.tsx index 2d5b5d1e27b..bb8281e5185 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/nodes/ResourceNode.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/ResourceNode.tsx @@ -9,8 +9,8 @@ import { motion } from "motion/react"; import { useEffect } from "react"; import { styled } from "styled-components"; import { focusedNodeIdAtom } from "@/lib/graph"; -import { camelCaseToWords } from "@/lib/utils"; -import { RESOURCE_CREATION_TRANSITION } from "../../animations"; +import { EXPAND_TRANSITION } from "@/ui"; +import { camelCaseToWords } from "@/utils"; import { resourceNodeIsCommittingAtomFamily } from "../../atoms"; import { RESOURCE_NODE_PREVIEW_HEIGHT, RESOURCE_NODE_PREVIEW_WIDTH } from "./ResourceNodePreview"; @@ -154,7 +154,7 @@ export function ResourceNode({ id, data }: ResourceNodeProps) { <$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); @@ -168,7 +168,7 @@ export function ResourceNode({ id, data }: ResourceNodeProps) { <$ResourceIcon initial={isCommitting ? { scaleX: initialIconScaleX, scaleY: initialIconScaleY } : false} animate={{ scaleX: 1, scaleY: 1 }} - transition={RESOURCE_CREATION_TRANSITION} + transition={EXPAND_TRANSITION} > diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/nodes/ResourceNodePreview.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/ResourceNodePreview.tsx similarity index 100% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/components/nodes/ResourceNodePreview.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/ResourceNodePreview.tsx diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/hooks/use-apply-graph.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/hooks/use-apply-graph.ts similarity index 96% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/hooks/use-apply-graph.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/hooks/use-apply-graph.ts index d68df66552b..cf89946db2a 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/hooks/use-apply-graph.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/hooks/use-apply-graph.ts @@ -3,7 +3,7 @@ import type { PrimitiveAtom } from "jotai"; import type { AnimationPlaybackControlsWithThen } from "motion"; -import type { Box, Point } from "@/lib/utils"; +import type { Box, Point } from "@/lib/math"; import type { DeploymentGraph, NodeLayout } from "../api"; import { getDefaultStore, useSetAtom } from "jotai"; @@ -19,8 +19,8 @@ import { nodesByIdAtom, removeNodesAtom, } from "@/lib/graph"; -import { translateBox } from "@/lib/utils"; -import { isDeploymentGraphEqual } from "../utils/graph-equality"; +import { translateBox } from "@/lib/math"; +import { hasRangeOnlyChange } from "../utils/layout-invalidation"; const store = getDefaultStore(); @@ -130,10 +130,9 @@ export function useApplyGraph(getViewportCenter: () => Point) { hasNodes: (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)) { + // Nothing visible changed: refresh the source location on the mounted nodes and stop, rather + // than tearing the graph down and re-laying it out. + if (hasRangeOnlyChange(previousGraphRef.current, graph)) { if (graph) { const nodes = store.get(nodesByIdAtom); for (const node of graph.nodes) { diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/hooks/use-graph-update.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/hooks/use-graph-update.ts similarity index 98% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/hooks/use-graph-update.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/hooks/use-graph-update.ts index 181cba9ec0d..0fe8736f20d 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/hooks/use-graph-update.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/hooks/use-graph-update.ts @@ -1,8 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { ResourceTypeReference } from "@/features/palette"; -import type { Box, Point } from "@/lib/utils"; +import type { Box, Point } from "@/lib/math"; import type { DeploymentGraph, GraphBounds, @@ -13,11 +12,12 @@ import type { Range, RenderedGraph, } from "../api"; +import type { ResourceTypeReference } from "../types"; import { getDefaultStore } from "jotai"; import { useCallback, useRef } from "react"; import { nodesByIdAtom } from "@/lib/graph"; -import { useDeploymentGraphApi } from "../api"; +import { useCanvasApi } from "../api"; import { pendingResourcesAtom, resourceCreationErrorAtom, resourceNodeIsCommittingAtomFamily } from "../atoms"; import { patchMayAffectLayout, renderedGraphsEqual } from "../utils/layout-invalidation"; import { applyGraphLayout, useApplyGraph } from "./use-apply-graph"; @@ -227,7 +227,7 @@ export function useGraphUpdate( fitViewToBounds: (bounds: Box) => void, ): GraphUpdateActions { const applyGraph = useApplyGraph(getViewportCenter); - const api = useDeploymentGraphApi(); + const api = useCanvasApi(); const clientGraphRef = useRef(createClientGraph()); const lastLayoutInputRef = useRef(null); const inFlightRef = useRef(false); diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/index.ts similarity index 60% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/index.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/index.ts index 6a97008afce..2edab319af3 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/index.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/index.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -export { DeploymentGraphView, type DeploymentGraphSurface } from "./components/DeploymentGraphView"; +export { CanvasView, type CanvasSurface } from "./components/CanvasView"; export { ResourceCreationError } from "./components/ResourceCreationError"; -export { RESOURCE_CREATION_TRANSITION } from "./animations"; export { ResourceNodePreview } from "./components/nodes/ResourceNodePreview"; 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/deployment-graph/utils/__tests__/layout-invalidation.test.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/utils/__tests__/layout-invalidation.test.ts similarity index 62% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/utils/__tests__/layout-invalidation.test.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/utils/__tests__/layout-invalidation.test.ts index 736ac417e79..649df67d4c1 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/utils/__tests__/layout-invalidation.test.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/utils/__tests__/layout-invalidation.test.ts @@ -1,10 +1,17 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { GraphNode, GraphPatch, RenderedGraph, RenderedGraphNode } from "../../api"; +import type { + DeploymentGraph, + DeploymentGraphNode, + GraphNode, + GraphPatch, + RenderedGraph, + RenderedGraphNode, +} from "../../api"; import { describe, expect, it } from "vitest"; -import { patchMayAffectLayout, renderedGraphsEqual } from "../layout-invalidation"; +import { hasRangeOnlyChange, patchMayAffectLayout, renderedGraphsEqual } from "../layout-invalidation"; function makeNode(overrides: Partial = {}): GraphNode { return { @@ -156,3 +163,77 @@ describe("renderedGraphsEqual", () => { expect(renderedGraphsEqual(base, rewired)).toBe(false); }); }); + +const ZERO_RANGE = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }; + +function makeAppliedNode(overrides: Partial = {}): DeploymentGraphNode { + return { + id: "n", + type: "Microsoft.Storage/storageAccounts", + isCollection: false, + range: ZERO_RANGE, + hasChildren: false, + hasError: false, + filePath: "/main.bicep", + ...overrides, + }; +} + +function appliedGraph(nodes: DeploymentGraphNode[], errorCount = 0): DeploymentGraph { + return { nodes, edges: [], errorCount }; +} + +describe("hasRangeOnlyChange", () => { + it("ignores a range shift, which is what makes typing above a resource cheap", () => { + const before = appliedGraph([makeAppliedNode()]); + const after = appliedGraph([ + makeAppliedNode({ range: { start: { line: 9, character: 0 }, end: { line: 9, character: 4 } } }), + ]); + + expect(hasRangeOnlyChange(before, after)).toBe(true); + }); + + it("treats two nulls as unchanged but a null on one side as a change", () => { + expect(hasRangeOnlyChange(null, null)).toBe(true); + expect(hasRangeOnlyChange(null, appliedGraph([makeAppliedNode()]))).toBe(false); + expect(hasRangeOnlyChange(appliedGraph([makeAppliedNode()]), null)).toBe(false); + }); + + it.each([ + ["id", { id: "other" }], + ["type", { type: "Microsoft.Web/sites" }], + ["isCollection", { isCollection: true }], + ["hasChildren", { hasChildren: true }], + ["hasError", { hasError: true }], + ["filePath", { filePath: "/other.bicep" }], + ])("reports a change when %s differs", (_field, overrides) => { + const before = appliedGraph([makeAppliedNode()]); + const after = appliedGraph([makeAppliedNode(overrides as Partial)]); + + expect(hasRangeOnlyChange(before, after)).toBe(false); + }); + + it("reports a change when the error count differs, since status is derived from it", () => { + const before = appliedGraph([makeAppliedNode()], 0); + const after = appliedGraph([makeAppliedNode()], 1); + + expect(hasRangeOnlyChange(before, after)).toBe(false); + }); + + it("reports a change when an edge is added or retargeted", () => { + const nodes = [makeAppliedNode({ id: "a" }), makeAppliedNode({ id: "b" })]; + const none: DeploymentGraph = { nodes, edges: [], errorCount: 0 }; + const one: DeploymentGraph = { nodes, edges: [{ sourceId: "a", targetId: "b" }], errorCount: 0 }; + const other: DeploymentGraph = { nodes, edges: [{ sourceId: "b", targetId: "a" }], errorCount: 0 }; + + expect(hasRangeOnlyChange(none, one)).toBe(false); + expect(hasRangeOnlyChange(one, other)).toBe(false); + }); + + it("treats a reordering as a change, erring towards a redundant rebuild", () => { + const a = makeAppliedNode({ id: "a" }); + const b = makeAppliedNode({ id: "b" }); + + expect(hasRangeOnlyChange(appliedGraph([a, b]), appliedGraph([b, a]))).toBe(false); + }); +}); diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/utils/layout-invalidation.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/utils/layout-invalidation.ts similarity index 57% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/utils/layout-invalidation.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/utils/layout-invalidation.ts index eeae4cee9a2..c474f51aec6 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/utils/layout-invalidation.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/utils/layout-invalidation.ts @@ -1,21 +1,24 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { GraphNode, GraphPatch, RenderedGraph } from "../api"; +import type { DeploymentGraph, GraphNode, GraphPatch, RenderedGraph } from "../api"; /** * 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: + * "What affects layout" is decided in four places that must stay consistent: * - * 1. {@link patchMayAffectLayout} here — the cheap client pre-filter that decides whether an + * 1. {@link hasRangeOnlyChange} here — the coarsest gate, applied to the whole graph. When nothing + * but source ranges differs, node data is refreshed in place and neither a rebuild nor a layout + * happens. + * 2. {@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 + * 3. {@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 + * 4. 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 + * If you add a field that changes a node's rendered size, add it here and confirm the others 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; @@ -26,6 +29,64 @@ interface LayoutRelevantGraph { nodes: ReadonlyMap; } +/** + * Whether `next` differs from `previous` only in source ranges. + * + * Ranges shift on edits that change nothing visible — inserting a blank line above a resource moves + * every range below it — so treating them as a change would tear down and re-lay out the graph on + * every keystroke. When this returns true the caller refreshes `range` on the mounted nodes and + * stops there. + * + * Deliberately not an equality function: two graphs that differ only in `range` are *not* equal, and + * a caller wanting equality would be misled. Nodes and edges are compared pairwise by position, so a + * reordering counts as a change; that is conservative in the safe direction, costing a redundant + * rebuild rather than missing a real one. + */ +export function hasRangeOnlyChange(previous: DeploymentGraph | null, next: DeploymentGraph | null): boolean { + if (previous === next) { + return true; + } + + if (!previous || !next) { + return false; + } + + if (previous.errorCount !== next.errorCount) { + return false; + } + + if (previous.nodes.length !== next.nodes.length || previous.edges.length !== next.edges.length) { + return false; + } + + for (let i = 0; i < previous.nodes.length; i++) { + const previousNode = previous.nodes[i]!; + const nextNode = next.nodes[i]!; + + if ( + previousNode.id !== nextNode.id || + previousNode.type !== nextNode.type || + previousNode.isCollection !== nextNode.isCollection || + previousNode.hasChildren !== nextNode.hasChildren || + previousNode.hasError !== nextNode.hasError || + previousNode.filePath !== nextNode.filePath + ) { + return false; + } + } + + for (let i = 0; i < previous.edges.length; i++) { + const previousEdge = previous.edges[i]!; + const nextEdge = next.edges[i]!; + + if (previousEdge.sourceId !== nextEdge.sourceId || previousEdge.targetId !== nextEdge.targetId) { + return false; + } + } + + return true; +} + /** * 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. diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/viewport.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/utils/viewport.ts similarity index 94% rename from src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/viewport.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/utils/viewport.ts index 581449363b7..9edc8187f52 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/viewport.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/utils/viewport.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { Point } from "@/lib/utils"; +import type { Point } from "@/lib/math"; export function viewportToGraphPoint( clientPoint: Point, diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/controls/components/ControlBar.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/controls/components/ControlBar.tsx index 874e614841b..9d4ce18732a 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/controls/components/ControlBar.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/controls/components/ControlBar.tsx @@ -6,14 +6,14 @@ import { useAtomValue, useSetAtom } from "jotai"; import { styled } from "styled-components"; import { openExportOverlayAtom } from "@/features/export"; import { useFitView } from "@/lib/graph"; -import { IconButton, Surface } from "@/ui"; +import { FloatingPanel, IconButton } from "@/ui"; import { graphControlAvailabilityAtom } from "../atoms"; import { useResetLayout } from "../hooks/use-reset-layout"; const $Divider = styled.div` height: 1px; margin: 2px 4px; - background-color: ${({ theme }) => theme.controlBar.border}; + background-color: ${({ theme }) => theme.panel.border}; `; interface ControlBarProps { @@ -28,7 +28,7 @@ export function ControlBar({ requestLayout }: ControlBarProps) { const openExportOverlay = useSetAtom(openExportOverlayAtom); return ( - + zoomIn(1.5)} title="Zoom In" aria-label="Zoom In" data-testid="control-zoom-in"> @@ -63,6 +63,6 @@ export function ControlBar({ requestLayout }: ControlBarProps) { > - + ); } diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/animations.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/animations.ts deleted file mode 100644 index b1f2277c769..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/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/deployment-graph/utils/graph-equality.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/utils/graph-equality.ts deleted file mode 100644 index 85f94997e60..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/deployment-graph/utils/graph-equality.ts +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import type { DeploymentGraph } from "../api"; - -/** - * 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/features/devtools/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/devtools/index.ts deleted file mode 100644 index 45db780e252..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("./components/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 65c8db1c33e..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 @@ -4,6 +4,7 @@ import type { DefaultTheme } from "styled-components"; import { atom } from "jotai"; +import { documentUriAtom } from "@/hooks"; import { activeThemeAtom, getThemeByName } from "@/ui/theme"; export type ExportBackgroundMode = "transparent" | "solid"; @@ -15,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/components/ExportAreaCover.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/export/components/ExportAreaCover.tsx index d17497cfefb..5d0aeb4f41d 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/export/components/ExportAreaCover.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/export/components/ExportAreaCover.tsx @@ -8,7 +8,7 @@ import { exportPaddingAtom } 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() { @@ -26,7 +26,7 @@ export function ExportAreaCover() { top: graphBounds.min.y - padding, width: graphBounds.max.x - graphBounds.min.x + padding * 2, height: graphBounds.max.y - graphBounds.min.y + padding * 2, - backgroundColor: theme.canvas.background, + backgroundColor: theme.viewport.background, borderRadius: 2, pointerEvents: "none", }} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/export/components/ExportToolbar.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/export/components/ExportToolbar.tsx index fc4a5f6c624..edd455bd404 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/export/components/ExportToolbar.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/export/components/ExportToolbar.tsx @@ -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/palette/atoms.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/atoms.ts index a23921f47a8..6a4855b4a72 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/atoms.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/atoms.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { ResourceTypeReference } from "./types"; +import type { ResourceTypeReference } from "@/features/canvas"; import { atom } from "jotai"; import { atomFamily } from "jotai-family"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/ui/MotionAwareProgressBar.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/MotionAwareProgressBar.tsx similarity index 88% rename from src/vscode-bicep-ui/apps/visual-designer/src/ui/MotionAwareProgressBar.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/MotionAwareProgressBar.tsx index 76b4e0b942e..7cda84fb9aa 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/ui/MotionAwareProgressBar.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/MotionAwareProgressBar.tsx @@ -3,7 +3,7 @@ import { ProgressBar } from "@vscode-bicep-ui/components"; import { useAtomValue } from "jotai"; -import { motionPolicyAtom } from "@/lib/accessibility"; +import { motionPolicyAtom } from "@/hooks"; export function MotionAwareProgressBar({ testId, ariaLabel }: { testId?: string; ariaLabel: string }) { const policy = useAtomValue(motionPolicyAtom); 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 index 12c4c7a0b31..b820c9b299d 100644 --- 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 @@ -1,28 +1,27 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { DeploymentGraphSurface } from "@/features/deployment-graph"; +import type { CanvasSurface } from "@/features/canvas"; 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 { RESOURCE_CREATION_TRANSITION } from "@/features/deployment-graph"; -import { IconButton, Surface } from "@/ui"; -import { usePaletteDrag } from "../hooks/use-drag"; +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"; interface PaletteProps { - createResource: DeploymentGraphSurface["createResource"]; - canPlaceAt: DeploymentGraphSurface["canPlaceAt"]; + createResource: CanvasSurface["createResource"]; + canPlaceAt: CanvasSurface["canPlaceAt"]; } -const MotionSurface = motion.create(Surface); +const MotionFloatingPanel = motion.create(FloatingPanel); -const $PaletteLauncher = styled(MotionSurface)` +const $PaletteLauncher = styled(MotionFloatingPanel)` position: absolute; top: 16px; left: 16px; @@ -165,7 +164,7 @@ function EnabledPalette({ createResource, canPlaceAt }: PaletteProps) { opacity: 0, clipPath: "inset(0 calc(100% - 38px) calc(100% - 38px) 0 round 8px)", }} - transition={RESOURCE_CREATION_TRANSITION} + transition={EXPAND_TRANSITION} > <$PaletteBody initial={{ opacity: 0 }} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/PaletteContent.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/PaletteContent.tsx index a89388aa2cf..c1903e4dd29 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/PaletteContent.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/PaletteContent.tsx @@ -2,7 +2,8 @@ // Licensed under the MIT License. import type { PointerEvent } from "react"; -import type { ResourceTypeCatalog, ResourceTypeNamespace, ResourceTypeReference } from "../types"; +import type { ResourceTypeReference } from "@/features/canvas"; +import type { ResourceTypeCatalog, ResourceTypeNamespace } from "../types"; import { useAtomValue } from "jotai"; import { useEffect } from "react"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/PaletteControls.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/PaletteControls.tsx index 713d27c4c2e..385bb86af9d 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/PaletteControls.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 "@/ui"; +import { MotionAwareProgressBar } from "./MotionAwareProgressBar"; const $StickyControls = styled.div` position: sticky; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/PaletteDragOverlay.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/PaletteDragOverlay.tsx index df74cf14098..ffba6e9f59a 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/PaletteDragOverlay.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/PaletteDragOverlay.tsx @@ -7,7 +7,7 @@ import { usePanZoomTransform } from "@vscode-bicep-ui/components"; import { useAtomValue } from "jotai"; import { createPortal } from "react-dom"; import styled from "styled-components"; -import { ResourceNodePreview } from "@/features/deployment-graph"; +import { ResourceNodePreview } from "@/features/canvas"; import { paletteDragAtom } from "../atoms"; const $Positioner = styled.div` diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/ResourceTypeGroups.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/ResourceTypeGroups.tsx index b6c9d38509b..13ebd82d9b8 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/ResourceTypeGroups.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/ResourceTypeGroups.tsx @@ -11,7 +11,7 @@ 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 "@/lib/utils"; +import { getErrorMessage } from "@/utils"; import { getNamespaceResourceTypesKey, namespaceResourceTypesAtomFamily, diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/hooks/use-drag.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/hooks/use-palette-drag.ts similarity index 100% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/palette/hooks/use-drag.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/features/palette/hooks/use-palette-drag.ts 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 index b4a8c79f043..cb55257aa78 100644 --- 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 @@ -6,7 +6,7 @@ import type { ResourceTypeCatalog, ResourceTypeNamespace } from "../types"; import { useNotification } from "@vscode-bicep-ui/messaging"; import { useCallback, useEffect, useRef, useState } from "react"; -import { documentDidChange } from "@/lib/host"; +import { documentDidChange } from "@/hooks"; import { usePaletteApi } from "../api"; /** Edits arrive in bursts, so refreshes are debounced. The first load is immediate. */ diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/hooks/use-resource-type-search.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/hooks/use-resource-type-search.ts index 57b01c54f75..3756d1d94ff 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/hooks/use-resource-type-search.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/hooks/use-resource-type-search.ts @@ -5,7 +5,7 @@ import type { PaletteContentProps } from "../components/PaletteContent"; import type { ResourceTypeCatalogGroup } from "../types"; import { useEffect, useRef, useState } from "react"; -import { getErrorMessage } from "@/lib/utils"; +import { getErrorMessage } from "@/utils"; type SearchState = | { status: "idle" } 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 index d812f9259f4..6453d3eae7e 100644 --- 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 @@ -17,9 +17,3 @@ export interface ResourceTypeCatalog { catalogId: string; groups: ResourceTypeCatalogGroup[]; } - -/** A resource type the user can create. Mirrors the host's resource-creation contract. */ -export interface ResourceTypeReference { - fullyQualifiedType: string; - apiVersion: string; -} diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/hooks/index.ts similarity index 71% rename from src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/index.ts rename to src/vscode-bicep-ui/apps/visual-designer/src/hooks/index.ts index f2cafe64d5f..4056a741665 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/index.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/hooks/index.ts @@ -1,6 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -export * from "./atoms"; +export * from "./use-document-sync"; export * from "./use-motion-policy-sync"; -export * from "./api"; 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/lib/accessibility/api.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/api.ts deleted file mode 100644 index 12c274e1098..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/api.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { defineNotification, defineRequest } from "@vscode-bicep-ui/messaging"; - -// ── Motion policy ── -// The host resolves the user's effective motion preference, combining the VS Code setting with the -// OS-level reduced-motion preference. - -export type MotionPolicy = "system" | "reduce" | "animate"; - -export const getMotionPolicy = defineRequest("motionPolicy/get"); - -export const motionPolicyDidChange = defineNotification("motionPolicy/didChange"); diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/atoms.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/atoms.ts deleted file mode 100644 index b7293309eae..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/atoms.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import type { MotionPolicy } from "./api"; - -import { atom } from "jotai"; - -export const motionPolicyAtom = atom("system"); diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/use-motion-policy-sync.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/use-motion-policy-sync.ts deleted file mode 100644 index f88fb7308b6..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/accessibility/use-motion-policy-sync.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import type { MotionPolicy } from "./api"; - -import { useNotification, useRequest } from "@vscode-bicep-ui/messaging"; -import { useSetAtom } from "jotai"; -import { useCallback, useEffect } from "react"; -import { getMotionPolicy, motionPolicyDidChange } from "./api"; -import { motionPolicyAtom } from "./atoms"; - -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/lib/graph/atoms/graph.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/atoms/graph.ts index ade01359cec..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"; +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 73745b18f55..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"; +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 5421307cfcf..537ba52e5b6 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,15 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { AtomicNodeState } from "@/lib/graph/atoms/nodes"; +import type { AtomicNodeState } from "../atoms/nodes"; import useResizeObserver from "@react-hook/resize-observer"; import { useAtomValue, useStore } from "jotai"; import { frame } from "motion/react"; import { useLayoutEffect, useRef } from "react"; -import { focusedNodeIdAtom, getNodeZIndex } from "@/lib/graph/atoms/nodes"; -import { useBoxUpdate, useDragListener, useNodeActivation } from "@/lib/graph/hooks"; -import { translateBox } from "@/lib/utils"; +import { translateBox } from "@/lib/math"; +import { focusedNodeIdAtom, getNodeZIndex } from "../atoms/nodes"; +import { useBoxUpdate, useDragListener, useNodeActivation } from "../hooks"; import { BaseNode } from "./BaseNode"; import { NodeContent } from "./NodeContent"; 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 03efe5d8654..b67b2ca8eb5 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,15 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { CompoundNodeState } from "@/lib/graph/atoms/nodes"; +import type { CompoundNodeState } from "../atoms/nodes"; import { useAtomValue, useStore } from "jotai"; import { frame } from "motion/react"; import { useRef } from "react"; -import { nodesByIdAtom } from "@/lib/graph/atoms"; -import { focusedNodeIdAtom, getNodeZIndex } from "@/lib/graph/atoms/nodes"; -import { useBoxUpdate, useDragListener, useNodeActivation } from "@/lib/graph/hooks"; -import { translateBox } from "@/lib/utils"; +import { translateBox } from "@/lib/math"; +import { nodesByIdAtom } from "../atoms"; +import { focusedNodeIdAtom, getNodeZIndex } from "../atoms/nodes"; +import { useBoxUpdate, useDragListener, useNodeActivation } from "../hooks"; import { BaseNode } from "./BaseNode"; import { NodeContent } from "./NodeContent"; 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 b2d12953ded..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"; +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 6840a2a7808..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"; +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 0e7fc585846..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"; +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"; +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/hooks/use-node-activation.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/hooks/use-node-activation.ts index a8e3ae80c0e..d05032b4909 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/hooks/use-node-activation.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/hooks/use-node-activation.ts @@ -6,7 +6,7 @@ import type { RefObject } from "react"; import { useStore } from "jotai"; import { useEffect } from "react"; -import { nodeConfigAtom } from "@/lib/graph/atoms"; +import { nodeConfigAtom } from "../atoms"; /** * Calls the configured `onNodeActivate` when a node is double-clicked. 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 8df169721d2..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,4 +4,4 @@ export * from "./atoms"; export * from "./components"; export * from "./hooks"; -export * from "./viewport"; +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/host.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/host.ts deleted file mode 100644 index d943a4ef2d7..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/host.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { defineNotification, useWebviewMessageChannel } from "@vscode-bicep-ui/messaging"; -import { useMemo } from "react"; - -/** - * Lifecycle messages exchanged with the extension host. - * - * Everything else in this protocol belongs to one feature and is declared there, in that feature's - * `api.ts`. What remains here is what no single feature owns: the webview's own readiness, and a - * document-changed broadcast that several features independently react to. - * - * This is the *contract*, not the transport. Sending and receiving live in - * `@vscode-bicep-ui/messaging`. - */ - -/** Sent once the webview has mounted and can receive data. */ -export const ready = defineNotification("ready"); - -export interface DocumentDidChangeParams { - documentUri: string; -} - -/** "The document changed; re-fetch whatever you derive from it." */ -export const documentDidChange = defineNotification("documentDidChange"); - -/** - * Host-level operations: announcing readiness, and persisting which document this webview is showing - * so VS Code can restore it. - */ -export function useHostApi() { - const channel = useWebviewMessageChannel(); - - return useMemo( - () => ({ - announceReady: () => channel.notify(ready), - rememberDocument: (documentPath: string) => channel.setState({ documentPath }), - }), - [channel], - ); -} 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 87% 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 7abf314194f..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,7 +1,7 @@ // 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; 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/ui/Surface.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/ui/components/FloatingPanel.tsx similarity index 55% rename from src/vscode-bicep-ui/apps/visual-designer/src/ui/Surface.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/ui/components/FloatingPanel.tsx index 8bd6d82c9e8..ff3464d3056 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/ui/Surface.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/ui/components/FloatingPanel.tsx @@ -4,15 +4,16 @@ import styled from "styled-components"; /** - * A floating panel: the visual container for controls and islands layered over the canvas. + * A panel that floats above the viewport: the chrome shared by the control bar and the palette + * launcher. */ -export const Surface = styled.div` +export const FloatingPanel = styled.div` display: flex; flex-direction: column; gap: 1px; padding: 4px; - 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 1px 3px rgba(0, 0, 0, 0.08), diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/ui/IconButton.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/ui/components/IconButton.tsx similarity index 82% rename from src/vscode-bicep-ui/apps/visual-designer/src/ui/IconButton.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/ui/components/IconButton.tsx index b73aecf6451..296528e1eb5 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/ui/IconButton.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/ui/components/IconButton.tsx @@ -4,7 +4,7 @@ import styled from "styled-components"; /** - * A compact square icon button sized for toolbars and floating surfaces. + * A compact square icon button sized for toolbars and floating panels. */ export const IconButton = styled.button` display: flex; @@ -16,18 +16,18 @@ export const IconButton = 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 index 4f673c8055e..14f59badfba 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/ui/index.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/ui/index.ts @@ -1,6 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -export * from "./IconButton"; -export * from "./MotionAwareProgressBar"; -export * from "./Surface"; +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/ui/theme/styled.d.ts b/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/ui/theme/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/ui/theme/themes.ts b/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/ui/theme/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/utils/errors.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/lib/utils/errors.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 84% 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 8f1f795efa6..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 "./text"; export * from "./errors"; +export * from "./text"; 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/eslint.config.mjs b/src/vscode-bicep-ui/eslint.config.mjs index 8219279fd3a..463284102bd 100644 --- a/src/vscode-bicep-ui/eslint.config.mjs +++ b/src/vscode-bicep-ui/eslint.config.mjs @@ -7,66 +7,6 @@ import reactHooksPlugin from "eslint-plugin-react-hooks"; import reactRefreshPlugin from "eslint-plugin-react-refresh"; import tseslint from "typescript-eslint"; -// Layer boundaries for apps/visual-designer. See its architecture-notes.md: -// app -> features, ui, lib | features -> ui, lib | ui -> lib | lib -> lib -// Structure rules that are not machine-checked decay, and two lib -> features -// imports had already landed before this rule existed. -const VISUAL_DESIGNER_LAYERS = [ - { - layer: "lib", - forbids: ["features", "ui", "app"], - }, - { - layer: "ui", - forbids: ["features", "app"], - }, - { - layer: "features", - forbids: ["app"], - }, -]; - -const visualDesignerLayerBoundaries = VISUAL_DESIGNER_LAYERS.map(({ layer, forbids }) => ({ - files: [`apps/visual-designer/src/${layer}/**/*.{ts,tsx}`], - rules: { - "no-restricted-imports": [ - "error", - { - patterns: forbids.map((forbidden) => ({ - group: [`@/${forbidden}`, `@/${forbidden}/**`, `**/${forbidden}`, `**/${forbidden}/**`], - message: `"${layer}" must not import from "${forbidden}". See apps/visual-designer/architecture-notes.md.`, - })), - }, - ], - }, -})); - -// lib/graph is a Bicep-agnostic rendering engine, so it must not know the host protocol. The layer -// rule above cannot catch this because lib/graph -> lib/messaging is a legal lib -> lib edge, and the -// engine had in fact grown a double-click handler that sent reveal-source notifications directly. -// Bicep behaviour reaches the engine through nodeConfigAtom instead. -const visualDesignerGraphEngineBoundary = { - files: ["apps/visual-designer/src/lib/graph/**/*.{ts,tsx}"], - rules: { - "no-restricted-imports": [ - "error", - { - patterns: [ - { - group: ["@/features", "@/features/**", "@/ui", "@/ui/**", "@/app", "@/app/**"], - message: '"lib" must not import from a higher layer. See apps/visual-designer/architecture-notes.md.', - }, - { - group: ["@/lib/messaging", "@/lib/messaging/**", "@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 tseslint.config( { ignores: ["**/*.{js,cjs,mjs}", "**/.turbo/", "**/dist/", "**/e2e/.results/", "**/e2e/.report/"], @@ -107,6 +47,4 @@ export default tseslint.config( ], }, }, - ...visualDesignerLayerBoundaries, - visualDesignerGraphEngineBoundary, ); 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": [ From 26dede5fc4ebceba22008ced7cb45ec1f5c222fb Mon Sep 17 00:00:00 2001 From: Shenglong Li Date: Sat, 29 Aug 2026 17:07:47 -0700 Subject: [PATCH 4/5] Refactor canvas graph coordination Scope canvas actions and runtime ownership, separate graph model, layout, and application, and harden update, layout, and mutation ordering. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 91f8ba77-9474-4393-aa97-78e82fb5381b --- .github/copilot-instructions.md | 2 +- .../apps/visual-designer/README.md | 84 +++- .../apps/visual-designer/e2e/controls.spec.ts | 40 +- .../apps/visual-designer/e2e/fixtures.ts | 30 ++ .../e2e/node-interactions.spec.ts | 14 +- .../apps/visual-designer/src/app/App.tsx | 63 +-- .../{AppProviders.tsx => AppEnvironment.tsx} | 41 +- .../src/devtools/components/DevToolbar.tsx | 2 +- .../src/devtools/fakes/fake-graph-differ.ts | 13 +- .../devtools/fakes/fake-message-channel.ts | 133 +----- .../src/devtools/fakes/sample-graph.ts | 32 ++ .../canvas/__tests__/graph-layout.test.ts | 152 ++++++ .../canvas/__tests__/graph-model.test.ts | 253 ++++++++++ .../graph-update-coordinator.test.ts | 372 +++++++++++++++ .../src/features/canvas/api.ts | 43 +- .../src/features/canvas/atoms.ts | 38 ++ .../components/{CanvasView.tsx => Canvas.tsx} | 113 ++--- .../canvas/components/nodes/ModuleNode.tsx | 5 - .../components/nodes/NodeContentProvider.tsx | 66 +-- .../canvas/components/nodes/ResourceNode.tsx | 4 - .../canvas/context/CanvasActionsContext.ts | 21 + .../src/features/canvas/context/index.ts | 4 + .../canvas/context/use-canvas-actions.ts | 20 + .../src/features/canvas/graph-layout.ts | 99 ++++ .../src/features/canvas/graph-model.ts | 180 +++++++ .../canvas/graph-update-coordinator.ts | 282 +++++++++++ .../canvas/hooks/use-apply-graph-layout.ts | 93 ++++ .../features/canvas/hooks/use-apply-graph.ts | 199 ++------ .../canvas/hooks/use-canvas-controller.ts | 226 +++++++++ .../features/canvas/hooks/use-graph-update.ts | 443 ------------------ .../src/features/canvas/index.ts | 3 +- .../__tests__/layout-invalidation.test.ts | 239 ---------- .../canvas/utils/layout-invalidation.ts | 157 ------- .../src/features/canvas/utils/viewport.ts | 26 - .../src/features/controls/atoms.ts | 4 +- .../controls/components/ControlBar.tsx | 27 +- ...et-layout.ts => use-reset-graph-layout.ts} | 6 +- .../export/components/ExportAreaCover.tsx | 5 +- .../export/components/ExportPreviewLayer.tsx | 23 + .../src/features/export/index.ts | 1 + .../features/export/utils/capture-element.ts | 5 +- .../features/palette/components/Palette.tsx | 15 +- .../palette/hooks/use-palette-drag.ts | 6 +- .../src/lib/graph/atoms/configs.ts | 5 - .../src/lib/graph/components/AtomicNode.tsx | 4 +- .../src/lib/graph/components/CompoundNode.tsx | 4 +- .../src/lib/graph/hooks/index.ts | 1 - .../lib/graph/hooks/use-node-activation.ts | 40 -- src/vscode-bicep-ui/tsconfig.base.json | 48 +- .../features/visualization/visualizer-view.ts | 6 - 50 files changed, 2207 insertions(+), 1485 deletions(-) rename src/vscode-bicep-ui/apps/visual-designer/src/app/{AppProviders.tsx => AppEnvironment.tsx} (58%) create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/devtools/fakes/sample-graph.ts create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/__tests__/graph-layout.test.ts create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/__tests__/graph-model.test.ts create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/__tests__/graph-update-coordinator.test.ts rename src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/{CanvasView.tsx => Canvas.tsx} (51%) create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/context/CanvasActionsContext.ts create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/context/index.ts create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/context/use-canvas-actions.ts create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/graph-layout.ts create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/graph-model.ts create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/graph-update-coordinator.ts create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/hooks/use-apply-graph-layout.ts create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/hooks/use-canvas-controller.ts delete mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/hooks/use-graph-update.ts delete mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/utils/__tests__/layout-invalidation.test.ts delete mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/utils/layout-invalidation.ts delete mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/utils/viewport.ts rename src/vscode-bicep-ui/apps/visual-designer/src/features/controls/hooks/{use-reset-layout.ts => use-reset-graph-layout.ts} (78%) create mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/features/export/components/ExportPreviewLayer.tsx delete mode 100644 src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/hooks/use-node-activation.ts 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/README.md b/src/vscode-bicep-ui/apps/visual-designer/README.md index d5c533c2e35..484091a1e7c 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/README.md +++ b/src/vscode-bicep-ui/apps/visual-designer/README.md @@ -98,7 +98,13 @@ tree-shakes away. Feature-to-feature imports are fine — `palette` renders the node preview a dropped resource will become — but they must go through the target's `index.ts` and must not form a cycle. Resolve a cycle -by putting each shared symbol with its real owner, not by forbidding the edge. +by putting each shared symbol with its real owner, not by forbidding the edge. The current shape: + +```text +controls -> canvas, export, status +palette -> canvas +canvas -> export, status +``` ### Feature shape @@ -125,10 +131,9 @@ scaffolding it empty. Concept subfolders are allowed inside a type folder when t - **Everything else** (hooks, atoms, utils, types) and **folders**: kebab-case. - **A hook file is named for the hook it exports** — `use-palette-drag.ts` exports `usePaletteDrag`. Worth a mechanical check when adding one: the drift is invisible at the import site. -- **Name a feature for the capability it delivers, not the data it displays.** "Deployment graph" is - Bicep's own term for the payload the host sends, so it names the wire types (`DeploymentGraph`, - `DeploymentGraphNode`) — while `CanvasView`, `CanvasSurface` and `useCanvasApi` name the surface. - One set of words must not do both jobs. +- **Name a feature for the capability it delivers, not the data it displays.** `CanvasView`, + `CanvasActions` and `useCanvasApi` name the surface the user works on; the graph they carry is named + by the protocol types (`CanonicalGraph`, `RenderedGraph`). One set of words must not do both jobs. - **Name a thing for what it is in the domain, not its visual container.** `ResourceNodePreview`, not `ResourcePreviewCard`; "card" describes a border radius. - Prefer one-word folders, and treat a compound name as a prompt to check whether the folder is doing @@ -165,10 +170,21 @@ Each feature, `lib` module and `src/hooks/` exposes exactly one entry point: its Jotai for shared state that benefits from isolated subscriptions; local state when it has one owner. Across a boundary expose derived values and action atoms, never raw writable atoms. -**Graph actions are explicit props**, drilled one level from `CanvasView` to `ControlBar` and -`Palette`. A free-standing hook would invite a second `useGraphUpdate` instance, and that hook is a -single-instance state machine holding the client's mirror of the server's graph — a second one would -corrupt patch application. This is correctness, not style. +**Canvas actions travel through context, not Jotai.** `CanvasView` publishes `CanvasActions` +(`createResource`, `canPlaceAt`, `resetLayout`) that `ControlBar` and `Palette` read with +`useCanvasActions`. Two constraints shape this: + +- `useGraphUpdate` is a single-instance state machine holding the client's copy of the server's + graph, so only `CanvasView` may call it. A hook that called it again would corrupt patch + application. A hook that _reads what the single instance published_ is fine — that is the + difference between the two shapes. +- Jotai is the wrong tool for callbacks. `set(atom, fn)` is read as an updater, so storing one + requires `set(atom, () => fn)`; a registration atom is also null until the owner mounts, which + pushes a null branch into every consumer. Atoms hold state — `graphControlAvailabilityAtom` derives + three booleans from `hasNodes` and belongs in Jotai. Imperative machinery does not. + +`useCanvasActions` throws outside a `CanvasView`, so misuse is a loud error rather than a silent +null. ### Protocol @@ -182,6 +198,37 @@ name deliberately — they are one operation named at two levels. See [visual-graph-protocol.md](./docs/visual-graph-protocol.md) for the wire contract itself. +### Reconciliation + +One pass answers two separate questions: whether the server's graph has moved on, and whether what +we display has been laid out. Three modules split that work: + +| Module | Owns | +| ----------------------------- | ---------------------------------------------------------------------- | +| `graph-update-coordinator.ts` | When each step runs. No React, no Jotai. | +| `use-graph-update.ts` | The client's graph copy, measurement, patch application, Jotai writes. | +| `use-apply-graph.ts` | Turning a graph into mounted nodes and edges. | + +The coordinator tracks what is _owed_ — an update, and a layout that is `none`, `auto` or `reset` — +rather than what is running. Its rules: + +- Reconcile before laying out, so a layout always applies to the current graph. +- A reset outranks an automatic layout, so Reset Layout is never downgraded. +- A `graphChanged` layout re-pends the update **and** the layout it abandoned, keeping its mode. +- Mutations run one at a time, and a reconciliation that overlaps one is abandoned and retried. +- Every request resolves only once the coordinator runs out of work, never when work is merely + recorded. Callers gate on that promise — `useResetLayout` holds its deduplication lock for exactly + as long as it — so resolving early lets a second click queue a second server layout. + +Collapsing the update and layout questions into one flag is what previously let a `graphChanged` +response drop the layout it owed, leaving the graph hidden behind the visibility gate +`use-apply-graph` closes when most of the topology is replaced. Every rule guards an ordering hazard +that is impractical to force end to end, which is why the coordinator is React-free and unit tested +with controlled promises. + +Most keystrokes stop early: `displayedGraphsEqual` compares exactly the fields the apply path reads, +so an edit the canvas cannot show costs nothing. + ### Enforcement Structure rules that are not machine-checked decay. `../../eslint.config.mjs` carries import-boundary @@ -203,11 +250,16 @@ folder names inside features and `**/hooks/**` would flag every feature's own `. Most behavioural coverage is end-to-end in `e2e/` (Playwright): palette visibility, pointer and keyboard placement, drop rejection, catalog loading and search. Unit tests cover the pieces with logic -worth isolating in a store — graph atoms, layout invalidation, the export file stem. +worth isolating — graph atoms, patch application, layout invalidation, the export file stem, and the +update coordinator's ordering rules. Prefer assertions that cannot race. Poll for a settled value rather than sampling once: nodes animate in and the graph springs to its layout over ~0.6s, so a single read taken when a node appears can land -mid-flight. +mid-flight. A graph load runs two independent animations — the fit-view transform and each node's +spring — so waiting on the wrong one passes about half the time. + +Mutation-check a test that encodes an ordering rule: revert the fix it covers and confirm it fails. +The coordinator's rules all look plausible when broken. **The resource-creation failure path has no coverage.** The host reports failures as `CreateResourceErrorResult` from four call sites, but the dev fake always succeeds, so neither the @@ -216,12 +268,14 @@ error atom nor `ResourceCreationError` is exercised. Teaching the fake to fail o ## Known gaps -- Fold the legacy `DeploymentGraph` shape out of `canvas/api.ts` once the position-preserving apply - path no longer needs it. +- `GraphUpdatePatch` and `GraphLayoutPatch` are the same union, so a layout response is typed as + though it could carry `addNode`. Splitting them would let the compiler reject a phase mismatch that + is currently only a convention. - `Palette` hand-rolls a second floating-panel style from raw `var(--vscode-*)` values at a different radius; folding it onto `FloatingPanel` is the duplication `ui/` exists to remove. -- Two module-scope `getDefaultStore()` handles in `features/canvas/hooks` should come from context, so - the sync pipeline can be driven by a scoped store in tests. +- Two module-scope `getDefaultStore()` handles in `features/canvas/hooks`, and the coordinator beside + them, should come from context, so the sync pipeline can be driven by a scoped store in tests. They + move together: scoping one without the others buys nothing. - Share the protocol declarations with the extension host. `vscode-bicep` dispatches on raw string literals and casts params with `as`, so the two sides agree only by convention. It has no npm dependency on `vscode-bicep-ui` today, so this needs a `file:` dependency and a build-order 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 424574e42c1..68b093b7896 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/e2e/fixtures.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/e2e/fixtures.ts @@ -73,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/src/app/App.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/app/App.tsx index ff141f6ed07..789b93dca14 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/app/App.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/app/App.tsx @@ -2,20 +2,12 @@ // Licensed under the MIT License. import { PanZoomProvider } from "@vscode-bicep-ui/components"; -import { useAtomValue } from "jotai"; import { styled } from "styled-components"; -import { CanvasView, ResourceCreationError } from "@/features/canvas"; +import { Canvas, ResourceCreationError } from "@/features/canvas"; import { ControlBar } from "@/features/controls"; -import { - ExportAreaCover, - ExportAreaPreview, - ExportOverlay, - isExportCanvasCoverVisibleAtom, - isExportPreviewVisibleAtom, -} from "@/features/export"; import { Palette } from "@/features/palette"; import { StatusBar } from "@/features/status"; -import { AppProviders } from "./AppProviders"; +import { AppEnvironment } from "./AppEnvironment"; const $AppContainer = styled.div` flex: 1 1 auto; @@ -23,58 +15,19 @@ const $AppContainer = styled.div` overflow: hidden; `; -const $ControlBarContainer = styled.div` - position: absolute; - top: 16px; - right: 16px; - z-index: 100; -`; - -function ExportUILayer() { - const isExportPreviewVisible = useAtomValue(isExportPreviewVisibleAtom); - - if (!isExportPreviewVisible) { - return null; - } - - return ( - <> - - - - ); -} - -function ExportCanvasCoverLayer() { - const isExportCanvasCoverVisible = useAtomValue(isExportCanvasCoverVisibleAtom); - - if (!isExportCanvasCoverVisible) { - return null; - } - - return ; -} - export function App() { return ( - + <$AppContainer data-testid="app-root"> - }> - {({ canPlaceAt, createResource, resetLayout }) => ( - <> - <$ControlBarContainer> - - - - - - )} - + + + + - + ); } diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/app/AppProviders.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/app/AppEnvironment.tsx similarity index 58% rename from src/vscode-bicep-ui/apps/visual-designer/src/app/AppProviders.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/app/AppEnvironment.tsx index 368af48df37..f4d050db377 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/app/AppProviders.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/app/AppEnvironment.tsx @@ -4,6 +4,7 @@ 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"; @@ -13,7 +14,19 @@ import { GlobalStyle } from "./GlobalStyle"; const DevAppShell = loadDevAppShell(); -function ThemedApp({ children }: { children: ReactNode }) { +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 @@ -30,22 +43,14 @@ function ThemedApp({ children }: { children: ReactNode }) { } /** - * The provider stack. - * - * In dev, the lazy-loaded DevAppShell supplies a FakeMessageChannel, the DevToolbar, and the - * message-channel context. In production we render straight into the provider, which creates its own - * channel via acquireVsCodeApi. + * Establishes the app-wide store, host environment, synchronization, and theme. */ -export function AppProviders({ children }: { children: ReactNode }) { - const themed = {children}; - - if (DevAppShell) { - return ( - - {themed} - - ); - } - - return {themed}; +export function AppEnvironment({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); } diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/devtools/components/DevToolbar.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/devtools/components/DevToolbar.tsx index be8cf8120df..e003c46f708 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/devtools/components/DevToolbar.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/devtools/components/DevToolbar.tsx @@ -69,7 +69,7 @@ const $Button = styled.button` */ export function DevToolbar({ channel }: DevToolbarProps) { const applyMutation = ( - apply: (graph: import("@/features/canvas").DeploymentGraph) => import("@/features/canvas").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/devtools/fakes/fake-graph-differ.ts b/src/vscode-bicep-ui/apps/visual-designer/src/devtools/fakes/fake-graph-differ.ts index fde7b3b4f50..2d2b1587d8a 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/devtools/fakes/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, @@ -12,6 +10,7 @@ import type { NodeLayout, RenderedGraph, } 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/devtools/fakes/fake-message-channel.ts b/src/vscode-bicep-ui/apps/visual-designer/src/devtools/fakes/fake-message-channel.ts index a48e576c618..3105522f7be 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/devtools/fakes/fake-message-channel.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/devtools/fakes/fake-message-channel.ts @@ -12,16 +12,16 @@ import type { import type { CreateResourceParams, CreateResourceResult, - DeploymentGraph, 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, revealFileRange, revealNodeSource } from "@/features/canvas"; +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"; @@ -29,63 +29,48 @@ 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: [ @@ -99,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: [ @@ -147,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" }], @@ -194,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) ─────────────────────────────────────── @@ -221,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) ──────────────────── @@ -259,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) ─────────────────────────────────── @@ -288,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) ────────────────────────────────────── @@ -317,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) ────── @@ -346,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) ────────────────────────────────────── @@ -366,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) ──────────────────── @@ -386,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) ────────────────────────────── @@ -406,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) ──── @@ -435,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) ────────────── @@ -455,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) @@ -475,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) @@ -495,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) @@ -515,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: [ @@ -588,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, @@ -624,7 +523,7 @@ function getCatalogDelayMs(defaultDelayMs: number): number { export interface GraphMutation { label: string; description: string; - apply: (graph: DeploymentGraph) => DeploymentGraph; + apply: (graph: SampleGraph) => SampleGraph; } /** All available mutations for testing incremental updates. */ @@ -645,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, @@ -670,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, }, ], }; @@ -935,10 +828,8 @@ export class FakeMessageChannel implements WebviewMessageChannelApi { id: symbolicName, type: request.resourceType.fullyQualifiedType, isCollection: false, - range: ZERO_RANGE, hasChildren: false, hasError: true, - filePath: FAKE_FILE_PATH, }, ], }); @@ -958,7 +849,7 @@ export class FakeMessageChannel implements WebviewMessageChannelApi { } /** 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.method) { @@ -967,8 +858,6 @@ export class FakeMessageChannel implements WebviewMessageChannelApi { setTimeout(() => { this.pushGraph(MODULE_GRAPH); }, 50); - } else if (notificationMessage.method === revealFileRange.method) { - console.log("[FakeMessageChannel] revealFileRange:", notificationMessage.params); } 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); @@ -993,12 +882,12 @@ export class FakeMessageChannel implements WebviewMessageChannelApi { } /** 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(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/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/features/canvas/api.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/api.ts index 6393e757cbc..04ab6a61155 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/api.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/api.ts @@ -19,18 +19,9 @@ export interface Range { } // ── Notification: Webview → Extension ── -// Sent when the user wants to navigate to a source range. -export const revealFileRange = defineNotification("revealFileRange"); - -interface RevealFileRangeParams { - filePath: string; - range: Range; -} - -// ── 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. +// 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"); interface RevealNodeSourceParams { @@ -201,31 +192,6 @@ export type GraphPatch = | { op: "setGraphBounds"; bounds: GraphBounds } | { op: "setErrorCount"; errorCount: number }; -// ── Legacy graph shape ── -// The position-preserving apply path still consumes this. Source locations are filled with empty -// placeholders on the server-driven path; reveal is driven by node id instead. - -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; -} - -interface DeploymentGraphEdge { - sourceId: string; - targetId: string; -} - /** * The deployment graph's operations against the extension host. * @@ -242,9 +208,8 @@ export function useCanvasApi() { return useMemo( () => ({ fetchUpdate: (current: RenderedGraph | null) => channel.request(getGraphUpdate, { current }), - fetchLayout: (current: RenderedGraph) => channel.request(getGraphLayout, { current }), + fetchGraphLayout: (current: RenderedGraph) => channel.request(getGraphLayout, { current }), createResource: (params: CreateResourceParams) => channel.request(createResource, params), - revealFileRange: (params: RevealFileRangeParams) => channel.notify(revealFileRange, 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 index 499c23fced2..988beff6f37 100644 --- 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 @@ -17,3 +17,41 @@ export interface PendingResource { 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/CanvasView.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/Canvas.tsx similarity index 51% rename from src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/CanvasView.tsx rename to src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/Canvas.tsx index 50c48df552e..388d08cf661 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/CanvasView.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/Canvas.tsx @@ -3,6 +3,7 @@ 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"; @@ -10,11 +11,16 @@ 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, exportCanvasElementAtom } from "@/features/export"; +import { + effectiveExportThemeAtom, + ExportAreaCover, + exportCanvasElementAtom, + ExportPreviewLayer, +} from "@/features/export"; import { documentDidChange } from "@/hooks"; import { Graph, useFitViewToBounds, Viewport } from "@/lib/graph"; -import { useGraphUpdate } from "../hooks/use-graph-update"; -import { viewportToGraphPoint } from "../utils/viewport"; +import { CanvasActionsContext } from "../context/CanvasActionsContext"; +import { useCanvasController } from "../hooks/use-canvas-controller"; import { NodeContentProvider } from "./nodes/NodeContentProvider"; import { PendingResourceLayer } from "./PendingResourceLayer"; @@ -23,35 +29,35 @@ const $CanvasWrapper = styled.div` inset: 0; `; -export interface CanvasSurface { - /** - * Create a resource at a client-coordinate point. Omit `clientPoint` to use the surface's default - * placement, which is how keyboard activation creates a resource. - */ - createResource: (resourceType: ResourceTypeReference, clientPoint?: Point) => Promise; - /** Whether a client-coordinate point falls on the graph surface. */ - canPlaceAt: (clientPoint: Point) => boolean; - resetLayout: () => Promise; +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 CanvasViewProps { - /** Rendered inside the canvas, beneath the graph, for export overlays. */ - canvasOverlay?: ReactNode; - children: (surface: CanvasSurface) => ReactNode; +export interface CanvasProps { + /** Layered over the canvas and able to call `useCanvasActions`. */ + children: ReactNode; } -/** - * The Bicep design surface: owns the update loop, the canvas subtree, and the pending resource layer. - * - * The surface handed to `children` is stated in client coordinates on purpose. Converting a pointer - * position into a graph position needs the canvas rect and the pan/zoom transform, both of which are - * graph knowledge; exposing them would push that geometry into whichever feature happened to call. - * - * Actions are passed as explicit props rather than exposed as a free hook because `useGraphUpdate` is - * a single-instance state machine holding the client's mirror of the server's canonical graph, and a - * second instance would diverge and corrupt patch application. - */ -export function CanvasView({ canvasOverlay, children }: CanvasViewProps) { +/** 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(() => { @@ -59,18 +65,14 @@ export function CanvasView({ canvasOverlay, children }: CanvasViewProps) { return { x: width / 2, y: height / 2 }; }, [getPanZoomDimensions]); const fitViewToBounds = useFitViewToBounds(); - const { - requestGraphUpdate, - createResource: createResourceAtOrigin, - resetLayout, - } = useGraphUpdate(getViewportCenter, fitViewToBounds); + const { requestGraphUpdate, resetGraphLayout, createResourceAt } = useCanvasController( + getViewportCenter, + fitViewToBounds, + ); const exportTheme = useAtomValue(effectiveExportThemeAtom); const setExportCanvasElement = useSetAtom(exportCanvasElementAtom); const [canvasElement, setCanvasElement] = useState(null); - // "The graph may have changed." The webview pulls the update itself, submitting the graph it - // currently displays and applying the patches. Other features subscribe to this same notification - // independently for their own concerns. useNotification( documentDidChange, useCallback(() => { @@ -86,7 +88,7 @@ export function CanvasView({ canvasOverlay, children }: CanvasViewProps) { [setExportCanvasElement], ); - const canPlaceAt = useCallback( + const canPlaceResourceAt = useCallback( ({ x, y }: Point) => { if (!canvasElement) { return false; @@ -123,29 +125,32 @@ export function CanvasView({ canvasOverlay, children }: CanvasViewProps) { const origin = viewportToGraphPoint(point, bounds, getPanZoomTransform()); if (origin) { - await createResourceAtOrigin(resourceType, origin); + await createResourceAt(resourceType, origin); } }, - [canvasElement, createResourceAtOrigin, getPanZoomTransform], + [canvasElement, createResourceAt, getPanZoomTransform], ); - const surface = useMemo( - () => ({ createResource, canPlaceAt, resetLayout }), - [canPlaceAt, createResource, resetLayout], + const actions = useMemo( + () => ({ createResource, canPlaceResourceAt, resetGraphLayout }), + [canPlaceResourceAt, createResource, resetGraphLayout], ); return ( - - - <$CanvasWrapper ref={handleCanvasRef}> - - {canvasOverlay} - - - - - - {children(surface)} - + + + + <$CanvasWrapper ref={handleCanvasRef}> + + + + + + + + + + {children} + ); } diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/ModuleNode.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/ModuleNode.tsx index 86c5f81b706..ad665f91cf1 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/ModuleNode.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/ModuleNode.tsx @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { Range } from "../../api"; - import { AzureIcon } from "@vscode-bicep-ui/components"; import { useAtomValue } from "jotai"; import { styled } from "styled-components"; @@ -12,11 +10,8 @@ export interface ModuleNodeProps { id: string; data: { symbolicName: string; - path: string; isCollection?: boolean; hasError?: boolean; - range?: Range; - filePath?: string; }; } 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 index c7ee279a666..2b3fc739445 100644 --- 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 @@ -3,13 +3,13 @@ import type { ReactNode } from "react"; import type { NodeContentRenderProps, NodeKind } from "@/lib/graph"; -import type { Range } from "../../api"; import type { ModuleNodeProps } from "./ModuleNode"; import type { ResourceNodeProps } from "./ResourceNode"; import { useStore } from "jotai"; import { useHydrateAtoms } from "jotai/utils"; -import { useCallback } from "react"; +import { useEffect, useRef } from "react"; +import { styled } from "styled-components"; import { nodeConfigAtom } from "@/lib/graph"; import { useCanvasApi } from "../../api"; import { ModuleNode } from "./ModuleNode"; @@ -18,19 +18,47 @@ 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; -/** The source-location fields a node may carry. Absent on the server-driven path. */ -type NodeSourceLocation = { range?: Range; filePath?: string }; +const $NodeContent = styled.div` + display: contents; +`; -function renderNodeContent(kind: NodeKind, { id, data }: NodeContentRenderProps) { - if (kind === "compound") { - return ; - } +function CanvasNodeContent({ kind, id, data }: NodeContentRenderProps & { kind: NodeKind }) { + const ref = useRef(null); + const api = useCanvasApi(); + + useEffect(() => { + const element = ref.current; - return ; + 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 and what activating a node means. + * 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, @@ -40,23 +68,6 @@ function renderNodeContent(kind: NodeKind, { id, data }: NodeContentRenderProps) export function NodeContentProvider({ children }: { children: ReactNode }) { const store = useStore(); const defaults = store.get(nodeConfigAtom); - const api = useCanvasApi(); - - const handleNodeActivate = useCallback( - (id: string, data: unknown) => { - const { range, filePath } = (data ?? {}) as NodeSourceLocation; - - if (range && filePath) { - // Legacy push path: the node still carries an inline source location. - api.revealFileRange({ filePath, range }); - return; - } - - // Server-driven path: source location is resolved on demand by node id. - api.revealNodeSource(id); - }, - [api], - ); useHydrateAtoms([ [ @@ -65,7 +76,6 @@ export function NodeContentProvider({ children }: { children: ReactNode }) { ...defaults, padding: { ...defaults.padding, top: COMPOUND_NODE_LABEL_INSET }, renderContent: renderNodeContent, - onNodeActivate: handleNodeActivate, }, ], ] as const); diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/ResourceNode.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/ResourceNode.tsx index bb8281e5185..04d77716a59 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/ResourceNode.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/components/nodes/ResourceNode.tsx @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { Range } from "../../api"; - import { AzureIcon } from "@vscode-bicep-ui/components"; import { useAtom, useAtomValue } from "jotai"; import { motion } from "motion/react"; @@ -21,8 +19,6 @@ export interface ResourceNodeProps { resourceType: string; isCollection?: boolean; hasError?: boolean; - range?: Range; - filePath?: string; }; } 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/canvas/context/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/context/index.ts new file mode 100644 index 00000000000..00dce5cb532 --- /dev/null +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/context/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +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/features/canvas/hooks/use-apply-graph.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/hooks/use-apply-graph.ts index cf89946db2a..954e4f4300a 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/hooks/use-apply-graph.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/hooks/use-apply-graph.ts @@ -1,13 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { PrimitiveAtom } from "jotai"; -import type { AnimationPlaybackControlsWithThen } from "motion"; -import type { Box, Point } from "@/lib/math"; -import type { DeploymentGraph, NodeLayout } from "../api"; +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 { reportGraphStatusAtom } from "@/features/status"; import { @@ -19,76 +17,9 @@ import { nodesByIdAtom, removeNodesAtom, } from "@/lib/graph"; -import { translateBox } from "@/lib/math"; -import { hasRangeOnlyChange } from "../utils/layout-invalidation"; +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 @@ -96,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 +45,38 @@ function snapshotNodePositions(): Map { } 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()) => { + (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.length ?? 0) > 0, + hasNodes: (graph?.nodes.size ?? 0) > 0, }); - // Nothing visible changed: refresh the source location on the mounted nodes and stop, rather - // than tearing the graph down and re-laying it out. - if (hasRangeOnlyChange(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; + // 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 useApplyGraph(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 useApplyGraph(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 useApplyGraph(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 useApplyGraph(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 useApplyGraph(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 useApplyGraph(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 useApplyGraph(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 useApplyGraph(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 useApplyGraph(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 useApplyGraph(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/hooks/use-graph-update.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/hooks/use-graph-update.ts deleted file mode 100644 index 0fe8736f20d..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/hooks/use-graph-update.ts +++ /dev/null @@ -1,443 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import type { Box, Point } from "@/lib/math"; -import type { - DeploymentGraph, - GraphBounds, - GraphEdge, - GraphNode, - GraphPatch, - NodeLayout, - Range, - RenderedGraph, -} from "../api"; -import type { ResourceTypeReference } from "../types"; - -import { getDefaultStore } from "jotai"; -import { useCallback, useRef } from "react"; -import { nodesByIdAtom } from "@/lib/graph"; -import { useCanvasApi } from "../api"; -import { pendingResourcesAtom, resourceCreationErrorAtom, resourceNodeIsCommittingAtomFamily } from "../atoms"; -import { patchMayAffectLayout, renderedGraphsEqual } from "../utils/layout-invalidation"; -import { applyGraphLayout, useApplyGraph } from "./use-apply-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: ResourceTypeReference, 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 = useApplyGraph(getViewportCenter); - const api = useCanvasApi(); - 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 layoutResponse = await api.fetchLayout(measuredGraph); - - 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, api], - ); - - 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 response = await api.fetchUpdate(current); - - 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, api]); - - 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: ResourceTypeReference, 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 response = await api.createResource({ - version: 1, - operationId, - resourceType, - }); - - 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; - }, - [api, requestGraphUpdate], - ); - - return { requestGraphUpdate, createResource, resetLayout }; -} 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 index 2edab319af3..9b5e3175218 100644 --- 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 @@ -1,8 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -export { CanvasView, type CanvasSurface } from "./components/CanvasView"; 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/utils/__tests__/layout-invalidation.test.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/utils/__tests__/layout-invalidation.test.ts deleted file mode 100644 index 649df67d4c1..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/utils/__tests__/layout-invalidation.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import type { - DeploymentGraph, - DeploymentGraphNode, - GraphNode, - GraphPatch, - RenderedGraph, - RenderedGraphNode, -} from "../../api"; - -import { describe, expect, it } from "vitest"; -import { hasRangeOnlyChange, 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); - }); -}); - -const ZERO_RANGE = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }; - -function makeAppliedNode(overrides: Partial = {}): DeploymentGraphNode { - return { - id: "n", - type: "Microsoft.Storage/storageAccounts", - isCollection: false, - range: ZERO_RANGE, - hasChildren: false, - hasError: false, - filePath: "/main.bicep", - ...overrides, - }; -} - -function appliedGraph(nodes: DeploymentGraphNode[], errorCount = 0): DeploymentGraph { - return { nodes, edges: [], errorCount }; -} - -describe("hasRangeOnlyChange", () => { - it("ignores a range shift, which is what makes typing above a resource cheap", () => { - const before = appliedGraph([makeAppliedNode()]); - const after = appliedGraph([ - makeAppliedNode({ range: { start: { line: 9, character: 0 }, end: { line: 9, character: 4 } } }), - ]); - - expect(hasRangeOnlyChange(before, after)).toBe(true); - }); - - it("treats two nulls as unchanged but a null on one side as a change", () => { - expect(hasRangeOnlyChange(null, null)).toBe(true); - expect(hasRangeOnlyChange(null, appliedGraph([makeAppliedNode()]))).toBe(false); - expect(hasRangeOnlyChange(appliedGraph([makeAppliedNode()]), null)).toBe(false); - }); - - it.each([ - ["id", { id: "other" }], - ["type", { type: "Microsoft.Web/sites" }], - ["isCollection", { isCollection: true }], - ["hasChildren", { hasChildren: true }], - ["hasError", { hasError: true }], - ["filePath", { filePath: "/other.bicep" }], - ])("reports a change when %s differs", (_field, overrides) => { - const before = appliedGraph([makeAppliedNode()]); - const after = appliedGraph([makeAppliedNode(overrides as Partial)]); - - expect(hasRangeOnlyChange(before, after)).toBe(false); - }); - - it("reports a change when the error count differs, since status is derived from it", () => { - const before = appliedGraph([makeAppliedNode()], 0); - const after = appliedGraph([makeAppliedNode()], 1); - - expect(hasRangeOnlyChange(before, after)).toBe(false); - }); - - it("reports a change when an edge is added or retargeted", () => { - const nodes = [makeAppliedNode({ id: "a" }), makeAppliedNode({ id: "b" })]; - const none: DeploymentGraph = { nodes, edges: [], errorCount: 0 }; - const one: DeploymentGraph = { nodes, edges: [{ sourceId: "a", targetId: "b" }], errorCount: 0 }; - const other: DeploymentGraph = { nodes, edges: [{ sourceId: "b", targetId: "a" }], errorCount: 0 }; - - expect(hasRangeOnlyChange(none, one)).toBe(false); - expect(hasRangeOnlyChange(one, other)).toBe(false); - }); - - it("treats a reordering as a change, erring towards a redundant rebuild", () => { - const a = makeAppliedNode({ id: "a" }); - const b = makeAppliedNode({ id: "b" }); - - expect(hasRangeOnlyChange(appliedGraph([a, b]), appliedGraph([b, a]))).toBe(false); - }); -}); diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/utils/layout-invalidation.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/utils/layout-invalidation.ts deleted file mode 100644 index c474f51aec6..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/utils/layout-invalidation.ts +++ /dev/null @@ -1,157 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import type { DeploymentGraph, GraphNode, GraphPatch, RenderedGraph } from "../api"; - -/** - * The node metadata fields that influence a node's rendered size, and therefore the layout. - * - * "What affects layout" is decided in four places that must stay consistent: - * - * 1. {@link hasRangeOnlyChange} here — the coarsest gate, applied to the whole graph. When nothing - * but source ranges differs, node data is refreshed in place and neither a rebuild nor a layout - * happens. - * 2. {@link patchMayAffectLayout} here — the cheap client pre-filter that decides whether an - * applied `updateNode` patch is worth a re-measure. - * 3. {@link renderedGraphsEqual} here — the authoritative check that compares the freshly measured - * graph (structure + measured sizes) against the last graph that produced a layout. - * 4. 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 the others 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 `next` differs from `previous` only in source ranges. - * - * Ranges shift on edits that change nothing visible — inserting a blank line above a resource moves - * every range below it — so treating them as a change would tear down and re-lay out the graph on - * every keystroke. When this returns true the caller refreshes `range` on the mounted nodes and - * stops there. - * - * Deliberately not an equality function: two graphs that differ only in `range` are *not* equal, and - * a caller wanting equality would be misled. Nodes and edges are compared pairwise by position, so a - * reordering counts as a change; that is conservative in the safe direction, costing a redundant - * rebuild rather than missing a real one. - */ -export function hasRangeOnlyChange(previous: DeploymentGraph | null, next: DeploymentGraph | null): boolean { - if (previous === next) { - return true; - } - - if (!previous || !next) { - return false; - } - - if (previous.errorCount !== next.errorCount) { - return false; - } - - if (previous.nodes.length !== next.nodes.length || previous.edges.length !== next.edges.length) { - return false; - } - - for (let i = 0; i < previous.nodes.length; i++) { - const previousNode = previous.nodes[i]!; - const nextNode = next.nodes[i]!; - - if ( - previousNode.id !== nextNode.id || - previousNode.type !== nextNode.type || - previousNode.isCollection !== nextNode.isCollection || - previousNode.hasChildren !== nextNode.hasChildren || - previousNode.hasError !== nextNode.hasError || - previousNode.filePath !== nextNode.filePath - ) { - return false; - } - } - - for (let i = 0; i < previous.edges.length; i++) { - const previousEdge = previous.edges[i]!; - const nextEdge = next.edges[i]!; - - if (previousEdge.sourceId !== nextEdge.sourceId || previousEdge.targetId !== nextEdge.targetId) { - return false; - } - } - - return true; -} - -/** - * 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/features/canvas/utils/viewport.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/utils/viewport.ts deleted file mode 100644 index 9edc8187f52..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/canvas/utils/viewport.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import type { Point } from "@/lib/math"; - -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/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/components/ControlBar.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/controls/components/ControlBar.tsx index 9d4ce18732a..0372d57dc99 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/controls/components/ControlBar.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/controls/components/ControlBar.tsx @@ -4,11 +4,19 @@ 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 { FloatingPanel, IconButton } from "@/ui"; import { graphControlAvailabilityAtom } from "../atoms"; -import { useResetLayout } from "../hooks/use-reset-layout"; +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; @@ -16,19 +24,16 @@ const $Divider = styled.div` 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 ( - + <$ControlBar data-testid="control-bar"> zoomIn(1.5)} title="Zoom In" aria-label="Zoom In" data-testid="control-zoom-in"> @@ -45,10 +50,10 @@ export function ControlBar({ requestLayout }: ControlBarProps) { @@ -63,6 +68,6 @@ export function ControlBar({ requestLayout }: ControlBarProps) { > - + ); } diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/controls/hooks/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/hooks/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/hooks/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/export/components/ExportAreaCover.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/export/components/ExportAreaCover.tsx index 5d0aeb4f41d..3d6cd9a3a0f 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/export/components/ExportAreaCover.tsx +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/export/components/ExportAreaCover.tsx @@ -4,7 +4,7 @@ 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) @@ -15,8 +15,9 @@ 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/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/export/index.ts index cd2eb5e2d64..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 @@ -4,4 +4,5 @@ export { ExportAreaCover } from "./components/ExportAreaCover"; export { ExportAreaPreview } from "./components/ExportAreaPreview"; export { ExportOverlay } from "./components/ExportOverlay"; +export { ExportPreviewLayer } from "./components/ExportPreviewLayer"; export * from "./atoms"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/export/utils/capture-element.ts b/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/utils/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/components/Palette.tsx b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/components/Palette.tsx index b820c9b299d..6c2bdb5ea7f 100644 --- 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 @@ -1,13 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { CanvasSurface } from "@/features/canvas"; 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"; @@ -15,10 +15,6 @@ import { useResourceTypeCatalog } from "../hooks/use-resource-type-catalog"; import { PaletteContent } from "./PaletteContent"; import { PaletteDragOverlay } from "./PaletteDragOverlay"; -interface PaletteProps { - createResource: CanvasSurface["createResource"]; - canPlaceAt: CanvasSurface["canPlaceAt"]; -} const MotionFloatingPanel = motion.create(FloatingPanel); const $PaletteLauncher = styled(MotionFloatingPanel)` @@ -125,8 +121,9 @@ const $PaletteScrollArea = styled.div` } `; -function EnabledPalette({ createResource, canPlaceAt }: PaletteProps) { +function EnabledPalette() { const [isOpen, setIsOpen] = useState(false); + const { createResource, canPlaceResourceAt } = useCanvasActions(); const { catalogId, namespaces, namespaceError, loadNamespace, search, refresh } = useResourceTypeCatalog(); const placeResource = useCallback( @@ -144,7 +141,7 @@ function EnabledPalette({ createResource, canPlaceAt }: PaletteProps) { [createResource], ); - const { startDrag } = usePaletteDrag(canPlaceAt, placeResource); + const { startDrag } = usePaletteDrag(canPlaceResourceAt, placeResource); return ( <> @@ -228,8 +225,8 @@ function EnabledPalette({ createResource, canPlaceAt }: PaletteProps) { * 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(props: PaletteProps) { +export function Palette() { const enabled = useResourceCreationEnablement(); - return enabled ? : null; + return enabled ? : null; } diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/hooks/use-palette-drag.ts b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/hooks/use-palette-drag.ts index 8a01520d924..ccfc1717167 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/hooks/use-palette-drag.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/features/palette/hooks/use-palette-drag.ts @@ -13,7 +13,7 @@ interface PendingDrag extends PaletteDragState { } export function usePaletteDrag( - canPlaceAt: (clientPoint: { x: number; y: number }) => boolean, + canPlaceResourceAt: (clientPoint: { x: number; y: number }) => boolean, onDrop: (item: PaletteDragState["item"], clientX: number, clientY: number) => void, ) { const activeDragRef = useRef(null); @@ -41,7 +41,7 @@ export function usePaletteDrag( return; } - if (canPlaceAt({ x: event.clientX, y: event.clientY })) { + if (canPlaceResourceAt({ x: event.clientX, y: event.clientY })) { onDrop(drag.item, event.clientX, event.clientY); } cancelDrag(); @@ -62,7 +62,7 @@ export function usePaletteDrag( window.removeEventListener("pointercancel", cancelDrag); window.removeEventListener("keydown", handleKeyDown); }; - }, [canPlaceAt, cancelDrag, 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/lib/graph/atoms/configs.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/atoms/configs.ts index 0e4f2bd1380..5245b959629 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/atoms/configs.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/atoms/configs.ts @@ -21,11 +21,6 @@ export interface NodeContentRenderProps { export interface NodeConfig { padding: Padding; renderContent: (kind: NodeState["kind"], props: NodeContentRenderProps) => ReactNode; - /** - * Invoked when the user activates a node (double-click). Optional: a graph with no activation - * behaviour is legitimate, which is why this does not throw the way `renderContent` does. - */ - onNodeActivate?: (id: string, data: unknown) => void; } export const nodeConfigAtom = atom({ 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 537ba52e5b6..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 @@ -9,7 +9,7 @@ import { frame } from "motion/react"; import { useLayoutEffect, useRef } from "react"; import { translateBox } from "@/lib/math"; import { focusedNodeIdAtom, getNodeZIndex } from "../atoms/nodes"; -import { useBoxUpdate, useDragListener, useNodeActivation } from "../hooks"; +import { useBoxUpdate, useDragListener } from "../hooks"; import { BaseNode } from "./BaseNode"; import { NodeContent } from "./NodeContent"; @@ -19,8 +19,6 @@ export function AtomicNode({ id, boxAtom, dataAtom }: AtomicNodeState) { const focusedNodeId = useAtomValue(focusedNodeIdAtom); const zIndex = getNodeZIndex(id, "atomic", focusedNodeId); - useNodeActivation(ref, id, dataAtom); - useLayoutEffect(() => { if (!ref.current) { return; 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 b67b2ca8eb5..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 @@ -9,7 +9,7 @@ import { useRef } from "react"; import { translateBox } from "@/lib/math"; import { nodesByIdAtom } from "../atoms"; import { focusedNodeIdAtom, getNodeZIndex } from "../atoms/nodes"; -import { useBoxUpdate, useDragListener, useNodeActivation } from "../hooks"; +import { useBoxUpdate, useDragListener } from "../hooks"; import { BaseNode } from "./BaseNode"; import { NodeContent } from "./NodeContent"; @@ -19,8 +19,6 @@ export function CompoundNode({ id, childIdsAtom, boxAtom, dataAtom }: CompoundNo const focusedNodeId = useAtomValue(focusedNodeIdAtom); const zIndex = getNodeZIndex(id, "compound", focusedNodeId); - useNodeActivation(ref, id, dataAtom); - 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/hooks/index.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/hooks/index.ts index 87a76b6391d..6970f41405a 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/hooks/index.ts +++ b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/hooks/index.ts @@ -4,4 +4,3 @@ export * from "./use-box-update"; export * from "./use-drag-listener"; export * from "./use-fit-view"; -export * from "./use-node-activation"; diff --git a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/hooks/use-node-activation.ts b/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/hooks/use-node-activation.ts deleted file mode 100644 index d05032b4909..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/src/lib/graph/hooks/use-node-activation.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import type { Atom } from "jotai"; -import type { RefObject } from "react"; - -import { useStore } from "jotai"; -import { useEffect } from "react"; -import { nodeConfigAtom } from "../atoms"; - -/** - * Calls the configured `onNodeActivate` when a node is double-clicked. - * - * What activation *means* belongs to the product, not the engine: `lib/graph` reports that a node was - * activated and lets `nodeConfigAtom` decide what happens. This is the same injection seam - * `renderContent` uses, and it is what keeps this module free of any host-protocol knowledge. - * - * Uses a native listener rather than an `onDoubleClick` prop so it can `stopPropagation()` before - * d3-zoom's handler on the PanZoom ancestor sees the event. - */ -export function useNodeActivation(ref: RefObject, id: string, dataAtom: Atom) { - const store = useStore(); - - useEffect(() => { - const element = ref.current; - - if (!element) { - return; - } - - const handler = (event: MouseEvent) => { - event.stopPropagation(); - store.get(nodeConfigAtom).onNodeActivate?.(id, store.get(dataAtom)); - }; - - element.addEventListener("dblclick", handler); - - return () => element.removeEventListener("dblclick", handler); - }, [dataAtom, id, ref, store]); -} 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); From 277c67f810fd118e8873c5dd4012c506b1e6c2dd Mon Sep 17 00:00:00 2001 From: Shenglong Li Date: Sun, 30 Aug 2026 20:04:40 -0700 Subject: [PATCH 5/5] Document visual designer architecture Replace legacy notes with concise current documentation for module boundaries, graph synchronization, layout, and resource creation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 91f8ba77-9474-4393-aa97-78e82fb5381b --- .../apps/visual-designer/README.md | 377 ++++------- .../apps/visual-designer/docs/architecture.md | 254 +++++++ .../docs/resource-creation-design.md | 625 ------------------ .../docs/visual-graph-protocol.md | 275 -------- 4 files changed, 384 insertions(+), 1147 deletions(-) create mode 100644 src/vscode-bicep-ui/apps/visual-designer/docs/architecture.md delete mode 100644 src/vscode-bicep-ui/apps/visual-designer/docs/resource-creation-design.md delete mode 100644 src/vscode-bicep-ui/apps/visual-designer/docs/visual-graph-protocol.md diff --git a/src/vscode-bicep-ui/apps/visual-designer/README.md b/src/vscode-bicep-ui/apps/visual-designer/README.md index 484091a1e7c..a03d31b3fb1 100644 --- a/src/vscode-bicep-ui/apps/visual-designer/README.md +++ b/src/vscode-bicep-ui/apps/visual-designer/README.md @@ -1,293 +1,176 @@ # Bicep Visual Designer -A React webview that renders a Bicep file's deployment graph and lets you edit it — pan and zoom the -canvas, reveal a node's source, export a diagram, and create resources from a palette. +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. -It runs inside the `vscode-bicep` extension, which builds it and serves the bundle from -`out/visual-designer/`. In development it runs standalone against a fake extension host, so you can -work on it without launching VS Code. +Production runs inside the `vscode-bicep` extension. Development mode runs in a browser against the +fake host in `src/devtools`. -## Getting started +## Development -Run everything from the workspace root (`src/vscode-bicep-ui`) so sibling packages resolve: +Use Node.js 22 or later. Install workspace dependencies from `src/vscode-bicep-ui`: ```bash -npm install -npm run build # turbo: builds packages, then apps +npm ci +npm run build ``` -Then, from `apps/visual-designer`: +Run app commands from `apps/visual-designer`: ```bash -npm run dev # standalone dev server against the fake host -npm run test # vitest unit tests -npm run e2e # playwright end-to-end tests (npm run e2e:install first) +npm run dev +npm run build npm run lint +npm run test +npm run e2e:install +npm run e2e ``` -`npm run build` must run from the workspace root: `tsc -b` in the app cannot resolve -`@vscode-bicep-ui/*` on its own. `lint` runs with `--max-warnings 0` and -`--report-unused-disable-directives`, so warnings and stale suppressions both fail. +`npm run dev` loads a fake extension host. E2E tests use query parameters such as `catalogDelay` to +make loading and concurrency states deterministic. -The dev server loads `devtools/`, a fake extension host that implements the whole protocol. Query -parameters drive it — `?catalogDelay=…` holds the palette's loading state open, for instance — which -is how e2e reaches states the real host would race. +## Architecture -## Project structure +| 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 | -| Layer | Path | Contains | -| ---------- | --------------- | ------------------------------------------------------------------------- | -| `app` | `src/app/` | Composition root: provider stack, wiring, global style. No product logic. | -| `features` | `src/features/` | User-facing capabilities. Owns product state and Bicep vocabulary. | -| `devtools` | `src/devtools/` | A fake extension host so the webview runs standalone. Dev-only. | -| `hooks` | `src/hooks/` | Cross-cutting concerns, each owning its own host conversation. | -| `ui` | `src/ui/` | Workflow-neutral primitives, motion tokens and theme. No Bicep knowledge. | -| `lib` | `src/lib/` | Reusable libraries: the headless graph engine and the math library. | -| `utils` | `src/utils/` | Shared helpers belonging to no library: text casing, error messages. | +Dependency direction is enforced by ESLint: ```text -app -> features, ui, hooks, lib, utils, devtools -devtools -> features, ui, hooks, lib, utils -features -> ui, hooks, lib, utils, other features (barrel only, acyclic) +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 ``` -Everything not listed is forbidden, including `ui -> hooks`, which keeps primitives taking props -rather than reaching into global state. +Feature-to-feature imports go through the target feature's `index.ts` and must remain acyclic. + +### Source layout ```text src/ - app/ # App, AppProviders, GlobalStyle + app/ + App.tsx + AppEnvironment.tsx + GlobalStyle.ts features/ - canvas/ # the design surface: hydrates and edits the deployment graph - components/ # CanvasView, PendingResourceLayer, nodes/ - hooks/ # use-graph-update (the update state machine), use-apply-graph - utils/ # layout-invalidation, viewport - api.ts atoms.ts types.ts - palette/ # resource type catalog, search, drag-to-create - controls/ export/ status/ - hooks/ # use-document-sync, use-motion-policy-sync - devtools/ # components/, hooks/, fakes/ + 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/ # atoms/, components/, hooks/, theme.ts - math/ # geometry/, comparison.ts - ui/ # components/, motion.ts, theme/ - utils/ # text.ts, errors.ts + graph/ + math/ + ui/ + utils/ ``` -### What goes where +Feature folders contain only the surfaces they need: -The line between `lib` and `features` is **not** "logic vs. UI". It is _would this still make sense in -an app that had nothing to do with Bicep?_ A headless graph engine would. A pending-resource -reconciler would not. +| 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 | -`lib` holds **libraries** — code with a subject of its own, which is why `geometry/` sits inside -`lib/math` rather than beside it. `utils` holds what is left when every library has taken its own. +Components use PascalCase filenames. Hooks, non-component files, and folders use kebab-case. -A **cross-cutting concern is not a feature**, even when it owns protocol and state. Motion policy and -the document are consulted by the whole app and render nothing, so they live in `src/hooks/` as single -self-contained files: descriptor, atom and sync hook are one thought. +### Public boundaries -`devtools` is **not a feature** either. 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`. -Only `app` may import it, and `loadDevAppShell` returns `undefined` in production so the chunk -tree-shakes away. +Each feature, library, and `src/hooks` exposes one barrel: -Feature-to-feature imports are fine — `palette` renders the node preview a dropped resource will -become — but they must go through the target's `index.ts` and must not form a cycle. Resolve a cycle -by putting each shared symbol with its real owner, not by forbidding the edge. The current shape: +- Import other modules through `@/features/*`, `@/lib/*`, `@/ui`, `@/hooks`, or `@/utils`. +- Use relative imports within the same module. +- Export only symbols intended for other modules. -```text -controls -> canvas, export, status -palette -> canvas -canvas -> export, status +### 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; +} ``` -### Feature shape - -Every feature and `lib` module organises its contents the same way, so a reader who opens one can -guess where things are in any other: - -| Folder / file | Holds | -| ------------- | ------------------------------------------------------------------------- | -| `components/` | Components, including any used only inside the feature. | -| `hooks/` | Reusable `use-*` hooks. | -| `utils/` | Pure helpers with no React dependency. | -| `api.ts` | The host protocol this feature uses: descriptors and payload shapes. | -| `atoms.ts` | Feature state. Splits into `atoms/` only when it holds distinct concerns. | -| `types.ts` | Shared domain vocabulary. | - -Include only what a feature needs, and add a folder when its first member arrives rather than -scaffolding it empty. Concept subfolders are allowed inside a type folder when they name a real seam: -`components/nodes/` is the content plugged into `lib/graph`'s node containers. Tests live in -`__tests__/` beside the code they cover, at whatever depth that is. - -### Naming - -- **Components**: PascalCase, filename equals the exported component. One primary component per file. -- **Everything else** (hooks, atoms, utils, types) and **folders**: kebab-case. -- **A hook file is named for the hook it exports** — `use-palette-drag.ts` exports `usePaletteDrag`. - Worth a mechanical check when adding one: the drift is invisible at the import site. -- **Name a feature for the capability it delivers, not the data it displays.** `CanvasView`, - `CanvasActions` and `useCanvasApi` name the surface the user works on; the graph they carry is named - by the protocol types (`CanonicalGraph`, `RenderedGraph`). One set of words must not do both jobs. -- **Name a thing for what it is in the domain, not its visual container.** `ResourceNodePreview`, not - `ResourcePreviewCard`; "card" describes a border radius. -- Prefer one-word folders, and treat a compound name as a prompt to check whether the folder is doing - two jobs or dodging a collision. A two-word name for one real concept is fine. -- Do not prefix a file with the folder containing it. This yields to the hook-file rule above. - -### Public surface - -Each feature, `lib` module and `src/hooks/` exposes exactly one entry point: its `index.ts`. - -- Import through the barrel: `@/lib/graph`, `@/features/export`, `@/hooks`. -- Do not deep-import across a boundary; `@/lib/math/geometry` is `@/lib/math`. -- **Within a module, import relatively** — never `@/its-own-name/...`. Reaching your own siblings - through the barrel creates a cycle, and an aliased deep path sits one keystroke from the form that - does. -- Feature barrels export an intended surface; `lib` and `ui` keep `export *`. -- Export only what crosses the boundary. Because `api.ts` is re-exported through the barrel, - `noUnusedLocals` stops seeing a symbol once it is exported, so unused payload types accumulate - silently. - -### State - -| State | Owner | -| --------------------------------------------------------- | ------------------------------ | -| Canonical graph nodes, edges, boxes, bounds, focus | `lib/graph` | -| Node content registration, pending resources, transitions | `features/canvas` | -| Palette interaction and resource catalog | `features/palette` | -| Export workflow | `features/export` | -| User-facing graph status | `features/status` | -| Effective motion policy | `hooks/use-motion-policy-sync` | -| Document being visualized | `hooks/use-document-sync` | -| Active theme | `ui/theme` | - -Jotai for shared state that benefits from isolated subscriptions; local state when it has one owner. -Across a boundary expose derived values and action atoms, never raw writable atoms. - -**Canvas actions travel through context, not Jotai.** `CanvasView` publishes `CanvasActions` -(`createResource`, `canPlaceAt`, `resetLayout`) that `ControlBar` and `Palette` read with -`useCanvasActions`. Two constraints shape this: - -- `useGraphUpdate` is a single-instance state machine holding the client's copy of the server's - graph, so only `CanvasView` may call it. A hook that called it again would corrupt patch - application. A hook that _reads what the single instance published_ is fine — that is the - difference between the two shapes. -- Jotai is the wrong tool for callbacks. `set(atom, fn)` is read as an updater, so storing one - requires `set(atom, () => fn)`; a registration atom is also null until the owner mounts, which - pushes a null branch into every consumer. Atoms hold state — `graphControlAvailabilityAtom` derives - three booleans from `hasNodes` and belongs in Jotai. Imperative machinery does not. - -`useCanvasActions` throws outside a `CanvasView`, so misuse is a loud error rather than a silent -null. - -### Protocol - -Each feature declares the host messages it uses in its own `api.ts`, and exposes them through an API -hook (`useCanvasApi`, `usePaletteApi`) so callers make method calls instead of hand-assembling -messages. Descriptors are typed via `defineRequest` / `defineNotification` from -`@vscode-bicep-ui/messaging`, which owns _how_ to talk while each feature owns _what it says_. - -Payloads are suffixed `Params` and `Result`. A descriptor and the API method that sends it share a -name deliberately — they are one operation named at two levels. - -See [visual-graph-protocol.md](./docs/visual-graph-protocol.md) for the wire contract itself. - -### Reconciliation - -One pass answers two separate questions: whether the server's graph has moved on, and whether what -we display has been laid out. Three modules split that work: - -| Module | Owns | -| ----------------------------- | ---------------------------------------------------------------------- | -| `graph-update-coordinator.ts` | When each step runs. No React, no Jotai. | -| `use-graph-update.ts` | The client's graph copy, measurement, patch application, Jotai writes. | -| `use-apply-graph.ts` | Turning a graph into mounted nodes and edges. | - -The coordinator tracks what is _owed_ — an update, and a layout that is `none`, `auto` or `reset` — -rather than what is running. Its rules: - -- Reconcile before laying out, so a layout always applies to the current graph. -- A reset outranks an automatic layout, so Reset Layout is never downgraded. -- A `graphChanged` layout re-pends the update **and** the layout it abandoned, keeping its mode. -- Mutations run one at a time, and a reconciliation that overlaps one is abandoned and retried. -- Every request resolves only once the coordinator runs out of work, never when work is merely - recorded. Callers gate on that promise — `useResetLayout` holds its deduplication lock for exactly - as long as it — so resolving early lets a second click queue a second server layout. - -Collapsing the update and layout questions into one flag is what previously let a `graphChanged` -response drop the layout it owed, leaving the graph hidden behind the visibility gate -`use-apply-graph` closes when most of the topology is replaced. Every rule guards an ordering hazard -that is impractical to force end to end, which is why the coordinator is React-free and unit tested -with controlled promises. - -Most keystrokes stop early: `displayedGraphsEqual` compares exactly the fields the apply path reads, -so an edit the canvas cannot show costs nothing. - -### Enforcement - -Structure rules that are not machine-checked decay. `../../eslint.config.mjs` carries import-boundary -rules scoped to this app, built on core `no-restricted-imports` with flat-config `files` zones: - -- each layer's forbidden imports, per the table above -- `src/lib/graph/**` may not import any messaging module - -The last is not a layer rule. `lib/graph -> a messaging module` is a legal `lib -> lib` edge, so -nothing else would stop the engine from learning the host protocol; Bicep behaviour reaches it through -`nodeConfigAtom` instead. `lib/graph/theme.ts` closes the same kind of gap for styling: the engine -declares the theme tokens it needs, and `DefaultTheme` extends that interface, so dropping one is a -compile error rather than a blank canvas. - -The shared-`hooks` and `utils` layers are matched through the `@/` alias only, because both are also -folder names inside features and `**/hooks/**` would flag every feature's own `../hooks/use-x`. +`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 -Most behavioural coverage is end-to-end in `e2e/` (Playwright): palette visibility, pointer and -keyboard placement, drop rejection, catalog loading and search. Unit tests cover the pieces with logic -worth isolating — graph atoms, patch application, layout invalidation, the export file stem, and the -update coordinator's ordering rules. - -Prefer assertions that cannot race. Poll for a settled value rather than sampling once: nodes animate -in and the graph springs to its layout over ~0.6s, so a single read taken when a node appears can land -mid-flight. A graph load runs two independent animations — the fit-view transform and each node's -spring — so waiting on the wrong one passes about half the time. - -Mutation-check a test that encodes an ordering rule: revert the fix it covers and confirm it fails. -The coordinator's rules all look plausible when broken. - -**The resource-creation failure path has no coverage.** The host reports failures as -`CreateResourceErrorResult` from four call sites, but the dev fake always succeeds, so neither the -error atom nor `ResourceCreationError` is exercised. Teaching the fake to fail on demand — the way -`catalogDelay` makes the loading state reachable — is the missing piece. - -## Known gaps - -- `GraphUpdatePatch` and `GraphLayoutPatch` are the same union, so a layout response is typed as - though it could carry `addNode`. Splitting them would let the compiler reject a phase mismatch that - is currently only a convention. -- `Palette` hand-rolls a second floating-panel style from raw `var(--vscode-*)` values at a different - radius; folding it onto `FloatingPanel` is the duplication `ui/` exists to remove. -- Two module-scope `getDefaultStore()` handles in `features/canvas/hooks`, and the coordinator beside - them, should come from context, so the sync pipeline can be driven by a scoped store in tests. They - move together: scoping one without the others buys nothing. -- Share the protocol declarations with the extension host. `vscode-bicep` dispatches on raw string - literals and casts params with `as`, so the two sides agree only by convention. It has no npm - dependency on `vscode-bicep-ui` today, so this needs a `file:` dependency and a build-order - constraint — and should be per-feature modules in a shared package, not one central protocol file. -- Extract `lib/graph` into `packages/` **when a second consumer appears, not before**. It is prepared: - a clean barrel, a documented injection seam (`nodeConfigAtom`), a declared theme contract, and no - Bicep knowledge, enforced by lint. The likely consumer is the playground, which sits outside this npm - workspace and shares none of the engine's runtime dependencies. +- 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 -- [visual-graph-protocol.md](./docs/visual-graph-protocol.md) — the server-driven graph and layout protocol. -- [resource-creation-design.md](./docs/resource-creation-design.md) — the resource creation feature design. -- [.github/instructions/](./.github/instructions) — React, state management and styling conventions, - applied automatically by Copilot when editing matching files. +- [Architecture](./docs/architecture.md) +- [Project instructions](./.github/instructions/) 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/docs/resource-creation-design.md b/src/vscode-bicep-ui/apps/visual-designer/docs/resource-creation-design.md deleted file mode 100644 index 3b4e7312a06..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/docs/resource-creation-design.md +++ /dev/null @@ -1,625 +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 -- Builds on: the [Visual Graph Protocol](visual-graph-protocol.md). That document defines how the - webview, extension and language server keep the graph in sync. This one describes a feature layered - on top of it, and adds one rule to it: the mutation interlock below. -- Related documents: - - [Visual Designer README](../README.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 - -Two interlocks are in play, and they come from different places. The graph loop already allows one -in-flight request at a time and sets a dirty flag when a change arrives during one — that is the -protocol's [concurrency rule](visual-graph-protocol.md#concurrency-rules), independent of this -feature. Resource creation adds a second: while a mutation is in flight, graph responses are deferred, -because a response may already contain the new node before its expected ID is bound to a drop origin. - -```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 - -The webview side lives in `src/features/palette` (browsing, search, drag initiation) and -`src/features/canvas` (pending state, placement, commit reconciliation). See the -[README](../README.md) for the layer rules those follow. - -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, range-only change detection, per-node committing atom isolation and export file naming. -- Playwright tests for: - - Experimental setting disabled - - Palette open/close without canvas resizing - - Zoomed preview/pending center and size alignment - - Progress while the catalog loads - - Lazy global search without expanding providers first - - Drop rejection over the Resource Palette - - Keyboard creation at canvas center - -Not covered: the creation **failure** path. The host reports failures as `CreateResourceErrorResult` from four call sites, but the dev fake always succeeds, so neither the error atom nor `ResourceCreationError` is exercised. - -## 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/docs/visual-graph-protocol.md b/src/vscode-bicep-ui/apps/visual-designer/docs/visual-graph-protocol.md deleted file mode 100644 index 083cde09397..00000000000 --- a/src/vscode-bicep-ui/apps/visual-designer/docs/visual-graph-protocol.md +++ /dev/null @@ -1,275 +0,0 @@ -# Visual Graph Protocol - -This document describes the server-driven visual graph protocol used by the Bicep visual designer. It -covers the graph and layout contract only; features layered on it, such as -[resource creation](resource-creation-design.md), document their own messages and any rule they add. - -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.