-
Notifications
You must be signed in to change notification settings - Fork 1
VisualEditor
🌐 This page in: English · Português
A first-party VS Code extension that turns the SDK into a visual development environment: a screen renders beside the file that produces it, from the buffer you are typing in, every rendered element knows which C# expression built it, and selecting one shows — and edits — that expression.
The preview is the real web realizer running the real compiled module. It is not a lookalike, and that is the point: a preview that renders is evidence the page renders. Nothing new draws pixels.
Status. The preview, the identity mechanism, selection, the property inspector and the three structural gestures (insert, move, remove) are in place and covered — by unit tests against a real project compilation, and by an integration suite that runs inside a real VS Code. The extension is still built from source rather than installed from the Marketplace. Track E in the Roadmap has the milestones.
Since 0.2.0-preview.30
eqdesign is a long-lived process that holds the project's Roslyn compilation and compiles the
editor's buffer — not the file on disk. That distinction is the whole reason it exists: eqc
reads files and the hot-reload service watches the filesystem, so neither can show you the text you
are currently looking at, only the text you last saved.
It speaks newline-delimited JSON over stdio. No port to choose, nothing to authorise, nothing left listening if the window dies, and it works unchanged over Remote-SSH because the extension host runs on the remote machine anyway.
Measured on samples/DefaultUIDashboard (16 source files, 316 references):
| Step | Time |
|---|---|
| Build the compilation, once at activation | 271 ms |
Compile an edit (662-line PaymentsPage, warm) |
p50 293 ms |
| Diagnostics only (bind, no transpile) | 36 ms |
Hence two cadences: errors are re-checked after a 150 ms pause in typing, the preview recompiles after 400 ms. A C# error stops the compile before anything is emitted — Roslyn parses leniently, so a missing brace still yields a tree the transpiler would walk, producing a module that mounts and throws, which arrives on screen as a blank frame with nothing to explain it.
The host refuses to start if the reference list is empty, rather than compiling against an incomplete model. That failure is otherwise invisible: without the semantic model, named arguments are emitted in syntactic order, and the screen renders with its values in the wrong slots and no error anywhere.
A screen is rarely one file. A shell, a row, a data helper — so the extension sends every C# buffer the editor currently holds, not only the active one, and the host overlays all of them onto the compilation before it compiles. Otherwise the preview would show you the last saved version of everything except the file you happen to be typing in.
Since 0.2.0-preview.30
VisualNode carries an Origin — the source span of the expression that constructed it, or null,
which is what every shipped build is. A design-mode compilation fills it in:
var compiler = new ComponentCompiler { DesignMode = true };
// Every node construction is emitted wrapped, so the built node remembers where it came from:
// $eq.origin(UI.text(…), "…/PaymentsPage.cs|28:12|28:73")Both realizers carry it into the DOM as data-eq-origin, attached at their single dispatch:
<div data-eq-origin="/src/Screens/PaymentsPage.cs|27:8|34:10">
<span data-eq-origin="/src/Screens/PaymentsPage.cs|28:12|28:73">Count: 0</span>
</div>The format is path|startLine:startColumn|endLine:endColumn, zero-based — the editor's own
coordinates.
Why not source maps. The obvious answer does not work. The whole Build body is converted to one
flat string and emitted through a single call, so the finest position a V3 map can name is the start
of the method: one shipped module has a 942-character line. An origin is exact instead — and because
it is a plain string on the abstract node, the native (Photon) track inherits the same mechanism the
day it wants it.
Only constructions are stamped, never references. Stamping a variable where it is merely mentioned
would overwrite the construction's origin with the span of a use, and select the wrong line. A node
built inside a foreach carries the span of the one expression that builds it — which is the only
editable thing there anyway.
Design mode is off by default and never on in an SDK build: the wrapper is real emitted code, and
shipping it would put a design tool's concern in every user's bundle. A production render contains no
data-eq-origin at all, and the SSR/hydration fingerprint test pins that.
Since 0.2.0-preview.30
An origin says where a node came from. It does not say whether that place can be edited, and treating
the two as one question is how a visual editor corrupts a file. A row built five hundred times inside
a foreach has one source site and no separate existence; a node returned by a helper method is
written somewhere else entirely. "Delete this row" is wrong for both.
So every selected node is classified, by walking Roslyn's ancestors from the origin's position:
| Tier | Where it is written | What the canvas offers |
|---|---|---|
literal |
Unconditionally, in the Build being previewed |
everything, including structural edits |
derived |
Inside a loop, a conditional or a callback | select, inspect, edit properties |
foreign |
Another member, a local function, or another file | select, inspect, and jump to where it is written |
A local function counts as its own member here, because a node built there is written once and reached by a call — exactly like a helper method.
Every refusal explains itself before the affordance is drawn, never after it is clicked:
Built inside a loop — every repetition comes from this one expression, so there is no single row to move or delete.
Built by
PaymentRow(), not byBuild().
The distinction that matters in practice: understanding is not fenced, only writing is. The editor opens code that already exists, and real code splits itself across helper methods and files. Selecting, inspecting and editing a property work wherever the node was written, including in another file, which is why the host is sent every open buffer. Structural insertion is what stays inside the declarative form.
Since 0.2.0-preview.30
Selecting an element opens a docked panel, in the editor's own chrome, listing what the component takes: each property's type, the doc comment read off the source symbol, and how it is currently written — as an argument, in an object initializer, or not at all.
Edits are written back as C# source, because the file is C#. An enum offers its members as a list
rather than a text box; everything else is a value you type, and 12, "Save" and
Variant.Secondary are all just expressions.
Three things keep that honest:
- It is compiled before it is offered. A candidate edit is parsed and bound, and a refusal quotes the compiler: That would not compile: CS0117 ….
-
It goes through
WorkspaceEdit, never a file write. The change lands in the document's own undo stack, so one Ctrl+Z reverses the whole gesture and an unsaved buffer stays unsaved. -
It never rewrites the form the file is written in. A component's init-only members —
Button'sLoading,FlexNode'sWidth— are reachable only through an object initializer, and the factory surface exists precisely so nobody writes one. On a factory call the honest answer is "not from here, and here is why", which beats silently turningButton("Save")intonew Button("Save") { … }behind the author's back.
The panel also descends one level, into a value an argument carries — style.Padding, where
style is the argument and Padding a member of the BoxStyle written into it. Without that it would
offer a Box's gap and nothing anyone came for: padding, background and corner radius are not on the
node at all.
Since 0.2.0-preview.30
Structural gestures are fenced to the declarative form — a children: [ … ] collection
expression:
Column(gap: Space.S4, children: [
Text("Total", TypeRole.LabelM, context.Theme.TextSecondary),
Text(total, TypeRole.TitleM, context.Theme.TextPrimary),
])That list is the only shape an insertion can be spliced into safely: its elements have real spans, so
a new one goes between two of them without touching anything else. A container built with statements
(var column = new Column(); column.Add(…);) is dataflow, not a list, and rearranging it is a
different problem. The panel asks first and says so rather than offering a control that refuses.
-
Insert comes from a palette derived from the factory surfaces themselves, so a new component
appears in it without anyone maintaining a list. That includes the app's own components: the
generator writes them an
AppUIsurface exactly like the framework's, and they are offered first, under their own heading. Every entry is proven to compile where it lands — a test inserts each one into a real list and fails on any that would not. - Move swaps two elements and leaves everything between them exactly where it is: the comma, the newline, the indentation. Rewriting the list from its element texts would be simpler and would silently discard a comment someone left between two children. One imperfection remains, and it is named rather than hidden: a comment written above a child stays with the position, not with the child.
- Remove takes the element and exactly one separator. The element alone leaves a stray comma; both leave a hole.
Neither move nor remove is offered where it means nothing: a node that is not an element of a list has no order to be moved within, so the controls are hidden rather than disabled — there is nothing to explain about an arrangement that does not exist.
Each gesture is a single WorkspaceEdit, so each is one undo.
The same three gestures are on the pointer, where the tree is legible:
- Drag a child to reorder it. A caret marks the gap it would land in, and the badge says where it is going — Text → 3 of 5. However far it travels, it is one edit and one undo.
-
Hover a child and a
+appears at each end of it — before this one, after this one — along the container's own axis. Which axis is read off the rendered result rather than the CSS: aRowis flex, aGridis grid and aStackis absolute, and on screen all three answer "are these side by side?" the same way. (With fewer than two children there is no result to read, and only there does the canvas ask the layout itself.) -
Hover a container's own space, including an empty one, and the gap nearest the pointer is marked
with a dashed line and a single
+. That mark is the answer to where could something go, which is a question a canvas should never make anyone guess at. The dash is deliberate: the caret marks a commitment, the dash marks a possibility.
A drag is not confined to the list it started in: the drop target is recomputed from the pointer on
every move, so the caret follows into whatever container it is over. Leaving its own list is a remove
and an insert, which the host writes as one replacement spanning both — one edit, one Ctrl+Z. The
moved text is re-indented to its new depth and otherwise carried across verbatim, which is what makes
the compiler the fence: a node written against a local of the method it came from does not compile
in its new home, and the refusal quotes CS0103 rather than inventing a rule about what is portable.
Refusals are drawn before the gesture rather than after it. The screen and the C# list have to line up child for child — a child that renders as several elements, or as none, breaks that correspondence, and an index computed from pixels would then name a different element in the file, so the affordance is withheld rather than guessed. A node cannot be dropped into its own subtree, into a container that takes no list, or into a list in another file — that would edit two documents at once.
A move invalidates the span the panel was holding, so the host answers with where the node landed and the selection follows it there. Without that, asking about the old coordinates describes the sibling it was just swapped with.
The extension is built from source and points at the design host in the same checkout:
cd extensions/vscode && npm install && npm run compileThen open a C# page or component and run eQuantic UI: Open Preview. The project must have been
built once — the preview stands on an ordinary build's output (the reference list the SDK writes, and
wwwroot/_equantic/runtime.js), so "build the project once" is the honest instruction when a piece is
missing.
The preview's own controls live in the editor title bar, contributed as ordinary commands, so they inherit the theme and the icon set like any other editor action: the pointer starts and stops inspecting, and a restart is there for when the host has to be brought back.
- No dragging between files. A node can be dragged into any list in the file being edited, but a container written in another one is refused: that edit would touch two documents at once.
-
GridandStackneed their own gesture. A Grid's columns are tracks whose addition re-flows every child, and a Stack's list order is paint order — neither is the "insert a sibling" gesture the other containers share. -
No native preview. Identity is already solved on Photon (
LayoutNodecarries the node, absolute bounds and a stable path); only getting frames into a webview is missing. -
No
[ServerAction]calls. Action ids come from a startup assembly scan, so an ad-hoc preview class is never in the registry. - The baseline theme. An app selects its theme at startup, and reading that back means running the app's composition root. Shapes and layout are exact; a rebranded palette is not yet reflected.
- Debugging & Development Tools — the logger and error overlay the preview reuses.
- Compiler — how C# becomes the module the preview runs.
- Roadmap — Track E and what is ahead of it.
🌐 English · Português
🏁 Start here
📱 Write-once
- Write-Once Components
- Declarative Surface
- Photon Engine
- Design System
- Capabilities
- Storage
- Forms
- Code Editor
- Markdown
- Mermaid
- Email Rendering
🏗️ Architecture
⚙️ Compilation
- Compiler
- Compile-Time Evaluation
- Supported C# Features
- External Type Resolution
- Build Flow
- Diagnostics
⚡ Runtime
🔌 Server
🎨 Ecosystem
🚀 Development