From 4fa4ad287002b047ac671023e3629cd98ef4faf4 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Mon, 13 Jul 2026 16:15:22 +0200 Subject: [PATCH 1/2] feat(#213): add shared read-only code viewer Add an injected CodeMirror viewer for text, JSON, SQL, XML/HTML, and plain Markdown source. Share presentation/search extensions with the editable editor while keeping history, completions, hover, schema, drag/drop, tab state, and app synchronization isolated. Co-Authored-By: OpenAI Codex Claude-Session: Codex --- CHANGELOG.md | 11 +++ CLAUDE.md | 12 +-- README.md | 16 +++- THIRD-PARTY-NOTICES.md | 13 ++- build/build.mjs | 5 +- docs/ADR-0001-reactivity.md | 15 +++ package.json | 2 + src/editor/code-viewer.js | 82 +++++++++++++++++ src/editor/codemirror-adapter.js | 29 +----- src/editor/codemirror-base.js | 47 ++++++++++ src/main.js | 3 +- src/ui/app.js | 3 + tests/e2e/code-viewer.spec.js | 42 +++++++++ tests/e2e/editor.html | 26 ++++++ tests/unit/app.test.js | 22 +++++ tests/unit/code-viewer.test.js | 127 ++++++++++++++++++++++++++ tests/unit/codemirror-adapter.test.js | 13 +++ 17 files changed, 426 insertions(+), 42 deletions(-) create mode 100644 src/editor/code-viewer.js create mode 100644 src/editor/codemirror-base.js create mode 100644 tests/e2e/code-viewer.spec.js create mode 100644 tests/unit/code-viewer.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index b48b3f0f..eeda94ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,17 @@ auto-generated per-PR notes; this file is the curated, human-readable history. flashes its chevron shut and back open (`src/ui/schema.js`). ### Added +- **A shared, injected read-only CodeMirror source viewer** (#213) now provides + complete-text rendering, line numbers, local search, selection/copy, and + compartment-based wrapping for text, JSON, SQL, XML/HTML source, and plain + Markdown source. It mounts in either the app document or a detached document + realm and has explicit idempotent teardown. The editable SQL editor and the + viewer share only presentation/search extensions and the existing `.sql-*` + token classes; editor history, completion, hover, schema loading, drag/drop, + tab parking, and state synchronization remain isolated behind `EditorPort`. + `@codemirror/lang-json` and `@codemirror/lang-xml` are the only added packages; + the measured self-contained artifact grows by 18,024 bytes raw / 7,039 bytes + gzip. - **Iceberg Catalog Explorer example library** ([docs/ICEBERG-CATALOG-EXPLORER-DEMO.md](docs/ICEBERG-CATALOG-EXPLORER-DEMO.md)). Content-only (no code changes): `examples/iceberg-install.json` carries diff --git a/CLAUDE.md b/CLAUDE.md index b70bb53e..21ba957b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,8 +26,9 @@ all bundled — see hard rule 4). Quality is held by tests. 4. **The build is esbuild only; runtime deps are rare and deliberate.** Source files are the tested files; esbuild bundles `src/main.js` → `dist/sql.html`. There are **four** bundled runtime dependencies — **CodeMirror 6** (the SQL - editor, behind the `EditorPort` seam — #21), **Chart.js** (the Chart - result view), **@dagrejs/dagre** (the EXPLAIN pipeline-graph layout), and + editor and read-only source viewer, behind injected seams — #21/#213), + **Chart.js** (the Chart result view), **@dagrejs/dagre** (the EXPLAIN + pipeline-graph layout), and **@preact/signals-core** (the reactivity primitive — see `docs/ADR-0001-reactivity.md`) — all inlined into the artifact, so the page still makes zero third-party requests. @@ -36,10 +37,9 @@ all bundled — see hard rule 4). Quality is held by tests. keep the testable logic pure in `src/core/` (chart axis/role/pivot math in `src/core/chart-data.js`; DOT→positions in `src/core/dot-layout.js`, both 100%-covered) and make the library call an **injected seam** (`app.Chart` / - `app.Dagre` / `env.Editor`, like the fetch/crypto seams) so the DOM wrapper - stays fully tested rather than dropping below the coverage gate. (The CM6 - adapter is the port-shaped variant: the *factory* is injected, and the - adapter is unit-tested against the real library under happy-dom.) + `app.Dagre` / `env.Editor` / `env.CodeViewer`, like the fetch/crypto seams) + so the DOM wrapper stays fully tested rather than dropping below the coverage gate. (The CM6 + adapters are unit-tested against the real libraries under happy-dom.) 5. **No UI framework; signals for state, imperative adapters for islands.** State reactivity is `@preact/signals-core` (`signal`/`effect`/`computed`/`batch`), migrated slice-by-slice (ADR-0001). **No React/Preact/Solid** — a Preact spike diff --git a/README.md b/README.md index 99c1d920..6b62fa48 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,9 @@ saved queries, history, and shareable links. It ships as a **single self-contained HTML file served from ClickHouse itself** (no Node server, no CDN, no external fonts) — the page makes **zero third-party requests** and renders in the OS's native UI font. Its four bundled runtime -dependencies — **CodeMirror 6** (the SQL editor), **Chart.js** (the chart -result view), **@dagrejs/dagre** (the EXPLAIN pipeline-graph layout), and +dependencies — **CodeMirror 6** (the SQL editor and read-only source viewer), +**Chart.js** (the chart result view), **@dagrejs/dagre** (the EXPLAIN +pipeline-graph layout), and **@preact/signals-core** (state reactivity) — are inlined into that one file. Refactored from a single-file SPA into a fully modular, test-first codebase @@ -53,6 +54,13 @@ The editor is **CodeMirror 6** behind an injected `EditorPort` seam (#143/#21) — bundled and inlined like the other runtime deps, so the page still makes zero third-party requests. On top of it: +The same bundled CodeMirror presentation/search base also powers an injected +read-only `CodeViewer` seam (#213) for source surfaces. It supports complete +text, JSON, SQL, XML/HTML-source, and plain Markdown-source documents with line +numbers, local search, selection/copy, configurable wrapping, detached-document +mounting, and explicit teardown—without inheriting editor history, completion, +schema, drag/drop, or app-state behavior. + - **Per-tab undo** — each query tab keeps its own edit history; switching tabs parks and restores it. - **Find / replace** — `Cmd/Ctrl+F` opens CM6's search panel (app-styled) with @@ -565,8 +573,8 @@ src/ stream, storage, chart-data, completions (editor reference data + ranking) — no DOM, no globals net/ oauth-config, oauth, ch-client (injected fetch seam) - editor/ the EditorPort seam (editor-port) + its CodeMirror 6 adapter - (codemirror-adapter) — injected via createApp(env) + editor/ injected CodeMirror islands: the editable EditorPort adapter and + the smaller read-only CodeViewer, sharing presentation/search base ui/ dom (hyperscript), icons, + render modules (login, tabs, schema, results, saved-history, shortcuts, splitters, toast, app) state.js state model + pure operations diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index d74aeddf..56e909e5 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -50,17 +50,20 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI --- -## CodeMirror 6 (the SQL editor) and its dependencies +## CodeMirror 6 (SQL editor and read-only source viewer) and its dependencies -The editor is composed of these MIT-licensed packages, all inlined into the -artifact. Each carries the license text below with its own copyright line: +The CodeMirror surfaces are composed of these MIT-licensed packages, all +inlined into the artifact. Each carries the license text below with its own +copyright line: -- `@codemirror/state` — v6.7.0, `@codemirror/view` — v6.43.4, +- `@codemirror/state` — v6.7.1, `@codemirror/view` — v6.43.6, `@codemirror/commands` — v6.10.4, `@codemirror/language` — v6.12.4, - `@codemirror/lang-sql` — v6.10.0, `@codemirror/autocomplete` — v6.20.3, + `@codemirror/lang-sql` — v6.10.0, `@codemirror/lang-json` — v6.0.2, + `@codemirror/lang-xml` — v6.1.0, `@codemirror/autocomplete` — v6.20.3, `@codemirror/search` — v6.7.1 — Copyright (C) 2018-2021 by Marijn Haverbeke and others - `@lezer/common` — v1.5.2, `@lezer/highlight` — v1.2.3, `@lezer/lr` — v1.4.10, + `@lezer/json` — v1.0.3, `@lezer/xml` — v1.0.6, `style-mod` — v4.1.3 — Copyright (C) 2018 by Marijn Haverbeke and others - `w3c-keyname` — v2.2.8 — Copyright (C) 2016 by Marijn Haverbeke diff --git a/build/build.mjs b/build/build.mjs index fffab22c..ba04619b 100644 --- a/build/build.mjs +++ b/build/build.mjs @@ -2,7 +2,8 @@ // is inlined (with the stylesheet) into build/template.html → dist/sql.html. // // esbuild is the only build-time tool; the bundled runtime dependencies are -// Chart.js, @dagrejs/dagre, and @preact/signals-core (inlined, not fetched). The output is a self-contained HTML file +// CodeMirror 6, Chart.js, @dagrejs/dagre, and @preact/signals-core (inlined, +// not fetched). The output is a self-contained HTML file // that installs into any ClickHouse cluster's user_files and is served by an // static rule — it still makes zero third-party requests. @@ -55,7 +56,7 @@ async function main() { const styles = (await transform(stylesSrc, { loader: 'css', minify: true })).code; const template = await readFile(resolve(here, 'template.html'), 'utf8'); - // The runtime deps (Chart.js, dagre, @preact/signals-core) are MIT and inlined + // The runtime deps (CodeMirror 6, Chart.js, dagre, @preact/signals-core) are MIT and inlined // into the bundle, so the artifact must carry their notices. esbuild strips legal comments // (legalComments: 'none'), so embed THIRD-PARTY-NOTICES.md as a leading HTML // comment — sanitized so its text can't close the comment early. diff --git a/docs/ADR-0001-reactivity.md b/docs/ADR-0001-reactivity.md index eca756b0..9dd89452 100644 --- a/docs/ADR-0001-reactivity.md +++ b/docs/ADR-0001-reactivity.md @@ -207,3 +207,18 @@ keyword/function sets via a `Compartment` reconfigure. Nothing about the state m addendum records that the editor island now has its intended long-term implementation, and that #84 (schema-aware autocomplete) plugs into the CM6 completion source rather than growing new overlay machinery. + +## Addendum — read-only CodeMirror viewer behind a separate seam (#213) + +Read-only source surfaces now use a smaller injected `env.CodeViewer` factory, +not the editable `EditorPort`. The two adapters share only CodeMirror +presentation/search extensions and the established `.sql-*` token-class map in +`editor/codemirror-base.js`; the viewer cannot inherit editor history, +completion, hover, schema loading, drag/drop insertion, tab parking, or app-state +subscriptions. Its language registry is explicit (text, JSON, SQL, XML, +XML-style HTML, and plain Markdown source), adding only the CodeMirror JSON/XML +language packages. Wrapping and language changes reconfigure compartments +without reconstructing the view, and the adapter supplies the target parent and +document root before CM6 initializes its realm-bound observers. This is the same imperative-island rule +applied at a smaller boundary, and gives later cell/detail consumers a stub-able +`app.CodeViewer` seam without coupling them to CodeMirror imports. diff --git a/package.json b/package.json index aa8d0f20..a803801d 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,9 @@ "dependencies": { "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.10.4", + "@codemirror/lang-json": "^6.0.2", "@codemirror/lang-sql": "^6.10.0", + "@codemirror/lang-xml": "^6.1.0", "@codemirror/language": "^6.12.4", "@codemirror/search": "^6.7.1", "@codemirror/state": "^6.7.0", diff --git a/src/editor/code-viewer.js b/src/editor/code-viewer.js new file mode 100644 index 00000000..dcf79ac9 --- /dev/null +++ b/src/editor/code-viewer.js @@ -0,0 +1,82 @@ +// A small, reusable read-only CodeMirror surface (#213). It deliberately has +// no EditorPort behavior: no app subscriptions, history, completion, hover, +// schema loading, drag/drop insertion, or editable key commands. + +import { Compartment, EditorState } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; +import { json } from '@codemirror/lang-json'; +import { sql } from '@codemirror/lang-sql'; +import { xml } from '@codemirror/lang-xml'; +import { + codePresentationExtensions, + codeSearchKeymap, + createWrapCompartment, +} from './codemirror-base.js'; + +const LANGUAGES = { + text: () => [], + json, + sql, + xml, + html: xml, + markdown: () => [], +}; + +export function languageExtension(language = 'text') { + const factory = LANGUAGES[language] || LANGUAGES.text; + return factory(); +} + +export function createCodeViewer({ + parent, + document: targetDocument = parent && parent.ownerDocument, + text = '', + language = 'text', + wrap = false, +}) { + const languageCompartment = new Compartment(); + const wrapping = createWrapCompartment(wrap); + let view = new EditorView({ + parent, + root: targetDocument, + state: EditorState.create({ + doc: String(text), + extensions: [ + EditorState.readOnly.of(true), + EditorView.editable.of(false), + ...codePresentationExtensions(), + codeSearchKeymap, + languageCompartment.of(languageExtension(language)), + wrapping.extension, + ], + }), + }); + // CM6 creates its wrapper through its module-realm `document`, but appending + // to `parent` during construction makes the browser adopt it BEFORE CM6 + // initializes observers/listeners and reads `view.win`. happy-dom does not + // implement that automatic cross-document adoption, so normalize ownership + // afterward there; real browsers have already taken the first, critical path. + if (view.dom.ownerDocument !== targetDocument) targetDocument.adoptNode(view.dom); + if (view.dom.parentNode !== parent) parent.appendChild(view.dom); + + return { + setText: (nextText) => { + if (!view) return; + const next = String(nextText); + if (view.state.doc.length === next.length && view.state.doc.toString() === next) return; + view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: next } }); + }, + setLanguage: (nextLanguage) => { + if (view) view.dispatch({ effects: languageCompartment.reconfigure(languageExtension(nextLanguage)) }); + }, + setWrap: (enabled) => { + if (view) view.dispatch({ effects: wrapping.reconfigure(!!enabled) }); + }, + focus: () => { if (view) view.focus(); }, + destroy: () => { + if (!view) return; + view.destroy(); + view = null; + }, + }; +} diff --git a/src/editor/codemirror-adapter.js b/src/editor/codemirror-adapter.js index defe35f2..5e59b25e 100644 --- a/src/editor/codemirror-adapter.js +++ b/src/editor/codemirror-adapter.js @@ -15,13 +15,11 @@ // unreliable. import { EditorState, Compartment, Annotation, Transaction, Prec } from '@codemirror/state'; -import { EditorView, keymap, lineNumbers, drawSelection, dropCursor, hoverTooltip } from '@codemirror/view'; +import { EditorView, keymap, dropCursor, hoverTooltip } from '@codemirror/view'; import { history, historyKeymap, defaultKeymap } from '@codemirror/commands'; -import { bracketMatching, syntaxHighlighting, syntaxTree, HighlightStyle } from '@codemirror/language'; +import { bracketMatching, syntaxTree } from '@codemirror/language'; import { sql, SQLDialect } from '@codemirror/lang-sql'; import { autocompletion, closeBrackets, closeBracketsKeymap, acceptCompletion, startCompletion, completionStatus } from '@codemirror/autocomplete'; -import { search, searchKeymap } from '@codemirror/search'; -import { tags } from '@lezer/highlight'; import { h } from '../ui/dom.js'; import { completionContext, rankCompletions, wordAt } from '../core/completions.js'; import { fromScopeAt, pendingColumnLoads } from '../core/from-scope.js'; @@ -29,6 +27,7 @@ import { lexSql } from '../core/sql-lex.js'; import { toSubquery, clamp } from '../core/format.js'; import { activeTab } from '../state.js'; import { IDENT_MIME, SUBQUERY_MIME, COLUMN_TYPE_MIME } from '../ui/dnd-mime.js'; +import { codePresentationExtensions, codeSearchKeymap } from './codemirror-base.js'; // Programmatic state syncs (tab switch, external tab.sql reconcile) must not // reach onDocChange subscribers — the app-level subscriber writes tab.sql + @@ -42,21 +41,6 @@ const syncAnnotations = () => [syncTx.of(true), Transaction.addToHistory.of(fals // syncFromState reconcile paths so their shapes can't drift. const fullReplace = (state, text) => ({ changes: { from: 0, to: state.doc.length, insert: text } }); -// Map the lang-sql token tags onto the EXISTING .sql-* stylesheet classes -// (styles.css) — token colors and light/dark theming stay in the stylesheet, -// zero duplicated color values. `class:` entries generate no CSS of their own. -const sqlClasses = HighlightStyle.define([ - { tag: tags.keyword, class: 'sql-keyword' }, - { tag: tags.standard(tags.name), class: 'sql-func' }, // dialect `builtin` = server function names - { tag: tags.string, class: 'sql-string' }, - { tag: tags.special(tags.string), class: 'sql-ident' }, // `quoted` identifiers - { tag: tags.number, class: 'sql-number' }, - { tag: tags.bool, class: 'sql-keyword' }, - { tag: tags.null, class: 'sql-keyword' }, - { tag: tags.comment, class: 'sql-comment' }, - { tag: tags.operator, class: 'sql-op' }, -]); - // String/comment/backtick-ident syntax nodes — the contexts where bracket // auto-close and hover docs must stay quiet (the old adapter's maskLiterals // role, now answered by CM6's syntax tree). @@ -371,23 +355,20 @@ export function createCodeMirrorEditor(app) { }; const extensions = () => [ - lineNumbers(), + ...codePresentationExtensions(), history(), - drawSelection(), dropCursor(), bracketMatching(), Prec.high(EditorView.inputHandler.of(inputGuards)), closeBrackets(), - syntaxHighlighting(sqlClasses), langCompartment.of(langExt), autocompletion({ override: [completionSourceFor(app)] }), hoverTooltip(hoverSourceFor(app)), - search({ top: true }), + codeSearchKeymap, keymap.of([ { key: 'Tab', run: acceptCompletion }, { key: 'Tab', run: insertTwoSpaces }, ...closeBracketsKeymap, - ...searchKeymap, ...historyKeymap, // Global chords (⌘↵ run, ⌘⇧↵ format, ⌘S/⌘⇧S, Esc) live on the document // handler (main.js) — drop CM6's Mod-Enter (insertBlankLine) so ⌘↵ diff --git a/src/editor/codemirror-base.js b/src/editor/codemirror-base.js new file mode 100644 index 00000000..7dfdacb9 --- /dev/null +++ b/src/editor/codemirror-base.js @@ -0,0 +1,47 @@ +// Presentation shared by the editable SQL EditorPort and read-only code +// viewers (#213). Keep this module free of SQL/editor behavior: dialects, +// completion, hover, history, input guards, tab parking, and app state belong +// to their adapters. + +import { Compartment } from '@codemirror/state'; +import { EditorView, drawSelection, keymap, lineNumbers } from '@codemirror/view'; +import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; +import { search, searchKeymap } from '@codemirror/search'; +import { tags } from '@lezer/highlight'; + +// Map CodeMirror language tokens onto the existing stylesheet classes. The +// editable SQL editor keeps its established classes; JSON/XML reuse the same +// theme without injecting a second palette into the single-file artifact. +export const codeHighlightStyle = HighlightStyle.define([ + { tag: tags.keyword, class: 'sql-keyword' }, + { tag: tags.standard(tags.name), class: 'sql-func' }, + { tag: tags.string, class: 'sql-string' }, + { tag: tags.special(tags.string), class: 'sql-ident' }, + { tag: [tags.propertyName, tags.attributeName], class: 'sql-ident' }, + { tag: tags.tagName, class: 'sql-func' }, + { tag: tags.number, class: 'sql-number' }, + { tag: tags.bool, class: 'sql-keyword' }, + { tag: tags.null, class: 'sql-keyword' }, + { tag: tags.comment, class: 'sql-comment' }, + { tag: [tags.operator, tags.angleBracket], class: 'sql-op' }, +]); + +export function codePresentationExtensions() { + return [ + lineNumbers(), + drawSelection(), + syntaxHighlighting(codeHighlightStyle), + search({ top: true }), + ]; +} + +export const codeSearchKeymap = keymap.of(searchKeymap); + +export function createWrapCompartment(enabled = false) { + const compartment = new Compartment(); + const value = (wrap) => (wrap ? EditorView.lineWrapping : []); + return { + extension: compartment.of(value(enabled)), + reconfigure: (wrap) => compartment.reconfigure(value(wrap)), + }; +} diff --git a/src/main.js b/src/main.js index 5fe9c96f..507f4632 100644 --- a/src/main.js +++ b/src/main.js @@ -7,6 +7,7 @@ import Chart from 'chart.js/auto'; import Dagre from '@dagrejs/dagre'; import { createApp } from './ui/app.js'; import { createCodeMirrorEditor } from './editor/codemirror-adapter.js'; +import { createCodeViewer } from './editor/code-viewer.js'; import { handleKeydown } from './ui/shortcuts.js'; import { exchangeCodeForTokens, bearerFromTokens } from './net/oauth.js'; import { decodeShare } from './core/share.js'; @@ -119,7 +120,7 @@ export async function bootstrap(app, env) { /* c8 ignore start -- browser entry side-effect, exercised via the live app */ if (typeof document !== 'undefined' && !globalThis.__ASB_NO_AUTOSTART__) { - const app = createApp({ Chart, Dagre, Editor: createCodeMirrorEditor, build: '__ASB_BUILD__' }); + const app = createApp({ Chart, Dagre, Editor: createCodeMirrorEditor, CodeViewer: createCodeViewer, build: '__ASB_BUILD__' }); document.addEventListener('keydown', (e) => handleKeydown(e, app)); bootstrap(app, { location: window.location, diff --git a/src/ui/app.js b/src/ui/app.js index 18ecbb0d..564dce5b 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -234,6 +234,9 @@ export function createApp(env = {}) { // tests omit it and get the noop port. The instance is created here — before // renderApp mounts it — so every consumer can call the port unconditionally. app.Editor = env.Editor || createNoopPort; + app.CodeViewer = env.CodeViewer || (() => ({ + setText() {}, setLanguage() {}, setWrap() {}, focus() {}, destroy() {}, + })); app.editor = app.Editor(app); // The editor→state inversion (#143): the adapter reports each text change; // the state writes live here. Order matters — updateSaveBtn and the #134 diff --git a/tests/e2e/code-viewer.spec.js b/tests/e2e/code-viewer.spec.js new file mode 100644 index 00000000..6ea04e8f --- /dev/null +++ b/tests/e2e/code-viewer.spec.js @@ -0,0 +1,42 @@ +import { test, expect } from '@playwright/test'; + +test.describe('read-only CodeMirror viewer', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tests/e2e/editor.html'); + await page.waitForFunction(() => window.__ready === true); + }); + + test('mounts in a detached document, searches, wraps, and tears down', async ({ page }) => { + await page.evaluate(() => window.__mountViewer({ + text: '{"first":1}\n{"second":2}', language: 'json', wrap: false, + })); + const frame = page.frames().find((candidate) => candidate !== page.mainFrame()); + const editor = frame.locator('.cm-editor'); + await expect(editor).toBeVisible(); + await expect(frame.locator('.cm-content')).toHaveAttribute('contenteditable', 'false'); + await expect(frame.locator('.cm-lineNumbers')).toBeVisible(); + expect(await page.evaluate(() => { + const doc = window.__viewerFrame.contentDocument; + const root = doc.querySelector('.cm-editor'); + return root.ownerDocument === doc + && [...root.querySelectorAll('*')].every((node) => node.ownerDocument === doc); + })).toBe(true); + + await page.evaluate(() => { + window.__viewer.setWrap(true); + window.__viewer.focus(); + }); + await expect(frame.locator('.cm-content')).toHaveClass(/cm-lineWrapping/); + await page.keyboard.type('cannot edit'); + await expect(frame.locator('.cm-content')).toHaveText('{"first":1}{"second":2}'); + const modifier = await page.evaluate(() => /Mac/.test(navigator.platform) ? 'Meta' : 'Control'); + await page.keyboard.press(`${modifier}+f`); + await expect(frame.locator('.cm-panel.cm-search')).toBeVisible(); + + await page.evaluate(() => { + window.__viewer.destroy(); + window.__viewer.destroy(); + }); + await expect(editor).toHaveCount(0); + }); +}); diff --git a/tests/e2e/editor.html b/tests/e2e/editor.html index b822b96a..4578bbd7 100644 --- a/tests/e2e/editor.html +++ b/tests/e2e/editor.html @@ -25,13 +25,17 @@ "@codemirror/state": "/node_modules/@codemirror/state/dist/index.js", "@codemirror/view": "/node_modules/@codemirror/view/dist/index.js", "@codemirror/commands": "/node_modules/@codemirror/commands/dist/index.js", + "@codemirror/lang-json": "/node_modules/@codemirror/lang-json/dist/index.js", "@codemirror/language": "/node_modules/@codemirror/language/dist/index.js", "@codemirror/lang-sql": "/node_modules/@codemirror/lang-sql/dist/index.js", + "@codemirror/lang-xml": "/node_modules/@codemirror/lang-xml/dist/index.js", "@codemirror/autocomplete": "/node_modules/@codemirror/autocomplete/dist/index.js", "@codemirror/search": "/node_modules/@codemirror/search/dist/index.js", "@lezer/common": "/node_modules/@lezer/common/dist/index.js", "@lezer/highlight": "/node_modules/@lezer/highlight/dist/index.js", "@lezer/lr": "/node_modules/@lezer/lr/dist/index.js", + "@lezer/json": "/node_modules/@lezer/json/dist/index.js", + "@lezer/xml": "/node_modules/@lezer/xml/dist/index.js", "style-mod": "/node_modules/style-mod/src/style-mod.js", "w3c-keyname": "/node_modules/w3c-keyname/index.js", "crelt": "/node_modules/crelt/index.js", @@ -40,6 +44,7 @@ diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index 3a180635..c2efb28a 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -106,6 +106,7 @@ function env(over = {}) { crypto: webcrypto, Dagre: dagre, Editor: createCodeMirrorEditor, // the real adapter — app tests exercise editor-backed flows (#143/#21) + CodeViewer: vi.fn(() => ({ setText: vi.fn(), setLanguage: vi.fn(), setWrap: vi.fn(), focus: vi.fn(), destroy: vi.fn() })), fetch: makeFetch([]), now: () => 0, retryMs: 0, // instant script-statement retry in tests (no real 250ms wait) @@ -167,6 +168,27 @@ describe('createApp basics', () => { expect(app.document).toBe(customDoc); expect(app.document).not.toBe(document); }); + it('exposes an injected read-only viewer factory with a stub-friendly lifecycle contract', () => { + const createViewer = vi.fn(() => ({ + setText: vi.fn(), setLanguage: vi.fn(), setWrap: vi.fn(), focus: vi.fn(), destroy: vi.fn(), + })); + const app = createApp(env({ CodeViewer: createViewer })); + const args = { parent: document.createElement('div'), document, text: 'raw', language: 'text', wrap: false }; + const viewer = app.CodeViewer(args); + viewer.setWrap(true); // consumer mode change + viewer.destroy(); // outgoing mode teardown + viewer.destroy(); // parent teardown may safely repeat it + expect(createViewer).toHaveBeenCalledWith(args); + expect(viewer.setWrap).toHaveBeenCalledWith(true); + expect(viewer.destroy).toHaveBeenCalledTimes(2); + + const fallback = createApp(env({ CodeViewer: undefined })).CodeViewer(args); + expect(fallback.setText('x')).toBeUndefined(); + expect(fallback.setLanguage('json')).toBeUndefined(); + expect(fallback.setWrap(true)).toBeUndefined(); + expect(fallback.focus()).toBeUndefined(); + expect(fallback.destroy()).toBeUndefined(); + }); }); describe('renderApp shell', () => { diff --git a/tests/unit/code-viewer.test.js b/tests/unit/code-viewer.test.js new file mode 100644 index 00000000..957c9525 --- /dev/null +++ b/tests/unit/code-viewer.test.js @@ -0,0 +1,127 @@ +import { describe, it, expect } from 'vitest'; +import { EditorState } from '@codemirror/state'; +import { syntaxTree } from '@codemirror/language'; +import { EditorView, runScopeHandlers } from '@codemirror/view'; +import { searchPanelOpen } from '@codemirror/search'; +import { createCodeViewer, languageExtension } from '../../src/editor/code-viewer.js'; + +function mounted(over = {}) { + const doc = over.document || document; + const parent = over.parent || doc.createElement('div'); + if (!parent.parentNode) doc.body.appendChild(parent); + const viewer = createCodeViewer({ + parent, + document: doc, + text: 'one\ntwo', + language: 'text', + wrap: false, + ...over, + }); + const view = EditorView.findFromDOM(parent.querySelector('.cm-editor')); + return { parent, viewer, view }; +} + +describe('read-only code viewer', () => { + it('mounts the complete supplied text with line numbers and permits selection', () => { + const text = 'first\nsecond\nthird'; + const { parent, viewer, view } = mounted({ text }); + expect(view.state.doc.toString()).toBe(text); + expect(parent.querySelectorAll('.cm-lineNumbers .cm-gutterElement')).toHaveLength(4); // spacer + 3 lines + view.dispatch({ selection: { anchor: 1, head: 7 } }); + expect(view.state.sliceDoc(view.state.selection.main.from, view.state.selection.main.to)).toBe('irst\ns'); + viewer.destroy(); + }); + + it('is state-read-only, has a non-editable DOM, and installs no editing key commands', () => { + const { viewer, view } = mounted(); + expect(view.state.readOnly).toBe(true); + expect(view.state.facet(EditorView.editable)).toBe(false); + expect(view.contentDOM.getAttribute('contenteditable')).toBe('false'); + const enter = new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true }); + expect(runScopeHandlers(view, enter, 'editor')).toBe(false); + expect(view.state.doc.toString()).toBe('one\ntwo'); + viewer.destroy(); + }); + + it('installs the local Mod-f search keymap and keeps the panel inside the viewer', () => { + const { parent, viewer, view } = mounted(); + const event = (modifier) => new KeyboardEvent('keydown', { + key: 'f', code: 'KeyF', [modifier]: true, bubbles: true, cancelable: true, + }); + const handled = runScopeHandlers(view, event('ctrlKey'), 'editor') + || runScopeHandlers(view, event('metaKey'), 'editor'); + expect(handled).toBe(true); + expect(searchPanelOpen(view.state)).toBe(true); + expect(parent.querySelector('.cm-panel.cm-search')).not.toBeNull(); + viewer.destroy(); + }); + + it('toggles wrapping through a compartment without rebuilding the view', () => { + const { parent, viewer, view } = mounted(); + expect(view.contentDOM.classList.contains('cm-lineWrapping')).toBe(false); + viewer.setWrap(true); + expect(EditorView.findFromDOM(parent.querySelector('.cm-editor'))).toBe(view); + expect(view.contentDOM.classList.contains('cm-lineWrapping')).toBe(true); + viewer.setWrap(false); + expect(view.contentDOM.classList.contains('cm-lineWrapping')).toBe(false); + viewer.destroy(); + }); + + it('replaces text programmatically, preserves equal text as a no-op, and focuses', () => { + const { viewer, view } = mounted(); + viewer.setText('replacement'); + expect(view.state.doc.toString()).toBe('replacement'); + const state = view.state; + viewer.setText('replacement'); + expect(view.state).toBe(state); + viewer.focus(); + expect(view.hasFocus).toBe(true); + viewer.destroy(); + }); + + it('loads JSON, SQL, XML, and XML-style HTML, then reconfigures language in place', () => { + const cases = [ + ['json', '{"ok":true}', 'JsonText'], + ['sql', 'SELECT 1', 'Script'], + ['xml', '', 'Document'], + ['html', '', 'Document'], + ]; + for (const [language, text, rootName] of cases) { + const { parent, viewer, view } = mounted({ language, text }); + expect(syntaxTree(view.state).type.name).toBe(rootName); + viewer.setLanguage('text'); + expect(EditorView.findFromDOM(parent.querySelector('.cm-editor'))).toBe(view); + expect(syntaxTree(view.state).length).toBe(0); + viewer.destroy(); + } + }); + + it('uses no language extension for text, Markdown, or an unknown fallback', () => { + for (const language of ['text', 'markdown', 'future-mode']) { + const state = EditorState.create({ doc: '# source', extensions: languageExtension(language) }); + expect(syntaxTree(state).length).toBe(0); + } + }); + + it('mounts every viewer node in the supplied detached document realm', () => { + const detached = document.implementation.createHTMLDocument('detached'); + const parent = detached.createElement('div'); + detached.body.appendChild(parent); + const { viewer, view } = mounted({ document: detached, parent, text: '{"x":1}', language: 'json' }); + expect(view.dom.ownerDocument).toBe(detached); + expect(view.root).toBe(detached); + expect(parent.querySelector('.cm-editor')).toBe(view.dom); + viewer.destroy(); + }); + + it('destroy is explicit and idempotent, and later method calls are safe no-ops', () => { + const { parent, viewer } = mounted(); + viewer.destroy(); + expect(parent.querySelector('.cm-editor')).toBeNull(); + expect(viewer.destroy()).toBeUndefined(); + expect(viewer.setText('x')).toBeUndefined(); + expect(viewer.setLanguage('json')).toBeUndefined(); + expect(viewer.setWrap(true)).toBeUndefined(); + expect(viewer.focus()).toBeUndefined(); + }); +}); diff --git a/tests/unit/codemirror-adapter.test.js b/tests/unit/codemirror-adapter.test.js index b56e49a6..5d5e9963 100644 --- a/tests/unit/codemirror-adapter.test.js +++ b/tests/unit/codemirror-adapter.test.js @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { undoDepth, undo } from '@codemirror/commands'; import { EditorState } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; import { createCodeMirrorEditor, langExtensionFor, completionSourceFor, applyFor, infoFor, hoverSourceFor, handleDrop, insertTwoSpaces, inputGuards, syncTx, @@ -95,6 +96,18 @@ describe('mount / re-mount / destroy', () => { expect(changes).toEqual([]); expect(port.destroy()).toBeUndefined(); // idempotent }); + + it('keeps the editable SQL surface and shared token classes after the base extraction', () => { + const { port, view } = mounted(); + port.replaceDocument("SELECT count(*) FROM t WHERE n = 1 AND s = 'x'"); + expect(view.state.readOnly).toBe(false); + expect(view.state.facet(EditorView.editable)).toBe(true); + expect(view.contentDOM.getAttribute('contenteditable')).toBe('true'); + expect(view.dom.querySelector('.sql-keyword')?.textContent).toBe('SELECT'); + expect(view.dom.querySelector('.sql-string')?.textContent).toBe("'x'"); + expect(view.dom.querySelector('.cm-lineNumbers')).not.toBeNull(); + port.destroy(); + }); }); describe('document edits through the port', () => { From 0d44b4e08e5cc14dbd816045e43b52df2984e478 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Mon, 13 Jul 2026 17:10:08 +0200 Subject: [PATCH 2/2] fix(#213): keep code viewer keyboard focusable Restore an explicit tab stop after editable=false removes contenteditable focusability, allowing viewer.focus(), selection/copy, and Mod-f search to work in real browsers. Co-Authored-By: OpenAI Codex Claude-Session: Codex --- CHANGELOG.md | 2 +- src/editor/code-viewer.js | 4 ++++ tests/e2e/code-viewer.spec.js | 1 + tests/unit/code-viewer.test.js | 1 + 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eeda94ce..afe4b2bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,7 @@ auto-generated per-PR notes; this file is the curated, human-readable history. token classes; editor history, completion, hover, schema loading, drag/drop, tab parking, and state synchronization remain isolated behind `EditorPort`. `@codemirror/lang-json` and `@codemirror/lang-xml` are the only added packages; - the measured self-contained artifact grows by 18,024 bytes raw / 7,039 bytes + the measured self-contained artifact grows by 18,063 bytes raw / 7,059 bytes gzip. - **Iceberg Catalog Explorer example library** ([docs/ICEBERG-CATALOG-EXPLORER-DEMO.md](docs/ICEBERG-CATALOG-EXPLORER-DEMO.md)). diff --git a/src/editor/code-viewer.js b/src/editor/code-viewer.js index dcf79ac9..0339df2f 100644 --- a/src/editor/code-viewer.js +++ b/src/editor/code-viewer.js @@ -44,6 +44,10 @@ export function createCodeViewer({ extensions: [ EditorState.readOnly.of(true), EditorView.editable.of(false), + // editable=false removes contenteditable and its implicit focusability. + // Keep the read-only surface keyboard reachable for selection/copy and + // the Mod-f search keymap. + EditorView.contentAttributes.of({ tabindex: '0' }), ...codePresentationExtensions(), codeSearchKeymap, languageCompartment.of(languageExtension(language)), diff --git a/tests/e2e/code-viewer.spec.js b/tests/e2e/code-viewer.spec.js index 6ea04e8f..75631503 100644 --- a/tests/e2e/code-viewer.spec.js +++ b/tests/e2e/code-viewer.spec.js @@ -14,6 +14,7 @@ test.describe('read-only CodeMirror viewer', () => { const editor = frame.locator('.cm-editor'); await expect(editor).toBeVisible(); await expect(frame.locator('.cm-content')).toHaveAttribute('contenteditable', 'false'); + await expect(frame.locator('.cm-content')).toHaveAttribute('tabindex', '0'); await expect(frame.locator('.cm-lineNumbers')).toBeVisible(); expect(await page.evaluate(() => { const doc = window.__viewerFrame.contentDocument; diff --git a/tests/unit/code-viewer.test.js b/tests/unit/code-viewer.test.js index 957c9525..ecbb4263 100644 --- a/tests/unit/code-viewer.test.js +++ b/tests/unit/code-viewer.test.js @@ -37,6 +37,7 @@ describe('read-only code viewer', () => { expect(view.state.readOnly).toBe(true); expect(view.state.facet(EditorView.editable)).toBe(false); expect(view.contentDOM.getAttribute('contenteditable')).toBe('false'); + expect(view.contentDOM.getAttribute('tabindex')).toBe('0'); const enter = new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true }); expect(runScopeHandlers(view, enter, 'editor')).toBe(false); expect(view.state.doc.toString()).toBe('one\ntwo');