Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,063 bytes raw / 7,059 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
Expand Down
12 changes: 6 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 8 additions & 5 deletions THIRD-PARTY-NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<marijn@haverbeke.berlin> 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
<marijn@haverbeke.berlin> and others
- `w3c-keyname` — v2.2.8 — Copyright (C) 2016 by Marijn Haverbeke
Expand Down
5 changes: 3 additions & 2 deletions build/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// <http_handlers> static rule — it still makes zero third-party requests.

Expand Down Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions docs/ADR-0001-reactivity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
86 changes: 86 additions & 0 deletions src/editor/code-viewer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// 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),
// 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)),
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;
},
};
}
29 changes: 5 additions & 24 deletions src/editor/codemirror-adapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,19 @@
// 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';
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 +
Expand All @@ -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).
Expand Down Expand Up @@ -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 ⌘↵
Expand Down
47 changes: 47 additions & 0 deletions src/editor/codemirror-base.js
Original file line number Diff line number Diff line change
@@ -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)),
};
}
3 changes: 2 additions & 1 deletion src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading