From 22002ac2888f6e6c48bb47b6f3eb3080c36f10ce Mon Sep 17 00:00:00 2001
From: raiseCatError <315733358+raiseCatError@users.noreply.github.com>
Date: Thu, 22 Oct 2026 14:34:27 +0530
Subject: [PATCH 1/2] Mermaid diagrams: drawn by the person's installed Mermaid
in an isolated, script-free, offline Chromium page; pixels only; on request
by default, bounded and cached; source fallback with the reason
---
.github/workflows/ci.yml | 13 ++
CHANGELOG.md | 3 +-
docs/design/browser-engine.md | 3 +-
docs/design/diagrams.md | 113 ++++++++++
docs/design/graphics-service.md | 1 +
docs/development/v024-ledger.md | 2 +
docs/testing/v024-editor-physical-qa.md | 9 +
src/app/TerminalApp.ts | 121 ++++++++++-
src/browser/engine/diagram.ts | 229 +++++++++++++++++++++
src/browser/engine/engine.ts | 8 +-
src/browser/engine/markdownHtml.ts | 26 ++-
src/browser/engine/page.ts | 6 +
src/browser/host.ts | 2 +-
src/browser/types.ts | 36 ++++
src/diagrams/document.ts | 155 ++++++++++++++
src/diagrams/library.ts | 104 ++++++++++
src/diagrams/mermaid.ts | 121 +++++++++++
src/diagrams/service.ts | 262 ++++++++++++++++++++++++
src/graphics/service.ts | 17 +-
src/markdown/render.ts | 57 +++++-
src/prompt/configuration.ts | 11 +-
src/setup/SetupCat.ts | 1 +
src/ui/SettingsPanel.ts | 9 +-
src/viewer/DocumentWorkspace.ts | 6 +
src/viewer/EditorPalette.ts | 1 +
src/viewer/EditorPanel.ts | 7 +-
src/viewer/HybridView.ts | 13 ++
src/viewer/ViewerPanel.ts | 53 +++--
tests/browserPreviewMarkdown.test.ts | 17 ++
tests/diagramsChrome.test.ts | 212 +++++++++++++++++++
tests/diagramsDocument.test.ts | 225 ++++++++++++++++++++
tests/diagramsLibrary.test.ts | 74 +++++++
tests/diagramsMermaid.test.ts | 77 +++++++
tests/diagramsService.test.ts | 228 +++++++++++++++++++++
tests/documentWorkspaceApp.test.ts | 59 ++++++
35 files changed, 2249 insertions(+), 32 deletions(-)
create mode 100644 docs/design/diagrams.md
create mode 100644 src/browser/engine/diagram.ts
create mode 100644 src/diagrams/document.ts
create mode 100644 src/diagrams/library.ts
create mode 100644 src/diagrams/mermaid.ts
create mode 100644 src/diagrams/service.ts
create mode 100644 tests/diagramsChrome.test.ts
create mode 100644 tests/diagramsDocument.test.ts
create mode 100644 tests/diagramsLibrary.test.ts
create mode 100644 tests/diagramsMermaid.test.ts
create mode 100644 tests/diagramsService.test.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6ce65087..6832882e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -86,6 +86,19 @@ jobs:
- uses: ./.github/actions/linux-shells
if: runner.os == 'Linux'
- run: npm ci
+ # The Mermaid diagram tests use a real Mermaid. NMSh never downloads one; CI fetches the pinned release, checks its npm
+ # integrity and keeps only the bundle (tests/diagramsChrome.test.ts reads NMSH_TEST_MERMAID).
+ - name: Mermaid for the diagram tests
+ shell: bash
+ run: |
+ set -euo pipefail
+ dir="$RUNNER_TEMP/mermaid"
+ mkdir -p "$dir"
+ npm pack mermaid@12.2.0 --pack-destination "$dir" --silent > /dev/null
+ actual="sha512-$(openssl dgst -sha512 -binary "$dir/mermaid-12.2.0.tgz" | openssl base64 -A)"
+ test "$actual" = "sha512-w7nr/SFrLO7BaVTs3dQTvYSV6nYSBj0Yy9DjBhsIJtAcHqRzXSq4Pg32ujHZ/AdWsAMNEvvhbm5dt9E2tOiHzw=="
+ tar -xzf "$dir/mermaid-12.2.0.tgz" -C "$dir" package/package.json package/LICENSE package/dist/mermaid.min.js
+ echo "NMSH_TEST_MERMAID=$dir/package" >> "$GITHUB_ENV"
# Built-launcher tests must execute rather than silently skip on fresh runners.
- run: npm run build
- run: npm test -- --shard=${{ matrix.shard }}/2 --test-timeout=120000
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1b162646..620ca7c9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Added
+- **Mermaid diagrams in Markdown** ([#462](https://github.com/raiseCatError/notMyShell/issues/462); [design](docs/design/diagrams.md)). A ```` ```mermaid ```` fence is drawn as a real diagram — flowcharts, sequence, class, state, ER, Gantt, pie, journey, git graph, mind map, timeline, quadrant, XY, requirement, Sankey, block and C4 diagrams — in Preview (NMSh's renderer and the Chromium preview), Split's preview pane, the viewer and Hybrid's inactive blocks, with the image backend your terminal supports (Kitty graphics, iTerm2, Sixel, or character art); `i` opens it in the image viewer to zoom and pan. NMSh uses the Mermaid you installed (`npm install -g mermaid`, or `NMSH_MERMAID`) inside the real browser it already drives, and downloads nothing. The page that draws runs no document script, loads nothing from the network or disk and returns only pixels; Mermaid's security settings are locked against directives. Settings → General → **Mermaid diagrams**: *On request* (default: press `D`, or F1 › Diagrams while editing — opening a document never starts a browser), *Automatic*, or *Off*. The fence stays the source: in Hybrid the block under the caret is its exact text, edits are drawn after a pause, and anything not drawn (not asked yet, no browser or Mermaid, a syntax error, too large, a timeout) shows the fence as written with `(diagram not drawn: reason)`. Drawings are cached by content and theme, bounded in number, bytes and time, and the renderer page closes when no document shows a diagram.
- **Hybrid Markdown editing** ([#460](https://github.com/raiseCatError/notMyShell/issues/460)). **F3** in a Markdown document (or `H` from the read-only views) shows it *rendered* — headings, emphasis, links, lists, tables, quotes, code — except for the block your caret (or selection) is in, which shows its exact Markdown source and is fully editable. Move on and it renders again. It is another presentation of the same buffer: every key is the editor's (letters always type; snippets, list/table helpers, undo/redo, hunk review, recovery drafts and Save As all work as in the source editor), the caret lives in source positions, and switching views never changes the text. The smallest safe block is revealed — the whole list for a nested item, the whole table, the whole fence — and a selection shows every block it touches as source. The view is anchored to a source line, so revealing a block does not move what is above it. F3 again returns to the source editor; in the split view (F2) F3 switches the text pane between source and hybrid. Limits: Markdown files only (not HTML); images appear as `[image: alt]`; Up/Down move by source line; pointer input is not handled.
- **Expandable ``** ([#462](https://github.com/raiseCatError/notMyShell/issues/462)). The native Markdown/HTML viewer now shows `……` as a disclosure: `▸ Summary` when closed (hiding its content), `▾ Summary` with the content indented when open — in the rendered view, the preview, the split's preview pane and the hybrid view. GitHub-style details, with a blank line and Markdown inside and `` in a later block, are understood (nested ones too); the `open` attribute sets the starting state. **`z`** toggles the summary at or below the top of the screen, **`Z`** all summaries in view; choices are kept by summary text, survive scrolling, resizing and edits above, and never change the file. Scripts, handlers and unknown tags in a details block are inert text. Not yet: pointer clicks (the viewer has no pointer support), and toggling inside the hybrid view (it shows each details as written: closed unless `open`).
- **Math in Markdown** ([#462](https://github.com/raiseCatError/notMyShell/issues/462)). `$…$` (GitHub's rules: no space inside the dollars, not before a digit) and display `$$…$$` / ```` ```math ```` render as typeset math in the viewer, preview, split and hybrid view, offline and without a browser: real stacked fractions with a bar, roots, super- and subscripts (Unicode where every character has one, an unambiguous `x^(…)` form otherwise, placed in two dimensions in display), `∑ ∏ ∫` with limits, Greek letters, operators and relations, `\left…\right` stretched delimiters, `matrix`/`pmatrix`/`bmatrix`/`vmatrix`/`cases` and `\text`. It is a defined subset, not full LaTeX: anything outside it (unknown commands, too large, too wide, a fraction that does not fit one row inline) stays as its source text with a dim `(not rendered: reason)`, never a guess. Source is preserved exactly (the editor and `/copy` are unchanged), Safe Glyphs draw with plain ASCII, and formulas are limited in size, depth and rows. Rendering through KaTeX in the optional browser is not part of this slice.
@@ -16,7 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **Split view for Markdown and HTML documents** ([#461](https://github.com/raiseCatError/notMyShell/issues/461)). **F2** (or `s` in the read-only views) in the document workspace shows the editor beside NMSh's own preview of the *same* buffer: the text on the left, the rendered document on the right, updated as you type, without saving. The preview follows the caret to its block (`l` in the preview pane turns that off), **Ctrl+O** moves between the panes, `x` swaps the sides and `<` `>` change their widths (30–70%). Only the text pane is editable; keys typed while the preview has the focus never reach the text. The preview pane has its own find (`/`) and heading prompt (`#`). Below 100 columns only the focused pane is shown at full width, and widening the terminal brings the split back. Esc leaves for the read-only source view with your unsaved text kept; F2 again returns to editing. The browser-quality preview is not used in the split; the pane is NMSh's renderer. Mouse input in the split is not handled yet.
- **Heading navigation** ([#462](https://github.com/raiseCatError/notMyShell/issues/462), first slice). In a Markdown or HTML document, **`#`** in the viewer or workspace opens a heading prompt: type to narrow, Up/Down choose, Enter jumps (source or rendered view, any width) and **Backspace** goes back to where you were. In the editor, F1 then `h` (or *Go to heading…* in the snippet palette) lists the headings with their depth; `j` / *Back* returns to the caret position you left, even after edits. `/view README.md#install` opens the file at that heading. Anchors follow GitHub's rules (lower-case, punctuation dropped, `-1`, `-2` for repeats; raw HTML headings listed without an anchor); Jekyll/Kramdown ids are different and are not implemented (see the [architecture note](docs/architecture/document-source-map.md)). Underneath, every parsed Markdown block now knows its source lines and every rendered row its block's line, the shared map the coming split view and hybrid editing build on.
- **`/playground`: an unsaved Markdown or HTML document** ([#468](https://github.com/raiseCatError/notMyShell/issues/468), second slice). `/playground md`, `/playground html`, or `/playground` to choose, opens an editable scratch document in the document workspace at once — type, switch between source, edit and preview with the usual keys, and use the snippets. Nothing is saved for you: **Ctrl+S asks for a file name** (Save As creates a new file with the same checked save as `/edit` and never replaces an existing one), and closing with unsaved text asks Save As, Discard or Keep editing. If NMSh or the terminal dies, the text is kept as a private draft and `/playground recover` brings it back. The preview is NMSh's own renderer: no browser process, no folder served, scripts and network stay off. Split view arrives with [#461](https://github.com/raiseCatError/notMyShell/issues/461).
-- **Snippets in `/edit`** ([#468](https://github.com/raiseCatError/notMyShell/issues/468), first slice). In a Markdown or HTML file, **F1** (Markdown: F1 then `/`) opens a searchable palette of built-in templates — headings, tables, task lists, fenced code, block quotes, footnotes, Mermaid and math blocks as source text (NMSh does not render them), an HTML page skeleton, semantic sections, lists, tables, links and images — together with the editing actions that already exist. Type to filter, Enter inserts. A snippet is plain text inserted as one undoable edit; its placeholders are selected one by one with **Tab / Shift+Tab** for as long as you are inside it, **Esc** or moving away ends that without touching the text, and Tab is an ordinary tab everywhere else. Block templates start on their own line, wrap a selection where it makes sense, and follow the line's indentation. Nothing is executed, fetched or installed.
+- **Snippets in `/edit`** ([#468](https://github.com/raiseCatError/notMyShell/issues/468), first slice). In a Markdown or HTML file, **F1** (Markdown: F1 then `/`) opens a searchable palette of built-in templates — headings, tables, task lists, fenced code, block quotes, footnotes, Mermaid and math blocks (inserted as source text), an HTML page skeleton, semantic sections, lists, tables, links and images — together with the editing actions that already exist. Type to filter, Enter inserts. A snippet is plain text inserted as one undoable edit; its placeholders are selected one by one with **Tab / Shift+Tab** for as long as you are inside it, **Esc** or moving away ends that without touching the text, and Tab is an ordinary tab everywhere else. Block templates start on their own line, wrap a selection where it makes sense, and follow the line's indentation. Nothing is executed, fetched or installed.
- **Review changes made on disk while you edit** ([#463](https://github.com/raiseCatError/notMyShell/issues/463), third slice). When saving finds the file changed by something else, the conflict choice now includes **Review changes** (`v`). It merges three versions — the file as you opened or last saved it, your unsaved text and the file now on disk — and lists the hunks. Changes only the disk made default to the disk's version (`m` keeps yours instead); text you both changed, or changes that touch each other, are conflicts that stay undecided until you pick yours (`m`), the file's (`t`), both (`b`) or the original (`o`). `n`/`p` move between hunks, `c` jumps to the next conflict, `r` previews the result, `z` takes back a decision, Esc closes without changing anything. **Enter** applies the result to the editor as one undoable edit after re-reading the file (if it changed again, the review starts over); nothing is written to the file until you save, and the save re-checks it as always. A deleted file offers Save anyway only. Very different files (more than 1,500 edits from the version you opened) are one coarse conflict, never a guess.
- **Table editing in `/edit`** ([#463](https://github.com/raiseCatError/notMyShell/issues/463), second slice). In a GFM table in a Markdown file, **Tab / Shift+Tab** move between cells and select the cell's text (the delimiter row is skipped; outside a table Tab is a tab as before), the status line names the row and column, and **F1** adds the table actions: add a row below, delete a row, add a column to the right, delete a column, cycle the column's alignment. Each edits only the table rows it changes, keeps your spacing, outer pipes and alignment colons, and is one undo step; the table is never regenerated or re-padded.
- **Markdown editing helpers in `/edit`** ([#463](https://github.com/raiseCatError/notMyShell/issues/463), first slice; Markdown files only). **Enter** continues a bullet, numbered or task list (same marker and indentation, next number, unchecked box) and splits an item at the caret; on an empty item it ends the list (a nested one steps out a level). **Tab / Shift+Tab** on list items indent or outdent the item with its children (selected items together); elsewhere Tab is a tab as before. **F1** opens the Markdown actions: toggle a task checkbox, renumber the list at the caret, insert a link or image around the selection, wrap the selection in bold, italic, code or strikethrough (again to unwrap). Every action changes only the lines it names and is one undo step; text is never reflowed or regenerated.
diff --git a/docs/design/browser-engine.md b/docs/design/browser-engine.md
index 9720d4ae..d898824a 100644
--- a/docs/design/browser-engine.md
+++ b/docs/design/browser-engine.md
@@ -13,7 +13,8 @@ to install or set and everything else keeps working.
| Process | `engine.ts` | One lazily started process tree, idle shutdown, `shutdown()`, relaunch after a crash. |
| Page | `page.ts` | One DevTools target plus NMSh's policy: URL rules, frames, input, find, zoom, dialogs, popups, downloads. |
| Preview | `preview.ts` | The loopback server behind a document preview. |
-| Markdown | `markdownHtml.ts` | `markdownToHtml` and `htmlDocumentFor`. |
+| Markdown | `markdownHtml.ts` | `markdownToHtml` and `htmlDocumentFor` (Mermaid fences become `data:` PNG images when drawn). |
+| Diagrams | `diagram.ts` | The Mermaid renderer page: isolated world, page scripts off, no network, SVG drawn only as an image, PNG out ([diagrams](diagrams.md)). |
## Process
diff --git a/docs/design/diagrams.md b/docs/design/diagrams.md
new file mode 100644
index 00000000..f9625027
--- /dev/null
+++ b/docs/design/diagrams.md
@@ -0,0 +1,113 @@
+# Mermaid diagrams (#462)
+
+A ```` ```mermaid ```` fence in a Markdown document is drawn as a diagram in NMSh's rendered views when a Mermaid the person installed and
+the real browser are available. The fence's text is the only truth: drawing never changes it, and anything not drawn shows the fence
+exactly as written with one line saying why.
+
+## Acceptance criteria
+
+| # | Criterion | Evidence |
+|---|---|---|
+| 1 | Fences whose first info word is `mermaid` (any case) are diagrams; other fences are untouched. | `diagramsMermaid`, `diagramsDocument` |
+| 2 | Preview (NMSh's renderer and the Chromium preview), Split's preview pane, the read-only viewer and Hybrid's inactive blocks draw them; Source and the editor show text. | `diagramsDocument`, `browserPreviewMarkdown`, `documentWorkspaceApp` |
+| 3 | Hybrid: the block with the caret is its exact, editable source; nothing is drawn while it is open; leaving it draws the edited text. | `diagramsDocument` (Hybrid) |
+| 4 | Source positions: diagram rows carry the fence's source line; copy-code numbering is unchanged; a drawing arriving does not move the hybrid viewport (it is anchored to a line). | `diagramsDocument` |
+| 5 | Edits re-draw without rewriting Markdown; undo and redo go through the text (an undone diagram comes from the cache). | `diagramsDocument` |
+| 6 | Theme: light or dark from the theme's text colour, the theme's text, accent and line colours; grey without colour. | `diagramsMermaid`, `diagramTheme()` |
+| 7 | Any width: the picture is fitted to the columns (aspect kept) and at most 24 rows; `i` opens it in the image viewer (zoom 150–400 %, pan). | `diagramsDocument` (30/50/80/120) |
+| 8 | Not drawn → the fence as written + `(diagram not drawn: reason)`: on request and not asked yet, off, no browser, browser off, images off, Mermaid not installed, syntax error, out of bounds, timeout, crash, unclosed fence. | `diagramsService`, `diagramsDocument`, `documentWorkspaceApp`, `diagramsChrome` |
+| 9 | No document script runs, nothing is fetched, nothing on disk is reachable, the generated SVG reaches the terminal only as pixels. | `diagramsChrome` (hostile set, zero requests at a local server) |
+| 10 | Bounded work: text, lines, line length, nesting, directives, edges, drawing size, pixels, time; a hung or crashed renderer never freezes the editor. | `diagramsMermaid`, `diagramsChrome` |
+| 11 | No browser is started by opening a document; one renderer page at most, closed when no document shows diagrams or after 60 s idle; nothing retained after close. | `diagramsService`, `documentWorkspaceApp` |
+
+## Renderer choice
+
+| Option (October 2026) | Licence | Cost | Verdict |
+|---|---|---|---|
+| **mermaid** 12.2.0, the reference implementation | MIT; its single-file bundle `dist/mermaid.min.js` (5.5 MB) also contains DOMPurify (Apache-2.0/MPL-2.0), chevrotain (Apache-2.0), d3 (ISC), KaTeX, cytoscape, dagre, lodash (MIT) and ELK (**EPL-2.0**) | needs a DOM: a browser | **Chosen**, run in NMSh's existing lazy Chromium. Whatever it draws is Mermaid's own reading of the syntax, so nothing is approximated. |
+| mermaid as an npm dependency of NMSh | as above | 122 MB unpacked plus about 20 dependencies in every install | Rejected: weight for every user, and the bundle's EPL-2.0 part would ship inside a GPL-3.0-only package. |
+| Vendoring `mermaid.min.js` into the repository | as above | 5.5 MB in git and in every package | Rejected for the same licence reason (an aggregate is arguable, but it is the maintainer's call, not this slice's). |
+| @mermaid-js/mermaid-cli 12.0.0 | MIT | Puppeteer and its own Chromium download | Rejected: a second browser manager and implicit downloads. |
+| beautiful-mermaid 1.1.3 (SVG/ASCII without a DOM) | MIT, depends on elkjs (EPL-2.0) | small, in-process | Rejected: a re-implementation of a subset of the syntax; unsupported input could be drawn wrongly instead of refused. Not updated since 2026-02. |
+| A Rust renderer (mermaid-rs and similar) | various | a native binary per platform | Rejected: a second toolchain and binary to sign and ship, again a subset. |
+
+So NMSh **uses a Mermaid the person installed**, exactly as it uses a browser the person installed: `NMSH_MERMAID` (a package folder or
+its `dist/mermaid.min.js`, absolute; when set it is the only candidate), else a global npm install next to the running Node
+(`/lib/node_modules/mermaid`), `/opt/homebrew`, `/usr/local`, `/usr`, `~/.npm-global`, `~/.local`, or the copy inside a global
+`@mermaid-js/mermaid-cli`. Install with `npm install -g mermaid`. The bundle is read once into memory (a regular file opened without
+following a final symlink, at most 16 MB, not world-writable, from a package named `mermaid`, version 10 or newer). Nothing is downloaded.
+Tested with 12.2.0; CI fetches that exact release for its tests and checks the npm integrity hash.
+
+## The security boundary
+
+The page that draws is designed so that **neither the document nor Mermaid itself has to be trusted**: Mermaid is trusted only to
+draw correctly, not to be safe.
+
+```
+document text ──(argument of a DevTools call: data, never HTML or code)──► isolated world: mermaid.render() ──► SVG string
+ │ untrusted
+ inert DOMParser document ◄── parse (no script, no loads) ◄─────────────────────────────────────────────────┘
+ │ sized from its viewBox (bounded), serialised as XML
+ ▼
+ (SVG as an image: no script, no external loads, no interaction) ──► Page.captureScreenshot ──► PNG
+ │
+NMSh: bounded PNG decoder (graphics service) ──► Kitty placeholders │ iTerm2/Sixel (viewer) │ character art ◄────┘
+```
+
+- **Process and context.** The existing engine (`src/browser/engine`), its single lazily started Chromium (sandbox on, private
+ throwaway profile, minimal environment) and a `preview` page: its own throwaway browser context, downloads denied. No new browser, no
+ Puppeteer, no remote-debugging port.
+- **No network.** The page has no origin of its own, so the engine's request interception fails every request (only `data:` images and
+ fonts pass, which cannot leave the machine); the document additionally carries a CSP with `default-src 'none'`, no scripts,
+ connections, frames, objects, `base` or forms. The hostile test points every vector it knows (label images, iframes, links, `click`
+ directives, `themeCSS` `@import` and `url()`, front matter, icon packs) at a local server: it receives **zero** requests.
+- **No page script.** Page scripts are disabled (`Emulation.setScriptExecutionDisabled`): no `"] --> B[""]\n click A href "javascript:document.title=3"\n click B href "${origin}/link"`,
+ `---\nconfig:\n securityLevel: loose\n htmlLabels: true\n themeCSS: "body { background: url('${origin}/front') }"\n---\nflowchart LR\n X[""] --> Y`,
+ `flowchart LR\n A["link"] --> B("")\n style A fill:url(${origin}/style)`,
+ `sequenceDiagram\n Alice->>Bob: \n link Alice: Site @ ${origin}/actorlink`,
+ `classDiagram\n class A{\n +img(src="${origin}/class")\n }\n link A "${origin}/classlink" "tip"\n callback A "alert"`,
+ `architecture-beta\n service s(logos:aws)[S]`,
+ ];
+ for (const source of hostile) {
+ const result = await renderer.render(request(source));
+ // Drawn or refused, it never reached the network and never ran page script.
+ t.diagnostic(`${source.split('\n').find(line => /^(flowchart|sequenceDiagram|classDiagram|architecture)/u.test(line.trim()))?.trim()}: ${result.ok ? `drawn ${result.width}x${result.height}` : `${result.code}: ${result.reason.split('\n')[0]}`}`);
+ }
+ await new Promise(resolve => setTimeout(resolve, 500));
+ assert.deepEqual(hits, [], `requests reached the server: ${hits.join(', ')}`);
+ const title = await page.protocol<{result: {value?: string}}>('Runtime.evaluate', {expression: 'document.title', returnByValue: true});
+ assert.equal(title.result.value, 'diagram');
+ assert.match(page.url, /^about:blank/u);
+ t.diagnostic(`refused in the browser: ${blocked.length} request(s)`);
+ await renderer.close();
+ } finally {
+ await done();
+ server.close();
+ }
+});
+
+test('a library that never finishes is stopped at the timeout; the page is thrown away and a new renderer works', async t => {
+ const reason = await skip();
+ if (reason) { t.skip(reason); return; }
+ const {engine, done} = await lab();
+ try {
+ const hanging = await engine.openDiagramRenderer!({version: 'test', source: 'globalThis.mermaid = {initialize() {}, async parse() { return {diagramType: "loop"}; }, render() { for (;;) {} }};'});
+ const started = Date.now();
+ const result = await hanging.render(request('flowchart LR\n A', {timeoutMs: 1500}));
+ const took = Date.now() - started;
+ assert.equal(!result.ok && result.code, 'timeout');
+ assert.ok(took < 8000, `took ${took} ms`);
+ assert.equal(hanging.closed, true);
+ assert.equal(engine.pages().length, 0);
+ const forever = await engine.openDiagramRenderer!({version: 'test', source: 'globalThis.mermaid = {initialize() {}, async parse() { return {}; }, render() { return new Promise(() => {}); }};'});
+ assert.equal(((await forever.render(request('x', {timeoutMs: 800}))) as {code: string}).code, 'timeout');
+ const fresh = await engine.openDiagramRenderer!(library!);
+ assert.ok((await fresh.render(request(TYPES.pie!))).ok);
+ await fresh.close();
+ await assert.rejects(engine.openDiagramRenderer!({version: 'x', source: 'globalThis.notMermaid = 1;'}), /not a Mermaid library/u);
+ assert.equal(engine.pages().length, 0);
+ } finally { await done(); }
+});
+
+test('a crashed renderer page is reported, never hangs, and the browser stops cleanly afterwards', async t => {
+ const reason = await skip();
+ if (reason) { t.skip(reason); return; }
+ const {engine, done} = await lab();
+ try {
+ const renderer: DiagramRenderer = await engine.openDiagramRenderer!(library!);
+ assert.ok((await renderer.render(request(TYPES.flowchart!))).ok);
+ const page = engine.pages()[0] as ChromePage;
+ const pending = renderer.render(request(TYPES.sequence!, {timeoutMs: 5000}));
+ void page.protocol('Page.crash').catch(() => undefined);
+ const result = await pending;
+ if (!result.ok) assert.ok(['crashed', 'timeout', 'failed'].includes(result.code), result.code);
+ // Whatever the race, a later render on the crashed page does not hang.
+ const after = await renderer.render(request(TYPES.pie!, {timeoutMs: 3000}));
+ if (!after.ok) assert.ok(['crashed', 'timeout', 'failed'].includes(after.code));
+ await renderer.close();
+ assert.equal(engine.pages().length, 0);
+ const pid = engine.stats().pid;
+ await engine.shutdown();
+ if (pid) assert.throws(() => process.kill(pid, 0));
+ } finally { await done(); }
+});
+
+test('repeated open and close leaves no pages behind', async t => {
+ const reason = await skip();
+ if (reason) { t.skip(reason); return; }
+ const {engine, done} = await lab();
+ try {
+ for (let i = 0; i < 5; i++) {
+ const renderer = await engine.openDiagramRenderer!(library!);
+ assert.ok((await renderer.render(request(`flowchart LR\n A${i} --> B${i}`))).ok);
+ await renderer.close();
+ assert.equal(engine.pages().length, 0);
+ }
+ assert.equal(engine.stats().pages, 0);
+ } finally { await done(); }
+});
diff --git a/tests/diagramsDocument.test.ts b/tests/diagramsDocument.test.ts
new file mode 100644
index 00000000..b32982d6
--- /dev/null
+++ b/tests/diagramsDocument.test.ts
@@ -0,0 +1,225 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {mkdtempSync, realpathSync, rmSync, writeFileSync} from 'node:fs';
+import {tmpdir} from 'node:os';
+import {join} from 'node:path';
+import type {DiagramRenderer, DiagramRenderRequest, DiagramRenderResult} from '../src/browser/types.js';
+import type {LoadedImage} from '../src/graphics/service.js';
+import {DiagramService, type DiagramPlacer} from '../src/diagrams/service.js';
+import {DocumentDiagrams, type DiagramMode} from '../src/diagrams/document.js';
+import {codeBlocks, renderMarkdown, type DiagramProvider} from '../src/markdown/render.js';
+import {createDocumentSession, documentSessionKey, renderDocumentSession, setDocumentMode, type DocumentSession} from '../src/viewer/DocumentWorkspace.js';
+import {openForEdit} from '../src/viewer/fileEdit.js';
+import {decodeKeys} from '../src/terminal/keys.js';
+import {displayWidth, stripAnsi} from '../src/util/text.js';
+import type {ViewerLook} from '../src/viewer/ViewerPanel.js';
+
+/**
+ * #462 Mermaid: how a diagram fence reads in every view. The source is the only truth: a drawn diagram replaces the fence's rows,
+ * never its text; anything not drawn shows the fence exactly as written with the reason under it.
+ */
+const FLOW = ['flowchart LR', ' A[Start] --> B{Ok?}', ' B -->|yes| C[Done]'];
+const DOC = ['# Title', '', 'Before the diagram.', '', '```mermaid', ...FLOW, '```', '', 'After the diagram.', '', '```js', 'const x = 1;', '```', '', 'The end.'].join('\n');
+const THEME = {scheme: 'dark' as const, text: '#ffffff', accent: '#c5b9e8', line: '#b0b8c2'};
+
+/** A provider whose every diagram is drawn as `rows` rows of "▒" (what the graphics service would place), or refused. */
+function fixed(rows: number, note?: string): DiagramProvider & {asked: string[]} {
+ const asked: string[] = [];
+ return {asked, slot: source => { asked.push(source); return note ? {index: -1, note, place: () => undefined} : {index: 0, type: 'flowchart-v2', place: (columns, maxRows) => { const height = Math.min(rows, maxRows), width = Math.min(columns, 12); return {lines: Array.from({length: height}, () => '▒'.repeat(width)), columns: width, rows: height}; }}; }};
+}
+
+test('a drawn diagram takes the fence\'s rows: picture rows, a caption, the fence\'s source line, and the code numbering stays aligned', () => {
+ const rows = renderMarkdown(DOC, 60, {diagrams: fixed(4), level: 'truecolor'});
+ const picture = rows.filter(row => row.kind === 'image');
+ assert.equal(picture.length, 4);
+ assert.ok(picture.every(row => row.diagram === 0 && row.line === 4));
+ assert.equal(picture[0]!.plain, '[diagram: flowchart-v2]');
+ const caption = rows.find(row => /mermaid · flowchart/u.test(row.plain));
+ assert.ok(caption);
+ assert.equal(caption!.line, 4);
+ // The JS fence is still code block 1 (copy-code numbering counts the diagram as block 0, as codeBlocks does).
+ assert.ok(rows.filter(row => row.kind === 'code').every(row => row.code === 1));
+ assert.deepEqual(codeBlocks(DOC).map(block => block.language), ['mermaid', 'js']);
+ assert.ok(!rows.some(row => row.plain.includes('flowchart LR')));
+});
+
+test('not drawn: the fence exactly as written, then one dim line with the reason', () => {
+ const rows = renderMarkdown(DOC, 70, {diagrams: fixed(4, 'Mermaid is not installed.'), level: 'none'});
+ const plain = rows.map(row => row.plain);
+ const at = plain.findIndex(line => line.includes('flowchart LR'));
+ assert.ok(at >= 0);
+ assert.deepEqual(plain.slice(at, at + 3).map(line => line.replace(/^[│|] /u, '').trimEnd()), FLOW.map(line => line.trimEnd()));
+ assert.match(plain[at + 3]!, /\(diagram not drawn: Mermaid is not installed\.\)/u);
+ // Without a provider (agent replies, GitHub bodies) a mermaid fence is ordinary code, with no note.
+ assert.ok(!renderMarkdown(DOC, 70, {level: 'none'}).some(row => /diagram not drawn/u.test(row.plain)));
+ // An unclosed fence is never sent to be drawn.
+ const open = fixed(4);
+ const unclosed = renderMarkdown('```mermaid\nflowchart LR\n A --> B', 70, {diagrams: open, level: 'none'});
+ assert.equal(open.asked.length, 0);
+ assert.ok(unclosed.some(row => /fence is not closed/u.test(row.plain)));
+});
+
+test('30/50/80/120 columns: no row is wider than the view, drawn or not; NO_COLOR and Safe Glyphs keep it plain text', () => {
+ for (const columns of [30, 50, 80, 120]) {
+ for (const provider of [fixed(30), fixed(3, 'a long reason that has to wrap across several rows when the terminal is narrow')]) {
+ for (const options of [{level: 'truecolor' as const, glyphs: 'nerd' as const}, {level: 'none' as const, glyphs: 'safe' as const}]) {
+ const rows = renderMarkdown(DOC, columns, {diagrams: provider, maxDiagramRows: 10, ...options});
+ for (const row of rows) assert.ok(displayWidth(row.ansi) <= columns, `${columns}: ${JSON.stringify(row.plain)}`);
+ // No colour without colour (bold and italic are not colour): structure never depends on it.
+ if (options.level === 'none') assert.ok(rows.every(row => !/\u001b\[(?:[0-9;]*;)?(?:3[0-8]|4[0-8]|9[0-7]|10[0-7])(?:;|m)/u.test(row.ansi)), rows.map(row => JSON.stringify(row.ansi)).join('\n'));
+ if (options.glyphs === 'safe') assert.ok(rows.every(row => !/[◇│…]/u.test(row.plain)), rows.map(row => row.plain).join('\n'));
+ }
+ }
+ // The picture never takes more rows than allowed.
+ assert.equal(renderMarkdown(DOC, columns, {diagrams: fixed(30), maxDiagramRows: 10}).filter(row => row.kind === 'image').length, 10);
+ }
+});
+
+test('diagrams nested in quotes, lists and details are drawn too, and keep their index for the enlarged view', () => {
+ const text = ['> ```mermaid', '> flowchart LR', '> A --> B', '> ```', '', '- item', '', ' ```mermaid', ' flowchart LR', ' C --> D', ' ```'].join('\n');
+ const provider = fixed(2);
+ const rows = renderMarkdown(text, 60, {diagrams: provider});
+ assert.deepEqual(provider.asked, ['flowchart LR\n A --> B', 'flowchart LR\n C --> D']);
+ assert.equal(rows.filter(row => row.diagram === 0 && row.kind === 'quote').length, 3);
+});
+
+// ---- The workspace: Source, Preview, Split and Hybrid over one buffer --------------------------------------------------
+
+class InstantRenderer implements DiagramRenderer {
+ closed = false;
+ sources: string[] = [];
+ async render(request: DiagramRenderRequest): Promise { this.sources.push(request.source); return {ok: true, png: new Uint8Array(8), width: 80, height: 40, type: 'flowchart-v2'}; }
+ async close(): Promise { this.closed = true; }
+}
+
+function diagramLab(mode: DiagramMode = 'auto') {
+ const renderer = new InstantRenderer();
+ const tasks: Array<() => void> = [];
+ const service = new DiagramService({
+ backend: {
+ blocked: async () => undefined,
+ library: async () => ({ok: true, path: '/m.js', version: '12.2.0', source: '//'}),
+ open: async () => renderer,
+ decode: async (_png, name, key) => ({key, name, path: name, info: {format: 'png', width: 80, height: 40}, raster: {width: 80, height: 40, data: new Uint8Array(80 * 40 * 4)}, fileBytes: 8}) as unknown as LoadedImage,
+ },
+ settleMs: 0,
+ schedule: fn => { tasks.push(fn); return {cancel: () => { const at = tasks.indexOf(fn); if (at >= 0) tasks.splice(at, 1); }}; },
+ });
+ const placer: DiagramPlacer = {place: (_image, request) => ({kind: 'kitty-placeholder', columns: Math.min(request.columns, 10), rows: 3, lines: Array.from({length: 3}, () => '▒'.repeat(Math.min(request.columns, 10))), owner: request.owner}), releaseOwner: () => {}};
+ const diagrams = new DocumentDiagrams(() => service, {mode: () => mode, theme: () => THEME, placer, changed: () => {}});
+ const settle = async () => { for (let round = 0; round < 5; round++) { while (tasks.length) tasks.shift()!(); for (let i = 0; i < 20; i++) await new Promise(resolve => setImmediate(resolve)); } };
+ return {service, renderer, diagrams, settle, look: {diagrams} as ViewerLook};
+}
+
+async function openDoc(content = `${DOC}\n`, mode: 'edit' | 'source' = 'source') {
+ const root = realpathSync(mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), 'nmsh-diagram-doc-')));
+ const path = join(root, 'doc.md');
+ writeFileSync(path, content);
+ const opened = await openForEdit(path, '/');
+ if (!opened.ok) throw new Error(opened.reason);
+ return {root, state: createDocumentSession(opened.file, mode)};
+}
+const press = (state: DocumentSession, bytes: string, look: ViewerLook, columns = 100, height = 30) => { const actions = []; for (const key of decodeKeys(bytes)) actions.push(documentSessionKey(state, key, columns, height, look)); return actions; };
+const draw = (state: DocumentSession, look: ViewerLook, columns = 100, height = 40) => renderDocumentSession(state, columns, height, look).map(row => stripAnsi(row)).join('\n');
+
+test('Source shows the fence as text; Preview draws it once asked (D); the buffer is never touched', async () => {
+ const {root, state} = await openDoc();
+ const {diagrams, settle, renderer, look} = diagramLab('manual');
+ try {
+ const before = state.editor.buffer.lines.join('\n');
+ assert.match(draw(state, look), /flowchart LR/u);
+ press(state, 'h', look);
+ assert.equal(state.mode, 'preview');
+ assert.match(draw(state, look), /diagram not drawn: not drawn yet: press D/u);
+ assert.match(draw(state, look), /D draw diagrams/u);
+ const actions = press(state, 'D', look);
+ assert.deepEqual(actions, [{kind: 'diagrams'}]);
+ diagrams.request();
+ draw(state, look);
+ await settle();
+ const shown = draw(state, look);
+ assert.match(shown, /▒▒▒/u);
+ assert.match(shown, /mermaid · flowchart/u);
+ assert.doesNotMatch(shown, /flowchart LR/u);
+ assert.deepEqual(renderer.sources, [FLOW.join('\n')]);
+ assert.equal(state.editor.buffer.lines.join('\n'), before);
+ assert.equal(state.editor.buffer.dirty, false);
+ } finally { diagrams.close(); rmSync(root, {recursive: true, force: true}); }
+});
+
+test('Hybrid: the active diagram block is its exact source and stays editable; leaving it draws the edited diagram; undo and redo restore the text', async () => {
+ const {root, state} = await openDoc(`${DOC}\n`, 'edit');
+ const {diagrams, settle, renderer, look} = diagramLab('auto');
+ try {
+ press(state, '\u001bOR', look); // F3: hybrid
+ assert.equal(state.mode, 'hybrid');
+ state.editor.buffer.caret = {line: 0, column: 0};
+ draw(state, look);
+ await settle();
+ assert.match(draw(state, look), /▒▒▒/u);
+ // Into the fence: its exact source, caret and all.
+ state.editor.buffer.caret = {line: 6, column: 0};
+ const editing = draw(state, look);
+ for (const line of FLOW) assert.ok(editing.includes(line.trim()), line);
+ assert.doesNotMatch(editing, /▒▒▒/u);
+ const drawnBefore = renderer.sources.length;
+ // Typing inside the fence draws nothing while the block is open.
+ state.editor.buffer.caret = {line: 6, column: FLOW[1]!.length};
+ press(state, ' --> E', look);
+ draw(state, look);
+ await settle();
+ assert.equal(renderer.sources.length, drawnBefore);
+ assert.equal(state.editor.buffer.lines[6], `${FLOW[1]} --> E`);
+ // Leaving the block draws the edited text.
+ state.editor.buffer.caret = {line: 0, column: 0};
+ draw(state, look);
+ await settle();
+ assert.equal(renderer.sources.at(-1), [FLOW[0], `${FLOW[1]} --> E`, FLOW[2]].join('\n'));
+ // Undo and redo go through the text, never through the picture.
+ for (let i = 0; i < 6; i++) state.editor.buffer.undo();
+ assert.equal(state.editor.buffer.lines.join('\n'), DOC);
+ draw(state, look);
+ await settle();
+ assert.equal(renderer.sources.filter(source => source === FLOW.join('\n')).length, 1, 'the original drawing came from the cache');
+ for (let i = 0; i < 6; i++) state.editor.buffer.redo();
+ assert.equal(state.editor.buffer.lines[6], `${FLOW[1]} --> E`);
+ } finally { diagrams.close(); rmSync(root, {recursive: true, force: true}); }
+});
+
+test('Hybrid: a diagram arriving below the top does not move the view; F1 › Diagrams asks for drawing from the editor', async () => {
+ const filler = Array.from({length: 40}, (_, i) => `Line ${i}.`).join('\n\n');
+ const {root, state} = await openDoc(`${filler}\n\n${DOC}\n`, 'edit');
+ const {diagrams, settle, look} = diagramLab('manual');
+ try {
+ press(state, '\u001bOR', look);
+ state.editor.buffer.caret = {line: 10, column: 0};
+ draw(state, look, 100, 20);
+ const top = {...state.hybrid!.top};
+ const actions = press(state, '\u001bOP', look); // F1: the Markdown menu
+ assert.match(draw(state, look, 160, 20), /g diagrams/u);
+ actions.push(...press(state, 'g', look));
+ assert.deepEqual(actions.filter(Boolean), [{kind: 'diagrams'}]);
+ diagrams.request();
+ draw(state, look, 100, 20);
+ await settle();
+ draw(state, look, 100, 20);
+ assert.deepEqual(state.hybrid!.top, top);
+ } finally { diagrams.close(); rmSync(root, {recursive: true, force: true}); }
+});
+
+test('Split: typing in the text pane draws only the text that is on screen once typing pauses', async () => {
+ const {root, state} = await openDoc(`${DOC}\n`, 'edit');
+ const {diagrams, settle, renderer, service, look} = diagramLab('auto');
+ try {
+ setDocumentMode(state, 'split');
+ state.editor.buffer.caret = {line: 6, column: FLOW[1]!.length};
+ for (const char of ' --> XYZ') { press(state, char, look, 140); draw(state, look, 140); }
+ await settle();
+ assert.equal(renderer.sources.length, 1);
+ assert.equal(renderer.sources[0], [FLOW[0], `${FLOW[1]} --> XYZ`, FLOW[2]].join('\n'));
+ assert.equal(service.stats.dropped, 7);
+ // D in the preview pane asks for drawing too.
+ state.split!.focus = 'preview';
+ assert.deepEqual(press(state, 'D', look, 140), [{kind: 'diagrams'}]);
+ } finally { diagrams.close(); rmSync(root, {recursive: true, force: true}); }
+});
diff --git a/tests/diagramsLibrary.test.ts b/tests/diagramsLibrary.test.ts
new file mode 100644
index 00000000..f7daf437
--- /dev/null
+++ b/tests/diagramsLibrary.test.ts
@@ -0,0 +1,74 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import {libraryCandidates, locateMermaid, MAX_LIBRARY_BYTES} from '../src/diagrams/library.js';
+
+function scratch(): string {
+ return fs.realpathSync(fs.mkdtempSync(path.join(process.env.TMPDIR ?? os.tmpdir(), 'nmsh-mermaid-lib-')));
+}
+
+/** A package laid out the way npm installs it: /package.json and /dist/mermaid.min.js. */
+function fakePackage(root: string, options: {name?: string; version?: string; body?: string} = {}): string {
+ const dir = path.join(root, 'mermaid');
+ fs.mkdirSync(path.join(dir, 'dist'), {recursive: true});
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({name: options.name ?? 'mermaid', version: options.version ?? '12.2.0'}));
+ fs.writeFileSync(path.join(dir, 'dist', 'mermaid.min.js'), options.body ?? 'globalThis.mermaid = {};');
+ return dir;
+}
+
+test('the person\'s NMSH_MERMAID comes first: a package folder or the bundle itself', async () => {
+ const root = scratch();
+ try {
+ const dir = fakePackage(root);
+ const fromDir = await locateMermaid({env: {NMSH_MERMAID: dir}, home: root, execPath: '/nonexistent/bin/node'});
+ assert.equal(fromDir.ok, true);
+ assert.equal(fromDir.ok && fromDir.version, '12.2.0');
+ assert.equal(fromDir.ok && fromDir.source, 'globalThis.mermaid = {};');
+ const fromFile = await locateMermaid({env: {NMSH_MERMAID: path.join(dir, 'dist', 'mermaid.min.js')}, home: root, execPath: '/nonexistent/bin/node'});
+ assert.equal(fromFile.ok && fromFile.path, path.join(dir, 'dist', 'mermaid.min.js'));
+ // A relative or missing NMSH_MERMAID is an error that names the variable; it is never silently replaced by another install.
+ const relative = await locateMermaid({env: {NMSH_MERMAID: 'mermaid.min.js'}, home: root, execPath: '/nonexistent/bin/node'});
+ assert.equal(relative.ok, false);
+ assert.match(!relative.ok ? relative.reason : '', /NMSH_MERMAID/u);
+ } finally { fs.rmSync(root, {recursive: true, force: true}); }
+});
+
+test('global npm installs are found next to the running Node and in the usual prefixes; nothing is downloaded', async () => {
+ const root = scratch();
+ try {
+ const prefix = path.join(root, 'prefix');
+ fs.mkdirSync(path.join(prefix, 'bin'), {recursive: true});
+ fakePackage(path.join(prefix, 'lib', 'node_modules'), {version: '11.4.0'});
+ const found = await locateMermaid({env: {}, home: root, execPath: path.join(prefix, 'bin', 'node')});
+ assert.equal(found.ok && found.version, '11.4.0');
+ assert.ok(libraryCandidates({env: {}, home: '/h', execPath: '/p/bin/node'}).some(candidate => candidate === '/opt/homebrew/lib/node_modules/mermaid'));
+ const none = await locateMermaid({env: {}, home: root, execPath: path.join(root, 'nowhere', 'bin', 'node'), candidates: [path.join(root, 'missing')]});
+ assert.equal(none.ok, false);
+ assert.match(!none.ok ? none.reason : '', /npm install -g mermaid/u);
+ assert.match(!none.ok ? none.reason : '', /does not download/u);
+ } finally { fs.rmSync(root, {recursive: true, force: true}); }
+});
+
+test('a package that is not Mermaid, too old, too large, world-writable or not a regular file is refused with a reason', async () => {
+ const root = scratch();
+ const at = (dir: string) => locateMermaid({env: {NMSH_MERMAID: dir}, home: root, execPath: '/nonexistent/bin/node'});
+ try {
+ const other = fakePackage(path.join(root, 'a'), {name: 'not-mermaid'});
+ assert.match(((await at(other)) as {reason: string}).reason, /not the mermaid package/u);
+ const old = fakePackage(path.join(root, 'b'), {version: '9.4.3'});
+ assert.match(((await at(old)) as {reason: string}).reason, /10 or newer/u);
+ const big = fakePackage(path.join(root, 'c'));
+ fs.truncateSync(path.join(big, 'dist', 'mermaid.min.js'), MAX_LIBRARY_BYTES + 1);
+ assert.match(((await at(big)) as {reason: string}).reason, /too large/u);
+ const open = fakePackage(path.join(root, 'd'));
+ fs.chmodSync(path.join(open, 'dist', 'mermaid.min.js'), 0o666);
+ assert.match(((await at(open)) as {reason: string}).reason, /changed by anyone/u);
+ const fifo = path.join(root, 'e', 'mermaid');
+ fs.mkdirSync(path.join(fifo, 'dist'), {recursive: true});
+ fs.writeFileSync(path.join(fifo, 'package.json'), JSON.stringify({name: 'mermaid', version: '12.0.0'}));
+ fs.mkdirSync(path.join(fifo, 'dist', 'mermaid.min.js'));
+ assert.match(((await at(fifo)) as {reason: string}).reason, /not a regular file/u);
+ } finally { fs.rmSync(root, {recursive: true, force: true}); }
+});
diff --git a/tests/diagramsMermaid.test.ts b/tests/diagramsMermaid.test.ts
new file mode 100644
index 00000000..ee8605d0
--- /dev/null
+++ b/tests/diagramsMermaid.test.ts
@@ -0,0 +1,77 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {checkMermaidSource, diagramKey, isMermaidLanguage, MERMAID_LIMITS, mermaidConfig, mermaidSources, LOCKED_KEYS} from '../src/diagrams/mermaid.js';
+import {parseBlocks} from '../src/markdown/blocks.js';
+
+const theme = {scheme: 'dark' as const, text: '#f2f0ec', accent: '#c5b9e8', line: '#b0b8c2'};
+
+test('a fence is a Mermaid diagram by its first info word, case-insensitively', () => {
+ assert.equal(isMermaidLanguage('mermaid'), true);
+ assert.equal(isMermaidLanguage('Mermaid'), true);
+ assert.equal(isMermaidLanguage('mmd'), false);
+ assert.equal(isMermaidLanguage(undefined), false);
+ assert.equal(isMermaidLanguage('mermaidjs'), false);
+});
+
+test('ordinary diagrams pass the bounds; the reason names the limit otherwise', () => {
+ assert.deepEqual(checkMermaidSource('flowchart LR\n A --> B'), {ok: true});
+ assert.match((checkMermaidSource(' \n ') as {reason: string}).reason, /empty/u);
+ const long = `flowchart LR\n${' A --> B\n'.repeat(MERMAID_LIMITS.lines)}`;
+ assert.match((checkMermaidSource(long) as {reason: string}).reason, /lines/u);
+ const wide = `flowchart LR\n A["${'x'.repeat(MERMAID_LIMITS.chars)}"]`;
+ assert.match((checkMermaidSource(wide) as {reason: string}).reason, /characters/u);
+ const label = `flowchart LR\n A["${'y'.repeat(MERMAID_LIMITS.lineChars + 1)}"]`;
+ assert.match((checkMermaidSource(label) as {reason: string}).reason, /line/u);
+});
+
+test('nesting is bounded before anything is drawn: subgraphs, blocks and braces', () => {
+ const depth = MERMAID_LIMITS.nesting + 1;
+ const subgraphs = `flowchart TB\n${Array.from({length: depth}, (_, i) => `subgraph s${i}`).join('\n')}\nA\n${'end\n'.repeat(depth)}`;
+ assert.match((checkMermaidSource(subgraphs) as {reason: string}).reason, /nested/u);
+ const loops = `sequenceDiagram\n${'loop x\n'.repeat(depth)}A->>B: hi\n${'end\n'.repeat(depth)}`;
+ assert.match((checkMermaidSource(loops) as {reason: string}).reason, /nested/u);
+ const braces = `stateDiagram-v2\n${'state a {\n'.repeat(depth)}x --> y\n${'}\n'.repeat(depth)}`;
+ assert.match((checkMermaidSource(braces) as {reason: string}).reason, /nested/u);
+ // Just inside the limit is fine; braces inside quoted labels do not count.
+ const fine = `flowchart TB\n${Array.from({length: MERMAID_LIMITS.nesting}, (_, i) => `subgraph s${i}`).join('\n')}\nA["{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{"]\n${'end\n'.repeat(MERMAID_LIMITS.nesting)}`;
+ assert.deepEqual(checkMermaidSource(fine), {ok: true});
+});
+
+test('directives are limited in number but allowed (the security keys are locked in the configuration)', () => {
+ assert.deepEqual(checkMermaidSource('%%{init: {"theme": "forest"}}%%\nflowchart LR\n A --> B'), {ok: true});
+ const many = `${'%%{init: {"theme": "forest"}}%%\n'.repeat(MERMAID_LIMITS.directives + 1)}flowchart LR\n A --> B`;
+ assert.match((checkMermaidSource(many) as {reason: string}).reason, /directives/u);
+});
+
+test('the configuration is NMSh\'s: strict security, no HTML labels, bounded text and edges, security keys locked', () => {
+ const config = mermaidConfig(theme);
+ assert.equal(config.securityLevel, 'strict');
+ assert.equal(config.startOnLoad, false);
+ assert.equal(config.htmlLabels, false);
+ assert.equal((config.flowchart as {htmlLabels: boolean}).htmlLabels, false);
+ assert.equal(config.maxTextSize, MERMAID_LIMITS.chars);
+ assert.equal(config.maxEdges, MERMAID_LIMITS.edges);
+ assert.equal(config.deterministicIds, true);
+ for (const key of ['secure', 'securityLevel', 'startOnLoad', 'maxTextSize', 'maxEdges', 'htmlLabels', 'dompurifyConfig', 'themeCSS', 'suppressErrorRendering']) assert.ok(LOCKED_KEYS.includes(key), key);
+ assert.deepEqual(config.secure, [...LOCKED_KEYS]);
+ // Only JSON values: it travels as data.
+ assert.deepEqual(JSON.parse(JSON.stringify(config)), config);
+ assert.equal(mermaidConfig({...theme, scheme: 'light'}).theme, 'default');
+ assert.equal(config.theme, 'dark');
+ assert.equal(mermaidConfig({...theme, scheme: 'mono'}).theme, 'neutral');
+ assert.equal((config.themeVariables as Record).primaryBorderColor, theme.accent);
+});
+
+test('the cache key changes with the text and the theme, not with anything else', () => {
+ const a = diagramKey('flowchart LR\n A-->B', theme);
+ assert.equal(a, diagramKey('flowchart LR\n A-->B', {...theme}));
+ assert.notEqual(a, diagramKey('flowchart LR\n A-->C', theme));
+ assert.notEqual(a, diagramKey('flowchart LR\n A-->B', {...theme, accent: '#000000'}));
+ assert.notEqual(a, diagramKey('flowchart LR\n A-->B ', theme));
+ assert.match(a, /^[0-9a-f]{64}$/u);
+});
+
+test('the diagrams of a document are found wherever a fence may be: quotes, lists and details included', () => {
+ const text = ['# Doc', '```mermaid', 'flowchart LR', ' A --> B', '```', '> ```mermaid', '> sequenceDiagram', '> A->>B: x', '> ```', '- item', '', ' ```Mermaid', ' pie', ' "a": 1', ' ```', '```js', 'x', '```'].join('\n');
+ assert.deepEqual(mermaidSources(parseBlocks(text, {html: true, math: true})), ['flowchart LR\n A --> B', 'sequenceDiagram\nA->>B: x', 'pie\n"a": 1']);
+});
diff --git a/tests/diagramsService.test.ts b/tests/diagramsService.test.ts
new file mode 100644
index 00000000..25dc62c2
--- /dev/null
+++ b/tests/diagramsService.test.ts
@@ -0,0 +1,228 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import type {DiagramLibrary, DiagramRenderer, DiagramRenderRequest, DiagramRenderResult} from '../src/browser/types.js';
+import type {LoadedImage} from '../src/graphics/service.js';
+import {DiagramService, type DiagramBackend, type DiagramPlacer} from '../src/diagrams/service.js';
+import {DocumentDiagrams} from '../src/diagrams/document.js';
+import {MERMAID_LIMITS} from '../src/diagrams/mermaid.js';
+
+const THEME = {scheme: 'dark' as const, text: '#ffffff', accent: '#c5b9e8', line: '#b0b8c2'};
+
+/** A manual scheduler: nothing happens until the test advances it, so counts are exact. */
+class Clock {
+ private tasks: Array<{at: number; fn: () => void; live: boolean}> = [];
+ now = 0;
+ schedule = (fn: () => void, ms: number) => { const task = {at: this.now + ms, fn, live: true}; this.tasks.push(task); return {cancel: () => { task.live = false; }}; };
+ async advance(ms: number): Promise {
+ this.now += ms;
+ for (;;) {
+ const due = this.tasks.filter(task => task.live && task.at <= this.now);
+ if (!due.length) break;
+ for (const task of due) { task.live = false; task.fn(); }
+ this.tasks = this.tasks.filter(task => task.live);
+ await flush();
+ }
+ await flush();
+ }
+}
+
+async function flush(): Promise { for (let i = 0; i < 20; i++) await new Promise(resolve => setImmediate(resolve)); }
+
+type Behaviour = (request: DiagramRenderRequest) => DiagramRenderResult | Promise;
+
+class FakeRenderer implements DiagramRenderer {
+ closed = false;
+ readonly requests: string[] = [];
+ constructor(private readonly behaviour: Behaviour) {}
+ async render(request: DiagramRenderRequest): Promise {
+ this.requests.push(request.source);
+ const result = await this.behaviour(request);
+ if (!result.ok && (result.code === 'timeout' || result.code === 'crashed')) this.closed = true;
+ return result;
+ }
+ async close(): Promise { this.closed = true; }
+}
+
+const PNG = new Uint8Array([137, 80, 78, 71]);
+const ok: Behaviour = () => ({ok: true, png: PNG, width: 40, height: 20, type: 'flowchart-v2'});
+
+function lab(behaviour: Behaviour = ok, options: {blocked?: string; library?: boolean} = {}) {
+ const clock = new Clock();
+ const renderers: FakeRenderer[] = [];
+ const decoded: string[] = [];
+ let libraryCalls = 0;
+ const backend: DiagramBackend = {
+ blocked: async () => options.blocked,
+ library: async () => { libraryCalls += 1; return options.library === false ? {ok: false, reason: 'Mermaid is not installed.'} : {ok: true, path: '/x/mermaid.min.js', version: '12.2.0', source: '//'}; },
+ open: async (_library: DiagramLibrary) => { const renderer = new FakeRenderer(behaviour); renderers.push(renderer); return renderer; },
+ decode: async (_png, name, key) => { decoded.push(key); return {key, name, path: name, info: {format: 'png', width: 40, height: 20}, raster: {width: 40, height: 20, data: new Uint8Array(40 * 20 * 4)}, fileBytes: 4} as unknown as LoadedImage; },
+ };
+ const service = new DiagramService({backend, schedule: clock.schedule, settleMs: 100, idleMs: 60_000, timeoutMs: 5000});
+ const placed: unknown[] = [];
+ const released: unknown[] = [];
+ const placer: DiagramPlacer = {
+ place: (_image, request) => { placed.push(request.owner); return {kind: 'kitty-placeholder', columns: Math.min(request.columns, 10), rows: 3, lines: ['a', 'b', 'c'], owner: request.owner}; },
+ releaseOwner: owner => { released.push(owner); },
+ };
+ const document = (mode: 'auto' | 'manual' | 'off' = 'auto') => {
+ let changes = 0;
+ const doc = new DocumentDiagrams(() => service, {mode: () => mode, theme: () => THEME, placer, changed: () => { changes += 1; }});
+ return {doc, changes: () => changes};
+ };
+ return {clock, service, renderers, decoded, placed, released, document, libraryCalls: () => libraryCalls};
+}
+
+test('nothing starts for a document without diagrams, or with diagrams on request until the person asks', async () => {
+ const {clock, service, renderers, document, libraryCalls} = lab();
+ const {doc} = document('manual');
+ doc.pass(() => doc.slot('flowchart LR\n A --> B'));
+ await clock.advance(1000);
+ assert.equal(renderers.length, 0);
+ assert.equal(libraryCalls(), 0);
+ assert.match(doc.slot('flowchart LR\n A --> B').note ?? '', /press D/u);
+ assert.equal(service.stats.renders, 0);
+ doc.request();
+ doc.pass(() => doc.slot('flowchart LR\n A --> B'));
+ await clock.advance(200);
+ assert.equal(renderers.length, 1);
+ assert.equal(service.stats.renders, 1);
+ assert.ok(doc.slot('flowchart LR\n A --> B').place(80, 20));
+ const off = document('off').doc;
+ assert.match(off.pass(() => off.slot('flowchart LR\n A --> C')).note ?? '', /off/u);
+});
+
+test('a drawn diagram is cached by its text and theme: drawing it again or in another document costs nothing', async () => {
+ const {clock, service, renderers, document} = lab();
+ const a = document().doc, b = document().doc;
+ a.pass(() => a.slot('flowchart LR\n A --> B'));
+ await clock.advance(200);
+ b.pass(() => b.slot('flowchart LR\n A --> B'));
+ await clock.advance(200);
+ assert.equal(service.stats.renders, 1);
+ assert.equal(renderers[0]!.requests.length, 1);
+ assert.ok(b.slot('flowchart LR\n A --> B').place(80, 20));
+});
+
+test('rapid edits: work waits for a pause, and text nobody shows any more is never drawn', async () => {
+ const {clock, service, renderers, document} = lab();
+ const {doc} = document();
+ // Fifty keystrokes, 30 ms apart: each one is a new version of the diagram on screen.
+ let text = 'flowchart LR\n A --> B';
+ for (let i = 0; i < 50; i++) {
+ text += String(i % 10);
+ doc.pass(() => doc.slot(text));
+ await clock.advance(30);
+ }
+ await clock.advance(500);
+ assert.equal(service.stats.renders, 1, 'only the text on screen after the pause is drawn');
+ assert.equal(renderers[0]!.requests[0], text);
+ assert.equal(service.stats.dropped, 49);
+});
+
+test('an edit while a diagram is drawing: the drawing finishes (bounded), then only the latest text is drawn', async () => {
+ let release: (() => void) | undefined;
+ const {clock, service, document} = lab(request => request.source.endsWith('slow') ? new Promise(resolve => { release = () => resolve(ok(request) as DiagramRenderResult); }) : ok(request));
+ const {doc} = document();
+ doc.pass(() => doc.slot('flowchart LR\n A --> slow'));
+ await clock.advance(200);
+ assert.equal(service.stats.renders, 1);
+ for (const suffix of ['1', '12', '123']) { doc.pass(() => doc.slot(`flowchart LR\n A --> B${suffix}`)); await clock.advance(20); }
+ release!();
+ await clock.advance(500);
+ assert.equal(service.stats.renders, 2);
+ assert.ok(doc.slot('flowchart LR\n A --> B123').place(80, 20));
+});
+
+test('syntax errors and refused text show the reason; the source is never sent when it is out of bounds', async () => {
+ const {clock, service, renderers, document} = lab(request => request.source.includes('-->') && !request.source.includes('B') ? {ok: false, code: 'syntax', reason: 'Parse error on line 2:\n...A -->\n------^\nExpecting \'NODE_STRING\', got \'EOF\''} : ok(request));
+ const {doc} = document();
+ doc.pass(() => doc.slot('flowchart LR\n A -->'));
+ await clock.advance(200);
+ const slot = doc.slot('flowchart LR\n A -->');
+ assert.equal(slot.place(80, 20), undefined);
+ assert.match(slot.note ?? '', /Parse error on line 2/u);
+ assert.doesNotMatch(slot.note ?? '', /\n/u);
+ const huge = `flowchart LR\n${' A --> B\n'.repeat(MERMAID_LIMITS.lines + 5)}`;
+ doc.pass(() => doc.slot(huge));
+ await clock.advance(200);
+ assert.match(doc.slot(huge).note ?? '', /at most/u);
+ assert.ok(!renderers.some(renderer => renderer.requests.includes(huge)));
+ assert.equal(service.stats.renders, 1);
+});
+
+test('a timeout or crash closes the renderer; the next diagram opens a fresh one, and the failure is not retried on its own', async () => {
+ const {clock, service, renderers, document} = lab(request => request.source.includes('hang') ? {ok: false, code: 'timeout', reason: 'Drawing took longer than 5 s, so it was stopped.'} : request.source.includes('crash') ? {ok: false, code: 'crashed', reason: 'The browser page drawing diagrams stopped.'} : ok(request));
+ const {doc} = document();
+ doc.pass(() => doc.slot('flowchart LR\n hang'));
+ await clock.advance(200);
+ assert.match(doc.slot('flowchart LR\n hang').note ?? '', /longer than/u);
+ doc.pass(() => { doc.slot('flowchart LR\n hang'); doc.slot('flowchart LR\n crash'); });
+ await clock.advance(200);
+ doc.pass(() => doc.slot('flowchart LR\n fine'));
+ await clock.advance(200);
+ assert.equal(renderers.length, 3);
+ assert.ok(doc.slot('flowchart LR\n fine').place(80, 20));
+ assert.equal(service.stats.timeouts, 1);
+ assert.equal(service.stats.crashes, 1);
+ // Asking again (D) retries what failed for a transient reason.
+ doc.request();
+ doc.pass(() => doc.slot('flowchart LR\n hang'));
+ await clock.advance(200);
+ assert.equal(service.stats.timeouts, 2);
+});
+
+test('no browser, the browser off, or no Mermaid: the reason is shown and nothing is opened', async () => {
+ for (const [options, pattern] of [[{blocked: 'The real browser is off.'}, /browser is off/u], [{library: false}, /not installed/u]] as const) {
+ const {clock, renderers, document} = lab(ok, options);
+ const {doc} = document();
+ doc.pass(() => doc.slot('flowchart LR\n A --> B'));
+ await clock.advance(300);
+ assert.equal(renderers.length, 0);
+ assert.match(doc.slot('flowchart LR\n A --> B').note ?? '', pattern);
+ }
+});
+
+test('the cache is bounded in images and bytes; old drawings are forgotten first', async () => {
+ const {clock, service, document} = lab();
+ const {doc} = document();
+ for (let i = 0; i < 40; i++) { doc.pass(() => doc.slot(`flowchart LR\n A --> N${i}`)); await clock.advance(200); }
+ assert.ok(service.stats.cachedImages <= 24, `${service.stats.cachedImages}`);
+ assert.ok(service.stats.cacheBytes <= 48 * 1024 * 1024);
+ assert.ok(doc.slot('flowchart LR\n A --> N39').place(80, 20));
+});
+
+test('closing every document releases its pictures and closes the renderer page now; reopening works', async () => {
+ const {clock, service, renderers, released, document} = lab();
+ const first = document();
+ first.doc.pass(() => first.doc.slot('flowchart LR\n A --> B'));
+ await clock.advance(200);
+ first.doc.slot('flowchart LR\n A --> B').place(80, 20);
+ first.doc.close();
+ assert.equal(renderers[0]!.closed, true);
+ assert.ok(released.includes(first.doc));
+ assert.equal(service.stats.documents, 0);
+ // Work queued for a closed document is dropped; its change callback is never called again.
+ const second = document();
+ second.doc.pass(() => second.doc.slot('flowchart LR\n X --> Y'));
+ second.doc.close();
+ const before = second.changes();
+ await clock.advance(500);
+ assert.equal(second.changes(), before);
+ assert.equal(service.stats.renders, 1);
+ for (let i = 0; i < 20; i++) { const again = document(); again.doc.pass(() => again.doc.slot('flowchart LR\n A --> B')); await clock.advance(200); again.doc.close(); }
+ assert.ok(renderers.every(renderer => renderer.closed));
+ assert.equal(service.stats.documents, 0);
+});
+
+test('an idle renderer page closes by itself; the browser engine then stops on its own idle timer', async () => {
+ const {clock, renderers, document} = lab();
+ const {doc} = document();
+ doc.pass(() => doc.slot('flowchart LR\n A --> B'));
+ await clock.advance(200);
+ assert.equal(renderers[0]!.closed, false);
+ await clock.advance(60_000);
+ assert.equal(renderers[0]!.closed, true);
+ doc.pass(() => doc.slot('flowchart LR\n A --> C'));
+ await clock.advance(200);
+ assert.equal(renderers.length, 2);
+});
diff --git a/tests/documentWorkspaceApp.test.ts b/tests/documentWorkspaceApp.test.ts
index 85f59cfa..34134123 100644
--- a/tests/documentWorkspaceApp.test.ts
+++ b/tests/documentWorkspaceApp.test.ts
@@ -139,3 +139,62 @@ test('the native preview draws local images of the current buffer, and closing t
assert.equal(f.app['viewerImages'], undefined, 'closing the document closes its images');
} finally { f.done(); }
});
+
+// ---- #462 Mermaid diagrams in the app -----------------------------------------------------------------------------------
+
+const MERMAID_DOC = '# Plan\n\n```mermaid\nflowchart LR\n A --> B\n```\n\nText after.\n';
+
+test('Mermaid on request (default): opening and previewing a document starts nothing; D asks, the reason shows, closing lets go of everything', async () => {
+ const f = fixture();
+ try {
+ f.app['promptConfiguration'] = {...f.app['promptConfiguration'], browser: 'off'};
+ writeFileSync(join(f.dir, 'plan.md'), MERMAID_DOC);
+ await f.view('plan.md');
+ f.key({kind: 'text', value: 'h'});
+ assert.match(f.screen(), /diagram not drawn: not drawn yet: press D/u);
+ assert.match(f.screen(), /flowchart LR/u, 'the fence shows exactly as written');
+ assert.equal(f.app['diagramService'], undefined, 'no diagram service exists until the person asks');
+ assert.equal(f.app['browserHost'], undefined, 'nothing browser-related was created');
+ f.key({kind: 'text', value: 'D'});
+ assert.ok(f.app['diagramService']);
+ await settle(() => /real browser, which is off/u.test(f.screen()));
+ assert.match(f.screen(), /diagram not drawn: Diagrams are drawn by the real browser, which is off/u);
+ assert.equal(f.app['browserHost'], undefined, 'the browser setting is off: no host either');
+ assert.equal(readFileSync(join(f.dir, 'plan.md'), 'utf8'), MERMAID_DOC, 'the document is never rewritten');
+ f.key({kind: 'escape'});
+ f.key({kind: 'escape'});
+ assert.equal(f.app['docSession'], undefined);
+ f.frame.capture();
+ assert.equal(f.app['diagrams'], undefined, 'the closed document\'s diagrams are released');
+ assert.equal(f.app['diagramService'].stats.documents, 0);
+ assert.equal(f.app['diagramService'].stats.rendererOpen, false);
+ f.type('echo ok');
+ assert.equal(f.app['editor'].text, 'echo ok', 'typing goes back to the composer');
+ } finally { f.done(); }
+});
+
+test('Mermaid automatic: the preview draws the diagram from the drawing service; Off keeps the fence as code with the reason', async () => {
+ const f = fixture();
+ try {
+ f.app['promptConfiguration'] = {...f.app['promptConfiguration'], browser: 'off', diagrams: 'auto'};
+ const {DiagramService} = await import('../src/diagrams/service.js');
+ const drawn: string[] = [];
+ f.app['diagramService'] = new DiagramService({settleMs: 0, backend: {
+ blocked: async () => undefined,
+ library: async () => ({ok: true, path: '/m.js', version: '12.2.0', source: '//'}),
+ open: async () => ({closed: false, render: async (request: {source: string}) => { drawn.push(request.source); return {ok: true, png: encodePng({width: 40, height: 20, data: new Uint8Array(40 * 20 * 4).fill(200)}), width: 40, height: 20, type: 'flowchart-v2'}; }, close: async () => {}}),
+ decode: (png: Uint8Array, name: string, key: string) => f.app['graphicsService']().fromPng(png, name, key),
+ }});
+ writeFileSync(join(f.dir, 'plan.md'), MERMAID_DOC);
+ await f.view('plan.md');
+ f.key({kind: 'text', value: 'h'});
+ f.screen();
+ await settle(() => /mermaid · flowchart/u.test(f.screen()));
+ assert.match(f.screen(), /mermaid · flowchart/u);
+ assert.deepEqual(drawn, ['flowchart LR\n A --> B']);
+ assert.doesNotMatch(f.screen(), /flowchart LR/u);
+ f.app['promptConfiguration'] = {...f.app['promptConfiguration'], diagrams: 'off'};
+ assert.match(f.screen(), /diagram not drawn: Mermaid diagrams are off/u);
+ assert.match(f.screen(), /flowchart LR/u);
+ } finally { f.done(); }
+});
From 0a4168eaced994470fa38929cf29186f95001e36 Mon Sep 17 00:00:00 2001
From: raiseCatError <315733358+raiseCatError@users.noreply.github.com>
Date: Thu, 22 Oct 2026 14:41:32 +0530
Subject: [PATCH 2/2] Mermaid review fixes: D works in the browser preview, a
drawing abandoned by a closing document is forgotten not failed, the
per-document cap holds in hybrid, reasons carry no terminal controls
---
src/app/TerminalApp.ts | 5 +++-
src/diagrams/document.ts | 9 ++++---
src/diagrams/service.ts | 13 +++++++++-
src/viewer/DocumentWorkspace.ts | 2 ++
tests/diagramsDocument.test.ts | 13 ++++++++++
tests/diagramsService.test.ts | 44 +++++++++++++++++++++++++++++++++
6 files changed, 81 insertions(+), 5 deletions(-)
diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts
index 31a3fb83..657bbc36 100644
--- a/src/app/TerminalApp.ts
+++ b/src/app/TerminalApp.ts
@@ -4892,6 +4892,8 @@ export class TerminalApp {
// The provider exists once a rendered view of this session was drawn; asking from the editor first creates it.
if (!editor.file.scratch) this.documentDiagrams(session, formatForPath(editor.file.path).format === 'markdown');
this.requestDiagrams(text => { editor.note = {text, tone: 'info'}; session.note = text; });
+ // The browser preview shows the fences as its page: rebuild it so they are requested (and drawn once ready).
+ if (session.mode === 'preview' && session.preview) void session.preview.refresh(bufferText(editor));
return;
}
if (action.kind === 'diagram') { this.openDiagramImage(session.viewer, action.index); return; }
@@ -6348,7 +6350,6 @@ export class TerminalApp {
}
private panelContentRows(columns: number): string[] {
- this.sweepDiagrams();
this.agentCaret = undefined;
if (this.startupPanel) return framePanel(renderStartupPanel({tail: this.startupPanel.tail, elapsedMs: Date.now() - this.startupPanel.since}, columns, this.dimensions().rows), columns);
if (this.toolConfigurationLoading) return framePanel([' Reading supported configuration...', ' Esc cancel'], columns);
@@ -12068,6 +12069,8 @@ export class TerminalApp {
}
private render(): void {
+ // A document closed for good lets go of its diagrams (pictures, and the renderer page once no document is left).
+ this.sweepDiagrams();
this.syncTypography();
this.scheduleSequencePoke();
this.reconcileOutputNavigation();
diff --git a/src/diagrams/document.ts b/src/diagrams/document.ts
index 31b235b9..31aedd79 100644
--- a/src/diagrams/document.ts
+++ b/src/diagrams/document.ts
@@ -29,7 +29,9 @@ export interface DocumentDiagramOptions {
/** A one-line reason from a library message (Mermaid's parse errors are several lines with a caret drawing). */
export function shortReason(message: string): string {
- const lines = message.split('\n').map(line => line.trim()).filter(line => line && !/^[-.]*\^$/u.test(line) && !/^\.\.\./u.test(line));
+ // Reasons can come from files on disk (a package.json) or the library: no terminal controls or bidi formatting reach a row.
+ // eslint-disable-next-line no-control-regex
+ const lines = message.replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu, '').split('\n').map(line => line.trim()).filter(line => line && !/^[-.]*\^$/u.test(line) && !/^\.\.\./u.test(line));
const text = lines.length > 1 ? `${lines[0]!.replace(/:$/u, '')}: ${lines.at(-1)}` : (lines[0] ?? message);
return text.length > 240 ? `${text.slice(0, 239)}…` : text;
}
@@ -105,10 +107,11 @@ export class DocumentDiagrams implements DiagramProvider, DiagramClient {
if (!check.ok) return empty(check.reason);
const theme = this.options.theme();
const key = diagramKey(source, theme);
+ const capped = empty(`NMSh draws at most ${MAX_DOCUMENT_DIAGRAMS} diagrams in one document`);
if (this.collecting) {
- if (!this.collecting.has(key) && this.collecting.size >= MAX_DOCUMENT_DIAGRAMS) return empty(`NMSh draws at most ${MAX_DOCUMENT_DIAGRAMS} diagrams in one document`);
+ if (!this.collecting.has(key) && this.collecting.size >= MAX_DOCUMENT_DIAGRAMS) return capped;
this.collecting.add(key);
- }
+ } else if (!this.shown.has(key) && this.shown.size >= MAX_DOCUMENT_DIAGRAMS) return capped;
if (!this.attached) { this.service().attach(this); this.attached = true; }
const entry = this.service().request(key, source, mermaidConfig(theme));
let index = this.order.indexOf(key);
diff --git a/src/diagrams/service.ts b/src/diagrams/service.ts
index c4c6bc8b..8328ab03 100644
--- a/src/diagrams/service.ts
+++ b/src/diagrams/service.ts
@@ -176,14 +176,18 @@ export class DiagramService {
const blocked = await this.options.backend.blocked().catch(error => error instanceof Error ? error.message : 'Diagrams cannot be drawn here.');
if (blocked) { this.fail(entry, 'unavailable', blocked); return; }
const renderer = await this.rendererNow();
+ if (this.abandoned(entry)) return;
if ('error' in renderer) { this.fail(entry, 'unavailable', renderer.error); return; }
this.counters.renders += 1;
const result = await renderer.render({source: entry.source, config: entry.config, scale: this.options.scale, timeoutMs: this.options.timeoutMs})
.catch(error => ({ok: false as const, code: 'failed' as const, reason: error instanceof Error ? error.message : 'The diagram could not be drawn.'}));
+ if (!result.ok && (result.code === 'timeout' || result.code === 'crashed' || renderer.closed)) this.closeRenderer();
+ // Nobody shows it any more (the document closed or switched while it was drawing, which may also have closed the page): it is
+ // forgotten, not failed, so the next document that shows the same text gets it drawn.
+ if (this.abandoned(entry)) return;
if (!result.ok) {
if (result.code === 'timeout') this.counters.timeouts += 1;
if (result.code === 'crashed') this.counters.crashes += 1;
- if (result.code === 'timeout' || result.code === 'crashed' || renderer.closed) this.closeRenderer();
this.fail(entry, result.code, result.reason);
return;
}
@@ -197,6 +201,13 @@ export class DiagramService {
}
}
+ private abandoned(entry: DiagramEntry): boolean {
+ if (this.wanted(entry.key)) return false;
+ this.entries.delete(entry.key);
+ this.counters.dropped += 1;
+ return true;
+ }
+
private async rendererNow(): Promise {
if (this.renderer && !this.renderer.closed) return this.renderer;
this.renderer = undefined;
diff --git a/src/viewer/DocumentWorkspace.ts b/src/viewer/DocumentWorkspace.ts
index 6412f1d5..26d881dc 100644
--- a/src/viewer/DocumentWorkspace.ts
+++ b/src/viewer/DocumentWorkspace.ts
@@ -229,6 +229,8 @@ export function documentSessionKey(state: DocumentSession, key: Key, columns: nu
if (editor.buffer.dirty) { editor.dialog = {kind: 'conflict', focus: 0, conflict: 'changed', reason: `${editor.file.title} has unsaved changes. Reloading from disk discards them.`}; return undefined; }
return {kind: 'reload'};
}
+ // The browser preview's page says "press D" for diagrams drawn on request: D is the workspace's, not the page's.
+ if (text === 'D' && state.mode === 'preview' && state.preview) return {kind: 'diagrams'};
if (text === 'T' && state.mode === 'preview') { state.trusted = !state.trusted; return {kind: 'trust', on: state.trusted}; }
if ((text === 'q' || key.kind === 'escape' || key.kind === 'interrupt') && !state.viewer.query) {
if (state.mode === 'preview') { setDocumentMode(state, 'source'); return undefined; }
diff --git a/tests/diagramsDocument.test.ts b/tests/diagramsDocument.test.ts
index b32982d6..ab543073 100644
--- a/tests/diagramsDocument.test.ts
+++ b/tests/diagramsDocument.test.ts
@@ -223,3 +223,16 @@ test('Split: typing in the text pane draws only the text that is on screen once
assert.deepEqual(press(state, 'D', look, 140), [{kind: 'diagrams'}]);
} finally { diagrams.close(); rmSync(root, {recursive: true, force: true}); }
});
+
+test('review: D in the browser preview asks for the diagrams (its page says to press D); other keys still go to the page', async () => {
+ const {root, state} = await openDoc();
+ const {diagrams, look} = diagramLab('manual');
+ try {
+ press(state, 'h', look);
+ const keys: string[] = [];
+ state.preview = {render: () => ({rows: []}), key: key => { keys.push(key.kind === 'text' ? key.value : key.kind); }, dispose: () => {}, status: () => undefined, capturing: () => false, refresh: async () => {}, setTrusted: async () => {}};
+ assert.deepEqual(press(state, 'D', look), [{kind: 'diagrams'}]);
+ press(state, 'j', look);
+ assert.deepEqual(keys, ['j']);
+ } finally { diagrams.close(); rmSync(root, {recursive: true, force: true}); }
+});
diff --git a/tests/diagramsService.test.ts b/tests/diagramsService.test.ts
index 25dc62c2..7f226f27 100644
--- a/tests/diagramsService.test.ts
+++ b/tests/diagramsService.test.ts
@@ -226,3 +226,47 @@ test('an idle renderer page closes by itself; the browser engine then stops on i
await clock.advance(200);
assert.equal(renderers.length, 2);
});
+
+// ---- Regressions from the independent review ----------------------------------------------------------------------------
+
+test('review: a document that closes or switches while its diagram is drawing does not leave that diagram failed for the next one', async () => {
+ let release: (() => void) | undefined;
+ const crashing: Behaviour = request => new Promise(resolve => { release = () => resolve(request.source.includes('first') ? {ok: false, code: 'crashed', reason: 'The browser page drawing diagrams stopped.'} : ok(request) as DiagramRenderResult); });
+ const {clock, service, document} = lab(crashing);
+ const viewer = document().doc;
+ viewer.pass(() => viewer.slot('flowchart LR\n first'));
+ await clock.advance(200);
+ assert.equal(service.stats.renders, 1);
+ // The person presses e: the viewer's diagrams are released (the page closes, the drawing in flight is lost) and the session shows the same fence.
+ viewer.close();
+ release!();
+ await clock.advance(10);
+ const session = document().doc;
+ const slot = session.pass(() => session.slot('flowchart LR\n first'));
+ assert.doesNotMatch(slot.note ?? '', /stopped/u);
+ assert.equal(service.stats.crashes, 0);
+ await clock.advance(200);
+ release!();
+ await clock.advance(200);
+ assert.equal(service.stats.renders, 2);
+});
+
+test('review: past the per-document cap, a block rendered on its own (hybrid) says so instead of queueing forever', async () => {
+ const {clock, service, document} = lab();
+ const {doc} = document();
+ const sources = Array.from({length: 26}, (_, i) => `flowchart LR\n A --> N${i}`);
+ doc.want(sources);
+ for (const source of sources) doc.slot(source);
+ await clock.advance(300);
+ for (let frame = 0; frame < 3; frame++) { for (const source of sources) doc.slot(source); await clock.advance(300); }
+ assert.match(doc.slot(sources[25]!).note ?? '', /at most 24 diagrams/u);
+ assert.equal(service.stats.dropped, 0);
+ assert.equal(service.stats.renders, 24);
+});
+
+test('review: reasons shown in rows carry no terminal controls, whatever file or library they came from', async () => {
+ const {shortReason} = await import('../src/diagrams/document.js');
+ const text = shortReason('Mermaid 9.0\u001b]52;c;ZXZpbA==\u0007 at /x is too old: NMSh needs version 10 or newer.');
+ assert.doesNotMatch(text, /[\u0000-\u001f\u007f-\u009f]/u);
+ assert.match(text, /too old/u);
+});