VS_CODE_MERMAID_PLAN

VS Code — Workflow Diagram Preview (Mermaid + Cytoscape)

Date: 2026-04-27 Branch (target): new branch off main, e.g. wf_diagram_preview Scope of first pass: Mermaid only. Architecture must accommodate Cytoscape as a drop-in second renderer with no protocol changes.

Cross-references: see VS_CODE_ARCHITECTURE.md §4.5 (virtual doc providers), §4.4 (commands), §5.5 (custom LSP services), §10 (CleanWorkflowService / ConvertWorkflowService — closest existing analogues), §14 (custom LSP protocol).


1. Goal

Add a “Preview Workflow Diagram” command to the editor that opens a webview rendering the active workflow as an interactive graph. First pass renders Mermaid; the same plumbing must support a future Cytoscape renderer by changing only one enum value and one webview module.

Behavior:

In scope (first pass):

Non-goals (first pass):


2. Upstream Status

Versions in package.json are already on ^1.1.0 for @galaxy-tool-util/{core,schema,search}. Confirm workflowToMermaid is exported in the installed version (check node_modules/@galaxy-tool-util/schema/dist/index.d.ts); bump the floor if needed.


3. Architecture Overview

Three layers, mirroring convertWorkflow exactly on the server side, plus a webview on the client.

┌──────────────────────────────────────────────────────────────┐
│ Client                                                        │
│  Command: previewWorkflowDiagram (format = "mermaid")         │
│    ↓                                                          │
│  DiagramPreviewPanelManager                                   │
│    - one WebviewPanel per (uri, format)                       │
│    - subscribes onDidChangeTextDocument (debounced 400ms)     │
│    ↓                                                          │
│  LSP RENDER_WORKFLOW_DIAGRAM { contents, format }             │
│                          ↓                                    │
└──────────────────────────┼────────────────────────────────────┘

┌──────────────────────────┼────────────────────────────────────┐
│ Server (server-common)   ↓                                    │
│  RenderDiagramService (extends ServiceBase)                   │
│    - detectLanguageId(contents) → route to language service   │
│    - languageService.renderDiagram(text, format) → string     │
│  Native LS:  workflowToMermaid(JSON.parse(text))              │
│  Format2 LS: workflowToMermaid(yamlParse(text))               │
└───────────────────────────────────────────────────────────────┘

Webview holds the only renderer-specific logic: a single bundled JS module (mermaid for the first pass, swap to cytoscape later) that consumes the rendered string.


4. Server-Side Work

4.1 Shared protocol (shared/src/requestsDefinitions.ts)

Add:

export type DiagramFormat = "mermaid" | "cytoscape";

export interface RenderWorkflowDiagramParams {
  contents: string;
  format: DiagramFormat;
  /** Renderer-specific options; serialized as-is. Mermaid: { comments?: boolean }. */
  options?: Record<string, unknown>;
}

export interface RenderWorkflowDiagramResult {
  contents: string;     // mermaid → "graph LR ..."; cytoscape → JSON.stringify(elements)
  error?: string;
}

Add identifier:

LSRequestIdentifiers.RENDER_WORKFLOW_DIAGRAM = "galaxy-workflows-ls.renderWorkflowDiagram";

4.2 Language service interface (server-common/src/languageTypes.ts)

Extend LanguageServiceBase with an abstract-ish method (default throws, like convertWorkflowText):

public renderDiagram(_text: string, _format: DiagramFormat, _options?: object): Promise<string> {
  throw new Error("renderDiagram not implemented for this language service");
}

4.3 Native language service

server/gx-workflow-ls-native/src/languageService.ts:

import { workflowToMermaid } from "@galaxy-tool-util/schema";

public async renderDiagram(text: string, format: DiagramFormat, options?: MermaidOptions): Promise<string> {
  const wf = JSON.parse(text);
  switch (format) {
    case "mermaid":   return workflowToMermaid(wf, options ?? {});
    case "cytoscape": throw new Error("Cytoscape not yet implemented");
  }
}

4.4 Format2 language service

server/gx-workflow-ls-format2/src/languageService.ts: same body but parse via the YAML library already in use (yaml.parse(text) — check what convertWorkflowText uses and reuse). The upstream workflowToMermaid accepts the parsed Format2 dict directly.

4.5 RenderDiagramService

New file server/packages/server-common/src/services/renderDiagramService.ts, modeled exactly on convertWorkflow.ts (see §10.2 of architecture doc):

export class RenderDiagramService extends ServiceBase {
  public static register(server) { return new RenderDiagramService(server); }

  protected listenToRequests(): void {
    this.server.connection.onRequest(
      LSRequestIdentifiers.RENDER_WORKFLOW_DIAGRAM,
      (params) => this.onRender(params)
    );
  }

  private async onRender(params: RenderWorkflowDiagramParams): Promise<RenderWorkflowDiagramResult> {
    try {
      const languageId = this.detectLanguageId(params.contents);
      const ls = this.server.getLanguageServiceById(languageId);
      const contents = await ls.renderDiagram(params.contents, params.format, params.options);
      return { contents };
    } catch (error) {
      return { contents: "", error: String(error) };
    }
  }
}

Register it in GalaxyWorkflowLanguageServerImpl.registerServices() alongside ConvertWorkflowService.

4.6 Why send contents over the wire (not URI)?

Same rationale as CONVERT_WORKFLOW_CONTENTS: lets the client preview unsaved edits. The webview always sends the editor’s current text, debounced.


5. Client-Side Work

5.1 Command classes

client/src/commands/previewWorkflowDiagram.ts:

export class PreviewMermaidDiagramCommand extends CustomCommand {
  readonly identifier = getCommandFullIdentifier("previewMermaidDiagram");
  constructor(
    private nativeClient: BaseLanguageClient,
    private format2Client: BaseLanguageClient,
    private panelManager: DiagramPreviewPanelManager,
  ) { super(nativeClient); }

  async execute(_args: unknown[]): Promise<void> {
    const editor = window.activeTextEditor;
    if (!editor) return;
    await this.panelManager.openOrFocus(editor.document, "mermaid");
  }
}

client/src/commands/exportWorkflowDiagram.ts — companion export command:

export class ExportMermaidDiagramCommand extends CustomCommand {
  readonly identifier = getCommandFullIdentifier("exportMermaid");
  // execute: pick the right client by languageId, sendRequest(RENDER_WORKFLOW_DIAGRAM,
  // { contents, format: "mermaid", options: { comments: true } }),
  // write result.contents to <workflow-stem>.mmd alongside the source via workspace.fs,
  // showInformationMessage with a "Reveal in Explorer" action on success,
  // showErrorMessage on { error }.
}

Cytoscape preview/export commands ship in the follow-up PR with the same shape — format: "cytoscape", .cyjs extension, same panel manager.

5.2 Panel manager

client/src/providers/diagramPreviewPanelManager.ts. Single file, ~200 LOC. Responsibilities:

Routing logic mirrors client/src/requests/gxworkflows.ts — extract a small helper if not already shared.

5.3 Webview HTML + JS

Layout: client/media/diagram/

client/media/diagram/
├── mermaid.html          # template; <div id="root"></div> + <script src="{{mainJs}}"></script>
├── mermaid.js            # bundled client code: imports mermaid, listens for postMessage
├── cytoscape.html        # later
├── cytoscape.js          # later
└── shared.css            # minimal (background, container sizing)

mermaid.js (entry):

import mermaid from "mermaid";
mermaid.initialize({ startOnLoad: false, theme: "default" });

const root = document.getElementById("root");
const vscode = acquireVsCodeApi();

window.addEventListener("message", async (ev) => {
  const msg = ev.data;
  if (msg.type === "render") {
    if (msg.error) { root.innerHTML = `<pre class="error">${msg.error}</pre>`; return; }
    try {
      const { svg } = await mermaid.render("diagram", msg.payload);
      root.innerHTML = svg;
    } catch (e) {
      root.innerHTML = `<pre class="error">${e}</pre>`;
      vscode.postMessage({ type: "error", message: String(e) });
    }
  }
});

vscode.postMessage({ type: "ready" });

Bundled with esbuild. Add to client/tsup.config.ts (or a sibling esbuild script in package.json scripts):

{
  entry: { "media/diagram/mermaid": "src/webview/diagram/mermaid.ts" },
  outDir: "dist",
  format: ["iife"],
  platform: "browser",
  external: [],
  bundle: true,
  sourcemap: true,
}

Source moves under client/src/webview/diagram/mermaid.ts so TypeScript checking covers it; the HTML template lives at client/media/diagram/mermaid.html (static, copied to dist or referenced directly via extensionUri).

CSP: webview HTML must include a Content-Security-Policy meta tag. Pattern:

<meta http-equiv="Content-Security-Policy"
      content="default-src 'none';
               style-src ${cspSource} 'unsafe-inline';
               script-src ${cspSource};
               font-src ${cspSource};
               img-src ${cspSource} data:;">

(unsafe-inline for styles is required because mermaid injects <style> tags during render. If we want to tighten this later, use a nonce.)

5.4 Wiring in setupCommands()

client/src/commands/setup.ts:

const diagramPanelManager = new DiagramPreviewPanelManager(context, nativeClient, gxFormat2Client);
context.subscriptions.push(diagramPanelManager);
context.subscriptions.push(new PreviewMermaidDiagramCommand(nativeClient, gxFormat2Client, diagramPanelManager).register());
context.subscriptions.push(new ExportMermaidDiagramCommand(nativeClient, gxFormat2Client).register());

5.5 package.json contributions

"commands": [
  {
    "command": "galaxy-workflows.previewMermaidDiagram",
    "title": "Galaxy Workflows: Preview Diagram (Mermaid)",
    "icon": "$(graph)"
  },
  {
    "command": "galaxy-workflows.exportMermaid",
    "title": "Galaxy Workflows: Export as Mermaid (.mmd)"
  }
],
"menus": {
  "editor/title": [
    {
      "command": "galaxy-workflows.previewMermaidDiagram",
      "when": "resourceLangId == galaxyworkflow || resourceLangId == gxformat2",
      "group": "navigation"
    }
  ],
  "commandPalette": [
    {
      "command": "galaxy-workflows.previewMermaidDiagram",
      "when": "resourceLangId == galaxyworkflow || resourceLangId == gxformat2"
    },
    {
      "command": "galaxy-workflows.exportMermaid",
      "when": "resourceLangId == galaxyworkflow || resourceLangId == gxformat2"
    }
  ]
}

6. Cytoscape-Ready Design Notes

Concrete extension points so the second pass is mechanical:

  1. Wire shapeformat: DiagramFormat and contents: string already cover both. Cytoscape returns JSON.stringify(elements); webview parses it.
  2. Server LS dispatchrenderDiagram(text, format, options) already switches on format; cytoscape adds one case calling workflowToCytoscape (once upstream exists). Until then the case throws and the client surfaces the error gracefully.
  3. Webview — separate cytoscape.html + bundled cytoscape.js entry point; the panel manager picks the file based on format. Mermaid bundle and Cytoscape bundle don’t share weight.
  4. Panel keying${uri}::${format} already permits both panels open simultaneously for the same workflow.
  5. Live update — debounced onDidChangeTextDocument is renderer-agnostic; cytoscape’s cy.json({ elements }) replaces the graph in place without recreating the instance — defer that optimization.
  6. Click-to-jump (committed v2 follow-up) — webview → extension via postMessage({ type: "selectStep", stepId }). Extension uses the existing revealToolStep plumbing (client/src/commands/revealToolStep.ts) to scroll the editor. Both renderers emit the same message — cytoscape via cy.on("tap", "node", …), mermaid via DOM click handlers on [id^="flowchart-"] nodes. To preserve room: server includes a stepId per node in mermaid output as a node-id suffix or via the existing label, and the cytoscape elements carry data.stepId. Track getStepNodes() ranges for the existing revealToolStep so mapping stepId → Range is one lookup. Not implemented this PR; structures must not preclude it.

7. Build Considerations


8. Testing Plan

Red-to-green for each layer.

8.1 Server unit tests (Vitest)

server/packages/server-common/tests/unit/renderDiagramService.test.ts:

server/gx-workflow-ls-native/tests/integration/renderMermaid.test.ts:

server/gx-workflow-ls-format2/tests/integration/renderMermaid.test.ts: same with .gxwf.yml fixture.

8.2 Client unit tests (Jest)

client/tests/unit/diagramPreviewPanelManager.test.ts:

8.3 E2E test (VS Code Test API)

client/tests/e2e/diagramPreview.e2e.ts:

8.4 Manual checks


9. Implementation Order (suggested commits)

Status as of 2026-04-27 (branch wf_tool_state):

  1. Protocol + server stub (commit 492494d) — shared/ types + identifier, RenderDiagramService, LanguageServiceBase.renderDiagram default-throw, native + format2 implementations calling workflowToMermaid. Cytoscape case throws "not yet implemented". 6 server unit tests + 5 integration tests passing; full server suite 440/440.
  2. Webview bundle target (commit a52ec96) — third tsup entry (iife/browser), client/src/webview/diagram/mermaid.ts, client/media/diagram/mermaid.html + shared.css. Output is dist/media/diagram/mermaid.global.js (tsup IIFE convention adds .global.js — accepted, panel manager references that exact name). Mermaid ^11.14.0 added as a client dep.
  3. Panel manager + preview command (commit 70cb06c) — DiagramPreviewPanelManager with Map<uri::format, PanelEntry>, PreviewMermaidDiagramCommand, wired in setupCommands. package.json contributes the command + commandPalette + editor/title menu entries. HTML template loaded via workspace.fs.readFile (works in both Node and browser hosts).
  4. Live update (commit 06881f9) — debounced 400ms via extracted RenderScheduler (per-key, hooks-injectable for fake timers). Subscribes onDidChangeTextDocument filtered to entry URI; onDidCloseTextDocument disposes the panel. 7 jest unit tests; full client jest suite 58/58.
  5. ⏭️ Export commandExportMermaidDiagramCommand writes .mmd alongside source; manual check: file written, “Reveal in Explorer” works.
  6. ⏭️ Tests — partially landed (server unit + integration in commit 1; LSP-wire E2E in commit 81accd2; scheduler unit tests in commit 4). Outstanding: panel-manager / DOM-level coverage — deferred to upstream issue davelopez#86 (wdio-vscode-service harness, opt-in npm run test:wdio target).
  7. ⏭️ Docs — README section + update VS_CODE_ARCHITECTURE.md with new service / provider entries.

Layer-1 LSP-wire E2E (commit 81accd2, 4 tests in client/tests/e2e/suite/diagramPreview.e2e.ts) covers both formats, malformed input, and the cytoscape stub branch through the real LSP transport. Layer-2 (panel + render postMessage) was rejected in favour of the wdio follow-up to avoid leaking test-only message types into the webview script.


10. Files Touched (Summary)

New (✅ landed unless marked ⏭️):

Modified:


11. Decisions and Remaining Questions

Locked in:

Still open (lower stakes):